refactor: split subagent.go into container and preset files
Reduce upstream merge conflict surface by splitting fork additions: - subagent_container.go: ContainerMessage, SubagentPlanState, deliberate workflow (runDeliberateTask, setPlanState, PendingQuestions, AnswerQuestion) - subagent_preset.go: preset system (buildPresetRegistry, system prompts, runExploratoryTask, extractPlanContext, formatToolStats) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fb662b02d3
commit
6fc07c4409
3 changed files with 658 additions and 640 deletions
|
|
@ -3,11 +3,7 @@ package tools
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -23,73 +19,6 @@ import (
|
|||
|
||||
const spawnTimeout = 30 * time.Minute
|
||||
|
||||
// ContainerMessage is sent from a subagent to the conductor via outCh.
|
||||
|
||||
type ContainerMessage struct {
|
||||
Type string // "question" or "plan_review"
|
||||
|
||||
Content string
|
||||
|
||||
TaskID string
|
||||
}
|
||||
|
||||
// isDeliberatePreset returns true for presets that use the deliberate
|
||||
|
||||
// (clarifying → review → executing) workflow with escalation channels.
|
||||
|
||||
func isDeliberatePreset(p Preset) bool {
|
||||
switch p {
|
||||
case PresetCoder, PresetWorker, PresetCoordinator:
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SubagentPlanState represents the deliberate workflow phase.
|
||||
|
||||
type SubagentPlanState int
|
||||
|
||||
const (
|
||||
PlanNone SubagentPlanState = iota // Not a deliberate preset
|
||||
|
||||
PlanClarifying // Gathering info, asking questions
|
||||
|
||||
PlanReview // Plan submitted, awaiting approval
|
||||
|
||||
PlanExecuting // Plan approved, executing
|
||||
|
||||
PlanCompleted // Done
|
||||
|
||||
)
|
||||
|
||||
// String returns a human-readable label for the plan state.
|
||||
|
||||
func (s SubagentPlanState) String() string {
|
||||
switch s {
|
||||
case PlanClarifying:
|
||||
|
||||
return "clarifying"
|
||||
|
||||
case PlanReview:
|
||||
|
||||
return "review"
|
||||
|
||||
case PlanExecuting:
|
||||
|
||||
return "executing"
|
||||
|
||||
case PlanCompleted:
|
||||
|
||||
return "completed"
|
||||
|
||||
default:
|
||||
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
type SubagentTask struct {
|
||||
ID string
|
||||
|
||||
|
|
@ -337,62 +266,6 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, pres
|
|||
}
|
||||
}
|
||||
|
||||
// clarifyingSystemPrompt returns the system prompt for the clarifying phase.
|
||||
|
||||
func clarifyingSystemPrompt() string {
|
||||
return `You are a deliberate subagent in the CLARIFYING phase.
|
||||
|
||||
Your job is to understand the task fully before acting. You MUST:
|
||||
|
||||
1. Read relevant files and gather context using your tools.
|
||||
|
||||
2. If anything is unclear, use ask_conductor to ask the conductor.
|
||||
|
||||
3. When you have a clear plan, use submit_plan with a goal and steps.
|
||||
|
||||
|
||||
|
||||
Do NOT execute any changes yet. Only investigate and plan.
|
||||
|
||||
Available escalation tools: ask_conductor, submit_plan.`
|
||||
}
|
||||
|
||||
// executingSystemPrompt returns the system prompt for the executing phase.
|
||||
|
||||
func executingSystemPrompt() string {
|
||||
return `You are a deliberate subagent in the EXECUTING phase. Your plan was approved.
|
||||
|
||||
Execute the plan steps methodically. Use all available tools to complete the work.
|
||||
|
||||
After completing, provide a clear summary of what was done and how it was verified.
|
||||
|
||||
|
||||
|
||||
If you encounter a blocker, use ask_conductor to escalate.`
|
||||
}
|
||||
|
||||
// exploratorySystemPrompt returns the system prompt for exploratory presets.
|
||||
|
||||
func exploratorySystemPrompt(p Preset) string {
|
||||
switch p {
|
||||
case PresetScout, PresetAnalyst:
|
||||
|
||||
return `You are an exploratory subagent. Investigate the task and report your findings.
|
||||
|
||||
Use your best judgment when encountering ambiguity. Use tools as needed.
|
||||
|
||||
Return clear findings and observations.`
|
||||
|
||||
default:
|
||||
|
||||
return `You are a subagent. Complete the given task independently and report the result.
|
||||
|
||||
You have access to tools - use them as needed to complete your task.
|
||||
|
||||
After completing the task, provide a clear summary of what was done.`
|
||||
}
|
||||
}
|
||||
|
||||
// getLLMOptions returns the LLM options snapshot under read lock.
|
||||
|
||||
func (sm *SubagentManager) getLLMOptions() map[string]any {
|
||||
|
|
@ -546,402 +419,6 @@ func (sm *SubagentManager) finishTask(
|
|||
}
|
||||
}
|
||||
|
||||
// setPlanState updates task's plan state in memory and records status in session DAG.
|
||||
|
||||
func (sm *SubagentManager) setPlanState(task *SubagentTask, state SubagentPlanState) {
|
||||
task.PlanState = state
|
||||
|
||||
if sm.recorder != nil {
|
||||
subKey := routing.BuildSubagentSessionKey(task.ID)
|
||||
|
||||
_ = sm.recorder.RecordCompletion(subKey, state.String(), "")
|
||||
}
|
||||
}
|
||||
|
||||
// runExploratoryTask runs a single-phase tool loop for exploratory presets.
|
||||
|
||||
func (sm *SubagentManager) runExploratoryTask(
|
||||
ctx context.Context,
|
||||
task *SubagentTask,
|
||||
preset Preset,
|
||||
callback AsyncCallback,
|
||||
) {
|
||||
systemPrompt := buildSubagentSystemPrompt(exploratorySystemPrompt(preset), sm.workspace)
|
||||
|
||||
messages := []providers.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
|
||||
{Role: "user", Content: task.Task},
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
sm.mu.Lock()
|
||||
|
||||
task.Status = "canceled"
|
||||
|
||||
task.Result = "Task canceled before execution"
|
||||
|
||||
sm.mu.Unlock()
|
||||
|
||||
return
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
sm.mu.RLock()
|
||||
|
||||
reg := sm.tools
|
||||
|
||||
if IsValidPreset(preset) {
|
||||
reg = sm.buildPresetRegistry(preset, sm.workspace, task)
|
||||
}
|
||||
|
||||
maxIter := sm.maxIterations
|
||||
|
||||
sm.mu.RUnlock()
|
||||
|
||||
sm.reporter.ReportConversation("conductor", task.ID, task.Task)
|
||||
|
||||
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
||||
Provider: sm.provider,
|
||||
|
||||
Model: sm.defaultModel,
|
||||
|
||||
Tools: reg,
|
||||
|
||||
MaxIterations: maxIter,
|
||||
|
||||
LLMOptions: sm.getLLMOptions(),
|
||||
|
||||
Reporter: sm.reporter,
|
||||
|
||||
AgentID: task.ID,
|
||||
}, messages, task.OriginChannel, task.OriginChatID)
|
||||
|
||||
sm.finishTask(ctx, task, messages, loopResult, err, callback)
|
||||
}
|
||||
|
||||
// runDeliberateTask runs the clarifying → review → executing workflow.
|
||||
|
||||
func (sm *SubagentManager) runDeliberateTask(
|
||||
ctx context.Context,
|
||||
task *SubagentTask,
|
||||
preset Preset,
|
||||
callback AsyncCallback,
|
||||
) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
sm.mu.Lock()
|
||||
|
||||
task.Status = "canceled"
|
||||
|
||||
task.Result = "Task canceled before execution"
|
||||
|
||||
sm.mu.Unlock()
|
||||
|
||||
return
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
sm.mu.RLock()
|
||||
|
||||
reg := sm.buildPresetRegistry(preset, sm.workspace, task)
|
||||
|
||||
maxIter := sm.maxIterations
|
||||
|
||||
sm.mu.RUnlock()
|
||||
|
||||
sm.reporter.ReportConversation("conductor", task.ID, task.Task)
|
||||
|
||||
sm.setPlanState(task, PlanClarifying)
|
||||
|
||||
// Phase 1: Clarifying — subagent gathers info and submits a plan.
|
||||
|
||||
clarifyMsgs := []providers.Message{
|
||||
{Role: "system", Content: buildSubagentSystemPrompt(clarifyingSystemPrompt(), sm.workspace)},
|
||||
|
||||
{Role: "user", Content: task.Task},
|
||||
}
|
||||
|
||||
clarifyResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
||||
Provider: sm.provider,
|
||||
|
||||
Model: sm.defaultModel,
|
||||
|
||||
Tools: reg,
|
||||
|
||||
MaxIterations: maxIter,
|
||||
|
||||
LLMOptions: sm.getLLMOptions(),
|
||||
|
||||
Reporter: sm.reporter,
|
||||
|
||||
AgentID: task.ID,
|
||||
}, clarifyMsgs, task.OriginChannel, task.OriginChatID)
|
||||
if err != nil {
|
||||
sm.finishTask(ctx, task, clarifyMsgs, nil, err, callback)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// After clarifying, the subagent should have used submit_plan.
|
||||
|
||||
// If it didn't produce a plan, treat the clarifying result as direct completion.
|
||||
|
||||
if task.PlanGoal == "" {
|
||||
sm.finishTask(ctx, task, clarifyMsgs, clarifyResult, nil, callback)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: Executing — plan was approved, now execute it.
|
||||
|
||||
sm.setPlanState(task, PlanExecuting)
|
||||
|
||||
executeMsgs := []providers.Message{
|
||||
{Role: "system", Content: buildSubagentSystemPrompt(executingSystemPrompt(), sm.workspace)},
|
||||
|
||||
{Role: "user", Content: fmt.Sprintf("Execute the approved plan:\nGoal: %s\nSteps:\n%s",
|
||||
|
||||
task.PlanGoal, formatPlanSteps(task.PlanSteps))},
|
||||
}
|
||||
|
||||
execResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
||||
Provider: sm.provider,
|
||||
|
||||
Model: sm.defaultModel,
|
||||
|
||||
Tools: reg,
|
||||
|
||||
MaxIterations: maxIter * 2, // Executing gets more iterations
|
||||
|
||||
LLMOptions: sm.getLLMOptions(),
|
||||
|
||||
Reporter: sm.reporter,
|
||||
|
||||
AgentID: task.ID,
|
||||
}, executeMsgs, task.OriginChannel, task.OriginChatID)
|
||||
|
||||
sm.finishTask(ctx, task, executeMsgs, execResult, err, callback)
|
||||
}
|
||||
|
||||
// formatPlanSteps formats plan steps as a numbered list.
|
||||
|
||||
func formatPlanSteps(steps []string) string {
|
||||
var b strings.Builder
|
||||
|
||||
for i, step := range steps {
|
||||
fmt.Fprintf(&b, "%d. %s\n", i+1, step)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildPresetRegistry constructs a ToolRegistry for the given preset with appropriate restrictions.
|
||||
|
||||
// If task is non-nil and has escalation channels, ask_conductor and submit_plan are registered.
|
||||
|
||||
func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string, task ...*SubagentTask) *ToolRegistry {
|
||||
registry := NewToolRegistry()
|
||||
|
||||
config := SandboxConfigForPreset(preset, writeRoot)
|
||||
|
||||
readRoot := writeRoot
|
||||
|
||||
if readRoot == "" {
|
||||
readRoot = sm.workspace
|
||||
}
|
||||
|
||||
// Register read_file and list_dir with restrict=true
|
||||
|
||||
if config.AllowedTools["read_file"] {
|
||||
registry.Register(NewReadFileTool(readRoot, true, 0))
|
||||
}
|
||||
|
||||
if config.AllowedTools["list_dir"] {
|
||||
registry.Register(NewListDirTool(readRoot, true))
|
||||
}
|
||||
|
||||
// Register write tools only if allowed and writeRoot is set
|
||||
|
||||
if config.AllowedTools["write_file"] && writeRoot != "" {
|
||||
registry.Register(NewWriteFileTool(writeRoot, true))
|
||||
|
||||
registry.Register(NewEditFileTool(writeRoot, true))
|
||||
|
||||
registry.Register(NewAppendFileTool(writeRoot, true))
|
||||
}
|
||||
|
||||
// Register exec and bg_monitor if allowed.
|
||||
|
||||
// Each subagent gets its own ExecTool to avoid mutating the shared instance's
|
||||
|
||||
// allowRules (which would leak sandbox restrictions to the conductor).
|
||||
|
||||
if config.AllowedTools["exec"] {
|
||||
execWorkDir := writeRoot
|
||||
|
||||
if execWorkDir == "" {
|
||||
execWorkDir = sm.workspace
|
||||
}
|
||||
|
||||
execTool, err := NewExecTool(execWorkDir, true)
|
||||
if err != nil {
|
||||
// exec disabled for this subagent; skip registration
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
if config.ExecPolicy != nil {
|
||||
execTool.SetAllowRules(config.ExecPolicy.AllowRules)
|
||||
|
||||
execTool.SetLocalNetOnly(config.ExecPolicy.LocalNetOnly)
|
||||
}
|
||||
|
||||
registry.Register(execTool)
|
||||
|
||||
if config.AllowedTools["bg_monitor"] {
|
||||
registry.Register(NewBgMonitorTool(execTool))
|
||||
}
|
||||
}
|
||||
|
||||
// Register git tools (worktree-safe push and PR creation)
|
||||
|
||||
if config.AllowedTools["git_push"] {
|
||||
registry.Register(NewGitPushTool())
|
||||
}
|
||||
|
||||
if config.AllowedTools["create_pr"] {
|
||||
registry.Register(NewCreatePRTool())
|
||||
}
|
||||
|
||||
// Register web tools
|
||||
|
||||
if config.AllowedTools["web_search"] {
|
||||
webSearchTool, _ := NewWebSearchTool(sm.webSearchOpts)
|
||||
|
||||
if webSearchTool != nil {
|
||||
registry.Register(webSearchTool)
|
||||
}
|
||||
}
|
||||
|
||||
if config.AllowedTools["web_fetch"] {
|
||||
if fetchTool, err := NewWebFetchTool(50000); err == nil {
|
||||
registry.Register(fetchTool)
|
||||
}
|
||||
}
|
||||
|
||||
// Register message tool (always available)
|
||||
|
||||
registry.Register(NewMessageTool())
|
||||
|
||||
// Register spawn tool only for coordinator preset
|
||||
|
||||
if config.AllowedTools["spawn"] && preset == PresetCoordinator {
|
||||
spawnTool := NewSpawnTool(sm)
|
||||
|
||||
registry.Register(spawnTool)
|
||||
}
|
||||
|
||||
// Register escalation tools for deliberate presets with channels.
|
||||
|
||||
if len(task) > 0 && task[0] != nil && task[0].outCh != nil {
|
||||
t := task[0]
|
||||
|
||||
subKey := "subagent:" + t.ID
|
||||
|
||||
registry.Register(NewAskConductorTool(
|
||||
|
||||
t.ID, sm.conductorSessionKey, subKey,
|
||||
|
||||
t.outCh, t.inCh, sm.recorder,
|
||||
))
|
||||
|
||||
submitPlan := NewSubmitPlanTool(
|
||||
|
||||
t.ID, sm.conductorSessionKey, subKey,
|
||||
|
||||
t.outCh, t.inCh, sm.recorder,
|
||||
)
|
||||
|
||||
submitPlan.SetPlanCallback(func(goal string, steps []string) {
|
||||
t.PlanGoal = goal
|
||||
|
||||
t.PlanSteps = steps
|
||||
})
|
||||
|
||||
registry.Register(submitPlan)
|
||||
}
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
// PendingQuestions drains all outCh channels and returns pending container messages.
|
||||
|
||||
// Non-blocking: reads all available messages without waiting.
|
||||
|
||||
func (sm *SubagentManager) PendingQuestions() []ContainerMessage {
|
||||
sm.mu.RLock()
|
||||
|
||||
defer sm.mu.RUnlock()
|
||||
|
||||
var msgs []ContainerMessage
|
||||
|
||||
for _, task := range sm.tasks {
|
||||
if task.outCh == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg := <-task.outCh:
|
||||
|
||||
msgs = append(msgs, msg)
|
||||
|
||||
default:
|
||||
|
||||
goto nextTask
|
||||
}
|
||||
}
|
||||
|
||||
nextTask:
|
||||
}
|
||||
|
||||
return msgs
|
||||
}
|
||||
|
||||
// AnswerQuestion sends an answer to a subagent's inCh (non-blocking).
|
||||
|
||||
func (sm *SubagentManager) AnswerQuestion(taskID, answer string) error {
|
||||
sm.mu.RLock()
|
||||
|
||||
task, ok := sm.tasks[taskID]
|
||||
|
||||
sm.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("task %q not found", taskID)
|
||||
}
|
||||
|
||||
if task.inCh == nil {
|
||||
return fmt.Errorf("task %q has no escalation channel", taskID)
|
||||
}
|
||||
|
||||
select {
|
||||
case task.inCh <- answer:
|
||||
|
||||
return nil
|
||||
|
||||
default:
|
||||
|
||||
return fmt.Errorf("task %q answer channel full", taskID)
|
||||
}
|
||||
}
|
||||
|
||||
// WaitAll blocks until all spawned subagent goroutines have finished
|
||||
|
||||
// or the timeout expires. Returns true if all goroutines finished,
|
||||
|
|
@ -1152,120 +629,3 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
|||
Async: false,
|
||||
}
|
||||
}
|
||||
|
||||
// formatToolStats formats a tool stats map as a compact string: "exec:3,read_file:5".
|
||||
|
||||
// Keys are sorted alphabetically for deterministic output.
|
||||
|
||||
func formatToolStats(stats map[string]int) string {
|
||||
keys := make([]string, 0, len(stats))
|
||||
|
||||
for k := range stats {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+":"+strconv.Itoa(stats[k]))
|
||||
}
|
||||
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// extractPlanContext reads MEMORY.md from the workspace and extracts relevant
|
||||
|
||||
// sections (Task, Context, Commands) to provide as subagent environment.
|
||||
|
||||
func extractPlanContext(workspace string) string {
|
||||
memPath := filepath.Join(workspace, "memory", "MEMORY.md")
|
||||
|
||||
data, err := os.ReadFile(memPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
var sections []string
|
||||
|
||||
// Extract key sections by header.
|
||||
|
||||
for _, header := range []string{"## Context", "## Commands", "## Orchestration"} {
|
||||
if section := extractSection(content, header); section != "" {
|
||||
sections = append(sections, section)
|
||||
}
|
||||
}
|
||||
|
||||
// Also extract the task line from the header block.
|
||||
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if strings.HasPrefix(line, "> Task:") {
|
||||
sections = append([]string{strings.TrimSpace(line)}, sections...)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
// extractSection extracts a markdown section by header (including its content
|
||||
|
||||
// until the next section of the same or higher level).
|
||||
|
||||
func extractSection(content, header string) string {
|
||||
idx := strings.Index(content, header)
|
||||
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Determine header level.
|
||||
|
||||
level := 0
|
||||
|
||||
for _, c := range header {
|
||||
if c == '#' {
|
||||
level++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
start := idx
|
||||
|
||||
rest := content[idx+len(header):]
|
||||
|
||||
// Find next section at same or higher level.
|
||||
|
||||
nextHeader := "\n" + strings.Repeat("#", level) + " "
|
||||
|
||||
end := strings.Index(rest, nextHeader)
|
||||
|
||||
if end < 0 {
|
||||
return strings.TrimSpace(content[start:])
|
||||
}
|
||||
|
||||
return strings.TrimSpace(content[start : start+len(header)+end])
|
||||
}
|
||||
|
||||
// buildSubagentSystemPrompt builds an enriched system prompt for a subagent
|
||||
|
||||
// by combining the base prompt with environment context from MEMORY.md.
|
||||
|
||||
func buildSubagentSystemPrompt(basePrompt, workspace string) string {
|
||||
envContext := extractPlanContext(workspace)
|
||||
|
||||
if envContext == "" {
|
||||
return basePrompt
|
||||
}
|
||||
|
||||
return basePrompt + "\n\n## Environment Context\n\n" + envContext
|
||||
}
|
||||
|
|
|
|||
269
pkg/tools/subagent_container.go
Normal file
269
pkg/tools/subagent_container.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
)
|
||||
|
||||
// ContainerMessage is sent from a subagent to the conductor via outCh.
|
||||
|
||||
type ContainerMessage struct {
|
||||
Type string // "question" or "plan_review"
|
||||
|
||||
Content string
|
||||
|
||||
TaskID string
|
||||
}
|
||||
|
||||
// isDeliberatePreset returns true for presets that use the deliberate
|
||||
|
||||
// (clarifying → review → executing) workflow with escalation channels.
|
||||
|
||||
func isDeliberatePreset(p Preset) bool {
|
||||
switch p {
|
||||
case PresetCoder, PresetWorker, PresetCoordinator:
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SubagentPlanState represents the deliberate workflow phase.
|
||||
|
||||
type SubagentPlanState int
|
||||
|
||||
const (
|
||||
PlanNone SubagentPlanState = iota // Not a deliberate preset
|
||||
|
||||
PlanClarifying // Gathering info, asking questions
|
||||
|
||||
PlanReview // Plan submitted, awaiting approval
|
||||
|
||||
PlanExecuting // Plan approved, executing
|
||||
|
||||
PlanCompleted // Done
|
||||
|
||||
)
|
||||
|
||||
// String returns a human-readable label for the plan state.
|
||||
|
||||
func (s SubagentPlanState) String() string {
|
||||
switch s {
|
||||
case PlanClarifying:
|
||||
|
||||
return "clarifying"
|
||||
|
||||
case PlanReview:
|
||||
|
||||
return "review"
|
||||
|
||||
case PlanExecuting:
|
||||
|
||||
return "executing"
|
||||
|
||||
case PlanCompleted:
|
||||
|
||||
return "completed"
|
||||
|
||||
default:
|
||||
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
// setPlanState updates task's plan state in memory and records status in session DAG.
|
||||
|
||||
func (sm *SubagentManager) setPlanState(task *SubagentTask, state SubagentPlanState) {
|
||||
task.PlanState = state
|
||||
|
||||
if sm.recorder != nil {
|
||||
subKey := routing.BuildSubagentSessionKey(task.ID)
|
||||
|
||||
_ = sm.recorder.RecordCompletion(subKey, state.String(), "")
|
||||
}
|
||||
}
|
||||
|
||||
// runDeliberateTask runs the clarifying → review → executing workflow.
|
||||
|
||||
func (sm *SubagentManager) runDeliberateTask(
|
||||
ctx context.Context,
|
||||
task *SubagentTask,
|
||||
preset Preset,
|
||||
callback AsyncCallback,
|
||||
) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
sm.mu.Lock()
|
||||
|
||||
task.Status = "canceled"
|
||||
|
||||
task.Result = "Task canceled before execution"
|
||||
|
||||
sm.mu.Unlock()
|
||||
|
||||
return
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
sm.mu.RLock()
|
||||
|
||||
reg := sm.buildPresetRegistry(preset, sm.workspace, task)
|
||||
|
||||
maxIter := sm.maxIterations
|
||||
|
||||
sm.mu.RUnlock()
|
||||
|
||||
sm.reporter.ReportConversation("conductor", task.ID, task.Task)
|
||||
|
||||
sm.setPlanState(task, PlanClarifying)
|
||||
|
||||
// Phase 1: Clarifying — subagent gathers info and submits a plan.
|
||||
|
||||
clarifyMsgs := []providers.Message{
|
||||
{Role: "system", Content: buildSubagentSystemPrompt(clarifyingSystemPrompt(), sm.workspace)},
|
||||
|
||||
{Role: "user", Content: task.Task},
|
||||
}
|
||||
|
||||
clarifyResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
||||
Provider: sm.provider,
|
||||
|
||||
Model: sm.defaultModel,
|
||||
|
||||
Tools: reg,
|
||||
|
||||
MaxIterations: maxIter,
|
||||
|
||||
LLMOptions: sm.getLLMOptions(),
|
||||
|
||||
Reporter: sm.reporter,
|
||||
|
||||
AgentID: task.ID,
|
||||
}, clarifyMsgs, task.OriginChannel, task.OriginChatID)
|
||||
if err != nil {
|
||||
sm.finishTask(ctx, task, clarifyMsgs, nil, err, callback)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// After clarifying, the subagent should have used submit_plan.
|
||||
|
||||
// If it didn't produce a plan, treat the clarifying result as direct completion.
|
||||
|
||||
if task.PlanGoal == "" {
|
||||
sm.finishTask(ctx, task, clarifyMsgs, clarifyResult, nil, callback)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: Executing — plan was approved, now execute it.
|
||||
|
||||
sm.setPlanState(task, PlanExecuting)
|
||||
|
||||
executeMsgs := []providers.Message{
|
||||
{Role: "system", Content: buildSubagentSystemPrompt(executingSystemPrompt(), sm.workspace)},
|
||||
|
||||
{Role: "user", Content: fmt.Sprintf("Execute the approved plan:\nGoal: %s\nSteps:\n%s",
|
||||
|
||||
task.PlanGoal, formatPlanSteps(task.PlanSteps))},
|
||||
}
|
||||
|
||||
execResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
||||
Provider: sm.provider,
|
||||
|
||||
Model: sm.defaultModel,
|
||||
|
||||
Tools: reg,
|
||||
|
||||
MaxIterations: maxIter * 2, // Executing gets more iterations
|
||||
|
||||
LLMOptions: sm.getLLMOptions(),
|
||||
|
||||
Reporter: sm.reporter,
|
||||
|
||||
AgentID: task.ID,
|
||||
}, executeMsgs, task.OriginChannel, task.OriginChatID)
|
||||
|
||||
sm.finishTask(ctx, task, executeMsgs, execResult, err, callback)
|
||||
}
|
||||
|
||||
// formatPlanSteps formats plan steps as a numbered list.
|
||||
|
||||
func formatPlanSteps(steps []string) string {
|
||||
var b strings.Builder
|
||||
|
||||
for i, step := range steps {
|
||||
fmt.Fprintf(&b, "%d. %s\n", i+1, step)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// PendingQuestions drains all outCh channels and returns pending container messages.
|
||||
|
||||
// Non-blocking: reads all available messages without waiting.
|
||||
|
||||
func (sm *SubagentManager) PendingQuestions() []ContainerMessage {
|
||||
sm.mu.RLock()
|
||||
|
||||
defer sm.mu.RUnlock()
|
||||
|
||||
var msgs []ContainerMessage
|
||||
|
||||
for _, task := range sm.tasks {
|
||||
if task.outCh == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg := <-task.outCh:
|
||||
|
||||
msgs = append(msgs, msg)
|
||||
|
||||
default:
|
||||
|
||||
goto nextTask
|
||||
}
|
||||
}
|
||||
|
||||
nextTask:
|
||||
}
|
||||
|
||||
return msgs
|
||||
}
|
||||
|
||||
// AnswerQuestion sends an answer to a subagent's inCh (non-blocking).
|
||||
|
||||
func (sm *SubagentManager) AnswerQuestion(taskID, answer string) error {
|
||||
sm.mu.RLock()
|
||||
|
||||
task, ok := sm.tasks[taskID]
|
||||
|
||||
sm.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("task %q not found", taskID)
|
||||
}
|
||||
|
||||
if task.inCh == nil {
|
||||
return fmt.Errorf("task %q has no escalation channel", taskID)
|
||||
}
|
||||
|
||||
select {
|
||||
case task.inCh <- answer:
|
||||
|
||||
return nil
|
||||
|
||||
default:
|
||||
|
||||
return fmt.Errorf("task %q answer channel full", taskID)
|
||||
}
|
||||
}
|
||||
389
pkg/tools/subagent_preset.go
Normal file
389
pkg/tools/subagent_preset.go
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// clarifyingSystemPrompt returns the system prompt for the clarifying phase.
|
||||
|
||||
func clarifyingSystemPrompt() string {
|
||||
return `You are a deliberate subagent in the CLARIFYING phase.
|
||||
|
||||
Your job is to understand the task fully before acting. You MUST:
|
||||
|
||||
1. Read relevant files and gather context using your tools.
|
||||
|
||||
2. If anything is unclear, use ask_conductor to ask the conductor.
|
||||
|
||||
3. When you have a clear plan, use submit_plan with a goal and steps.
|
||||
|
||||
|
||||
|
||||
Do NOT execute any changes yet. Only investigate and plan.
|
||||
|
||||
Available escalation tools: ask_conductor, submit_plan.`
|
||||
}
|
||||
|
||||
// executingSystemPrompt returns the system prompt for the executing phase.
|
||||
|
||||
func executingSystemPrompt() string {
|
||||
return `You are a deliberate subagent in the EXECUTING phase. Your plan was approved.
|
||||
|
||||
Execute the plan steps methodically. Use all available tools to complete the work.
|
||||
|
||||
After completing, provide a clear summary of what was done and how it was verified.
|
||||
|
||||
|
||||
|
||||
If you encounter a blocker, use ask_conductor to escalate.`
|
||||
}
|
||||
|
||||
// exploratorySystemPrompt returns the system prompt for exploratory presets.
|
||||
|
||||
func exploratorySystemPrompt(p Preset) string {
|
||||
switch p {
|
||||
case PresetScout, PresetAnalyst:
|
||||
|
||||
return `You are an exploratory subagent. Investigate the task and report your findings.
|
||||
|
||||
Use your best judgment when encountering ambiguity. Use tools as needed.
|
||||
|
||||
Return clear findings and observations.`
|
||||
|
||||
default:
|
||||
|
||||
return `You are a subagent. Complete the given task independently and report the result.
|
||||
|
||||
You have access to tools - use them as needed to complete your task.
|
||||
|
||||
After completing the task, provide a clear summary of what was done.`
|
||||
}
|
||||
}
|
||||
|
||||
// runExploratoryTask runs a single-phase tool loop for exploratory presets.
|
||||
|
||||
func (sm *SubagentManager) runExploratoryTask(
|
||||
ctx context.Context,
|
||||
task *SubagentTask,
|
||||
preset Preset,
|
||||
callback AsyncCallback,
|
||||
) {
|
||||
systemPrompt := buildSubagentSystemPrompt(exploratorySystemPrompt(preset), sm.workspace)
|
||||
|
||||
messages := []providers.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
|
||||
{Role: "user", Content: task.Task},
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
sm.mu.Lock()
|
||||
|
||||
task.Status = "canceled"
|
||||
|
||||
task.Result = "Task canceled before execution"
|
||||
|
||||
sm.mu.Unlock()
|
||||
|
||||
return
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
sm.mu.RLock()
|
||||
|
||||
reg := sm.tools
|
||||
|
||||
if IsValidPreset(preset) {
|
||||
reg = sm.buildPresetRegistry(preset, sm.workspace, task)
|
||||
}
|
||||
|
||||
maxIter := sm.maxIterations
|
||||
|
||||
sm.mu.RUnlock()
|
||||
|
||||
sm.reporter.ReportConversation("conductor", task.ID, task.Task)
|
||||
|
||||
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
||||
Provider: sm.provider,
|
||||
|
||||
Model: sm.defaultModel,
|
||||
|
||||
Tools: reg,
|
||||
|
||||
MaxIterations: maxIter,
|
||||
|
||||
LLMOptions: sm.getLLMOptions(),
|
||||
|
||||
Reporter: sm.reporter,
|
||||
|
||||
AgentID: task.ID,
|
||||
}, messages, task.OriginChannel, task.OriginChatID)
|
||||
|
||||
sm.finishTask(ctx, task, messages, loopResult, err, callback)
|
||||
}
|
||||
|
||||
// buildPresetRegistry constructs a ToolRegistry for the given preset with appropriate restrictions.
|
||||
|
||||
// If task is non-nil and has escalation channels, ask_conductor and submit_plan are registered.
|
||||
|
||||
func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string, task ...*SubagentTask) *ToolRegistry {
|
||||
registry := NewToolRegistry()
|
||||
|
||||
config := SandboxConfigForPreset(preset, writeRoot)
|
||||
|
||||
readRoot := writeRoot
|
||||
|
||||
if readRoot == "" {
|
||||
readRoot = sm.workspace
|
||||
}
|
||||
|
||||
// Register read_file and list_dir with restrict=true
|
||||
|
||||
if config.AllowedTools["read_file"] {
|
||||
registry.Register(NewReadFileTool(readRoot, true, 0))
|
||||
}
|
||||
|
||||
if config.AllowedTools["list_dir"] {
|
||||
registry.Register(NewListDirTool(readRoot, true))
|
||||
}
|
||||
|
||||
// Register write tools only if allowed and writeRoot is set
|
||||
|
||||
if config.AllowedTools["write_file"] && writeRoot != "" {
|
||||
registry.Register(NewWriteFileTool(writeRoot, true))
|
||||
|
||||
registry.Register(NewEditFileTool(writeRoot, true))
|
||||
|
||||
registry.Register(NewAppendFileTool(writeRoot, true))
|
||||
}
|
||||
|
||||
// Register exec and bg_monitor if allowed.
|
||||
|
||||
// Each subagent gets its own ExecTool to avoid mutating the shared instance's
|
||||
|
||||
// allowRules (which would leak sandbox restrictions to the conductor).
|
||||
|
||||
if config.AllowedTools["exec"] {
|
||||
execWorkDir := writeRoot
|
||||
|
||||
if execWorkDir == "" {
|
||||
execWorkDir = sm.workspace
|
||||
}
|
||||
|
||||
execTool, err := NewExecTool(execWorkDir, true)
|
||||
if err != nil {
|
||||
// exec disabled for this subagent; skip registration
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
if config.ExecPolicy != nil {
|
||||
execTool.SetAllowRules(config.ExecPolicy.AllowRules)
|
||||
|
||||
execTool.SetLocalNetOnly(config.ExecPolicy.LocalNetOnly)
|
||||
}
|
||||
|
||||
registry.Register(execTool)
|
||||
|
||||
if config.AllowedTools["bg_monitor"] {
|
||||
registry.Register(NewBgMonitorTool(execTool))
|
||||
}
|
||||
}
|
||||
|
||||
// Register git tools (worktree-safe push and PR creation)
|
||||
|
||||
if config.AllowedTools["git_push"] {
|
||||
registry.Register(NewGitPushTool())
|
||||
}
|
||||
|
||||
if config.AllowedTools["create_pr"] {
|
||||
registry.Register(NewCreatePRTool())
|
||||
}
|
||||
|
||||
// Register web tools
|
||||
|
||||
if config.AllowedTools["web_search"] {
|
||||
webSearchTool, _ := NewWebSearchTool(sm.webSearchOpts)
|
||||
|
||||
if webSearchTool != nil {
|
||||
registry.Register(webSearchTool)
|
||||
}
|
||||
}
|
||||
|
||||
if config.AllowedTools["web_fetch"] {
|
||||
if fetchTool, err := NewWebFetchTool(50000); err == nil {
|
||||
registry.Register(fetchTool)
|
||||
}
|
||||
}
|
||||
|
||||
// Register message tool (always available)
|
||||
|
||||
registry.Register(NewMessageTool())
|
||||
|
||||
// Register spawn tool only for coordinator preset
|
||||
|
||||
if config.AllowedTools["spawn"] && preset == PresetCoordinator {
|
||||
spawnTool := NewSpawnTool(sm)
|
||||
|
||||
registry.Register(spawnTool)
|
||||
}
|
||||
|
||||
// Register escalation tools for deliberate presets with channels.
|
||||
|
||||
if len(task) > 0 && task[0] != nil && task[0].outCh != nil {
|
||||
t := task[0]
|
||||
|
||||
subKey := "subagent:" + t.ID
|
||||
|
||||
registry.Register(NewAskConductorTool(
|
||||
|
||||
t.ID, sm.conductorSessionKey, subKey,
|
||||
|
||||
t.outCh, t.inCh, sm.recorder,
|
||||
))
|
||||
|
||||
submitPlan := NewSubmitPlanTool(
|
||||
|
||||
t.ID, sm.conductorSessionKey, subKey,
|
||||
|
||||
t.outCh, t.inCh, sm.recorder,
|
||||
)
|
||||
|
||||
submitPlan.SetPlanCallback(func(goal string, steps []string) {
|
||||
t.PlanGoal = goal
|
||||
|
||||
t.PlanSteps = steps
|
||||
})
|
||||
|
||||
registry.Register(submitPlan)
|
||||
}
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
// extractPlanContext reads MEMORY.md from the workspace and extracts relevant
|
||||
|
||||
// sections (Task, Context, Commands) to provide as subagent environment.
|
||||
|
||||
func extractPlanContext(workspace string) string {
|
||||
memPath := filepath.Join(workspace, "memory", "MEMORY.md")
|
||||
|
||||
data, err := os.ReadFile(memPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
var sections []string
|
||||
|
||||
// Extract key sections by header.
|
||||
|
||||
for _, header := range []string{"## Context", "## Commands", "## Orchestration"} {
|
||||
if section := extractSection(content, header); section != "" {
|
||||
sections = append(sections, section)
|
||||
}
|
||||
}
|
||||
|
||||
// Also extract the task line from the header block.
|
||||
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if strings.HasPrefix(line, "> Task:") {
|
||||
sections = append([]string{strings.TrimSpace(line)}, sections...)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
// extractSection extracts a markdown section by header (including its content
|
||||
|
||||
// until the next section of the same or higher level).
|
||||
|
||||
func extractSection(content, header string) string {
|
||||
idx := strings.Index(content, header)
|
||||
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Determine header level.
|
||||
|
||||
level := 0
|
||||
|
||||
for _, c := range header {
|
||||
if c == '#' {
|
||||
level++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
start := idx
|
||||
|
||||
rest := content[idx+len(header):]
|
||||
|
||||
// Find next section at same or higher level.
|
||||
|
||||
nextHeader := "\n" + strings.Repeat("#", level) + " "
|
||||
|
||||
end := strings.Index(rest, nextHeader)
|
||||
|
||||
if end < 0 {
|
||||
return strings.TrimSpace(content[start:])
|
||||
}
|
||||
|
||||
return strings.TrimSpace(content[start : start+len(header)+end])
|
||||
}
|
||||
|
||||
// buildSubagentSystemPrompt builds an enriched system prompt for a subagent
|
||||
|
||||
// by combining the base prompt with environment context from MEMORY.md.
|
||||
|
||||
func buildSubagentSystemPrompt(basePrompt, workspace string) string {
|
||||
envContext := extractPlanContext(workspace)
|
||||
|
||||
if envContext == "" {
|
||||
return basePrompt
|
||||
}
|
||||
|
||||
return basePrompt + "\n\n## Environment Context\n\n" + envContext
|
||||
}
|
||||
|
||||
// formatToolStats formats a tool stats map as a compact string: "exec:3,read_file:5".
|
||||
|
||||
// Keys are sorted alphabetically for deterministic output.
|
||||
|
||||
func formatToolStats(stats map[string]int) string {
|
||||
keys := make([]string, 0, len(stats))
|
||||
|
||||
for k := range stats {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+":"+strconv.Itoa(stats[k]))
|
||||
}
|
||||
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue