From 3dc39d8f1a126b32166f70988ddde52c00bad05b Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Sat, 10 Jan 2026 17:09:43 -0600 Subject: [PATCH 1/3] feat(task): add subagent-to-subagent task delegation with configurable limits Enable nested task delegation between subagents with two-dimensional configuration: - task_budget (CALLER): max task calls per request (messageID) - callable_by_subagents (TARGET): whether agent can be called by subagents Key changes: - Add budget tracking per (sessionID, messageID) for per-request limits - Check caller's task_budget before allowing delegation - Check target's callable_by_subagents before allowing calls - Validate session ownership before resuming with session_id - Primary agents bypass all nested delegation controls - Conditionally enable/disable task tool based on target's task_budget Backwards compatible: missing config = delegation disabled (current behavior) Co-Authored-By: Claude Opus 4.5 --- packages/opencode/src/tool/task.ts | 129 +++++++-- .../opencode/test/task-delegation.test.ts | 272 ++++++++++++++++++ 2 files changed, 379 insertions(+), 22 deletions(-) create mode 100644 packages/opencode/test/task-delegation.test.ts diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 53b501ba91a..082ca35dae1 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -11,6 +11,30 @@ import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" import { PermissionNext } from "@/permission/next" +import { Instance } from "../project/instance" + +// Track task calls per request: Map> +// Budget is per-request (one "work assignment" within a session), resets on new messageID +// Note: State grows with sessions/messages but entries are small. Future optimization: +// clean up completed sessions via Session lifecycle hooks if memory becomes a concern. +const taskCallState = Instance.state(() => new Map>()) + +function getCallCount(sessionID: string, messageID: string): number { + const sessionCounts = taskCallState().get(sessionID) + return sessionCounts?.get(messageID) ?? 0 +} + +function incrementCallCount(sessionID: string, messageID: string): number { + const state = taskCallState() + let sessionCounts = state.get(sessionID) + if (!sessionCounts) { + sessionCounts = new Map() + state.set(sessionID, sessionCounts) + } + const newCount = (sessionCounts.get(messageID) ?? 0) + 1 + sessionCounts.set(messageID, newCount) + return newCount +} const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -54,33 +78,93 @@ export const TaskTool = Tool.define("task", async (ctx) => { }) } - const agent = await Agent.get(params.subagent_type) - if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + const targetAgent = await Agent.get(params.subagent_type) + if (!targetAgent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + + // Get caller's session to check if this is a subagent calling + const callerSession = await Session.get(ctx.sessionID) + const isSubagent = callerSession.parentID !== undefined + + // Get caller agent info for budget check (ctx.agent is just the name) + const callerAgentInfo = ctx.agent ? await Agent.get(ctx.agent) : undefined + + // Get config values: + // - task_budget on CALLER: how many calls the caller can make per request + // - callable_by_subagents on TARGET: whether target can be called by subagents + const callerTaskBudget = (callerAgentInfo?.options?.task_budget as number) ?? 0 + const targetCallable = (targetAgent.options?.callable_by_subagents as boolean) ?? false + + // Get target's task_budget once (used for session permissions and tool availability) + const targetTaskBudget = (targetAgent.options?.task_budget as number) ?? 0 + + // Check session ownership BEFORE incrementing budget (if session_id provided) + // This prevents "wasting" budget on invalid session resume attempts + if (isSubagent && params.session_id) { + const existingSession = await Session.get(params.session_id).catch(() => undefined) + if (existingSession && existingSession.parentID !== ctx.sessionID) { + throw new Error( + `Cannot resume session: not a child of caller session. ` + + `Session "${params.session_id}" is not owned by this caller.`, + ) + } + } + + // Enforce nested delegation controls only for subagent-to-subagent calls + if (isSubagent) { + // Check 1: Caller must have task_budget configured + if (callerTaskBudget <= 0) { + throw new Error( + `Caller has no task budget configured. ` + + `Set task_budget > 0 on the calling agent to enable nested delegation.`, + ) + } + + // Check 2: Target must be callable by subagents + if (!targetCallable) { + throw new Error( + `Target "${params.subagent_type}" is not callable by subagents. ` + + `Set callable_by_subagents: true on the target agent to enable.`, + ) + } + + // Check 3: Budget not exhausted for this request (messageID) + const currentCount = getCallCount(ctx.sessionID, ctx.messageID) + if (currentCount >= callerTaskBudget) { + throw new Error( + `Task budget exhausted (${currentCount}/${callerTaskBudget} calls). ` + + `Return control to caller to continue.`, + ) + } + + // Increment count after passing all checks (including ownership above) + incrementCallCount(ctx.sessionID, ctx.messageID) + } + const session = await iife(async () => { if (params.session_id) { const found = await Session.get(params.session_id).catch(() => {}) - if (found) return found + if (found) { + // Ownership already verified above for subagents + return found + } + } + + // Build session permissions + const sessionPermissions: PermissionNext.Rule[] = [ + { permission: "todowrite", pattern: "*", action: "deny" }, + { permission: "todoread", pattern: "*", action: "deny" }, + ] + + // Only deny task if target agent has no task_budget (cannot delegate further) + if (targetTaskBudget <= 0) { + sessionPermissions.push({ permission: "task", pattern: "*", action: "deny" }) } return await Session.create({ parentID: ctx.sessionID, - title: params.description + ` (@${agent.name} subagent)`, + title: params.description + ` (@${targetAgent.name} subagent)`, permission: [ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - { - permission: "todoread", - pattern: "*", - action: "deny", - }, - { - permission: "task", - pattern: "*", - action: "deny", - }, + ...sessionPermissions, ...(config.experimental?.primary_tools?.map((t) => ({ pattern: "*", action: "allow" as const, @@ -123,7 +207,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { }) }) - const model = agent.model ?? { + const model = targetAgent.model ?? { modelID: msg.info.modelID, providerID: msg.info.providerID, } @@ -142,11 +226,12 @@ export const TaskTool = Tool.define("task", async (ctx) => { modelID: model.modelID, providerID: model.providerID, }, - agent: agent.name, + agent: targetAgent.name, tools: { todowrite: false, todoread: false, - task: false, + // Only disable task if target agent has no task_budget (cannot delegate further) + ...(targetTaskBudget <= 0 ? { task: false } : {}), ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), }, parts: promptParts, diff --git a/packages/opencode/test/task-delegation.test.ts b/packages/opencode/test/task-delegation.test.ts new file mode 100644 index 00000000000..e0ccc470d85 --- /dev/null +++ b/packages/opencode/test/task-delegation.test.ts @@ -0,0 +1,272 @@ +import { describe, test, expect } from "bun:test" +import { Config } from "../src/config/config" +import { Instance } from "../src/project/instance" +import { Agent } from "../src/agent/agent" +import { PermissionNext } from "../src/permission/next" +import { tmpdir } from "./fixture/fixture" + +describe("task_budget configuration (caller)", () => { + test("task_budget is preserved in agent.options from config", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "principal-partner": { + description: "Orchestrator with high budget", + mode: "subagent", + task_budget: 20, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["principal-partner"] + expect(agentConfig?.options?.task_budget).toBe(20) + }, + }) + }) + + test("task_budget of 0 is preserved (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "disabled-agent": { + description: "Agent with explicitly disabled budget", + mode: "subagent", + task_budget: 0, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["disabled-agent"] + expect(agentConfig?.options?.task_budget).toBe(0) + }, + }) + }) + + test("missing task_budget defaults to undefined (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "default-agent": { + description: "Agent without task_budget", + mode: "subagent", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["default-agent"] + expect(agentConfig?.options?.task_budget).toBeUndefined() + }, + }) + }) +}) + +describe("callable_by_subagents configuration (target)", () => { + test("callable_by_subagents true is preserved", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "assistant-sonnet": { + description: "Callable assistant", + mode: "subagent", + callable_by_subagents: true, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["assistant-sonnet"] + expect(agentConfig?.options?.callable_by_subagents).toBe(true) + }, + }) + }) + + test("callable_by_subagents false is preserved (default)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "private-agent": { + description: "Not callable by subagents", + mode: "subagent", + callable_by_subagents: false, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["private-agent"] + expect(agentConfig?.options?.callable_by_subagents).toBe(false) + }, + }) + }) + + test("missing callable_by_subagents defaults to undefined (not callable)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "default-agent": { + description: "Agent without callable_by_subagents", + mode: "subagent", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["default-agent"] + expect(agentConfig?.options?.callable_by_subagents).toBeUndefined() + }, + }) + }) +}) + +describe("two-dimensional delegation config", () => { + test("full delegation config with both dimensions", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "principal-partner": { + description: "Orchestrates complex workflows", + mode: "subagent", + task_budget: 20, + callable_by_subagents: false, + permission: { + task: { + "*": "deny", + "assistant-sonnet": "allow", + "assistant-flash": "allow", + }, + }, + }, + "assistant-sonnet": { + description: "Thorough analysis", + mode: "subagent", + task_budget: 3, + callable_by_subagents: true, + permission: { + task: { + "*": "deny", + "assistant-flash": "allow", + }, + }, + }, + "assistant-flash": { + description: "Fast analytical passes", + mode: "subagent", + task_budget: 1, + callable_by_subagents: true, + permission: { + task: { + "*": "deny", + "assistant-sonnet": "allow", + }, + }, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + + // Principal-Partner: high budget, not callable + const partnerConfig = config.agent?.["principal-partner"] + expect(partnerConfig?.options?.task_budget).toBe(20) + expect(partnerConfig?.options?.callable_by_subagents).toBe(false) + + // Verify permission rules + const partnerRuleset = PermissionNext.fromConfig(partnerConfig?.permission ?? {}) + expect(PermissionNext.evaluate("task", "assistant-sonnet", partnerRuleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "assistant-flash", partnerRuleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "principal-partner", partnerRuleset).action).toBe("deny") + + // Assistant-Sonnet: lower budget, callable + const sonnetConfig = config.agent?.["assistant-sonnet"] + expect(sonnetConfig?.options?.task_budget).toBe(3) + expect(sonnetConfig?.options?.callable_by_subagents).toBe(true) + + // Assistant-Flash: lowest budget, callable + const flashConfig = config.agent?.["assistant-flash"] + expect(flashConfig?.options?.task_budget).toBe(1) + expect(flashConfig?.options?.callable_by_subagents).toBe(true) + }, + }) + }) +}) + +describe("backwards compatibility", () => { + test("agent without delegation config has defaults (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "legacy-agent": { + description: "Agent without delegation config", + mode: "subagent", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["legacy-agent"] + + // Both should be undefined/falsy = delegation disabled + const taskBudget = (agentConfig?.options?.task_budget as number) ?? 0 + const callable = (agentConfig?.options?.callable_by_subagents as boolean) ?? false + + expect(taskBudget).toBe(0) + expect(callable).toBe(false) + }, + }) + }) + + test("built-in agents should not have delegation config by default", async () => { + await using tmp = await tmpdir({ + git: true, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Get the built-in general agent + const generalAgent = await Agent.get("general") + + // Built-in agents should not have delegation configured + const taskBudget = (generalAgent?.options?.task_budget as number) ?? 0 + const callable = (generalAgent?.options?.callable_by_subagents as boolean) ?? false + + expect(taskBudget).toBe(0) + expect(callable).toBe(false) + }, + }) + }) +}) From 19e1231e1c95800719499f1417434f64d6ce2081 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Sat, 10 Jan 2026 17:12:01 -0600 Subject: [PATCH 2/3] fix(task): use strict equality for callable_by_subagents check Use === true instead of truthy coercion to prevent accidental enablement from misconfigured values like "yes" or 1. Co-Authored-By: Claude Opus 4.5 --- packages/opencode/src/tool/task.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 082ca35dae1..de1c4cc0ee5 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -92,7 +92,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { // - task_budget on CALLER: how many calls the caller can make per request // - callable_by_subagents on TARGET: whether target can be called by subagents const callerTaskBudget = (callerAgentInfo?.options?.task_budget as number) ?? 0 - const targetCallable = (targetAgent.options?.callable_by_subagents as boolean) ?? false + const targetCallable = targetAgent.options?.callable_by_subagents === true // Get target's task_budget once (used for session permissions and tool availability) const targetTaskBudget = (targetAgent.options?.task_budget as number) ?? 0 From a89d3321d22bdcc2934783c325dfb03b9bffa9bc Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Sat, 10 Jan 2026 21:10:48 -0600 Subject: [PATCH 3/3] fix(task): change budget scope from per-message to per-session The task_budget was incorrectly keyed by (sessionID, messageID), causing the budget counter to reset every turn since each assistant response generates a new messageID. Changed to per-session tracking so all task calls within a delegated session count toward the same budget. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 113 +++++++++++++++++++++++++++++ packages/opencode/src/tool/task.ts | 30 +++----- 2 files changed, 125 insertions(+), 18 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..603941e75b6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +OpenCode is an open-source AI-powered coding agent, similar to Claude Code but provider-agnostic. It supports multiple LLM providers (Anthropic, OpenAI, Google, Azure, local models) and features a TUI built with SolidJS, LSP support, and client/server architecture. + +## Development Commands + +```bash +# Install and run development server +bun install +bun dev # Run in packages/opencode directory +bun dev # Run against a specific directory +bun dev . # Run against repo root + +# Type checking +bun run typecheck # Single package +bun turbo typecheck # All packages + +# Testing (per-package, not from root) +cd packages/opencode && bun test + +# Build standalone executable +./packages/opencode/script/build.ts --single +# Output: ./packages/opencode/dist/opencode-/bin/opencode + +# Regenerate SDK after API changes +./script/generate.ts +# Or for JS SDK specifically: +./packages/sdk/js/script/build.ts + +# Web app development +bun run --cwd packages/app dev # http://localhost:5173 + +# Desktop app (requires Tauri/Rust) +bun run --cwd packages/desktop tauri dev # Native + web server +bun run --cwd packages/desktop dev # Web only (port 1420) +bun run --cwd packages/desktop tauri build # Production build +``` + +## Architecture + +**Monorepo Structure** (Bun workspaces + Turbo): + +| Package | Purpose | +|---------|---------| +| `packages/opencode` | Core CLI, server, business logic | +| `packages/app` | Shared web UI components (SolidJS + Vite) | +| `packages/desktop` | Native desktop app (Tauri wrapper) | +| `packages/ui` | Shared component library (Kobalte + Tailwind) | +| `packages/console/app` | Console dashboard (Solid Start) | +| `packages/console/core` | Backend services (Hono + DrizzleORM) | +| `packages/sdk/js` | JavaScript SDK | +| `packages/plugin` | Plugin system API | + +**Key Directories in `packages/opencode/src`**: +- `cli/cmd/tui/` - Terminal UI (SolidJS + opentui) +- `agent/` - Agent logic and state +- `provider/` - AI provider implementations +- `server/` - Server mode +- `mcp/` - Model Context Protocol integration +- `lsp/` - Language Server Protocol support + +**Default branch**: `dev` + +## Code Style + +- Keep logic in single functions unless reusable +- Avoid destructuring: use `obj.a` instead of `const { a } = obj` +- Avoid `try/catch` - prefer `.catch()` +- Avoid `else` statements +- Avoid `any` type +- Avoid `let` - use immutable patterns +- Prefer single-word variable names when descriptive +- Use Bun APIs (e.g., `Bun.file()`) when applicable + +## Built-in Agents + +- **build** - Default agent with full access for development +- **plan** - Read-only agent for analysis (denies edits, asks before bash) +- **general** - Subagent for complex tasks, invoked with `@general` + +Switch agents with `Tab` key in TUI. + +## Debugging + +```bash +# Debug with inspector +bun run --inspect=ws://localhost:6499/ dev + +# Debug server separately +bun run --inspect=ws://localhost:6499/ ./src/index.ts serve --port 4096 +opencode attach http://localhost:4096 + +# Debug TUI +bun run --inspect=ws://localhost:6499/ --conditions=browser ./src/index.ts + +# Use spawn for breakpoints in server code +bun dev spawn +``` + +Use `--inspect-wait` or `--inspect-brk` for different breakpoint behaviors. + +## PR Guidelines + +- All PRs must reference an existing issue (`Fixes #123`) +- UI/core feature changes require design review with core team +- PR titles follow conventional commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:` +- Optional scope: `feat(app):`, `fix(desktop):` +- Include screenshots/videos for UI changes +- Explain verification steps for logic changes diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index de1c4cc0ee5..d748accf6f0 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -13,26 +13,20 @@ import { Config } from "../config/config" import { PermissionNext } from "@/permission/next" import { Instance } from "../project/instance" -// Track task calls per request: Map> -// Budget is per-request (one "work assignment" within a session), resets on new messageID -// Note: State grows with sessions/messages but entries are small. Future optimization: +// Track task calls per session: Map +// Budget is per-session (all calls within the delegated work count toward the limit) +// Note: State grows with sessions but entries are small. Future optimization: // clean up completed sessions via Session lifecycle hooks if memory becomes a concern. -const taskCallState = Instance.state(() => new Map>()) +const taskCallState = Instance.state(() => new Map()) -function getCallCount(sessionID: string, messageID: string): number { - const sessionCounts = taskCallState().get(sessionID) - return sessionCounts?.get(messageID) ?? 0 +function getCallCount(sessionID: string): number { + return taskCallState().get(sessionID) ?? 0 } -function incrementCallCount(sessionID: string, messageID: string): number { +function incrementCallCount(sessionID: string): number { const state = taskCallState() - let sessionCounts = state.get(sessionID) - if (!sessionCounts) { - sessionCounts = new Map() - state.set(sessionID, sessionCounts) - } - const newCount = (sessionCounts.get(messageID) ?? 0) + 1 - sessionCounts.set(messageID, newCount) + const newCount = (state.get(sessionID) ?? 0) + 1 + state.set(sessionID, newCount) return newCount } @@ -127,8 +121,8 @@ export const TaskTool = Tool.define("task", async (ctx) => { ) } - // Check 3: Budget not exhausted for this request (messageID) - const currentCount = getCallCount(ctx.sessionID, ctx.messageID) + // Check 3: Budget not exhausted for this session + const currentCount = getCallCount(ctx.sessionID) if (currentCount >= callerTaskBudget) { throw new Error( `Task budget exhausted (${currentCount}/${callerTaskBudget} calls). ` + @@ -137,7 +131,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { } // Increment count after passing all checks (including ownership above) - incrementCallCount(ctx.sessionID, ctx.messageID) + incrementCallCount(ctx.sessionID) } const session = await iife(async () => {