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:
dj-oyu 2026-03-13 16:06:47 +09:00
parent fb662b02d3
commit 6fc07c4409
3 changed files with 658 additions and 640 deletions

View file

@ -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
}

View 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)
}
}

View 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, ",")
}