From 6fc07c44094727ff8cd54ae45d6c93689cb6d390 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:06:47 +0900 Subject: [PATCH] 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 --- pkg/tools/subagent.go | 640 -------------------------------- pkg/tools/subagent_container.go | 269 ++++++++++++++ pkg/tools/subagent_preset.go | 389 +++++++++++++++++++ 3 files changed, 658 insertions(+), 640 deletions(-) create mode 100644 pkg/tools/subagent_container.go create mode 100644 pkg/tools/subagent_preset.go diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 09b3a11ab..8ce9d961a 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -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 -} diff --git a/pkg/tools/subagent_container.go b/pkg/tools/subagent_container.go new file mode 100644 index 000000000..b68c963ce --- /dev/null +++ b/pkg/tools/subagent_container.go @@ -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) + } +} diff --git a/pkg/tools/subagent_preset.go b/pkg/tools/subagent_preset.go new file mode 100644 index 000000000..e9354865a --- /dev/null +++ b/pkg/tools/subagent_preset.go @@ -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, ",") +}