From bc3195fc8a0145be20a1944d1caa54590a6e2632 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 06:27:07 +0900 Subject: [PATCH] feat: implement preset feature for subagent orchestration - Add pkg/tools/sandbox.go: Define 5 presets (scout/analyst/coder/worker/coordinator) with capability tiers, exec allowlists, and tool restrictions - Add AllowedToolsForPreset() and SandboxConfigForPreset() factories - Implement buildPresetRegistry() in SubagentManager to construct isolated tool registries per preset with appropriate restrictions - Add preset parameter to SpawnTool and Spawn() method - Pass WebSearchToolOptions to NewSubagentManager() constructor - Add orchestration mode banner to system prompt (main agent only) - Update all tests to use new SubagentManager and Spawn() signatures Presets: - scout: read-only exploration - analyst: read + limited exec (test/vet/git) - coder: read/write + test/lint exec - worker: read/write + build/package manager exec - coordinator: full exec + can spawn scout-worker presets Co-Authored-By: Claude Haiku 4.5 --- pkg/agent/context.go | 29 +++- pkg/agent/loop.go | 21 ++- pkg/tools/sandbox.go | 125 ++++++++++++++ pkg/tools/sandbox_test.go | 254 ++++++++++++++++++++++++++++ pkg/tools/spawn.go | 20 ++- pkg/tools/subagent.go | 98 ++++++++++- pkg/tools/subagent_reporter_test.go | 12 +- pkg/tools/subagent_tool_test.go | 20 +-- 8 files changed, 546 insertions(+), 33 deletions(-) create mode 100644 pkg/tools/sandbox.go create mode 100644 pkg/tools/sandbox_test.go diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 3229c6ca3..a39e8fa00 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -40,12 +40,13 @@ After spawning, record the assignment in ## Orchestration > Delegated in MEMORY. When results come back, synthesize and decide the next fork.` type ContextBuilder struct { - workspace string - workDir string // session-specific working directory (worktree or project subdir) - skillsLoader *skills.SkillsLoader - memory *MemoryStore - tools *tools.ToolRegistry // Direct reference to tool registry - peerNote string // set per-call from loop.go for peer session awareness + workspace string + workDir string // session-specific working directory (worktree or project subdir) + skillsLoader *skills.SkillsLoader + memory *MemoryStore + tools *tools.ToolRegistry // Direct reference to tool registry + peerNote string // set per-call from loop.go for peer session awareness + orchestrationEnabled bool // set from AgentLoop when --orchestration flag is used } func getGlobalConfigDir() string { @@ -86,6 +87,11 @@ func (cb *ContextBuilder) SetPeerNote(note string) { cb.peerNote = note } +// SetOrchestrationEnabled sets whether orchestration is enabled. +func (cb *ContextBuilder) SetOrchestrationEnabled(enabled bool) { + cb.orchestrationEnabled = enabled +} + func (cb *ContextBuilder) getIdentity() string { now := time.Now().Format("2006-01-02 15:04 (Monday)") workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) @@ -94,7 +100,16 @@ func (cb *ContextBuilder) getIdentity() string { // Build tools section dynamically toolsSection := cb.buildToolsSection() - return fmt.Sprintf(`# picoclaw 🦞 + // Build prompt with optional orchestration banner + var prompt string + if cb.orchestrationEnabled { + prompt = `/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/ +O R C H E S T R A M O D E +\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ + +` + } + return fmt.Sprintf(prompt+`# picoclaw 🦞 You are picoclaw, a helpful AI assistant. diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 00033edbe..d07d5cbd4 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -261,7 +261,21 @@ func registerSharedTools( // Spawn tool — only registered when orchestration is explicitly enabled. if agent.Subagents != nil && agent.Subagents.Enabled { - subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus, al.reporter()) + webSearchOpts := tools.WebSearchToolOptions{ + BraveAPIKey: cfg.Tools.Web.Brave.APIKey, + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, + PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, + PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + } + subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus, al.reporter(), webSearchOpts) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) spawnTool := tools.NewSpawnTool(subagentManager) currentAgentID := agentID @@ -273,6 +287,11 @@ func registerSharedTools( // Update context builder with the complete tools registry agent.ContextBuilder.SetToolsRegistry(agent.Tools) + + // Set orchestration mode if enabled + if agent.Subagents != nil && agent.Subagents.Enabled { + agent.ContextBuilder.SetOrchestrationEnabled(true) + } } } diff --git a/pkg/tools/sandbox.go b/pkg/tools/sandbox.go new file mode 100644 index 000000000..1653e6bd9 --- /dev/null +++ b/pkg/tools/sandbox.go @@ -0,0 +1,125 @@ +package tools + +// Preset defines the capability tier for a subagent. +type Preset string + +const ( + PresetScout Preset = "scout" + PresetAnalyst Preset = "analyst" + PresetCoder Preset = "coder" + PresetWorker Preset = "worker" + PresetCoordinator Preset = "coordinator" +) + +// IsValidPreset checks if the given preset is a valid capability tier. +func IsValidPreset(p Preset) bool { + switch p { + case PresetScout, PresetAnalyst, PresetCoder, PresetWorker, PresetCoordinator: + return true + } + return false +} + +// ExecPolicy defines which commands are allowed for execution. +type ExecPolicy struct { + AllowPattern string // Prefix-match regex; matched commands are allowed +} + +// SandboxConfig describes the sandbox isolation policy for a preset. +type SandboxConfig struct { + Preset Preset + WriteRoot string // Path restriction for write tools; empty = no writes allowed + AllowedTools map[string]bool // Tools that can be used + ExecPolicy *ExecPolicy // nil = exec not allowed + SpawnablePresets []string // Presets that can be spawned; nil = spawn not allowed +} + +// SubagentEnvironment provides context for subagent execution. +type SubagentEnvironment struct { + Workspace string // Absolute path to workspace + WorktreeDir string // Absolute path to worktree (write root) + Background string // Additional context/intent + Constraints string // Execution constraints + ContextFiles []string // Files to provide as context +} + +// presetExecPatterns maps presets to command allowlist regexes. +var presetExecPatterns = map[Preset]string{ + PresetScout: ``, // No exec allowed + PresetAnalyst: `^(go\s+(test|vet)|git\s+(log|diff|status)|curl|wget|grep|find)\b`, + PresetCoder: `^(go\s+(test|vet|fmt)|gofmt|goimports|golangci-lint|prettier|eslint|black|ruff|cargo\s+(test|fmt|clippy)|pnpm\s+(test|run\s+(test|lint|format))|bun\s+(test|run\s+(test|lint|format))|uv\s+run\s+)\b`, + PresetWorker: `^(go\s+|pnpm\s+(install|add|run|test|build)|bun\s+(install|add|run|test|build)|uv\s+(run|sync|add|pip\s+install)|pip\s+install|cargo\s+)\b`, + PresetCoordinator: `^(go\s+|pnpm\s+|bun\s+|curl|wget)\b`, +} + +// presetSpawnablePresets maps presets to which presets they can spawn. +var presetSpawnablePresets = map[Preset][]string{ + PresetScout: nil, + PresetAnalyst: nil, + PresetCoder: nil, + PresetWorker: nil, + PresetCoordinator: {"scout", "analyst", "coder", "worker"}, +} + +// AllowedToolsForPreset returns the set of allowed tools for a given preset. +func AllowedToolsForPreset(p Preset) map[string]bool { + // Base tools available to all presets + allowed := map[string]bool{ + "read_file": true, + "list_dir": true, + "web_search": true, + "web_fetch": true, + "message": true, + } + + // Add analyst+ tools (exec, git, etc.) + if p == PresetAnalyst || p == PresetCoder || p == PresetWorker || p == PresetCoordinator { + allowed["exec"] = true + } + + // Add coder/worker/coordinator tools (write, bg_monitor) + if p == PresetCoder || p == PresetWorker || p == PresetCoordinator { + allowed["write_file"] = true + allowed["edit_file"] = true + allowed["append_file"] = true + allowed["bg_monitor"] = true + } + + // Add coordinator-only tools (spawn) + if p == PresetCoordinator { + allowed["spawn"] = true + } + + return allowed +} + +// SandboxConfigForPreset creates a SandboxConfig for the given preset. +func SandboxConfigForPreset(p Preset, writeRoot string) SandboxConfig { + allowed := AllowedToolsForPreset(p) + + config := SandboxConfig{ + Preset: p, + AllowedTools: allowed, + } + + // Only set WriteRoot for presets that have write permissions + if allowed["write_file"] { + config.WriteRoot = writeRoot + } + + // Set ExecPolicy if exec is allowed and pattern is non-empty + if allowed["exec"] { + if pattern := presetExecPatterns[p]; pattern != "" { + config.ExecPolicy = &ExecPolicy{ + AllowPattern: pattern, + } + } + } + + // Set SpawnablePresets if spawn is allowed + if allowed["spawn"] { + config.SpawnablePresets = presetSpawnablePresets[p] + } + + return config +} diff --git a/pkg/tools/sandbox_test.go b/pkg/tools/sandbox_test.go new file mode 100644 index 000000000..ada24e095 --- /dev/null +++ b/pkg/tools/sandbox_test.go @@ -0,0 +1,254 @@ +package tools + +import ( + "regexp" + "testing" +) + +// TestAllowedToolsForPreset checks that each preset has appropriate tool access. +func TestAllowedToolsForPreset(t *testing.T) { + tests := []struct { + name string + preset Preset + wantRead bool + wantWrite bool + wantExec bool + wantSpawn bool + wantWebSearch bool + }{ + { + name: "scout", + preset: PresetScout, + wantRead: true, + wantWrite: false, + wantExec: false, + wantSpawn: false, + wantWebSearch: true, + }, + { + name: "analyst", + preset: PresetAnalyst, + wantRead: true, + wantWrite: false, + wantExec: true, + wantSpawn: false, + wantWebSearch: true, + }, + { + name: "coder", + preset: PresetCoder, + wantRead: true, + wantWrite: true, + wantExec: true, + wantSpawn: false, + wantWebSearch: true, + }, + { + name: "worker", + preset: PresetWorker, + wantRead: true, + wantWrite: true, + wantExec: true, + wantSpawn: false, + wantWebSearch: true, + }, + { + name: "coordinator", + preset: PresetCoordinator, + wantRead: true, + wantWrite: true, + wantExec: true, + wantSpawn: true, + wantWebSearch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + allowed := AllowedToolsForPreset(tt.preset) + + // Check read tools + if got := allowed["read_file"]; got != tt.wantRead { + t.Errorf("read_file: got %v, want %v", got, tt.wantRead) + } + if got := allowed["list_dir"]; got != tt.wantRead { + t.Errorf("list_dir: got %v, want %v", got, tt.wantRead) + } + + // Check write tools + if got := allowed["write_file"]; got != tt.wantWrite { + t.Errorf("write_file: got %v, want %v", got, tt.wantWrite) + } + + // Check exec + if got := allowed["exec"]; got != tt.wantExec { + t.Errorf("exec: got %v, want %v", got, tt.wantExec) + } + + // Check spawn + if got := allowed["spawn"]; got != tt.wantSpawn { + t.Errorf("spawn: got %v, want %v", got, tt.wantSpawn) + } + + // Check web_search (all presets should have it) + if got := allowed["web_search"]; got != tt.wantWebSearch { + t.Errorf("web_search: got %v, want %v", got, tt.wantWebSearch) + } + }) + } +} + +// TestSandboxConfigForPreset_Scout checks scout preset isolation. +func TestSandboxConfigForPreset_Scout(t *testing.T) { + config := SandboxConfigForPreset(PresetScout, "/tmp/scout") + + if config.Preset != PresetScout { + t.Errorf("Preset: got %v, want %v", config.Preset, PresetScout) + } + if config.WriteRoot != "" { + t.Errorf("WriteRoot: got %q, want empty", config.WriteRoot) + } + if config.ExecPolicy != nil { + t.Errorf("ExecPolicy: got non-nil, want nil") + } + if config.SpawnablePresets != nil { + t.Errorf("SpawnablePresets: got non-nil, want nil") + } +} + +// TestSandboxConfigForPreset_Coder checks coder preset isolation. +func TestSandboxConfigForPreset_Coder(t *testing.T) { + config := SandboxConfigForPreset(PresetCoder, "/tmp/coder") + + if config.Preset != PresetCoder { + t.Errorf("Preset: got %v, want %v", config.Preset, PresetCoder) + } + if config.WriteRoot != "/tmp/coder" { + t.Errorf("WriteRoot: got %q, want /tmp/coder", config.WriteRoot) + } + if config.ExecPolicy == nil { + t.Errorf("ExecPolicy: got nil, want non-nil") + } + if config.SpawnablePresets != nil { + t.Errorf("SpawnablePresets: got non-nil, want nil") + } +} + +// TestSandboxConfigForPreset_Coordinator checks coordinator preset isolation. +func TestSandboxConfigForPreset_Coordinator(t *testing.T) { + config := SandboxConfigForPreset(PresetCoordinator, "/tmp/coord") + + if config.Preset != PresetCoordinator { + t.Errorf("Preset: got %v, want %v", config.Preset, PresetCoordinator) + } + if config.WriteRoot != "/tmp/coord" { + t.Errorf("WriteRoot: got %q, want /tmp/coord", config.WriteRoot) + } + if config.ExecPolicy == nil { + t.Errorf("ExecPolicy: got nil, want non-nil") + } + if config.SpawnablePresets == nil { + t.Errorf("SpawnablePresets: got nil, want non-nil") + } + // Verify coordinator cannot spawn itself + canSpawnCoordinator := false + for _, p := range config.SpawnablePresets { + if p == "coordinator" { + canSpawnCoordinator = true + break + } + } + if canSpawnCoordinator { + t.Errorf("Coordinator should not be in SpawnablePresets") + } +} + +// TestPresetExecPatterns_Coder validates coder exec allowlist. +func TestPresetExecPatterns_Coder(t *testing.T) { + pattern, ok := presetExecPatterns[PresetCoder] + if !ok || pattern == "" { + t.Fatalf("coder pattern missing or empty") + } + + re, err := regexp.Compile(pattern) + if err != nil { + t.Fatalf("failed to compile pattern: %v", err) + } + + tests := []struct { + cmd string + wantOK bool + }{ + {"go test ./...", true}, + {"go vet ./...", true}, + {"gofmt -w file.go", true}, + {"golangci-lint run", true}, + {"go build ./...", false}, + {"npm install", false}, + {"pnpm test", true}, + } + + for _, tt := range tests { + gotOK := re.MatchString(tt.cmd) + if gotOK != tt.wantOK { + t.Errorf("cmd %q: got %v, want %v", tt.cmd, gotOK, tt.wantOK) + } + } +} + +// TestPresetExecPatterns_Analyst validates analyst exec allowlist. +func TestPresetExecPatterns_Analyst(t *testing.T) { + pattern, ok := presetExecPatterns[PresetAnalyst] + if !ok || pattern == "" { + t.Fatalf("analyst pattern missing or empty") + } + + re, err := regexp.Compile(pattern) + if err != nil { + t.Fatalf("failed to compile pattern: %v", err) + } + + tests := []struct { + cmd string + wantOK bool + }{ + {"go test ./...", true}, + {"go vet ./...", true}, + {"git log --oneline", true}, + {"git diff HEAD", true}, + {"grep pattern file", true}, + {"curl http://example.com", true}, + {"go build ./...", false}, + {"npm install", false}, + } + + for _, tt := range tests { + gotOK := re.MatchString(tt.cmd) + if gotOK != tt.wantOK { + t.Errorf("cmd %q: got %v, want %v", tt.cmd, gotOK, tt.wantOK) + } + } +} + +// TestIsValidPreset checks preset validation. +func TestIsValidPreset(t *testing.T) { + tests := []struct { + preset Preset + valid bool + }{ + {PresetScout, true}, + {PresetAnalyst, true}, + {PresetCoder, true}, + {PresetWorker, true}, + {PresetCoordinator, true}, + {Preset("invalid"), false}, + {Preset(""), false}, + } + + for _, tt := range tests { + gotValid := IsValidPreset(tt.preset) + if gotValid != tt.valid { + t.Errorf("preset %q: got %v, want %v", tt.preset, gotValid, tt.valid) + } + } +} diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index 73d385cb0..5e6e59384 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -50,6 +50,11 @@ func (t *SpawnTool) Parameters() map[string]any { "type": "string", "description": "Optional target agent ID to delegate the task to", }, + "preset": map[string]any{ + "type": "string", + "enum": []string{"scout", "analyst", "coder", "worker", "coordinator"}, + "description": "Optional capability tier: scout (explore), analyst (analyze), coder (code), worker (build), coordinator (orchestrate)", + }, }, "required": []string{"task"}, } @@ -72,11 +77,16 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul label, _ := args["label"].(string) agentID, _ := args["agent_id"].(string) + preset, _ := args["preset"].(string) - // Check allowlist if targeting a specific agent - if agentID != "" && t.allowlistCheck != nil { - if !t.allowlistCheck(agentID) { - return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", agentID)) + // Check allowlist if targeting a specific agent or preset + checkTarget := agentID + if checkTarget == "" && preset != "" { + checkTarget = preset + } + if checkTarget != "" && t.allowlistCheck != nil { + if !t.allowlistCheck(checkTarget) { + return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s' or preset '%s'", agentID, preset)) } } @@ -85,7 +95,7 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul } // Pass callback to manager for async completion notification - result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, t.callback) + result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, preset, t.callback) if err != nil { return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err)) } diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 3956cf0fd..6e227f788 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -31,6 +31,8 @@ type SubagentManager struct { bus *bus.MessageBus workspace string tools *ToolRegistry + webSearchOpts WebSearchToolOptions + execTool *ExecTool // Shared exec tool for all presets maxIterations int maxTokens int temperature float64 @@ -45,10 +47,13 @@ func NewSubagentManager( defaultModel, workspace string, bus *bus.MessageBus, reporter orch.AgentReporter, + webSearchOpts WebSearchToolOptions, ) *SubagentManager { if reporter == nil { reporter = orch.Noop } + // Create a shared exec tool for all presets + execTool := NewExecTool(workspace, true) return &SubagentManager{ tasks: make(map[string]*SubagentTask), provider: provider, @@ -56,6 +61,8 @@ func NewSubagentManager( bus: bus, workspace: workspace, tools: NewToolRegistry(), + webSearchOpts: webSearchOpts, + execTool: execTool, maxIterations: 10, nextID: 1, reporter: reporter, @@ -89,7 +96,7 @@ func (sm *SubagentManager) RegisterTool(tool Tool) { func (sm *SubagentManager) Spawn( ctx context.Context, - task, label, agentID, originChannel, originChatID string, + task, label, agentID, originChannel, originChatID, preset string, callback AsyncCallback, ) (string, error) { sm.mu.Lock() @@ -113,7 +120,7 @@ func (sm *SubagentManager) Spawn( sm.reporter.ReportSpawn(taskID, label, task) // Start task in background with context cancellation support - go sm.runTask(ctx, subagentTask, callback) + go sm.runTask(ctx, subagentTask, preset, callback) if label != "" { return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil @@ -121,14 +128,31 @@ func (sm *SubagentManager) Spawn( return fmt.Sprintf("Spawned subagent for task: %s", task), nil } -func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { +func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, preset string, callback AsyncCallback) { task.Status = "running" - // Build system prompt for subagent + // Build system prompt based on preset type systemPrompt := `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.` + // Select prompt based on preset (exploratory vs deliberate) + p := Preset(preset) + if IsValidPreset(p) { + switch p { + case PresetScout, PresetAnalyst: + // Exploratory presets + systemPrompt = `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.` + case PresetCoder, PresetWorker, PresetCoordinator: + // Deliberate presets + systemPrompt = `You are a deliberate subagent. Complete the task methodically and verify your work. +Before executing significant actions, think through your approach. +After completing, provide a clear summary of what was done and how it was verified.` + } + } + messages := []providers.Message{ { Role: "system", @@ -153,7 +177,11 @@ After completing the task, provide a clear summary of what was done.` // Run tool loop with access to tools sm.mu.RLock() + // Use preset registry if preset is valid, otherwise use default registry tools := sm.tools + if IsValidPreset(p) { + tools = sm.buildPresetRegistry(p, sm.workspace) + } maxIter := sm.maxIterations maxTokens := sm.maxTokens temperature := sm.temperature @@ -247,6 +275,68 @@ After completing the task, provide a clear summary of what was done.` } } +// buildPresetRegistry constructs a ToolRegistry for the given preset with appropriate restrictions. +func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string) *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)) + } + 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 + if config.AllowedTools["exec"] { + // Use the shared exec tool but set allow patterns + execTool := sm.execTool + if config.ExecPolicy != nil { + _ = execTool.SetAllowPatterns([]string{config.ExecPolicy.AllowPattern}) + } + registry.Register(execTool) + + if config.AllowedTools["bg_monitor"] { + registry.Register(NewBgMonitorTool(execTool)) + } + } + + // Register web tools + if config.AllowedTools["web_search"] { + webSearchTool := NewWebSearchTool(sm.webSearchOpts) + if webSearchTool != nil { + registry.Register(webSearchTool) + } + } + if config.AllowedTools["web_fetch"] { + registry.Register(NewWebFetchTool(50000)) + } + + // 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) + } + + return registry +} + func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { sm.mu.RLock() defer sm.mu.RUnlock() diff --git a/pkg/tools/subagent_reporter_test.go b/pkg/tools/subagent_reporter_test.go index aaf3bd7a4..1b64599b1 100644 --- a/pkg/tools/subagent_reporter_test.go +++ b/pkg/tools/subagent_reporter_test.go @@ -43,7 +43,7 @@ func TestSubagentManager_Spawn_EmitsLifecycleEvents(t *testing.T) { defer b.Unsubscribe(sub) provider := &MockLLMProvider{} - mgr := NewSubagentManager(provider, "test-model", "/tmp/test", nil, b) + mgr := NewSubagentManager(provider, "test-model", "/tmp/test", nil, b, WebSearchToolOptions{}) var callbackCalled int32 cb := AsyncCallback(func(_ context.Context, _ *ToolResult) { @@ -52,7 +52,7 @@ func TestSubagentManager_Spawn_EmitsLifecycleEvents(t *testing.T) { _, err := mgr.Spawn( context.Background(), - "say hello", "hello-task", "", "cli", "direct", + "say hello", "hello-task", "", "cli", "direct", "", cb, ) if err != nil { @@ -143,11 +143,11 @@ func TestSubagentManager_Spawn_SnapshotLiveDuringExecution(t *testing.T) { defer b.Unsubscribe(sub) provider := &MockLLMProvider{} - mgr := NewSubagentManager(provider, "test-model", "/tmp/test", nil, b) + mgr := NewSubagentManager(provider, "test-model", "/tmp/test", nil, b, WebSearchToolOptions{}) _, err := mgr.Spawn( context.Background(), - "any task", "live-test", "", "cli", "direct", + "any task", "live-test", "", "cli", "direct", "", nil, ) if err != nil { @@ -188,12 +188,12 @@ func TestSubagentManager_Spawn_CancelledDuringExecution(t *testing.T) { defer b.Unsubscribe(sub) bp := newBlockingProvider() - mgr := NewSubagentManager(bp, "test-model", "/tmp/test", nil, b) + mgr := NewSubagentManager(bp, "test-model", "/tmp/test", nil, b, WebSearchToolOptions{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - _, err := mgr.Spawn(ctx, "long task", "cancel-me", "", "cli", "direct", nil) + _, err := mgr.Spawn(ctx, "long task", "cancel-me", "", "cli", "direct", "", nil) if err != nil { t.Fatalf("Spawn() error: %v", err) } diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go index 30838d843..6291724dc 100644 --- a/pkg/tools/subagent_tool_test.go +++ b/pkg/tools/subagent_tool_test.go @@ -48,7 +48,7 @@ func (m *MockLLMProvider) GetContextWindow() int { func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) manager.SetLLMOptions(2048, 0.6) tool := NewSubagentTool(manager) tool.SetContext("cli", "direct") @@ -75,7 +75,7 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { // TestSubagentTool_Name verifies tool name func TestSubagentTool_Name(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) tool := NewSubagentTool(manager) if tool.Name() != "subagent" { @@ -86,7 +86,7 @@ func TestSubagentTool_Name(t *testing.T) { // TestSubagentTool_Description verifies tool description func TestSubagentTool_Description(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) tool := NewSubagentTool(manager) desc := tool.Description() @@ -101,7 +101,7 @@ func TestSubagentTool_Description(t *testing.T) { // TestSubagentTool_Parameters verifies tool parameters schema func TestSubagentTool_Parameters(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) tool := NewSubagentTool(manager) params := tool.Parameters() @@ -151,7 +151,7 @@ func TestSubagentTool_Parameters(t *testing.T) { // TestSubagentTool_SetContext verifies context setting func TestSubagentTool_SetContext(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) tool := NewSubagentTool(manager) tool.SetContext("test-channel", "test-chat") @@ -165,7 +165,7 @@ func TestSubagentTool_SetContext(t *testing.T) { func TestSubagentTool_Execute_Success(t *testing.T) { provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{}) tool := NewSubagentTool(manager) tool.SetContext("telegram", "chat-123") @@ -221,7 +221,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) { func TestSubagentTool_Execute_NoLabel(t *testing.T) { provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{}) tool := NewSubagentTool(manager) ctx := context.Background() @@ -244,7 +244,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) { // TestSubagentTool_Execute_MissingTask tests error handling for missing task func TestSubagentTool_Execute_MissingTask(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) tool := NewSubagentTool(manager) ctx := context.Background() @@ -295,7 +295,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) { func TestSubagentTool_Execute_ContextPassing(t *testing.T) { provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{}) tool := NewSubagentTool(manager) // Set context @@ -324,7 +324,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) { // Create a mock provider that returns very long content provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{}) tool := NewSubagentTool(manager) ctx := context.Background()