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 <noreply@anthropic.com>
This commit is contained in:
parent
887d6adfa8
commit
d8b18b57e6
8 changed files with 546 additions and 33 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
125
pkg/tools/sandbox.go
Normal file
125
pkg/tools/sandbox.go
Normal file
|
|
@ -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
|
||||
}
|
||||
254
pkg/tools/sandbox_test.go
Normal file
254
pkg/tools/sandbox_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue