diff --git a/go-opencode/cmd/opencode/commands/run.go b/go-opencode/cmd/opencode/commands/run.go index f1ae8febeef..a343409d42b 100644 --- a/go-opencode/cmd/opencode/commands/run.go +++ b/go-opencode/cmd/opencode/commands/run.go @@ -6,6 +6,7 @@ import ( "os" "strings" + "github.com/opencode-ai/opencode/internal/agent" "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/mcp" "github.com/opencode-ai/opencode/internal/permission" @@ -99,6 +100,10 @@ func runInteractive(cmd *cobra.Command, args []string) error { // Initialize tool registry toolReg := tool.DefaultRegistry(workDir, store) + // Initialize agent registry and task tool + agentReg := agent.NewRegistry() + toolReg.RegisterTaskTool(agentReg) + // Initialize MCP client and servers from config var mcpClient *mcp.Client if appConfig.MCP != nil && len(appConfig.MCP) > 0 { @@ -192,6 +197,19 @@ func runInteractive(cmd *cobra.Command, args []string) error { } } + // Create and configure SubagentExecutor for task tool + subagentExecutor := tool.NewSubagentExecutor(tool.SubagentExecutorConfig{ + Storage: store, + ProviderRegistry: providerReg, + ToolRegistry: toolReg, + PermissionChecker: permChecker, + AgentRegistry: agentReg, + WorkDir: workDir, + DefaultProviderID: defaultProviderID, + DefaultModelID: defaultModelID, + }) + toolReg.SetTaskExecutor(subagentExecutor) + // Create processor processor := session.NewProcessor(providerReg, toolReg, store, permChecker, defaultProviderID, defaultModelID) diff --git a/go-opencode/cmd/opencode/commands/serve.go b/go-opencode/cmd/opencode/commands/serve.go index 49f31ee2185..30476be7ddb 100644 --- a/go-opencode/cmd/opencode/commands/serve.go +++ b/go-opencode/cmd/opencode/commands/serve.go @@ -6,9 +6,11 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" + "github.com/opencode-ai/opencode/internal/agent" "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/mcp" @@ -85,6 +87,40 @@ func runServe(cmd *cobra.Command, args []string) error { // Initialize tool registry toolReg := tool.DefaultRegistry(workDir, store) + // Initialize agent registry + agentReg := agent.NewRegistry() + logging.Info(). + Int("agentCount", agentReg.Count()). + Strs("agents", agentReg.Names()). + Msg("Agent registry initialized") + + // Register task tool with agent registry + toolReg.RegisterTaskTool(agentReg) + + // Parse default provider and model from config + var defaultProviderID, defaultModelID string + if appConfig != nil && appConfig.Model != "" { + parts := strings.SplitN(appConfig.Model, "/", 2) + if len(parts) == 2 { + defaultProviderID = parts[0] + defaultModelID = parts[1] + } + } + + // Create and configure SubagentExecutor for task tool + subagentExecutor := tool.NewSubagentExecutor(tool.SubagentExecutorConfig{ + Storage: store, + ProviderRegistry: providerReg, + ToolRegistry: toolReg, + PermissionChecker: nil, // No permission checker for subagents + AgentRegistry: agentReg, + WorkDir: workDir, + DefaultProviderID: defaultProviderID, + DefaultModelID: defaultModelID, + }) + toolReg.SetTaskExecutor(subagentExecutor) + logging.Info().Msg("Subagent executor configured for task tool") + // Configure server serverConfig := server.DefaultConfig() serverConfig.Port = servePort diff --git a/go-opencode/internal/agent/agent.go b/go-opencode/internal/agent/agent.go index b033e7bf640..76e275874f4 100644 --- a/go-opencode/internal/agent/agent.go +++ b/go-opencode/internal/agent/agent.go @@ -203,6 +203,26 @@ func matchWildcard(pattern, s string) bool { return pattern == s } +// ExploreAgentPrompt is the system prompt for the explore agent. +const ExploreAgentPrompt = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases. + +Your strengths: +- Rapidly finding files using glob patterns +- Searching code and text with powerful regex patterns +- Reading and analyzing file contents + +Guidelines: +- Use Glob for broad file pattern matching +- Use Grep for searching file contents with regex +- Use Read when you know the specific file path you need to read +- Use Bash for file operations like copying, moving, or listing directory contents +- Adapt your search approach based on the thoroughness level specified by the caller +- Return file paths as absolute paths in your final response +- For clear communication, avoid using emojis +- Do not create any files, or run bash commands that modify the user's system state in any way + +Complete the user's search request efficiently and report your findings clearly.` + // BuiltInAgents returns the default agent configurations. func BuiltInAgents() map[string]*Agent { return map[string]*Agent{ @@ -230,70 +250,94 @@ func BuiltInAgents() map[string]*Agent { Permission: AgentPermission{ Edit: permission.ActionDeny, Bash: map[string]permission.PermissionAction{ - "grep*": permission.ActionAllow, - "find*": permission.ActionAllow, - "ls*": permission.ActionAllow, - "cat*": permission.ActionAllow, - "git status": permission.ActionAllow, - "git diff*": permission.ActionAllow, - "git log*": permission.ActionAllow, - "*": permission.ActionDeny, + "cut*": permission.ActionAllow, + "diff*": permission.ActionAllow, + "du*": permission.ActionAllow, + "file *": permission.ActionAllow, + "find * -delete*": permission.ActionAsk, + "find * -exec*": permission.ActionAsk, + "find * -fprint*": permission.ActionAsk, + "find * -fls*": permission.ActionAsk, + "find * -fprintf*": permission.ActionAsk, + "find * -ok*": permission.ActionAsk, + "find *": permission.ActionAllow, + "git diff*": permission.ActionAllow, + "git log*": permission.ActionAllow, + "git show*": permission.ActionAllow, + "git status*": permission.ActionAllow, + "git branch": permission.ActionAllow, + "git branch -v": permission.ActionAllow, + "grep*": permission.ActionAllow, + "head*": permission.ActionAllow, + "less*": permission.ActionAllow, + "ls*": permission.ActionAllow, + "more*": permission.ActionAllow, + "pwd*": permission.ActionAllow, + "rg*": permission.ActionAllow, + "sort --output=*": permission.ActionAsk, + "sort -o *": permission.ActionAsk, + "sort*": permission.ActionAllow, + "stat*": permission.ActionAllow, + "tail*": permission.ActionAllow, + "tree -o *": permission.ActionAsk, + "tree*": permission.ActionAllow, + "uniq*": permission.ActionAllow, + "wc*": permission.ActionAllow, + "whereis*": permission.ActionAllow, + "which*": permission.ActionAllow, + "*": permission.ActionAsk, }, WebFetch: permission.ActionAllow, ExternalDir: permission.ActionDeny, DoomLoop: permission.ActionDeny, }, Tools: map[string]bool{ - "read": true, - "glob": true, - "grep": true, - "ls": true, - "bash": true, - "edit": false, - "write": false, + "*": true, + "edit": false, + "write": false, + "todoread": false, + "todowrite": false, }, }, "general": { Name: "general", - Description: "General-purpose subagent for searches and exploration", + Description: "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.", Mode: ModeSubagent, BuiltIn: true, Permission: AgentPermission{ - Edit: permission.ActionDeny, - Bash: map[string]permission.PermissionAction{"*": permission.ActionDeny}, + Edit: permission.ActionAllow, + Bash: map[string]permission.PermissionAction{"*": permission.ActionAllow}, WebFetch: permission.ActionAllow, - ExternalDir: permission.ActionDeny, - DoomLoop: permission.ActionDeny, + ExternalDir: permission.ActionAsk, + DoomLoop: permission.ActionAsk, }, Tools: map[string]bool{ - "read": true, - "glob": true, - "grep": true, - "webfetch": true, - "bash": false, - "edit": false, - "write": false, + "*": true, + "todoread": false, + "todowrite": false, + "task": false, // Prevent recursive task calls }, }, "explore": { Name: "explore", - Description: "Fast agent specialized for codebase exploration", + Description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, Mode: ModeSubagent, BuiltIn: true, + Prompt: ExploreAgentPrompt, Permission: AgentPermission{ - Edit: permission.ActionDeny, - Bash: map[string]permission.PermissionAction{"*": permission.ActionDeny}, - WebFetch: permission.ActionDeny, - ExternalDir: permission.ActionDeny, - DoomLoop: permission.ActionDeny, + Edit: permission.ActionAllow, + Bash: map[string]permission.PermissionAction{"*": permission.ActionAllow}, + WebFetch: permission.ActionAllow, + ExternalDir: permission.ActionAsk, + DoomLoop: permission.ActionAsk, }, Tools: map[string]bool{ - "read": true, - "glob": true, - "grep": true, - "ls": true, - "bash": false, - "edit": false, + "*": true, + "todoread": false, + "todowrite": false, + "edit": false, + "write": false, + "task": false, // Prevent recursive task calls }, }, } diff --git a/go-opencode/internal/tool/SUBAGENT.md b/go-opencode/internal/tool/SUBAGENT.md new file mode 100644 index 00000000000..092a8360f9b --- /dev/null +++ b/go-opencode/internal/tool/SUBAGENT.md @@ -0,0 +1,467 @@ +# Subagent System Documentation + +This document describes the subagent (task agent) system in go-opencode and compares it with the TypeScript implementation in packages/opencode. + +## Table of Contents + +1. [Overview](#overview) +2. [Available Agents](#available-agents) +3. [Task Tool Usage](#task-tool-usage) +4. [Parallel Execution](#parallel-execution) +5. [Architecture](#architecture) +6. [TS vs Go Implementation Comparison](#ts-vs-go-implementation-comparison) +7. [Orchestration Patterns](#orchestration-patterns) +8. [Future Roadmap](#future-roadmap) + +--- + +## Overview + +The subagent system allows the primary agent to spawn specialized child agents to handle complex, multi-step tasks autonomously. Each subagent runs in its own session context with specific tools and permissions. + +### Key Concepts + +- **Primary Agent**: The main agent (e.g., `build`, `plan`) that interacts with the user +- **Subagent**: A specialized agent spawned via the `task` tool to handle specific tasks +- **Child Session**: Each subagent execution creates a new session linked to the parent +- **Task Tool**: The tool that enables spawning subagents + +### When to Use Subagents + +- **Complex research tasks** requiring multiple search iterations +- **Codebase exploration** that needs deep file traversal +- **Parallel workloads** that can be executed concurrently +- **Isolated analysis** where you want a fresh context + +--- + +## Available Agents + +### Primary Agents (Cannot be used as subagents) + +#### `build` +- **Mode**: Primary +- **Purpose**: Main agent for executing tasks, writing code, and making changes +- **Permissions**: Full access to all tools +- **Use Case**: Default agent for user interactions + +#### `plan` +- **Mode**: Primary +- **Purpose**: Planning and analysis without making changes +- **Permissions**: + - Edit: Denied + - Bash: Read-only commands only (git, grep, find, ls, etc.) + - Write: Denied +- **Use Case**: Safe exploration and planning mode + +### Subagents (Available via Task tool) + +#### `general` +- **Mode**: Subagent +- **Description**: General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel. +- **Permissions**: Full access (edit, bash, webfetch allowed) +- **Disabled Tools**: `todoread`, `todowrite`, `task` (prevents recursion) +- **Best For**: + - Complex multi-step research + - Tasks requiring file modifications + - Parallel execution of independent work units + +#### `explore` +- **Mode**: Subagent +- **Description**: Fast agent specialized for exploring codebases. Use for finding files by patterns, searching code for keywords, or answering questions about the codebase. +- **Custom Prompt**: File search specialist with guidelines for efficient exploration +- **Permissions**: Full access but edit/write disabled +- **Disabled Tools**: `todoread`, `todowrite`, `edit`, `write`, `task` +- **Thoroughness Levels**: + - `quick`: Basic searches, first-level matches + - `medium`: Moderate exploration, follow references + - `very thorough`: Comprehensive analysis across multiple locations +- **Best For**: + - Finding files by glob patterns (e.g., `src/**/*.tsx`) + - Searching code for keywords or patterns + - Understanding codebase structure + - Answering "where is X defined?" questions + +--- + +## Task Tool Usage + +### Basic Invocation + +```json +{ + "description": "Find auth handlers", + "prompt": "Search the codebase for all authentication-related handlers and middleware. Report file paths and a brief description of each.", + "subagentType": "explore" +} +``` + +### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `description` | string | Yes | Short 3-5 word description of the task | +| `prompt` | string | Yes | Detailed task instructions for the subagent | +| `subagentType` | string | Yes | Agent to use: `general`, `explore`, or `plan` | +| `model` | string | No | Optional model override: `sonnet`, `opus`, `haiku` | +| `resume` | string | No | Optional session ID to resume from (TS only) | + +### Response Format + +```json +{ + "title": "Completed: Find auth handlers", + "output": "Found 5 authentication handlers:\n1. /src/auth/login.go:45 - LoginHandler\n...", + "metadata": { + "subagent": "explore", + "status": "completed", + "sessionID": "01HXYZ..." + } +} +``` + +--- + +## Parallel Execution + +### How It Works + +The LLM can invoke multiple task tools in a single response. Each task runs independently in its own session. + +``` +Primary Agent Response: +├── Task 1: [explore] "Find all API endpoints" +├── Task 2: [explore] "Find all database models" +└── Task 3: [general] "Analyze error handling patterns" +``` + +### Concurrency Model + +#### TypeScript Implementation +- Uses `Promise.all()` for parallel execution +- Each task gets independent context with unique `callID` +- Progress updates via event bus (real-time) +- Supports up to 10 parallel tasks via Batch tool + +#### Go Implementation +- Each task executed via goroutines (when triggered by LLM) +- Independent sessions with separate processor instances +- Results collected after all tasks complete +- No explicit concurrency limit (bounded by LLM tool calls) + +### Example: Parallel Codebase Analysis + +``` +User: "Analyze this codebase architecture" + +Primary Agent spawns: +1. Task(explore): "Find all entry points (main.go, cmd/)" +2. Task(explore): "Find configuration handling" +3. Task(explore): "Find database/storage layer" +4. Task(explore): "Find API/HTTP handlers" +5. Task(general): "Analyze dependency injection patterns" + +Results aggregated by primary agent into comprehensive response. +``` + +### Best Practices for Parallel Execution + +1. **Independent Tasks**: Ensure tasks don't depend on each other's results +2. **Specific Prompts**: Give each subagent focused, unambiguous instructions +3. **Appropriate Agent**: Use `explore` for read-only, `general` for modifications +4. **Result Aggregation**: Primary agent should synthesize subagent outputs + +--- + +## Architecture + +### Component Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Primary Session │ +│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ +│ │ User │───▶│ Processor │───▶│ Tool Registry │ │ +│ │ Message │ │ (Loop) │ │ │ │ +│ └─────────────┘ └──────────────┘ └───────┬───────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌───────────────┐ │ +│ │ LLM Call │ │ Task Tool │ │ +│ └──────────────┘ └───────┬───────┘ │ +└─────────────────────────────────────────────────┼──────────┘ + │ + ┌─────────────────────────────┼─────────────────────────────┐ + │ ▼ │ + │ ┌─────────────────────────────┐ │ + │ │ SubagentExecutor │ │ + │ │ - Creates child session │ │ + │ │ - Converts agent config │ │ + │ │ - Runs processor loop │ │ + │ └─────────────┬───────────────┘ │ + │ │ │ + ┌─────────┴────────┐ ┌────────┴────────┐ ┌──────────┴─────────┐ + ▼ ▼ ▼ ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ Child Session│ │ Child Session│ │ Child Session│ │ Child Session│ + │ (explore) │ │ (explore) │ │ (general) │ │ ... │ + │ │ │ │ │ │ │ │ + │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │ │ + │ │Processor │ │ │ │Processor │ │ │ │Processor │ │ │ │ + │ │ Loop │ │ │ │ Loop │ │ │ │ Loop │ │ │ │ + │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │ │ │ + └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +### Session Hierarchy + +``` +Session: "Main conversation" (ID: 01HX...) +├── Message: User "Analyze the codebase" +├── Message: Assistant (spawns tasks) +│ ├── ToolPart: task (explore) → Child Session 01HY... +│ ├── ToolPart: task (explore) → Child Session 01HZ... +│ └── ToolPart: task (general) → Child Session 01HA... +└── Message: Assistant (aggregated response) + +Child Session: 01HY... (ParentID: 01HX...) +├── Message: User (subagent prompt) +└── Message: Assistant (subagent response) +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `internal/agent/agent.go` | Agent definitions and permissions | +| `internal/agent/registry.go` | Agent registration and lookup | +| `internal/tool/task.go` | Task tool implementation | +| `internal/tool/subagent_executor.go` | Subagent execution logic | +| `internal/tool/registry.go` | Tool registry with task tool support | +| `internal/session/processor.go` | Agentic loop processor | + +--- + +## TS vs Go Implementation Comparison + +### Feature Matrix + +| Feature | TypeScript | Go | Notes | +|---------|------------|-----|-------| +| Basic subagent execution | ✅ | ✅ | Both create child sessions | +| Agent registry | ✅ | ✅ | Identical agent definitions | +| Custom agent prompts | ✅ | ✅ | Explore agent has custom prompt | +| Permission system | ✅ | ✅ | Bash patterns, edit control | +| Session hierarchy | ✅ | ✅ | ParentID linking | +| Session resume | ✅ | ❌ | TS supports `session_id` param | +| Real-time progress | ✅ | ❌ | TS uses event bus subscription | +| Metadata callback | ✅ | ❌ | TS updates parent with progress | +| Workflow orchestration | ✅ | ❌ | TS has full workflow DSL | +| Parallel steps | ✅ | ❌ | TS has explicit parallel execution | +| Conditional branching | ✅ | ❌ | TS supports if/then/else workflows | +| Loop steps | ✅ | ❌ | TS supports while/until loops | +| Pause/Resume | ✅ | ❌ | TS has human-in-the-loop | +| Batch tool | ✅ | ❌ | TS can batch 10 parallel tools | + +### Implementation Differences + +#### Session Creation + +**TypeScript:** +```typescript +const session = await Session.create({ + parentID: ctx.sessionID, + title: `${params.description} (@${agent.name} subagent)`, +}) +``` + +**Go:** +```go +session := &types.Session{ + ID: ulid.Make().String(), + ParentID: &parentSessionID, + Title: fmt.Sprintf("Subtask: %s", agentName), + // ... +} +``` + +#### Progress Tracking + +**TypeScript:** +```typescript +const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { + if (evt.properties.part.sessionID !== session.id) return + parts[evt.properties.part.id] = evt.properties.part + ctx.metadata({ + summary: Object.values(parts).sort(...) + }) +}) +``` + +**Go:** +```go +// Currently no real-time progress tracking +// Results returned only after completion +``` + +#### Agent Config Conversion + +**TypeScript:** +```typescript +// Direct use of agent config +const tools = { + todowrite: false, + todoread: false, + task: false, + ...agent.tools, +} +``` + +**Go:** +```go +func convertToSessionAgent(a *agent.Agent) *session.Agent { + // Converts agent.Agent to session.Agent + // Maps tools, permissions, and settings +} +``` + +--- + +## Orchestration Patterns + +### Pattern 1: Fan-Out / Fan-In + +Use multiple subagents for parallel exploration, then aggregate results. + +``` +Primary Agent: +1. Spawn N explore agents for different aspects +2. Wait for all to complete +3. Synthesize findings into coherent response + +Example: +- Task 1: Find all REST endpoints +- Task 2: Find all GraphQL resolvers +- Task 3: Find all WebSocket handlers +→ Aggregate into "API Surface Analysis" +``` + +### Pattern 2: Specialist Delegation + +Route specific subtasks to the most appropriate agent. + +``` +User: "Review and improve error handling" + +Primary Agent: +1. Task(explore): "Find all error handling patterns" +2. Based on findings... +3. Task(general): "Refactor error handling in auth module" +4. Task(general): "Add error handling to database layer" +``` + +### Pattern 3: Iterative Refinement + +Use subagents for progressive deepening. + +``` +Round 1: Task(explore, quick): "Find main components" +Round 2: Task(explore, thorough): "Deep dive into auth component" +Round 3: Task(general): "Implement improvements to auth" +``` + +### Pattern 4: Verification Pipeline + +Use subagents to verify work. + +``` +1. Task(general): "Implement feature X" +2. Task(explore): "Verify feature X implementation" +3. Task(general): "Fix issues found in verification" +``` + +### TypeScript-Only: Workflow Orchestration + +The TS implementation supports declarative workflows: + +```typescript +const workflow = { + steps: [ + { id: "analyze", type: "agent", agent: "explore", input: "..." }, + { id: "implement", type: "agent", agent: "general", dependsOn: ["analyze"] }, + { id: "verify", type: "parallel", steps: ["test", "lint"], maxConcurrency: 2 }, + { id: "review", type: "pause", message: "Please review changes" }, + ], + orchestrator: { + mode: "guided", + onError: "pause", + maxRetries: 3, + } +} +``` + +--- + +## Future Roadmap + +### Planned Go Implementation + +#### Phase 1: Core Improvements +- [ ] Add session resume via `session_id` parameter +- [ ] Implement real-time progress events +- [ ] Add metadata callback to tool context +- [ ] Implement tool restriction in subagents + +#### Phase 2: Orchestration +- [ ] Define workflow types and schema +- [ ] Implement workflow executor +- [ ] Add parallel step execution +- [ ] Add conditional branching + +#### Phase 3: Advanced Features +- [ ] Implement pause/resume mechanism +- [ ] Add loop steps (while/until) +- [ ] Implement transform steps +- [ ] Add batch tool for parallel tool execution + +### Contributing + +To contribute to the subagent system: + +1. Agent definitions: `internal/agent/agent.go` +2. Task tool: `internal/tool/task.go` +3. Executor: `internal/tool/subagent_executor.go` +4. Tests: Add tests in `*_test.go` files + +--- + +## Appendix: Agent Permission Reference + +### Bash Command Patterns (Plan Agent) + +| Pattern | Permission | Purpose | +|---------|------------|---------| +| `git diff*`, `git log*`, `git status*` | Allow | Git read operations | +| `grep*`, `rg*` | Allow | Search tools | +| `find *` | Allow | File finding | +| `find * -delete*`, `find * -exec*` | Ask | Dangerous find flags | +| `ls*`, `cat*`, `head*`, `tail*` | Allow | File reading | +| `tree*` | Allow | Directory visualization | +| `*` | Ask | All other commands | + +### Tool Availability by Agent + +| Tool | build | plan | general | explore | +|------|-------|------|---------|---------| +| read | ✅ | ✅ | ✅ | ✅ | +| write | ✅ | ❌ | ✅ | ❌ | +| edit | ✅ | ❌ | ✅ | ❌ | +| bash | ✅ | ✅* | ✅ | ✅ | +| glob | ✅ | ✅ | ✅ | ✅ | +| grep | ✅ | ✅ | ✅ | ✅ | +| task | ✅ | ✅ | ❌ | ❌ | +| todowrite | ✅ | ❌ | ❌ | ❌ | +| todoread | ✅ | ❌ | ❌ | ❌ | +| webfetch | ✅ | ✅ | ✅ | ✅ | + +*Plan agent has restricted bash (read-only commands only) diff --git a/go-opencode/internal/tool/registry.go b/go-opencode/internal/tool/registry.go index cff5e4e5503..047dcec8d85 100644 --- a/go-opencode/internal/tool/registry.go +++ b/go-opencode/internal/tool/registry.go @@ -6,6 +6,7 @@ import ( einotool "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/schema" + "github.com/opencode-ai/opencode/internal/agent" "github.com/opencode-ai/opencode/internal/storage" ) @@ -118,8 +119,30 @@ func DefaultRegistry(workDir string, store *storage.Storage) *Registry { r.Register(NewTodoWriteTool(workDir, store)) r.Register(NewTodoReadTool(workDir, store)) - // Note: TaskTool requires agent registry, register separately if needed + // Note: TaskTool requires agent registry, register separately using RegisterTaskTool fmt.Printf("[registry] DefaultRegistry created with %d tools: %v\n", len(r.tools), r.IDs()) return r } + +// RegisterTaskTool registers the task tool with the given agent registry. +// This must be called separately after the agent registry is available. +func (r *Registry) RegisterTaskTool(agentReg *agent.Registry) { + taskTool := NewTaskTool(r.workDir, agentReg) + r.Register(taskTool) + fmt.Printf("[registry] Registered task tool with agent registry\n") +} + +// SetTaskExecutor sets the executor for the task tool. +// This enables actual subagent execution instead of placeholder responses. +func (r *Registry) SetTaskExecutor(executor TaskExecutor) { + r.mu.Lock() + defer r.mu.Unlock() + + if tool, ok := r.tools["task"]; ok { + if taskTool, ok := tool.(*TaskTool); ok { + taskTool.SetExecutor(executor) + fmt.Printf("[registry] Task executor configured\n") + } + } +} diff --git a/go-opencode/internal/tool/subagent_executor.go b/go-opencode/internal/tool/subagent_executor.go new file mode 100644 index 00000000000..850f120a087 --- /dev/null +++ b/go-opencode/internal/tool/subagent_executor.go @@ -0,0 +1,376 @@ +// Package tool provides tool implementations for the agentic loop. +package tool + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/oklog/ulid/v2" + + "github.com/opencode-ai/opencode/internal/agent" + "github.com/opencode-ai/opencode/internal/event" + "github.com/opencode-ai/opencode/internal/permission" + "github.com/opencode-ai/opencode/internal/provider" + "github.com/opencode-ai/opencode/internal/session" + "github.com/opencode-ai/opencode/internal/storage" + "github.com/opencode-ai/opencode/pkg/types" +) + +// SubagentExecutor implements TaskExecutor to run subagent tasks. +type SubagentExecutor struct { + storage *storage.Storage + providerRegistry *provider.Registry + toolRegistry *Registry + permissionChecker *permission.Checker + agentRegistry *agent.Registry + workDir string + + // Default provider and model settings + defaultProviderID string + defaultModelID string +} + +// SubagentExecutorConfig holds configuration for creating a SubagentExecutor. +type SubagentExecutorConfig struct { + Storage *storage.Storage + ProviderRegistry *provider.Registry + ToolRegistry *Registry + PermissionChecker *permission.Checker + AgentRegistry *agent.Registry + WorkDir string + DefaultProviderID string + DefaultModelID string +} + +// NewSubagentExecutor creates a new SubagentExecutor. +func NewSubagentExecutor(cfg SubagentExecutorConfig) *SubagentExecutor { + return &SubagentExecutor{ + storage: cfg.Storage, + providerRegistry: cfg.ProviderRegistry, + toolRegistry: cfg.ToolRegistry, + permissionChecker: cfg.PermissionChecker, + agentRegistry: cfg.AgentRegistry, + workDir: cfg.WorkDir, + defaultProviderID: cfg.DefaultProviderID, + defaultModelID: cfg.DefaultModelID, + } +} + +// ExecuteSubtask implements TaskExecutor.ExecuteSubtask. +// It creates a child session, runs the subagent, and returns the result. +func (e *SubagentExecutor) ExecuteSubtask( + ctx context.Context, + parentSessionID string, + agentName string, + prompt string, + opts TaskOptions, +) (*TaskResult, error) { + // Get the agent configuration + agentConfig, err := e.agentRegistry.Get(agentName) + if err != nil { + return nil, fmt.Errorf("agent not found: %s: %w", agentName, err) + } + + // Verify it can be used as a subagent + if !agentConfig.IsSubagent() { + return nil, fmt.Errorf("agent %s cannot be used as subagent (mode: %s)", agentName, agentConfig.Mode) + } + + // Create a child session + childSession, err := e.createChildSession(ctx, parentSessionID, agentName) + if err != nil { + return nil, fmt.Errorf("failed to create child session: %w", err) + } + + // Convert agent.Agent to session.Agent + sessionAgent := convertToSessionAgent(agentConfig) + + // Resolve model from options + providerID, modelID := e.resolveModel(opts.Model) + + // Create user message with the prompt + userMsg, err := e.createUserMessage(ctx, childSession, prompt, providerID, modelID) + if err != nil { + return nil, fmt.Errorf("failed to create user message: %w", err) + } + + // Create and run processor + processor := session.NewProcessor( + e.providerRegistry, + e.toolRegistry, + e.storage, + e.permissionChecker, + providerID, + modelID, + ) + + // Collect response parts + var responseParts []types.Part + var responseMsg *types.Message + + // Run the processing loop + err = processor.Process(ctx, childSession.ID, sessionAgent, func(msg *types.Message, parts []types.Part) { + responseMsg = msg + responseParts = parts + }) + + if err != nil { + return &TaskResult{ + Output: fmt.Sprintf("Error executing subtask: %s", err.Error()), + SessionID: childSession.ID, + Error: err.Error(), + Metadata: map[string]any{ + "parentSessionID": parentSessionID, + "userMessageID": userMsg.ID, + }, + }, nil + } + + // Extract text content from response + output := extractTextContent(responseParts) + + return &TaskResult{ + Output: output, + SessionID: childSession.ID, + AgentID: agentName, + Metadata: map[string]any{ + "parentSessionID": parentSessionID, + "assistantMessageID": responseMsg.ID, + "userMessageID": userMsg.ID, + }, + }, nil +} + +// createChildSession creates a new session as a child of the parent session. +func (e *SubagentExecutor) createChildSession(ctx context.Context, parentSessionID string, agentName string) (*types.Session, error) { + now := time.Now().UnixMilli() + sessionID := ulid.Make().String() + + // Get parent session to inherit directory + var parentSession types.Session + var directory string + + // Try to find parent session + projects, err := e.storage.List(ctx, []string{"session"}) + if err == nil { + for _, projectID := range projects { + if err := e.storage.Get(ctx, []string{"session", projectID, parentSessionID}, &parentSession); err == nil { + directory = parentSession.Directory + break + } + } + } + + // Use work directory if parent not found + if directory == "" { + directory = e.workDir + } + + // Create project ID from directory + projectID := hashDirectory(directory) + + session := &types.Session{ + ID: sessionID, + ProjectID: projectID, + Directory: directory, + Title: fmt.Sprintf("Subtask: %s", agentName), + ParentID: &parentSessionID, + Version: "1", + Summary: types.SessionSummary{ + Additions: 0, + Deletions: 0, + Files: 0, + }, + Time: types.SessionTime{ + Created: now, + Updated: now, + }, + } + + if err := e.storage.Put(ctx, []string{"session", projectID, session.ID}, session); err != nil { + return nil, fmt.Errorf("failed to save child session: %w", err) + } + + // Publish session created event + event.PublishSync(event.Event{ + Type: event.SessionCreated, + Data: event.SessionCreatedData{Info: session}, + }) + + return session, nil +} + +// createUserMessage creates a user message with the prompt. +func (e *SubagentExecutor) createUserMessage( + ctx context.Context, + sess *types.Session, + prompt string, + providerID string, + modelID string, +) (*types.Message, error) { + now := time.Now().UnixMilli() + msgID := ulid.Make().String() + + msg := &types.Message{ + ID: msgID, + SessionID: sess.ID, + Role: "user", + ProviderID: providerID, + ModelID: modelID, + Model: &types.ModelRef{ + ProviderID: providerID, + ModelID: modelID, + }, + Path: &types.MessagePath{ + Cwd: sess.Directory, + Root: sess.Directory, + }, + Time: types.MessageTime{ + Created: now, + }, + } + + // Save message + if err := e.storage.Put(ctx, []string{"message", sess.ID, msg.ID}, msg); err != nil { + return nil, fmt.Errorf("failed to save user message: %w", err) + } + + // Create text part for the prompt + partID := ulid.Make().String() + textPart := &types.TextPart{ + ID: partID, + SessionID: sess.ID, + MessageID: msg.ID, + Type: "text", + Text: prompt, + } + + // Save part + if err := e.storage.Put(ctx, []string{"part", msg.ID, partID}, textPart); err != nil { + return nil, fmt.Errorf("failed to save text part: %w", err) + } + + // Publish message created event + event.PublishSync(event.Event{ + Type: event.MessageCreated, + Data: event.MessageCreatedData{Info: msg}, + }) + + // Publish part updated event + event.PublishSync(event.Event{ + Type: event.MessagePartUpdated, + Data: event.MessagePartUpdatedData{Part: textPart}, + }) + + return msg, nil +} + +// resolveModel resolves provider and model IDs from the options. +func (e *SubagentExecutor) resolveModel(modelOption string) (providerID, modelID string) { + providerID = e.defaultProviderID + modelID = e.defaultModelID + + // Handle model override from options + switch modelOption { + case "sonnet": + modelID = "claude-sonnet-4-20250514" + case "opus": + modelID = "claude-opus-4-20250514" + case "haiku": + modelID = "claude-haiku-3-20240307" + default: + // Keep defaults + } + + return providerID, modelID +} + +// convertToSessionAgent converts agent.Agent to session.Agent. +func convertToSessionAgent(a *agent.Agent) *session.Agent { + // Build enabled/disabled tool lists from the map + var enabledTools []string + var disabledTools []string + + hasWildcard := false + wildcardEnabled := false + + for tool, enabled := range a.Tools { + if tool == "*" { + hasWildcard = true + wildcardEnabled = enabled + continue + } + if enabled { + enabledTools = append(enabledTools, tool) + } else { + disabledTools = append(disabledTools, tool) + } + } + + // If wildcard is enabled but not explicitly set, we treat it as all enabled + // The DisabledTools list will handle exceptions + if hasWildcard && wildcardEnabled { + enabledTools = nil // Empty means all enabled + } + + // Convert bash permission to simple string + bashPerm := "ask" + if a.Permission.Bash != nil { + if action, ok := a.Permission.Bash["*"]; ok { + bashPerm = string(action) + } + } + + // Convert write/edit permission + writePerm := "ask" + if a.Permission.Edit != "" { + writePerm = string(a.Permission.Edit) + } + + // Convert doom loop permission + doomLoopPerm := "ask" + if a.Permission.DoomLoop != "" { + doomLoopPerm = string(a.Permission.DoomLoop) + } + + return &session.Agent{ + Name: a.Name, + Prompt: a.Prompt, + Temperature: a.Temperature, + TopP: a.TopP, + MaxSteps: 50, // Default max steps for subagents + Tools: enabledTools, + DisabledTools: disabledTools, + Permission: session.AgentPermission{ + DoomLoop: doomLoopPerm, + Bash: bashPerm, + Write: writePerm, + }, + } +} + +// extractTextContent extracts text content from response parts. +func extractTextContent(parts []types.Part) string { + var texts []string + for _, part := range parts { + switch p := part.(type) { + case *types.TextPart: + if p.Text != "" { + texts = append(texts, p.Text) + } + } + } + return strings.Join(texts, "\n") +} + +// hashDirectory creates a project ID from a directory path. +// This is duplicated from session package to avoid circular imports. +func hashDirectory(directory string) string { + h := sha256.New() + h.Write([]byte(directory)) + return hex.EncodeToString(h.Sum(nil))[:16] +} diff --git a/go-opencode/internal/tool/task.go b/go-opencode/internal/tool/task.go index 0db6d23cb6c..dc68c58511c 100644 --- a/go-opencode/internal/tool/task.go +++ b/go-opencode/internal/tool/task.go @@ -4,21 +4,20 @@ import ( "context" "encoding/json" "fmt" + "strings" einotool "github.com/cloudwego/eino/components/tool" "github.com/opencode-ai/opencode/internal/agent" ) -const taskDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. +const taskDescriptionPrefix = `Launch a new agent to handle complex, multi-step tasks autonomously. The Task tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it. -Available agent types: -- general: General-purpose agent for researching and exploration -- explore: Fast agent specialized for codebase exploration -- plan: Planning agent for analysis without making changes +Available agent types:` +const taskDescriptionSuffix = ` Usage notes: - Launch multiple agents concurrently when possible - Each agent invocation is stateless @@ -80,8 +79,23 @@ func (t *TaskTool) SetExecutor(executor TaskExecutor) { t.executor = executor } -func (t *TaskTool) ID() string { return "task" } -func (t *TaskTool) Description() string { return taskDescription } +func (t *TaskTool) ID() string { return "task" } + +// Description returns a dynamic description including available agents. +func (t *TaskTool) Description() string { + var builder strings.Builder + builder.WriteString(taskDescriptionPrefix) + builder.WriteString("\n") + + // List available subagents with their descriptions + subagents := t.agentRegistry.ListSubagents() + for _, ag := range subagents { + builder.WriteString(fmt.Sprintf("- %s: %s\n", ag.Name, ag.Description)) + } + + builder.WriteString(taskDescriptionSuffix) + return builder.String() +} func (t *TaskTool) Parameters() json.RawMessage { return json.RawMessage(`{ @@ -134,9 +148,10 @@ func (t *TaskTool) Execute(ctx context.Context, input json.RawMessage, toolCtx * subagent, err := t.agentRegistry.Get(params.SubagentType) if err != nil { // Try with lowercase - subagent, err = t.agentRegistry.Get(params.SubagentType) + subagent, err = t.agentRegistry.Get(strings.ToLower(params.SubagentType)) if err != nil { - return nil, fmt.Errorf("unknown subagent type: %s. Available types: general, explore, plan", params.SubagentType) + availableAgents := t.GetAvailableAgents() + return nil, fmt.Errorf("unknown subagent type: %s. Available types: %s", params.SubagentType, strings.Join(availableAgents, ", ")) } }