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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -23,73 +19,6 @@ import (
|
||||||
|
|
||||||
const spawnTimeout = 30 * time.Minute
|
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 {
|
type SubagentTask struct {
|
||||||
ID string
|
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.
|
// getLLMOptions returns the LLM options snapshot under read lock.
|
||||||
|
|
||||||
func (sm *SubagentManager) getLLMOptions() map[string]any {
|
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
|
// WaitAll blocks until all spawned subagent goroutines have finished
|
||||||
|
|
||||||
// or the timeout expires. Returns true if all goroutines 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,
|
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