From f8d426980b5512360185b125b40960ab6c7d2f98 Mon Sep 17 00:00:00 2001
From: jbreite
Date: Sun, 14 Jun 2026 22:22:59 -0400
Subject: [PATCH 01/20] docs: Add subagent foundation design plan
---
...06-15-001-feat-subagent-foundation-plan.md | 926 ++++++++++++++++++
1 file changed, 926 insertions(+)
create mode 100644 docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
diff --git a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
new file mode 100644
index 0000000..4601575
--- /dev/null
+++ b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
@@ -0,0 +1,926 @@
+---
+title: "feat: Make Codemode and subagents the default agent model"
+type: "feat"
+date: "2026-06-15"
+---
+
+# feat: Make Codemode and subagents the default agent model
+
+## Summary
+
+This plan makes BashKit a Codemode-first, subagent-first agent framework. The breaking release replaces the current direct-tool default and blocking `Task` delegation pattern with a reusable subagent control plane, profile-scoped Codemode execution, typed lifecycle events, shared cost controls, and host-facing control panel state.
+
+The design borrows Codex's core shape: tools are thin adapters, orchestration lives in a controller, agent identities are stable and addressable, role/profile resolution is configuration-driven, and parent agents supervise child agents through explicit control tools.
+
+---
+
+## Problem Frame
+
+BashKit already has the ingredients for an agentic coding toolkit: sandbox tools, context layers, budget tracking, skills, Codemode integration, and a `Task` tool that can run a child model call. The current default shape still centers direct tool exposure. A parent model receives individual tools such as `Bash`, `Read`, `Write`, and `Edit`, and `Task` behaves like a one-shot nested generation that returns text.
+
+That model is too small for dynamic workflows and serious subagent orchestration. It lacks:
+
+- Stable child identity that survives beyond one tool call.
+- A registry for listing, addressing, and supervising agents.
+- Non-blocking spawn/wait/message/interrupt controls.
+- Profile configuration that changes model, tools, Codemode access, budget, context, and callbacks together.
+- A cost policy that understands parent Codemode, child Codemode, nested generations, concurrency, and spawn depth as one shared budget domain.
+- A host-facing state projection for control panels.
+- A disciplined way to keep full child transcripts out of the parent model context.
+
+Codex has already solved the architectural split. Its multi-agent tools do not own orchestration. `AgentControl`, `AgentRegistry`, role resolution, path-like names, execution limits, lifecycle notifications, and status subscriptions form the actual control plane. BashKit should implement that idea in TypeScript and AI SDK terms, without importing Codex's Rust runtime, TUI assumptions, rollout storage, or cloud task APIs.
+
+---
+
+## Goals
+
+- Make Codemode the default model-facing coding harness.
+- Make first-class subagent controls the default delegation interface.
+- Preserve direct BashKit tools as inner providers and explicit compatibility mode, not the normal public surface.
+- Build a reusable `src/subagents/` architecture that library consumers can use directly without model-visible tools.
+- Keep subagent execution safe through profile quarantine, shared cost controls, depth limits, concurrency limits, and bounded context inheritance.
+- Expose typed state and events so hosts can build control panels without depending on internal controller objects.
+- Provide a breaking migration story from `Task` and direct tool exposure.
+
+## Non-Goals
+
+- Do not add subagent concepts to the `Sandbox` interface.
+- Do not ship a bundled React/Vue/Svelte control panel.
+- Do not implement durable resume of active JavaScript execution in this milestone.
+- Do not build a full dynamic workflow authoring system in this milestone.
+- Do not require BashKit to own Codex-style terminal UI, rollout storage, or thread manager behavior.
+
+---
+
+## Requirements
+
+**Breaking API Direction**
+
+- R1. Make Codemode the default way models perform coding work through BashKit when a Codemode executor is configured.
+- R2. Make the breaking change explicit in versioning, migration docs, and release notes.
+- R3. Replace `Task` as the primary subagent API with first-class subagent control tools and controller APIs.
+- R4. Keep tool failures model-visible as `{ error: string }` results, except for construction-time configuration errors.
+- R5. Use nullable tool input fields for optional parameters so OpenAI structured outputs remain compatible.
+- R6. Keep direct tool exposure available only as explicit compatibility or advanced configuration.
+
+**Subagent Foundation**
+
+- R7. Add stable subagent identity with generated IDs, optional path-like task names, profile type, status, parent/child relationship, and last task message.
+- R8. Introduce a profile registry that resolves built-in, inline, and file-loaded profiles into model, prompt, tools, Codemode policy, context policy, cost policy, stop conditions, callbacks, and metadata.
+- R9. Centralize spawn-time runtime inheritance so children receive the intended model, wrapped tools, budget tracker, context layers, prepare-step behavior, and Codemode harness.
+- R10. Provide non-blocking tools for spawning, listing, waiting, messaging, follow-up tasks, and interrupting subagents.
+- R11. Maintain UI/event visibility for subagent activity without forcing full child transcripts into parent model context.
+- R12. Enforce cost controls across parent Codemode, child runs, nested Codemode calls, and parallel child executions.
+- R13. Use Codemode as the default orchestration tool inside subagents, with profile-level quarantine applied to Codemode inner providers.
+- R14. Provide a parent-agent control surface that exposes live state, supervision actions, budget usage, warnings, and concise activity history to host UIs.
+- R15. Support lifecycle hooks for subagent start, stop, completion, failure, interruption, message delivery, and follow-up delivery.
+- R16. Support controlled context inheritance so spawn can receive no parent context, full parent context, or a bounded recent-turn window.
+- R17. Document the new architecture and update examples around the Codemode-first, subagent-first API.
+
+**Workflow Readiness**
+
+- R18. Make the controller usable by future workflow harnesses that fan out work, synthesize results, adversarially verify outputs, loop until done, or route models.
+- R19. Provide persistence boundaries for subagent metadata and terminal results, while deferring active-process resume unless the host supplies a durable runner.
+- R20. Support quarantine-style tool scoping so low-trust agents can be restricted from high-privilege write, shell, and subagent-control tools.
+- R21. Keep the implementation fully typed with no new `any` in public or internal subagent contracts.
+- R22. Add explicit completion semantics so child agents terminate by status transition or stop condition rather than heuristic parent-side guessing.
+- R23. Keep every host-visible state snapshot serializable as plain JSON.
+- R24. Preserve context-layer and cache behavior by inheriting already-wrapped tools unless a profile narrows them.
+
+---
+
+## Design Principles
+
+- **Unified orchestrator, varied profiles:** All subagents run through one controller and runner contract. Profiles vary the model, prompt, allowed tools, Codemode policy, context policy, budget cap, and callbacks.
+- **Tools are primitives, not workflows:** `SpawnAgent`, `WaitAgent`, `SendMessage`, and `InterruptAgent` are atomic controls. Fan-out, tournament, verification, and loop-until-done patterns are composed by prompts or host workflow code.
+- **Agent and UI share state:** The controller emits events and snapshots that host UIs can observe. The UI should not scrape model text to understand agent state.
+- **Context is a budgeted resource:** Parent history should not silently flood every child. Spawn calls make context inheritance explicit.
+- **Quarantine is structural:** A restricted profile must not regain blocked tools through Codemode's inner providers, direct tool exposure, recursive subagents, or extra tools.
+- **Completion is explicit:** The controller records terminal states and completion events. Parent agents should not infer completion from absence of tool calls.
+- **Library boundaries stay clean:** Sandboxes execute commands and file IO. Subagents orchestrate model/tool execution. Host apps render UI.
+
+---
+
+## Key Technical Decisions
+
+- KTD1. Split subagent core from tool adapters: create `src/subagents/` for profiles, identity, registry, controller, stores, events, state projections, policies, and runner contracts. Tool files call into this module.
+- KTD2. Make Codemode the default public harness: `createAgentTools` exposes a Codemode tool by default when a Codemode executor is supplied. Direct tools are inner providers and optional compatibility exposure.
+- KTD3. Make subagent controls the default delegation API: expose `SpawnAgent`, `ListAgents`, `SendMessage`, `FollowupTask`, `WaitAgent`, and `InterruptAgent` for parent model coordination.
+- KTD4. Treat `Task` as legacy: remove it from default examples and either remove it from the default factory or retain it as a deprecated one-shot adapter over the controller.
+- KTD5. Put Codemode inside subagents by default: children receive a profile-scoped Codemode tool that can orchestrate only the tools allowed by the profile.
+- KTD6. Apply quarantine at two layers: profile allowlists filter direct tools and Codemode inner providers. A restricted profile cannot regain `Write`, `Edit`, `Patch`, `Bash`, `SpawnAgent`, or `FollowupTask` through generated code.
+- KTD7. Model profiles as runtime configuration, not prompt strings: profiles resolve to model, prompt, tool policy, Codemode policy, context policy, cost policy, stop conditions, prepare-step, callbacks, and host metadata.
+- KTD8. Use in-memory orchestration first with host-extensible persistence: the default store tracks live promises and terminal records in memory. A `SubagentStore` interface lets hosts persist metadata and results.
+- KTD9. Treat messaging as mailbox semantics: `SendMessage` queues input, `FollowupTask` queues input and requests a turn, and `WaitAgent` observes status/mailbox changes.
+- KTD10. Inherit wrapped tools by default: child agents receive the same cache/context-wrapped tools as the parent unless narrowed. This preserves execution policy, output policy, and budget behavior.
+- KTD11. Keep child transcripts out of parent context: parent-visible results include terminal summary, status, usage, and references. Full transcripts are available only through host callbacks, event sinks, or explicit transcript APIs.
+- KTD12. Treat the control panel as a host projection: BashKit emits typed state snapshots and events, not a UI framework component.
+- KTD13. Make cost control shared and preemptive: spawns and follow-ups are rejected before model calls when budget, depth, active count, total count, or timeout policy is exhausted.
+- KTD14. Use path-like agent references: task names form stable paths addressable relative to the current parent, while generated IDs remain canonical for storage.
+- KTD15. Add lifecycle hooks as extension points: hosts can observe, audit, enrich, or block lifecycle transitions without changing tool implementations.
+- KTD16. Keep profile-visible descriptions generated from config: parent agents can route to the right profile because model-facing descriptions include profile purpose, allowed capabilities, locked model/cost settings, and quarantine constraints.
+- KTD17. Do not require durable active-run resume in the default runner: the store persists terminal metadata and results, but active process recovery belongs to host-supplied durable runners.
+
+---
+
+## Codex Patterns to Borrow
+
+| Codex pattern | BashKit adaptation | Why it matters |
+| --- | --- | --- |
+| Agent control plane | `SubagentController`, `SubagentRegistry`, `SubagentRunner`, `SubagentStore` | Keeps tools thin and makes the model-visible API stable. |
+| Path-based identity | Optional `task_name` paths plus canonical generated IDs | Lets agents coordinate with readable names such as `research/auth`. |
+| Role/config layering | `SubagentProfileRegistry` with layered defaults and overrides | Makes profile selection safer than ad hoc system prompts. |
+| `fork_turns` context control | `none`, `all`, or bounded recent-turn context | Makes context cost explicit during fan-out. |
+| Lifecycle hooks | `onStart`, `onStop`, `onComplete`, `onInterrupt`, `onMessage` | Enables audit, UI, policy, and telemetry without embedding UI concerns. |
+| Completion notifications | Event sink and control panel updates independent of `WaitAgent` | Keeps supervision state current even when parent is doing other work. |
+| Execution limits | Max active agents, total agents, depth, and per-profile caps | Prevents runaway recursion and local resource overload. |
+| Role-visible descriptions | Generated profile summaries in tool descriptions and config helpers | Helps parent models route work without guessing. |
+
+Do not copy Codex's Rust thread manager, TUI implementation, cloud task APIs, or rollout storage. BashKit should borrow the contracts and invariants, not the product shell.
+
+---
+
+## Architecture Overview
+
+```mermaid
+flowchart TB
+ Host["Host app"] --> Factory["createAgentTools"]
+ Factory --> ParentSurface["Parent model tool surface"]
+ ParentSurface --> ParentCodemode["Codemode (default coding tool)"]
+ ParentSurface --> ControlTools["Subagent control tools"]
+
+ ParentCodemode --> ParentProviders["Filtered parent inner providers"]
+ ControlTools --> Controller["SubagentController"]
+
+ Controller --> Registry["SubagentRegistry"]
+ Controller --> ProfileRegistry["SubagentProfileRegistry"]
+ Controller --> Store["SubagentStore"]
+ Controller --> Cost["SubagentCostController"]
+ Controller --> Events["SubagentEventSink"]
+ Controller --> Runner["SubagentRunner"]
+
+ Runner --> AISDK["AI SDK generateText / streamText"]
+ Runner --> ChildSurface["Child model tool surface"]
+ ChildSurface --> ChildCodemode["Profile-scoped Codemode"]
+ ChildSurface --> ChildDirect["Optional filtered direct tools"]
+ ChildCodemode --> ChildProviders["Profile-filtered inner providers"]
+
+ Events --> Projection["Control panel projection"]
+ Store --> Projection
+ Cost --> Projection
+ Projection --> UI["Host UI / logs / telemetry"]
+```
+
+The parent receives Codemode as the normal way to perform coding work and subagent controls as the normal way to delegate. `createAgentTools` creates a controller when `subagents` config is present. Tool adapters validate nullable schemas, call the controller, and return compact structured results. The controller resolves profiles, reserves identities, checks cost/execution policy, starts runner work, records status, and emits events.
+
+---
+
+## Runtime Flow
+
+### Spawn and Completion Sequence
+
+```mermaid
+sequenceDiagram
+ participant P as Parent model
+ participant T as SpawnAgent tool
+ participant C as SubagentController
+ participant R as Registry
+ participant PR as ProfileRegistry
+ participant B as CostController
+ participant Runner as SubagentRunner
+ participant E as EventSink
+ participant S as Store
+
+ P->>T: SpawnAgent(task, profile, context_policy)
+ T->>C: spawn(request)
+ C->>PR: resolve(profile, overrides)
+ PR-->>C: resolved profile
+ C->>B: reserveSpawn(profile, parent)
+ B-->>C: allowed or error
+ C->>R: reserve identity and path
+ R-->>C: handle
+ C->>S: write pending metadata
+ C->>E: subagent.started
+ C->>Runner: run(resolved request)
+ C-->>T: handle
+ T-->>P: agent_id, task_name, status
+ Runner->>S: running status
+ Runner->>E: tool events and status events
+ Runner->>S: terminal result
+ Runner->>B: record usage
+ Runner->>E: subagent.completed or subagent.failed
+```
+
+### Wait and Message Sequence
+
+```mermaid
+sequenceDiagram
+ participant P as Parent model
+ participant W as WaitAgent tool
+ participant M as SendMessage / FollowupTask
+ participant C as SubagentController
+ participant S as Store
+ participant Runner as Runner
+ participant E as EventSink
+
+ P->>W: WaitAgent(agent_id, timeout_ms)
+ W->>C: wait(handle, timeout)
+ C->>S: subscribe to status changes
+ S-->>C: terminal result or timeout
+ C-->>W: result, timeout, or error
+ W-->>P: compact structured output
+
+ P->>M: FollowupTask(agent_id, message)
+ M->>C: enqueueMessage(handle, triggerTurn=true)
+ C->>S: append mailbox item
+ C->>E: message.queued
+ C->>Runner: request turn if supported
+ C-->>M: queued
+```
+
+---
+
+## State Model
+
+### Subagent Status State Machine
+
+```mermaid
+stateDiagram-v2
+ [*] --> pending: spawn accepted
+ pending --> running: runner starts
+ pending --> failed: setup error
+ pending --> interrupted: interrupt before start
+ running --> waiting: mailbox empty or paused by runner
+ waiting --> running: follow-up task triggers turn
+ running --> completed: stop condition met
+ running --> failed: model/tool/runner error
+ running --> interrupted: interrupt accepted
+ waiting --> interrupted: interrupt accepted
+ completed --> [*]
+ failed --> [*]
+ interrupted --> [*]
+```
+
+Terminal states are `completed`, `failed`, and `interrupted`. `waiting` is non-terminal and means the agent exists, has context/state, and can receive follow-up work if the runner supports it. If the default AI SDK runner cannot maintain a paused live conversation safely, it may treat every run as terminal after one task and return an unsupported error for follow-up turns. The controller contract should still model `waiting` so durable runners and future workflow harnesses have the right shape.
+
+### Identity Model
+
+Each subagent has two identifiers:
+
+- `agent_id`: generated, unique, stable, and canonical for storage.
+- `task_name`: optional model-facing path such as `research/auth`, `verify/security`, or `fix/tests`.
+
+Path resolution should support:
+
+- Exact current-tree lookup by `task_name`.
+- Relative child lookup from the current parent.
+- Sibling lookup when the caller is a child and names a sibling.
+- Generated ID lookup as the unambiguous fallback.
+
+Duplicate live paths under the same root should return `{ error }`. Terminal paths may be reusable only if the controller policy allows it. The default should reject duplicate live paths and allow explicit reuse only through config.
+
+---
+
+## Public API Shape
+
+The following sketches are directional contracts, not final implementation code.
+
+### Factory Configuration
+
+```ts
+export interface AgentConfig {
+ codemode?: CodemodeConfig;
+ subagents?: SubagentConfig;
+ exposeDirectTools?: boolean | DirectToolExposureConfig;
+ budget?: BudgetConfig;
+ context?: ContextConfig;
+}
+
+export interface SubagentConfig {
+ model?: LanguageModel;
+ profiles?: SubagentProfileInput[];
+ defaultProfile?: string;
+ codemode?: SubagentCodemodeConfig;
+ store?: SubagentStore;
+ eventSink?: SubagentEventSink;
+ cost?: SubagentCostPolicyInput;
+ contextInheritance?: SubagentContextPolicyInput;
+ lifecycle?: SubagentLifecycleHooks;
+ exposeControlTools?: boolean;
+ legacyTask?: false | "deprecated-adapter";
+}
+```
+
+Default behavior:
+
+- If `codemode` is configured, expose Codemode as the primary coding tool.
+- If `subagents` is configured, expose subagent control tools.
+- Direct tools are hidden from the parent unless `exposeDirectTools` is configured.
+- Direct tools remain available as Codemode inner providers after safety filtering.
+- Child Codemode receives profile-filtered inner providers.
+
+### Core Controller
+
+```ts
+export interface SubagentController {
+ spawn(request: SubagentSpawnRequest): Promise;
+ list(filter?: SubagentListFilter): Promise;
+ wait(request: SubagentWaitRequest): Promise;
+ sendMessage(request: SubagentMessageRequest): Promise;
+ followupTask(request: SubagentFollowupRequest): Promise;
+ interrupt(request: SubagentInterruptRequest): Promise;
+ snapshot(): Promise;
+}
+```
+
+The controller owns identity, status, policy checks, hooks, events, and persistence writes. Runners execute resolved work. Stores persist metadata and terminal outputs. Tool adapters should contain no orchestration logic beyond schema defaults and result formatting.
+
+### Runner Contract
+
+```ts
+export interface SubagentRunner {
+ capabilities: SubagentRunnerCapabilities;
+ run(request: ResolvedSubagentRunRequest): Promise;
+ interrupt?(handle: SubagentHandle): Promise;
+ requestTurn?(handle: SubagentHandle): Promise;
+}
+```
+
+The default runner should use AI SDK `generateText` or `streamText` and be easy to fake in tests. A future durable runner can implement `requestTurn`, checkpointing, and active-run resume without changing tool schemas.
+
+### Store Contract
+
+```ts
+export interface SubagentStore {
+ create(metadata: SubagentMetadata): Promise;
+ update(handle: SubagentHandle, patch: SubagentMetadataPatch): Promise;
+ appendEvent(event: SubagentEvent): Promise;
+ appendMessage(handle: SubagentHandle, message: SubagentMailboxMessage): Promise;
+ get(handle: SubagentHandle): Promise;
+ list(filter?: SubagentListFilter): Promise;
+}
+```
+
+The default `InMemorySubagentStore` keeps live records and terminal results. Host stores can persist metadata, compact terminal results, event references, and transcript references. Stores should not need to persist model instances or executable functions.
+
+---
+
+## Tool Surface
+
+### Parent Model Tools
+
+| Tool | Purpose | Blocking behavior | Returns |
+| --- | --- | --- | --- |
+| `Codemode` | Default coding harness | Runs until Codemode completes | Codemode result or `{ error }` |
+| `SpawnAgent` | Start a child agent | Non-blocking after reservation | Handle, path, status, profile metadata |
+| `ListAgents` | Inspect active and terminal agents | Immediate | Compact records and supported actions |
+| `WaitAgent` | Await status/result changes | Bounded by timeout policy | Terminal result, status update, or timeout |
+| `SendMessage` | Queue information for an agent | Immediate | Queued result |
+| `FollowupTask` | Queue work and request a turn | Immediate unless runner rejects | Queued/triggered result |
+| `InterruptAgent` | Request cancellation | Immediate best effort | Previous status and new status |
+
+### Tool Schema Rules
+
+- Optional inputs use `.nullable()`, not `.optional()`.
+- Tool outputs return structured objects, not prose-only strings.
+- Errors return `{ error: string }` plus optional typed metadata where useful.
+- `WaitAgent` timeouts must be bounded by config.
+- `SpawnAgent` must reject empty task prompts.
+- `FollowupTask` must reject self-targeting that could deadlock the current agent.
+- Tool descriptions must warn against unnecessary fan-out and recursive spawning.
+
+### Proposed Tool Inputs
+
+| Tool | Key inputs |
+| --- | --- |
+| `SpawnAgent` | `task`, `profile`, `task_name`, `context`, `tools`, `model`, `budget`, `metadata` |
+| `ListAgents` | `status`, `path_prefix`, `include_terminal`, `limit` |
+| `WaitAgent` | `agent`, `timeout_ms`, `until_status` |
+| `SendMessage` | `agent`, `message`, `metadata` |
+| `FollowupTask` | `agent`, `task`, `context`, `metadata` |
+| `InterruptAgent` | `agent`, `reason` |
+
+The final implementation can add fields, but the first version should avoid fields that imply durable active-run resume unless the default runner supports them.
+
+---
+
+## Profile System
+
+Profiles are the safety and specialization boundary. They should be layered in this order:
+
+1. Built-in BashKit defaults.
+2. Host-level subagent defaults.
+3. Named profile config.
+4. Spawn-time overrides allowed by policy.
+
+### Profile Shape
+
+```ts
+export interface SubagentProfile {
+ name: string;
+ description: string;
+ nickname?: string;
+ model?: LanguageModel;
+ system?: string;
+ allowedTools?: string[];
+ deniedTools?: string[];
+ codemode?: SubagentCodemodePolicy;
+ context?: SubagentContextPolicy;
+ cost?: SubagentCostPolicyInput;
+ stopWhen?: StopCondition[];
+ prepareStep?: PrepareStepCallback;
+ lifecycle?: SubagentLifecycleHooks;
+ metadata?: JsonObject;
+}
+```
+
+### Built-In Profiles
+
+| Profile | Intended use | Default tools | Notes |
+| --- | --- | --- | --- |
+| `worker` | General implementation or investigation | Codemode with normal safe providers | Default child profile. |
+| `researcher` | Read-only exploration and synthesis | Read/search/list tools, no writes or shell by default | Good for fan-out and adversarial review. |
+| `reviewer` | Critique, verification, test design | Read/search/list tools, optional test command through guarded Codemode | Should be isolated from write tools by default. |
+| `fixer` | Focused code changes | Codemode with write/patch/test tools | Should usually have tighter budget and depth caps. |
+| `summarizer` | Compact transcripts and terminal results | Read-only tools or no tools | Useful for keeping parent context small. |
+
+Built-ins should be conservative. Hosts can add broader profiles for trusted environments.
+
+### Quarantine Rules
+
+```mermaid
+flowchart TB
+ Profile["Resolved profile"] --> Allowlist["Allowed tool names"]
+ Profile --> Denylist["Denied tool names"]
+ ParentTools["Parent wrapped tool set"] --> Filter["Tool filter"]
+ Allowlist --> Filter
+ Denylist --> Filter
+ Filter --> Direct["Optional direct child tools"]
+ Filter --> Providers["Codemode inner providers"]
+ Providers --> ChildCodemode["Child Codemode"]
+ Direct --> ChildSurface["Child tool surface"]
+ ChildCodemode --> ChildSurface
+```
+
+Quarantine must be applied after context/cache/budget wrapping and before child exposure. This prevents a restricted child from using unwrapped tools or wider Codemode providers.
+
+Default denied tools for low-trust profiles should include `Write`, `Edit`, `Patch`, `Bash`, `SpawnAgent`, `FollowupTask`, and any tool that can mutate external state. Hosts can opt into wider access when they understand the risk.
+
+---
+
+## Context Inheritance
+
+Context inheritance is a first-class spawn policy:
+
+| Policy | Behavior | Use case |
+| --- | --- | --- |
+| `none` | Child receives only task, profile prompt, static system context, and injected metadata | Cheap, isolated research or verification. |
+| `{ recent_turns: n }` | Child receives the last `n` parent turns plus task | Default for most delegated work. |
+| `all` | Child receives all eligible parent context | Rare, expensive, useful when precise continuity matters. |
+
+The controller should also support `context_summary` as host-supplied compact context. It should not summarize implicitly in the first milestone unless an explicit summarizer profile or callback is configured.
+
+Context inheritance should exclude:
+
+- Full child transcripts from sibling agents.
+- Host-only secrets.
+- Tool outputs that a context layer already redacted.
+- Internal budget telemetry unless the profile asks for it.
+
+---
+
+## Cost and Execution Control
+
+### Cost Domains
+
+```mermaid
+flowchart TB
+ RootBudget["Root BudgetTracker"] --> ParentTurn["Parent model turns"]
+ RootBudget --> ParentCodemode["Parent Codemode"]
+ RootBudget --> ChildA["Child agent A"]
+ RootBudget --> ChildB["Child agent B"]
+ ChildA --> ChildACodemode["Child A Codemode"]
+ ChildB --> ChildBCodemode["Child B Codemode"]
+ RootBudget --> Warnings["Budget warnings"]
+ Warnings --> ControlPanel["Control panel state"]
+ Warnings --> SpawnPolicy["Pre-spawn policy checks"]
+```
+
+All model usage should report into the same root budget tracker by default. Per-profile caps can stop a child early without terminating the whole root run unless the root budget is exhausted.
+
+### Guardrail Policy
+
+| Limit | Default posture | Failure mode |
+| --- | --- | --- |
+| Max active agents | Conservative configurable default | `SpawnAgent` returns `{ error }`. |
+| Max total agents per root run | Configurable | Spawn rejected once exhausted. |
+| Max depth | Default 1 or 2 | Nested spawn rejected. |
+| Per-agent budget cap | Optional profile cap | Child stops with budget status. |
+| Root budget cap | Existing budget config | Parent and children stop using same tracker. |
+| Wait timeout bounds | Required min/max | `WaitAgent` clamps or rejects. |
+| Max mailbox length | Configurable | Messages rejected before unbounded growth. |
+
+Cost policy should run before expensive work. If budget is already exhausted, `SpawnAgent` should fail before reserving a runner. If active count is exhausted, the controller should avoid starting work and should release any tentative reservation.
+
+---
+
+## Lifecycle Hooks and Events
+
+### Hook Contract
+
+Lifecycle hooks let host apps observe or shape behavior without forking tools:
+
+- `onBeforeSpawn`: inspect request, inject metadata, or return `{ error }`.
+- `onStart`: observe accepted handle and resolved profile.
+- `onMessage`: observe queued messages and follow-up tasks.
+- `onToolEvent`: observe child tool activity.
+- `onComplete`: observe terminal successful result.
+- `onFail`: observe terminal failure.
+- `onInterrupt`: observe interrupt requests and results.
+- `onStop`: observe all terminal transitions.
+
+Hooks should be typed, async, and isolated from tool adapter code. Hook failures should be policy-driven:
+
+- Blocking hooks such as `onBeforeSpawn` may return `{ error }`.
+- Observability hooks should emit errors to debug/event channels without changing child status unless configured.
+
+### Event Types
+
+| Event | Emitted when | Host use |
+| --- | --- | --- |
+| `subagent.created` | Identity reserved | Add row to control panel. |
+| `subagent.started` | Runner begins | Mark active. |
+| `subagent.status_changed` | Status changes | Update UI and subscriptions. |
+| `subagent.message_queued` | Message/follow-up queued | Show pending work. |
+| `subagent.tool_call` | Child tool starts | Activity timeline. |
+| `subagent.tool_result` | Child tool completes | Activity timeline and debugging. |
+| `subagent.usage` | Usage reported | Budget panel. |
+| `subagent.completed` | Successful terminal state | Show result reference. |
+| `subagent.failed` | Error terminal state | Show error. |
+| `subagent.interrupted` | Interrupt terminal state | Show reason. |
+
+Events should contain `agent_id`, `task_name`, `parent_id`, `profile`, `status`, timestamp, and compact payload. They must avoid full transcript inclusion by default.
+
+---
+
+## Control Panel State
+
+The control panel is a serializable projection, not a UI component.
+
+```ts
+export interface SubagentControlPanelState {
+ root_id: string;
+ generated_at: string;
+ budget: SubagentBudgetSummary | null;
+ warnings: SubagentWarning[];
+ agents: SubagentControlPanelAgent[];
+ activity: SubagentActivityItem[];
+}
+```
+
+Each agent row should include:
+
+- `agent_id`
+- `task_name`
+- `profile`
+- `nickname`
+- `status`
+- `parent_id`
+- `depth`
+- `last_task_message`
+- `created_at`
+- `updated_at`
+- `usage`
+- `supported_actions`
+- `result_ref`
+- `transcript_ref`
+- `warnings`
+
+Supported actions are computed from status, runner capabilities, and policy:
+
+| Status | Actions |
+| --- | --- |
+| `pending` | `interrupt` when supported |
+| `running` | `message`, `followup`, `interrupt`, `wait` |
+| `waiting` | `message`, `followup`, `interrupt`, `wait` |
+| `completed` | `wait`, `inspect_result` |
+| `failed` | `wait`, `inspect_result` |
+| `interrupted` | `wait`, `inspect_result` |
+
+The state projection should be JSON-serializable and stable enough for docs, devtools, logs, and host UIs.
+
+---
+
+## Persistence and Resume Boundary
+
+The first implementation should persist:
+
+- Subagent metadata.
+- Status transitions.
+- Mailbox items.
+- Compact terminal results.
+- Usage summaries.
+- Event records or event references.
+- Transcript references when the host supplies transcript storage.
+
+The first implementation should not promise:
+
+- Restarting a live AI SDK generation after Node process termination.
+- Rehydrating live `LanguageModel` instances from serialized profile files.
+- Replaying in-flight Codemode execution.
+
+This boundary should be documented as "metadata and terminal result persistence" rather than "durable active-run resume." Future durable runners can add checkpoint/restore behind the same controller contracts.
+
+---
+
+## Migration Strategy
+
+### Breaking Defaults
+
+| Current behavior | New behavior |
+| --- | --- |
+| Direct tools are parent-visible by default | Codemode is parent-visible by default when configured |
+| `Task` is the main delegation tool | Subagent controls are the main delegation tools |
+| Child execution is one nested generation | Child execution is controller-managed |
+| Cost tracking exists mostly at tool/generation boundaries | Cost tracking is shared across parent and children |
+| UI receives optional `data-subagent` events from `Task` | UI can consume typed controller events and snapshots |
+
+### `Task` Options
+
+Preferred path:
+
+- Remove `Task` from default docs and factory examples.
+- Export `createLegacyTaskTool` for one migration window.
+- Implement the adapter over `SubagentController.spawn` plus `wait`.
+- Emit deprecation messaging in docs and release notes.
+
+Alternative path:
+
+- Remove `Task` entirely in the breaking release.
+- Provide a migration recipe: `Task(description, prompt)` becomes `SpawnAgent(task, task_name?)` then `WaitAgent(agent_id)`.
+
+The preferred path is kinder to existing consumers while still making the default mental model correct.
+
+### Versioning
+
+BashKit is currently pre-1.0, but the contributor guide says breaking changes require a major bump. The implementation should either ship as `1.0.0` or explicitly document the pre-1.0 breaking version policy. Because this change redefines the default tool surface, `1.0.0` is the cleaner signal.
+
+---
+
+## Implementation Units
+
+### U1. Create the Subagent Core Module
+
+- **Goal:** Add foundational subagent types, identity, profile resolution, path handling, and tool filtering.
+- **Requirements:** R7, R8, R9, R16, R20, R21, R24
+- **Dependencies:** None
+- **Files:** `src/subagents/AGENTS.md`, `src/subagents/CLAUDE.md`, `src/subagents/index.ts`, `src/subagents/types.ts`, `src/subagents/identity.ts`, `src/subagents/path.ts`, `src/subagents/profiles.ts`, `src/subagents/profile-descriptions.ts`, `src/subagents/tool-filter.ts`, `src/subagents/registry.ts`, `src/tools/AGENTS.md`, `src/index.ts`, `tests/subagents/identity.test.ts`, `tests/subagents/path.test.ts`, `tests/subagents/profiles.test.ts`, `tests/subagents/tool-filter.test.ts`, `tests/subagents/registry.test.ts`
+- **Approach:** Define public-but-small interfaces for `SubagentProfile`, `ResolvedSubagentProfile`, `SubagentMetadata`, `SubagentStatus`, `SubagentPath`, `SubagentRunResult`, `SubagentCodemodePolicy`, `SubagentContextPolicy`, and `SubagentError`. Keep core request types separate from AI SDK tool schemas. Add path parsing, relative lookup, duplicate live-name checks, and profile-visible description generation.
+- **Patterns to follow:** `src/tools/patch/` for promoting non-trivial internals into a folder; `src/tools/AGENTS.md` for documentation expectations; `src/tools/task.ts` for AI SDK type imports and budget callback conventions.
+- **Test scenarios:** Unique IDs are generated; duplicate live paths return `{ error }`; relative child and sibling paths resolve; terminal path reuse follows policy; profile resolution applies defaults, named profile config, and allowed overrides; profile descriptions include purpose, locked model/cost hints, and capability limits; tool filtering excludes unknown tools without throwing; Codemode policy inherits the same allowlist as direct tools; context policy accepts `none`, `all`, and recent-turn modes; optional nullable fields resolve with `??` defaults.
+- **Verification:** `src/subagents/` exports only stable types/helpers and every new file is covered by `src/subagents/AGENTS.md`.
+
+### U2. Add Controller, Store, Runner, and Event Contracts
+
+- **Goal:** Introduce the runtime control plane for spawn, list, wait, message, follow-up, interrupt, lifecycle hooks, status transitions, and event emission.
+- **Requirements:** R8, R9, R10, R11, R12, R14, R15, R16, R19, R22, R23
+- **Dependencies:** U1
+- **Files:** `src/subagents/controller.ts`, `src/subagents/store.ts`, `src/subagents/runner.ts`, `src/subagents/events.ts`, `src/subagents/status.ts`, `src/subagents/mailbox.ts`, `tests/subagents/controller.test.ts`, `tests/subagents/store.test.ts`, `tests/subagents/runner.test.ts`, `tests/subagents/events.test.ts`
+- **Approach:** Implement `SubagentController` over `SubagentStore`, `SubagentRunner`, lifecycle hooks, and `SubagentEventSink`. Provide `InMemorySubagentStore` and an AI SDK runner. The controller owns reservations, status transitions, context inheritance, mailbox writes, completion notifications, and event ordering. The runner executes resolved runs and reports usage/events back through typed callbacks.
+- **Patterns to follow:** Codex `AgentControl` and `AgentRegistry`; BashKit `BudgetTracker` stopWhen/onStepFinish composition in `src/tools/task.ts`; debug parent attribution in `src/utils/debug.ts`.
+- **Test scenarios:** Spawn moves `pending -> running -> completed`; setup failures move to `failed`; lifecycle hooks fire in deterministic order; completion notification emits even when no one waits; runner rejection records errored status and returns `{ error }`; interrupt changes status and aborts when supported; unsupported interrupt returns `{ error }`; wait timeout does not change status; mailbox updates `last_task_message`; follow-up requests a turn only when runner supports it; snapshots are JSON-serializable; fake runners can test behavior without real models.
+- **Verification:** Controller tests use fake stores and fake runners; no live AI SDK calls are needed.
+
+### U3. Add Shared Cost and Execution Guardrails
+
+- **Goal:** Enforce cost, depth, concurrency, total count, mailbox, and wait limits before work fans out.
+- **Requirements:** R12, R14, R18, R19, R22
+- **Dependencies:** U1, U2
+- **Files:** `src/subagents/cost-control.ts`, `src/subagents/execution-limits.ts`, `src/subagents/controller.ts`, `src/subagents/types.ts`, `src/utils/budget-tracking.ts`, `tests/subagents/cost-control.test.ts`, `tests/subagents/execution-limits.test.ts`, `tests/tools/budget-integration.test.ts`
+- **Approach:** Add `SubagentCostPolicy` and `SubagentExecutionPolicy`. Compose existing `BudgetTracker` with subagent-specific limits: max active agents, max total agents per root run, max depth, per-profile budget caps, wait timeout bounds, mailbox length, and optional step caps. Check policy before spawn and follow-up. Report parent Codemode and child usage into the same tracker.
+- **Patterns to follow:** Existing budget tracker `stopWhen` and `onStepFinish`; Codex active execution limiter; Codex spawn depth checks.
+- **Test scenarios:** Spawn is rejected when root budget is exceeded; spawn is rejected at max active agents; nested spawn is rejected past max depth; total spawned count cannot exceed policy; child usage increments shared budget; parent Codemode and children appear in one budget status; per-profile caps stop expensive agents; wait timeout bounds prevent tight polling; mailbox limits reject unbounded queued work.
+- **Verification:** Guardrails are tested with fake runners and fake usage records.
+
+### U4. Implement Subagent Control Tools
+
+- **Goal:** Add model-facing tools for multi-agent orchestration as the canonical delegation surface.
+- **Requirements:** R3, R5, R7, R10, R11, R14, R20, R21, R22
+- **Dependencies:** U1, U2, U3
+- **Files:** `src/tools/subagents/AGENTS.md`, `src/tools/subagents/CLAUDE.md`, `src/tools/subagents/index.ts`, `src/tools/subagents/spawn-agent.ts`, `src/tools/subagents/list-agents.ts`, `src/tools/subagents/send-message.ts`, `src/tools/subagents/followup-task.ts`, `src/tools/subagents/wait-agent.ts`, `src/tools/subagents/interrupt-agent.ts`, `tests/tools/subagents/spawn-agent.test.ts`, `tests/tools/subagents/list-agents.test.ts`, `tests/tools/subagents/message-tools.test.ts`, `tests/tools/subagents/wait-agent.test.ts`, `tests/tools/subagents/interrupt-agent.test.ts`
+- **Approach:** Keep each adapter thin: parse nullable input, apply `??` defaults, call the controller, and return compact structured output. Tool descriptions should teach models that subagents are for separable work and warn against redundant fan-out.
+- **Patterns to follow:** Codex multi-agent tool split; BashKit tool factories returning `Tool`; nullable schema convention in `src/tools/AGENTS.md`.
+- **Test scenarios:** `SpawnAgent` rejects empty tasks; duplicate `task_name` returns `{ error }`; `ListAgents` filters by status and path prefix; `SendMessage` accepts valid IDs and paths; `FollowupTask` rejects self-targeting deadlock cases; `WaitAgent` enforces timeout policy; `InterruptAgent` returns previous and new status; every optional schema field is nullable; tool outputs are stable structured objects.
+- **Verification:** Tool tests assert structured results and do not require live model execution.
+
+### U5. Implement the Default AI SDK Runner
+
+- **Goal:** Provide the default execution backend for subagents using AI SDK generation and profile-scoped Codemode.
+- **Requirements:** R9, R11, R12, R13, R16, R20, R22, R24
+- **Dependencies:** U1, U2, U3
+- **Files:** `src/subagents/ai-sdk-runner.ts`, `src/subagents/tool-surface.ts`, `src/subagents/context-inheritance.ts`, `src/subagents/transcripts.ts`, `tests/subagents/ai-sdk-runner.test.ts`, `tests/subagents/context-inheritance.test.ts`, `tests/subagents/tool-surface.test.ts`
+- **Approach:** Construct a child tool surface from the resolved profile. Build Codemode providers after profile filtering. Include optional direct tools only when the profile asks for them. Apply context inheritance before the model call. Route usage, tool events, and terminal summaries back through runner callbacks.
+- **Patterns to follow:** `src/tools/task.ts` AI SDK generation flow; `src/tools/codemode.ts` provider selection and `selectCodemodeTools`; `src/context/index.ts` wrapping behavior.
+- **Test scenarios:** Child receives Codemode by default; child direct tools are hidden unless profile opts in; blocked tools are absent from Codemode providers; recent-turn context includes only the configured number of turns; `none` context excludes parent history; runner reports usage to the controller; generated terminal summary excludes full transcript; stop conditions produce `completed`; model/tool failure produces `failed` with `{ error }`.
+- **Verification:** Runner tests use mock models/tools where possible and avoid networked model calls.
+
+### U6. Decide and Implement the `Task` Migration Path
+
+- **Goal:** Make the breaking `Task` decision explicit and provide the smallest responsible compatibility path.
+- **Requirements:** R1, R2, R3, R4, R5, R8, R10, R11, R13
+- **Dependencies:** U1, U2, U3, U5
+- **Files:** `src/tools/task.ts`, `tests/tools/task.test.ts`, `tests/tools/budget-integration.test.ts`
+- **Approach:** Prefer retaining `Task` only as `createLegacyTaskTool` or a deprecated adapter. Translate old fields into a one-shot spawn request, call `wait`, and format the old `TaskOutput` shape. Emit existing streaming event names through the shared event sink so legacy and new paths share observability code.
+- **Patterns to follow:** Current `src/tools/task.ts` output and debug behavior; existing budget tests; `runWithDebugParent` for nested attribution.
+- **Test scenarios:** Deprecated adapter can be imported; legacy success returns `result`, `usage`, `duration_ms`, `subagent`, and `description`; legacy errors return `TaskError`; custom `system_prompt` maps to profile override; custom `tools` narrows direct tools and Codemode providers; streaming mode emits compatible `data-subagent` events; debug parent wraps child tool calls; budget stopWhen still halts child work.
+- **Verification:** New docs do not teach `Task` as the default subagent API.
+
+### U7. Add Parent-Agent Control Panel State
+
+- **Goal:** Expose typed host-facing state for supervising subagents from the parent session.
+- **Requirements:** R10, R11, R12, R14, R19, R23
+- **Dependencies:** U1, U2, U3, U4, U6
+- **Files:** `src/subagents/control-panel.ts`, `src/subagents/events.ts`, `src/subagents/index.ts`, `tests/subagents/control-panel.test.ts`, `README.md`, `docs/src/app/tools/page.tsx`
+- **Approach:** Add a projection layer that turns controller state, store records, events, and budget summaries into `SubagentControlPanelState`. Include live and terminal agents, status, profile/type, task name, nickname, last task message, parent/child relationship, budget summary, warnings, supported actions, and transcript/result references.
+- **Patterns to follow:** Existing `SubagentEventData` shape in `src/tools/task.ts`; Codex `list_agents` status output; BashKit budget status shape in `src/utils/budget-tracking.ts`.
+- **Test scenarios:** Starting a child adds it to active state; completion moves it to terminal state without dropping result references; budget updates and warnings appear; unsupported actions are absent; full transcript text is excluded by default; snapshots serialize to plain JSON; path filters produce stable subsets.
+- **Verification:** Host apps can build a control panel without reaching into controller internals.
+
+### U8. Make Codemode and Subagents the Default Factory Surface
+
+- **Goal:** Change BashKit's factory and package surface to Codemode-first and subagent-first defaults.
+- **Requirements:** R1, R2, R6, R8, R9, R10, R12, R13, R15, R16, R21, R24
+- **Dependencies:** U1, U2, U3, U4, U5, U6, U7
+- **Files:** `src/types.ts`, `src/tools/index.ts`, `src/index.ts`, `tests/tools/index.test.ts`, `tests/context/integration.test.ts`
+- **Approach:** Reshape `createAgentTools` so Codemode config creates a Codemode-first surface. Direct tools remain as Codemode inner providers and explicit compatibility exposure. Add top-level `subagents` config for model defaults, profiles, limits, store, event sink, lifecycle hooks, context policy, and Codemode executor/config. Pass already-wrapped tools into Codemode and the controller.
+- **Patterns to follow:** Config-presence pattern for `webSearch`, `webFetch`, `skill`, `codemode`, and `budget`; `context.extraTools` wrapping order in `src/tools/index.ts`; `selectCodemodeTools` safety filtering in `src/tools/codemode.ts`.
+- **Test scenarios:** Codemode config creates Codemode-first parent surface; direct tool exposure requires explicit compatibility config; `subagents` creates control tools; missing model/executor returns clear construction error; context-wrapped tools are inherited; budget enters the controller; max concurrent agents rejects excess spawns; profile restrictions apply to direct tools and Codemode providers; exported types are available from `src/index.ts`.
+- **Verification:** Factory tests describe Codemode-default, subagent-default, context-layer, profile-restricted Codemode, and legacy-adapter cases.
+
+### U9. Add Profile Loading Helpers
+
+- **Goal:** Provide a shareable profile format for team-standard subagent definitions and future workflow templates.
+- **Requirements:** R8, R13, R17, R18, R20, R21
+- **Dependencies:** U1, U8
+- **Files:** `src/subagents/profile-loader.ts`, `src/subagents/profile-loader.schema.ts`, `tests/subagents/profile-loader.test.ts`, `README.md`, `docs/src/app/tools/page.tsx`
+- **Approach:** Add explicit helpers for loading JSON profile files from a sandbox or local filesystem abstraction. Validate with Zod. Keep live model instances and Codemode executors supplied by the host because serialized files cannot contain functions or model objects.
+- **Patterns to follow:** `src/skills/discovery.ts` and `src/setup/setup-environment.ts` for explicit discovery; README's Agent Profiles Loader roadmap section.
+- **Test scenarios:** Valid JSON profiles load; invalid profiles return validation errors; tool names are preserved but filtered later; missing file returns `{ error }`; failed loads do not mutate the registry; profile metadata produces model-visible descriptions.
+- **Verification:** The README roadmap becomes current usage guidance instead of an unimplemented promise.
+
+### U10. Update Documentation, Examples, and Release Notes
+
+- **Goal:** Teach the new default model and provide a clear migration path.
+- **Requirements:** R2, R14, R17, R18, R19, R20
+- **Dependencies:** U4, U6, U7, U8, U9
+- **Files:** `README.md`, `docs/src/app/tools/page.tsx`, `docs/src/app/api-reference/page.tsx`, `examples/basic.ts`, `examples/subagents.ts`, `examples/codemode-subagents.ts`, `CHANGELOG.md`
+- **Approach:** Update docs to teach Codemode first for coding and subagent controls first for delegation. Add examples for fan-out-and-synthesize, adversarial verification, profile/model routing, Codemode-backed children, and quarantine. Include migration notes from direct tool exposure to Codemode and from `Task` to `SpawnAgent` plus `WaitAgent`.
+- **Patterns to follow:** Current README examples for streaming subagent activity, budget tracking, Codemode, and skills.
+- **Test scenarios:** Documentation examples typecheck where repo patterns support it; docs mention active-process resume requires a host durable runner; examples avoid unrestricted recursive subagent controls unless intentionally shown; release notes call out breaking default behavior.
+- **Verification:** Users can identify the default API and understand what changed.
+
+---
+
+## Acceptance Examples
+
+- AE1. Given a host configures subagents, when the parent model receives tools, then `SpawnAgent`, `ListAgents`, `WaitAgent`, `SendMessage`, `FollowupTask`, and `InterruptAgent` are the model-visible delegation tools.
+- AE2. Given a host configures Codemode, when the parent model receives tools, then Codemode is the default coding tool and direct sandbox tools are hidden unless compatibility mode requests them.
+- AE3. Given the parent calls `SpawnAgent`, then it receives a stable handle and can later call `ListAgents` or `WaitAgent` without re-running the child in parent context.
+- AE4. Given a child subagent starts, then the child receives a Codemode tool whose inner providers are limited by the child's resolved profile.
+- AE5. Given two subagents run concurrently under one budget tracker, when both finish, then shared budget status includes both child usages.
+- AE6. Given a read-only research profile, when it is spawned with `tools: ["Read", "Grep", "Glob"]`, then write and shell tools are unavailable directly and through Codemode-generated code.
+- AE7. Given shared budget is exhausted or active-agent limit is reached, when the parent requests another spawn or follow-up task, then BashKit rejects it before starting a costly child turn.
+- AE8. Given a consumer still imports the legacy adapter, when they upgrade through the migration window, then their one-shot task can still run or fails with a clear migration error, depending on the chosen deprecation policy.
+- AE9. Given a future workflow harness needs fan-out-and-synthesize, when it uses the controller directly, then it can spawn multiple child runs, await terminal results, and synthesize without model-visible `Task` calls.
+- AE10. Given the parent uses `context: { recent_turns: 3 }`, when the child starts, then it receives only the bounded recent context plus explicit spawn task and profile prompt.
+- AE11. Given a host renders `SubagentControlPanelState`, when a child completes while the parent is not waiting, then the control panel still updates with terminal status, usage, and result reference.
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+
+- Identity and path parsing.
+- Profile resolution and generated descriptions.
+- Tool filtering and quarantine.
+- Context inheritance policies.
+- Registry reservation and release behavior.
+- Controller state transitions.
+- Store serialization.
+- Event ordering.
+- Cost and execution limits.
+- Control panel projection.
+- Tool schema nullability and output shapes.
+
+### Integration Tests
+
+- `createAgentTools` Codemode-default behavior.
+- Direct tool compatibility mode.
+- Subagent control tools with fake runner.
+- Budget integration across parent and child.
+- Context layers inherited by child tools.
+- Legacy `Task` adapter over controller.
+
+### Regression Tests
+
+- Restricted profiles cannot access blocked tools through Codemode providers.
+- `WaitAgent` timeout never changes child status.
+- Interrupting completed agents returns stable previous status.
+- Duplicate task paths return `{ error }` without leaking reservations.
+- Missing Codemode executor fails at construction with a clear error.
+- Full child transcript is not returned to parent by default.
+
+### Manual Verification
+
+- Run `bun run typecheck`.
+- Run `bun run check`.
+- Run `bun run test`.
+- Run `bun run check:agents`.
+- Exercise `examples/subagents.ts` with fake or local model configuration.
+- Exercise `examples/codemode-subagents.ts` with a real Codemode executor when available.
+
+---
+
+## Rollout Plan
+
+1. Land subagent core and tests behind exports without changing defaults.
+2. Add controller, fake-runner tests, event/store contracts, and cost policy.
+3. Add AI SDK runner and profile-scoped Codemode tool surface.
+4. Add model-facing control tools.
+5. Add control panel projection.
+6. Add legacy `Task` adapter or removal path.
+7. Flip factory defaults to Codemode-first and subagent-first in the breaking release branch.
+8. Update examples, docs, and release notes.
+9. Run full local gates and review public API surface before release.
+
+This sequence keeps the hardest internals testable before changing public defaults.
+
+---
+
+## System-Wide Impact
+
+This work changes BashKit's public API, default tool surface, docs, examples, Codemode dependency posture, context-layer inheritance, event model, and budget semantics. It should be treated as a breaking release because it changes both the default coding harness and the default delegation model.
+
+The `Sandbox` interface should remain unchanged. Subagents orchestrate model/tool execution. Sandboxes remain responsible for filesystem and process operations.
+
+Optional peer dependency strategy needs a release decision. If Codemode becomes the default harness, the package can either make Codemode a required peer for the default path or require hosts to opt into direct-tool compatibility when Codemode is unavailable. The cleaner API is Codemode-first with an explicit construction-time error when a caller asks for Codemode defaults but does not provide the executor.
+
+---
+
+## Risks & Mitigations
+
+| Risk | Impact | Mitigation |
+| --- | --- | --- |
+| AI SDK cancellation support is limited | Interrupt may be best effort | Return explicit unsupported errors and keep status transitions truthful. |
+| Codemode dependency boundary changes | Consumers may need new setup | Document peer dependency and compatibility mode clearly. |
+| Recursive spawning runs away | Cost and resource exhaustion | Enforce depth, active count, total count, and budget before spawn. |
+| Quarantine bypass through Codemode | Restricted agents mutate state | Filter Codemode providers after profile resolution and test blocked tools. |
+| Context bloat from fan-out | Cost spikes and parent context pressure | Make context inheritance explicit and default to bounded recent turns. |
+| UI expects transcripts in model output | Parent context fills with child logs | Provide event sinks and transcript references instead. |
+| Durable resume expectations | Users assume active-run recovery | Document metadata/result persistence and defer active-run resume. |
+| API surface becomes too complex | Hard adoption | Keep defaults simple and put advanced knobs in profile/config objects. |
+| Legacy `Task` behavior diverges | Migration confusion | Implement adapter on same controller or remove with clear release notes. |
+
+---
+
+## Open Questions
+
+- OQ1. Should this ship as `1.0.0` to make the breaking change obvious, or as a pre-1.0 breaking minor with unusually strong migration notes?
+- OQ2. Should `Task` remain as `createLegacyTaskTool` for one release, or be removed entirely from the breaking release?
+- OQ3. What should the default max depth be: `1` for safest behavior or `2` to allow supervisor -> worker -> specialist patterns?
+- OQ4. Should the default `worker` profile expose subagent control tools, or should recursive spawning require explicit profile opt-in?
+- OQ5. Should profile files live under a conventional path such as `.bashkit/agents/*.json`, or should loading remain completely caller-directed?
+
+---
+
+## Documentation Notes
+
+The docs should introduce the model in this order:
+
+1. Codemode is the default coding harness.
+2. Subagent controls are the default delegation surface.
+3. Profiles define capability, safety, cost, and context boundaries.
+4. Control panel state is host-rendered from typed snapshots.
+5. `Task` is legacy or removed.
+
+The examples should show:
+
+- Basic Codemode-first setup.
+- Spawn/wait/list lifecycle.
+- Read-only researcher profile.
+- Reviewer/fixer fan-out.
+- Budget exhaustion behavior.
+- Control panel snapshot rendering.
+- Migration from `Task`.
+
+---
+
+## Sources & Research
+
+- BashKit current implementation: `src/tools/task.ts`, `src/tools/codemode.ts`, `src/tools/index.ts`, `src/types.ts`, `tests/tools/budget-integration.test.ts`, `README.md`
+- BashKit module guidance: `src/tools/AGENTS.md`, `src/tools/patch/AGENTS.md`
+- Codex reference concepts: `codex-rs/core/src/agent/control.rs`, `codex-rs/core/src/agent/registry.rs`, `codex-rs/core/src/agent/role.rs`, `codex-rs/core/src/tools/handlers/multi_agents_v2.rs`, `codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs`, `codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs`, `codex-rs/core/src/tools/handlers/multi_agents_spec.rs`
+- Codex test coverage patterns: `codex-rs/core/tests/suite/subagent_notifications.rs`, `codex-rs/core/tests/suite/spawn_agent_description.rs`, `codex-rs/core/tests/suite/agent_execution.rs`, `codex-rs/core/tests/suite/hierarchical_agents.rs`
+- Agent-native architecture guidance: unified orchestrator, primitive tools, explicit completion, shared state, context limits, profile/model tier selection, and agent-to-UI event projection.
+- Workflow direction from the provided dynamic workflows note: fan-out-and-synthesize, adversarial verification, generate-and-filter, tournament, loop-until-done, quarantine, model routing, and resume pressure.
From 1223d215f8835aec540a8d87e039c4d5df72f421 Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 08:06:17 -0400
Subject: [PATCH 02/20] docs: Reference Codex subagent implementation files
---
...06-15-001-feat-subagent-foundation-plan.md | 71 +++++++++++++++++++
1 file changed, 71 insertions(+)
diff --git a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
index 4601575..f6af223 100644
--- a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
+++ b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
@@ -140,6 +140,77 @@ Do not copy Codex's Rust thread manager, TUI implementation, cloud task APIs, or
---
+## Codex Reference File Map
+
+When implementing this plan, use these paths relative to a local Codex checkout. The file map is intentionally explicit because the BashKit implementation should borrow Codex's shape, not rediscover it from scratch.
+
+### Read First
+
+| Codex file | Reference purpose | BashKit design area |
+| --- | --- | --- |
+| `codex-rs/core/src/agent/control.rs` | Main control-plane contract for listing agents, sending input, interrupting, and waiting for completion | `SubagentController` public API and ownership boundaries |
+| `codex-rs/core/src/agent/registry.rs` | Agent identity, path/name registration, reservations, and max-thread constraints | `SubagentRegistry`, path resolution, duplicate-name behavior |
+| `codex-rs/core/src/agent/role.rs` | Role resolution, built-in roles, locked settings, model/tool visibility, and role metadata | `SubagentProfileRegistry` and generated profile descriptions |
+| `codex-rs/core/src/tools/handlers/multi_agents_spec.rs` | Model-facing tool schemas and descriptions for multi-agent controls | BashKit control tool schemas and model-facing wording |
+| `codex-rs/core/src/tools/handlers/multi_agents_v2.rs` | Tool registration and dispatch split for the newer multi-agent API | `src/tools/subagents/index.ts` and adapter boundaries |
+
+### Control Plane Internals
+
+| Codex file | Reference purpose | BashKit design area |
+| --- | --- | --- |
+| `codex-rs/core/src/agent/control/spawn.rs` | Spawn orchestration, role resolution, context forking, and child setup | `SubagentController.spawn` and `SubagentRunner` setup |
+| `codex-rs/core/src/agent/control/execution.rs` | Active execution accounting and concurrency limiting | `SubagentExecutionPolicy` and pre-spawn rejection |
+| `codex-rs/core/src/agent/control/residency.rs` | Residency and lifecycle tracking for active agents | Store boundary and active-vs-terminal metadata |
+| `codex-rs/core/src/agent/status.rs` | Agent status representation and status updates | `SubagentStatus`, terminal states, control panel projection |
+| `codex-rs/core/src/agent/agent_resolver.rs` | Resolving agent references from names and paths | `SubagentPath` parsing and relative lookup |
+| `codex-rs/core/src/agent/agent_names.txt` | Human-readable generated agent names | Optional nickname generation and display metadata |
+
+### Tool Adapter Split
+
+| Codex file | Reference purpose | BashKit design area |
+| --- | --- | --- |
+| `codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs` | Thin spawn tool adapter over the control plane | `SpawnAgent` adapter |
+| `codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs` | Compact list output and status display | `ListAgents` output and control panel summaries |
+| `codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs` | Bounded wait behavior and terminal result formatting | `WaitAgent` timeout and result shape |
+| `codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs` | Message delivery to an existing agent | `SendMessage` mailbox behavior |
+| `codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs` | Follow-up task routing to a child agent | `FollowupTask` turn-triggering semantics |
+| `codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs` | Interrupt request behavior | `InterruptAgent` best-effort cancellation |
+| `codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs` | Shared helper behavior for message-like tools | Shared message adapter utilities |
+
+### Role and Profile Examples
+
+| Codex file | Reference purpose | BashKit design area |
+| --- | --- | --- |
+| `codex-rs/core/src/agent/builtins/explorer.toml` | Built-in exploratory role configuration | BashKit `researcher` or `reviewer` profile defaults |
+| `codex-rs/core/src/agent/builtins/awaiter.toml` | Built-in role with specific coordination behavior | BashKit specialized coordination profiles |
+| `codex-rs/core/src/agent/role_tests.rs` | Role resolution edge cases | Profile registry tests |
+| `codex-rs/core/src/agent/registry_tests.rs` | Registry reservations and path behavior | Registry and identity tests |
+| `codex-rs/core/src/agent/control_tests.rs` | Control-plane behavior coverage | Controller tests with fake runners |
+
+### Integration and Regression Tests
+
+| Codex file | Reference purpose | BashKit design area |
+| --- | --- | --- |
+| `codex-rs/core/tests/suite/subagent_notifications.rs` | Parent notification when subagents finish | Completion events and control panel updates |
+| `codex-rs/core/tests/suite/spawn_agent_description.rs` | Model-facing descriptions for spawnable agents | Generated profile descriptions |
+| `codex-rs/core/tests/suite/agent_execution.rs` | Execution lifecycle and active execution behavior | Runner lifecycle and execution limits |
+| `codex-rs/core/tests/suite/hierarchical_agents.rs` | Nested agents and parent/child relationships | Depth policy and hierarchical identity |
+| `codex-rs/core/tests/suite/agent_jobs.rs` | Batch-style agent work | Future workflow harness inspiration, not first milestone scope |
+| `codex-rs/core/tests/suite/agent_websocket.rs` | Event streaming and status visibility | Host event sink and UI state projection |
+
+### Read Later or Treat as Non-Goals
+
+| Codex file | Why it is secondary |
+| --- | --- |
+| `codex-rs/core/src/tools/handlers/multi_agents.rs` | Older multi-agent API; useful for migration comparison but not the target shape. |
+| `codex-rs/core/src/tools/handlers/multi_agents_common.rs` | Shared older helpers; inspect only when porting a specific behavior. |
+| `codex-rs/core/src/tools/handlers/agent_jobs.rs` | Batch agent jobs are useful workflow inspiration, but BashKit should first land the subagent foundation. |
+| `codex-rs/core/src/tools/handlers/agent_jobs_spec.rs` | Same as above; do not let batch jobs expand the first milestone. |
+
+Recommended implementation reading order: start with `control.rs`, `registry.rs`, `role.rs`, and `multi_agents_spec.rs`; then inspect the `multi_agents_v2/` adapters; then read the test files that correspond to the BashKit unit being implemented.
+
+---
+
## Architecture Overview
```mermaid
From 1f42e93ac91cb3971d2f28803cda8ddf02ec5236 Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 09:05:16 -0400
Subject: [PATCH 03/20] feat: Add subagent foundation
---
AGENTS.md | 2 +
src/index.ts | 78 +++++
src/subagents/AGENTS.md | 64 ++++
src/subagents/CLAUDE.md | 1 +
src/subagents/controller.ts | 374 +++++++++++++++++++++
src/subagents/cost-control.ts | 30 ++
src/subagents/events.ts | 22 ++
src/subagents/execution-limits.ts | 39 +++
src/subagents/identity.ts | 13 +
src/subagents/index.ts | 83 +++++
src/subagents/mailbox.ts | 20 ++
src/subagents/path.ts | 76 +++++
src/subagents/profile-descriptions.ts | 23 ++
src/subagents/profiles.ts | 159 +++++++++
src/subagents/registry.ts | 89 +++++
src/subagents/runner.ts | 27 ++
src/subagents/status.ts | 15 +
src/subagents/store.ts | 109 +++++++
src/subagents/tool-filter.ts | 26 ++
src/subagents/types.ts | 398 +++++++++++++++++++++++
tests/subagents/controller.test.ts | 105 ++++++
tests/subagents/cost-control.test.ts | 50 +++
tests/subagents/execution-limits.test.ts | 37 +++
tests/subagents/identity.test.ts | 18 +
tests/subagents/path.test.ts | 28 ++
tests/subagents/profiles.test.ts | 85 +++++
tests/subagents/registry.test.ts | 76 +++++
tests/subagents/store.test.ts | 60 ++++
tests/subagents/tool-filter.test.ts | 38 +++
29 files changed, 2145 insertions(+)
create mode 100644 src/subagents/AGENTS.md
create mode 120000 src/subagents/CLAUDE.md
create mode 100644 src/subagents/controller.ts
create mode 100644 src/subagents/cost-control.ts
create mode 100644 src/subagents/events.ts
create mode 100644 src/subagents/execution-limits.ts
create mode 100644 src/subagents/identity.ts
create mode 100644 src/subagents/index.ts
create mode 100644 src/subagents/mailbox.ts
create mode 100644 src/subagents/path.ts
create mode 100644 src/subagents/profile-descriptions.ts
create mode 100644 src/subagents/profiles.ts
create mode 100644 src/subagents/registry.ts
create mode 100644 src/subagents/runner.ts
create mode 100644 src/subagents/status.ts
create mode 100644 src/subagents/store.ts
create mode 100644 src/subagents/tool-filter.ts
create mode 100644 src/subagents/types.ts
create mode 100644 tests/subagents/controller.test.ts
create mode 100644 tests/subagents/cost-control.test.ts
create mode 100644 tests/subagents/execution-limits.test.ts
create mode 100644 tests/subagents/identity.test.ts
create mode 100644 tests/subagents/path.test.ts
create mode 100644 tests/subagents/profiles.test.ts
create mode 100644 tests/subagents/registry.test.ts
create mode 100644 tests/subagents/store.test.ts
create mode 100644 tests/subagents/tool-filter.test.ts
diff --git a/AGENTS.md b/AGENTS.md
index 6a12b20..10e9b24 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,6 +44,7 @@ src/
├── cache/ # Tool result caching (LRU, Redis) — src/cache/AGENTS.md
├── middleware/ # AI SDK language model middleware — src/middleware/AGENTS.md
├── utils/ # Budget, compaction, context status, helpers — src/utils/AGENTS.md
+├── subagents/ # Subagent control plane foundation — src/subagents/AGENTS.md
├── skills/ # Agent Skills standard — src/skills/AGENTS.md
├── setup/ # Agent environment setup (sandbox bootstrapping) — src/setup/AGENTS.md
├── cli/ # CLI initialization — src/cli/AGENTS.md
@@ -293,5 +294,6 @@ See `src/tools/AGENTS.md` for per-tool config details.
| Add middleware | `src/middleware/AGENTS.md` → "Common Modifications" |
| Add a cache backend | `src/cache/AGENTS.md` → "Common Modifications" |
| Add a context layer or prompt section | `src/context/AGENTS.md` → "Common Modifications" |
+| Add subagent foundation behavior | `src/subagents/AGENTS.md` → "Common Modifications" |
| Add a skill source | `src/skills/AGENTS.md` → "Common Modifications" |
| Add a config field | Define in `src/types.ts`, consume in the relevant factory via `config?.yourField ?? default` |
diff --git a/src/index.ts b/src/index.ts
index 5c3d46d..b6123eb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -31,6 +31,84 @@ export {
ensureSandboxTools,
} from "./sandbox";
+// Subagent foundation
+export type {
+ JsonObject,
+ JsonPrimitive,
+ JsonValue,
+ ResolvedSubagentProfile,
+ ResolvedSubagentRunRequest,
+ SubagentCodemodePolicy,
+ SubagentContextPolicy,
+ SubagentContextPolicyInput,
+ SubagentController,
+ SubagentControllerConfig,
+ SubagentCostPolicy,
+ SubagentCostPolicyInput,
+ SubagentError,
+ SubagentEvent,
+ SubagentEventSink,
+ SubagentEventType,
+ SubagentFollowupRequest,
+ SubagentHandle,
+ SubagentId,
+ SubagentInterruptRequest,
+ SubagentInterruptResult,
+ SubagentLifecycleHooks,
+ SubagentListFilter,
+ SubagentMailboxMessage,
+ SubagentMessageRequest,
+ SubagentMessageResult,
+ SubagentMetadata,
+ SubagentMetadataPatch,
+ SubagentPath,
+ SubagentPolicyState,
+ SubagentProfileDefaults,
+ SubagentProfileInput,
+ SubagentProfileRegistry,
+ SubagentRecord,
+ SubagentRegistry,
+ SubagentReservationRequest,
+ SubagentRunResult,
+ SubagentRunner,
+ SubagentRunnerCallbacks,
+ SubagentRunnerCapabilities,
+ SubagentSpawnRequest,
+ SubagentSpawnResult,
+ SubagentStatus,
+ SubagentStore,
+ SubagentUsage,
+ SubagentWaitRequest,
+ SubagentWaitResult,
+} from "./subagents";
+export {
+ DEFAULT_SUBAGENT_CODEMODE_POLICY,
+ DEFAULT_SUBAGENT_CONTEXT_POLICY,
+ DEFAULT_SUBAGENT_COST_POLICY,
+ DEFAULT_SUBAGENT_PROFILE_NAME,
+ checkSubagentCostPolicy,
+ checkSubagentExecutionLimits,
+ clampSubagentWaitTimeout,
+ createInMemorySubagentStore,
+ createMailboxMessage,
+ createMemorySubagentEventSink,
+ createStaticSubagentRunner,
+ createSubagentController,
+ createSubagentId,
+ createSubagentProfileRegistry,
+ createSubagentRegistry,
+ describeSubagentProfile,
+ emitSubagentEvent,
+ filterSubagentTools,
+ isActiveSubagentStatus,
+ isTerminalSubagentStatus,
+ normalizeSubagentPath,
+ resetSubagentIdCounterForTests,
+ resolveSubagentContextPolicy,
+ resolveSubagentCostPolicy,
+ resolveSubagentPath,
+} from "./subagents";
+
// Sandbox interface
export type { ExecOptions, ExecResult, Sandbox } from "./sandbox/interface";
// Tool output types
diff --git a/src/subagents/AGENTS.md b/src/subagents/AGENTS.md
new file mode 100644
index 0000000..1d9243d
--- /dev/null
+++ b/src/subagents/AGENTS.md
@@ -0,0 +1,64 @@
+# Subagents Module
+
+Foundation for controller-managed subagents. This module owns identity, path resolution, profile resolution, tool filtering, lifecycle status, mailbox records, event contracts, stores, runners, controller orchestration, and guardrail policies.
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `types.ts` | Shared public contracts for subagents, profiles, controller requests/results, runners, stores, events, and policies |
+| `identity.ts` | Generated subagent IDs |
+| `path.ts` | Task-name normalization and relative path resolution |
+| `status.ts` | Status helpers and terminal-state checks |
+| `profiles.ts` | Profile registry factory and profile resolution |
+| `profile-descriptions.ts` | Model-visible profile description generation |
+| `tool-filter.ts` | Tool allowlist/denylist filtering |
+| `registry.ts` | In-memory identity and path registry factory |
+| `store.ts` | Store factory for in-memory metadata, events, and mailbox records |
+| `events.ts` | Event sink factory and helpers |
+| `mailbox.ts` | Mailbox helpers |
+| `runner.ts` | Runner capability helpers and fake test runner utilities |
+| `execution-limits.ts` | Active/total/depth/mailbox/wait limit policy |
+| `cost-control.ts` | Budget-backed policy checks |
+| `controller.ts` | Subagent controller orchestration |
+| `index.ts` | Barrel exports |
+
+## Architecture
+
+`createSubagentController` owns orchestration through closure state. It resolves profiles, reserves identity, checks guardrails, writes store records, emits events, invokes lifecycle hooks, and delegates actual child execution to a `SubagentRunner`.
+
+The default foundation does not put subagent methods on `Sandbox`. Child agents use sandbox-backed tools through their runner/tool surface.
+
+## Design Rules
+
+- Keep tool schemas out of this module. Model-facing tools adapt to these core contracts.
+- Return `{ error: string }` objects at controller/tool boundaries instead of throwing for runtime failures.
+- Use generated IDs as canonical storage identity. Path-like `task_name` values are model-facing references.
+- Keep records JSON-serializable unless a type explicitly represents live runtime configuration.
+- Avoid `any`; use `unknown`, narrow types, and explicit interfaces.
+- Prefer factory functions that return typed plain objects over classes.
+- Prefer small pure helpers where possible so controller behavior is easy to test with fake runners.
+
+## Common Modifications
+
+### Add a new controller capability
+
+1. Add request/result types in `types.ts`.
+2. Implement orchestration in `controller.ts`.
+3. Persist necessary state through `store.ts`.
+4. Emit a typed event in `events.ts` if host UIs should observe it.
+5. Add focused tests in `tests/subagents/`.
+
+### Add a new profile field
+
+1. Add it to profile input and resolved profile types.
+2. Resolve defaults in `profiles.ts`.
+3. Include model-visible text in `profile-descriptions.ts` when it helps routing.
+4. Add tests for defaulting and override behavior.
+
+### Add a new guardrail
+
+1. Add policy input and resolved policy fields in `types.ts`.
+2. Implement the check in `execution-limits.ts` or `cost-control.ts`.
+3. Call it from `controller.ts` before expensive runner work starts.
+4. Test rejection and reservation cleanup.
diff --git a/src/subagents/CLAUDE.md b/src/subagents/CLAUDE.md
new file mode 120000
index 0000000..47dc3e3
--- /dev/null
+++ b/src/subagents/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/src/subagents/controller.ts b/src/subagents/controller.ts
new file mode 100644
index 0000000..e7e8c2f
--- /dev/null
+++ b/src/subagents/controller.ts
@@ -0,0 +1,374 @@
+import { checkSubagentCostPolicy } from "./cost-control";
+import { clampSubagentWaitTimeout } from "./execution-limits";
+import { createMailboxMessage } from "./mailbox";
+import { createSubagentProfileRegistry } from "./profiles";
+import { createSubagentRegistry } from "./registry";
+import { isActiveSubagentStatus, isTerminalSubagentStatus } from "./status";
+import { createInMemorySubagentStore } from "./store";
+import { filterSubagentTools } from "./tool-filter";
+import type {
+ ResolvedSubagentProfile,
+ SubagentController,
+ SubagentControllerConfig,
+ SubagentError,
+ SubagentEvent,
+ SubagentFollowupRequest,
+ SubagentHandle,
+ SubagentInterruptRequest,
+ SubagentInterruptResult,
+ SubagentListFilter,
+ SubagentMailboxMessage,
+ SubagentMessageRequest,
+ SubagentMessageResult,
+ SubagentMetadata,
+ SubagentRunResult,
+ SubagentSpawnRequest,
+ SubagentSpawnResult,
+ SubagentStatus,
+ SubagentWaitRequest,
+ SubagentWaitResult,
+} from "./types";
+
+function hasError(value: T | SubagentError): value is SubagentError {
+ return typeof value === "object" && value !== null && "error" in value;
+}
+
+function eventFromMetadata(
+ metadata: SubagentMetadata,
+ type: SubagentEvent["type"],
+ payload?: SubagentEvent["payload"],
+): SubagentEvent {
+ return {
+ type,
+ agent_id: metadata.agent_id,
+ task_name: metadata.task_name,
+ parent_id: metadata.parent_id,
+ profile: metadata.profile,
+ status: metadata.status,
+ timestamp: new Date().toISOString(),
+ payload,
+ };
+}
+
+export function createSubagentController(
+ config: SubagentControllerConfig,
+): SubagentController {
+ const registry = createSubagentRegistry();
+ const profiles = createSubagentProfileRegistry({
+ profiles: config.profiles,
+ defaults: config.profileDefaults,
+ defaultProfile: config.defaultProfile,
+ });
+ const store = config.store ?? createInMemorySubagentStore();
+ const runner = config.runner;
+ const tools = config.tools ?? {};
+ const eventSink = config.eventSink;
+ const lifecycle = config.lifecycle;
+ const budget = config.budget;
+
+ async function emit(event: SubagentEvent): Promise {
+ await store.appendEvent(event);
+ await eventSink?.emit(event);
+ }
+
+ async function setStatus(
+ handle: SubagentHandle,
+ status: SubagentStatus,
+ payload?: SubagentEvent["payload"],
+ ): Promise {
+ const metadata = registry.updateStatus(handle, status);
+ if (hasError(metadata)) return;
+ await store.update(handle, { status });
+ await emit(eventFromMetadata(metadata, "subagent.status_changed", payload));
+ }
+
+ async function complete(
+ handle: SubagentHandle,
+ result: SubagentRunResult,
+ ): Promise {
+ registry.release(handle);
+ const metadata = await store.complete(handle, result);
+ if (hasError(metadata)) return;
+
+ if (result.status === "completed") {
+ await lifecycle?.onComplete?.(result);
+ await emit(eventFromMetadata(metadata, "subagent.completed"));
+ } else if (result.status === "failed") {
+ await lifecycle?.onFail?.(result);
+ await emit(
+ eventFromMetadata(metadata, "subagent.failed", {
+ error: result.error ?? null,
+ }),
+ );
+ } else {
+ await emit(eventFromMetadata(metadata, "subagent.interrupted"));
+ }
+ await lifecycle?.onStop?.(result);
+ }
+
+ function resolveHandle(agentRef: string): SubagentHandle | SubagentError {
+ const metadata = registry.get(agentRef);
+ if (!metadata) return { error: `Unknown subagent: ${agentRef}` };
+ return {
+ agent_id: metadata.agent_id,
+ task_name: metadata.task_name,
+ };
+ }
+
+ async function runSubagent(
+ handle: SubagentHandle,
+ request: SubagentSpawnRequest,
+ profile: ResolvedSubagentProfile,
+ depth: number,
+ ): Promise {
+ await setStatus(handle, "running");
+ const childTools = filterSubagentTools(tools, {
+ allowedTools: profile.allowedTools,
+ deniedTools: profile.deniedTools,
+ });
+
+ try {
+ const result = await runner.run({
+ handle,
+ task: request.task,
+ profile,
+ parent_id: request.parent_id ?? null,
+ depth,
+ tools: childTools,
+ messages: request.messages,
+ metadata: request.metadata,
+ callbacks: {
+ onStatus: async (status) => {
+ await setStatus(handle, status);
+ },
+ onEvent: async (event) => {
+ await emit({ ...event, timestamp: new Date().toISOString() });
+ },
+ onUsage: async (usage) => {
+ await store.update(handle, { usage });
+ },
+ },
+ });
+ await complete(handle, result);
+ } catch (error) {
+ await complete(handle, {
+ agent_id: handle.agent_id,
+ task_name: handle.task_name,
+ status: "failed",
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+
+ async function enqueueMessage(
+ agentRef: string,
+ message: string,
+ kind: SubagentMailboxMessage["kind"],
+ triggerTurn: boolean,
+ metadata: SubagentMailboxMessage["metadata"],
+ ): Promise {
+ if (!message.trim()) return { error: "Subagent message cannot be empty" };
+ const handle = resolveHandle(agentRef);
+ if (hasError(handle)) return handle;
+ const record = await store.get(handle);
+ if (!record) return { error: `Unknown subagent: ${agentRef}` };
+ const profile = profiles.resolve(record.metadata.profile);
+ if (hasError(profile)) return profile;
+
+ const policyError = checkSubagentCostPolicy(profile.cost, {
+ activeAgents: registry
+ .list()
+ .filter((agent) => isActiveSubagentStatus(agent.status)).length,
+ totalAgents: registry.list().length,
+ depth: record.metadata.depth,
+ mailboxMessages: record.mailbox.length,
+ budgetStatus: budget?.getStatus(),
+ });
+ if (policyError) return policyError;
+
+ const mailboxMessage = createMailboxMessage({
+ agentId: handle.agent_id,
+ message,
+ kind,
+ triggerTurn,
+ metadata,
+ });
+ await store.appendMessage(mailboxMessage);
+ await store.update(handle, { last_task_message: message });
+ await lifecycle?.onMessage?.(mailboxMessage);
+ await emit(
+ eventFromMetadata(record.metadata, "subagent.message_queued", {
+ message_id: mailboxMessage.id,
+ kind,
+ }),
+ );
+ return {
+ queued: true,
+ agent_id: handle.agent_id,
+ message_id: mailboxMessage.id,
+ triggered_turn: triggerTurn && runner.capabilities.followup,
+ };
+ }
+
+ return {
+ async spawn(
+ request: SubagentSpawnRequest,
+ ): Promise {
+ if (!request.task.trim())
+ return { error: "Subagent task cannot be empty" };
+
+ const beforeSpawn = await lifecycle?.onBeforeSpawn?.(request);
+ if (beforeSpawn && "error" in beforeSpawn) return beforeSpawn;
+
+ const profile = profiles.resolve(request.profile, {
+ allowedTools: request.tools ?? undefined,
+ context: request.context,
+ });
+ if (hasError(profile)) return profile;
+
+ const parent = request.parent_id ? registry.get(request.parent_id) : null;
+ const depth = parent ? parent.depth + 1 : 0;
+ const activeAgents = registry
+ .list()
+ .filter((agent) => isActiveSubagentStatus(agent.status)).length;
+ const policyError = checkSubagentCostPolicy(profile.cost, {
+ activeAgents,
+ totalAgents: registry.list().length,
+ depth,
+ mailboxMessages: 0,
+ budgetStatus: budget?.getStatus(),
+ });
+ if (policyError) return policyError;
+
+ const metadata = registry.reserve({
+ taskName: request.task_name,
+ profile: profile.name,
+ nickname: profile.nickname,
+ parentId: request.parent_id ?? null,
+ depth,
+ lastTaskMessage: request.task,
+ });
+ if (hasError(metadata)) return metadata;
+
+ const handle: SubagentHandle = {
+ agent_id: metadata.agent_id,
+ task_name: metadata.task_name,
+ };
+
+ await store.create(metadata);
+ await emit(eventFromMetadata(metadata, "subagent.created"));
+ await lifecycle?.onStart?.(metadata);
+
+ void runSubagent(handle, request, profile, depth);
+
+ return {
+ ...handle,
+ status: metadata.status,
+ profile: metadata.profile,
+ nickname: metadata.nickname,
+ };
+ },
+
+ async list(filter?: SubagentListFilter): Promise {
+ const records = await store.list(filter);
+ return records.map((record) => record.metadata);
+ },
+
+ async wait(
+ request: SubagentWaitRequest,
+ ): Promise {
+ const handle = resolveHandle(request.agent);
+ if (hasError(handle)) return handle;
+ const record = await store.get(handle);
+ if (!record) return { error: `Unknown subagent: ${request.agent}` };
+
+ const profile = profiles.resolve(record.metadata.profile);
+ if (hasError(profile)) return profile;
+ const timeoutMs = clampSubagentWaitTimeout(
+ request.timeoutMs,
+ profile.cost,
+ );
+ const deadline = Date.now() + timeoutMs;
+
+ while (Date.now() < deadline) {
+ const latest = await store.get(handle);
+ if (!latest) return { error: `Unknown subagent: ${request.agent}` };
+ if (
+ isTerminalSubagentStatus(latest.metadata.status) ||
+ (request.untilStatus &&
+ latest.metadata.status === request.untilStatus)
+ ) {
+ return {
+ status: "ready",
+ agent: latest.metadata,
+ result: latest.result,
+ };
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+
+ const latest = await store.get(handle);
+ if (!latest) return { error: `Unknown subagent: ${request.agent}` };
+ return { status: "timeout", agent: latest.metadata };
+ },
+
+ async sendMessage(
+ request: SubagentMessageRequest,
+ ): Promise {
+ return enqueueMessage(
+ request.agent,
+ request.message,
+ "message",
+ false,
+ request.metadata,
+ );
+ },
+
+ async followupTask(
+ request: SubagentFollowupRequest,
+ ): Promise {
+ const result = await enqueueMessage(
+ request.agent,
+ request.task,
+ "followup",
+ true,
+ request.metadata,
+ );
+ if (hasError(result)) return result;
+ if (!runner.capabilities.followup || !runner.requestTurn) return result;
+
+ const handle = resolveHandle(request.agent);
+ if (hasError(handle)) return handle;
+ const turnResult = await runner.requestTurn(handle);
+ if (hasError(turnResult)) return turnResult;
+ return { ...result, triggered_turn: turnResult.triggered_turn };
+ },
+
+ async interrupt(
+ request: SubagentInterruptRequest,
+ ): Promise {
+ const handle = resolveHandle(request.agent);
+ if (hasError(handle)) return handle;
+ const record = await store.get(handle);
+ if (!record) return { error: `Unknown subagent: ${request.agent}` };
+ const previous = record.metadata.status;
+
+ if (!runner.capabilities.interrupt || !runner.interrupt) {
+ return { error: "Subagent runner does not support interrupt" };
+ }
+
+ const runnerResult = await runner.interrupt(handle);
+ if (hasError(runnerResult)) return runnerResult;
+ await setStatus(handle, "interrupted", {
+ reason: request.reason ?? null,
+ });
+ const result = {
+ agent_id: handle.agent_id,
+ previous_status: previous,
+ status: "interrupted" as const,
+ };
+ await lifecycle?.onInterrupt?.(result);
+ return result;
+ },
+ };
+}
diff --git a/src/subagents/cost-control.ts b/src/subagents/cost-control.ts
new file mode 100644
index 0000000..0607bbb
--- /dev/null
+++ b/src/subagents/cost-control.ts
@@ -0,0 +1,30 @@
+import type {
+ SubagentCostPolicy,
+ SubagentError,
+ SubagentPolicyState,
+} from "./types";
+import { checkSubagentExecutionLimits } from "./execution-limits";
+
+export function checkSubagentCostPolicy(
+ policy: SubagentCostPolicy,
+ state: SubagentPolicyState,
+): SubagentError | null {
+ const executionError = checkSubagentExecutionLimits(policy, state);
+ if (executionError) return executionError;
+
+ if (state.budgetStatus?.exceeded) {
+ return { error: "Subagent budget is already exhausted" };
+ }
+
+ if (
+ policy.maxUsd != null &&
+ state.budgetStatus &&
+ state.budgetStatus.totalCostUsd >= policy.maxUsd
+ ) {
+ return {
+ error: `Subagent profile budget limit reached ($${policy.maxUsd})`,
+ };
+ }
+
+ return null;
+}
diff --git a/src/subagents/events.ts b/src/subagents/events.ts
new file mode 100644
index 0000000..3cb9ed8
--- /dev/null
+++ b/src/subagents/events.ts
@@ -0,0 +1,22 @@
+import type { SubagentEvent, SubagentEventSink } from "./types";
+
+export interface MemorySubagentEventSink extends SubagentEventSink {
+ readonly events: SubagentEvent[];
+}
+
+export function createMemorySubagentEventSink(): MemorySubagentEventSink {
+ const events: SubagentEvent[] = [];
+ return {
+ events,
+ emit(event: SubagentEvent): void {
+ events.push(event);
+ },
+ };
+}
+
+export async function emitSubagentEvent(
+ sink: SubagentEventSink | undefined,
+ event: SubagentEvent,
+): Promise {
+ await sink?.emit(event);
+}
diff --git a/src/subagents/execution-limits.ts b/src/subagents/execution-limits.ts
new file mode 100644
index 0000000..d1bd704
--- /dev/null
+++ b/src/subagents/execution-limits.ts
@@ -0,0 +1,39 @@
+import type {
+ SubagentCostPolicy,
+ SubagentError,
+ SubagentPolicyState,
+} from "./types";
+
+export function checkSubagentExecutionLimits(
+ policy: SubagentCostPolicy,
+ state: SubagentPolicyState,
+): SubagentError | null {
+ if (state.activeAgents >= policy.maxActiveAgents) {
+ return {
+ error: `Subagent active limit reached (${policy.maxActiveAgents})`,
+ };
+ }
+ if (state.totalAgents >= policy.maxTotalAgents) {
+ return { error: `Subagent total limit reached (${policy.maxTotalAgents})` };
+ }
+ if (state.depth > policy.maxDepth) {
+ return { error: `Subagent depth limit reached (${policy.maxDepth})` };
+ }
+ if (state.mailboxMessages >= policy.maxMailboxMessages) {
+ return {
+ error: `Subagent mailbox limit reached (${policy.maxMailboxMessages})`,
+ };
+ }
+ return null;
+}
+
+export function clampSubagentWaitTimeout(
+ timeoutMs: number | null | undefined,
+ policy: SubagentCostPolicy,
+): number {
+ const requested = timeoutMs ?? policy.maxWaitTimeoutMs;
+ return Math.min(
+ policy.maxWaitTimeoutMs,
+ Math.max(policy.minWaitTimeoutMs, requested),
+ );
+}
diff --git a/src/subagents/identity.ts b/src/subagents/identity.ts
new file mode 100644
index 0000000..d69f494
--- /dev/null
+++ b/src/subagents/identity.ts
@@ -0,0 +1,13 @@
+import type { SubagentId } from "./types";
+
+let nextSubagentId = 1;
+
+export function createSubagentId(prefix = "agent"): SubagentId {
+ const id = `${prefix}_${nextSubagentId.toString(36)}`;
+ nextSubagentId++;
+ return id;
+}
+
+export function resetSubagentIdCounterForTests(): void {
+ nextSubagentId = 1;
+}
diff --git a/src/subagents/index.ts b/src/subagents/index.ts
new file mode 100644
index 0000000..c1e16f1
--- /dev/null
+++ b/src/subagents/index.ts
@@ -0,0 +1,83 @@
+export {
+ createSubagentId,
+ resetSubagentIdCounterForTests,
+} from "./identity";
+export { normalizeSubagentPath, resolveSubagentPath } from "./path";
+export {
+ DEFAULT_SUBAGENT_CODEMODE_POLICY,
+ DEFAULT_SUBAGENT_CONTEXT_POLICY,
+ DEFAULT_SUBAGENT_COST_POLICY,
+ DEFAULT_SUBAGENT_PROFILE_NAME,
+ createSubagentProfileRegistry,
+ resolveSubagentContextPolicy,
+ resolveSubagentCostPolicy,
+} from "./profiles";
+export { describeSubagentProfile } from "./profile-descriptions";
+export { filterSubagentTools } from "./tool-filter";
+export { createSubagentRegistry } from "./registry";
+export { createInMemorySubagentStore } from "./store";
+export {
+ createMemorySubagentEventSink,
+ emitSubagentEvent,
+} from "./events";
+export { createMailboxMessage } from "./mailbox";
+export {
+ DEFAULT_SUBAGENT_RUNNER_CAPABILITIES,
+ createStaticSubagentRunner,
+} from "./runner";
+export {
+ clampSubagentWaitTimeout,
+ checkSubagentExecutionLimits,
+} from "./execution-limits";
+export { checkSubagentCostPolicy } from "./cost-control";
+export { createSubagentController } from "./controller";
+export { isActiveSubagentStatus, isTerminalSubagentStatus } from "./status";
+export type {
+ JsonObject,
+ JsonPrimitive,
+ JsonValue,
+ ResolvedSubagentProfile,
+ ResolvedSubagentRunRequest,
+ SubagentCodemodePolicy,
+ SubagentContextPolicy,
+ SubagentContextPolicyInput,
+ SubagentController,
+ SubagentControllerConfig,
+ SubagentCostPolicy,
+ SubagentCostPolicyInput,
+ SubagentError,
+ SubagentEvent,
+ SubagentEventSink,
+ SubagentEventType,
+ SubagentFollowupRequest,
+ SubagentHandle,
+ SubagentId,
+ SubagentInterruptRequest,
+ SubagentInterruptResult,
+ SubagentLifecycleHooks,
+ SubagentListFilter,
+ SubagentMailboxMessage,
+ SubagentMessageRequest,
+ SubagentMessageResult,
+ SubagentMetadata,
+ SubagentMetadataPatch,
+ SubagentPath,
+ SubagentPolicyState,
+ SubagentProfileDefaults,
+ SubagentProfileInput,
+ SubagentProfileRegistry,
+ SubagentRecord,
+ SubagentRegistry,
+ SubagentReservationRequest,
+ SubagentRunResult,
+ SubagentRunner,
+ SubagentRunnerCallbacks,
+ SubagentRunnerCapabilities,
+ SubagentSpawnRequest,
+ SubagentSpawnResult,
+ SubagentStatus,
+ SubagentStore,
+ SubagentUsage,
+ SubagentWaitRequest,
+ SubagentWaitResult,
+} from "./types";
diff --git a/src/subagents/mailbox.ts b/src/subagents/mailbox.ts
new file mode 100644
index 0000000..dce7768
--- /dev/null
+++ b/src/subagents/mailbox.ts
@@ -0,0 +1,20 @@
+import { createSubagentId } from "./identity";
+import type { JsonObject, SubagentId, SubagentMailboxMessage } from "./types";
+
+export function createMailboxMessage(options: {
+ agentId: SubagentId;
+ message: string;
+ kind: "message" | "followup";
+ triggerTurn: boolean;
+ metadata?: JsonObject;
+}): SubagentMailboxMessage {
+ return {
+ id: createSubagentId("message"),
+ agent_id: options.agentId,
+ message: options.message,
+ kind: options.kind,
+ trigger_turn: options.triggerTurn,
+ created_at: new Date().toISOString(),
+ metadata: options.metadata,
+ };
+}
diff --git a/src/subagents/path.ts b/src/subagents/path.ts
new file mode 100644
index 0000000..046d432
--- /dev/null
+++ b/src/subagents/path.ts
@@ -0,0 +1,76 @@
+import type { SubagentError, SubagentPath } from "./types";
+
+export function normalizeSubagentPath(
+ path: string | null | undefined,
+): SubagentPath | null | SubagentError {
+ if (path == null) return null;
+ const trimmed = path.trim();
+ if (!trimmed) return { error: "task_name cannot be empty" };
+ if (trimmed.startsWith("/") || trimmed.endsWith("/")) {
+ return {
+ error:
+ "task_name must be a relative path without leading or trailing slash",
+ };
+ }
+
+ const parts = trimmed.split("/");
+ const normalized: string[] = [];
+ for (const part of parts) {
+ if (!part || part === ".") continue;
+ if (part === "..") {
+ if (normalized.length === 0) {
+ return { error: "task_name cannot escape above the root path" };
+ }
+ normalized.pop();
+ continue;
+ }
+ if (!/^[a-zA-Z0-9._-]+$/.test(part)) {
+ return {
+ error:
+ "task_name segments may only contain letters, numbers, dots, underscores, and dashes",
+ };
+ }
+ normalized.push(part);
+ }
+
+ if (normalized.length === 0) return { error: "task_name cannot be empty" };
+ return normalized.join("/");
+}
+
+export function resolveSubagentPath(
+ reference: string,
+ options?: { currentPath?: string | null; parentPath?: string | null },
+): SubagentPath | SubagentError {
+ if (!reference.startsWith("./") && !reference.startsWith("../")) {
+ const normalizedReference = normalizeSubagentPath(reference);
+ if (normalizedReference == null) {
+ return { error: "agent path reference cannot be empty" };
+ }
+ if (typeof normalizedReference !== "string") return normalizedReference;
+ return normalizedReference;
+ }
+
+ const base = options?.currentPath ?? options?.parentPath ?? null;
+ const baseParts = base ? base.split("/") : [];
+ const rawParts = reference.split("/");
+ const resolved = [...baseParts];
+
+ for (const part of rawParts) {
+ if (!part || part === ".") continue;
+ if (part === "..") {
+ if (resolved.length === 0) {
+ return {
+ error: "agent path reference cannot escape above the root path",
+ };
+ }
+ resolved.pop();
+ } else {
+ resolved.push(part);
+ }
+ }
+
+ const normalized = normalizeSubagentPath(resolved.join("/"));
+ if (normalized == null)
+ return { error: "agent path reference cannot be empty" };
+ return normalized;
+}
diff --git a/src/subagents/profile-descriptions.ts b/src/subagents/profile-descriptions.ts
new file mode 100644
index 0000000..8ff0e84
--- /dev/null
+++ b/src/subagents/profile-descriptions.ts
@@ -0,0 +1,23 @@
+import type { ResolvedSubagentProfile } from "./types";
+
+export function describeSubagentProfile(
+ profile: ResolvedSubagentProfile,
+): string {
+ const parts = [
+ `${profile.name}: ${profile.description}`,
+ `codemode=${profile.codemode.enabled ? "enabled" : "disabled"}`,
+ ];
+
+ if (profile.allowedTools.length > 0) {
+ parts.push(`allowed tools: ${profile.allowedTools.join(", ")}`);
+ }
+ if (profile.deniedTools.length > 0) {
+ parts.push(`denied tools: ${profile.deniedTools.join(", ")}`);
+ }
+ if (profile.cost.maxUsd != null) {
+ parts.push(`budget cap: $${profile.cost.maxUsd}`);
+ }
+ parts.push(`max depth: ${profile.cost.maxDepth}`);
+
+ return parts.join("; ");
+}
diff --git a/src/subagents/profiles.ts b/src/subagents/profiles.ts
new file mode 100644
index 0000000..b0d522d
--- /dev/null
+++ b/src/subagents/profiles.ts
@@ -0,0 +1,159 @@
+import type {
+ ResolvedSubagentProfile,
+ SubagentCodemodePolicy,
+ SubagentContextPolicy,
+ SubagentContextPolicyInput,
+ SubagentCostPolicy,
+ SubagentCostPolicyInput,
+ SubagentError,
+ SubagentProfileDefaults,
+ SubagentProfileInput,
+ SubagentProfileRegistry,
+} from "./types";
+
+export const DEFAULT_SUBAGENT_PROFILE_NAME = "worker";
+
+export const DEFAULT_SUBAGENT_CONTEXT_POLICY: SubagentContextPolicy = {
+ mode: "recent",
+ turns: 3,
+};
+
+export const DEFAULT_SUBAGENT_CODEMODE_POLICY: SubagentCodemodePolicy = {
+ enabled: true,
+ exposeDirectTools: false,
+};
+
+export const DEFAULT_SUBAGENT_COST_POLICY: SubagentCostPolicy = {
+ maxUsd: null,
+ maxActiveAgents: 4,
+ maxTotalAgents: 25,
+ maxDepth: 2,
+ maxMailboxMessages: 100,
+ minWaitTimeoutMs: 100,
+ maxWaitTimeoutMs: 30_000,
+};
+
+export function resolveSubagentContextPolicy(
+ input: SubagentContextPolicyInput | undefined,
+ fallback: SubagentContextPolicy = DEFAULT_SUBAGENT_CONTEXT_POLICY,
+): SubagentContextPolicy | SubagentError {
+ if (!input) return fallback;
+ if (input === "none") return { mode: "none" };
+ if (input === "all") return { mode: "all" };
+ if ("recent_turns" in input) {
+ if (!Number.isInteger(input.recent_turns) || input.recent_turns < 0) {
+ return { error: "recent_turns must be a non-negative integer" };
+ }
+ return { mode: "recent", turns: input.recent_turns };
+ }
+ if (input.mode === "recent") {
+ if (!Number.isInteger(input.turns) || input.turns < 0) {
+ return { error: "recent context turns must be a non-negative integer" };
+ }
+ }
+ return input;
+}
+
+export function resolveSubagentCostPolicy(
+ input?: SubagentCostPolicyInput,
+ fallback: SubagentCostPolicy = DEFAULT_SUBAGENT_COST_POLICY,
+): SubagentCostPolicy {
+ return {
+ maxUsd: input?.maxUsd ?? fallback.maxUsd,
+ maxActiveAgents: input?.maxActiveAgents ?? fallback.maxActiveAgents,
+ maxTotalAgents: input?.maxTotalAgents ?? fallback.maxTotalAgents,
+ maxDepth: input?.maxDepth ?? fallback.maxDepth,
+ maxMailboxMessages:
+ input?.maxMailboxMessages ?? fallback.maxMailboxMessages,
+ minWaitTimeoutMs: input?.minWaitTimeoutMs ?? fallback.minWaitTimeoutMs,
+ maxWaitTimeoutMs: input?.maxWaitTimeoutMs ?? fallback.maxWaitTimeoutMs,
+ };
+}
+
+export function createSubagentProfileRegistry(options?: {
+ profiles?: SubagentProfileInput[];
+ defaults?: SubagentProfileDefaults;
+ defaultProfile?: string;
+}): SubagentProfileRegistry {
+ const profiles = new Map();
+ const defaults = options?.defaults ?? {};
+ const defaultProfile =
+ options?.defaultProfile ?? DEFAULT_SUBAGENT_PROFILE_NAME;
+
+ function register(profile: SubagentProfileInput): void {
+ profiles.set(profile.name, profile);
+ }
+
+ register({
+ name: DEFAULT_SUBAGENT_PROFILE_NAME,
+ description:
+ "General-purpose child agent for delegated implementation or investigation.",
+ nickname: "worker",
+ });
+
+ for (const profile of options?.profiles ?? []) {
+ register(profile);
+ }
+
+ return {
+ register,
+ has(name: string): boolean {
+ return profiles.has(name);
+ },
+ resolve(
+ name: string | undefined,
+ overrides?: Partial,
+ ): ResolvedSubagentProfile | SubagentError {
+ const profileName = name ?? defaultProfile;
+ const profile = profiles.get(profileName);
+ if (!profile)
+ return { error: `Unknown subagent profile: ${profileName}` };
+
+ const context = resolveSubagentContextPolicy(
+ overrides?.context ?? profile.context ?? defaults.context,
+ );
+ if ("error" in context) return context;
+
+ const baseCost = resolveSubagentCostPolicy(defaults.cost);
+ const profileCost = resolveSubagentCostPolicy(profile.cost, baseCost);
+ const cost = resolveSubagentCostPolicy(overrides?.cost, profileCost);
+
+ const codemode: SubagentCodemodePolicy = {
+ ...DEFAULT_SUBAGENT_CODEMODE_POLICY,
+ ...defaults.codemode,
+ ...profile.codemode,
+ ...overrides?.codemode,
+ };
+
+ const metadata = {
+ ...(profile.metadata ?? {}),
+ ...(overrides?.metadata ?? {}),
+ };
+
+ return {
+ name: profile.name,
+ description:
+ overrides?.description ??
+ profile.description ??
+ `Subagent profile ${profile.name}`,
+ nickname: overrides?.nickname ?? profile.nickname ?? null,
+ model: overrides?.model ?? profile.model ?? defaults.model,
+ system: overrides?.system ?? profile.system ?? defaults.system ?? "",
+ allowedTools: [
+ ...(defaults.allowedTools ?? []),
+ ...(profile.allowedTools ?? []),
+ ...(overrides?.allowedTools ?? []),
+ ],
+ deniedTools: [
+ ...(defaults.deniedTools ?? []),
+ ...(profile.deniedTools ?? []),
+ ...(overrides?.deniedTools ?? []),
+ ],
+ codemode,
+ context,
+ cost,
+ metadata,
+ };
+ },
+ };
+}
diff --git a/src/subagents/registry.ts b/src/subagents/registry.ts
new file mode 100644
index 0000000..a6275ef
--- /dev/null
+++ b/src/subagents/registry.ts
@@ -0,0 +1,89 @@
+import { createSubagentId } from "./identity";
+import { normalizeSubagentPath } from "./path";
+import { isTerminalSubagentStatus } from "./status";
+import type {
+ SubagentError,
+ SubagentHandle,
+ SubagentRegistry,
+ SubagentMetadata,
+ SubagentReservationRequest,
+ SubagentStatus,
+} from "./types";
+
+export function createSubagentRegistry(): SubagentRegistry {
+ const records = new Map();
+ const livePaths = new Map();
+
+ return {
+ reserve(
+ request: SubagentReservationRequest,
+ ): SubagentMetadata | SubagentError {
+ const normalizedPath = normalizeSubagentPath(request.taskName);
+ if (normalizedPath && typeof normalizedPath !== "string")
+ return normalizedPath;
+ if (normalizedPath && livePaths.has(normalizedPath)) {
+ return {
+ error: `Subagent task_name already exists: ${normalizedPath}`,
+ };
+ }
+
+ const now = new Date().toISOString();
+ const metadata: SubagentMetadata = {
+ agent_id: createSubagentId(),
+ task_name: normalizedPath,
+ profile: request.profile,
+ nickname: request.nickname,
+ status: "pending",
+ parent_id: request.parentId ?? null,
+ depth: request.depth,
+ last_task_message: request.lastTaskMessage,
+ created_at: now,
+ updated_at: now,
+ metadata: {},
+ };
+
+ records.set(metadata.agent_id, metadata);
+ if (metadata.task_name)
+ livePaths.set(metadata.task_name, metadata.agent_id);
+ return metadata;
+ },
+
+ get(handleOrRef: SubagentHandle | string): SubagentMetadata | null {
+ const id =
+ typeof handleOrRef === "string"
+ ? (livePaths.get(handleOrRef) ?? handleOrRef)
+ : handleOrRef.agent_id;
+ return records.get(id) ?? null;
+ },
+
+ updateStatus(
+ handle: SubagentHandle,
+ status: SubagentStatus,
+ ): SubagentMetadata | SubagentError {
+ const metadata = records.get(handle.agent_id);
+ if (!metadata) return { error: `Unknown subagent: ${handle.agent_id}` };
+
+ const updated = {
+ ...metadata,
+ status,
+ updated_at: new Date().toISOString(),
+ };
+ records.set(handle.agent_id, updated);
+
+ if (updated.task_name && isTerminalSubagentStatus(status)) {
+ livePaths.delete(updated.task_name);
+ }
+
+ return updated;
+ },
+
+ release(handle: SubagentHandle): void {
+ const metadata = records.get(handle.agent_id);
+ if (metadata?.task_name) livePaths.delete(metadata.task_name);
+ },
+
+ list(): SubagentMetadata[] {
+ return [...records.values()];
+ },
+ };
+}
diff --git a/src/subagents/runner.ts b/src/subagents/runner.ts
new file mode 100644
index 0000000..9f73e84
--- /dev/null
+++ b/src/subagents/runner.ts
@@ -0,0 +1,27 @@
+import type {
+ SubagentRunner,
+ SubagentRunnerCapabilities,
+ SubagentRunResult,
+} from "./types";
+
+export const DEFAULT_SUBAGENT_RUNNER_CAPABILITIES: SubagentRunnerCapabilities =
+ {
+ interrupt: false,
+ followup: false,
+ };
+
+export function createStaticSubagentRunner(
+ result: Pick,
+): SubagentRunner {
+ return {
+ capabilities: DEFAULT_SUBAGENT_RUNNER_CAPABILITIES,
+ async run(request) {
+ await request.callbacks.onStatus("running");
+ return {
+ agent_id: request.handle.agent_id,
+ task_name: request.handle.task_name,
+ ...result,
+ };
+ },
+ };
+}
diff --git a/src/subagents/status.ts b/src/subagents/status.ts
new file mode 100644
index 0000000..c34f2d6
--- /dev/null
+++ b/src/subagents/status.ts
@@ -0,0 +1,15 @@
+import type { SubagentStatus } from "./types";
+
+const TERMINAL_STATUSES = new Set([
+ "completed",
+ "failed",
+ "interrupted",
+]);
+
+export function isTerminalSubagentStatus(status: SubagentStatus): boolean {
+ return TERMINAL_STATUSES.has(status);
+}
+
+export function isActiveSubagentStatus(status: SubagentStatus): boolean {
+ return status === "pending" || status === "running" || status === "waiting";
+}
diff --git a/src/subagents/store.ts b/src/subagents/store.ts
new file mode 100644
index 0000000..b6f7875
--- /dev/null
+++ b/src/subagents/store.ts
@@ -0,0 +1,109 @@
+import type {
+ SubagentError,
+ SubagentEvent,
+ SubagentHandle,
+ SubagentListFilter,
+ SubagentMailboxMessage,
+ SubagentMetadata,
+ SubagentMetadataPatch,
+ SubagentRecord,
+ SubagentRunResult,
+ SubagentStore,
+} from "./types";
+import { isTerminalSubagentStatus } from "./status";
+
+function cloneRecord(record: SubagentRecord): SubagentRecord {
+ return {
+ metadata: { ...record.metadata, metadata: record.metadata.metadata },
+ result: record.result ? { ...record.result } : undefined,
+ events: record.events.map((event) => ({ ...event })),
+ mailbox: record.mailbox.map((message) => ({ ...message })),
+ };
+}
+
+export function createInMemorySubagentStore(): SubagentStore {
+ const records = new Map();
+
+ return {
+ async create(metadata: SubagentMetadata): Promise {
+ records.set(metadata.agent_id, {
+ metadata,
+ events: [],
+ mailbox: [],
+ });
+ },
+
+ async update(
+ handle: SubagentHandle,
+ patch: SubagentMetadataPatch,
+ ): Promise {
+ const record = records.get(handle.agent_id);
+ if (!record) return { error: `Unknown subagent: ${handle.agent_id}` };
+
+ record.metadata = {
+ ...record.metadata,
+ ...patch,
+ metadata: patch.metadata ?? record.metadata.metadata,
+ updated_at: new Date().toISOString(),
+ };
+ return record.metadata;
+ },
+
+ async appendEvent(event: SubagentEvent): Promise {
+ const record = records.get(event.agent_id);
+ if (record) record.events.push(event);
+ },
+
+ async appendMessage(message: SubagentMailboxMessage): Promise {
+ const record = records.get(message.agent_id);
+ if (record) record.mailbox.push(message);
+ },
+
+ async complete(
+ handle: SubagentHandle,
+ result: SubagentRunResult,
+ ): Promise {
+ const record = records.get(handle.agent_id);
+ if (!record) return { error: `Unknown subagent: ${handle.agent_id}` };
+ record.result = result;
+ record.metadata = {
+ ...record.metadata,
+ status: result.status,
+ usage: result.usage ?? record.metadata.usage,
+ result_ref: result.result_ref ?? record.metadata.result_ref,
+ transcript_ref: result.transcript_ref ?? record.metadata.transcript_ref,
+ updated_at: new Date().toISOString(),
+ };
+ return record.metadata;
+ },
+
+ async get(handle: SubagentHandle): Promise {
+ const record = records.get(handle.agent_id);
+ return record ? cloneRecord(record) : null;
+ },
+
+ async list(filter: SubagentListFilter = {}): Promise {
+ let filteredRecords = [...records.values()];
+ if (filter.status) {
+ filteredRecords = filteredRecords.filter(
+ (record) => record.metadata.status === filter.status,
+ );
+ }
+ if (filter.pathPrefix) {
+ const pathPrefix = filter.pathPrefix;
+ filteredRecords = filteredRecords.filter((record) =>
+ record.metadata.task_name?.startsWith(pathPrefix),
+ );
+ }
+ if (filter.includeTerminal === false) {
+ filteredRecords = filteredRecords.filter(
+ (record) => !isTerminalSubagentStatus(record.metadata.status),
+ );
+ }
+ if (filter.limit != null) {
+ filteredRecords = filteredRecords.slice(0, Math.max(0, filter.limit));
+ }
+ return filteredRecords.map(cloneRecord);
+ },
+ };
+}
diff --git a/src/subagents/tool-filter.ts b/src/subagents/tool-filter.ts
new file mode 100644
index 0000000..ccd09cb
--- /dev/null
+++ b/src/subagents/tool-filter.ts
@@ -0,0 +1,26 @@
+import type { ToolSet } from "ai";
+
+export interface SubagentToolFilter {
+ allowedTools?: readonly string[];
+ deniedTools?: readonly string[];
+}
+
+export function filterSubagentTools(
+ tools: ToolSet,
+ filter: SubagentToolFilter,
+): ToolSet {
+ const allowed =
+ filter.allowedTools && filter.allowedTools.length > 0
+ ? new Set(filter.allowedTools)
+ : null;
+ const denied = new Set(filter.deniedTools ?? []);
+ const selected: ToolSet = {};
+
+ for (const [toolName, tool] of Object.entries(tools)) {
+ if (allowed && !allowed.has(toolName)) continue;
+ if (denied.has(toolName)) continue;
+ selected[toolName] = tool;
+ }
+
+ return selected;
+}
diff --git a/src/subagents/types.ts b/src/subagents/types.ts
new file mode 100644
index 0000000..2982e3d
--- /dev/null
+++ b/src/subagents/types.ts
@@ -0,0 +1,398 @@
+import type { LanguageModel, ModelMessage, ToolSet } from "ai";
+import type { BudgetStatus, BudgetTracker } from "../utils/budget-tracking";
+
+export type JsonPrimitive = string | number | boolean | null;
+export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
+export interface JsonObject {
+ [key: string]: JsonValue;
+}
+
+export interface SubagentError {
+ error: string;
+}
+
+export type SubagentId = string;
+export type SubagentPath = string;
+
+export type SubagentStatus =
+ | "pending"
+ | "running"
+ | "waiting"
+ | "completed"
+ | "failed"
+ | "interrupted";
+
+export interface SubagentHandle {
+ agent_id: SubagentId;
+ task_name: SubagentPath | null;
+}
+
+export interface SubagentUsage {
+ totalCostUsd?: number;
+ stepsCompleted?: number;
+ unpricedSteps?: number;
+ inputTokens?: number;
+ outputTokens?: number;
+}
+
+export interface SubagentRunResult {
+ agent_id: SubagentId;
+ task_name: SubagentPath | null;
+ status: Extract;
+ result?: string;
+ error?: string;
+ usage?: SubagentUsage;
+ result_ref?: string;
+ transcript_ref?: string;
+ metadata?: JsonObject;
+}
+
+export type SubagentContextPolicy =
+ | { mode: "none" }
+ | { mode: "all" }
+ | { mode: "recent"; turns: number };
+
+export type SubagentContextPolicyInput =
+ | "none"
+ | "all"
+ | { recent_turns: number }
+ | SubagentContextPolicy;
+
+export interface SubagentCodemodePolicy {
+ enabled: boolean;
+ exposeDirectTools: boolean;
+ includeTools?: string[];
+ excludeTools?: string[];
+}
+
+export interface SubagentProfileInput {
+ name: string;
+ description?: string;
+ nickname?: string;
+ model?: LanguageModel;
+ system?: string;
+ allowedTools?: string[];
+ deniedTools?: string[];
+ codemode?: Partial;
+ context?: SubagentContextPolicyInput;
+ cost?: SubagentCostPolicyInput;
+ metadata?: JsonObject;
+}
+
+export interface SubagentProfileDefaults {
+ model?: LanguageModel;
+ system?: string;
+ allowedTools?: string[];
+ deniedTools?: string[];
+ codemode?: Partial;
+ context?: SubagentContextPolicyInput;
+ cost?: SubagentCostPolicyInput;
+}
+
+export interface ResolvedSubagentProfile {
+ name: string;
+ description: string;
+ nickname: string | null;
+ model?: LanguageModel;
+ system: string;
+ allowedTools: readonly string[];
+ deniedTools: readonly string[];
+ codemode: SubagentCodemodePolicy;
+ context: SubagentContextPolicy;
+ cost: SubagentCostPolicy;
+ metadata: JsonObject;
+}
+
+export interface SubagentMetadata {
+ agent_id: SubagentId;
+ task_name: SubagentPath | null;
+ profile: string;
+ nickname: string | null;
+ status: SubagentStatus;
+ parent_id: SubagentId | null;
+ depth: number;
+ last_task_message: string | null;
+ created_at: string;
+ updated_at: string;
+ usage?: SubagentUsage;
+ result_ref?: string;
+ transcript_ref?: string;
+ metadata?: JsonObject;
+}
+
+export interface SubagentMetadataPatch {
+ status?: SubagentStatus;
+ last_task_message?: string | null;
+ usage?: SubagentUsage;
+ result_ref?: string;
+ transcript_ref?: string;
+ metadata?: JsonObject;
+}
+
+export type SubagentEventType =
+ | "subagent.created"
+ | "subagent.started"
+ | "subagent.status_changed"
+ | "subagent.message_queued"
+ | "subagent.tool_call"
+ | "subagent.tool_result"
+ | "subagent.usage"
+ | "subagent.completed"
+ | "subagent.failed"
+ | "subagent.interrupted";
+
+export interface SubagentEvent {
+ type: SubagentEventType;
+ agent_id: SubagentId;
+ task_name: SubagentPath | null;
+ parent_id: SubagentId | null;
+ profile: string;
+ status: SubagentStatus;
+ timestamp: string;
+ payload?: JsonObject;
+}
+
+export interface SubagentEventSink {
+ emit(event: SubagentEvent): void | Promise;
+}
+
+export interface SubagentMailboxMessage {
+ id: string;
+ agent_id: SubagentId;
+ message: string;
+ kind: "message" | "followup";
+ trigger_turn: boolean;
+ created_at: string;
+ metadata?: JsonObject;
+}
+
+export interface SubagentRecord {
+ metadata: SubagentMetadata;
+ result?: SubagentRunResult;
+ events: SubagentEvent[];
+ mailbox: SubagentMailboxMessage[];
+}
+
+export interface SubagentListFilter {
+ status?: SubagentStatus;
+ pathPrefix?: string;
+ includeTerminal?: boolean;
+ limit?: number;
+}
+
+export interface SubagentStore {
+ create(metadata: SubagentMetadata): Promise;
+ update(
+ handle: SubagentHandle,
+ patch: SubagentMetadataPatch,
+ ): Promise;
+ appendEvent(event: SubagentEvent): Promise;
+ appendMessage(message: SubagentMailboxMessage): Promise;
+ complete(
+ handle: SubagentHandle,
+ result: SubagentRunResult,
+ ): Promise;
+ get(handle: SubagentHandle): Promise;
+ list(filter?: SubagentListFilter): Promise;
+}
+
+export interface SubagentProfileRegistry {
+ register(profile: SubagentProfileInput): void;
+ has(name: string): boolean;
+ resolve(
+ name: string | undefined,
+ overrides?: Partial,
+ ): ResolvedSubagentProfile | SubagentError;
+}
+
+export interface SubagentRegistry {
+ reserve(
+ request: SubagentReservationRequest,
+ ): SubagentMetadata | SubagentError;
+ get(handleOrRef: SubagentHandle | string): SubagentMetadata | null;
+ updateStatus(
+ handle: SubagentHandle,
+ status: SubagentStatus,
+ ): SubagentMetadata | SubagentError;
+ release(handle: SubagentHandle): void;
+ list(): SubagentMetadata[];
+}
+
+export interface SubagentRunnerCapabilities {
+ interrupt: boolean;
+ followup: boolean;
+}
+
+export interface ResolvedSubagentRunRequest {
+ handle: SubagentHandle;
+ task: string;
+ profile: ResolvedSubagentProfile;
+ parent_id: SubagentId | null;
+ depth: number;
+ tools: ToolSet;
+ messages?: ModelMessage[];
+ metadata?: JsonObject;
+ signal?: AbortSignal;
+ callbacks: SubagentRunnerCallbacks;
+}
+
+export interface SubagentRunnerCallbacks {
+ onStatus(status: SubagentStatus): void | Promise;
+ onEvent(event: Omit): void | Promise;
+ onUsage(usage: SubagentUsage): void | Promise;
+}
+
+export interface SubagentRunner {
+ capabilities: SubagentRunnerCapabilities;
+ run(request: ResolvedSubagentRunRequest): Promise;
+ interrupt?(
+ handle: SubagentHandle,
+ ): Promise;
+ requestTurn?(
+ handle: SubagentHandle,
+ ): Promise;
+}
+
+export interface SubagentSpawnRequest {
+ task: string;
+ profile?: string;
+ task_name?: string | null;
+ parent_id?: SubagentId | null;
+ context?: SubagentContextPolicyInput;
+ tools?: string[] | null;
+ metadata?: JsonObject;
+ messages?: ModelMessage[];
+}
+
+export type SubagentSpawnResult = SubagentHandle & {
+ status: SubagentStatus;
+ profile: string;
+ nickname: string | null;
+};
+
+export interface SubagentWaitRequest {
+ agent: string;
+ timeoutMs?: number | null;
+ untilStatus?: SubagentStatus | null;
+}
+
+export type SubagentWaitResult =
+ | {
+ status: "timeout";
+ agent: SubagentMetadata;
+ }
+ | {
+ status: "ready";
+ agent: SubagentMetadata;
+ result?: SubagentRunResult;
+ };
+
+export interface SubagentMessageRequest {
+ agent: string;
+ message: string;
+ metadata?: JsonObject;
+}
+
+export interface SubagentFollowupRequest extends SubagentMessageRequest {
+ task: string;
+}
+
+export interface SubagentMessageResult {
+ queued: boolean;
+ agent_id: SubagentId;
+ message_id: string;
+ triggered_turn: boolean;
+}
+
+export interface SubagentInterruptRequest {
+ agent: string;
+ reason?: string | null;
+}
+
+export interface SubagentInterruptResult {
+ agent_id: SubagentId;
+ previous_status: SubagentStatus;
+ status: SubagentStatus;
+}
+
+export interface SubagentLifecycleHooks {
+ onBeforeSpawn?(
+ request: SubagentSpawnRequest,
+ ): Promise | SubagentError | undefined;
+ onStart?(metadata: SubagentMetadata): void | Promise;
+ onMessage?(message: SubagentMailboxMessage): void | Promise;
+ onComplete?(result: SubagentRunResult): void | Promise;
+ onFail?(result: SubagentRunResult): void | Promise;
+ onInterrupt?(result: SubagentInterruptResult): void | Promise;
+ onStop?(result: SubagentRunResult): void | Promise;
+}
+
+export interface SubagentCostPolicyInput {
+ maxUsd?: number;
+ maxActiveAgents?: number;
+ maxTotalAgents?: number;
+ maxDepth?: number;
+ maxMailboxMessages?: number;
+ minWaitTimeoutMs?: number;
+ maxWaitTimeoutMs?: number;
+}
+
+export interface SubagentCostPolicy {
+ maxUsd: number | null;
+ maxActiveAgents: number;
+ maxTotalAgents: number;
+ maxDepth: number;
+ maxMailboxMessages: number;
+ minWaitTimeoutMs: number;
+ maxWaitTimeoutMs: number;
+}
+
+export interface SubagentPolicyState {
+ activeAgents: number;
+ totalAgents: number;
+ depth: number;
+ mailboxMessages: number;
+ budgetStatus?: BudgetStatus;
+}
+
+export interface SubagentControllerConfig {
+ profiles?: SubagentProfileInput[];
+ defaultProfile?: string;
+ profileDefaults?: SubagentProfileDefaults;
+ store?: SubagentStore;
+ runner: SubagentRunner;
+ tools?: ToolSet;
+ eventSink?: SubagentEventSink;
+ lifecycle?: SubagentLifecycleHooks;
+ budget?: BudgetTracker;
+ cost?: SubagentCostPolicyInput;
+}
+
+export interface SubagentReservationRequest {
+ taskName?: string | null;
+ profile: string;
+ nickname: string | null;
+ parentId?: SubagentId | null;
+ depth: number;
+ lastTaskMessage: string;
+ metadata?: JsonObject;
+}
+
+export interface SubagentController {
+ spawn(
+ request: SubagentSpawnRequest,
+ ): Promise;
+ list(filter?: SubagentListFilter): Promise;
+ wait(
+ request: SubagentWaitRequest,
+ ): Promise;
+ sendMessage(
+ request: SubagentMessageRequest,
+ ): Promise;
+ followupTask(
+ request: SubagentFollowupRequest,
+ ): Promise;
+ interrupt(
+ request: SubagentInterruptRequest,
+ ): Promise;
+}
diff --git a/tests/subagents/controller.test.ts b/tests/subagents/controller.test.ts
new file mode 100644
index 0000000..8c417fb
--- /dev/null
+++ b/tests/subagents/controller.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ createMemorySubagentEventSink,
+ createStaticSubagentRunner,
+ createSubagentController,
+} from "@/subagents";
+import type { SubagentRunner } from "@/subagents";
+
+describe("createSubagentController", () => {
+ it("spawns, runs, emits events, and waits for completion", async () => {
+ const sink = createMemorySubagentEventSink();
+ const onComplete = vi.fn();
+ const controller = createSubagentController({
+ runner: createStaticSubagentRunner({
+ status: "completed",
+ result: "done",
+ }),
+ eventSink: sink,
+ lifecycle: { onComplete },
+ });
+
+ const spawned = await controller.spawn({
+ task: "Research auth",
+ task_name: "research/auth",
+ });
+ if ("error" in spawned) throw new Error(spawned.error);
+
+ const waited = await controller.wait({
+ agent: spawned.agent_id,
+ timeoutMs: 500,
+ });
+ expect(waited).toMatchObject({
+ status: "ready",
+ result: { result: "done", status: "completed" },
+ });
+ expect(onComplete).toHaveBeenCalledOnce();
+ expect(sink.events.map((event) => event.type)).toContain(
+ "subagent.completed",
+ );
+ });
+
+ it("records runner failures as failed terminal results", async () => {
+ const runner: SubagentRunner = {
+ capabilities: { interrupt: false, followup: false },
+ async run() {
+ throw new Error("runner failed");
+ },
+ };
+ const controller = createSubagentController({ runner });
+ const spawned = await controller.spawn({ task: "fail" });
+ if ("error" in spawned) throw new Error(spawned.error);
+
+ const waited = await controller.wait({
+ agent: spawned.agent_id,
+ timeoutMs: 500,
+ });
+ expect(waited).toMatchObject({
+ status: "ready",
+ result: { status: "failed", error: "runner failed" },
+ });
+ });
+
+ it("queues messages and updates last task message", async () => {
+ const controller = createSubagentController({
+ runner: createStaticSubagentRunner({
+ status: "completed",
+ result: "done",
+ }),
+ });
+ const spawned = await controller.spawn({ task: "Research" });
+ if ("error" in spawned) throw new Error(spawned.error);
+
+ const result = await controller.sendMessage({
+ agent: spawned.agent_id,
+ message: "new context",
+ });
+ expect(result).toMatchObject({
+ queued: true,
+ agent_id: spawned.agent_id,
+ triggered_turn: false,
+ });
+
+ const agents = await controller.list();
+ expect(agents[0].last_task_message).toBe("new context");
+ });
+
+ it("rejects spawns when active-agent limits are exhausted", async () => {
+ const runner: SubagentRunner = {
+ capabilities: { interrupt: false, followup: false },
+ async run(request) {
+ await request.callbacks.onStatus("running");
+ return new Promise(() => undefined);
+ },
+ };
+ const controller = createSubagentController({
+ runner,
+ profileDefaults: { cost: { maxActiveAgents: 1 } },
+ });
+
+ const first = await controller.spawn({ task: "one" });
+ expect(first).not.toHaveProperty("error");
+ const second = await controller.spawn({ task: "two" });
+ expect(second).toEqual({ error: "Subagent active limit reached (1)" });
+ });
+});
diff --git a/tests/subagents/cost-control.test.ts b/tests/subagents/cost-control.test.ts
new file mode 100644
index 0000000..983066c
--- /dev/null
+++ b/tests/subagents/cost-control.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import {
+ checkSubagentCostPolicy,
+ DEFAULT_SUBAGENT_COST_POLICY,
+} from "@/subagents";
+
+describe("subagent cost control", () => {
+ it("rejects exhausted root budget before spawn", () => {
+ expect(
+ checkSubagentCostPolicy(DEFAULT_SUBAGENT_COST_POLICY, {
+ activeAgents: 0,
+ totalAgents: 0,
+ depth: 0,
+ mailboxMessages: 0,
+ budgetStatus: {
+ totalCostUsd: 1,
+ maxUsd: 1,
+ remainingUsd: 0,
+ usagePercent: 100,
+ stepsCompleted: 1,
+ exceeded: true,
+ unpricedSteps: 0,
+ },
+ }),
+ ).toEqual({ error: "Subagent budget is already exhausted" });
+ });
+
+ it("rejects per-profile budget caps", () => {
+ expect(
+ checkSubagentCostPolicy(
+ { ...DEFAULT_SUBAGENT_COST_POLICY, maxUsd: 0.5 },
+ {
+ activeAgents: 0,
+ totalAgents: 0,
+ depth: 0,
+ mailboxMessages: 0,
+ budgetStatus: {
+ totalCostUsd: 0.5,
+ maxUsd: 5,
+ remainingUsd: 4.5,
+ usagePercent: 10,
+ stepsCompleted: 1,
+ exceeded: false,
+ unpricedSteps: 0,
+ },
+ },
+ ),
+ ).toEqual({ error: "Subagent profile budget limit reached ($0.5)" });
+ });
+});
diff --git a/tests/subagents/execution-limits.test.ts b/tests/subagents/execution-limits.test.ts
new file mode 100644
index 0000000..7e90a5e
--- /dev/null
+++ b/tests/subagents/execution-limits.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import {
+ clampSubagentWaitTimeout,
+ checkSubagentExecutionLimits,
+ DEFAULT_SUBAGENT_COST_POLICY,
+} from "@/subagents";
+
+describe("subagent execution limits", () => {
+ it("rejects active-agent limit exhaustion", () => {
+ expect(
+ checkSubagentExecutionLimits(DEFAULT_SUBAGENT_COST_POLICY, {
+ activeAgents: 4,
+ totalAgents: 4,
+ depth: 0,
+ mailboxMessages: 0,
+ }),
+ ).toEqual({ error: "Subagent active limit reached (4)" });
+ });
+
+ it("rejects depth limit exhaustion", () => {
+ expect(
+ checkSubagentExecutionLimits(DEFAULT_SUBAGENT_COST_POLICY, {
+ activeAgents: 0,
+ totalAgents: 0,
+ depth: 3,
+ mailboxMessages: 0,
+ }),
+ ).toEqual({ error: "Subagent depth limit reached (2)" });
+ });
+
+ it("clamps wait timeouts", () => {
+ expect(clampSubagentWaitTimeout(1, DEFAULT_SUBAGENT_COST_POLICY)).toBe(100);
+ expect(clampSubagentWaitTimeout(60_000, DEFAULT_SUBAGENT_COST_POLICY)).toBe(
+ 30_000,
+ );
+ });
+});
diff --git a/tests/subagents/identity.test.ts b/tests/subagents/identity.test.ts
new file mode 100644
index 0000000..b2fbc27
--- /dev/null
+++ b/tests/subagents/identity.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it, beforeEach } from "vitest";
+import {
+ createSubagentId,
+ resetSubagentIdCounterForTests,
+} from "@/subagents/identity";
+
+describe("subagent identity", () => {
+ beforeEach(() => resetSubagentIdCounterForTests());
+
+ it("generates stable unique ids", () => {
+ expect(createSubagentId()).toBe("agent_1");
+ expect(createSubagentId()).toBe("agent_2");
+ });
+
+ it("supports custom prefixes", () => {
+ expect(createSubagentId("message")).toBe("message_1");
+ });
+});
diff --git a/tests/subagents/path.test.ts b/tests/subagents/path.test.ts
new file mode 100644
index 0000000..7d90a88
--- /dev/null
+++ b/tests/subagents/path.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { normalizeSubagentPath, resolveSubagentPath } from "@/subagents/path";
+
+describe("subagent paths", () => {
+ it("normalizes path segments", () => {
+ expect(normalizeSubagentPath(" research/./auth ")).toBe("research/auth");
+ });
+
+ it("rejects absolute paths", () => {
+ expect(normalizeSubagentPath("/research")).toEqual({
+ error:
+ "task_name must be a relative path without leading or trailing slash",
+ });
+ });
+
+ it("rejects invalid segments", () => {
+ expect(normalizeSubagentPath("research/auth token")).toEqual({
+ error:
+ "task_name segments may only contain letters, numbers, dots, underscores, and dashes",
+ });
+ });
+
+ it("resolves relative references from the current path", () => {
+ expect(
+ resolveSubagentPath("../verify", { currentPath: "research/auth" }),
+ ).toBe("research/verify");
+ });
+});
diff --git a/tests/subagents/profiles.test.ts b/tests/subagents/profiles.test.ts
new file mode 100644
index 0000000..a66c8cf
--- /dev/null
+++ b/tests/subagents/profiles.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import {
+ createSubagentProfileRegistry,
+ describeSubagentProfile,
+ resolveSubagentContextPolicy,
+} from "@/subagents";
+
+describe("createSubagentProfileRegistry", () => {
+ it("resolves built-in worker profile defaults", () => {
+ const registry = createSubagentProfileRegistry();
+ const profile = registry.resolve(undefined);
+
+ expect(profile).toMatchObject({
+ name: "worker",
+ nickname: "worker",
+ codemode: { enabled: true, exposeDirectTools: false },
+ context: { mode: "recent", turns: 3 },
+ });
+ });
+
+ it("layers defaults, named profile config, and overrides", () => {
+ const registry = createSubagentProfileRegistry({
+ defaults: {
+ allowedTools: ["Read", "Grep", "Bash"],
+ deniedTools: ["Bash"],
+ context: "none",
+ },
+ profiles: [
+ {
+ name: "researcher",
+ description: "Read-only research",
+ allowedTools: ["Glob"],
+ codemode: { exposeDirectTools: true },
+ },
+ ],
+ });
+
+ const profile = registry.resolve("researcher", {
+ allowedTools: ["WebSearch"],
+ context: { recent_turns: 2 },
+ });
+
+ expect(profile).toMatchObject({
+ name: "researcher",
+ allowedTools: ["Read", "Grep", "Bash", "Glob", "WebSearch"],
+ deniedTools: ["Bash"],
+ codemode: { enabled: true, exposeDirectTools: true },
+ context: { mode: "recent", turns: 2 },
+ });
+ });
+
+ it("returns an error for unknown profiles", () => {
+ const registry = createSubagentProfileRegistry();
+ expect(registry.resolve("missing")).toEqual({
+ error: "Unknown subagent profile: missing",
+ });
+ });
+
+ it("validates recent-turn context", () => {
+ expect(resolveSubagentContextPolicy({ recent_turns: -1 })).toEqual({
+ error: "recent_turns must be a non-negative integer",
+ });
+ });
+
+ it("generates profile descriptions from resolved config", () => {
+ const registry = createSubagentProfileRegistry({
+ profiles: [
+ {
+ name: "reviewer",
+ description: "Review code",
+ allowedTools: ["Read"],
+ deniedTools: ["Write"],
+ cost: { maxUsd: 1, maxDepth: 1 },
+ },
+ ],
+ });
+ const profile = registry.resolve("reviewer");
+ if ("error" in profile) throw new Error(profile.error);
+
+ expect(describeSubagentProfile(profile)).toContain("reviewer: Review code");
+ expect(describeSubagentProfile(profile)).toContain("allowed tools: Read");
+ expect(describeSubagentProfile(profile)).toContain("denied tools: Write");
+ expect(describeSubagentProfile(profile)).toContain("budget cap: $1");
+ });
+});
diff --git a/tests/subagents/registry.test.ts b/tests/subagents/registry.test.ts
new file mode 100644
index 0000000..00841af
--- /dev/null
+++ b/tests/subagents/registry.test.ts
@@ -0,0 +1,76 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ createSubagentRegistry,
+ resetSubagentIdCounterForTests,
+} from "@/subagents";
+
+describe("createSubagentRegistry", () => {
+ beforeEach(() => resetSubagentIdCounterForTests());
+
+ it("reserves generated ids and optional task names", () => {
+ const registry = createSubagentRegistry();
+ const metadata = registry.reserve({
+ taskName: "research/auth",
+ profile: "worker",
+ nickname: "worker",
+ depth: 0,
+ lastTaskMessage: "Research auth",
+ });
+
+ expect(metadata).toMatchObject({
+ agent_id: "agent_1",
+ task_name: "research/auth",
+ status: "pending",
+ });
+ expect(registry.get("research/auth")).toMatchObject({
+ agent_id: "agent_1",
+ });
+ });
+
+ it("rejects duplicate live task names", () => {
+ const registry = createSubagentRegistry();
+ registry.reserve({
+ taskName: "research/auth",
+ profile: "worker",
+ nickname: null,
+ depth: 0,
+ lastTaskMessage: "first",
+ });
+
+ expect(
+ registry.reserve({
+ taskName: "research/auth",
+ profile: "worker",
+ nickname: null,
+ depth: 0,
+ lastTaskMessage: "second",
+ }),
+ ).toEqual({ error: "Subagent task_name already exists: research/auth" });
+ });
+
+ it("releases task names when a subagent reaches a terminal status", () => {
+ const registry = createSubagentRegistry();
+ const metadata = registry.reserve({
+ taskName: "research/auth",
+ profile: "worker",
+ nickname: null,
+ depth: 0,
+ lastTaskMessage: "first",
+ });
+ if ("error" in metadata) throw new Error(metadata.error);
+
+ registry.updateStatus(
+ { agent_id: metadata.agent_id, task_name: metadata.task_name },
+ "completed",
+ );
+
+ const next = registry.reserve({
+ taskName: "research/auth",
+ profile: "worker",
+ nickname: null,
+ depth: 0,
+ lastTaskMessage: "second",
+ });
+ expect(next).toMatchObject({ task_name: "research/auth" });
+ });
+});
diff --git a/tests/subagents/store.test.ts b/tests/subagents/store.test.ts
new file mode 100644
index 0000000..3da230a
--- /dev/null
+++ b/tests/subagents/store.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { createInMemorySubagentStore } from "@/subagents";
+import type { SubagentMetadata } from "@/subagents";
+
+function metadata(): SubagentMetadata {
+ return {
+ agent_id: "agent_1",
+ task_name: "research/auth",
+ profile: "worker",
+ nickname: "worker",
+ status: "pending",
+ parent_id: null,
+ depth: 0,
+ last_task_message: "Research auth",
+ created_at: "2026-06-15T00:00:00.000Z",
+ updated_at: "2026-06-15T00:00:00.000Z",
+ };
+}
+
+describe("createInMemorySubagentStore", () => {
+ it("stores metadata, mailbox, events, and terminal results", async () => {
+ const store = createInMemorySubagentStore();
+ await store.create(metadata());
+ await store.appendMessage({
+ id: "message_1",
+ agent_id: "agent_1",
+ message: "hello",
+ kind: "message",
+ trigger_turn: false,
+ created_at: "2026-06-15T00:00:00.000Z",
+ });
+ await store.appendEvent({
+ type: "subagent.created",
+ agent_id: "agent_1",
+ task_name: "research/auth",
+ parent_id: null,
+ profile: "worker",
+ status: "pending",
+ timestamp: "2026-06-15T00:00:00.000Z",
+ });
+ await store.complete(
+ { agent_id: "agent_1", task_name: "research/auth" },
+ {
+ agent_id: "agent_1",
+ task_name: "research/auth",
+ status: "completed",
+ result: "done",
+ },
+ );
+
+ const record = await store.get({
+ agent_id: "agent_1",
+ task_name: "research/auth",
+ });
+ expect(record?.metadata.status).toBe("completed");
+ expect(record?.mailbox).toHaveLength(1);
+ expect(record?.events).toHaveLength(1);
+ expect(record?.result?.result).toBe("done");
+ });
+});
diff --git a/tests/subagents/tool-filter.test.ts b/tests/subagents/tool-filter.test.ts
new file mode 100644
index 0000000..0359fc5
--- /dev/null
+++ b/tests/subagents/tool-filter.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { tool, zodSchema, type ToolSet } from "ai";
+import { z } from "zod";
+import { filterSubagentTools } from "@/subagents";
+
+function executableTools(): ToolSet {
+ const simpleTool = tool({
+ description: "test",
+ inputSchema: zodSchema(z.object({})),
+ execute: async () => ({ ok: true }),
+ });
+ return {
+ Read: simpleTool,
+ Grep: simpleTool,
+ Bash: simpleTool,
+ };
+}
+
+describe("filterSubagentTools", () => {
+ it("applies allowlists without mutating the original tool set", () => {
+ const tools = executableTools();
+ const filtered = filterSubagentTools(tools, {
+ allowedTools: ["Read", "Grep"],
+ });
+
+ expect(Object.keys(filtered)).toEqual(["Read", "Grep"]);
+ expect(Object.keys(tools)).toEqual(["Read", "Grep", "Bash"]);
+ });
+
+ it("applies denylist after allowlist", () => {
+ const filtered = filterSubagentTools(executableTools(), {
+ allowedTools: ["Read", "Bash"],
+ deniedTools: ["Bash"],
+ });
+
+ expect(Object.keys(filtered)).toEqual(["Read"]);
+ });
+});
From 46182f07b3db14276c3d300bfdc17eaab627fdee Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 09:35:40 -0400
Subject: [PATCH 04/20] feat: Add subagent control tools
---
src/index.ts | 15 +++
src/tools/AGENTS.md | 3 +
src/tools/index.ts | 19 ++++
src/tools/subagents/AGENTS.md | 39 +++++++
src/tools/subagents/CLAUDE.md | 1 +
src/tools/subagents/followup-task.ts | 52 +++++++++
src/tools/subagents/index.ts | 40 +++++++
src/tools/subagents/interrupt-agent.ts | 34 ++++++
src/tools/subagents/list-agents.ts | 61 +++++++++++
src/tools/subagents/send-message.ts | 40 +++++++
src/tools/subagents/spawn-agent.ts | 72 ++++++++++++
src/tools/subagents/types.ts | 84 ++++++++++++++
src/tools/subagents/wait-agent.ts | 56 ++++++++++
tests/tools/subagents/helpers.ts | 33 ++++++
tests/tools/subagents/interrupt-agent.test.ts | 88 +++++++++++++++
tests/tools/subagents/list-agents.test.ts | 57 ++++++++++
tests/tools/subagents/message-tools.test.ts | 103 ++++++++++++++++++
tests/tools/subagents/spawn-agent.test.ts | 66 +++++++++++
tests/tools/subagents/wait-agent.test.ts | 66 +++++++++++
19 files changed, 929 insertions(+)
create mode 100644 src/tools/subagents/AGENTS.md
create mode 120000 src/tools/subagents/CLAUDE.md
create mode 100644 src/tools/subagents/followup-task.ts
create mode 100644 src/tools/subagents/index.ts
create mode 100644 src/tools/subagents/interrupt-agent.ts
create mode 100644 src/tools/subagents/list-agents.ts
create mode 100644 src/tools/subagents/send-message.ts
create mode 100644 src/tools/subagents/spawn-agent.ts
create mode 100644 src/tools/subagents/types.ts
create mode 100644 src/tools/subagents/wait-agent.ts
create mode 100644 tests/tools/subagents/helpers.ts
create mode 100644 tests/tools/subagents/interrupt-agent.test.ts
create mode 100644 tests/tools/subagents/list-agents.test.ts
create mode 100644 tests/tools/subagents/message-tools.test.ts
create mode 100644 tests/tools/subagents/spawn-agent.test.ts
create mode 100644 tests/tools/subagents/wait-agent.test.ts
diff --git a/src/index.ts b/src/index.ts
index b6123eb..934277c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -147,6 +147,14 @@ export type {
PatchError,
PatchFileResult,
PatchOutput,
+ CompactSubagentRecord,
+ InterruptAgentOutput,
+ ListAgentsOutput,
+ MessageAgentOutput,
+ SpawnAgentOutput,
+ SubagentControlToolConfig,
+ SubagentToolError,
+ WaitAgentOutput,
GrepContentOutput,
GrepCountOutput,
GrepError,
@@ -194,10 +202,17 @@ export {
createGlobTool,
createGrepTool,
createPatchTool,
+ createFollowupTaskTool,
+ createInterruptAgentTool,
+ createListAgentsTool,
createReadTool,
+ createSendMessageTool,
createSkillTool,
+ createSpawnAgentTool,
+ createSubagentControlTools,
createTaskTool,
createTodoWriteTool,
+ createWaitAgentTool,
createWebFetchTool,
createWebSearchTool,
createWriteTool,
diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md
index 3a757d1..e352283 100644
--- a/src/tools/AGENTS.md
+++ b/src/tools/AGENTS.md
@@ -20,6 +20,7 @@ Most tools are a single file. **Tools with non-trivial internals live in their o
| `enter-plan-mode.ts` | Enter planning mode for explore-then-execute workflows |
| `exit-plan-mode.ts` | Exit planning mode and submit plan for approval |
| `skill.ts` | Activate pre-loaded skills from SKILL.md files |
+| `subagents/` | Controller-backed multi-agent control tools |
| `task.ts` | Spawn sub-agents for autonomous multi-step tasks |
| `todo-write.ts` | Manage structured task lists with state tracking |
| `web-search.ts` | Search the web via Parallel API with domain filtering |
@@ -42,6 +43,7 @@ Most tools are a single file. **Tools with non-trivial internals live in their o
- `createEnterPlanModeTool(state, onEnter?)` -- Planning mode entry
- `createExitPlanModeTool(onPlanSubmit?)` -- Planning mode exit
- `createSkillTool(config)` -- Skill activation
+- `createSubagentControlTools(controller, config?)` -- Create Spawn/List/Wait/Message/Interrupt subagent control tools
- `createTaskTool(config)` -- Sub-agent spawning
- `createTodoWriteTool(state, onUpdate?)` -- Task list management
- `createWebSearchTool(config)` -- Web search
@@ -86,6 +88,7 @@ Each tool exports `Output` for success and `Error` for errors:
**Workflow Tools** (require Task tool config):
- Task -- Spawn sub-agents with custom system prompts and tool restrictions
- TodoWrite -- Shared state management for task tracking
+- Subagent control tools -- First-class controller adapters for SpawnAgent, ListAgents, WaitAgent, SendMessage, FollowupTask, and InterruptAgent
**Web Tools** (opt-in via config, require parallel-web):
- WebSearch -- Search via Parallel API
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 4213e66..3ce59d1 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -451,6 +451,25 @@ export type { SkillError, SkillOutput, SkillToolConfig } from "./skill";
export { createSkillTool } from "./skill";
// --- Task Tool ---
+export type {
+ CompactSubagentRecord,
+ InterruptAgentOutput,
+ ListAgentsOutput,
+ MessageAgentOutput,
+ SpawnAgentOutput,
+ SubagentControlToolConfig,
+ SubagentToolError,
+ WaitAgentOutput,
+} from "./subagents";
+export {
+ createFollowupTaskTool,
+ createInterruptAgentTool,
+ createListAgentsTool,
+ createSendMessageTool,
+ createSpawnAgentTool,
+ createSubagentControlTools,
+ createWaitAgentTool,
+} from "./subagents";
export type {
SubagentEventData,
SubagentStepEvent,
diff --git a/src/tools/subagents/AGENTS.md b/src/tools/subagents/AGENTS.md
new file mode 100644
index 0000000..5699d1b
--- /dev/null
+++ b/src/tools/subagents/AGENTS.md
@@ -0,0 +1,39 @@
+# Subagent Control Tools
+
+Model-facing tools for supervising controller-managed subagents.
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `index.ts` | Barrel exports and `createSubagentControlTools` tool-set factory |
+| `types.ts` | Shared tool config and output types |
+| `spawn-agent.ts` | `SpawnAgent` adapter |
+| `list-agents.ts` | `ListAgents` adapter |
+| `send-message.ts` | `SendMessage` adapter |
+| `followup-task.ts` | `FollowupTask` adapter |
+| `wait-agent.ts` | `WaitAgent` adapter |
+| `interrupt-agent.ts` | `InterruptAgent` adapter |
+
+## Architecture
+
+These files are thin adapters over `src/subagents/`. They do not own orchestration, state, profiles, budget, or lifecycle behavior. They parse model-facing inputs, apply nullable defaults with `??`, call `SubagentController`, and return compact structured results.
+
+## Design Rules
+
+- Keep orchestration in `src/subagents/controller.ts`.
+- Use nullable schema fields for optional model inputs.
+- Return `{ error: string }` for controller/tool failures.
+- Keep outputs compact; do not include full child transcripts.
+- Prefer factory functions and typed plain objects, not classes.
+- Keep tool names PascalCase.
+
+## Common Modifications
+
+### Add a control tool
+
+1. Add shared output/input types in `types.ts` when needed.
+2. Add a single adapter file with a `createXTool(controller, config?)` factory.
+3. Export it from `index.ts`.
+4. Add focused tests under `tests/tools/subagents/`.
+
diff --git a/src/tools/subagents/CLAUDE.md b/src/tools/subagents/CLAUDE.md
new file mode 120000
index 0000000..47dc3e3
--- /dev/null
+++ b/src/tools/subagents/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/src/tools/subagents/followup-task.ts b/src/tools/subagents/followup-task.ts
new file mode 100644
index 0000000..79dd817
--- /dev/null
+++ b/src/tools/subagents/followup-task.ts
@@ -0,0 +1,52 @@
+import { tool, zodSchema } from "ai";
+import { z } from "zod";
+import type { SubagentController } from "../../subagents";
+import type {
+ MessageAgentOutput,
+ SubagentControlToolConfig,
+ SubagentToolError,
+} from "./types";
+
+const metadataSchema = z.record(
+ z.string(),
+ z.union([z.string(), z.number(), z.boolean(), z.null()]),
+);
+
+const followupTaskInputSchema = z.object({
+ agent: z
+ .string()
+ .describe("Subagent id or task_name returned by SpawnAgent/ListAgents."),
+ task: z.string().describe("Additional work for the target subagent."),
+ metadata: metadataSchema
+ .nullable()
+ .default(null)
+ .describe("Optional host metadata for the follow-up task."),
+});
+
+type FollowupTaskInput = z.infer;
+
+const FOLLOWUP_TASK_DESCRIPTION = `Queue additional work for an existing subagent and request another turn when the runner supports it. Do not target yourself; use this for continuing child work after inspecting progress or results.`;
+
+export function createFollowupTaskTool(
+ controller: SubagentController,
+ config?: SubagentControlToolConfig,
+) {
+ return tool({
+ description: FOLLOWUP_TASK_DESCRIPTION,
+ inputSchema: zodSchema(followupTaskInputSchema),
+ execute: async (
+ input: FollowupTaskInput,
+ ): Promise => {
+ if (config?.currentAgentId && input.agent === config.currentAgentId) {
+ return { error: "FollowupTask cannot target the current agent" };
+ }
+
+ return await controller.followupTask({
+ agent: input.agent,
+ message: input.task,
+ task: input.task,
+ metadata: input.metadata ?? undefined,
+ });
+ },
+ });
+}
diff --git a/src/tools/subagents/index.ts b/src/tools/subagents/index.ts
new file mode 100644
index 0000000..a0b74cc
--- /dev/null
+++ b/src/tools/subagents/index.ts
@@ -0,0 +1,40 @@
+import type { ToolSet } from "ai";
+import type { SubagentController } from "../../subagents";
+import { createFollowupTaskTool } from "./followup-task";
+import { createInterruptAgentTool } from "./interrupt-agent";
+import { createListAgentsTool } from "./list-agents";
+import { createSendMessageTool } from "./send-message";
+import { createSpawnAgentTool } from "./spawn-agent";
+import { createWaitAgentTool } from "./wait-agent";
+import type { SubagentControlToolConfig } from "./types";
+
+export function createSubagentControlTools(
+ controller: SubagentController,
+ config?: SubagentControlToolConfig,
+): ToolSet {
+ return {
+ SpawnAgent: createSpawnAgentTool(controller, config),
+ ListAgents: createListAgentsTool(controller),
+ SendMessage: createSendMessageTool(controller),
+ FollowupTask: createFollowupTaskTool(controller, config),
+ WaitAgent: createWaitAgentTool(controller),
+ InterruptAgent: createInterruptAgentTool(controller),
+ };
+}
+
+export { createSpawnAgentTool } from "./spawn-agent";
+export { createListAgentsTool } from "./list-agents";
+export { createSendMessageTool } from "./send-message";
+export { createFollowupTaskTool } from "./followup-task";
+export { createWaitAgentTool } from "./wait-agent";
+export { createInterruptAgentTool } from "./interrupt-agent";
+export type {
+ CompactSubagentRecord,
+ InterruptAgentOutput,
+ ListAgentsOutput,
+ MessageAgentOutput,
+ SpawnAgentOutput,
+ SubagentControlToolConfig,
+ SubagentToolError,
+ WaitAgentOutput,
+} from "./types";
diff --git a/src/tools/subagents/interrupt-agent.ts b/src/tools/subagents/interrupt-agent.ts
new file mode 100644
index 0000000..ee3dac2
--- /dev/null
+++ b/src/tools/subagents/interrupt-agent.ts
@@ -0,0 +1,34 @@
+import { tool, zodSchema } from "ai";
+import { z } from "zod";
+import type { SubagentController } from "../../subagents";
+import type { InterruptAgentOutput, SubagentToolError } from "./types";
+
+const interruptAgentInputSchema = z.object({
+ agent: z
+ .string()
+ .describe("Subagent id or task_name returned by SpawnAgent/ListAgents."),
+ reason: z
+ .string()
+ .nullable()
+ .default(null)
+ .describe("Optional reason for interrupting the subagent."),
+});
+
+type InterruptAgentInput = z.infer;
+
+const INTERRUPT_AGENT_DESCRIPTION = `Request cancellation of an existing subagent. This is best effort and depends on the configured runner. Use when the child is no longer needed, is stuck, or is working on obsolete instructions.`;
+
+export function createInterruptAgentTool(controller: SubagentController) {
+ return tool({
+ description: INTERRUPT_AGENT_DESCRIPTION,
+ inputSchema: zodSchema(interruptAgentInputSchema),
+ execute: async (
+ input: InterruptAgentInput,
+ ): Promise => {
+ return await controller.interrupt({
+ agent: input.agent,
+ reason: input.reason ?? null,
+ });
+ },
+ });
+}
diff --git a/src/tools/subagents/list-agents.ts b/src/tools/subagents/list-agents.ts
new file mode 100644
index 0000000..cdbc964
--- /dev/null
+++ b/src/tools/subagents/list-agents.ts
@@ -0,0 +1,61 @@
+import { tool, zodSchema } from "ai";
+import { z } from "zod";
+import type { SubagentController, SubagentStatus } from "../../subagents";
+import type { ListAgentsOutput, SubagentToolError } from "./types";
+import { compactSubagentRecord } from "./types";
+
+const statusSchema = z.enum([
+ "pending",
+ "running",
+ "waiting",
+ "completed",
+ "failed",
+ "interrupted",
+] satisfies SubagentStatus[]);
+
+const listAgentsInputSchema = z.object({
+ status: statusSchema
+ .nullable()
+ .default(null)
+ .describe("Optional status filter."),
+ path_prefix: z
+ .string()
+ .nullable()
+ .default(null)
+ .describe("Optional task_name prefix filter."),
+ include_terminal: z
+ .boolean()
+ .nullable()
+ .default(null)
+ .describe("Whether to include completed, failed, and interrupted agents."),
+ limit: z
+ .number()
+ .nullable()
+ .default(null)
+ .describe("Maximum number of agents to return."),
+});
+
+type ListAgentsInput = z.infer;
+
+const LIST_AGENTS_DESCRIPTION = `List known subagents and their compact status. Use this before waiting or messaging when you need to know which agents are active, completed, failed, or interrupted. This returns metadata and references, not full transcripts.`;
+
+export function createListAgentsTool(controller: SubagentController) {
+ return tool({
+ description: LIST_AGENTS_DESCRIPTION,
+ inputSchema: zodSchema(listAgentsInputSchema),
+ execute: async (
+ input: ListAgentsInput,
+ ): Promise => {
+ const agents = await controller.list({
+ status: input.status ?? undefined,
+ pathPrefix: input.path_prefix ?? undefined,
+ includeTerminal: input.include_terminal ?? undefined,
+ limit: input.limit ?? undefined,
+ });
+
+ return {
+ agents: agents.map(compactSubagentRecord),
+ };
+ },
+ });
+}
diff --git a/src/tools/subagents/send-message.ts b/src/tools/subagents/send-message.ts
new file mode 100644
index 0000000..4a22679
--- /dev/null
+++ b/src/tools/subagents/send-message.ts
@@ -0,0 +1,40 @@
+import { tool, zodSchema } from "ai";
+import { z } from "zod";
+import type { SubagentController } from "../../subagents";
+import type { MessageAgentOutput, SubagentToolError } from "./types";
+
+const metadataSchema = z.record(
+ z.string(),
+ z.union([z.string(), z.number(), z.boolean(), z.null()]),
+);
+
+const sendMessageInputSchema = z.object({
+ agent: z
+ .string()
+ .describe("Subagent id or task_name returned by SpawnAgent/ListAgents."),
+ message: z.string().describe("Information to queue for the target subagent."),
+ metadata: metadataSchema
+ .nullable()
+ .default(null)
+ .describe("Optional host metadata for the message."),
+});
+
+type SendMessageInput = z.infer;
+
+const SEND_MESSAGE_DESCRIPTION = `Queue information for an existing subagent without necessarily asking it to start a new turn. Use this for context, constraints, or updates the child should see. Use FollowupTask when you need the child to do more work.`;
+
+export function createSendMessageTool(controller: SubagentController) {
+ return tool({
+ description: SEND_MESSAGE_DESCRIPTION,
+ inputSchema: zodSchema(sendMessageInputSchema),
+ execute: async (
+ input: SendMessageInput,
+ ): Promise => {
+ return await controller.sendMessage({
+ agent: input.agent,
+ message: input.message,
+ metadata: input.metadata ?? undefined,
+ });
+ },
+ });
+}
diff --git a/src/tools/subagents/spawn-agent.ts b/src/tools/subagents/spawn-agent.ts
new file mode 100644
index 0000000..d1501e0
--- /dev/null
+++ b/src/tools/subagents/spawn-agent.ts
@@ -0,0 +1,72 @@
+import { tool, zodSchema } from "ai";
+import { z } from "zod";
+import type { SubagentController } from "../../subagents";
+import type {
+ SpawnAgentOutput,
+ SubagentControlToolConfig,
+ SubagentToolError,
+} from "./types";
+
+const metadataSchema = z.record(
+ z.string(),
+ z.union([z.string(), z.number(), z.boolean(), z.null()]),
+);
+
+const spawnAgentInputSchema = z.object({
+ task: z.string().describe("The task the subagent should work on."),
+ profile: z
+ .string()
+ .nullable()
+ .default(null)
+ .describe("Optional subagent profile name."),
+ task_name: z
+ .string()
+ .nullable()
+ .default(null)
+ .describe("Optional path-like name such as research/auth."),
+ context: z
+ .union([
+ z.literal("none"),
+ z.literal("all"),
+ z.object({ recent_turns: z.number() }),
+ ])
+ .nullable()
+ .default(null)
+ .describe("Optional context inheritance policy."),
+ tools: z
+ .array(z.string())
+ .nullable()
+ .default(null)
+ .describe("Optional allowed tool names for this child."),
+ metadata: metadataSchema
+ .nullable()
+ .default(null)
+ .describe("Optional host metadata for the child."),
+});
+
+type SpawnAgentInput = z.infer;
+
+const SPAWN_AGENT_DESCRIPTION = `Spawn a subagent for separable work. Use this when work can run independently, such as research, review, verification, or a focused implementation task. The call returns immediately with a handle; use ListAgents or WaitAgent to inspect progress. Avoid redundant fan-out and do not spawn agents for tiny tasks you can complete directly.`;
+
+export function createSpawnAgentTool(
+ controller: SubagentController,
+ _config?: SubagentControlToolConfig,
+) {
+ return tool({
+ description: SPAWN_AGENT_DESCRIPTION,
+ inputSchema: zodSchema(spawnAgentInputSchema),
+ execute: async (
+ input: SpawnAgentInput,
+ ): Promise => {
+ const result = await controller.spawn({
+ task: input.task,
+ profile: input.profile ?? undefined,
+ task_name: input.task_name ?? null,
+ context: input.context ?? undefined,
+ tools: input.tools ?? null,
+ metadata: input.metadata ?? undefined,
+ });
+ return result;
+ },
+ });
+}
diff --git a/src/tools/subagents/types.ts b/src/tools/subagents/types.ts
new file mode 100644
index 0000000..6ab6097
--- /dev/null
+++ b/src/tools/subagents/types.ts
@@ -0,0 +1,84 @@
+import type {
+ SubagentMetadata,
+ SubagentRunResult,
+ SubagentStatus,
+} from "../../subagents";
+
+export interface SubagentControlToolConfig {
+ /**
+ * Current agent id, when these tools are exposed to a child. Used to reject
+ * self-targeting follow-up work that would deadlock the caller.
+ */
+ currentAgentId?: string;
+}
+
+export interface SubagentToolError {
+ error: string;
+}
+
+export interface CompactSubagentRecord {
+ agent_id: string;
+ task_name: string | null;
+ profile: string;
+ nickname: string | null;
+ status: SubagentStatus;
+ parent_id: string | null;
+ depth: number;
+ last_task_message: string | null;
+ created_at: string;
+ updated_at: string;
+ usage?: SubagentMetadata["usage"];
+ result_ref?: string;
+ transcript_ref?: string;
+}
+
+export interface SpawnAgentOutput {
+ agent_id: string;
+ task_name: string | null;
+ status: SubagentStatus;
+ profile: string;
+ nickname: string | null;
+}
+
+export interface ListAgentsOutput {
+ agents: CompactSubagentRecord[];
+}
+
+export interface WaitAgentOutput {
+ status: "ready" | "timeout";
+ agent: CompactSubagentRecord;
+ result?: SubagentRunResult;
+}
+
+export interface MessageAgentOutput {
+ queued: boolean;
+ agent_id: string;
+ message_id: string;
+ triggered_turn: boolean;
+}
+
+export interface InterruptAgentOutput {
+ agent_id: string;
+ previous_status: SubagentStatus;
+ status: SubagentStatus;
+}
+
+export function compactSubagentRecord(
+ metadata: SubagentMetadata,
+): CompactSubagentRecord {
+ return {
+ agent_id: metadata.agent_id,
+ task_name: metadata.task_name,
+ profile: metadata.profile,
+ nickname: metadata.nickname,
+ status: metadata.status,
+ parent_id: metadata.parent_id,
+ depth: metadata.depth,
+ last_task_message: metadata.last_task_message,
+ created_at: metadata.created_at,
+ updated_at: metadata.updated_at,
+ usage: metadata.usage,
+ result_ref: metadata.result_ref,
+ transcript_ref: metadata.transcript_ref,
+ };
+}
diff --git a/src/tools/subagents/wait-agent.ts b/src/tools/subagents/wait-agent.ts
new file mode 100644
index 0000000..3844147
--- /dev/null
+++ b/src/tools/subagents/wait-agent.ts
@@ -0,0 +1,56 @@
+import { tool, zodSchema } from "ai";
+import { z } from "zod";
+import type { SubagentController, SubagentStatus } from "../../subagents";
+import type { SubagentToolError, WaitAgentOutput } from "./types";
+import { compactSubagentRecord } from "./types";
+
+const statusSchema = z.enum([
+ "pending",
+ "running",
+ "waiting",
+ "completed",
+ "failed",
+ "interrupted",
+] satisfies SubagentStatus[]);
+
+const waitAgentInputSchema = z.object({
+ agent: z
+ .string()
+ .describe("Subagent id or task_name returned by SpawnAgent/ListAgents."),
+ timeout_ms: z
+ .number()
+ .nullable()
+ .default(null)
+ .describe("Optional bounded wait timeout in milliseconds."),
+ until_status: statusSchema
+ .nullable()
+ .default(null)
+ .describe("Optional non-terminal or terminal status to wait for."),
+});
+
+type WaitAgentInput = z.infer;
+
+const WAIT_AGENT_DESCRIPTION = `Wait briefly for a subagent to reach a status or terminal result. Use bounded waits and prefer ListAgents for polling. Waiting returns compact metadata and a terminal result when available.`;
+
+export function createWaitAgentTool(controller: SubagentController) {
+ return tool({
+ description: WAIT_AGENT_DESCRIPTION,
+ inputSchema: zodSchema(waitAgentInputSchema),
+ execute: async (
+ input: WaitAgentInput,
+ ): Promise => {
+ const result = await controller.wait({
+ agent: input.agent,
+ timeoutMs: input.timeout_ms ?? null,
+ untilStatus: input.until_status ?? null,
+ });
+ if ("error" in result) return result;
+
+ return {
+ status: result.status,
+ agent: compactSubagentRecord(result.agent),
+ result: result.status === "ready" ? result.result : undefined,
+ };
+ },
+ });
+}
diff --git a/tests/tools/subagents/helpers.ts b/tests/tools/subagents/helpers.ts
new file mode 100644
index 0000000..40d0875
--- /dev/null
+++ b/tests/tools/subagents/helpers.ts
@@ -0,0 +1,33 @@
+import type { SubagentRunner } from "@/subagents";
+import {
+ createStaticSubagentRunner,
+ createSubagentController,
+ createSubagentControlTools,
+} from "@/index";
+
+export function createCompletedTools() {
+ const controller = createSubagentController({
+ runner: createStaticSubagentRunner({
+ status: "completed",
+ result: "done",
+ }),
+ });
+ return createSubagentControlTools(controller);
+}
+
+export function createLongRunningRunner(): SubagentRunner {
+ return {
+ capabilities: { interrupt: false, followup: false },
+ async run(request) {
+ await request.callbacks.onStatus("running");
+ return new Promise(() => undefined);
+ },
+ };
+}
+
+export function createLongRunningTools() {
+ const controller = createSubagentController({
+ runner: createLongRunningRunner(),
+ });
+ return createSubagentControlTools(controller);
+}
diff --git a/tests/tools/subagents/interrupt-agent.test.ts b/tests/tools/subagents/interrupt-agent.test.ts
new file mode 100644
index 0000000..fe9557f
--- /dev/null
+++ b/tests/tools/subagents/interrupt-agent.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+import { executeTool } from "@test/helpers";
+import type { SubagentRunner } from "@/subagents";
+import { createSubagentController, createSubagentControlTools } from "@/index";
+
+describe("InterruptAgent tool", () => {
+ it("returns an unsupported error when the runner cannot interrupt", async () => {
+ const controller = createSubagentController({
+ runner: {
+ capabilities: { interrupt: false, followup: false },
+ async run(request) {
+ await request.callbacks.onStatus("running");
+ return new Promise(() => undefined);
+ },
+ },
+ });
+ const tools = createSubagentControlTools(controller);
+ const spawned = await executeTool(tools.SpawnAgent, {
+ task: "Research",
+ task_name: null,
+ profile: null,
+ context: null,
+ tools: null,
+ metadata: null,
+ });
+ if (
+ typeof spawned !== "object" ||
+ spawned === null ||
+ !("agent_id" in spawned)
+ ) {
+ throw new Error("spawn failed");
+ }
+
+ const result = await executeTool(tools.InterruptAgent, {
+ agent: String(spawned.agent_id),
+ reason: null,
+ });
+
+ expect(result).toEqual({
+ error: "Subagent runner does not support interrupt",
+ });
+ });
+
+ it("interrupts when the runner supports cancellation", async () => {
+ const runner: SubagentRunner = {
+ capabilities: { interrupt: true, followup: false },
+ async run(request) {
+ await request.callbacks.onStatus("running");
+ return new Promise(() => undefined);
+ },
+ async interrupt(handle) {
+ return {
+ agent_id: handle.agent_id,
+ previous_status: "running",
+ status: "interrupted",
+ };
+ },
+ };
+ const controller = createSubagentController({ runner });
+ const tools = createSubagentControlTools(controller);
+ const spawned = await executeTool(tools.SpawnAgent, {
+ task: "Research",
+ task_name: null,
+ profile: null,
+ context: null,
+ tools: null,
+ metadata: null,
+ });
+ if (
+ typeof spawned !== "object" ||
+ spawned === null ||
+ !("agent_id" in spawned)
+ ) {
+ throw new Error("spawn failed");
+ }
+
+ const result = await executeTool(tools.InterruptAgent, {
+ agent: String(spawned.agent_id),
+ reason: "No longer needed",
+ });
+
+ expect(result).toMatchObject({
+ agent_id: spawned.agent_id,
+ previous_status: expect.stringMatching(/pending|running/),
+ status: "interrupted",
+ });
+ });
+});
diff --git a/tests/tools/subagents/list-agents.test.ts b/tests/tools/subagents/list-agents.test.ts
new file mode 100644
index 0000000..64edd7b
--- /dev/null
+++ b/tests/tools/subagents/list-agents.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import { executeTool } from "@test/helpers";
+import { createLongRunningTools } from "./helpers";
+
+describe("ListAgents tool", () => {
+ it("lists compact agent records", async () => {
+ const tools = createLongRunningTools();
+ await executeTool(tools.SpawnAgent, {
+ task: "Research auth",
+ task_name: "research/auth",
+ profile: null,
+ context: null,
+ tools: null,
+ metadata: null,
+ });
+
+ const result = await executeTool(tools.ListAgents, {
+ status: null,
+ path_prefix: "research",
+ include_terminal: false,
+ limit: null,
+ });
+
+ expect(result).toMatchObject({
+ agents: [
+ {
+ agent_id: "agent_1",
+ task_name: "research/auth",
+ profile: "worker",
+ status: expect.stringMatching(/pending|running/),
+ last_task_message: "Research auth",
+ },
+ ],
+ });
+ });
+
+ it("filters by status", async () => {
+ const tools = createLongRunningTools();
+ await executeTool(tools.SpawnAgent, {
+ task: "Research auth",
+ task_name: null,
+ profile: null,
+ context: null,
+ tools: null,
+ metadata: null,
+ });
+
+ const result = await executeTool(tools.ListAgents, {
+ status: "completed",
+ path_prefix: null,
+ include_terminal: true,
+ limit: null,
+ });
+
+ expect(result).toEqual({ agents: [] });
+ });
+});
diff --git a/tests/tools/subagents/message-tools.test.ts b/tests/tools/subagents/message-tools.test.ts
new file mode 100644
index 0000000..ee3911c
--- /dev/null
+++ b/tests/tools/subagents/message-tools.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from "vitest";
+import { executeTool } from "@test/helpers";
+import type { SubagentRunner } from "@/subagents";
+import { createSubagentController, createSubagentControlTools } from "@/index";
+import { createLongRunningRunner, createLongRunningTools } from "./helpers";
+
+describe("SendMessage and FollowupTask tools", () => {
+ it("queues messages for existing agents", async () => {
+ const tools = createLongRunningTools();
+ const spawned = await executeTool(tools.SpawnAgent, {
+ task: "Research",
+ task_name: null,
+ profile: null,
+ context: null,
+ tools: null,
+ metadata: null,
+ });
+ if (
+ typeof spawned !== "object" ||
+ spawned === null ||
+ !("agent_id" in spawned)
+ ) {
+ throw new Error("spawn failed");
+ }
+
+ const result = await executeTool(tools.SendMessage, {
+ agent: String(spawned.agent_id),
+ message: "Use the new constraint",
+ metadata: null,
+ });
+
+ expect(result).toMatchObject({
+ queued: true,
+ agent_id: spawned.agent_id,
+ triggered_turn: false,
+ });
+ });
+
+ it("rejects follow-up tasks targeting the current agent", async () => {
+ const controller = createSubagentController({
+ runner: createLongRunningRunner(),
+ });
+ const tools = createSubagentControlTools(controller, {
+ currentAgentId: "agent_1",
+ });
+
+ const result = await executeTool(tools.FollowupTask, {
+ agent: "agent_1",
+ task: "Continue",
+ metadata: null,
+ });
+
+ expect(result).toEqual({
+ error: "FollowupTask cannot target the current agent",
+ });
+ });
+
+ it("requests a turn when the runner supports follow-ups", async () => {
+ const runner: SubagentRunner = {
+ capabilities: { interrupt: false, followup: true },
+ async run(request) {
+ await request.callbacks.onStatus("waiting");
+ return new Promise(() => undefined);
+ },
+ async requestTurn(handle) {
+ return {
+ queued: true,
+ agent_id: handle.agent_id,
+ message_id: "runner-turn",
+ triggered_turn: true,
+ };
+ },
+ };
+ const controller = createSubagentController({ runner });
+ const tools = createSubagentControlTools(controller);
+ const spawned = await executeTool(tools.SpawnAgent, {
+ task: "Research",
+ task_name: null,
+ profile: null,
+ context: null,
+ tools: null,
+ metadata: null,
+ });
+ if (
+ typeof spawned !== "object" ||
+ spawned === null ||
+ !("agent_id" in spawned)
+ ) {
+ throw new Error("spawn failed");
+ }
+
+ const result = await executeTool(tools.FollowupTask, {
+ agent: String(spawned.agent_id),
+ task: "Continue",
+ metadata: null,
+ });
+
+ expect(result).toMatchObject({
+ queued: true,
+ triggered_turn: true,
+ });
+ });
+});
diff --git a/tests/tools/subagents/spawn-agent.test.ts b/tests/tools/subagents/spawn-agent.test.ts
new file mode 100644
index 0000000..718ed60
--- /dev/null
+++ b/tests/tools/subagents/spawn-agent.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import { executeTool } from "@test/helpers";
+import { createLongRunningTools } from "./helpers";
+
+describe("SpawnAgent tool", () => {
+ it("spawns a subagent and returns a stable handle", async () => {
+ const tools = createLongRunningTools();
+
+ const result = await executeTool(tools.SpawnAgent, {
+ task: "Research auth patterns",
+ task_name: "research/auth",
+ profile: null,
+ context: null,
+ tools: null,
+ metadata: null,
+ });
+
+ expect(result).toMatchObject({
+ agent_id: "agent_1",
+ task_name: "research/auth",
+ status: "pending",
+ profile: "worker",
+ nickname: "worker",
+ });
+ });
+
+ it("returns controller errors for empty tasks", async () => {
+ const tools = createLongRunningTools();
+
+ const result = await executeTool(tools.SpawnAgent, {
+ task: "",
+ task_name: null,
+ profile: null,
+ context: null,
+ tools: null,
+ metadata: null,
+ });
+
+ expect(result).toEqual({ error: "Subagent task cannot be empty" });
+ });
+
+ it("returns controller errors for duplicate live task names", async () => {
+ const tools = createLongRunningTools();
+ await executeTool(tools.SpawnAgent, {
+ task: "first",
+ task_name: "research/auth",
+ profile: null,
+ context: null,
+ tools: null,
+ metadata: null,
+ });
+
+ const duplicate = await executeTool(tools.SpawnAgent, {
+ task: "second",
+ task_name: "research/auth",
+ profile: null,
+ context: null,
+ tools: null,
+ metadata: null,
+ });
+
+ expect(duplicate).toEqual({
+ error: "Subagent task_name already exists: research/auth",
+ });
+ });
+});
diff --git a/tests/tools/subagents/wait-agent.test.ts b/tests/tools/subagents/wait-agent.test.ts
new file mode 100644
index 0000000..01d9b20
--- /dev/null
+++ b/tests/tools/subagents/wait-agent.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import { executeTool } from "@test/helpers";
+import { createCompletedTools, createLongRunningTools } from "./helpers";
+
+describe("WaitAgent tool", () => {
+ it("returns terminal results when a subagent completes", async () => {
+ const tools = createCompletedTools();
+ const spawned = await executeTool(tools.SpawnAgent, {
+ task: "Research auth",
+ task_name: "research/auth",
+ profile: null,
+ context: null,
+ tools: null,
+ metadata: null,
+ });
+ if (
+ typeof spawned !== "object" ||
+ spawned === null ||
+ !("agent_id" in spawned)
+ ) {
+ throw new Error("spawn failed");
+ }
+
+ const result = await executeTool(tools.WaitAgent, {
+ agent: String(spawned.agent_id),
+ timeout_ms: 500,
+ until_status: null,
+ });
+
+ expect(result).toMatchObject({
+ status: "ready",
+ agent: { status: "completed" },
+ result: { status: "completed", result: "done" },
+ });
+ });
+
+ it("returns timeout without changing status", async () => {
+ const tools = createLongRunningTools();
+ const spawned = await executeTool(tools.SpawnAgent, {
+ task: "Research auth",
+ task_name: null,
+ profile: null,
+ context: null,
+ tools: null,
+ metadata: null,
+ });
+ if (
+ typeof spawned !== "object" ||
+ spawned === null ||
+ !("agent_id" in spawned)
+ ) {
+ throw new Error("spawn failed");
+ }
+
+ const result = await executeTool(tools.WaitAgent, {
+ agent: String(spawned.agent_id),
+ timeout_ms: 100,
+ until_status: "completed",
+ });
+
+ expect(result).toMatchObject({
+ status: "timeout",
+ agent: { agent_id: spawned.agent_id },
+ });
+ });
+});
From ae8bf9445e02e3874999ae13fb065b65839e229c Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 10:07:33 -0400
Subject: [PATCH 05/20] Add runtime events and update plan tool
---
AGENTS.md | 2 +
src/index.ts | 70 +++++++++
src/runtime/AGENTS.md | 45 ++++++
src/runtime/CLAUDE.md | 1 +
src/runtime/approvals.ts | 86 +++++++++++
src/runtime/events.ts | 51 +++++++
src/runtime/index.ts | 71 +++++++++
src/runtime/plan.ts | 119 +++++++++++++++
src/runtime/snapshots.ts | 106 ++++++++++++++
src/runtime/types.ts | 249 ++++++++++++++++++++++++++++++++
src/subagents/AGENTS.md | 6 +-
src/subagents/controller.ts | 26 ++++
src/subagents/events.ts | 112 ++++++++++++++
src/subagents/index.ts | 1 +
src/subagents/types.ts | 2 +
src/tools/AGENTS.md | 19 ++-
src/tools/index.ts | 35 ++++-
src/tools/update-plan.ts | 126 ++++++++++++++++
src/types.ts | 12 ++
tests/runtime/events.test.ts | 113 +++++++++++++++
tests/runtime/plan.test.ts | 76 ++++++++++
tests/subagents/events.test.ts | 55 +++++++
tests/tools/index.test.ts | 2 +
tests/tools/update-plan.test.ts | 76 ++++++++++
24 files changed, 1447 insertions(+), 14 deletions(-)
create mode 100644 src/runtime/AGENTS.md
create mode 120000 src/runtime/CLAUDE.md
create mode 100644 src/runtime/approvals.ts
create mode 100644 src/runtime/events.ts
create mode 100644 src/runtime/index.ts
create mode 100644 src/runtime/plan.ts
create mode 100644 src/runtime/snapshots.ts
create mode 100644 src/runtime/types.ts
create mode 100644 src/tools/update-plan.ts
create mode 100644 tests/runtime/events.test.ts
create mode 100644 tests/runtime/plan.test.ts
create mode 100644 tests/subagents/events.test.ts
create mode 100644 tests/tools/update-plan.test.ts
diff --git a/AGENTS.md b/AGENTS.md
index 10e9b24..7299e90 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,6 +45,7 @@ src/
├── middleware/ # AI SDK language model middleware — src/middleware/AGENTS.md
├── utils/ # Budget, compaction, context status, helpers — src/utils/AGENTS.md
├── subagents/ # Subagent control plane foundation — src/subagents/AGENTS.md
+├── runtime/ # Runtime events, plan state, approvals, snapshots — src/runtime/AGENTS.md
├── skills/ # Agent Skills standard — src/skills/AGENTS.md
├── setup/ # Agent environment setup (sandbox bootstrapping) — src/setup/AGENTS.md
├── cli/ # CLI initialization — src/cli/AGENTS.md
@@ -295,5 +296,6 @@ See `src/tools/AGENTS.md` for per-tool config details.
| Add a cache backend | `src/cache/AGENTS.md` → "Common Modifications" |
| Add a context layer or prompt section | `src/context/AGENTS.md` → "Common Modifications" |
| Add subagent foundation behavior | `src/subagents/AGENTS.md` → "Common Modifications" |
+| Add runtime events, plan state, approvals, or snapshots | `src/runtime/AGENTS.md` → "Common Modifications" |
| Add a skill source | `src/skills/AGENTS.md` → "Common Modifications" |
| Add a config field | Define in `src/types.ts`, consume in the relevant factory via `config?.yourField ?? default` |
diff --git a/src/index.ts b/src/index.ts
index 934277c..d056eee 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -109,6 +109,70 @@ export {
resolveSubagentPath,
} from "./subagents";
+// Runtime events, plans, approvals, and host-facing snapshots
+export type {
+ AgentActivitySnapshot,
+ AgentLifecycleEvent,
+ AgentStatus,
+ ApprovalDecision,
+ ApprovalRequest,
+ ApprovalRequestedEvent,
+ ApprovalResolvedEvent,
+ ApprovalResult,
+ ApprovalSubject,
+ ChangesSnapshotItem,
+ CommandOutputEvent,
+ CostUpdatedEvent,
+ FileChangedEvent,
+ FileChangeKind,
+ MemoryRuntimeEventSink,
+ PlanItem,
+ PlanItemStatus,
+ PlanSnapshot,
+ PlanState,
+ PlanStats,
+ PlanUpdateContext,
+ PlanUpdateError,
+ PlanUpdateInput,
+ PlanUpdateResult,
+ PlanUpdatedEvent,
+ ProgressSnapshot,
+ RuntimeEvent,
+ RuntimeEventBase,
+ RuntimeEventListener,
+ RuntimeEventSink,
+ RuntimeEventType,
+ RuntimeUsage,
+ ThreadStartedEvent,
+ ToolCompletedEvent,
+ ToolFailedEvent,
+ ToolStartedEvent,
+ TurnCompletedEvent,
+ TurnFailedEvent,
+ TurnStartedEvent,
+} from "./runtime";
+export {
+ createApprovalId,
+ createApprovalRequest,
+ createApprovalRequestedEvent,
+ createApprovalResolvedEvent,
+ createApprovalResult,
+ createMemoryRuntimeEventSink,
+ createPlanState,
+ createPlanUpdatedEvent,
+ createRuntimeEvent,
+ emitRuntimeEvent,
+ getPlanStats,
+ projectAgentActivitySnapshot,
+ projectChangesSnapshot,
+ projectPlanSnapshot,
+ projectProgressSnapshot,
+ resetApprovalIdCounterForTests,
+ snapshotPlanState,
+ updatePlanState,
+ validatePlanUpdate,
+} from "./runtime";
+
// Sandbox interface
export type { ExecOptions, ExecResult, Sandbox } from "./sandbox/interface";
// Tool output types
@@ -176,6 +240,10 @@ export type {
TaskError,
TaskOutput,
TaskToolConfig,
+ // UpdatePlan tool
+ UpdatePlanError,
+ UpdatePlanOutput,
+ UpdatePlanToolConfig,
// TodoWrite tool
TodoItem,
TodoState,
@@ -212,6 +280,7 @@ export {
createSubagentControlTools,
createTaskTool,
createTodoWriteTool,
+ createUpdatePlanTool,
createWaitAgentTool,
createWebFetchTool,
createWebSearchTool,
@@ -228,6 +297,7 @@ export type {
ModelRegistryConfig,
ModelRegistryProvider,
PricingProvider,
+ RuntimeConfig,
SkillConfig,
ToolConfig,
WebFetchConfig,
diff --git a/src/runtime/AGENTS.md b/src/runtime/AGENTS.md
new file mode 100644
index 0000000..66f8ae7
--- /dev/null
+++ b/src/runtime/AGENTS.md
@@ -0,0 +1,45 @@
+# Runtime Module
+
+Host-facing runtime primitives for building Codex-like agent experiences on top of BashKit without making BashKit an app server. This module owns normalized event contracts, event sinks, canonical plan state, approval lifecycle contracts, and snapshot reducers.
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `types.ts` | Shared JSON, runtime event, approval, file change, command output, and tool lifecycle contracts |
+| `events.ts` | Subscribable runtime event sink factory and emit helpers |
+| `plan.ts` | Canonical Codex-style plan state, stats, and update helpers |
+| `approvals.ts` | Approval request/result helpers and lifecycle event constructors |
+| `snapshots.ts` | Reducers that project event streams into plan/progress/change snapshots |
+| `index.ts` | Barrel exports |
+
+## Architecture
+
+`RuntimeEventSink` is a small host-facing event stream. It records and publishes typed JSON-safe `RuntimeEvent` objects but does not decide where they go. Hosts can forward events to React state, WebSockets, databases, logs, or queues.
+
+`PlanState` is canonical for progress tracking. It follows Codex `update_plan` semantics: an optional explanation plus plan items with `step` and `status`. Legacy `TodoWrite` can adapt into this shape, but new runtime state should not depend on `TodoWrite.activeForm`.
+
+## Design Rules
+
+- Keep this module host-agnostic. Do not add HTTP servers, WebSocket servers, databases, auth, or queue implementations here.
+- Use serializable event records so events can cross process and service boundaries.
+- Prefer factory functions and pure reducers over classes.
+- Keep event payloads typed and explicit. Use `JsonObject` only for genuinely open metadata.
+- Avoid importing from `tools/` to prevent runtime/tool cycles. Tools may import runtime primitives.
+- Return `{ error: string }` only at model/tool boundaries; pure helpers can return typed values.
+
+## Common Modifications
+
+### Add a new runtime event
+
+1. Add a specific event interface and union member in `types.ts`.
+2. Add helper construction logic in the module closest to the source of the event.
+3. Update `snapshots.ts` if host UIs should derive state from the event.
+4. Add focused tests in `tests/runtime/`.
+
+### Add a new snapshot projection
+
+1. Define the snapshot type in `types.ts` or `snapshots.ts`.
+2. Implement a pure reducer in `snapshots.ts`.
+3. Test multiple-event ordering, empty streams, and irrelevant-event filtering.
+
diff --git a/src/runtime/CLAUDE.md b/src/runtime/CLAUDE.md
new file mode 120000
index 0000000..47dc3e3
--- /dev/null
+++ b/src/runtime/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/src/runtime/approvals.ts b/src/runtime/approvals.ts
new file mode 100644
index 0000000..9269d13
--- /dev/null
+++ b/src/runtime/approvals.ts
@@ -0,0 +1,86 @@
+import { createRuntimeEvent } from "./events";
+import type {
+ ApprovalDecision,
+ ApprovalRequest,
+ ApprovalRequestedEvent,
+ ApprovalResolvedEvent,
+ ApprovalResult,
+ ApprovalSubject,
+ JsonObject,
+} from "./types";
+
+let approvalIdCounter = 0;
+
+export function createApprovalId(prefix = "approval"): string {
+ approvalIdCounter += 1;
+ return `${prefix}_${approvalIdCounter.toString(36).padStart(4, "0")}`;
+}
+
+export function resetApprovalIdCounterForTests(): void {
+ approvalIdCounter = 0;
+}
+
+export function createApprovalRequest(input: {
+ subject: ApprovalSubject;
+ reason?: string | null;
+ approvalId?: string;
+ agentId?: string | null;
+ threadId?: string | null;
+ turnId?: string | null;
+ metadata?: JsonObject;
+}): ApprovalRequest {
+ return {
+ approval_id: input.approvalId ?? createApprovalId(),
+ subject: input.subject,
+ reason: input.reason ?? null,
+ requested_at: new Date().toISOString(),
+ agent_id: input.agentId ?? null,
+ thread_id: input.threadId ?? null,
+ turn_id: input.turnId ?? null,
+ metadata: input.metadata,
+ };
+}
+
+export function createApprovalResult(input: {
+ approvalId: string;
+ decision: ApprovalDecision;
+ reason?: string | null;
+ metadata?: JsonObject;
+}): ApprovalResult {
+ return {
+ approval_id: input.approvalId,
+ decision: input.decision,
+ reason: input.reason ?? null,
+ resolved_at: new Date().toISOString(),
+ metadata: input.metadata,
+ };
+}
+
+export function createApprovalRequestedEvent(
+ approval: ApprovalRequest,
+): ApprovalRequestedEvent {
+ return createRuntimeEvent({
+ type: "approval.requested",
+ approval,
+ agent_id: approval.agent_id ?? null,
+ thread_id: approval.thread_id ?? null,
+ turn_id: approval.turn_id ?? null,
+ }) as ApprovalRequestedEvent;
+}
+
+export function createApprovalResolvedEvent(
+ approval: ApprovalRequest,
+ result: ApprovalResult,
+): ApprovalResolvedEvent {
+ return createRuntimeEvent({
+ type:
+ result.decision === "rejected"
+ ? "approval.rejected"
+ : "approval.resolved",
+ approval,
+ result,
+ agent_id: approval.agent_id ?? null,
+ thread_id: approval.thread_id ?? null,
+ turn_id: approval.turn_id ?? null,
+ }) as ApprovalResolvedEvent;
+}
diff --git a/src/runtime/events.ts b/src/runtime/events.ts
new file mode 100644
index 0000000..f3a63e8
--- /dev/null
+++ b/src/runtime/events.ts
@@ -0,0 +1,51 @@
+import type {
+ RuntimeEvent,
+ RuntimeEventListener,
+ RuntimeEventSink,
+} from "./types";
+
+type RuntimeEventInput = RuntimeEvent extends infer Event
+ ? Event extends RuntimeEvent
+ ? Omit & { timestamp?: string }
+ : never
+ : never;
+
+export interface MemoryRuntimeEventSink extends RuntimeEventSink {
+ readonly events: RuntimeEvent[];
+ subscribe(listener: RuntimeEventListener): () => void;
+}
+
+export function createRuntimeEvent(event: RuntimeEventInput): RuntimeEvent {
+ return {
+ ...event,
+ timestamp: event.timestamp ?? new Date().toISOString(),
+ } as RuntimeEvent;
+}
+
+export function createMemoryRuntimeEventSink(): MemoryRuntimeEventSink {
+ const events: RuntimeEvent[] = [];
+ const listeners = new Set();
+
+ return {
+ events,
+ async emit(event: RuntimeEvent): Promise {
+ events.push(event);
+ for (const listener of listeners) {
+ await listener(event);
+ }
+ },
+ subscribe(listener: RuntimeEventListener): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+}
+
+export async function emitRuntimeEvent(
+ sink: RuntimeEventSink | undefined,
+ event: RuntimeEvent,
+): Promise {
+ await sink?.emit(event);
+}
diff --git a/src/runtime/index.ts b/src/runtime/index.ts
new file mode 100644
index 0000000..d768bac
--- /dev/null
+++ b/src/runtime/index.ts
@@ -0,0 +1,71 @@
+export type {
+ AgentLifecycleEvent,
+ AgentStatus,
+ ApprovalDecision,
+ ApprovalRequest,
+ ApprovalRequestedEvent,
+ ApprovalResolvedEvent,
+ ApprovalResult,
+ ApprovalSubject,
+ CommandOutputEvent,
+ CostUpdatedEvent,
+ FileChangedEvent,
+ FileChangeKind,
+ JsonObject,
+ JsonPrimitive,
+ JsonValue,
+ PlanItem,
+ PlanItemStatus,
+ PlanSnapshot,
+ PlanStats,
+ PlanUpdatedEvent,
+ RuntimeEvent,
+ RuntimeEventBase,
+ RuntimeEventListener,
+ RuntimeEventSink,
+ RuntimeEventType,
+ RuntimeUsage,
+ ThreadStartedEvent,
+ ToolCompletedEvent,
+ ToolFailedEvent,
+ ToolStartedEvent,
+ TurnCompletedEvent,
+ TurnFailedEvent,
+ TurnStartedEvent,
+} from "./types";
+export {
+ createMemoryRuntimeEventSink,
+ createRuntimeEvent,
+ emitRuntimeEvent,
+ type MemoryRuntimeEventSink,
+} from "./events";
+export {
+ createPlanState,
+ createPlanUpdatedEvent,
+ getPlanStats,
+ snapshotPlanState,
+ updatePlanState,
+ validatePlanUpdate,
+ type PlanState,
+ type PlanUpdateContext,
+ type PlanUpdateError,
+ type PlanUpdateInput,
+ type PlanUpdateResult,
+} from "./plan";
+export {
+ createApprovalId,
+ createApprovalRequest,
+ createApprovalRequestedEvent,
+ createApprovalResolvedEvent,
+ createApprovalResult,
+ resetApprovalIdCounterForTests,
+} from "./approvals";
+export {
+ projectAgentActivitySnapshot,
+ projectChangesSnapshot,
+ projectPlanSnapshot,
+ projectProgressSnapshot,
+ type AgentActivitySnapshot,
+ type ChangesSnapshotItem,
+ type ProgressSnapshot,
+} from "./snapshots";
diff --git a/src/runtime/plan.ts b/src/runtime/plan.ts
new file mode 100644
index 0000000..690c5f5
--- /dev/null
+++ b/src/runtime/plan.ts
@@ -0,0 +1,119 @@
+import { createRuntimeEvent } from "./events";
+import type {
+ PlanItem,
+ PlanSnapshot,
+ PlanStats,
+ PlanUpdatedEvent,
+ RuntimeEventSink,
+} from "./types";
+
+export interface PlanState {
+ explanation: string | null;
+ plan: PlanItem[];
+ updated_at: string | null;
+}
+
+export interface PlanUpdateInput {
+ explanation?: string | null;
+ plan: PlanItem[];
+}
+
+export interface PlanUpdateResult {
+ message: string;
+ snapshot: PlanSnapshot;
+}
+
+export interface PlanUpdateError {
+ error: string;
+}
+
+export interface PlanUpdateContext {
+ agent_id?: string | null;
+ thread_id?: string | null;
+ turn_id?: string | null;
+ parent_agent_id?: string | null;
+}
+
+export function createPlanState(initial?: Partial): PlanState {
+ return {
+ explanation: initial?.explanation ?? null,
+ plan: initial?.plan ? [...initial.plan] : [],
+ updated_at: initial?.updated_at ?? null,
+ };
+}
+
+export function getPlanStats(plan: readonly PlanItem[]): PlanStats {
+ return {
+ total: plan.length,
+ pending: plan.filter((item) => item.status === "pending").length,
+ in_progress: plan.filter((item) => item.status === "in_progress").length,
+ completed: plan.filter((item) => item.status === "completed").length,
+ };
+}
+
+export function snapshotPlanState(state: PlanState): PlanSnapshot {
+ return {
+ explanation: state.explanation,
+ plan: state.plan.map((item) => ({ ...item })),
+ stats: getPlanStats(state.plan),
+ updated_at: state.updated_at,
+ };
+}
+
+export function validatePlanUpdate(
+ input: PlanUpdateInput,
+): PlanUpdateError | null {
+ const inProgressCount = input.plan.filter(
+ (item) => item.status === "in_progress",
+ ).length;
+ if (inProgressCount > 1) {
+ return { error: "At most one plan item can be in_progress" };
+ }
+
+ const emptyStep = input.plan.find((item) => item.step.trim().length === 0);
+ if (emptyStep) return { error: "Plan item step cannot be empty" };
+
+ return null;
+}
+
+export function createPlanUpdatedEvent(
+ snapshot: PlanSnapshot,
+ context?: PlanUpdateContext,
+): PlanUpdatedEvent {
+ return createRuntimeEvent({
+ type: "plan.updated",
+ explanation: snapshot.explanation,
+ plan: snapshot.plan,
+ stats: snapshot.stats,
+ agent_id: context?.agent_id ?? null,
+ parent_agent_id: context?.parent_agent_id ?? null,
+ thread_id: context?.thread_id ?? null,
+ turn_id: context?.turn_id ?? null,
+ }) as PlanUpdatedEvent;
+}
+
+export async function updatePlanState(
+ state: PlanState,
+ input: PlanUpdateInput,
+ options?: {
+ eventSink?: RuntimeEventSink;
+ context?: PlanUpdateContext;
+ },
+): Promise {
+ const validation = validatePlanUpdate(input);
+ if (validation) return validation;
+
+ state.explanation = input.explanation ?? null;
+ state.plan = input.plan.map((item) => ({ ...item }));
+ state.updated_at = new Date().toISOString();
+
+ const snapshot = snapshotPlanState(state);
+ await options?.eventSink?.emit(
+ createPlanUpdatedEvent(snapshot, options.context),
+ );
+
+ return {
+ message: "Plan updated",
+ snapshot,
+ };
+}
diff --git a/src/runtime/snapshots.ts b/src/runtime/snapshots.ts
new file mode 100644
index 0000000..a794c16
--- /dev/null
+++ b/src/runtime/snapshots.ts
@@ -0,0 +1,106 @@
+import type {
+ AgentLifecycleEvent,
+ AgentStatus,
+ FileChangeKind,
+ PlanSnapshot,
+ RuntimeEvent,
+} from "./types";
+
+export interface AgentActivitySnapshot {
+ agent_id: string;
+ parent_agent_id: string | null;
+ status: AgentStatus;
+ task_name: string | null;
+ profile: string | null;
+ message: string | null;
+ error: string | null;
+ updated_at: string;
+}
+
+export interface ChangesSnapshotItem {
+ path: string;
+ change: FileChangeKind;
+ unified_diff: string | null;
+ updated_at: string;
+}
+
+export interface ProgressSnapshot {
+ plan: PlanSnapshot | null;
+ agents: AgentActivitySnapshot[];
+ changes: ChangesSnapshotItem[];
+}
+
+function isAgentLifecycleEvent(
+ event: RuntimeEvent,
+): event is AgentLifecycleEvent {
+ return event.type.startsWith("agent.");
+}
+
+export function projectPlanSnapshot(
+ events: readonly RuntimeEvent[],
+): PlanSnapshot | null {
+ const latest = [...events]
+ .reverse()
+ .find((event) => event.type === "plan.updated");
+ if (!latest || latest.type !== "plan.updated") return null;
+ return {
+ explanation: latest.explanation,
+ plan: latest.plan.map((item) => ({ ...item })),
+ stats: { ...latest.stats },
+ updated_at: latest.timestamp,
+ };
+}
+
+export function projectAgentActivitySnapshot(
+ events: readonly RuntimeEvent[],
+): AgentActivitySnapshot[] {
+ const agents = new Map();
+
+ for (const event of events) {
+ if (!isAgentLifecycleEvent(event)) continue;
+ agents.set(event.agent_id, {
+ agent_id: event.agent_id,
+ parent_agent_id: event.parent_agent_id,
+ status: event.status,
+ task_name: event.task_name ?? null,
+ profile: event.profile ?? null,
+ message: event.message ?? null,
+ error: event.error ?? null,
+ updated_at: event.timestamp,
+ });
+ }
+
+ return [...agents.values()].sort((left, right) =>
+ left.updated_at.localeCompare(right.updated_at),
+ );
+}
+
+export function projectChangesSnapshot(
+ events: readonly RuntimeEvent[],
+): ChangesSnapshotItem[] {
+ const changes = new Map();
+
+ for (const event of events) {
+ if (event.type !== "file.changed") continue;
+ changes.set(event.path, {
+ path: event.path,
+ change: event.change,
+ unified_diff: event.unified_diff ?? null,
+ updated_at: event.timestamp,
+ });
+ }
+
+ return [...changes.values()].sort((left, right) =>
+ left.path.localeCompare(right.path),
+ );
+}
+
+export function projectProgressSnapshot(
+ events: readonly RuntimeEvent[],
+): ProgressSnapshot {
+ return {
+ plan: projectPlanSnapshot(events),
+ agents: projectAgentActivitySnapshot(events),
+ changes: projectChangesSnapshot(events),
+ };
+}
diff --git a/src/runtime/types.ts b/src/runtime/types.ts
new file mode 100644
index 0000000..9c7c458
--- /dev/null
+++ b/src/runtime/types.ts
@@ -0,0 +1,249 @@
+export type JsonPrimitive = string | number | boolean | null;
+export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
+export interface JsonObject {
+ [key: string]: JsonValue;
+}
+
+export type RuntimeEventType =
+ | "thread.started"
+ | "turn.started"
+ | "turn.completed"
+ | "turn.failed"
+ | "agent.created"
+ | "agent.started"
+ | "agent.status_changed"
+ | "agent.message_queued"
+ | "agent.completed"
+ | "agent.failed"
+ | "agent.interrupted"
+ | "tool.started"
+ | "tool.completed"
+ | "tool.failed"
+ | "plan.updated"
+ | "file.changed"
+ | "command.output"
+ | "approval.requested"
+ | "approval.resolved"
+ | "approval.rejected"
+ | "cost.updated";
+
+export interface RuntimeEventBase {
+ type: RuntimeEventType;
+ timestamp: string;
+ thread_id?: string | null;
+ turn_id?: string | null;
+ agent_id?: string | null;
+ parent_agent_id?: string | null;
+ metadata?: JsonObject;
+}
+
+export interface RuntimeUsage {
+ input_tokens?: number;
+ cached_input_tokens?: number;
+ output_tokens?: number;
+ reasoning_output_tokens?: number;
+ total_cost_usd?: number;
+}
+
+export interface ThreadStartedEvent extends RuntimeEventBase {
+ type: "thread.started";
+ thread_id: string;
+}
+
+export interface TurnStartedEvent extends RuntimeEventBase {
+ type: "turn.started";
+ turn_id: string;
+}
+
+export interface TurnCompletedEvent extends RuntimeEventBase {
+ type: "turn.completed";
+ turn_id: string;
+ usage?: RuntimeUsage;
+}
+
+export interface TurnFailedEvent extends RuntimeEventBase {
+ type: "turn.failed";
+ turn_id: string;
+ error: string;
+}
+
+export type AgentStatus =
+ | "pending"
+ | "running"
+ | "waiting"
+ | "completed"
+ | "failed"
+ | "interrupted";
+
+export interface AgentLifecycleEvent extends RuntimeEventBase {
+ type:
+ | "agent.created"
+ | "agent.started"
+ | "agent.status_changed"
+ | "agent.message_queued"
+ | "agent.completed"
+ | "agent.failed"
+ | "agent.interrupted";
+ agent_id: string;
+ parent_agent_id: string | null;
+ status: AgentStatus;
+ task_name?: string | null;
+ profile?: string | null;
+ message?: string | null;
+ error?: string | null;
+}
+
+export interface ToolStartedEvent extends RuntimeEventBase {
+ type: "tool.started";
+ tool_call_id: string;
+ tool_name: string;
+ input?: JsonObject;
+}
+
+export interface ToolCompletedEvent extends RuntimeEventBase {
+ type: "tool.completed";
+ tool_call_id: string;
+ tool_name: string;
+ output?: JsonValue;
+ duration_ms?: number;
+}
+
+export interface ToolFailedEvent extends RuntimeEventBase {
+ type: "tool.failed";
+ tool_call_id: string;
+ tool_name: string;
+ error: string;
+ duration_ms?: number;
+}
+
+export type PlanItemStatus = "pending" | "in_progress" | "completed";
+
+export interface PlanItem {
+ step: string;
+ status: PlanItemStatus;
+}
+
+export interface PlanStats {
+ total: number;
+ pending: number;
+ in_progress: number;
+ completed: number;
+}
+
+export interface PlanSnapshot {
+ explanation: string | null;
+ plan: PlanItem[];
+ stats: PlanStats;
+ updated_at: string | null;
+}
+
+export interface PlanUpdatedEvent extends RuntimeEventBase {
+ type: "plan.updated";
+ explanation: string | null;
+ plan: PlanItem[];
+ stats: PlanStats;
+}
+
+export type FileChangeKind = "created" | "modified" | "deleted";
+
+export interface FileChangedEvent extends RuntimeEventBase {
+ type: "file.changed";
+ path: string;
+ change: FileChangeKind;
+ unified_diff?: string | null;
+}
+
+export interface CommandOutputEvent extends RuntimeEventBase {
+ type: "command.output";
+ command_id: string;
+ stream: "stdout" | "stderr";
+ chunk: string;
+}
+
+export type ApprovalSubject =
+ | {
+ type: "tool";
+ tool_name: string;
+ tool_call_id?: string | null;
+ input?: JsonObject;
+ }
+ | {
+ type: "command";
+ command: string;
+ cwd?: string | null;
+ }
+ | {
+ type: "file_change";
+ path: string;
+ change: FileChangeKind;
+ }
+ | {
+ type: "permission";
+ permission: string;
+ };
+
+export type ApprovalDecision =
+ | "approved"
+ | "approved_for_session"
+ | "rejected"
+ | "cancelled";
+
+export interface ApprovalRequest {
+ approval_id: string;
+ subject: ApprovalSubject;
+ reason: string | null;
+ requested_at: string;
+ agent_id?: string | null;
+ thread_id?: string | null;
+ turn_id?: string | null;
+ metadata?: JsonObject;
+}
+
+export interface ApprovalResult {
+ approval_id: string;
+ decision: ApprovalDecision;
+ reason: string | null;
+ resolved_at: string;
+ metadata?: JsonObject;
+}
+
+export interface ApprovalRequestedEvent extends RuntimeEventBase {
+ type: "approval.requested";
+ approval: ApprovalRequest;
+}
+
+export interface ApprovalResolvedEvent extends RuntimeEventBase {
+ type: "approval.resolved" | "approval.rejected";
+ approval: ApprovalRequest;
+ result: ApprovalResult;
+}
+
+export interface CostUpdatedEvent extends RuntimeEventBase {
+ type: "cost.updated";
+ usage: RuntimeUsage;
+}
+
+export type RuntimeEvent =
+ | ThreadStartedEvent
+ | TurnStartedEvent
+ | TurnCompletedEvent
+ | TurnFailedEvent
+ | AgentLifecycleEvent
+ | ToolStartedEvent
+ | ToolCompletedEvent
+ | ToolFailedEvent
+ | PlanUpdatedEvent
+ | FileChangedEvent
+ | CommandOutputEvent
+ | ApprovalRequestedEvent
+ | ApprovalResolvedEvent
+ | CostUpdatedEvent;
+
+export interface RuntimeEventSink {
+ emit(event: RuntimeEvent): void | Promise;
+ subscribe?(listener: RuntimeEventListener): () => void;
+}
+
+export type RuntimeEventListener = (
+ event: RuntimeEvent,
+) => void | Promise;
diff --git a/src/subagents/AGENTS.md b/src/subagents/AGENTS.md
index 1d9243d..2ddf126 100644
--- a/src/subagents/AGENTS.md
+++ b/src/subagents/AGENTS.md
@@ -1,6 +1,6 @@
# Subagents Module
-Foundation for controller-managed subagents. This module owns identity, path resolution, profile resolution, tool filtering, lifecycle status, mailbox records, event contracts, stores, runners, controller orchestration, and guardrail policies.
+Foundation for controller-managed subagents. This module owns identity, path resolution, profile resolution, tool filtering, lifecycle status, mailbox records, subagent event contracts, stores, runners, controller orchestration, runtime event bridging, and guardrail policies.
## Files
@@ -15,7 +15,7 @@ Foundation for controller-managed subagents. This module owns identity, path res
| `tool-filter.ts` | Tool allowlist/denylist filtering |
| `registry.ts` | In-memory identity and path registry factory |
| `store.ts` | Store factory for in-memory metadata, events, and mailbox records |
-| `events.ts` | Event sink factory and helpers |
+| `events.ts` | Subagent event sink factory, helpers, and runtime event bridge |
| `mailbox.ts` | Mailbox helpers |
| `runner.ts` | Runner capability helpers and fake test runner utilities |
| `execution-limits.ts` | Active/total/depth/mailbox/wait limit policy |
@@ -25,7 +25,7 @@ Foundation for controller-managed subagents. This module owns identity, path res
## Architecture
-`createSubagentController` owns orchestration through closure state. It resolves profiles, reserves identity, checks guardrails, writes store records, emits events, invokes lifecycle hooks, and delegates actual child execution to a `SubagentRunner`.
+`createSubagentController` owns orchestration through closure state. It resolves profiles, reserves identity, checks guardrails, writes store records, emits subagent events, optionally emits normalized runtime events, invokes lifecycle hooks, and delegates actual child execution to a `SubagentRunner`.
The default foundation does not put subagent methods on `Sandbox`. Child agents use sandbox-backed tools through their runner/tool surface.
diff --git a/src/subagents/controller.ts b/src/subagents/controller.ts
index e7e8c2f..142acfb 100644
--- a/src/subagents/controller.ts
+++ b/src/subagents/controller.ts
@@ -1,5 +1,6 @@
import { checkSubagentCostPolicy } from "./cost-control";
import { clampSubagentWaitTimeout } from "./execution-limits";
+import { subagentEventToRuntimeEvent } from "./events";
import { createMailboxMessage } from "./mailbox";
import { createSubagentProfileRegistry } from "./profiles";
import { createSubagentRegistry } from "./registry";
@@ -25,6 +26,7 @@ import type {
SubagentSpawnRequest,
SubagentSpawnResult,
SubagentStatus,
+ SubagentUsage,
SubagentWaitRequest,
SubagentWaitResult,
} from "./types";
@@ -50,6 +52,16 @@ function eventFromMetadata(
};
}
+function usagePayload(usage: SubagentUsage): SubagentEvent["payload"] {
+ return {
+ totalCostUsd: usage.totalCostUsd ?? null,
+ stepsCompleted: usage.stepsCompleted ?? null,
+ unpricedSteps: usage.unpricedSteps ?? null,
+ inputTokens: usage.inputTokens ?? null,
+ outputTokens: usage.outputTokens ?? null,
+ };
+}
+
export function createSubagentController(
config: SubagentControllerConfig,
): SubagentController {
@@ -63,12 +75,15 @@ export function createSubagentController(
const runner = config.runner;
const tools = config.tools ?? {};
const eventSink = config.eventSink;
+ const runtimeEventSink = config.runtimeEventSink;
const lifecycle = config.lifecycle;
const budget = config.budget;
async function emit(event: SubagentEvent): Promise {
await store.appendEvent(event);
await eventSink?.emit(event);
+ const runtimeEvent = subagentEventToRuntimeEvent(event);
+ if (runtimeEvent) await runtimeEventSink?.emit(runtimeEvent);
}
async function setStatus(
@@ -146,6 +161,16 @@ export function createSubagentController(
},
onUsage: async (usage) => {
await store.update(handle, { usage });
+ const metadata = registry.get(handle);
+ if (metadata) {
+ await emit(
+ eventFromMetadata(
+ metadata,
+ "subagent.usage",
+ usagePayload(usage),
+ ),
+ );
+ }
},
},
});
@@ -200,6 +225,7 @@ export function createSubagentController(
eventFromMetadata(record.metadata, "subagent.message_queued", {
message_id: mailboxMessage.id,
kind,
+ message,
}),
);
return {
diff --git a/src/subagents/events.ts b/src/subagents/events.ts
index 3cb9ed8..12935ff 100644
--- a/src/subagents/events.ts
+++ b/src/subagents/events.ts
@@ -1,3 +1,5 @@
+import { createRuntimeEvent } from "../runtime";
+import type { RuntimeEvent, RuntimeUsage } from "../runtime";
import type { SubagentEvent, SubagentEventSink } from "./types";
export interface MemorySubagentEventSink extends SubagentEventSink {
@@ -20,3 +22,113 @@ export async function emitSubagentEvent(
): Promise {
await sink?.emit(event);
}
+
+function numberFromPayload(
+ payload: SubagentEvent["payload"],
+ key: string,
+): number | undefined {
+ const value = payload?.[key];
+ return typeof value === "number" ? value : undefined;
+}
+
+function stringFromPayload(
+ payload: SubagentEvent["payload"],
+ key: string,
+): string | null {
+ const value = payload?.[key];
+ return typeof value === "string" ? value : null;
+}
+
+function runtimeUsageFromPayload(
+ payload: SubagentEvent["payload"],
+): RuntimeUsage {
+ return {
+ input_tokens: numberFromPayload(payload, "inputTokens"),
+ output_tokens: numberFromPayload(payload, "outputTokens"),
+ total_cost_usd: numberFromPayload(payload, "totalCostUsd"),
+ };
+}
+
+export function subagentEventToRuntimeEvent(
+ event: SubagentEvent,
+): RuntimeEvent | null {
+ const common = {
+ agent_id: event.agent_id,
+ parent_agent_id: event.parent_id,
+ metadata: {
+ source: "subagent",
+ subagent_event_type: event.type,
+ },
+ timestamp: event.timestamp,
+ } as const;
+
+ switch (event.type) {
+ case "subagent.created":
+ return createRuntimeEvent({
+ ...common,
+ type: "agent.created",
+ status: event.status,
+ task_name: event.task_name,
+ profile: event.profile,
+ });
+ case "subagent.started":
+ return createRuntimeEvent({
+ ...common,
+ type: "agent.started",
+ status: event.status,
+ task_name: event.task_name,
+ profile: event.profile,
+ });
+ case "subagent.status_changed":
+ return createRuntimeEvent({
+ ...common,
+ type: "agent.status_changed",
+ status: event.status,
+ task_name: event.task_name,
+ profile: event.profile,
+ });
+ case "subagent.message_queued":
+ return createRuntimeEvent({
+ ...common,
+ type: "agent.message_queued",
+ status: event.status,
+ task_name: event.task_name,
+ profile: event.profile,
+ message: stringFromPayload(event.payload, "message"),
+ });
+ case "subagent.completed":
+ return createRuntimeEvent({
+ ...common,
+ type: "agent.completed",
+ status: event.status,
+ task_name: event.task_name,
+ profile: event.profile,
+ });
+ case "subagent.failed":
+ return createRuntimeEvent({
+ ...common,
+ type: "agent.failed",
+ status: event.status,
+ task_name: event.task_name,
+ profile: event.profile,
+ error: stringFromPayload(event.payload, "error"),
+ });
+ case "subagent.interrupted":
+ return createRuntimeEvent({
+ ...common,
+ type: "agent.interrupted",
+ status: event.status,
+ task_name: event.task_name,
+ profile: event.profile,
+ });
+ case "subagent.usage":
+ return createRuntimeEvent({
+ ...common,
+ type: "cost.updated",
+ usage: runtimeUsageFromPayload(event.payload),
+ });
+ case "subagent.tool_call":
+ case "subagent.tool_result":
+ return null;
+ }
+}
diff --git a/src/subagents/index.ts b/src/subagents/index.ts
index c1e16f1..a7476cd 100644
--- a/src/subagents/index.ts
+++ b/src/subagents/index.ts
@@ -19,6 +19,7 @@ export { createInMemorySubagentStore } from "./store";
export {
createMemorySubagentEventSink,
emitSubagentEvent,
+ subagentEventToRuntimeEvent,
} from "./events";
export { createMailboxMessage } from "./mailbox";
export {
diff --git a/src/subagents/types.ts b/src/subagents/types.ts
index 2982e3d..3d42675 100644
--- a/src/subagents/types.ts
+++ b/src/subagents/types.ts
@@ -1,4 +1,5 @@
import type { LanguageModel, ModelMessage, ToolSet } from "ai";
+import type { RuntimeEventSink } from "../runtime";
import type { BudgetStatus, BudgetTracker } from "../utils/budget-tracking";
export type JsonPrimitive = string | number | boolean | null;
@@ -363,6 +364,7 @@ export interface SubagentControllerConfig {
runner: SubagentRunner;
tools?: ToolSet;
eventSink?: SubagentEventSink;
+ runtimeEventSink?: RuntimeEventSink;
lifecycle?: SubagentLifecycleHooks;
budget?: BudgetTracker;
cost?: SubagentCostPolicyInput;
diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md
index e352283..ef7ce86 100644
--- a/src/tools/AGENTS.md
+++ b/src/tools/AGENTS.md
@@ -1,6 +1,6 @@
# Tools Module
-The tools module implements all 16 AI agent tools in BashKit. These tools bridge AI models with sandbox execution environments, enabling agents to perform file operations, run commands, search code, fetch web content, manage workflows, and interact with users. Each tool follows the Vercel AI SDK tool() pattern with Zod schemas for input validation and structured error handling.
+The tools module implements AI agent tools in BashKit. These tools bridge AI models with sandbox execution environments, enabling agents to perform file operations, run commands, search code, fetch web content, manage workflows, update progress, and interact with users. Each tool follows the Vercel AI SDK tool() pattern with Zod schemas for input validation and structured error handling.
Most tools are a single file. **Tools with non-trivial internals live in their own folder with their own `AGENTS.md`** (e.g. `patch/`). When a single-file tool grows past ~3 files of supporting modules, promote it to a folder.
@@ -21,8 +21,9 @@ Most tools are a single file. **Tools with non-trivial internals live in their o
| `exit-plan-mode.ts` | Exit planning mode and submit plan for approval |
| `skill.ts` | Activate pre-loaded skills from SKILL.md files |
| `subagents/` | Controller-backed multi-agent control tools |
+| `update-plan.ts` | Codex-style canonical checklist/progress tool backed by runtime `PlanState` |
| `task.ts` | Spawn sub-agents for autonomous multi-step tasks |
-| `todo-write.ts` | Manage structured task lists with state tracking |
+| `todo-write.ts` | Legacy task-list compatibility tool |
| `web-search.ts` | Search the web via Parallel API with domain filtering |
| `web-fetch.ts` | Fetch and process web content with AI model |
| `index.ts` | Tool factory orchestration and caching layer |
@@ -44,8 +45,9 @@ Most tools are a single file. **Tools with non-trivial internals live in their o
- `createExitPlanModeTool(onPlanSubmit?)` -- Planning mode exit
- `createSkillTool(config)` -- Skill activation
- `createSubagentControlTools(controller, config?)` -- Create Spawn/List/Wait/Message/Interrupt subagent control tools
+- `createUpdatePlanTool(state, config?)` -- Codex-style checklist/progress updates backed by runtime `PlanState`
- `createTaskTool(config)` -- Sub-agent spawning
-- `createTodoWriteTool(state, onUpdate?)` -- Task list management
+- `createTodoWriteTool(state, onUpdate?)` -- Legacy task list management
- `createWebSearchTool(config)` -- Web search
- `createWebFetchTool(config)` -- Web content fetching
@@ -85,9 +87,12 @@ Each tool exports `Output` for success and `Error` for errors:
**Code Orchestration** (opt-in via config):
- codemode -- A Cloudflare Codemode tool that lets the model write code to orchestrate a filtered set of BashKit tools, codemode-only extra tools, and optional named providers. Client-intervention tools (`AskUser`, `EnterPlanMode`, `ExitPlanMode`), tools without `execute`, and tools with `needsApproval` are excluded from every inner tool set. Thrown codemode execution failures are converted to `{ error }` tool results like other BashKit tools.
-**Workflow Tools** (require Task tool config):
+**Progress Tools** (default):
+- UpdatePlan -- Canonical Codex-style checklist/progress tool. Updates runtime `PlanState` and emits `plan.updated` events when a runtime event sink is configured.
+
+**Workflow Tools** (require Task tool config or explicit factory use):
- Task -- Spawn sub-agents with custom system prompts and tool restrictions
-- TodoWrite -- Shared state management for task tracking
+- TodoWrite -- Legacy shared state management for task tracking
- Subagent control tools -- First-class controller adapters for SpawnAgent, ListAgents, WaitAgent, SendMessage, FollowupTask, and InterruptAgent
**Web Tools** (opt-in via config, require parallel-web):
@@ -96,7 +101,7 @@ Each tool exports `Output` for success and `Error` for errors:
### Data Flow
-1. **Tool Creation**: `createAgentTools()` → individual `create*Tool()` factories → `tool()` from AI SDK
+1. **Tool Creation**: `createAgentTools()` → individual `create*Tool()` factories → `tool()` from AI SDK. `UpdatePlan` is included by default with a canonical runtime `PlanState`.
2. **Execution**: AI model calls tool → `execute()` function or deferred client round-trip → sandbox operation or external API → return Output or Error
3. **Caching** (optional): `resolveCache()` wraps cacheable tools with `cached()` from cache module
4. **Model Registry** (optional): `createAgentTools()` fetches model info (pricing + context lengths) from a provider (e.g., OpenRouter). Data is shared with budget tracking and returned as `openRouterModels` in the result.
@@ -113,6 +118,7 @@ Each tool exports `Output` for success and `Error` for errors:
- `../sandbox/interface.ts` -- Sandbox abstraction for Bash/Read/Write/Edit/Glob/Grep
- `../types.ts` -- Config types and DEFAULT_CONFIG
- `../cache/` -- Caching layer for Read, Glob, Grep, WebFetch, WebSearch
+- `../runtime/` -- Runtime event sink and canonical plan state for UpdatePlan
- `../utils/debug.ts` -- Debug logging for all tools
- `../utils/budget-tracking.ts` -- Budget tracker creation and OpenRouter model/pricing fetch (used by index.ts and task.ts)
- `../skills/types.ts` -- Skill metadata for Skill tool
@@ -251,6 +257,7 @@ Located at `/tests/tools/`:
- `edit.test.ts` -- String replacement, uniqueness validation, replace_all
- `glob.test.ts` -- Pattern matching, path filtering
- `grep.test.ts` -- Content search, output modes, context lines, pagination
+- `update-plan.test.ts` -- Canonical plan updates and runtime event emission
- `todo-write.test.ts` -- Task state management
- `web-search.test.ts` -- Parallel API search (requires PARALLEL_API_KEY)
- `web-fetch.test.ts` -- Web content extraction (requires PARALLEL_API_KEY)
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 3ce59d1..5d0cd41 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -25,6 +25,8 @@ import { createGrepTool } from "./grep";
import { createPatchTool } from "./patch";
import { createReadTool } from "./read";
import { createSkillTool } from "./skill";
+import { createPlanState, type PlanState } from "../runtime";
+import { createUpdatePlanTool } from "./update-plan";
import { createWebFetchTool } from "./web-fetch";
import { createWebSearchTool } from "./web-search";
import { createWriteTool } from "./write";
@@ -129,6 +131,8 @@ export interface AgentToolsResult {
tools: ToolSet;
/** Shared plan mode state (only present when planMode is enabled) */
planModeState?: PlanModeState;
+ /** Canonical Codex-style task plan state, updated by the default UpdatePlan tool. */
+ planState: PlanState;
/** Budget tracker (only present when budget config is set) */
budget?: BudgetTracker;
/** Model info from OpenRouter (only present when modelRegistry or budget pricingProvider is configured) */
@@ -181,6 +185,11 @@ export async function createAgentTools(
...config?.tools,
};
+ const planModeState: PlanModeState | undefined = config?.planMode
+ ? { isActive: false }
+ : undefined;
+ const planState = createPlanState(config?.runtime?.initialPlan);
+
const tools: ToolSet = {
// Core sandbox tools (always included)
Bash: createBashTool(sandbox, toolsConfig.Bash),
@@ -189,10 +198,13 @@ export async function createAgentTools(
Edit: createEditTool(sandbox, toolsConfig.Edit),
Glob: createGlobTool(sandbox, toolsConfig.Glob),
Grep: createGrepTool(sandbox, toolsConfig.Grep),
+ UpdatePlan: createUpdatePlanTool(planState, {
+ eventSink: config?.runtime?.eventSink,
+ context: config?.runtime?.planContext,
+ planModeState,
+ }),
};
- let planModeState: PlanModeState | undefined;
-
// Add AskUser tool if configured
if (config?.askUser) {
tools.AskUser = createAskUserTool(
@@ -209,8 +221,7 @@ export async function createAgentTools(
}
// Add plan mode tools if configured
- if (config?.planMode) {
- planModeState = { isActive: false };
+ if (planModeState) {
tools.EnterPlanMode = createEnterPlanModeTool(planModeState);
tools.ExitPlanMode = createExitPlanModeTool();
}
@@ -383,7 +394,14 @@ export async function createAgentTools(
});
}
- return { tools, planModeState, budget, openRouterModels, contextLayers };
+ return {
+ tools,
+ planModeState,
+ planState,
+ budget,
+ openRouterModels,
+ contextLayers,
+ };
}
// --- Ask User Tool ---
@@ -479,6 +497,13 @@ export type {
TaskToolConfig,
} from "./task";
export { createTaskTool } from "./task";
+// --- UpdatePlan Tool ---
+export type {
+ UpdatePlanError,
+ UpdatePlanOutput,
+ UpdatePlanToolConfig,
+} from "./update-plan";
+export { createUpdatePlanTool } from "./update-plan";
// State/workflow tool types
export type {
TodoItem,
diff --git a/src/tools/update-plan.ts b/src/tools/update-plan.ts
new file mode 100644
index 0000000..f0156ee
--- /dev/null
+++ b/src/tools/update-plan.ts
@@ -0,0 +1,126 @@
+import { tool, zodSchema } from "ai";
+import { z } from "zod";
+import type {
+ PlanState,
+ PlanUpdateContext,
+ RuntimeEventSink,
+} from "../runtime";
+import { updatePlanState } from "../runtime";
+import type { SDKToolOptions } from "../types";
+import {
+ debugEnd,
+ debugError,
+ debugStart,
+ isDebugEnabled,
+} from "../utils/debug";
+import type { PlanModeState } from "./enter-plan-mode";
+
+const updatePlanInputSchema = z.object({
+ explanation: z
+ .string()
+ .nullable()
+ .default(null)
+ .describe("Optional explanation for this plan update."),
+ plan: z
+ .array(
+ z.object({
+ step: z.string().describe("Task step text."),
+ status: z
+ .enum(["pending", "in_progress", "completed"])
+ .describe("Step status."),
+ }),
+ )
+ .describe("The list of steps"),
+});
+
+type UpdatePlanInput = z.infer;
+
+export interface UpdatePlanOutput {
+ message: string;
+ stats: {
+ total: number;
+ pending: number;
+ in_progress: number;
+ completed: number;
+ };
+}
+
+export interface UpdatePlanError {
+ error: string;
+}
+
+export type UpdatePlanToolConfig = SDKToolOptions & {
+ eventSink?: RuntimeEventSink;
+ context?: PlanUpdateContext;
+ planModeState?: PlanModeState;
+};
+
+const UPDATE_PLAN_DESCRIPTION = `Updates the task plan.
+Provide an optional explanation and a list of plan items, each with a step and status.
+At most one step can be in_progress at a time.`;
+
+export function createUpdatePlanTool(
+ state: PlanState,
+ config?: UpdatePlanToolConfig,
+) {
+ const { eventSink, context, planModeState, ...toolOptions } = config ?? {};
+
+ return tool({
+ description: UPDATE_PLAN_DESCRIPTION,
+ inputSchema: zodSchema(updatePlanInputSchema),
+ ...toolOptions,
+ execute: async ({
+ explanation,
+ plan,
+ }: UpdatePlanInput): Promise => {
+ const startTime = performance.now();
+ const debugId = isDebugEnabled()
+ ? debugStart("update-plan", {
+ stepCount: plan.length,
+ pending: plan.filter((item) => item.status === "pending").length,
+ in_progress: plan.filter((item) => item.status === "in_progress")
+ .length,
+ completed: plan.filter((item) => item.status === "completed")
+ .length,
+ })
+ : "";
+
+ try {
+ if (planModeState?.isActive) {
+ return {
+ error:
+ "UpdatePlan is a checklist/progress tool and is not allowed in Plan mode",
+ };
+ }
+
+ const result = await updatePlanState(
+ state,
+ { explanation, plan },
+ {
+ eventSink,
+ context,
+ },
+ );
+ if ("error" in result) return result;
+
+ const durationMs = Math.round(performance.now() - startTime);
+ if (debugId) {
+ debugEnd(debugId, "update-plan", {
+ summary: { ...result.snapshot.stats },
+ duration_ms: durationMs,
+ });
+ }
+
+ return {
+ message: result.message,
+ stats: result.snapshot.stats,
+ };
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Unknown error";
+ if (debugId) debugError(debugId, "update-plan", errorMessage);
+ return { error: errorMessage };
+ }
+ },
+ });
+}
diff --git a/src/types.ts b/src/types.ts
index 0889f12..e68d5fe 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -3,6 +3,7 @@ import type { CacheStore } from "./cache/types";
import type { ContextLayer } from "./context/index";
import type { ExecutionPolicyConfig } from "./context/execution-policy";
import type { OutputPolicyConfig } from "./context/output-policy";
+import type { PlanState, PlanUpdateContext, RuntimeEventSink } from "./runtime";
import type { SkillMetadata } from "./skills/types";
import type { CodemodeConfig } from "./tools/codemode";
import type { ModelPricing } from "./utils/budget-tracking";
@@ -74,6 +75,15 @@ export type SkillConfig = {
) => void | Promise;
};
+export interface RuntimeConfig {
+ /** Receives normalized runtime events for host UIs, logs, persistence, or streaming. */
+ eventSink?: RuntimeEventSink;
+ /** Initial canonical UpdatePlan state. */
+ initialPlan?: Partial;
+ /** Default event identity for main-agent plan updates. */
+ planContext?: PlanUpdateContext;
+}
+
/**
* Cache configuration for tool result caching.
*
@@ -208,6 +218,8 @@ export type AgentConfig = {
webFetch?: WebFetchConfig;
/** Include a Cloudflare Codemode tool that can orchestrate selected tools */
codemode?: CodemodeConfig;
+ /** Host-facing runtime state and event stream configuration. */
+ runtime?: RuntimeConfig;
/** Enable tool result caching */
cache?: CacheConfig;
/** Fetch model info (pricing + context lengths) from a provider.
diff --git a/tests/runtime/events.test.ts b/tests/runtime/events.test.ts
new file mode 100644
index 0000000..5308070
--- /dev/null
+++ b/tests/runtime/events.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ createApprovalRequest,
+ createApprovalRequestedEvent,
+ createApprovalResolvedEvent,
+ createApprovalResult,
+ createMemoryRuntimeEventSink,
+ createRuntimeEvent,
+ projectChangesSnapshot,
+ projectProgressSnapshot,
+ resetApprovalIdCounterForTests,
+} from "@/runtime";
+
+describe("runtime events", () => {
+ it("records events and notifies subscribers", async () => {
+ const sink = createMemoryRuntimeEventSink();
+ const listener = vi.fn();
+ const unsubscribe = sink.subscribe(listener);
+
+ const event = createRuntimeEvent({
+ type: "thread.started",
+ thread_id: "thread_1",
+ });
+ await sink.emit(event);
+ unsubscribe();
+ await sink.emit(
+ createRuntimeEvent({ type: "turn.started", turn_id: "turn_1" }),
+ );
+
+ expect(sink.events.map((record) => record.type)).toEqual([
+ "thread.started",
+ "turn.started",
+ ]);
+ expect(listener).toHaveBeenCalledOnce();
+ expect(listener).toHaveBeenCalledWith(event);
+ });
+
+ it("creates approval lifecycle events", () => {
+ resetApprovalIdCounterForTests();
+ const approval = createApprovalRequest({
+ agentId: "agent_1",
+ subject: {
+ type: "command",
+ command: "bun run test",
+ cwd: "/repo",
+ },
+ reason: "Command requires approval",
+ });
+ const result = createApprovalResult({
+ approvalId: approval.approval_id,
+ decision: "rejected",
+ reason: "Too expensive right now",
+ });
+
+ expect(createApprovalRequestedEvent(approval)).toMatchObject({
+ type: "approval.requested",
+ agent_id: "agent_1",
+ });
+ expect(createApprovalResolvedEvent(approval, result)).toMatchObject({
+ type: "approval.rejected",
+ result: { decision: "rejected" },
+ });
+ });
+
+ it("projects progress and latest file changes from events", () => {
+ const events = [
+ createRuntimeEvent({
+ type: "agent.created",
+ agent_id: "agent_1",
+ parent_agent_id: null,
+ status: "pending",
+ task_name: "research",
+ profile: "default",
+ }),
+ createRuntimeEvent({
+ type: "file.changed",
+ path: "src/a.ts",
+ change: "created",
+ }),
+ createRuntimeEvent({
+ type: "file.changed",
+ path: "src/a.ts",
+ change: "modified",
+ unified_diff: "@@ diff",
+ }),
+ createRuntimeEvent({
+ type: "plan.updated",
+ explanation: null,
+ plan: [{ step: "Ship", status: "in_progress" }],
+ stats: {
+ total: 1,
+ pending: 0,
+ in_progress: 1,
+ completed: 0,
+ },
+ }),
+ ];
+
+ expect(projectChangesSnapshot(events)).toEqual([
+ {
+ path: "src/a.ts",
+ change: "modified",
+ unified_diff: "@@ diff",
+ updated_at: events[2].timestamp,
+ },
+ ]);
+ expect(projectProgressSnapshot(events).agents[0]).toMatchObject({
+ agent_id: "agent_1",
+ status: "pending",
+ });
+ expect(projectProgressSnapshot(events).plan?.plan[0].step).toBe("Ship");
+ });
+});
diff --git a/tests/runtime/plan.test.ts b/tests/runtime/plan.test.ts
new file mode 100644
index 0000000..67d4af7
--- /dev/null
+++ b/tests/runtime/plan.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import {
+ createMemoryRuntimeEventSink,
+ createPlanState,
+ getPlanStats,
+ projectPlanSnapshot,
+ updatePlanState,
+} from "@/runtime";
+
+describe("runtime plan state", () => {
+ it("updates canonical Codex-style plan state and emits plan.updated", async () => {
+ const state = createPlanState();
+ const sink = createMemoryRuntimeEventSink();
+
+ const result = await updatePlanState(
+ state,
+ {
+ explanation: "Starting implementation",
+ plan: [
+ { step: "Read plan", status: "completed" },
+ { step: "Write code", status: "in_progress" },
+ ],
+ },
+ {
+ eventSink: sink,
+ context: { agent_id: "agent_1", turn_id: "turn_1" },
+ },
+ );
+
+ if ("error" in result) throw new Error(result.error);
+ expect(result.snapshot.stats).toEqual({
+ total: 2,
+ pending: 0,
+ in_progress: 1,
+ completed: 1,
+ });
+ expect(state.plan[1].step).toBe("Write code");
+ expect(sink.events).toHaveLength(1);
+ expect(sink.events[0]).toMatchObject({
+ type: "plan.updated",
+ agent_id: "agent_1",
+ turn_id: "turn_1",
+ });
+ expect(projectPlanSnapshot(sink.events)?.plan).toEqual(
+ result.snapshot.plan,
+ );
+ });
+
+ it("rejects multiple in-progress items", async () => {
+ const result = await updatePlanState(createPlanState(), {
+ plan: [
+ { step: "One", status: "in_progress" },
+ { step: "Two", status: "in_progress" },
+ ],
+ });
+
+ expect(result).toEqual({
+ error: "At most one plan item can be in_progress",
+ });
+ });
+
+ it("computes plan stats", () => {
+ expect(
+ getPlanStats([
+ { step: "One", status: "pending" },
+ { step: "Two", status: "completed" },
+ { step: "Three", status: "completed" },
+ ]),
+ ).toEqual({
+ total: 3,
+ pending: 1,
+ in_progress: 0,
+ completed: 2,
+ });
+ });
+});
diff --git a/tests/subagents/events.test.ts b/tests/subagents/events.test.ts
new file mode 100644
index 0000000..5eb4eed
--- /dev/null
+++ b/tests/subagents/events.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { createMemoryRuntimeEventSink } from "@/runtime";
+import {
+ createStaticSubagentRunner,
+ createSubagentController,
+ subagentEventToRuntimeEvent,
+ type SubagentEvent,
+} from "@/subagents";
+
+describe("subagent runtime event bridge", () => {
+ it("maps subagent lifecycle events to runtime agent events", () => {
+ const event: SubagentEvent = {
+ type: "subagent.created",
+ agent_id: "agent_1",
+ task_name: "research",
+ parent_id: null,
+ profile: "default",
+ status: "pending",
+ timestamp: "2026-06-15T00:00:00.000Z",
+ };
+
+ expect(subagentEventToRuntimeEvent(event)).toMatchObject({
+ type: "agent.created",
+ agent_id: "agent_1",
+ task_name: "research",
+ status: "pending",
+ });
+ });
+
+ it("emits normalized runtime events from the controller", async () => {
+ const runtimeSink = createMemoryRuntimeEventSink();
+ const controller = createSubagentController({
+ runner: createStaticSubagentRunner({
+ status: "completed",
+ result: "done",
+ }),
+ runtimeEventSink: runtimeSink,
+ });
+
+ const spawned = await controller.spawn({
+ task: "Research auth",
+ task_name: "research/auth",
+ });
+ if ("error" in spawned) throw new Error(spawned.error);
+
+ await controller.wait({ agent: spawned.agent_id, timeoutMs: 500 });
+
+ expect(runtimeSink.events.map((event) => event.type)).toContain(
+ "agent.created",
+ );
+ expect(runtimeSink.events.map((event) => event.type)).toContain(
+ "agent.completed",
+ );
+ });
+});
diff --git a/tests/tools/index.test.ts b/tests/tools/index.test.ts
index 20f3ebb..33fc85c 100644
--- a/tests/tools/index.test.ts
+++ b/tests/tools/index.test.ts
@@ -37,6 +37,7 @@ describe("createAgentTools", () => {
expect(tools.Edit).toBeDefined();
expect(tools.Glob).toBeDefined();
expect(tools.Grep).toBeDefined();
+ expect(tools.UpdatePlan).toBeDefined();
});
it("should not include optional tools by default", async () => {
@@ -56,6 +57,7 @@ describe("createAgentTools", () => {
const result = await createAgentTools(sandbox);
expect(result.planModeState).toBeUndefined();
+ expect(result.planState).toBeDefined();
});
});
diff --git a/tests/tools/update-plan.test.ts b/tests/tools/update-plan.test.ts
new file mode 100644
index 0000000..bf39793
--- /dev/null
+++ b/tests/tools/update-plan.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import {
+ createMemoryRuntimeEventSink,
+ createPlanState,
+ type PlanState,
+} from "@/runtime";
+import {
+ createUpdatePlanTool,
+ type UpdatePlanOutput,
+} from "@/tools/update-plan";
+import { assertError, assertSuccess, executeTool } from "@test/helpers";
+
+describe("UpdatePlan Tool", () => {
+ it("updates PlanState and emits a runtime plan.updated event", async () => {
+ const state = createPlanState();
+ const sink = createMemoryRuntimeEventSink();
+ const tool = createUpdatePlanTool(state, {
+ eventSink: sink,
+ context: { thread_id: "thread_1" },
+ });
+
+ const result = await executeTool(tool, {
+ explanation: "Making progress",
+ plan: [
+ { step: "Design", status: "completed" },
+ { step: "Implement", status: "in_progress" },
+ ],
+ });
+
+ assertSuccess(result);
+ expect(result).toEqual({
+ message: "Plan updated",
+ stats: {
+ total: 2,
+ pending: 0,
+ in_progress: 1,
+ completed: 1,
+ },
+ });
+ expect(state.explanation).toBe("Making progress");
+ expect(sink.events[0]).toMatchObject({
+ type: "plan.updated",
+ thread_id: "thread_1",
+ });
+ });
+
+ it("returns an error when more than one item is in progress", async () => {
+ const tool = createUpdatePlanTool(createPlanState());
+ const result = await executeTool(tool, {
+ explanation: null,
+ plan: [
+ { step: "One", status: "in_progress" },
+ { step: "Two", status: "in_progress" },
+ ],
+ });
+
+ assertError(result);
+ expect(result.error).toBe("At most one plan item can be in_progress");
+ });
+
+ it("is blocked while plan mode is active", async () => {
+ const state: PlanState = createPlanState();
+ const tool = createUpdatePlanTool(state, {
+ planModeState: { isActive: true },
+ });
+
+ const result = await executeTool(tool, {
+ explanation: null,
+ plan: [{ step: "Plan", status: "pending" }],
+ });
+
+ assertError(result);
+ expect(result.error).toContain("not allowed in Plan mode");
+ expect(state.plan).toEqual([]);
+ });
+});
From b9055a84bcd7cbaa488b63834905687566ffe44c Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 20:06:55 -0400
Subject: [PATCH 06/20] Add AI SDK subagent runner
---
src/context/AGENTS.md | 4 +
src/context/index.ts | 2 +
src/context/runtime-events.ts | 130 ++++++++++
src/index.ts | 20 ++
src/subagents/AGENTS.md | 14 ++
src/subagents/ai-sdk-runner.ts | 253 ++++++++++++++++++++
src/subagents/context-inheritance.ts | 50 ++++
src/subagents/index.ts | 24 ++
src/subagents/runner.ts | 8 +
src/subagents/tool-surface.ts | 90 +++++++
src/subagents/transcripts.ts | 64 +++++
src/tools/index.ts | 24 +-
tests/context/runtime-events.test.ts | 93 +++++++
tests/subagents/ai-sdk-runner.test.ts | 176 ++++++++++++++
tests/subagents/context-inheritance.test.ts | 45 ++++
tests/subagents/tool-surface.test.ts | 97 ++++++++
16 files changed, 1088 insertions(+), 6 deletions(-)
create mode 100644 src/context/runtime-events.ts
create mode 100644 src/subagents/ai-sdk-runner.ts
create mode 100644 src/subagents/context-inheritance.ts
create mode 100644 src/subagents/tool-surface.ts
create mode 100644 src/subagents/transcripts.ts
create mode 100644 tests/context/runtime-events.test.ts
create mode 100644 tests/subagents/ai-sdk-runner.test.ts
create mode 100644 tests/subagents/context-inheritance.test.ts
create mode 100644 tests/subagents/tool-surface.test.ts
diff --git a/src/context/AGENTS.md b/src/context/AGENTS.md
index 538d89e..9ca8e4b 100644
--- a/src/context/AGENTS.md
+++ b/src/context/AGENTS.md
@@ -13,6 +13,7 @@ Builds the agent's runtime context: static system prompt assembly (instructions,
| `tool-guidance.ts` | `buildToolGuidance()` — one-line hint list keyed by registered tool names |
| `execution-policy.ts` | `createExecutionPolicy()` — plan-mode + custom gate layer (`beforeExecute`) |
| `output-policy.ts` | `createOutputPolicy()` — truncation + redirection hints + optional disk stash (`afterExecute`) |
+| `runtime-events.ts` | `createRuntimeEventLayer()` — emits normalized `tool.started`, `tool.completed`, and `tool.failed` events |
| `prepare-step.ts` | `createPrepareStep()` — composes compaction + context-status + plan-mode hints for AI SDK `prepareStep` |
## Key Exports
@@ -23,6 +24,7 @@ Builds the agent's runtime context: static system prompt assembly (instructions,
- `buildSystemContext(sandbox, config?)` -- Returns `{ instructions, environment, toolGuidance, combined, meta }`; designed to be called **once at init** for prompt-cache stability
- `createExecutionPolicy(planModeState, config?)` -- Blocks `["Bash", "Write", "Edit"]` by default when plan mode is active
- `createOutputPolicy(config?)` -- Defaults: `maxOutputLength: 30000`, `redirectionThreshold: 20000`, uses `middleTruncate`
+- `createRuntimeEventLayer(config)` -- Emits host-facing runtime tool lifecycle events through a `RuntimeEventSink`
- `createPrepareStep(config)` -- Returns a `PrepareStepFunction`; **never touches `system`** (prompt cache)
- `discoverInstructions`, `collectEnvironment`, `formatEnvironment`, `buildToolGuidance` -- individual section builders
@@ -66,6 +68,7 @@ prepare-step.ts ──→ ../utils/compact-conversation + ../utils/context-s
**Depends on**:
- `../sandbox/interface` — `Sandbox` type for `discoverInstructions`/`collectEnvironment`/`stashOutput`
- `../tools/enter-plan-mode` — `PlanModeState` type (execution-policy + prepare-step)
+- `../runtime` — runtime event sink and normalized event contracts (runtime-events)
- `../utils/helpers` — `middleTruncate` (output-policy)
- `../utils/compact-conversation`, `../utils/context-status` — prepare-step pipeline
- `ai` — `Tool`, `ToolSet`, `ModelMessage`, `PrepareStepFunction`, `PrepareStepResult`
@@ -117,6 +120,7 @@ Pass a `PrepareStepFunction` as `config.extend` to `createPrepareStep`. It runs
- `build-context.test.ts` (434 lines) — system prompt assembly, section enabling/disabling, combined output
- `execution-policy.test.ts` (145 lines) — plan-mode blocking, custom predicates
- `output-policy.test.ts` (520 lines) — truncation, hints, stash-to-disk, custom builders
+- `runtime-events.test.ts` — tool lifecycle event emission and createAgentTools auto-wiring
- `prepare-step.test.ts` (196 lines) — compaction, context-status injection, plan-mode hint, extend composition
- `with-context.test.ts` (346 lines) — layer wrapping, gate short-circuit, transform pipe, no-execute passthrough
- `parallel.test.ts` (175 lines) — layer isolation under concurrent tool calls
diff --git a/src/context/index.ts b/src/context/index.ts
index c8c954c..5dd98bd 100644
--- a/src/context/index.ts
+++ b/src/context/index.ts
@@ -33,6 +33,8 @@ export { buildSystemContext } from "./build-context";
export type { PrepareStepConfig } from "./prepare-step";
export { createPrepareStep } from "./prepare-step";
+export type { RuntimeEventLayerConfig } from "./runtime-events";
+export { createRuntimeEventLayer } from "./runtime-events";
/**
* Context layer that intercepts tool execution.
diff --git a/src/context/runtime-events.ts b/src/context/runtime-events.ts
new file mode 100644
index 0000000..ab9da2e
--- /dev/null
+++ b/src/context/runtime-events.ts
@@ -0,0 +1,130 @@
+import {
+ createRuntimeEvent,
+ type JsonObject,
+ type JsonValue,
+ type RuntimeEventSink,
+} from "../runtime";
+import type { ContextLayer } from "./index";
+
+export interface RuntimeEventLayerConfig {
+ eventSink: RuntimeEventSink;
+ agentId?: string | null;
+ threadId?: string | null;
+ turnId?: string | null;
+}
+
+interface ToolCallMeta {
+ tool_call_id: string;
+ started_at: number;
+}
+
+let toolCallCounter = 0;
+
+function createToolCallId(toolName: string): string {
+ toolCallCounter += 1;
+ return `${toolName.toLowerCase()}_${toolCallCounter.toString(36).padStart(4, "0")}`;
+}
+
+function jsonValueFromUnknown(value: unknown): JsonValue {
+ if (
+ value === null ||
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ ) {
+ return value;
+ }
+
+ if (Array.isArray(value)) {
+ return value.map((item) => jsonValueFromUnknown(item));
+ }
+
+ if (typeof value === "object") {
+ const objectValue: JsonObject = {};
+ for (const [key, item] of Object.entries(value)) {
+ objectValue[key] = jsonValueFromUnknown(item);
+ }
+ return objectValue;
+ }
+
+ return String(value);
+}
+
+function jsonObjectFromRecord(record: Record): JsonObject {
+ const objectValue: JsonObject = {};
+ for (const [key, value] of Object.entries(record)) {
+ objectValue[key] = jsonValueFromUnknown(value);
+ }
+ return objectValue;
+}
+
+export function createRuntimeEventLayer(
+ config: RuntimeEventLayerConfig,
+): ContextLayer {
+ const calls = new WeakMap, ToolCallMeta>();
+
+ return {
+ beforeExecute: async (toolName, params) => {
+ const meta = {
+ tool_call_id: createToolCallId(toolName),
+ started_at: performance.now(),
+ };
+ calls.set(params, meta);
+
+ await config.eventSink.emit(
+ createRuntimeEvent({
+ type: "tool.started",
+ tool_call_id: meta.tool_call_id,
+ tool_name: toolName,
+ input: jsonObjectFromRecord(params),
+ agent_id: config.agentId ?? null,
+ thread_id: config.threadId ?? null,
+ turn_id: config.turnId ?? null,
+ }),
+ );
+
+ return undefined;
+ },
+
+ afterExecute: async (toolName, params, result) => {
+ const meta =
+ calls.get(params) ??
+ ({
+ tool_call_id: createToolCallId(toolName),
+ started_at: performance.now(),
+ } satisfies ToolCallMeta);
+ const durationMs = Math.round(performance.now() - meta.started_at);
+
+ if (typeof result.error === "string") {
+ await config.eventSink.emit(
+ createRuntimeEvent({
+ type: "tool.failed",
+ tool_call_id: meta.tool_call_id,
+ tool_name: toolName,
+ error: result.error,
+ duration_ms: durationMs,
+ agent_id: config.agentId ?? null,
+ thread_id: config.threadId ?? null,
+ turn_id: config.turnId ?? null,
+ }),
+ );
+ } else {
+ await config.eventSink.emit(
+ createRuntimeEvent({
+ type: "tool.completed",
+ tool_call_id: meta.tool_call_id,
+ tool_name: toolName,
+ output: jsonValueFromUnknown(result),
+ duration_ms: durationMs,
+ agent_id: config.agentId ?? null,
+ thread_id: config.threadId ?? null,
+ turn_id: config.turnId ?? null,
+ }),
+ );
+ }
+
+ calls.delete(params);
+ return result;
+ },
+ };
+}
diff --git a/src/index.ts b/src/index.ts
index d056eee..f577cf4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -33,12 +33,17 @@ export {
// Subagent foundation
export type {
+ AiSdkSubagentGenerateOptions,
+ AiSdkSubagentGenerateResult,
+ AiSdkSubagentGenerateText,
+ AiSdkSubagentRunnerConfig,
JsonObject,
JsonPrimitive,
JsonValue,
ResolvedSubagentProfile,
ResolvedSubagentRunRequest,
SubagentCodemodePolicy,
+ SubagentCodemodeSurface,
SubagentContextPolicy,
SubagentContextPolicyInput,
SubagentController,
@@ -77,6 +82,9 @@ export type {
SubagentSpawnResult,
SubagentStatus,
SubagentStore,
+ SubagentToolSurface,
+ SubagentToolSurfaceConfig,
+ SubagentTranscriptSummary,
SubagentUsage,
SubagentWaitRequest,
SubagentWaitResult,
@@ -86,12 +94,18 @@ export {
DEFAULT_SUBAGENT_CONTEXT_POLICY,
DEFAULT_SUBAGENT_COST_POLICY,
DEFAULT_SUBAGENT_PROFILE_NAME,
+ buildSubagentMessages,
checkSubagentCostPolicy,
checkSubagentExecutionLimits,
clampSubagentWaitTimeout,
+ compactSubagentResult,
+ createAiSdkSubagentRunner,
createInMemorySubagentStore,
createMailboxMessage,
createMemorySubagentEventSink,
+ createSubagentResultRef,
+ createSubagentToolSurface,
+ createSubagentTranscriptRef,
createStaticSubagentRunner,
createSubagentController,
createSubagentId,
@@ -100,13 +114,17 @@ export {
describeSubagentProfile,
emitSubagentEvent,
filterSubagentTools,
+ inheritSubagentMessages,
isActiveSubagentStatus,
isTerminalSubagentStatus,
+ jsonObjectFromUnknown,
+ jsonValueFromUnknown,
normalizeSubagentPath,
resetSubagentIdCounterForTests,
resolveSubagentContextPolicy,
resolveSubagentCostPolicy,
resolveSubagentPath,
+ summarizeSubagentTranscript,
} from "./subagents";
// Runtime events, plans, approvals, and host-facing snapshots
@@ -319,12 +337,14 @@ export type {
SystemContextConfig,
SystemContext,
PrepareStepConfig,
+ RuntimeEventLayerConfig,
} from "./context";
export {
withContext,
applyContextLayers,
createExecutionPolicy,
createOutputPolicy,
+ createRuntimeEventLayer,
discoverInstructions,
collectEnvironment,
formatEnvironment,
diff --git a/src/subagents/AGENTS.md b/src/subagents/AGENTS.md
index 2ddf126..d31446d 100644
--- a/src/subagents/AGENTS.md
+++ b/src/subagents/AGENTS.md
@@ -13,6 +13,10 @@ Foundation for controller-managed subagents. This module owns identity, path res
| `profiles.ts` | Profile registry factory and profile resolution |
| `profile-descriptions.ts` | Model-visible profile description generation |
| `tool-filter.ts` | Tool allowlist/denylist filtering |
+| `context-inheritance.ts` | Parent-to-child message inheritance for `none`, `all`, and bounded recent-turn policies |
+| `tool-surface.ts` | Profile-scoped child tool surface construction with Codemode quarantine |
+| `transcripts.ts` | Compact terminal result and transcript reference helpers |
+| `ai-sdk-runner.ts` | Default in-process AI SDK runner for one-shot child agent execution |
| `registry.ts` | In-memory identity and path registry factory |
| `store.ts` | Store factory for in-memory metadata, events, and mailbox records |
| `events.ts` | Subagent event sink factory, helpers, and runtime event bridge |
@@ -29,6 +33,8 @@ Foundation for controller-managed subagents. This module owns identity, path res
The default foundation does not put subagent methods on `Sandbox`. Child agents use sandbox-backed tools through their runner/tool surface.
+`createAiSdkSubagentRunner` is the default in-process runner. It builds child messages from the profile context policy, constructs a profile-scoped tool surface, calls AI SDK `generateText`, reports usage/tool events through runner callbacks, and returns compact terminal results with result/transcript references. It does not maintain durable paused JavaScript execution or follow-up turns.
+
## Design Rules
- Keep tool schemas out of this module. Model-facing tools adapt to these core contracts.
@@ -62,3 +68,11 @@ The default foundation does not put subagent methods on `Sandbox`. Child agents
2. Implement the check in `execution-limits.ts` or `cost-control.ts`.
3. Call it from `controller.ts` before expensive runner work starts.
4. Test rejection and reservation cleanup.
+
+### Change child execution behavior
+
+1. Update `ai-sdk-runner.ts` only for model execution and callback routing.
+2. Update `tool-surface.ts` only for profile-scoped tool/Codemode exposure.
+3. Update `context-inheritance.ts` only for parent message inheritance policy.
+4. Keep full transcripts out of `SubagentRunResult`; return references or compact summaries instead.
+5. Add focused tests in `tests/subagents/`.
diff --git a/src/subagents/ai-sdk-runner.ts b/src/subagents/ai-sdk-runner.ts
new file mode 100644
index 0000000..45324b5
--- /dev/null
+++ b/src/subagents/ai-sdk-runner.ts
@@ -0,0 +1,253 @@
+import {
+ generateText as aiGenerateText,
+ stepCountIs,
+ type LanguageModel,
+ type LanguageModelUsage,
+ type ModelMessage,
+ type PrepareStepFunction,
+ type StepResult,
+ type StopCondition,
+ type ToolSet,
+} from "ai";
+import type { CodemodeConfig } from "../tools/codemode";
+import { buildSubagentMessages } from "./context-inheritance";
+import { createSubagentToolSurface } from "./tool-surface";
+import {
+ compactSubagentResult,
+ jsonObjectFromUnknown,
+ summarizeSubagentTranscript,
+} from "./transcripts";
+import type {
+ ResolvedSubagentRunRequest,
+ SubagentError,
+ SubagentInterruptResult,
+ SubagentRunResult,
+ SubagentRunner,
+ SubagentUsage,
+} from "./types";
+
+export interface AiSdkSubagentRunnerConfig {
+ model?: LanguageModel;
+ codemode?: CodemodeConfig;
+ stopWhen?: StopCondition | StopCondition[];
+ prepareStep?: PrepareStepFunction;
+ generateText?: AiSdkSubagentGenerateText;
+ maxResultLength?: number;
+}
+
+export type AiSdkSubagentGenerateText = (
+ options: AiSdkSubagentGenerateOptions,
+) => Promise;
+
+export interface AiSdkSubagentGenerateOptions {
+ model: LanguageModel;
+ tools: ToolSet;
+ system?: string;
+ messages: ModelMessage[];
+ stopWhen?: StopCondition | StopCondition[];
+ prepareStep?: PrepareStepFunction;
+ abortSignal?: AbortSignal;
+ onStepFinish?: (step: StepResult) => void | Promise;
+}
+
+export type AiSdkSubagentGenerateResult = Pick<
+ {
+ text: string;
+ usage: LanguageModelUsage;
+ totalUsage: LanguageModelUsage;
+ steps: StepResult[];
+ },
+ "text" | "usage" | "totalUsage" | "steps"
+> & {
+ response?: {
+ messages?: ModelMessage[];
+ };
+};
+
+function usageFromLanguageModelUsage(
+ usage: LanguageModelUsage | undefined,
+): SubagentUsage {
+ return {
+ inputTokens: usage?.inputTokens,
+ outputTokens: usage?.outputTokens,
+ };
+}
+
+function getErrorMessage(error: unknown): string {
+ if (error instanceof Error) return error.message;
+ return String(error);
+}
+
+function isAbortError(error: unknown, signal: AbortSignal): boolean {
+ return (
+ signal.aborted ||
+ (error instanceof Error &&
+ (error.name === "AbortError" || error.message.includes("aborted")))
+ );
+}
+
+async function defaultGenerateText(
+ options: AiSdkSubagentGenerateOptions,
+): Promise {
+ return aiGenerateText({
+ ...options,
+ });
+}
+
+async function reportStep(
+ request: ResolvedSubagentRunRequest,
+ step: StepResult,
+): Promise {
+ const usage = usageFromLanguageModelUsage(step.usage);
+ await request.callbacks.onUsage(usage);
+
+ for (const toolCall of step.toolCalls) {
+ await request.callbacks.onEvent({
+ type: "subagent.tool_call",
+ agent_id: request.handle.agent_id,
+ task_name: request.handle.task_name,
+ parent_id: request.parent_id,
+ profile: request.profile.name,
+ status: "running",
+ payload: {
+ tool_name: toolCall.toolName,
+ tool_call_id: toolCall.toolCallId,
+ input: jsonObjectFromUnknown(toolCall.input),
+ },
+ });
+ }
+
+ for (const toolResult of step.toolResults) {
+ await request.callbacks.onEvent({
+ type: "subagent.tool_result",
+ agent_id: request.handle.agent_id,
+ task_name: request.handle.task_name,
+ parent_id: request.parent_id,
+ profile: request.profile.name,
+ status: "running",
+ payload: {
+ tool_name: toolResult.toolName,
+ tool_call_id: toolResult.toolCallId,
+ output: jsonObjectFromUnknown(toolResult.output),
+ },
+ });
+ }
+}
+
+export function createAiSdkSubagentRunner(
+ config: AiSdkSubagentRunnerConfig = {},
+): SubagentRunner {
+ const activeRuns = new Map();
+ const generate = config.generateText ?? defaultGenerateText;
+
+ return {
+ capabilities: {
+ interrupt: true,
+ followup: false,
+ },
+
+ async run(request: ResolvedSubagentRunRequest): Promise {
+ const model = request.profile.model ?? config.model;
+ if (!model) {
+ return {
+ agent_id: request.handle.agent_id,
+ task_name: request.handle.task_name,
+ status: "failed",
+ error: "Subagent profile does not define a model",
+ };
+ }
+
+ const abortController = new AbortController();
+ const abortFromRequest = (): void => abortController.abort();
+ if (request.signal?.aborted) abortController.abort();
+ request.signal?.addEventListener("abort", abortFromRequest, {
+ once: true,
+ });
+ activeRuns.set(request.handle.agent_id, abortController);
+
+ try {
+ await request.callbacks.onStatus("running");
+ const toolSurface = await createSubagentToolSurface({
+ tools: request.tools,
+ profile: request.profile,
+ config: { codemode: config.codemode },
+ });
+ const messages = buildSubagentMessages({
+ parentMessages: request.messages,
+ policy: request.profile.context,
+ task: request.task,
+ });
+
+ const result = await generate({
+ model,
+ tools: toolSurface.tools,
+ system: request.profile.system || undefined,
+ messages,
+ stopWhen: config.stopWhen ?? stepCountIs(15),
+ prepareStep: config.prepareStep,
+ abortSignal: abortController.signal,
+ onStepFinish: async (step) => {
+ await reportStep(request, step);
+ },
+ });
+ const usage = usageFromLanguageModelUsage(
+ result.totalUsage ?? result.usage,
+ );
+ await request.callbacks.onUsage(usage);
+
+ const transcript = summarizeSubagentTranscript(
+ request.handle,
+ result.response?.messages,
+ );
+
+ return {
+ agent_id: request.handle.agent_id,
+ task_name: request.handle.task_name,
+ status: "completed",
+ result: compactSubagentResult(
+ result.text,
+ config.maxResultLength ?? 4000,
+ ),
+ usage,
+ result_ref: transcript.result_ref,
+ transcript_ref: transcript.transcript_ref,
+ metadata: {
+ message_count: transcript.message_count,
+ step_count: result.steps.length,
+ },
+ };
+ } catch (error) {
+ if (isAbortError(error, abortController.signal)) {
+ return {
+ agent_id: request.handle.agent_id,
+ task_name: request.handle.task_name,
+ status: "interrupted",
+ error: "Subagent interrupted",
+ };
+ }
+
+ return {
+ agent_id: request.handle.agent_id,
+ task_name: request.handle.task_name,
+ status: "failed",
+ error: getErrorMessage(error),
+ };
+ } finally {
+ request.signal?.removeEventListener("abort", abortFromRequest);
+ activeRuns.delete(request.handle.agent_id);
+ }
+ },
+
+ async interrupt(handle): Promise {
+ const active = activeRuns.get(handle.agent_id);
+ if (!active) return { error: "Subagent is not actively running" };
+
+ active.abort();
+ return {
+ agent_id: handle.agent_id,
+ previous_status: "running",
+ status: "interrupted",
+ };
+ },
+ };
+}
diff --git a/src/subagents/context-inheritance.ts b/src/subagents/context-inheritance.ts
new file mode 100644
index 0000000..9033b98
--- /dev/null
+++ b/src/subagents/context-inheritance.ts
@@ -0,0 +1,50 @@
+import type { ModelMessage } from "ai";
+import type { SubagentContextPolicy } from "./types";
+
+export interface BuildSubagentMessagesOptions {
+ parentMessages?: ModelMessage[];
+ policy: SubagentContextPolicy;
+ task: string;
+}
+
+function recentTurnStartIndex(
+ messages: readonly ModelMessage[],
+ turns: number,
+): number {
+ if (turns <= 0) return messages.length;
+
+ const userIndexes = messages
+ .map((message, index) => ({ role: message.role, index }))
+ .filter((entry) => entry.role === "user")
+ .map((entry) => entry.index);
+
+ if (userIndexes.length <= turns) return 0;
+ return userIndexes[userIndexes.length - turns];
+}
+
+export function inheritSubagentMessages(
+ parentMessages: readonly ModelMessage[] | undefined,
+ policy: SubagentContextPolicy,
+): ModelMessage[] {
+ const messages = parentMessages ?? [];
+
+ if (policy.mode === "none") return [];
+ if (policy.mode === "all") return messages.map((message) => ({ ...message }));
+
+ const startIndex = recentTurnStartIndex(messages, policy.turns);
+ return messages.slice(startIndex).map((message) => ({ ...message }));
+}
+
+export function buildSubagentMessages({
+ parentMessages,
+ policy,
+ task,
+}: BuildSubagentMessagesOptions): ModelMessage[] {
+ return [
+ ...inheritSubagentMessages(parentMessages, policy),
+ {
+ role: "user",
+ content: task,
+ },
+ ];
+}
diff --git a/src/subagents/index.ts b/src/subagents/index.ts
index a7476cd..59d7044 100644
--- a/src/subagents/index.ts
+++ b/src/subagents/index.ts
@@ -14,6 +14,25 @@ export {
} from "./profiles";
export { describeSubagentProfile } from "./profile-descriptions";
export { filterSubagentTools } from "./tool-filter";
+export {
+ buildSubagentMessages,
+ inheritSubagentMessages,
+} from "./context-inheritance";
+export {
+ createSubagentToolSurface,
+ type SubagentCodemodeSurface,
+ type SubagentToolSurface,
+ type SubagentToolSurfaceConfig,
+} from "./tool-surface";
+export {
+ compactSubagentResult,
+ createSubagentResultRef,
+ createSubagentTranscriptRef,
+ jsonObjectFromUnknown,
+ jsonValueFromUnknown,
+ summarizeSubagentTranscript,
+ type SubagentTranscriptSummary,
+} from "./transcripts";
export { createSubagentRegistry } from "./registry";
export { createInMemorySubagentStore } from "./store";
export {
@@ -24,7 +43,12 @@ export {
export { createMailboxMessage } from "./mailbox";
export {
DEFAULT_SUBAGENT_RUNNER_CAPABILITIES,
+ createAiSdkSubagentRunner,
createStaticSubagentRunner,
+ type AiSdkSubagentGenerateOptions,
+ type AiSdkSubagentGenerateResult,
+ type AiSdkSubagentGenerateText,
+ type AiSdkSubagentRunnerConfig,
} from "./runner";
export {
clampSubagentWaitTimeout,
diff --git a/src/subagents/runner.ts b/src/subagents/runner.ts
index 9f73e84..8159b84 100644
--- a/src/subagents/runner.ts
+++ b/src/subagents/runner.ts
@@ -25,3 +25,11 @@ export function createStaticSubagentRunner(
},
};
}
+
+export {
+ createAiSdkSubagentRunner,
+ type AiSdkSubagentGenerateOptions,
+ type AiSdkSubagentGenerateResult,
+ type AiSdkSubagentGenerateText,
+ type AiSdkSubagentRunnerConfig,
+} from "./ai-sdk-runner";
diff --git a/src/subagents/tool-surface.ts b/src/subagents/tool-surface.ts
new file mode 100644
index 0000000..8913813
--- /dev/null
+++ b/src/subagents/tool-surface.ts
@@ -0,0 +1,90 @@
+import type { ToolSet } from "ai";
+import {
+ createCodemodeTool,
+ type CodemodeConfig,
+ type CodemodeToolProvider,
+} from "../tools/codemode";
+import { filterSubagentTools } from "./tool-filter";
+import type { ResolvedSubagentProfile } from "./types";
+
+export interface SubagentToolSurfaceConfig {
+ codemode?: CodemodeConfig;
+}
+
+export interface SubagentCodemodeSurface {
+ name: string;
+ innerTools: ToolSet;
+ providers: CodemodeToolProvider[];
+}
+
+export interface SubagentToolSurface {
+ tools: ToolSet;
+ directTools: ToolSet;
+ codemode: SubagentCodemodeSurface | null;
+}
+
+function intersectDefined(
+ left: readonly string[] | undefined,
+ right: readonly string[] | undefined,
+): string[] | undefined {
+ if (!left && !right) return undefined;
+ if (!left) return [...(right ?? [])];
+ if (!right) return [...left];
+
+ const rightSet = new Set(right);
+ return left.filter((item) => rightSet.has(item));
+}
+
+function mergeExclusions(
+ left: readonly string[] | undefined,
+ right: readonly string[] | undefined,
+): string[] | undefined {
+ const merged = [...(left ?? []), ...(right ?? [])];
+ return merged.length > 0 ? [...new Set(merged)] : undefined;
+}
+
+export async function createSubagentToolSurface(options: {
+ tools: ToolSet;
+ profile: ResolvedSubagentProfile;
+ config?: SubagentToolSurfaceConfig;
+}): Promise {
+ const filteredTools = filterSubagentTools(options.tools, {
+ allowedTools: options.profile.allowedTools,
+ deniedTools: options.profile.deniedTools,
+ });
+ const directTools = options.profile.codemode.exposeDirectTools
+ ? filteredTools
+ : {};
+ const surfaceTools: ToolSet = { ...directTools };
+
+ if (options.profile.codemode.enabled && options.config?.codemode) {
+ const codemodeConfig: CodemodeConfig = {
+ ...options.config.codemode,
+ includeTools: intersectDefined(
+ options.config.codemode.includeTools,
+ options.profile.codemode.includeTools,
+ ),
+ excludeTools: mergeExclusions(
+ options.config.codemode.excludeTools,
+ options.profile.codemode.excludeTools,
+ ),
+ };
+ const codemode = await createCodemodeTool(filteredTools, codemodeConfig);
+ surfaceTools[codemode.name] = codemode.tool;
+ return {
+ tools: surfaceTools,
+ directTools,
+ codemode: {
+ name: codemode.name,
+ innerTools: codemode.innerTools,
+ providers: codemode.providers,
+ },
+ };
+ }
+
+ return {
+ tools: surfaceTools,
+ directTools,
+ codemode: null,
+ };
+}
diff --git a/src/subagents/transcripts.ts b/src/subagents/transcripts.ts
new file mode 100644
index 0000000..205ce0e
--- /dev/null
+++ b/src/subagents/transcripts.ts
@@ -0,0 +1,64 @@
+import type { ModelMessage } from "ai";
+import type { JsonObject, JsonValue, SubagentHandle } from "./types";
+
+export interface SubagentTranscriptSummary {
+ transcript_ref: string;
+ result_ref: string;
+ message_count: number;
+}
+
+export function createSubagentTranscriptRef(handle: SubagentHandle): string {
+ return `subagent-transcript:${handle.agent_id}`;
+}
+
+export function createSubagentResultRef(handle: SubagentHandle): string {
+ return `subagent-result:${handle.agent_id}`;
+}
+
+export function summarizeSubagentTranscript(
+ handle: SubagentHandle,
+ messages: readonly ModelMessage[] | undefined,
+): SubagentTranscriptSummary {
+ return {
+ transcript_ref: createSubagentTranscriptRef(handle),
+ result_ref: createSubagentResultRef(handle),
+ message_count: messages?.length ?? 0,
+ };
+}
+
+export function compactSubagentResult(text: string, maxLength = 4000): string {
+ if (text.length <= maxLength) return text;
+ return `${text.slice(0, maxLength)}\n\n[Subagent result truncated: ${text.length - maxLength} characters omitted]`;
+}
+
+export function jsonValueFromUnknown(value: unknown): JsonValue {
+ if (
+ value === null ||
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ ) {
+ return value;
+ }
+
+ if (Array.isArray(value)) {
+ return value.map((item) => jsonValueFromUnknown(item));
+ }
+
+ if (typeof value === "object") {
+ const objectValue: JsonObject = {};
+ for (const [key, item] of Object.entries(value)) {
+ objectValue[key] = jsonValueFromUnknown(item);
+ }
+ return objectValue;
+ }
+
+ return String(value);
+}
+
+export function jsonObjectFromUnknown(value: unknown): JsonObject {
+ const json = jsonValueFromUnknown(value);
+ return typeof json === "object" && json !== null && !Array.isArray(json)
+ ? json
+ : { value: json };
+}
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 5d0cd41..263ac4e 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -4,6 +4,7 @@ import { cached, LRUCacheStore } from "../cache";
import { applyContextLayers, type ContextLayer } from "../context/index";
import { createExecutionPolicy } from "../context/execution-policy";
import { createOutputPolicy } from "../context/output-policy";
+import { createRuntimeEventLayer } from "../context/runtime-events";
import type { Sandbox } from "../sandbox/interface";
import type { AgentConfig, CacheConfig } from "../types";
import { DEFAULT_CONFIG } from "../types";
@@ -294,13 +295,24 @@ export async function createAgentTools(
if (config.context.layers) {
contextLayers.push(...config.context.layers);
}
+ }
- // Apply all layers to all tools
- if (contextLayers.length > 0) {
- const wrapped = applyContextLayers(tools, contextLayers);
- for (const [name, wrappedTool] of Object.entries(wrapped)) {
- (tools as Record)[name] = wrappedTool;
- }
+ if (config?.runtime?.eventSink) {
+ contextLayers.push(
+ createRuntimeEventLayer({
+ eventSink: config.runtime.eventSink,
+ agentId: config.runtime.planContext?.agent_id,
+ threadId: config.runtime.planContext?.thread_id,
+ turnId: config.runtime.planContext?.turn_id,
+ }),
+ );
+ }
+
+ // Apply all layers to all tools
+ if (contextLayers.length > 0) {
+ const wrapped = applyContextLayers(tools, contextLayers);
+ for (const [name, wrappedTool] of Object.entries(wrapped)) {
+ (tools as Record)[name] = wrappedTool;
}
}
diff --git a/tests/context/runtime-events.test.ts b/tests/context/runtime-events.test.ts
new file mode 100644
index 0000000..15e15b1
--- /dev/null
+++ b/tests/context/runtime-events.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+import { tool, zodSchema } from "ai";
+import { z } from "zod";
+import { applyContextLayers, createRuntimeEventLayer } from "@/context";
+import { createMemoryRuntimeEventSink } from "@/runtime";
+import { createAgentTools } from "@/tools";
+import { createMockSandbox, executeTool } from "@test/helpers";
+
+describe("createRuntimeEventLayer", () => {
+ it("emits tool started and completed events", async () => {
+ const sink = createMemoryRuntimeEventSink();
+ const tools = applyContextLayers(
+ {
+ Echo: tool({
+ inputSchema: zodSchema(z.object({ value: z.string() })),
+ execute: async ({ value }) => ({ value }),
+ }),
+ },
+ [
+ createRuntimeEventLayer({
+ eventSink: sink,
+ agentId: "agent_1",
+ turnId: "turn_1",
+ }),
+ ],
+ );
+
+ await executeTool(tools.Echo, { value: "hello" });
+
+ expect(sink.events.map((event) => event.type)).toEqual([
+ "tool.started",
+ "tool.completed",
+ ]);
+ expect(sink.events[0]).toMatchObject({
+ type: "tool.started",
+ tool_name: "Echo",
+ agent_id: "agent_1",
+ turn_id: "turn_1",
+ });
+ expect(sink.events[1]).toMatchObject({
+ type: "tool.completed",
+ tool_name: "Echo",
+ output: { value: "hello" },
+ });
+ });
+
+ it("emits failed events for model-visible error objects", async () => {
+ const sink = createMemoryRuntimeEventSink();
+ const tools = applyContextLayers(
+ {
+ Fails: tool({
+ inputSchema: zodSchema(z.object({})),
+ execute: async () => ({ error: "nope" }),
+ }),
+ },
+ [createRuntimeEventLayer({ eventSink: sink })],
+ );
+
+ await executeTool(tools.Fails, {});
+
+ expect(sink.events[1]).toMatchObject({
+ type: "tool.failed",
+ tool_name: "Fails",
+ error: "nope",
+ });
+ });
+
+ it("createAgentTools wires runtime events without requiring context config", async () => {
+ const sink = createMemoryRuntimeEventSink();
+ const sandbox = createMockSandbox({ files: { "/tmp/test.txt": "hello" } });
+ const { tools } = await createAgentTools(sandbox, {
+ runtime: {
+ eventSink: sink,
+ planContext: { thread_id: "thread_1" },
+ },
+ });
+
+ await executeTool(tools.Read, {
+ file_path: "/tmp/test.txt",
+ offset: null,
+ limit: null,
+ });
+
+ expect(sink.events.map((event) => event.type)).toEqual([
+ "tool.started",
+ "tool.completed",
+ ]);
+ expect(sink.events[0]).toMatchObject({
+ tool_name: "Read",
+ thread_id: "thread_1",
+ });
+ });
+});
diff --git a/tests/subagents/ai-sdk-runner.test.ts b/tests/subagents/ai-sdk-runner.test.ts
new file mode 100644
index 0000000..1a340e1
--- /dev/null
+++ b/tests/subagents/ai-sdk-runner.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it } from "vitest";
+import type { LanguageModel, ModelMessage, StepResult, ToolSet } from "ai";
+import {
+ createAiSdkSubagentRunner,
+ createSubagentProfileRegistry,
+ type AiSdkSubagentGenerateOptions,
+ type AiSdkSubagentGenerateResult,
+ type ResolvedSubagentRunRequest,
+ type SubagentEvent,
+ type SubagentUsage,
+} from "@/subagents";
+
+function model(): LanguageModel {
+ return { modelId: "test-model" } as LanguageModel;
+}
+
+function usage(inputTokens: number, outputTokens: number) {
+ return {
+ inputTokens,
+ outputTokens,
+ totalTokens: inputTokens + outputTokens,
+ inputTokenDetails: {
+ noCacheTokens: inputTokens,
+ cacheReadTokens: undefined,
+ cacheWriteTokens: undefined,
+ },
+ outputTokenDetails: {
+ textTokens: outputTokens,
+ reasoningTokens: undefined,
+ },
+ };
+}
+
+function fakeStep(): StepResult {
+ return {
+ toolCalls: [
+ {
+ type: "tool-call",
+ toolCallId: "call_1",
+ toolName: "Read",
+ input: { path: "src/index.ts" },
+ },
+ ],
+ toolResults: [
+ {
+ type: "tool-result",
+ toolCallId: "call_1",
+ toolName: "Read",
+ input: { path: "src/index.ts" },
+ output: { content: "ok" },
+ },
+ ],
+ usage: usage(10, 5),
+ } as unknown as StepResult;
+}
+
+function request(
+ overrides?: Partial,
+): ResolvedSubagentRunRequest {
+ const registry = createSubagentProfileRegistry({
+ defaults: { model: model(), context: { recent_turns: 1 } },
+ });
+ const profile = registry.resolve(undefined);
+ if ("error" in profile) throw new Error(profile.error);
+
+ return {
+ handle: { agent_id: "agent_1", task_name: "research" },
+ task: "child task",
+ profile,
+ parent_id: null,
+ depth: 0,
+ tools: {},
+ messages: [
+ { role: "user", content: "old" },
+ { role: "assistant", content: "old answer" },
+ { role: "user", content: "recent" },
+ ],
+ callbacks: {
+ onStatus: async () => undefined,
+ onEvent: async () => undefined,
+ onUsage: async () => undefined,
+ },
+ ...overrides,
+ };
+}
+
+describe("createAiSdkSubagentRunner", () => {
+ it("runs generateText with inherited messages and reports usage/events", async () => {
+ const seen: {
+ options?: AiSdkSubagentGenerateOptions;
+ events: SubagentEvent[];
+ usage: SubagentUsage[];
+ } = { events: [], usage: [] };
+ const runner = createAiSdkSubagentRunner({
+ generateText: async (options): Promise => {
+ seen.options = options;
+ await options.onStepFinish?.(fakeStep());
+ return {
+ text: "done",
+ usage: usage(1, 2),
+ totalUsage: usage(11, 7),
+ steps: [fakeStep()],
+ response: {
+ messages: [{ role: "assistant", content: "full transcript" }],
+ },
+ };
+ },
+ });
+
+ const result = await runner.run(
+ request({
+ callbacks: {
+ onStatus: async () => undefined,
+ onEvent: async (event) => {
+ seen.events.push({ ...event, timestamp: "now" });
+ },
+ onUsage: async (value) => {
+ seen.usage.push(value);
+ },
+ },
+ }),
+ );
+
+ expect(result).toMatchObject({
+ status: "completed",
+ result: "done",
+ usage: { inputTokens: 11, outputTokens: 7 },
+ transcript_ref: "subagent-transcript:agent_1",
+ result_ref: "subagent-result:agent_1",
+ metadata: { message_count: 1, step_count: 1 },
+ });
+ expect(seen.options?.messages).toEqual([
+ { role: "user", content: "recent" },
+ { role: "user", content: "child task" },
+ ]);
+ expect(typeof seen.options?.stopWhen).toBe("function");
+ expect(seen.usage).toContainEqual({ inputTokens: 10, outputTokens: 5 });
+ expect(seen.usage).toContainEqual({ inputTokens: 11, outputTokens: 7 });
+ expect(seen.events.map((event) => event.type)).toEqual([
+ "subagent.tool_call",
+ "subagent.tool_result",
+ ]);
+ });
+
+ it("returns failed when no model is available", async () => {
+ const registry = createSubagentProfileRegistry();
+ const profile = registry.resolve(undefined);
+ if ("error" in profile) throw new Error(profile.error);
+ const runner = createAiSdkSubagentRunner();
+
+ const result = await runner.run(request({ profile }));
+
+ expect(result).toEqual({
+ agent_id: "agent_1",
+ task_name: "research",
+ status: "failed",
+ error: "Subagent profile does not define a model",
+ });
+ });
+
+ it("returns interrupted for aborted generation", async () => {
+ const runner = createAiSdkSubagentRunner({
+ generateText: async (options): Promise => {
+ options.abortSignal?.dispatchEvent(new Event("abort"));
+ throw new DOMException("aborted", "AbortError");
+ },
+ });
+
+ const result = await runner.run(request());
+
+ expect(result).toMatchObject({
+ status: "interrupted",
+ error: "Subagent interrupted",
+ });
+ });
+});
diff --git a/tests/subagents/context-inheritance.test.ts b/tests/subagents/context-inheritance.test.ts
new file mode 100644
index 0000000..b5d7ec5
--- /dev/null
+++ b/tests/subagents/context-inheritance.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import type { ModelMessage } from "ai";
+import { buildSubagentMessages, inheritSubagentMessages } from "@/subagents";
+
+const parentMessages: ModelMessage[] = [
+ { role: "user", content: "first task" },
+ { role: "assistant", content: "first answer" },
+ { role: "user", content: "second task" },
+ { role: "assistant", content: "second answer" },
+ { role: "user", content: "third task" },
+];
+
+describe("subagent context inheritance", () => {
+ it("excludes parent history for none policy", () => {
+ expect(
+ buildSubagentMessages({
+ parentMessages,
+ policy: { mode: "none" },
+ task: "child task",
+ }),
+ ).toEqual([{ role: "user", content: "child task" }]);
+ });
+
+ it("includes all parent history for all policy", () => {
+ expect(
+ buildSubagentMessages({
+ parentMessages,
+ policy: { mode: "all" },
+ task: "child task",
+ }),
+ ).toEqual([...parentMessages, { role: "user", content: "child task" }]);
+ });
+
+ it("includes only recent user turns for recent policy", () => {
+ expect(
+ inheritSubagentMessages(parentMessages, { mode: "recent", turns: 2 }),
+ ).toEqual(parentMessages.slice(2));
+ });
+
+ it("supports zero recent turns", () => {
+ expect(
+ inheritSubagentMessages(parentMessages, { mode: "recent", turns: 0 }),
+ ).toEqual([]);
+ });
+});
diff --git a/tests/subagents/tool-surface.test.ts b/tests/subagents/tool-surface.test.ts
new file mode 100644
index 0000000..8e177e1
--- /dev/null
+++ b/tests/subagents/tool-surface.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from "vitest";
+import { tool, zodSchema, type Tool, type ToolSet } from "ai";
+import { z } from "zod";
+import {
+ createSubagentProfileRegistry,
+ createSubagentToolSurface,
+ type ResolvedSubagentProfile,
+ type SubagentProfileInput,
+} from "@/subagents";
+import type { CodemodeConfig, CodemodeToolProvider } from "@/tools/codemode";
+
+function fakeTool(name: string): Tool {
+ return tool({
+ description: name,
+ inputSchema: zodSchema(z.object({})),
+ execute: async () => ({ ok: true }),
+ });
+}
+
+function resolveProfile(
+ overrides: Omit,
+): ResolvedSubagentProfile {
+ const registry = createSubagentProfileRegistry({
+ profiles: [{ name: "test", ...overrides }],
+ });
+ const profile = registry.resolve("test");
+ if ("error" in profile) throw new Error(profile.error);
+ return profile;
+}
+
+function codemodeConfig(captured: {
+ providers: CodemodeToolProvider[];
+}): CodemodeConfig {
+ return {
+ executor: {
+ execute: async () => ({ result: null }),
+ },
+ createCodeTool: async ({ tools }) => {
+ if (!Array.isArray(tools)) throw new Error("expected providers");
+ captured.providers = tools;
+ return fakeTool("codemode");
+ },
+ };
+}
+
+describe("createSubagentToolSurface", () => {
+ it("exposes Codemode by default and hides direct tools", async () => {
+ const captured = { providers: [] as CodemodeToolProvider[] };
+ const surface = await createSubagentToolSurface({
+ tools: {
+ Read: fakeTool("Read"),
+ Write: fakeTool("Write"),
+ },
+ profile: resolveProfile({}),
+ config: { codemode: codemodeConfig(captured) },
+ });
+
+ expect(Object.keys(surface.tools)).toEqual(["codemode"]);
+ expect(surface.codemode?.name).toBe("codemode");
+ expect(Object.keys(surface.directTools)).toEqual([]);
+ expect(Object.keys(captured.providers[0].tools)).toEqual(["Read", "Write"]);
+ });
+
+ it("exposes direct tools only when profile opts in", async () => {
+ const surface = await createSubagentToolSurface({
+ tools: {
+ Read: fakeTool("Read"),
+ Grep: fakeTool("Grep"),
+ },
+ profile: resolveProfile({
+ codemode: { enabled: false, exposeDirectTools: true },
+ }),
+ });
+
+ expect(Object.keys(surface.tools).sort()).toEqual(["Grep", "Read"]);
+ expect(surface.codemode).toBeNull();
+ });
+
+ it("applies profile quarantine to Codemode providers", async () => {
+ const captured = { providers: [] as CodemodeToolProvider[] };
+ await createSubagentToolSurface({
+ tools: {
+ Read: fakeTool("Read"),
+ Bash: fakeTool("Bash"),
+ Write: fakeTool("Write"),
+ },
+ profile: resolveProfile({
+ allowedTools: ["Read", "Bash", "Write"],
+ deniedTools: ["Bash"],
+ codemode: { excludeTools: ["Write"] },
+ }),
+ config: { codemode: codemodeConfig(captured) },
+ });
+
+ expect(Object.keys(captured.providers[0].tools)).toEqual(["Read"]);
+ });
+});
From ee4202cb86976636b57a00e56454aacf4deddffa Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 20:12:07 -0400
Subject: [PATCH 07/20] Reject denied subagent tools by default
---
src/index.ts | 2 +
src/subagents/AGENTS.md | 5 ++-
src/subagents/controller.ts | 2 +
src/subagents/index.ts | 2 +
src/subagents/profile-descriptions.ts | 4 +-
src/subagents/profiles.ts | 9 ++++
src/subagents/tool-filter.ts | 43 +++++++++++++++++-
src/subagents/tool-surface.ts | 2 +
src/subagents/types.ts | 5 +++
tests/subagents/profiles.test.ts | 3 ++
tests/subagents/tool-filter.test.ts | 17 +++++++-
tests/subagents/tool-surface.test.ts | 63 +++++++++++++++++++++++++++
12 files changed, 152 insertions(+), 5 deletions(-)
diff --git a/src/index.ts b/src/index.ts
index f577cf4..0f3d9a4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -50,6 +50,7 @@ export type {
SubagentControllerConfig,
SubagentCostPolicy,
SubagentCostPolicyInput,
+ SubagentDeniedToolBehavior,
SubagentError,
SubagentEvent,
SubagentEventSink,
@@ -93,6 +94,7 @@ export {
DEFAULT_SUBAGENT_CODEMODE_POLICY,
DEFAULT_SUBAGENT_CONTEXT_POLICY,
DEFAULT_SUBAGENT_COST_POLICY,
+ DEFAULT_SUBAGENT_DENIED_TOOL_BEHAVIOR,
DEFAULT_SUBAGENT_PROFILE_NAME,
buildSubagentMessages,
checkSubagentCostPolicy,
diff --git a/src/subagents/AGENTS.md b/src/subagents/AGENTS.md
index d31446d..8365528 100644
--- a/src/subagents/AGENTS.md
+++ b/src/subagents/AGENTS.md
@@ -12,7 +12,7 @@ Foundation for controller-managed subagents. This module owns identity, path res
| `status.ts` | Status helpers and terminal-state checks |
| `profiles.ts` | Profile registry factory and profile resolution |
| `profile-descriptions.ts` | Model-visible profile description generation |
-| `tool-filter.ts` | Tool allowlist/denylist filtering |
+| `tool-filter.ts` | Tool allowlist filtering plus denied-tool reject/hide policy wrappers |
| `context-inheritance.ts` | Parent-to-child message inheritance for `none`, `all`, and bounded recent-turn policies |
| `tool-surface.ts` | Profile-scoped child tool surface construction with Codemode quarantine |
| `transcripts.ts` | Compact terminal result and transcript reference helpers |
@@ -35,6 +35,8 @@ The default foundation does not put subagent methods on `Sandbox`. Child agents
`createAiSdkSubagentRunner` is the default in-process runner. It builds child messages from the profile context policy, constructs a profile-scoped tool surface, calls AI SDK `generateText`, reports usage/tool events through runner callbacks, and returns compact terminal results with result/transcript references. It does not maintain durable paused JavaScript execution or follow-up turns.
+Profile tool policy is allowlist-first. `allowedTools` narrows the child surface. `deniedTools` defaults to execution-time rejection, so denied tools stay visible and return `{ error: string }` when called. Profiles can explicitly set `deniedBehavior: "hide"` when the denied tool names should be removed from direct and Codemode inner surfaces.
+
## Design Rules
- Keep tool schemas out of this module. Model-facing tools adapt to these core contracts.
@@ -44,6 +46,7 @@ The default foundation does not put subagent methods on `Sandbox`. Child agents
- Avoid `any`; use `unknown`, narrow types, and explicit interfaces.
- Prefer factory functions that return typed plain objects over classes.
- Prefer small pure helpers where possible so controller behavior is easy to test with fake runners.
+- Keep denied-tool failures model-visible as `{ error: string }` unless a profile explicitly uses hide behavior.
## Common Modifications
diff --git a/src/subagents/controller.ts b/src/subagents/controller.ts
index 142acfb..e1e0a9f 100644
--- a/src/subagents/controller.ts
+++ b/src/subagents/controller.ts
@@ -140,6 +140,8 @@ export function createSubagentController(
const childTools = filterSubagentTools(tools, {
allowedTools: profile.allowedTools,
deniedTools: profile.deniedTools,
+ deniedBehavior: profile.deniedBehavior,
+ profileName: profile.name,
});
try {
diff --git a/src/subagents/index.ts b/src/subagents/index.ts
index 59d7044..0c78778 100644
--- a/src/subagents/index.ts
+++ b/src/subagents/index.ts
@@ -7,6 +7,7 @@ export {
DEFAULT_SUBAGENT_CODEMODE_POLICY,
DEFAULT_SUBAGENT_CONTEXT_POLICY,
DEFAULT_SUBAGENT_COST_POLICY,
+ DEFAULT_SUBAGENT_DENIED_TOOL_BEHAVIOR,
DEFAULT_SUBAGENT_PROFILE_NAME,
createSubagentProfileRegistry,
resolveSubagentContextPolicy,
@@ -70,6 +71,7 @@ export type {
SubagentControllerConfig,
SubagentCostPolicy,
SubagentCostPolicyInput,
+ SubagentDeniedToolBehavior,
SubagentError,
SubagentEvent,
SubagentEventSink,
diff --git a/src/subagents/profile-descriptions.ts b/src/subagents/profile-descriptions.ts
index 8ff0e84..27b02e8 100644
--- a/src/subagents/profile-descriptions.ts
+++ b/src/subagents/profile-descriptions.ts
@@ -12,7 +12,9 @@ export function describeSubagentProfile(
parts.push(`allowed tools: ${profile.allowedTools.join(", ")}`);
}
if (profile.deniedTools.length > 0) {
- parts.push(`denied tools: ${profile.deniedTools.join(", ")}`);
+ parts.push(
+ `denied tools: ${profile.deniedTools.join(", ")} (${profile.deniedBehavior})`,
+ );
}
if (profile.cost.maxUsd != null) {
parts.push(`budget cap: $${profile.cost.maxUsd}`);
diff --git a/src/subagents/profiles.ts b/src/subagents/profiles.ts
index b0d522d..fbca1ad 100644
--- a/src/subagents/profiles.ts
+++ b/src/subagents/profiles.ts
@@ -5,6 +5,7 @@ import type {
SubagentContextPolicyInput,
SubagentCostPolicy,
SubagentCostPolicyInput,
+ SubagentDeniedToolBehavior,
SubagentError,
SubagentProfileDefaults,
SubagentProfileInput,
@@ -23,6 +24,9 @@ export const DEFAULT_SUBAGENT_CODEMODE_POLICY: SubagentCodemodePolicy = {
exposeDirectTools: false,
};
+export const DEFAULT_SUBAGENT_DENIED_TOOL_BEHAVIOR: SubagentDeniedToolBehavior =
+ "reject";
+
export const DEFAULT_SUBAGENT_COST_POLICY: SubagentCostPolicy = {
maxUsd: null,
maxActiveAgents: 4,
@@ -149,6 +153,11 @@ export function createSubagentProfileRegistry(options?: {
...(profile.deniedTools ?? []),
...(overrides?.deniedTools ?? []),
],
+ deniedBehavior:
+ overrides?.deniedBehavior ??
+ profile.deniedBehavior ??
+ defaults.deniedBehavior ??
+ DEFAULT_SUBAGENT_DENIED_TOOL_BEHAVIOR,
codemode,
context,
cost,
diff --git a/src/subagents/tool-filter.ts b/src/subagents/tool-filter.ts
index ccd09cb..167549e 100644
--- a/src/subagents/tool-filter.ts
+++ b/src/subagents/tool-filter.ts
@@ -1,8 +1,38 @@
-import type { ToolSet } from "ai";
+import type { Tool, ToolSet } from "ai";
+import type { SubagentDeniedToolBehavior } from "./types";
export interface SubagentToolFilter {
allowedTools?: readonly string[];
deniedTools?: readonly string[];
+ deniedBehavior?: SubagentDeniedToolBehavior;
+ profileName?: string;
+}
+
+export interface DeniedSubagentToolError {
+ error: string;
+}
+
+function createDeniedToolError(
+ toolName: string,
+ profileName: string | undefined,
+): DeniedSubagentToolError {
+ return {
+ error: `Tool ${toolName} is not allowed for subagent profile ${
+ profileName ?? "unknown"
+ }`,
+ };
+}
+
+export function createDeniedSubagentTool(
+ toolName: string,
+ tool: Tool,
+ profileName?: string,
+): Tool {
+ return {
+ ...tool,
+ execute: async (): Promise =>
+ createDeniedToolError(toolName, profileName),
+ };
}
export function filterSubagentTools(
@@ -14,11 +44,20 @@ export function filterSubagentTools(
? new Set(filter.allowedTools)
: null;
const denied = new Set(filter.deniedTools ?? []);
+ const deniedBehavior = filter.deniedBehavior ?? "reject";
const selected: ToolSet = {};
for (const [toolName, tool] of Object.entries(tools)) {
if (allowed && !allowed.has(toolName)) continue;
- if (denied.has(toolName)) continue;
+ if (denied.has(toolName)) {
+ if (deniedBehavior === "hide") continue;
+ selected[toolName] = createDeniedSubagentTool(
+ toolName,
+ tool,
+ filter.profileName,
+ );
+ continue;
+ }
selected[toolName] = tool;
}
diff --git a/src/subagents/tool-surface.ts b/src/subagents/tool-surface.ts
index 8913813..3dd4e24 100644
--- a/src/subagents/tool-surface.ts
+++ b/src/subagents/tool-surface.ts
@@ -51,6 +51,8 @@ export async function createSubagentToolSurface(options: {
const filteredTools = filterSubagentTools(options.tools, {
allowedTools: options.profile.allowedTools,
deniedTools: options.profile.deniedTools,
+ deniedBehavior: options.profile.deniedBehavior,
+ profileName: options.profile.name,
});
const directTools = options.profile.codemode.exposeDirectTools
? filteredTools
diff --git a/src/subagents/types.ts b/src/subagents/types.ts
index 3d42675..68c49f5 100644
--- a/src/subagents/types.ts
+++ b/src/subagents/types.ts
@@ -59,6 +59,8 @@ export type SubagentContextPolicyInput =
| { recent_turns: number }
| SubagentContextPolicy;
+export type SubagentDeniedToolBehavior = "reject" | "hide";
+
export interface SubagentCodemodePolicy {
enabled: boolean;
exposeDirectTools: boolean;
@@ -74,6 +76,7 @@ export interface SubagentProfileInput {
system?: string;
allowedTools?: string[];
deniedTools?: string[];
+ deniedBehavior?: SubagentDeniedToolBehavior;
codemode?: Partial;
context?: SubagentContextPolicyInput;
cost?: SubagentCostPolicyInput;
@@ -85,6 +88,7 @@ export interface SubagentProfileDefaults {
system?: string;
allowedTools?: string[];
deniedTools?: string[];
+ deniedBehavior?: SubagentDeniedToolBehavior;
codemode?: Partial;
context?: SubagentContextPolicyInput;
cost?: SubagentCostPolicyInput;
@@ -98,6 +102,7 @@ export interface ResolvedSubagentProfile {
system: string;
allowedTools: readonly string[];
deniedTools: readonly string[];
+ deniedBehavior: SubagentDeniedToolBehavior;
codemode: SubagentCodemodePolicy;
context: SubagentContextPolicy;
cost: SubagentCostPolicy;
diff --git a/tests/subagents/profiles.test.ts b/tests/subagents/profiles.test.ts
index a66c8cf..b05e0e0 100644
--- a/tests/subagents/profiles.test.ts
+++ b/tests/subagents/profiles.test.ts
@@ -14,6 +14,7 @@ describe("createSubagentProfileRegistry", () => {
name: "worker",
nickname: "worker",
codemode: { enabled: true, exposeDirectTools: false },
+ deniedBehavior: "reject",
context: { mode: "recent", turns: 3 },
});
});
@@ -23,6 +24,7 @@ describe("createSubagentProfileRegistry", () => {
defaults: {
allowedTools: ["Read", "Grep", "Bash"],
deniedTools: ["Bash"],
+ deniedBehavior: "hide",
context: "none",
},
profiles: [
@@ -44,6 +46,7 @@ describe("createSubagentProfileRegistry", () => {
name: "researcher",
allowedTools: ["Read", "Grep", "Bash", "Glob", "WebSearch"],
deniedTools: ["Bash"],
+ deniedBehavior: "hide",
codemode: { enabled: true, exposeDirectTools: true },
context: { mode: "recent", turns: 2 },
});
diff --git a/tests/subagents/tool-filter.test.ts b/tests/subagents/tool-filter.test.ts
index 0359fc5..b148953 100644
--- a/tests/subagents/tool-filter.test.ts
+++ b/tests/subagents/tool-filter.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { tool, zodSchema, type ToolSet } from "ai";
import { z } from "zod";
import { filterSubagentTools } from "@/subagents";
+import { executeTool } from "../helpers/tool-executor";
function executableTools(): ToolSet {
const simpleTool = tool({
@@ -27,10 +28,24 @@ describe("filterSubagentTools", () => {
expect(Object.keys(tools)).toEqual(["Read", "Grep", "Bash"]);
});
- it("applies denylist after allowlist", () => {
+ it("keeps denied tools visible by default and rejects execution", async () => {
const filtered = filterSubagentTools(executableTools(), {
allowedTools: ["Read", "Bash"],
deniedTools: ["Bash"],
+ profileName: "researcher",
+ });
+
+ expect(Object.keys(filtered)).toEqual(["Read", "Bash"]);
+ await expect(executeTool(filtered.Bash, {})).resolves.toEqual({
+ error: "Tool Bash is not allowed for subagent profile researcher",
+ });
+ });
+
+ it("applies hide behavior after allowlist when explicitly configured", () => {
+ const filtered = filterSubagentTools(executableTools(), {
+ allowedTools: ["Read", "Bash"],
+ deniedTools: ["Bash"],
+ deniedBehavior: "hide",
});
expect(Object.keys(filtered)).toEqual(["Read"]);
diff --git a/tests/subagents/tool-surface.test.ts b/tests/subagents/tool-surface.test.ts
index 8e177e1..d459d2e 100644
--- a/tests/subagents/tool-surface.test.ts
+++ b/tests/subagents/tool-surface.test.ts
@@ -8,6 +8,7 @@ import {
type SubagentProfileInput,
} from "@/subagents";
import type { CodemodeConfig, CodemodeToolProvider } from "@/tools/codemode";
+import { executeTool } from "../helpers/tool-executor";
function fakeTool(name: string): Tool {
return tool({
@@ -92,6 +93,68 @@ describe("createSubagentToolSurface", () => {
config: { codemode: codemodeConfig(captured) },
});
+ expect(Object.keys(captured.providers[0].tools)).toEqual(["Read", "Bash"]);
+ await expect(
+ executeTool(captured.providers[0].tools.Bash, {}),
+ ).resolves.toEqual({
+ error: "Tool Bash is not allowed for subagent profile test",
+ });
+ });
+
+ it("keeps denied direct tools present by default and rejects execution", async () => {
+ const surface = await createSubagentToolSurface({
+ tools: {
+ Read: fakeTool("Read"),
+ Bash: fakeTool("Bash"),
+ },
+ profile: resolveProfile({
+ deniedTools: ["Bash"],
+ codemode: { enabled: false, exposeDirectTools: true },
+ }),
+ });
+
+ expect(Object.keys(surface.directTools)).toEqual(["Read", "Bash"]);
+ await expect(executeTool(surface.directTools.Bash, {})).resolves.toEqual({
+ error: "Tool Bash is not allowed for subagent profile test",
+ });
+ });
+
+ it("uses allowedTools as the primary surface narrowing mechanism", async () => {
+ const captured = { providers: [] as CodemodeToolProvider[] };
+ const surface = await createSubagentToolSurface({
+ tools: {
+ Read: fakeTool("Read"),
+ Bash: fakeTool("Bash"),
+ Write: fakeTool("Write"),
+ },
+ profile: resolveProfile({
+ allowedTools: ["Read"],
+ deniedTools: ["Bash"],
+ codemode: { exposeDirectTools: true },
+ }),
+ config: { codemode: codemodeConfig(captured) },
+ });
+
+ expect(Object.keys(surface.directTools)).toEqual(["Read"]);
+ expect(Object.keys(captured.providers[0].tools)).toEqual(["Read"]);
+ });
+
+ it("hides denied direct and Codemode tools when configured", async () => {
+ const captured = { providers: [] as CodemodeToolProvider[] };
+ const surface = await createSubagentToolSurface({
+ tools: {
+ Read: fakeTool("Read"),
+ Bash: fakeTool("Bash"),
+ },
+ profile: resolveProfile({
+ deniedTools: ["Bash"],
+ deniedBehavior: "hide",
+ codemode: { exposeDirectTools: true },
+ }),
+ config: { codemode: codemodeConfig(captured) },
+ });
+
+ expect(Object.keys(surface.directTools)).toEqual(["Read"]);
expect(Object.keys(captured.providers[0].tools)).toEqual(["Read"]);
});
});
From cebc6fbadc8f9b113dbf43b35dcc38d638bbb943 Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 20:22:41 -0400
Subject: [PATCH 08/20] Remove legacy Task and TodoWrite tools
---
README.md | 154 ++-----
...06-15-001-feat-subagent-foundation-plan.md | 54 +--
docs/src/app/SideNav.tsx | 4 +-
docs/src/app/api-reference/page.tsx | 4 +-
docs/src/app/page.tsx | 6 +-
docs/src/app/tools/page.tsx | 47 +-
examples/basic.ts | 40 +-
src/context/tool-guidance.ts | 6 +-
src/index.ts | 14 -
src/runtime/AGENTS.md | 3 +-
src/tools/AGENTS.md | 21 +-
src/tools/glob.ts | 2 +-
src/tools/index.ts | 20 +-
src/tools/task.ts | 411 ------------------
src/tools/todo-write.ts | 140 ------
tests/tools/budget-integration.test.ts | 119 +----
tests/tools/index.test.ts | 6 +
tests/tools/todo-write.test.ts | 300 -------------
tests/utils/budget-tracking.test.ts | 2 +-
19 files changed, 110 insertions(+), 1243 deletions(-)
delete mode 100644 src/tools/task.ts
delete mode 100644 src/tools/todo-write.ts
delete mode 100644 tests/tools/todo-write.test.ts
diff --git a/README.md b/README.md
index c45a9d5..55c1102 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,8 @@ Agentic coding tools for Vercel AI SDK. Give AI agents the ability to execute co
- Edit existing files with string replacement
- Search for files by pattern
- Search file contents with regex
-- Spawn sub-agents for complex tasks
-- Track task progress with todos
+- Coordinate controller-managed subagents
+- Track task progress with UpdatePlan
- Search the web and fetch URLs
- Load skills on-demand via the [Agent Skills](https://agentskills.io) standard
@@ -141,12 +141,12 @@ await sandbox.destroy();
| `WebSearch` | Search the web | `webSearch: { apiKey }` |
| `WebFetch` | Fetch URL and process with AI | `webFetch: { apiKey, model }` |
-### Workflow Tools (created separately)
+### Workflow Tools
| Tool | Purpose | Factory |
|------|---------|---------|
-| `Task` | Spawn sub-agents | `createTaskTool({ model, tools, subagentTypes? })` |
-| `TodoWrite` | Track task progress | `createTodoWriteTool(state, config?, onUpdate?)` |
+| `UpdatePlan` | Track task progress | Included by `createAgentTools()` |
+| `SpawnAgent` / `WaitAgent` | Control subagents | `createSubagentControlTools(controller, config?)` |
### Web Tools (require `parallel-web` peer dependency)
@@ -340,102 +340,16 @@ const { tools } = createAgentTools(sandbox, {
- `strict` (boolean): Enable strict schema validation
- `providerOptions` (object): Provider-specific tool options
-## Sub-agents with Task Tool
-
-The Task tool spawns new agents for complex subtasks:
-
-```typescript
-import { createTaskTool } from 'bashkit';
-
-const taskTool = createTaskTool({
- model: anthropic('claude-sonnet-4-5'),
- tools: sandboxTools,
- subagentTypes: {
- research: {
- model: anthropic('claude-haiku-4'), // Cheaper model for research
- systemPrompt: 'You are a research specialist. Find information only.',
- tools: ['Read', 'Grep', 'Glob'], // Limited tools
- },
- coding: {
- systemPrompt: 'You are a coding expert. Write clean code.',
- tools: ['Read', 'Write', 'Edit', 'Bash'],
- },
- },
-});
+## Subagents
-// Add to tools
-const allTools = { ...sandboxTools, Task: taskTool };
-```
-
-The parent agent calls Task like any other tool:
-```typescript
-// Agent decides to delegate:
-{ tool: "Task", args: {
- description: "Research API patterns",
- prompt: "Find best practices for REST APIs",
- subagent_type: "research"
-}}
-```
-
-### Dynamic Agents
-
-You can create custom agents on the fly by passing `system_prompt` and/or `tools` directly, without predefined subagent types:
-
-```typescript
-// Agent creates a specialized agent dynamically:
-{ tool: "Task", args: {
- description: "Analyze security vulnerabilities",
- prompt: "Review the auth code for security issues",
- subagent_type: "custom",
- system_prompt: "You are a security expert. Focus on OWASP top 10 vulnerabilities.",
- tools: ["Read", "Grep", "Glob"]
-}}
-```
+Subagents are controller-managed agents with stable identity, profiles,
+lifecycle events, and explicit supervision tools. Use `SpawnAgent` to start
+separable work, `ListAgents` to inspect running and terminal children, and
+`WaitAgent` to collect the terminal result.
-This is useful when:
-- The parent agent needs to create specialized agents based on context
-- You want agents to delegate with custom instructions
-- Predefined subagent types don't fit the task
-
-### Streaming Sub-agent Activity to UI
-
-Pass a `streamWriter` to stream real-time sub-agent activity to the UI:
-
-```typescript
-import { createUIMessageStream } from 'ai';
-
-const stream = createUIMessageStream({
- execute: async ({ writer }) => {
- const taskTool = createTaskTool({
- model,
- tools: sandboxTools,
- streamWriter: writer, // Enable real-time streaming
- subagentTypes: { ... },
- });
-
- // Use with streamText
- const result = streamText({
- model,
- tools: { Task: taskTool },
- ...
- });
-
- writer.merge(result.toUIMessageStream());
- },
-});
-```
-
-When `streamWriter` is provided:
-- Uses `streamText` internally (instead of `generateText`)
-- Emits `data-subagent` events to the UI stream:
- - `start` - Sub-agent begins work
- - `tool-call` - Each tool the sub-agent uses (with args)
- - `done` - Sub-agent finished
- - `complete` - Full messages array for UI access
-
-These appear in `message.parts` on the client as `{ type: "data-subagent", data: SubagentEventData }`.
-
-**Important:** The TaskOutput returned to the lead agent does NOT include messages (to avoid context bloat). The UI accesses the full conversation via the streamed `complete` event.
+The older one-shot `Task` tool has been removed from the breaking API. Migrate
+`Task(description, prompt)` flows to `SpawnAgent(task, task_name?)` followed by
+`WaitAgent(agent_id)`.
## Context Management
@@ -916,25 +830,23 @@ const result = await generateText({
### Using with Subagents
-Use the same config for subagent prompts:
+Use the same config for subagent profiles:
```typescript
-const taskTool = createTaskTool({
- model,
- tools,
- subagentTypes: {
- researcher: {
- systemPrompt: `You are a researcher.
+const profiles = [
+ {
+ name: 'researcher',
+ system: `You are a researcher.
Save findings to: ${config.workspace.notes}`,
- tools: ['WebSearch', 'Write'],
- },
- 'report-writer': {
- systemPrompt: `Read from: ${config.workspace.notes}
+ allowedTools: ['WebSearch', 'Write'],
+ },
+ {
+ name: 'report-writer',
+ system: `Read from: ${config.workspace.notes}
Save reports to: ${config.workspace.outputs}`,
- tools: ['Read', 'Glob', 'Write'],
- },
+ allowedTools: ['Read', 'Glob', 'Write'],
},
-});
+];
```
## Sandbox Interface
@@ -1020,7 +932,7 @@ const { tools } = createAgentTools(sandbox);
See the `examples/` directory for complete working examples:
-- `basic.ts` - Full example with todos, sub-agents, and prompt caching
+- `basic.ts` - Full example with UpdatePlan and prompt caching
- `test-tools.ts` - Testing individual tools
- `test-web-tools.ts` - Web search and fetch examples
@@ -1045,8 +957,8 @@ Creates a set of agent tools bound to a sandbox instance.
### Workflow Tools
-- `createTaskTool(config)` - Spawn sub-agents for complex tasks
-- `createTodoWriteTool(state, config?, onUpdate?)` - Track task progress
+- `createUpdatePlanTool(state, config?)` - Track progress with the canonical Codex-style plan state
+- `createSubagentControlTools(controller, config?)` - Create Spawn/List/Wait/Message/Interrupt subagent controls
### Optional Tools (also available via config)
@@ -1113,14 +1025,12 @@ Load pre-configured subagent types from JSON/TypeScript configs:
}
```
-Helper function to auto-load profiles:
+Register loaded profiles with the subagent profile registry:
```typescript
-import { createTaskToolWithProfiles } from 'bashkit';
+import { createSubagentProfileRegistry } from 'bashkit';
-const taskTool = createTaskToolWithProfiles({
- model,
- tools,
- profilesPath: '.bashkit/agents.json', // Auto-loads
+const registry = createSubagentProfileRegistry({
+ profiles,
});
```
diff --git a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
index f6af223..3096906 100644
--- a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
+++ b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
@@ -106,7 +106,7 @@ Codex has already solved the architectural split. Its multi-agent tools do not o
- KTD1. Split subagent core from tool adapters: create `src/subagents/` for profiles, identity, registry, controller, stores, events, state projections, policies, and runner contracts. Tool files call into this module.
- KTD2. Make Codemode the default public harness: `createAgentTools` exposes a Codemode tool by default when a Codemode executor is supplied. Direct tools are inner providers and optional compatibility exposure.
- KTD3. Make subagent controls the default delegation API: expose `SpawnAgent`, `ListAgents`, `SendMessage`, `FollowupTask`, `WaitAgent`, and `InterruptAgent` for parent model coordination.
-- KTD4. Treat `Task` as legacy: remove it from default examples and either remove it from the default factory or retain it as a deprecated one-shot adapter over the controller.
+- KTD4. Remove `Task` entirely in the breaking release: the one-shot delegation abstraction does not fit the controller-managed subagent model. Migrate callers to `SpawnAgent` plus `WaitAgent`.
- KTD5. Put Codemode inside subagents by default: children receive a profile-scoped Codemode tool that can orchestrate only the tools allowed by the profile.
- KTD6. Apply quarantine at two layers: profile allowlists filter direct tools and Codemode inner providers. A restricted profile cannot regain `Write`, `Edit`, `Patch`, `Bash`, `SpawnAgent`, or `FollowupTask` through generated code.
- KTD7. Model profiles as runtime configuration, not prompt strings: profiles resolve to model, prompt, tool policy, Codemode policy, context policy, cost policy, stop conditions, prepare-step, callbacks, and host metadata.
@@ -716,21 +716,11 @@ This boundary should be documented as "metadata and terminal result persistence"
| Cost tracking exists mostly at tool/generation boundaries | Cost tracking is shared across parent and children |
| UI receives optional `data-subagent` events from `Task` | UI can consume typed controller events and snapshots |
-### `Task` Options
+### Removed Legacy Tools
-Preferred path:
-
-- Remove `Task` from default docs and factory examples.
-- Export `createLegacyTaskTool` for one migration window.
-- Implement the adapter over `SubagentController.spawn` plus `wait`.
-- Emit deprecation messaging in docs and release notes.
-
-Alternative path:
-
-- Remove `Task` entirely in the breaking release.
-- Provide a migration recipe: `Task(description, prompt)` becomes `SpawnAgent(task, task_name?)` then `WaitAgent(agent_id)`.
-
-The preferred path is kinder to existing consumers while still making the default mental model correct.
+- Remove `Task` entirely in the breaking release. The one-shot nested generation model conflicts with stable subagent identity, lifecycle events, control panel state, messaging, waiting, interruption, and profile-scoped Codemode policy.
+- Remove `TodoWrite` entirely in the breaking release. `UpdatePlan` is the canonical Codex-style progress primitive and emits normalized `plan.updated` runtime events for host UIs.
+- Provide migration recipes: `Task(description, prompt)` becomes `SpawnAgent(task, task_name?)` then `WaitAgent(agent_id)`. `TodoWrite(todos)` becomes `UpdatePlan(plan)`.
### Versioning
@@ -747,7 +737,7 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
- **Dependencies:** None
- **Files:** `src/subagents/AGENTS.md`, `src/subagents/CLAUDE.md`, `src/subagents/index.ts`, `src/subagents/types.ts`, `src/subagents/identity.ts`, `src/subagents/path.ts`, `src/subagents/profiles.ts`, `src/subagents/profile-descriptions.ts`, `src/subagents/tool-filter.ts`, `src/subagents/registry.ts`, `src/tools/AGENTS.md`, `src/index.ts`, `tests/subagents/identity.test.ts`, `tests/subagents/path.test.ts`, `tests/subagents/profiles.test.ts`, `tests/subagents/tool-filter.test.ts`, `tests/subagents/registry.test.ts`
- **Approach:** Define public-but-small interfaces for `SubagentProfile`, `ResolvedSubagentProfile`, `SubagentMetadata`, `SubagentStatus`, `SubagentPath`, `SubagentRunResult`, `SubagentCodemodePolicy`, `SubagentContextPolicy`, and `SubagentError`. Keep core request types separate from AI SDK tool schemas. Add path parsing, relative lookup, duplicate live-name checks, and profile-visible description generation.
-- **Patterns to follow:** `src/tools/patch/` for promoting non-trivial internals into a folder; `src/tools/AGENTS.md` for documentation expectations; `src/tools/task.ts` for AI SDK type imports and budget callback conventions.
+- **Patterns to follow:** `src/tools/patch/` for promoting non-trivial internals into a folder; `src/tools/AGENTS.md` for documentation expectations; AI SDK runner tests for model/tool execution conventions.
- **Test scenarios:** Unique IDs are generated; duplicate live paths return `{ error }`; relative child and sibling paths resolve; terminal path reuse follows policy; profile resolution applies defaults, named profile config, and allowed overrides; profile descriptions include purpose, locked model/cost hints, and capability limits; tool filtering excludes unknown tools without throwing; Codemode policy inherits the same allowlist as direct tools; context policy accepts `none`, `all`, and recent-turn modes; optional nullable fields resolve with `??` defaults.
- **Verification:** `src/subagents/` exports only stable types/helpers and every new file is covered by `src/subagents/AGENTS.md`.
@@ -758,7 +748,7 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
- **Dependencies:** U1
- **Files:** `src/subagents/controller.ts`, `src/subagents/store.ts`, `src/subagents/runner.ts`, `src/subagents/events.ts`, `src/subagents/status.ts`, `src/subagents/mailbox.ts`, `tests/subagents/controller.test.ts`, `tests/subagents/store.test.ts`, `tests/subagents/runner.test.ts`, `tests/subagents/events.test.ts`
- **Approach:** Implement `SubagentController` over `SubagentStore`, `SubagentRunner`, lifecycle hooks, and `SubagentEventSink`. Provide `InMemorySubagentStore` and an AI SDK runner. The controller owns reservations, status transitions, context inheritance, mailbox writes, completion notifications, and event ordering. The runner executes resolved runs and reports usage/events back through typed callbacks.
-- **Patterns to follow:** Codex `AgentControl` and `AgentRegistry`; BashKit `BudgetTracker` stopWhen/onStepFinish composition in `src/tools/task.ts`; debug parent attribution in `src/utils/debug.ts`.
+- **Patterns to follow:** Codex `AgentControl` and `AgentRegistry`; BashKit `BudgetTracker` stopWhen/onStepFinish composition in `src/subagents/ai-sdk-runner.ts`; debug parent attribution in `src/utils/debug.ts`.
- **Test scenarios:** Spawn moves `pending -> running -> completed`; setup failures move to `failed`; lifecycle hooks fire in deterministic order; completion notification emits even when no one waits; runner rejection records errored status and returns `{ error }`; interrupt changes status and aborts when supported; unsupported interrupt returns `{ error }`; wait timeout does not change status; mailbox updates `last_task_message`; follow-up requests a turn only when runner supports it; snapshots are JSON-serializable; fake runners can test behavior without real models.
- **Verification:** Controller tests use fake stores and fake runners; no live AI SDK calls are needed.
@@ -791,20 +781,20 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
- **Dependencies:** U1, U2, U3
- **Files:** `src/subagents/ai-sdk-runner.ts`, `src/subagents/tool-surface.ts`, `src/subagents/context-inheritance.ts`, `src/subagents/transcripts.ts`, `tests/subagents/ai-sdk-runner.test.ts`, `tests/subagents/context-inheritance.test.ts`, `tests/subagents/tool-surface.test.ts`
- **Approach:** Construct a child tool surface from the resolved profile. Build Codemode providers after profile filtering. Include optional direct tools only when the profile asks for them. Apply context inheritance before the model call. Route usage, tool events, and terminal summaries back through runner callbacks.
-- **Patterns to follow:** `src/tools/task.ts` AI SDK generation flow; `src/tools/codemode.ts` provider selection and `selectCodemodeTools`; `src/context/index.ts` wrapping behavior.
+- **Patterns to follow:** `src/subagents/ai-sdk-runner.ts` AI SDK generation flow; `src/tools/codemode.ts` provider selection and `selectCodemodeTools`; `src/context/index.ts` wrapping behavior.
- **Test scenarios:** Child receives Codemode by default; child direct tools are hidden unless profile opts in; blocked tools are absent from Codemode providers; recent-turn context includes only the configured number of turns; `none` context excludes parent history; runner reports usage to the controller; generated terminal summary excludes full transcript; stop conditions produce `completed`; model/tool failure produces `failed` with `{ error }`.
- **Verification:** Runner tests use mock models/tools where possible and avoid networked model calls.
-### U6. Decide and Implement the `Task` Migration Path
+### U6. Remove Legacy `Task` and `TodoWrite`
-- **Goal:** Make the breaking `Task` decision explicit and provide the smallest responsible compatibility path.
+- **Goal:** Make the breaking delegation/progress decision explicit by deleting old model-facing compatibility tools.
- **Requirements:** R1, R2, R3, R4, R5, R8, R10, R11, R13
- **Dependencies:** U1, U2, U3, U5
-- **Files:** `src/tools/task.ts`, `tests/tools/task.test.ts`, `tests/tools/budget-integration.test.ts`
-- **Approach:** Prefer retaining `Task` only as `createLegacyTaskTool` or a deprecated adapter. Translate old fields into a one-shot spawn request, call `wait`, and format the old `TaskOutput` shape. Emit existing streaming event names through the shared event sink so legacy and new paths share observability code.
-- **Patterns to follow:** Current `src/tools/task.ts` output and debug behavior; existing budget tests; `runWithDebugParent` for nested attribution.
-- **Test scenarios:** Deprecated adapter can be imported; legacy success returns `result`, `usage`, `duration_ms`, `subagent`, and `description`; legacy errors return `TaskError`; custom `system_prompt` maps to profile override; custom `tools` narrows direct tools and Codemode providers; streaming mode emits compatible `data-subagent` events; debug parent wraps child tool calls; budget stopWhen still halts child work.
-- **Verification:** New docs do not teach `Task` as the default subagent API.
+- **Files:** `src/tools/task.ts`, `src/tools/todo-write.ts`, `src/tools/index.ts`, `src/index.ts`, `tests/tools/todo-write.test.ts`, `tests/tools/budget-integration.test.ts`, `README.md`, docs pages
+- **Approach:** Delete `Task` and `TodoWrite` factories/types/tests/exports. Keep `UpdatePlan` as the canonical progress tool and subagent controls as the canonical delegation API. Move useful budget and event assertions into controller/runtime tests rather than preserving adapter behavior.
+- **Patterns to follow:** `UpdatePlan` runtime event behavior; subagent control tool tests; `createAgentTools` export surface tests.
+- **Test scenarios:** `Task` and `TodoWrite` are absent from package exports; `UpdatePlan` remains default; budget tests no longer import legacy tools; docs/examples contain migration wording instead of legacy usage examples.
+- **Verification:** New docs do not teach `Task` or `TodoWrite` as supported APIs.
### U7. Add Parent-Agent Control Panel State
@@ -813,7 +803,7 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
- **Dependencies:** U1, U2, U3, U4, U6
- **Files:** `src/subagents/control-panel.ts`, `src/subagents/events.ts`, `src/subagents/index.ts`, `tests/subagents/control-panel.test.ts`, `README.md`, `docs/src/app/tools/page.tsx`
- **Approach:** Add a projection layer that turns controller state, store records, events, and budget summaries into `SubagentControlPanelState`. Include live and terminal agents, status, profile/type, task name, nickname, last task message, parent/child relationship, budget summary, warnings, supported actions, and transcript/result references.
-- **Patterns to follow:** Existing `SubagentEventData` shape in `src/tools/task.ts`; Codex `list_agents` status output; BashKit budget status shape in `src/utils/budget-tracking.ts`.
+- **Patterns to follow:** Codex `list_agents` status output; BashKit budget status shape in `src/utils/budget-tracking.ts`; normalized runtime event snapshots.
- **Test scenarios:** Starting a child adds it to active state; completion moves it to terminal state without dropping result references; budget updates and warnings appear; unsupported actions are absent; full transcript text is excluded by default; snapshots serialize to plain JSON; path filters produce stable subsets.
- **Verification:** Host apps can build a control panel without reaching into controller internals.
@@ -891,7 +881,7 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
- Subagent control tools with fake runner.
- Budget integration across parent and child.
- Context layers inherited by child tools.
-- Legacy `Task` adapter over controller.
+- Removed `Task`/`TodoWrite` public export checks.
### Regression Tests
@@ -920,7 +910,7 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
3. Add AI SDK runner and profile-scoped Codemode tool surface.
4. Add model-facing control tools.
5. Add control panel projection.
-6. Add legacy `Task` adapter or removal path.
+6. Remove legacy `Task` and `TodoWrite` APIs.
7. Flip factory defaults to Codemode-first and subagent-first in the breaking release branch.
8. Update examples, docs, and release notes.
9. Run full local gates and review public API surface before release.
@@ -951,14 +941,14 @@ Optional peer dependency strategy needs a release decision. If Codemode becomes
| UI expects transcripts in model output | Parent context fills with child logs | Provide event sinks and transcript references instead. |
| Durable resume expectations | Users assume active-run recovery | Document metadata/result persistence and defer active-run resume. |
| API surface becomes too complex | Hard adoption | Keep defaults simple and put advanced knobs in profile/config objects. |
-| Legacy `Task` behavior diverges | Migration confusion | Implement adapter on same controller or remove with clear release notes. |
+| Removed legacy tools surprise consumers | Migration confusion | Treat as a major breaking change and document `Task` -> `SpawnAgent`/`WaitAgent`, `TodoWrite` -> `UpdatePlan`. |
---
## Open Questions
- OQ1. Should this ship as `1.0.0` to make the breaking change obvious, or as a pre-1.0 breaking minor with unusually strong migration notes?
-- OQ2. Should `Task` remain as `createLegacyTaskTool` for one release, or be removed entirely from the breaking release?
+- OQ2. Resolved: remove `Task` entirely in the breaking release.
- OQ3. What should the default max depth be: `1` for safest behavior or `2` to allow supervisor -> worker -> specialist patterns?
- OQ4. Should the default `worker` profile expose subagent control tools, or should recursive spawning require explicit profile opt-in?
- OQ5. Should profile files live under a conventional path such as `.bashkit/agents/*.json`, or should loading remain completely caller-directed?
@@ -973,7 +963,7 @@ The docs should introduce the model in this order:
2. Subagent controls are the default delegation surface.
3. Profiles define capability, safety, cost, and context boundaries.
4. Control panel state is host-rendered from typed snapshots.
-5. `Task` is legacy or removed.
+5. `Task` and `TodoWrite` are removed.
The examples should show:
@@ -989,7 +979,7 @@ The examples should show:
## Sources & Research
-- BashKit current implementation: `src/tools/task.ts`, `src/tools/codemode.ts`, `src/tools/index.ts`, `src/types.ts`, `tests/tools/budget-integration.test.ts`, `README.md`
+- BashKit current implementation: `src/subagents/`, `src/tools/subagents/`, `src/tools/codemode.ts`, `src/tools/index.ts`, `src/types.ts`, `tests/tools/budget-integration.test.ts`, `README.md`
- BashKit module guidance: `src/tools/AGENTS.md`, `src/tools/patch/AGENTS.md`
- Codex reference concepts: `codex-rs/core/src/agent/control.rs`, `codex-rs/core/src/agent/registry.rs`, `codex-rs/core/src/agent/role.rs`, `codex-rs/core/src/tools/handlers/multi_agents_v2.rs`, `codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs`, `codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs`, `codex-rs/core/src/tools/handlers/multi_agents_spec.rs`
- Codex test coverage patterns: `codex-rs/core/tests/suite/subagent_notifications.rs`, `codex-rs/core/tests/suite/spawn_agent_description.rs`, `codex-rs/core/tests/suite/agent_execution.rs`, `codex-rs/core/tests/suite/hierarchical_agents.rs`
diff --git a/docs/src/app/SideNav.tsx b/docs/src/app/SideNav.tsx
index 4bdb61f..3dd97d3 100644
--- a/docs/src/app/SideNav.tsx
+++ b/docs/src/app/SideNav.tsx
@@ -49,8 +49,8 @@ const links: {
{ id: "grep", text: "Grep" },
{ id: "websearch", text: "WebSearch" },
{ id: "webfetch", text: "WebFetch" },
- { id: "task", text: "Task" },
- { id: "todowrite", text: "TodoWrite" },
+ { id: "updateplan", text: "UpdatePlan" },
+ { id: "subagents", text: "Subagents" },
],
},
{
diff --git a/docs/src/app/api-reference/page.tsx b/docs/src/app/api-reference/page.tsx
index f9e8cff..16e960a 100644
--- a/docs/src/app/api-reference/page.tsx
+++ b/docs/src/app/api-reference/page.tsx
@@ -246,8 +246,8 @@ const result = await generateText({
});`}
/>
- Budget tracking auto-wires into Task tool sub-agents when
- configured.
+ Budget tracking can be shared with subagent controllers so parent
+ and child work are governed by one budget domain.
Model ID Matching
diff --git a/docs/src/app/page.tsx b/docs/src/app/page.tsx
index a9e9315..c8ba8ee 100644
--- a/docs/src/app/page.tsx
+++ b/docs/src/app/page.tsx
@@ -158,10 +158,12 @@ export default function Home() {
WebFetch — Fetch and extract web content
- Task — Spawn sub-agents for complex work
+ UpdatePlan — Track progress with a
+ Codex-style plan
- TodoWrite — Manage structured task lists
+ SpawnAgent — Start controller-managed
+ subagents for separable work
- Spawn sub-agents for complex, multi-step work. Each sub-agent gets
- its own tool set and conversation context. When budget tracking is
- enabled, costs are shared across parent and child agents.
+ Track multi-step progress with the canonical Codex-style plan state.
+ Hosts can observe plan updates through runtime events.
-
TodoWrite
+
Subagents
- Manage structured task lists. Useful for agents that need to track
- multi-step workflows.
+ Use controller-backed subagent tools for delegation. SpawnAgent
+ starts separable work, ListAgents inspects status, and WaitAgent
+ collects terminal results.
diff --git a/examples/basic.ts b/examples/basic.ts
index f8e880b..f3f9883 100644
--- a/examples/basic.ts
+++ b/examples/basic.ts
@@ -10,23 +10,14 @@ import {
anthropicPromptCacheMiddleware,
createAgentTools,
createLocalSandbox,
- createTaskTool,
- createTodoWriteTool,
- type TodoState,
} from "../src";
async function main() {
// Create sandbox in a temp directory
const sandbox = createLocalSandbox({ cwd: "/tmp/bashkit-test" });
- // Create sandbox-based tools
- const { tools: sandboxTools } = await createAgentTools(sandbox);
-
- // Create state for todos
- const todoState: TodoState = { todos: [] };
- const todoTool = createTodoWriteTool(todoState, undefined, (todos) => {
- console.log("📝 Todos updated:", JSON.stringify(todos, null, 2));
- });
+ // Create sandbox-based tools plus the canonical UpdatePlan progress tool.
+ const { tools } = await createAgentTools(sandbox);
// Wrap model with prompt caching middleware for better performance
const model = wrapLanguageModel({
@@ -34,29 +25,6 @@ async function main() {
middleware: anthropicPromptCacheMiddleware,
});
- // Create task tool for sub-agents (also uses cached model)
- const taskTool = createTaskTool({
- model,
- tools: sandboxTools,
- subagentTypes: {
- research: {
- systemPrompt: "You are a research specialist. Find information only.",
- tools: ["Read", "Grep", "Glob"],
- },
- coding: {
- systemPrompt: "You are a coding expert. Write clean code.",
- tools: ["Read", "Write", "Edit", "Bash"],
- },
- },
- });
-
- // Combine all tools
- const tools = {
- ...sandboxTools,
- TodoWrite: todoTool,
- Task: taskTool,
- };
-
console.log("🚀 Starting bashkit test...\n");
console.log("Available tools:", Object.keys(tools).join(", "));
console.log("Prompt caching: enabled");
@@ -71,9 +39,9 @@ async function main() {
system: `You are a helpful coding assistant with access to tools for file operations and code execution.
When given a task:
-1. Use TodoWrite to plan your steps
+1. Use UpdatePlan to plan your steps
2. Execute each step using the appropriate tools
-3. Mark todos as completed as you go`,
+3. Mark plan items as completed as you go`,
prompt: `Create a simple "hello world" TypeScript file at /tmp/bashkit-test/hello.ts and then run it with bun.`,
stopWhen: stepCountIs(10), // Allow up to 10 steps
onStepFinish: ({ finishReason, toolCalls, toolResults, text, usage }) => {
diff --git a/src/context/tool-guidance.ts b/src/context/tool-guidance.ts
index 634a0f0..0246227 100644
--- a/src/context/tool-guidance.ts
+++ b/src/context/tool-guidance.ts
@@ -22,8 +22,10 @@ const DEFAULT_HINTS: Record = {
"Run generated JavaScript once to batch and orchestrate selected BashKit/provider tools.",
Codemode:
"Run generated JavaScript once to batch and orchestrate selected BashKit/provider tools.",
- Task: "Spawn sub-agents for complex parallel work.",
- TodoWrite: "Track multi-step task progress.",
+ UpdatePlan: "Track multi-step progress with a Codex-style plan.",
+ SpawnAgent: "Start a controller-managed subagent for separable work.",
+ WaitAgent: "Wait for a subagent to finish or report current status.",
+ ListAgents: "Inspect active and terminal subagents.",
};
/**
diff --git a/src/index.ts b/src/index.ts
index 0f3d9a4..b7fa7b3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -253,22 +253,10 @@ export type {
SkillError,
SkillOutput,
SkillToolConfig,
- // Task tool
- SubagentEventData,
- SubagentStepEvent,
- SubagentTypeConfig,
- TaskError,
- TaskOutput,
- TaskToolConfig,
// UpdatePlan tool
UpdatePlanError,
UpdatePlanOutput,
UpdatePlanToolConfig,
- // TodoWrite tool
- TodoItem,
- TodoState,
- TodoWriteError,
- TodoWriteOutput,
// Web tools
WebFetchError,
WebFetchOutput,
@@ -298,8 +286,6 @@ export {
createSkillTool,
createSpawnAgentTool,
createSubagentControlTools,
- createTaskTool,
- createTodoWriteTool,
createUpdatePlanTool,
createWaitAgentTool,
createWebFetchTool,
diff --git a/src/runtime/AGENTS.md b/src/runtime/AGENTS.md
index 66f8ae7..826d361 100644
--- a/src/runtime/AGENTS.md
+++ b/src/runtime/AGENTS.md
@@ -17,7 +17,7 @@ Host-facing runtime primitives for building Codex-like agent experiences on top
`RuntimeEventSink` is a small host-facing event stream. It records and publishes typed JSON-safe `RuntimeEvent` objects but does not decide where they go. Hosts can forward events to React state, WebSockets, databases, logs, or queues.
-`PlanState` is canonical for progress tracking. It follows Codex `update_plan` semantics: an optional explanation plus plan items with `step` and `status`. Legacy `TodoWrite` can adapt into this shape, but new runtime state should not depend on `TodoWrite.activeForm`.
+`PlanState` is canonical for progress tracking. It follows Codex `update_plan` semantics: an optional explanation plus plan items with `step` and `status`. Runtime state should not depend on legacy todo-list fields such as `activeForm`.
## Design Rules
@@ -42,4 +42,3 @@ Host-facing runtime primitives for building Codex-like agent experiences on top
1. Define the snapshot type in `types.ts` or `snapshots.ts`.
2. Implement a pure reducer in `snapshots.ts`.
3. Test multiple-event ordering, empty streams, and irrelevant-event filtering.
-
diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md
index ef7ce86..5223082 100644
--- a/src/tools/AGENTS.md
+++ b/src/tools/AGENTS.md
@@ -22,8 +22,6 @@ Most tools are a single file. **Tools with non-trivial internals live in their o
| `skill.ts` | Activate pre-loaded skills from SKILL.md files |
| `subagents/` | Controller-backed multi-agent control tools |
| `update-plan.ts` | Codex-style canonical checklist/progress tool backed by runtime `PlanState` |
-| `task.ts` | Spawn sub-agents for autonomous multi-step tasks |
-| `todo-write.ts` | Legacy task-list compatibility tool |
| `web-search.ts` | Search the web via Parallel API with domain filtering |
| `web-fetch.ts` | Fetch and process web content with AI model |
| `index.ts` | Tool factory orchestration and caching layer |
@@ -46,8 +44,6 @@ Most tools are a single file. **Tools with non-trivial internals live in their o
- `createSkillTool(config)` -- Skill activation
- `createSubagentControlTools(controller, config?)` -- Create Spawn/List/Wait/Message/Interrupt subagent control tools
- `createUpdatePlanTool(state, config?)` -- Codex-style checklist/progress updates backed by runtime `PlanState`
-- `createTaskTool(config)` -- Sub-agent spawning
-- `createTodoWriteTool(state, onUpdate?)` -- Legacy task list management
- `createWebSearchTool(config)` -- Web search
- `createWebFetchTool(config)` -- Web content fetching
@@ -55,7 +51,7 @@ Most tools are a single file. **Tools with non-trivial internals live in their o
Each tool exports `Output` for success and `Error` for errors:
- Sandbox tools: `BashOutput | BashError`, `ReadOutput | ReadError`, etc.
- Interactive tools: `AskUserOutput`, etc.
-- Workflow tools: `TaskOutput | TaskError`, `TodoWriteOutput | TodoWriteError`
+- Workflow tools: subagent control tool outputs plus `UpdatePlanOutput | UpdatePlanError`
- Web tools: `WebSearchOutput | WebSearchError`, `WebFetchOutput | WebFetchError`
### Configuration Types
@@ -63,7 +59,6 @@ Each tool exports `Output` for success and `Error` for errors:
- `ToolConfig` -- Per-tool config (timeout, allowedPaths, maxFileSize, etc.)
- `AskUserConfig` -- AskUser AI SDK tool options
- `SkillConfig` -- Skill metadata and sandbox
-- `TaskToolConfig` -- Sub-agent configuration (includes optional `budget` for auto-wiring cost tracking)
- `CodemodeConfig` -- Optional Cloudflare Codemode adapter config (executor, tool filters, extra tools). Executor typing follows Cloudflare Codemode / AI SDK v6.
- `ModelRegistryConfig` -- Model registry config (provider, apiKey) for fetching model info
- `BudgetConfig` -- Budget tracking config (maxUsd, modelPricing; pricing provider via top-level `modelRegistry`)
@@ -90,9 +85,8 @@ Each tool exports `Output` for success and `Error` for errors:
**Progress Tools** (default):
- UpdatePlan -- Canonical Codex-style checklist/progress tool. Updates runtime `PlanState` and emits `plan.updated` events when a runtime event sink is configured.
-**Workflow Tools** (require Task tool config or explicit factory use):
-- Task -- Spawn sub-agents with custom system prompts and tool restrictions
-- TodoWrite -- Legacy shared state management for task tracking
+**Workflow Tools**:
+- UpdatePlan -- Canonical Codex-style progress tracking backed by runtime plan state
- Subagent control tools -- First-class controller adapters for SpawnAgent, ListAgents, WaitAgent, SendMessage, FollowupTask, and InterruptAgent
**Web Tools** (opt-in via config, require parallel-web):
@@ -105,7 +99,7 @@ Each tool exports `Output` for success and `Error` for errors:
2. **Execution**: AI model calls tool → `execute()` function or deferred client round-trip → sandbox operation or external API → return Output or Error
3. **Caching** (optional): `resolveCache()` wraps cacheable tools with `cached()` from cache module
4. **Model Registry** (optional): `createAgentTools()` fetches model info (pricing + context lengths) from a provider (e.g., OpenRouter). Data is shared with budget tracking and returned as `openRouterModels` in the result.
-5. **Budget** (optional): `createAgentTools()` creates a `BudgetTracker` from config, using pricing derived from model registry or manual overrides. Returns it for wiring into `onStepFinish`/`stopWhen`. Auto-wires into Task tool sub-agents.
+5. **Budget** (optional): `createAgentTools()` creates a `BudgetTracker` from config, using pricing derived from model registry or manual overrides. Returns it for wiring into `onStepFinish`/`stopWhen` and subagent controller policies.
5. **Export**: Tools surfaced via `src/index.ts` barrel export to package consumers
### Internal Dependencies
@@ -120,7 +114,7 @@ Each tool exports `Output` for success and `Error` for errors:
- `../cache/` -- Caching layer for Read, Glob, Grep, WebFetch, WebSearch
- `../runtime/` -- Runtime event sink and canonical plan state for UpdatePlan
- `../utils/debug.ts` -- Debug logging for all tools
-- `../utils/budget-tracking.ts` -- Budget tracker creation and OpenRouter model/pricing fetch (used by index.ts and task.ts)
+- `../utils/budget-tracking.ts` -- Budget tracker creation and OpenRouter model/pricing fetch (used by index.ts)
- `../skills/types.ts` -- Skill metadata for Skill tool
- `ai` -- tool(), zodSchema(), generateText(), streamText() from Vercel AI SDK
- `zod` -- Schema validation for all tool inputs
@@ -258,11 +252,10 @@ Located at `/tests/tools/`:
- `glob.test.ts` -- Pattern matching, path filtering
- `grep.test.ts` -- Content search, output modes, context lines, pagination
- `update-plan.test.ts` -- Canonical plan updates and runtime event emission
-- `todo-write.test.ts` -- Task state management
- `web-search.test.ts` -- Parallel API search (requires PARALLEL_API_KEY)
- `web-fetch.test.ts` -- Web content extraction (requires PARALLEL_API_KEY)
- `index.test.ts` -- Tool factory orchestration, caching
-- `budget-integration.test.ts` -- Budget tracker integration with createAgentTools and Task tool auto-wiring
+- `budget-integration.test.ts` -- Budget tracker integration with createAgentTools
### Running Tests
```bash
@@ -277,6 +270,6 @@ PARALLEL_API_KEY=xxx bun test tests/tools/web-search.test.ts
```
### Coverage Gaps
-- No tests for: `ask-user.ts`, `enter-plan-mode.ts`, `exit-plan-mode.ts`, `skill.ts`, `task.ts`
+- No tests for: `ask-user.ts`, `enter-plan-mode.ts`, `exit-plan-mode.ts`, `skill.ts`
- These tools require runtime integration (user interaction, plan mode state, skills, sub-agents)
- Test via examples: `/examples/basic.ts` for full agent loop
diff --git a/src/tools/glob.ts b/src/tools/glob.ts
index 046dee7..fcfe2b8 100644
--- a/src/tools/glob.ts
+++ b/src/tools/glob.ts
@@ -39,7 +39,7 @@ const GLOB_DESCRIPTION = `
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
- Returns matching file paths sorted by modification time
- Use this tool when you need to find files by name patterns
-- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
+- When you are doing open-ended research that may require multiple rounds of globbing and grepping, use subagent control tools when available
- It is always better to speculatively perform multiple searches in parallel if they are potentially useful
`;
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 263ac4e..bfafa8a 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -480,7 +480,7 @@ export { createReadTool } from "./read";
export type { SkillError, SkillOutput, SkillToolConfig } from "./skill";
export { createSkillTool } from "./skill";
-// --- Task Tool ---
+// --- Subagent Control Tools ---
export type {
CompactSubagentRecord,
InterruptAgentOutput,
@@ -500,15 +500,6 @@ export {
createSubagentControlTools,
createWaitAgentTool,
} from "./subagents";
-export type {
- SubagentEventData,
- SubagentStepEvent,
- SubagentTypeConfig,
- TaskError,
- TaskOutput,
- TaskToolConfig,
-} from "./task";
-export { createTaskTool } from "./task";
// --- UpdatePlan Tool ---
export type {
UpdatePlanError,
@@ -516,15 +507,6 @@ export type {
UpdatePlanToolConfig,
} from "./update-plan";
export { createUpdatePlanTool } from "./update-plan";
-// State/workflow tool types
-export type {
- TodoItem,
- TodoState,
- TodoWriteError,
- TodoWriteOutput,
-} from "./todo-write";
-// State/workflow tool factories (not sandbox-based)
-export { createTodoWriteTool } from "./todo-write";
export type { ExtractResult, WebFetchError, WebFetchOutput } from "./web-fetch";
export { createWebFetchTool } from "./web-fetch";
// Web tool types
diff --git a/src/tools/task.ts b/src/tools/task.ts
deleted file mode 100644
index 5b593f7..0000000
--- a/src/tools/task.ts
+++ /dev/null
@@ -1,411 +0,0 @@
-import {
- generateText,
- streamText,
- type ModelMessage,
- type LanguageModel,
- type PrepareStepFunction,
- type StepResult,
- type StopCondition,
- type UIMessageStreamWriter,
- stepCountIs,
- type Tool,
- type ToolSet,
- tool,
- zodSchema,
-} from "ai";
-import type { BudgetTracker } from "../utils/budget-tracking";
-import { z } from "zod";
-import {
- debugEnd,
- debugError,
- debugStart,
- isDebugEnabled,
- runWithDebugParent,
-} from "../utils/debug";
-
-export interface TaskOutput {
- result: string;
- usage?: {
- input_tokens: number;
- output_tokens: number;
- };
- duration_ms?: number;
- subagent?: string;
- description?: string;
-}
-
-export interface TaskError {
- error: string;
- subagent?: string;
- description?: string;
- duration_ms?: number;
-}
-
-const taskInputSchema = z.object({
- description: z
- .string()
- .describe("A short (3-5 word) description of the task"),
- prompt: z.string().describe("The task for the agent to perform"),
- subagent_type: z
- .string()
- .describe("The type of specialized agent to use for this task"),
- system_prompt: z
- .string()
- .nullable()
- .default(null)
- .describe(
- "Optional custom system prompt for this agent. If provided, overrides the default system prompt for the subagent type. Use this to create dynamic, specialized agents on the fly.",
- ),
- tools: z
- .array(z.string())
- .nullable()
- .default(null)
- .describe(
- "Optional list of tool names this agent can use (e.g., ['Read', 'Grep', 'WebSearch']). If provided, overrides the default tools for the subagent type. Use this to restrict or expand the agent's capabilities.",
- ),
-});
-
-type TaskInput = z.infer;
-
-const TASK_DESCRIPTION = `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.
-
-**Subagent types:**
-- Use predefined subagent_type values for common task patterns
-- For dynamic agents: provide custom system_prompt and/or tools to create a specialized agent on the fly
-
-**When NOT to use the Task tool:**
-- If you want to read a specific file path, use the Read or Glob tool instead, to find the match more quickly
-- If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly
-- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead, to find the match more quickly
-- Other tasks that are not related to the available agent types
-
-**Usage notes:**
-- Always include a short description (3-5 words) summarizing what the agent will do
-- Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
-- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
-- Provide clear, detailed prompts so the agent can work autonomously and return exactly the information you need.
-- The agent's outputs should generally be trusted
-- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
-- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.`;
-
-/** Event emitted for each step a subagent takes */
-export interface SubagentStepEvent {
- subagentType: string;
- description: string;
- step: StepResult;
-}
-
-/** Data format for streamed subagent events (appears in message.parts as type: "data-subagent") */
-export interface SubagentEventData {
- event: "start" | "tool-call" | "done" | "complete";
- subagent: string;
- description: string;
- toolName?: string;
- args?: Record;
- messages?: ModelMessage[]; // Only present on "complete" event
-}
-
-let eventCounter = 0;
-function generateEventId(): string {
- return `subagent-${Date.now()}-${++eventCounter}`;
-}
-
-export interface SubagentTypeConfig {
- /** Model to use for this subagent type */
- model?: LanguageModel;
- /** System prompt for this subagent type */
- systemPrompt?: string;
- /** Tool names this subagent can use (filters from parent tools) */
- tools?: string[];
- /** Additional tools only this subagent can use (merged with filtered tools) */
- additionalTools?: ToolSet;
- /** Stop condition(s) for this subagent (default: stepCountIs(15)). Can be a single condition or array - stops when ANY condition is met. */
- stopWhen?: StopCondition | StopCondition[];
- /** Prepare step callback for dynamic control per step */
- prepareStep?: PrepareStepFunction;
- /** Callback for each step this subagent takes */
- onStepFinish?: (step: StepResult) => void | Promise;
-}
-
-export interface TaskToolConfig {
- /** Default model for subagents */
- model: LanguageModel;
- /** All available tools that subagents can use */
- tools: ToolSet;
- /** Configuration for each subagent type */
- subagentTypes?: Record;
- /** Default stop condition(s) for subagents (default: stepCountIs(15)). Can be a single condition or array - stops when ANY condition is met. */
- defaultStopWhen?: StopCondition | StopCondition[];
- /** Default callback for each step any subagent takes */
- defaultOnStepFinish?: (event: SubagentStepEvent) => void | Promise;
- /** Optional stream writer for real-time subagent activity (uses streamText instead of generateText) */
- streamWriter?: UIMessageStreamWriter;
- /** Budget tracker — auto-wires stopWhen and onStepFinish for sub-agent cost tracking */
- budget?: BudgetTracker;
-}
-
-function filterTools(
- allTools: ToolSet,
- allowedTools?: string[],
- additionalTools?: ToolSet,
-): ToolSet {
- // Filter from parent tools if allowedTools specified
- let result: ToolSet;
- if (allowedTools) {
- result = {};
- for (const name of allowedTools) {
- if (allTools[name]) {
- result[name] = allTools[name];
- }
- }
- } else {
- result = allTools;
- }
-
- // Merge additional tools (agent-specific tools not in parent)
- if (additionalTools) {
- result = { ...result, ...additionalTools };
- }
-
- return result;
-}
-
-export function createTaskTool(
- config: TaskToolConfig,
-): Tool {
- const {
- model: defaultModel,
- tools: allTools,
- subagentTypes = {},
- defaultStopWhen,
- defaultOnStepFinish,
- streamWriter,
- budget,
- } = config;
-
- return tool({
- description: TASK_DESCRIPTION,
- inputSchema: zodSchema(taskInputSchema),
- execute: async ({
- description,
- prompt,
- subagent_type,
- system_prompt,
- tools: customTools,
- }: TaskInput): Promise => {
- const startTime = performance.now();
- const typeConfig = subagentTypes[subagent_type] || {};
- const debugId = isDebugEnabled()
- ? debugStart("task", {
- subagent_type,
- description,
- tools: [
- ...(customTools ?? typeConfig.tools ?? Object.keys(allTools)),
- ...Object.keys(typeConfig.additionalTools ?? {}),
- ],
- })
- : "";
-
- const executeTask = async (): Promise => {
- try {
- const model = typeConfig.model || defaultModel;
- // Custom tools override the type's default tools, additionalTools are merged in
- const tools = filterTools(
- allTools,
- customTools ?? typeConfig.tools,
- typeConfig.additionalTools,
- );
- // Custom system_prompt overrides the type's default
- const systemPrompt = system_prompt ?? typeConfig.systemPrompt;
-
- // Merge budget stopWhen with existing stop conditions
- const baseStopWhen =
- typeConfig.stopWhen ?? defaultStopWhen ?? stepCountIs(15);
- const effectiveStopWhen = budget
- ? [baseStopWhen, budget.stopWhen].flat()
- : baseStopWhen;
-
- // Common options for both generateText and streamText
- const commonOptions = {
- model,
- tools,
- system: systemPrompt,
- prompt,
- stopWhen: effectiveStopWhen,
- prepareStep: typeConfig.prepareStep,
- };
-
- // Use streamText if streamWriter is provided, otherwise generateText
- if (streamWriter) {
- // Emit start event
- const startId = generateEventId();
- streamWriter.write({
- type: "data-subagent",
- id: startId,
- data: {
- event: "start",
- subagent: subagent_type,
- description,
- } satisfies SubagentEventData,
- });
-
- const result = streamText({
- ...commonOptions,
- onStepFinish: async (step) => {
- // Track cost before anything else
- budget?.onStepFinish(step);
- // Stream tool calls
- if (step.toolCalls?.length) {
- for (const tc of step.toolCalls) {
- const eventId = generateEventId();
- streamWriter.write({
- type: "data-subagent",
- id: eventId,
- data: {
- event: "tool-call",
- subagent: subagent_type,
- description,
- toolName: tc.toolName,
- args: tc.input as Record,
- } satisfies SubagentEventData,
- });
- }
- }
- // Call subagent-specific callback
- await typeConfig.onStepFinish?.(step);
- // Call default callback with subagent context
- await defaultOnStepFinish?.({
- subagentType: subagent_type,
- description,
- step,
- });
- },
- });
-
- // Wait for stream to complete
- const text = await result.text;
- const usage = await result.usage;
- const response = await result.response;
-
- // Emit done event
- streamWriter.write({
- type: "data-subagent",
- id: generateEventId(),
- data: {
- event: "done",
- subagent: subagent_type,
- description,
- } satisfies SubagentEventData,
- });
-
- // Emit complete event with full messages for UI access
- streamWriter.write({
- type: "data-subagent",
- id: generateEventId(),
- data: {
- event: "complete",
- subagent: subagent_type,
- description,
- messages: response.messages,
- } satisfies SubagentEventData,
- });
-
- const durationMs = Math.round(performance.now() - startTime);
-
- return {
- result: text,
- usage:
- usage.inputTokens !== undefined &&
- usage.outputTokens !== undefined
- ? {
- input_tokens: usage.inputTokens,
- output_tokens: usage.outputTokens,
- }
- : undefined,
- duration_ms: durationMs,
- subagent: subagent_type,
- description,
- };
- }
-
- // Default: use generateText (no streaming)
- const result = await generateText({
- ...commonOptions,
- onStepFinish: async (step) => {
- // Track cost before anything else
- budget?.onStepFinish(step);
- // Call subagent-specific callback
- await typeConfig.onStepFinish?.(step);
- // Call default callback with subagent context
- await defaultOnStepFinish?.({
- subagentType: subagent_type,
- description,
- step,
- });
- },
- });
-
- const durationMs = Math.round(performance.now() - startTime);
-
- // Format usage
- const usage =
- result.usage.inputTokens !== undefined &&
- result.usage.outputTokens !== undefined
- ? {
- input_tokens: result.usage.inputTokens,
- output_tokens: result.usage.outputTokens,
- }
- : undefined;
-
- return {
- result: result.text,
- usage,
- duration_ms: durationMs,
- subagent: subagent_type,
- description,
- };
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : "Unknown error";
-
- const durationMs = Math.round(performance.now() - startTime);
- return {
- error: errorMessage,
- subagent: subagent_type,
- description,
- duration_ms: durationMs,
- };
- }
- };
-
- // Wrap in debug parent context so child tool calls are correctly attributed
- const result = await runWithDebugParent(debugId, executeTask);
-
- // Emit debug end/error outside the parent context for correct indent level
- if (debugId) {
- if ("error" in result) {
- debugError(debugId, "task", result.error);
- } else {
- debugEnd(debugId, "task", {
- output: result.result,
- summary: {
- subagent: result.subagent,
- tokens: result.usage
- ? {
- input: result.usage.input_tokens,
- output: result.usage.output_tokens,
- }
- : undefined,
- },
- duration_ms:
- result.duration_ms ?? Math.round(performance.now() - startTime),
- });
- }
- }
-
- return result;
- },
- });
-}
diff --git a/src/tools/todo-write.ts b/src/tools/todo-write.ts
deleted file mode 100644
index 8880542..0000000
--- a/src/tools/todo-write.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { tool, zodSchema } from "ai";
-import { z } from "zod";
-import {
- debugEnd,
- debugError,
- debugStart,
- isDebugEnabled,
-} from "../utils/debug";
-
-export interface TodoItem {
- content: string;
- status: "pending" | "in_progress" | "completed";
- activeForm: string;
-}
-
-export interface TodoWriteOutput {
- message: string;
- stats: {
- total: number;
- pending: number;
- in_progress: number;
- completed: number;
- };
-}
-
-export interface TodoWriteError {
- error: string;
-}
-
-const todoWriteInputSchema = z.object({
- todos: z
- .array(
- z.object({
- content: z.string().describe("The task description"),
- status: z
- .enum(["pending", "in_progress", "completed"])
- .describe("The task status"),
- activeForm: z.string().describe("Active form of the task description"),
- }),
- )
- .describe("The updated todo list"),
-});
-
-type TodoWriteInput = z.infer;
-
-export interface TodoState {
- todos: TodoItem[];
-}
-
-const TODO_WRITE_DESCRIPTION = `Use this tool to create and manage a structured task list for tracking progress. This helps organize complex tasks and gives the user visibility into your work.
-
-**When to use this tool proactively:**
-1. Complex multi-step tasks - When a task requires 3 or more distinct steps
-2. Non-trivial tasks - Tasks requiring careful planning or multiple operations
-3. User explicitly requests a todo list
-4. User provides multiple tasks - Numbered lists or comma-separated items
-5. After receiving new instructions - Immediately capture requirements as todos
-6. When starting work - Mark task as in_progress BEFORE beginning
-7. After completing - Mark as completed and add any follow-up tasks discovered
-
-**When NOT to use:**
-1. Single, straightforward tasks
-2. Trivial tasks with no organizational benefit
-3. Tasks completable in less than 3 trivial steps
-4. Purely conversational or informational requests
-
-**Task states:**
-- pending: Not yet started
-- in_progress: Currently working on (limit to ONE at a time)
-- completed: Finished successfully
-
-**Task format (both required):**
-- content: Imperative form ("Run tests", "Analyze data")
-- activeForm: Present continuous form ("Running tests", "Analyzing data")
-
-**Task management rules:**
-- Update status in real-time as you work
-- Mark complete IMMEDIATELY after finishing (don't batch)
-- Keep exactly ONE task in_progress at any time
-- ONLY mark completed when FULLY accomplished
-- If blocked/errors, keep in_progress and create new task for the blocker`;
-
-export function createTodoWriteTool(
- state: TodoState,
- onUpdate?: (todos: TodoItem[]) => void,
-) {
- return tool({
- description: TODO_WRITE_DESCRIPTION,
- inputSchema: zodSchema(todoWriteInputSchema),
- execute: async ({
- todos,
- }: TodoWriteInput): Promise => {
- const startTime = performance.now();
- const debugId = isDebugEnabled()
- ? debugStart("todo-write", {
- todoCount: todos.length,
- pending: todos.filter((t) => t.status === "pending").length,
- in_progress: todos.filter((t) => t.status === "in_progress").length,
- completed: todos.filter((t) => t.status === "completed").length,
- })
- : "";
-
- try {
- // Update the state
- state.todos = todos;
-
- // Call the update callback if provided
- if (onUpdate) {
- onUpdate(todos);
- }
-
- // Calculate stats
- const stats = {
- total: todos.length,
- pending: todos.filter((t) => t.status === "pending").length,
- in_progress: todos.filter((t) => t.status === "in_progress").length,
- completed: todos.filter((t) => t.status === "completed").length,
- };
-
- const durationMs = Math.round(performance.now() - startTime);
- if (debugId) {
- debugEnd(debugId, "todo-write", {
- summary: stats,
- duration_ms: durationMs,
- });
- }
-
- return {
- message: "Todo list updated successfully",
- stats,
- };
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : "Unknown error";
- if (debugId) debugError(debugId, "todo-write", errorMessage);
- return { error: errorMessage };
- }
- },
- });
-}
diff --git a/tests/tools/budget-integration.test.ts b/tests/tools/budget-integration.test.ts
index 5718bb7..4451073 100644
--- a/tests/tools/budget-integration.test.ts
+++ b/tests/tools/budget-integration.test.ts
@@ -1,12 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createAgentTools } from "@/tools/index";
-import { createTaskTool } from "@/tools/task";
-import {
- createBudgetTracker,
- resetOpenRouterCache,
-} from "@/utils/budget-tracking";
+import { resetOpenRouterCache } from "@/utils/budget-tracking";
import { createMockSandbox, makeStep, type MockSandbox } from "@test/helpers";
-import { MockLanguageModelV3 } from "ai/test";
/** Mock OpenRouter response with minimal valid pricing + context length data */
const MOCK_OPENROUTER_RESPONSE = {
@@ -196,115 +191,3 @@ describe("createAgentTools budget integration", () => {
expect(status.totalCostUsd).toBe(0);
});
});
-
-describe("Task tool budget auto-wiring", () => {
- /** Helper to create a mock model with known usage */
- function createMockModel(modelId = "test-model") {
- return new MockLanguageModelV3({
- modelId,
- doGenerate: async () => ({
- content: [{ type: "text" as const, text: "Done" }],
- finishReason: { unified: "stop" as const, raw: undefined },
- usage: {
- inputTokens: {
- total: 100,
- noCache: 100,
- cacheRead: undefined,
- cacheWrite: undefined,
- },
- outputTokens: { total: 50, text: 50, reasoning: undefined },
- },
- warnings: [],
- }),
- });
- }
-
- /** Helper to execute a task (asserts execute is defined) */
- function executeTask(
- taskTool: ReturnType,
- id: string,
- description: string,
- ) {
- if (!taskTool.execute) throw new Error("expected execute");
- return taskTool.execute(
- {
- description,
- prompt: `Do ${description}`,
- subagent_type: "general",
- system_prompt: null,
- tools: null,
- },
- { toolCallId: id, messages: [] },
- );
- }
-
- it("parallel task calls accumulate into a shared budget", async () => {
- const budget = createBudgetTracker(10.0, {
- modelPricing: {
- "test-model": { inputPerToken: 0.01, outputPerToken: 0.02 },
- },
- });
-
- const taskTool = createTaskTool({
- model: createMockModel(),
- tools: {},
- budget,
- });
-
- // Simulate 3 parallel task calls (orchestrator spawning concurrent subagents)
- const results = await Promise.all([
- executeTask(taskTool, "tc1", "Task 1"),
- executeTask(taskTool, "tc2", "Task 2"),
- executeTask(taskTool, "tc3", "Task 3"),
- ]);
-
- // All tasks should succeed
- for (const result of results) {
- expect(result).toHaveProperty("result");
- expect(result).not.toHaveProperty("error");
- }
-
- // Budget should reflect all 3 tasks
- const status = budget.getStatus();
- // Each task: 100 * 0.01 + 50 * 0.02 = 1.0 + 1.0 = 2.0
- // 3 tasks: 6.0 total
- expect(status.totalCostUsd).toBeCloseTo(6.0, 6);
- expect(status.stepsCompleted).toBe(3);
- expect(status.exceeded).toBe(false);
- expect(status.remainingUsd).toBeCloseTo(4.0, 6);
- });
-
- it("budget stopWhen halts subagent when global budget exceeded", async () => {
- // Budget of $3 — each step costs $2, so after 1 step the budget should not be exceeded,
- // but after 2 steps total across all tasks it should be exceeded
- const budget = createBudgetTracker(3.0, {
- modelPricing: {
- "test-model": { inputPerToken: 0.01, outputPerToken: 0.02 },
- },
- });
-
- const taskTool = createTaskTool({
- model: createMockModel(),
- tools: {},
- budget,
- });
-
- // First task: costs $2, budget not exceeded yet
- await executeTask(taskTool, "tc1", "First task");
-
- expect(budget.getStatus().totalCostUsd).toBeCloseTo(2.0, 6);
- expect(budget.getStatus().exceeded).toBe(false);
-
- // Second task: costs another $2, pushing total to $4 > $3 budget
- await executeTask(taskTool, "tc2", "Second task");
-
- // Budget should now be exceeded (total $4 > $3 limit)
- const status = budget.getStatus();
- expect(status.totalCostUsd).toBeCloseTo(4.0, 6);
- expect(status.exceeded).toBe(true);
- expect(status.stepsCompleted).toBe(2);
-
- // stopWhen should return true
- expect(budget.stopWhen({ steps: [] })).toBe(true);
- });
-});
diff --git a/tests/tools/index.test.ts b/tests/tools/index.test.ts
index 33fc85c..c823e8a 100644
--- a/tests/tools/index.test.ts
+++ b/tests/tools/index.test.ts
@@ -4,6 +4,7 @@ import {
type AgentToolsResult,
type CodemodeExecutor,
} from "@/tools/index";
+import * as bashkit from "@/index";
import type { WebFetchConfig } from "@/types";
import type { CachedTool } from "@/cache/cached";
import {
@@ -53,6 +54,11 @@ describe("createAgentTools", () => {
expect(tools.codemode).toBeUndefined();
});
+ it("does not export removed legacy Task or TodoWrite factories", () => {
+ expect("createTaskTool" in bashkit).toBe(false);
+ expect("createTodoWriteTool" in bashkit).toBe(false);
+ });
+
it("should not return planModeState by default", async () => {
const result = await createAgentTools(sandbox);
diff --git a/tests/tools/todo-write.test.ts b/tests/tools/todo-write.test.ts
deleted file mode 100644
index 649a8d9..0000000
--- a/tests/tools/todo-write.test.ts
+++ /dev/null
@@ -1,300 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from "vitest";
-import {
- createTodoWriteTool,
- type TodoState,
- type TodoItem,
- type TodoWriteOutput,
-} from "@/tools/todo-write";
-import { executeTool, assertSuccess } from "@test/helpers";
-
-describe("TodoWrite Tool", () => {
- let state: TodoState;
-
- beforeEach(() => {
- state = { todos: [] };
- });
-
- describe("basic todo management", () => {
- it("should create a new todo list", async () => {
- const tool = createTodoWriteTool(state);
- const result = await executeTool(tool, {
- todos: [
- {
- content: "Task 1",
- status: "pending",
- activeForm: "Starting task 1",
- },
- {
- content: "Task 2",
- status: "pending",
- activeForm: "Starting task 2",
- },
- ],
- });
-
- assertSuccess(result);
- expect(result.message).toContain("updated successfully");
- expect(result.stats.total).toBe(2);
- expect(result.stats.pending).toBe(2);
- expect(result.stats.in_progress).toBe(0);
- expect(result.stats.completed).toBe(0);
- });
-
- it("should update state with new todos", async () => {
- const tool = createTodoWriteTool(state);
- await executeTool(tool, {
- todos: [
- {
- content: "My task",
- status: "in_progress",
- activeForm: "Working on task",
- },
- ],
- });
-
- expect(state.todos).toHaveLength(1);
- expect(state.todos[0].content).toBe("My task");
- expect(state.todos[0].status).toBe("in_progress");
- });
-
- it("should replace all todos on update", async () => {
- state.todos = [
- { content: "Old task", status: "pending", activeForm: "Old" },
- ];
-
- const tool = createTodoWriteTool(state);
- await executeTool(tool, {
- todos: [{ content: "New task", status: "pending", activeForm: "New" }],
- });
-
- expect(state.todos).toHaveLength(1);
- expect(state.todos[0].content).toBe("New task");
- });
-
- it("should handle empty todo list", async () => {
- const tool = createTodoWriteTool(state);
- const result = await executeTool(tool, {
- todos: [],
- });
-
- assertSuccess(result);
- expect(result.stats.total).toBe(0);
- expect(state.todos).toHaveLength(0);
- });
- });
-
- describe("status tracking", () => {
- it("should track pending tasks", async () => {
- const tool = createTodoWriteTool(state);
- const result = await executeTool(tool, {
- todos: [
- { content: "Task 1", status: "pending", activeForm: "Task 1" },
- { content: "Task 2", status: "pending", activeForm: "Task 2" },
- { content: "Task 3", status: "in_progress", activeForm: "Task 3" },
- ],
- });
-
- assertSuccess(result);
- expect(result.stats.pending).toBe(2);
- });
-
- it("should track in_progress tasks", async () => {
- const tool = createTodoWriteTool(state);
- const result = await executeTool(tool, {
- todos: [
- { content: "Task 1", status: "in_progress", activeForm: "Task 1" },
- { content: "Task 2", status: "pending", activeForm: "Task 2" },
- ],
- });
-
- assertSuccess(result);
- expect(result.stats.in_progress).toBe(1);
- });
-
- it("should track completed tasks", async () => {
- const tool = createTodoWriteTool(state);
- const result = await executeTool(tool, {
- todos: [
- { content: "Task 1", status: "completed", activeForm: "Task 1" },
- { content: "Task 2", status: "completed", activeForm: "Task 2" },
- { content: "Task 3", status: "pending", activeForm: "Task 3" },
- ],
- });
-
- assertSuccess(result);
- expect(result.stats.completed).toBe(2);
- });
-
- it("should track mixed statuses", async () => {
- const tool = createTodoWriteTool(state);
- const result = await executeTool(tool, {
- todos: [
- { content: "Task 1", status: "pending", activeForm: "Task 1" },
- { content: "Task 2", status: "in_progress", activeForm: "Task 2" },
- { content: "Task 3", status: "completed", activeForm: "Task 3" },
- { content: "Task 4", status: "completed", activeForm: "Task 4" },
- ],
- });
-
- assertSuccess(result);
- expect(result.stats.total).toBe(4);
- expect(result.stats.pending).toBe(1);
- expect(result.stats.in_progress).toBe(1);
- expect(result.stats.completed).toBe(2);
- });
- });
-
- describe("onUpdate callback", () => {
- it("should call onUpdate with new todos", async () => {
- const onUpdate = vi.fn();
- const tool = createTodoWriteTool(state, onUpdate);
-
- const todos: TodoItem[] = [
- { content: "Task 1", status: "pending", activeForm: "Task 1" },
- ];
-
- await executeTool(tool, { todos });
-
- expect(onUpdate).toHaveBeenCalledTimes(1);
- expect(onUpdate).toHaveBeenCalledWith(todos);
- });
-
- it("should call onUpdate on every update", async () => {
- const onUpdate = vi.fn();
- const tool = createTodoWriteTool(state, onUpdate);
-
- await executeTool(tool, {
- todos: [{ content: "Task 1", status: "pending", activeForm: "Task 1" }],
- });
- await executeTool(tool, {
- todos: [
- { content: "Task 1", status: "in_progress", activeForm: "Task 1" },
- ],
- });
-
- expect(onUpdate).toHaveBeenCalledTimes(2);
- });
-
- it("should work without onUpdate callback", async () => {
- const tool = createTodoWriteTool(state);
-
- const result = await executeTool(tool, {
- todos: [{ content: "Task 1", status: "pending", activeForm: "Task 1" }],
- });
-
- assertSuccess(result);
- });
- });
-
- describe("state persistence", () => {
- it("should persist state across multiple calls", async () => {
- const tool = createTodoWriteTool(state);
-
- // First call
- await executeTool(tool, {
- todos: [
- { content: "Task 1", status: "pending", activeForm: "Task 1" },
- { content: "Task 2", status: "pending", activeForm: "Task 2" },
- ],
- });
-
- expect(state.todos).toHaveLength(2);
-
- // Second call - update status
- await executeTool(tool, {
- todos: [
- { content: "Task 1", status: "completed", activeForm: "Task 1" },
- { content: "Task 2", status: "in_progress", activeForm: "Task 2" },
- ],
- });
-
- expect(state.todos).toHaveLength(2);
- expect(state.todos[0].status).toBe("completed");
- expect(state.todos[1].status).toBe("in_progress");
- });
-
- it("should allow adding new todos", async () => {
- const tool = createTodoWriteTool(state);
-
- await executeTool(tool, {
- todos: [{ content: "Task 1", status: "pending", activeForm: "Task 1" }],
- });
-
- // Add another task
- await executeTool(tool, {
- todos: [
- { content: "Task 1", status: "pending", activeForm: "Task 1" },
- { content: "Task 2", status: "pending", activeForm: "Task 2" },
- ],
- });
-
- expect(state.todos).toHaveLength(2);
- });
-
- it("should allow removing todos", async () => {
- const tool = createTodoWriteTool(state);
-
- await executeTool(tool, {
- todos: [
- { content: "Task 1", status: "completed", activeForm: "Task 1" },
- { content: "Task 2", status: "pending", activeForm: "Task 2" },
- ],
- });
-
- // Remove completed task
- await executeTool(tool, {
- todos: [{ content: "Task 2", status: "pending", activeForm: "Task 2" }],
- });
-
- expect(state.todos).toHaveLength(1);
- expect(state.todos[0].content).toBe("Task 2");
- });
- });
-
- describe("todo item structure", () => {
- it("should preserve content field", async () => {
- const tool = createTodoWriteTool(state);
- await executeTool(tool, {
- todos: [
- {
- content: "Implement feature X",
- status: "pending",
- activeForm: "Implementing feature X",
- },
- ],
- });
-
- expect(state.todos[0].content).toBe("Implement feature X");
- });
-
- it("should preserve activeForm field", async () => {
- const tool = createTodoWriteTool(state);
- await executeTool(tool, {
- todos: [
- {
- content: "Run tests",
- status: "in_progress",
- activeForm: "Running tests",
- },
- ],
- });
-
- expect(state.todos[0].activeForm).toBe("Running tests");
- });
-
- it("should preserve status field", async () => {
- const tool = createTodoWriteTool(state);
- await executeTool(tool, {
- todos: [
- {
- content: "Task",
- status: "completed",
- activeForm: "Completing task",
- },
- ],
- });
-
- expect(state.todos[0].status).toBe("completed");
- });
- });
-});
diff --git a/tests/utils/budget-tracking.test.ts b/tests/utils/budget-tracking.test.ts
index d988e3a..5339cf9 100644
--- a/tests/utils/budget-tracking.test.ts
+++ b/tests/utils/budget-tracking.test.ts
@@ -727,7 +727,7 @@ describe("createBudgetTracker", () => {
});
// Simulate 10 parallel tasks each reporting a step concurrently
- // (like 10 subagents spawned by the Task tool sharing one budget)
+ // (like 10 controller-managed subagents sharing one budget)
const tasks = Array.from({ length: 10 }, () =>
Promise.resolve().then(() => {
tracker.onStepFinish(
From 0d699857ec9d302bfc4c03f0b4e8d20a206ec9e6 Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 20:32:50 -0400
Subject: [PATCH 09/20] Add subagent control panel state
---
...06-15-001-feat-subagent-foundation-plan.md | 2 +-
src/index.ts | 8 +
src/subagents/AGENTS.md | 4 +
src/subagents/control-panel.ts | 176 ++++++++++++++++++
src/subagents/controller.ts | 4 +
src/subagents/index.ts | 10 +
src/subagents/model-info.ts | 15 ++
src/subagents/registry.ts | 3 +-
src/subagents/types.ts | 7 +
tests/subagents/control-panel.test.ts | 126 +++++++++++++
tests/subagents/store.test.ts | 1 +
11 files changed, 354 insertions(+), 2 deletions(-)
create mode 100644 src/subagents/control-panel.ts
create mode 100644 src/subagents/model-info.ts
create mode 100644 tests/subagents/control-panel.test.ts
diff --git a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
index 3096906..023d2da 100644
--- a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
+++ b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
@@ -802,7 +802,7 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
- **Requirements:** R10, R11, R12, R14, R19, R23
- **Dependencies:** U1, U2, U3, U4, U6
- **Files:** `src/subagents/control-panel.ts`, `src/subagents/events.ts`, `src/subagents/index.ts`, `tests/subagents/control-panel.test.ts`, `README.md`, `docs/src/app/tools/page.tsx`
-- **Approach:** Add a projection layer that turns controller state, store records, events, and budget summaries into `SubagentControlPanelState`. Include live and terminal agents, status, profile/type, task name, nickname, last task message, parent/child relationship, budget summary, warnings, supported actions, and transcript/result references.
+- **Approach:** Add a projection layer that turns controller state, store records, events, and budget summaries into `SubagentControlPanelState`. Include live and terminal agents, status, profile/type, resolved model summary, task name, nickname, last task message, parent/child relationship, budget summary, warnings, supported actions, and transcript/result references.
- **Patterns to follow:** Codex `list_agents` status output; BashKit budget status shape in `src/utils/budget-tracking.ts`; normalized runtime event snapshots.
- **Test scenarios:** Starting a child adds it to active state; completion moves it to terminal state without dropping result references; budget updates and warnings appear; unsupported actions are absent; full transcript text is excluded by default; snapshots serialize to plain JSON; path filters produce stable subsets.
- **Verification:** Host apps can build a control panel without reaching into controller internals.
diff --git a/src/index.ts b/src/index.ts
index b7fa7b3..7efb599 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -44,6 +44,11 @@ export type {
ResolvedSubagentRunRequest,
SubagentCodemodePolicy,
SubagentCodemodeSurface,
+ SubagentControlPanelAction,
+ SubagentControlPanelAgent,
+ SubagentControlPanelOptions,
+ SubagentControlPanelState,
+ SubagentControlPanelStats,
SubagentContextPolicy,
SubagentContextPolicyInput,
SubagentController,
@@ -67,6 +72,7 @@ export type {
SubagentMessageResult,
SubagentMetadata,
SubagentMetadataPatch,
+ SubagentModelInfo,
SubagentPath,
SubagentPolicyState,
SubagentProfileDefaults,
@@ -110,7 +116,9 @@ export {
createSubagentTranscriptRef,
createStaticSubagentRunner,
createSubagentController,
+ createSubagentControlPanelState,
createSubagentId,
+ createSubagentModelInfo,
createSubagentProfileRegistry,
createSubagentRegistry,
describeSubagentProfile,
diff --git a/src/subagents/AGENTS.md b/src/subagents/AGENTS.md
index 8365528..e644be7 100644
--- a/src/subagents/AGENTS.md
+++ b/src/subagents/AGENTS.md
@@ -12,6 +12,7 @@ Foundation for controller-managed subagents. This module owns identity, path res
| `status.ts` | Status helpers and terminal-state checks |
| `profiles.ts` | Profile registry factory and profile resolution |
| `profile-descriptions.ts` | Model-visible profile description generation |
+| `model-info.ts` | Serializable model summary helpers for metadata and host snapshots |
| `tool-filter.ts` | Tool allowlist filtering plus denied-tool reject/hide policy wrappers |
| `context-inheritance.ts` | Parent-to-child message inheritance for `none`, `all`, and bounded recent-turn policies |
| `tool-surface.ts` | Profile-scoped child tool surface construction with Codemode quarantine |
@@ -24,6 +25,7 @@ Foundation for controller-managed subagents. This module owns identity, path res
| `runner.ts` | Runner capability helpers and fake test runner utilities |
| `execution-limits.ts` | Active/total/depth/mailbox/wait limit policy |
| `cost-control.ts` | Budget-backed policy checks |
+| `control-panel.ts` | Host-facing serializable control panel state projection |
| `controller.ts` | Subagent controller orchestration |
| `index.ts` | Barrel exports |
@@ -37,6 +39,8 @@ The default foundation does not put subagent methods on `Sandbox`. Child agents
Profile tool policy is allowlist-first. `allowedTools` narrows the child surface. `deniedTools` defaults to execution-time rejection, so denied tools stay visible and return `{ error: string }` when called. Profiles can explicitly set `deniedBehavior: "hide"` when the denied tool names should be removed from direct and Codemode inner surfaces.
+Control panel state is a normalized host projection, not a UI framework. It includes each agent's resolved profile model summary, status, supported actions, result/transcript refs, recent events, and budget warnings while excluding full child result/transcript text.
+
## Design Rules
- Keep tool schemas out of this module. Model-facing tools adapt to these core contracts.
diff --git a/src/subagents/control-panel.ts b/src/subagents/control-panel.ts
new file mode 100644
index 0000000..7138880
--- /dev/null
+++ b/src/subagents/control-panel.ts
@@ -0,0 +1,176 @@
+import type { BudgetStatus } from "../utils/budget-tracking";
+import { isActiveSubagentStatus, isTerminalSubagentStatus } from "./status";
+import type {
+ SubagentEvent,
+ SubagentMetadata,
+ SubagentModelInfo,
+ SubagentRecord,
+ SubagentRunnerCapabilities,
+ SubagentStatus,
+ SubagentUsage,
+} from "./types";
+
+export type SubagentControlPanelAction =
+ | "wait"
+ | "message"
+ | "followup"
+ | "interrupt";
+
+export interface SubagentControlPanelAgent {
+ agent_id: string;
+ task_name: string | null;
+ profile: string;
+ nickname: string | null;
+ model: SubagentModelInfo;
+ status: SubagentStatus;
+ parent_id: string | null;
+ depth: number;
+ last_task_message: string | null;
+ created_at: string;
+ updated_at: string;
+ usage?: SubagentUsage;
+ result_ref?: string;
+ transcript_ref?: string;
+ error?: string;
+ supported_actions: SubagentControlPanelAction[];
+}
+
+export interface SubagentControlPanelStats {
+ total: number;
+ active: number;
+ terminal: number;
+ completed: number;
+ failed: number;
+ interrupted: number;
+}
+
+export interface SubagentControlPanelState {
+ active_agents: SubagentControlPanelAgent[];
+ terminal_agents: SubagentControlPanelAgent[];
+ recent_events: SubagentEvent[];
+ stats: SubagentControlPanelStats;
+ budget?: BudgetStatus;
+ warnings: string[];
+}
+
+export interface SubagentControlPanelOptions {
+ records: readonly SubagentRecord[];
+ capabilities?: Partial;
+ budget?: BudgetStatus;
+ recentEventLimit?: number;
+}
+
+function supportedActions(
+ metadata: SubagentMetadata,
+ capabilities: Partial,
+): SubagentControlPanelAction[] {
+ const actions: SubagentControlPanelAction[] = ["wait"];
+ if (!isActiveSubagentStatus(metadata.status)) return actions;
+
+ actions.push("message");
+ if (capabilities.followup) actions.push("followup");
+ if (capabilities.interrupt) actions.push("interrupt");
+ return actions;
+}
+
+function compactAgent(
+ record: SubagentRecord,
+ capabilities: Partial,
+): SubagentControlPanelAgent {
+ const { metadata, result } = record;
+ return {
+ agent_id: metadata.agent_id,
+ task_name: metadata.task_name,
+ profile: metadata.profile,
+ nickname: metadata.nickname,
+ model: metadata.model,
+ status: metadata.status,
+ parent_id: metadata.parent_id,
+ depth: metadata.depth,
+ last_task_message: metadata.last_task_message,
+ created_at: metadata.created_at,
+ updated_at: metadata.updated_at,
+ usage: metadata.usage,
+ result_ref: metadata.result_ref,
+ transcript_ref: metadata.transcript_ref,
+ error: result?.error,
+ supported_actions: supportedActions(metadata, capabilities),
+ };
+}
+
+function statsFor(
+ records: readonly SubagentRecord[],
+): SubagentControlPanelStats {
+ let active = 0;
+ let completed = 0;
+ let failed = 0;
+ let interrupted = 0;
+
+ for (const record of records) {
+ const status = record.metadata.status;
+ if (isActiveSubagentStatus(status)) active += 1;
+ if (status === "completed") completed += 1;
+ if (status === "failed") failed += 1;
+ if (status === "interrupted") interrupted += 1;
+ }
+
+ return {
+ total: records.length,
+ active,
+ terminal: completed + failed + interrupted,
+ completed,
+ failed,
+ interrupted,
+ };
+}
+
+function warningsFor(budget: BudgetStatus | undefined): string[] {
+ if (!budget) return [];
+
+ const warnings: string[] = [];
+ if (budget.exceeded) warnings.push("Budget exceeded");
+ if (budget.unpricedSteps > 0) {
+ warnings.push(`${budget.unpricedSteps} unpriced step(s)`);
+ }
+ return warnings;
+}
+
+function recentEvents(
+ records: readonly SubagentRecord[],
+ limit: number,
+): SubagentEvent[] {
+ return records
+ .flatMap((record) => record.events)
+ .sort((left, right) => left.timestamp.localeCompare(right.timestamp))
+ .slice(-limit)
+ .map((event) => ({ ...event, payload: event.payload }));
+}
+
+export function createSubagentControlPanelState(
+ options: SubagentControlPanelOptions,
+): SubagentControlPanelState {
+ const capabilities = options.capabilities ?? {};
+ const activeAgents: SubagentControlPanelAgent[] = [];
+ const terminalAgents: SubagentControlPanelAgent[] = [];
+
+ for (const record of options.records) {
+ const agent = compactAgent(record, capabilities);
+ if (isTerminalSubagentStatus(record.metadata.status)) {
+ terminalAgents.push(agent);
+ } else {
+ activeAgents.push(agent);
+ }
+ }
+
+ return {
+ active_agents: activeAgents,
+ terminal_agents: terminalAgents,
+ recent_events: recentEvents(
+ options.records,
+ options.recentEventLimit ?? 25,
+ ),
+ stats: statsFor(options.records),
+ budget: options.budget,
+ warnings: warningsFor(options.budget),
+ };
+}
diff --git a/src/subagents/controller.ts b/src/subagents/controller.ts
index e1e0a9f..f48d674 100644
--- a/src/subagents/controller.ts
+++ b/src/subagents/controller.ts
@@ -2,6 +2,7 @@ import { checkSubagentCostPolicy } from "./cost-control";
import { clampSubagentWaitTimeout } from "./execution-limits";
import { subagentEventToRuntimeEvent } from "./events";
import { createMailboxMessage } from "./mailbox";
+import { createSubagentModelInfo } from "./model-info";
import { createSubagentProfileRegistry } from "./profiles";
import { createSubagentRegistry } from "./registry";
import { isActiveSubagentStatus, isTerminalSubagentStatus } from "./status";
@@ -272,9 +273,11 @@ export function createSubagentController(
taskName: request.task_name,
profile: profile.name,
nickname: profile.nickname,
+ model: createSubagentModelInfo(profile.model),
parentId: request.parent_id ?? null,
depth,
lastTaskMessage: request.task,
+ metadata: request.metadata,
});
if (hasError(metadata)) return metadata;
@@ -294,6 +297,7 @@ export function createSubagentController(
status: metadata.status,
profile: metadata.profile,
nickname: metadata.nickname,
+ model: metadata.model,
};
},
diff --git a/src/subagents/index.ts b/src/subagents/index.ts
index 0c78778..74624a3 100644
--- a/src/subagents/index.ts
+++ b/src/subagents/index.ts
@@ -25,6 +25,15 @@ export {
type SubagentToolSurface,
type SubagentToolSurfaceConfig,
} from "./tool-surface";
+export {
+ createSubagentControlPanelState,
+ type SubagentControlPanelAction,
+ type SubagentControlPanelAgent,
+ type SubagentControlPanelOptions,
+ type SubagentControlPanelState,
+ type SubagentControlPanelStats,
+} from "./control-panel";
+export { createSubagentModelInfo } from "./model-info";
export {
compactSubagentResult,
createSubagentResultRef,
@@ -88,6 +97,7 @@ export type {
SubagentMessageResult,
SubagentMetadata,
SubagentMetadataPatch,
+ SubagentModelInfo,
SubagentPath,
SubagentPolicyState,
SubagentProfileDefaults,
diff --git a/src/subagents/model-info.ts b/src/subagents/model-info.ts
new file mode 100644
index 0000000..f3e40fd
--- /dev/null
+++ b/src/subagents/model-info.ts
@@ -0,0 +1,15 @@
+import type { LanguageModel } from "ai";
+import type { SubagentModelInfo } from "./types";
+
+export function createSubagentModelInfo(
+ model: LanguageModel | undefined,
+): SubagentModelInfo {
+ if (typeof model === "object" && model !== null && "modelId" in model) {
+ const modelId = (model as { modelId?: unknown }).modelId;
+ if (typeof modelId === "string") return { id: modelId };
+ }
+
+ return {
+ id: null,
+ };
+}
diff --git a/src/subagents/registry.ts b/src/subagents/registry.ts
index a6275ef..2f1d045 100644
--- a/src/subagents/registry.ts
+++ b/src/subagents/registry.ts
@@ -33,13 +33,14 @@ export function createSubagentRegistry(): SubagentRegistry {
task_name: normalizedPath,
profile: request.profile,
nickname: request.nickname,
+ model: request.model ?? { id: null },
status: "pending",
parent_id: request.parentId ?? null,
depth: request.depth,
last_task_message: request.lastTaskMessage,
created_at: now,
updated_at: now,
- metadata: {},
+ metadata: request.metadata ?? {},
};
records.set(metadata.agent_id, metadata);
diff --git a/src/subagents/types.ts b/src/subagents/types.ts
index 68c49f5..a63d25a 100644
--- a/src/subagents/types.ts
+++ b/src/subagents/types.ts
@@ -36,6 +36,10 @@ export interface SubagentUsage {
outputTokens?: number;
}
+export interface SubagentModelInfo {
+ id: string | null;
+}
+
export interface SubagentRunResult {
agent_id: SubagentId;
task_name: SubagentPath | null;
@@ -114,6 +118,7 @@ export interface SubagentMetadata {
task_name: SubagentPath | null;
profile: string;
nickname: string | null;
+ model: SubagentModelInfo;
status: SubagentStatus;
parent_id: SubagentId | null;
depth: number;
@@ -274,6 +279,7 @@ export type SubagentSpawnResult = SubagentHandle & {
status: SubagentStatus;
profile: string;
nickname: string | null;
+ model: SubagentModelInfo;
};
export interface SubagentWaitRequest {
@@ -379,6 +385,7 @@ export interface SubagentReservationRequest {
taskName?: string | null;
profile: string;
nickname: string | null;
+ model?: SubagentModelInfo;
parentId?: SubagentId | null;
depth: number;
lastTaskMessage: string;
diff --git a/tests/subagents/control-panel.test.ts b/tests/subagents/control-panel.test.ts
new file mode 100644
index 0000000..1476441
--- /dev/null
+++ b/tests/subagents/control-panel.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from "vitest";
+import type { LanguageModel } from "ai";
+import {
+ createInMemorySubagentStore,
+ createStaticSubagentRunner,
+ createSubagentControlPanelState,
+ createSubagentController,
+ type SubagentRunner,
+} from "@/subagents";
+
+function model(modelId: string): LanguageModel {
+ return { modelId } as LanguageModel;
+}
+
+async function tick(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+describe("createSubagentControlPanelState", () => {
+ it("projects active agents with model identity and supported actions", async () => {
+ const store = createInMemorySubagentStore();
+ const runner: SubagentRunner = {
+ capabilities: { interrupt: true, followup: true },
+ async run(request) {
+ await request.callbacks.onStatus("running");
+ return new Promise(() => undefined);
+ },
+ };
+ const controller = createSubagentController({
+ store,
+ runner,
+ profiles: [
+ {
+ name: "researcher",
+ model: model("cheap-research-model"),
+ },
+ ],
+ });
+
+ const spawned = await controller.spawn({
+ task: "Research auth",
+ profile: "researcher",
+ task_name: "research/auth",
+ });
+ if ("error" in spawned) throw new Error(spawned.error);
+ await tick();
+
+ const state = createSubagentControlPanelState({
+ records: await store.list(),
+ capabilities: runner.capabilities,
+ });
+
+ expect(state.active_agents).toHaveLength(1);
+ expect(state.active_agents[0]).toMatchObject({
+ agent_id: spawned.agent_id,
+ task_name: "research/auth",
+ profile: "researcher",
+ model: { id: "cheap-research-model" },
+ supported_actions: ["wait", "message", "followup", "interrupt"],
+ });
+ expect(state.stats).toMatchObject({ total: 1, active: 1, terminal: 0 });
+ });
+
+ it("projects terminal agents without embedding result text", async () => {
+ const store = createInMemorySubagentStore();
+ const controller = createSubagentController({
+ store,
+ runner: createStaticSubagentRunner({
+ status: "completed",
+ result: "full child result should stay out of the panel",
+ }),
+ profiles: [{ name: "worker", model: model("worker-model") }],
+ });
+
+ const spawned = await controller.spawn({ task: "Build report" });
+ if ("error" in spawned) throw new Error(spawned.error);
+ await controller.wait({ agent: spawned.agent_id, timeoutMs: 500 });
+
+ const state = createSubagentControlPanelState({
+ records: await store.list(),
+ });
+
+ expect(state.active_agents).toHaveLength(0);
+ expect(state.terminal_agents).toHaveLength(1);
+ expect(state.terminal_agents[0]).toMatchObject({
+ status: "completed",
+ model: { id: "worker-model" },
+ supported_actions: ["wait"],
+ });
+ expect("result" in state.terminal_agents[0]).toBe(false);
+ expect(JSON.stringify(state)).not.toContain("full child result");
+ });
+
+ it("includes recent events and budget warnings in a serializable snapshot", async () => {
+ const store = createInMemorySubagentStore();
+ const controller = createSubagentController({
+ store,
+ runner: createStaticSubagentRunner({
+ status: "failed",
+ error: "child failed",
+ }),
+ });
+
+ const spawned = await controller.spawn({ task: "Fail" });
+ if ("error" in spawned) throw new Error(spawned.error);
+ await controller.wait({ agent: spawned.agent_id, timeoutMs: 500 });
+
+ const state = createSubagentControlPanelState({
+ records: await store.list(),
+ budget: {
+ totalCostUsd: 2,
+ maxUsd: 1,
+ remainingUsd: 0,
+ usagePercent: 200,
+ stepsCompleted: 1,
+ exceeded: true,
+ unpricedSteps: 1,
+ },
+ recentEventLimit: 2,
+ });
+
+ expect(state.recent_events.length).toBeLessThanOrEqual(2);
+ expect(state.warnings).toEqual(["Budget exceeded", "1 unpriced step(s)"]);
+ expect(() => JSON.stringify(state)).not.toThrow();
+ });
+});
diff --git a/tests/subagents/store.test.ts b/tests/subagents/store.test.ts
index 3da230a..5640658 100644
--- a/tests/subagents/store.test.ts
+++ b/tests/subagents/store.test.ts
@@ -8,6 +8,7 @@ function metadata(): SubagentMetadata {
task_name: "research/auth",
profile: "worker",
nickname: "worker",
+ model: { id: null },
status: "pending",
parent_id: null,
depth: 0,
From 28a7a3f9219e608d9555307d5a0d774e8021b609 Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 20:40:47 -0400
Subject: [PATCH 10/20] Flip factory to Codemode and subagent surface
---
...06-15-001-feat-subagent-foundation-plan.md | 2 +-
src/index.ts | 2 +
src/tools/AGENTS.md | 10 +-
src/tools/index.ts | 208 ++++++++++++------
src/tools/subagents/types.ts | 4 +
src/types.ts | 45 ++++
tests/tools/index.test.ts | 72 +++++-
7 files changed, 273 insertions(+), 70 deletions(-)
diff --git a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
index 023d2da..9a08638 100644
--- a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
+++ b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
@@ -813,7 +813,7 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
- **Requirements:** R1, R2, R6, R8, R9, R10, R12, R13, R15, R16, R21, R24
- **Dependencies:** U1, U2, U3, U4, U5, U6, U7
- **Files:** `src/types.ts`, `src/tools/index.ts`, `src/index.ts`, `tests/tools/index.test.ts`, `tests/context/integration.test.ts`
-- **Approach:** Reshape `createAgentTools` so Codemode config creates a Codemode-first surface. Direct tools remain as Codemode inner providers and explicit compatibility exposure. Add top-level `subagents` config for model defaults, profiles, limits, store, event sink, lifecycle hooks, context policy, and Codemode executor/config. Pass already-wrapped tools into Codemode and the controller.
+- **Approach:** Reshape `createAgentTools` so Codemode config creates a Codemode-first surface. Direct tools remain as Codemode inner providers and explicit compatibility exposure through `directTools: "legacy"`. Add top-level `subagents` config for model defaults, profiles, limits, store, event sink, lifecycle hooks, context policy, and Codemode executor/config. Pass already-wrapped tools into Codemode and the controller.
- **Patterns to follow:** Config-presence pattern for `webSearch`, `webFetch`, `skill`, `codemode`, and `budget`; `context.extraTools` wrapping order in `src/tools/index.ts`; `selectCodemodeTools` safety filtering in `src/tools/codemode.ts`.
- **Test scenarios:** Codemode config creates Codemode-first parent surface; direct tool exposure requires explicit compatibility config; `subagents` creates control tools; missing model/executor returns clear construction error; context-wrapped tools are inherited; budget enters the controller; max concurrent agents rejects excess spawns; profile restrictions apply to direct tools and Codemode providers; exported types are available from `src/index.ts`.
- **Verification:** Factory tests describe Codemode-default, subagent-default, context-layer, profile-restricted Codemode, and legacy-adapter cases.
diff --git a/src/index.ts b/src/index.ts
index 7efb599..15eee3f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -308,11 +308,13 @@ export type {
BudgetConfig,
CacheConfig,
ContextConfig,
+ DirectToolExposure,
ModelRegistryConfig,
ModelRegistryProvider,
PricingProvider,
RuntimeConfig,
SkillConfig,
+ SubagentConfig,
ToolConfig,
WebFetchConfig,
WebSearchConfig,
diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md
index 5223082..9dad086 100644
--- a/src/tools/AGENTS.md
+++ b/src/tools/AGENTS.md
@@ -55,11 +55,12 @@ Each tool exports `Output` for success and `Error` for errors:
- Web tools: `WebSearchOutput | WebSearchError`, `WebFetchOutput | WebFetchError`
### Configuration Types
-- `AgentConfig` -- Top-level config for createAgentTools()
+- `AgentConfig` -- Top-level config for createAgentTools(), including `directTools` and `subagents`
- `ToolConfig` -- Per-tool config (timeout, allowedPaths, maxFileSize, etc.)
- `AskUserConfig` -- AskUser AI SDK tool options
- `SkillConfig` -- Skill metadata and sandbox
- `CodemodeConfig` -- Optional Cloudflare Codemode adapter config (executor, tool filters, extra tools). Executor typing follows Cloudflare Codemode / AI SDK v6.
+- `SubagentConfig` -- Optional controller-backed subagent tools/configuration
- `ModelRegistryConfig` -- Model registry config (provider, apiKey) for fetching model info
- `BudgetConfig` -- Budget tracking config (maxUsd, modelPricing; pricing provider via top-level `modelRegistry`)
- `WebSearchConfig` / `WebFetchConfig` -- Web tool API keys and providers
@@ -80,14 +81,14 @@ Each tool exports `Output` for success and `Error` for errors:
- Skill -- Load specialized instructions from SKILL.md files
**Code Orchestration** (opt-in via config):
-- codemode -- A Cloudflare Codemode tool that lets the model write code to orchestrate a filtered set of BashKit tools, codemode-only extra tools, and optional named providers. Client-intervention tools (`AskUser`, `EnterPlanMode`, `ExitPlanMode`), tools without `execute`, and tools with `needsApproval` are excluded from every inner tool set. Thrown codemode execution failures are converted to `{ error }` tool results like other BashKit tools.
+- codemode -- A Cloudflare Codemode tool that lets the model write code to orchestrate a filtered set of BashKit tools, codemode-only extra tools, and optional named providers. When `codemode` is configured, `createAgentTools` defaults the parent-visible coding surface to Codemode and keeps direct coding/file tools as inner providers. Use `directTools: "legacy"` to expose the old direct parent surface alongside Codemode. Client-intervention tools (`AskUser`, `EnterPlanMode`, `ExitPlanMode`), tools without `execute`, and tools with `needsApproval` are excluded from every inner tool set. Thrown codemode execution failures are converted to `{ error }` tool results like other BashKit tools.
**Progress Tools** (default):
- UpdatePlan -- Canonical Codex-style checklist/progress tool. Updates runtime `PlanState` and emits `plan.updated` events when a runtime event sink is configured.
**Workflow Tools**:
- UpdatePlan -- Canonical Codex-style progress tracking backed by runtime plan state
-- Subagent control tools -- First-class controller adapters for SpawnAgent, ListAgents, WaitAgent, SendMessage, FollowupTask, and InterruptAgent
+- Subagent control tools -- First-class controller adapters for SpawnAgent, ListAgents, WaitAgent, SendMessage, FollowupTask, and InterruptAgent. Enabled through top-level `subagents` config and backed by a shared controller/store/runner.
**Web Tools** (opt-in via config, require parallel-web):
- WebSearch -- Search via Parallel API
@@ -100,7 +101,8 @@ Each tool exports `Output` for success and `Error` for errors:
3. **Caching** (optional): `resolveCache()` wraps cacheable tools with `cached()` from cache module
4. **Model Registry** (optional): `createAgentTools()` fetches model info (pricing + context lengths) from a provider (e.g., OpenRouter). Data is shared with budget tracking and returned as `openRouterModels` in the result.
5. **Budget** (optional): `createAgentTools()` creates a `BudgetTracker` from config, using pricing derived from model registry or manual overrides. Returns it for wiring into `onStepFinish`/`stopWhen` and subagent controller policies.
-5. **Export**: Tools surfaced via `src/index.ts` barrel export to package consumers
+6. **Surface Split**: policy-wrapped `innerTools` feed Codemode and subagent runners. Parent-visible tools are Codemode-first when `codemode` is configured, with direct exposure only through `directTools: "legacy"`.
+7. **Export**: Tools surfaced via `src/index.ts` barrel export to package consumers
### Internal Dependencies
diff --git a/src/tools/index.ts b/src/tools/index.ts
index bfafa8a..ab67f2e 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -8,6 +8,15 @@ import { createRuntimeEventLayer } from "../context/runtime-events";
import type { Sandbox } from "../sandbox/interface";
import type { AgentConfig, CacheConfig } from "../types";
import { DEFAULT_CONFIG } from "../types";
+import {
+ createAiSdkSubagentRunner,
+ createInMemorySubagentStore,
+ createSubagentControlPanelState,
+ createSubagentController,
+ type SubagentController,
+ type SubagentControlPanelState,
+ type SubagentStore,
+} from "../subagents";
import {
createBudgetTracker,
fetchOpenRouterModels,
@@ -27,6 +36,7 @@ import { createPatchTool } from "./patch";
import { createReadTool } from "./read";
import { createSkillTool } from "./skill";
import { createPlanState, type PlanState } from "../runtime";
+import { createSubagentControlTools } from "./subagents";
import { createUpdatePlanTool } from "./update-plan";
import { createWebFetchTool } from "./web-fetch";
import { createWebSearchTool } from "./web-search";
@@ -136,6 +146,12 @@ export interface AgentToolsResult {
planState: PlanState;
/** Budget tracker (only present when budget config is set) */
budget?: BudgetTracker;
+ /** Subagent controller (only present when subagents config is set) */
+ subagentController?: SubagentController;
+ /** Subagent store used by the controller (only present when subagents config is set) */
+ subagentStore?: SubagentStore;
+ /** Returns a serializable control-panel snapshot for host UIs. */
+ getSubagentControlPanelState?: () => Promise;
/** Model info from OpenRouter (only present when modelRegistry or budget pricingProvider is configured) */
openRouterModels?: Map;
/** Context layers applied to tools. Use with applyContextLayers() for late-added tools. */
@@ -191,8 +207,59 @@ export async function createAgentTools(
: undefined;
const planState = createPlanState(config?.runtime?.initialPlan);
- const tools: ToolSet = {
- // Core sandbox tools (always included)
+ // Fetch model info from provider before budget so both can share the data.
+ let openRouterModels: Map | undefined;
+
+ const shouldFetchOpenRouter =
+ config?.modelRegistry?.provider === "openRouter" ||
+ config?.budget?.pricingProvider === "openRouter";
+
+ if (shouldFetchOpenRouter) {
+ const apiKey = config?.modelRegistry?.apiKey ?? config?.budget?.apiKey;
+
+ try {
+ openRouterModels = await fetchOpenRouterModels(apiKey);
+ } catch (err) {
+ // Only fatal if budget needs it and has no modelPricing fallback
+ if (config?.budget && !config.budget.modelPricing) {
+ throw new Error(
+ `[bashkit] Failed to fetch OpenRouter pricing and no modelPricing overrides provided. ` +
+ `Either provide modelPricing in your budget config or ensure network access to OpenRouter. ` +
+ `Original error: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }
+ }
+
+ // Derive pricing-only map from models map for budget tracker
+ let openRouterPricing: Map | undefined;
+ if (openRouterModels) {
+ openRouterPricing = new Map(
+ [...openRouterModels].map(([k, v]) => [k, v.pricing]),
+ );
+ }
+
+ // Create budget tracker if configured
+ let budget: BudgetTracker | undefined;
+ if (config?.budget) {
+ const { modelPricing, maxUsd } = config.budget;
+
+ // Validate: at least one pricing source required
+ // modelRegistry, pricingProvider, or modelPricing
+ if (!openRouterPricing && !modelPricing) {
+ throw new Error(
+ "[bashkit] Budget requires either modelRegistry, pricingProvider, or modelPricing.",
+ );
+ }
+
+ budget = createBudgetTracker(maxUsd, {
+ modelPricing,
+ openRouterPricing,
+ });
+ }
+
+ const innerTools: ToolSet = {
+ // Core sandbox tools
Bash: createBashTool(sandbox, toolsConfig.Bash),
Read: createReadTool(sandbox, toolsConfig.Read),
Write: createWriteTool(sandbox, toolsConfig.Write),
@@ -208,14 +275,14 @@ export async function createAgentTools(
// Add AskUser tool if configured
if (config?.askUser) {
- tools.AskUser = createAskUserTool(
+ innerTools.AskUser = createAskUserTool(
config.askUser === true ? undefined : config.askUser,
);
}
// Add Patch tool if configured
if (config?.patch) {
- tools.Patch = createPatchTool(
+ innerTools.Patch = createPatchTool(
sandbox,
config.patch === true ? undefined : config.patch,
);
@@ -223,13 +290,13 @@ export async function createAgentTools(
// Add plan mode tools if configured
if (planModeState) {
- tools.EnterPlanMode = createEnterPlanModeTool(planModeState);
- tools.ExitPlanMode = createExitPlanModeTool();
+ innerTools.EnterPlanMode = createEnterPlanModeTool(planModeState);
+ innerTools.ExitPlanMode = createExitPlanModeTool();
}
// Add Skill tool if configured
if (config?.skill) {
- tools.Skill = createSkillTool({
+ innerTools.Skill = createSkillTool({
skills: config.skill.skills,
sandbox,
onActivate: config.skill.onActivate,
@@ -238,26 +305,26 @@ export async function createAgentTools(
// Add web tools if configured
if (config?.webSearch) {
- tools.WebSearch = createWebSearchTool(config.webSearch);
+ innerTools.WebSearch = createWebSearchTool(config.webSearch);
}
if (config?.webFetch) {
- tools.WebFetch = createWebFetchTool(config.webFetch);
+ innerTools.WebFetch = createWebFetchTool(config.webFetch);
}
// Merge extra tools from context config
if (config?.context?.extraTools) {
for (const [name, extraTool] of Object.entries(config.context.extraTools)) {
- (tools as Record)[name] = extraTool;
+ (innerTools as Record)[name] = extraTool;
}
}
// Apply caching if configured (inner wrapper — cache sits inside context)
const cacheConfig = resolveCache(config?.cache);
if (cacheConfig.store) {
- for (const [name, tool] of Object.entries(tools)) {
+ for (const [name, tool] of Object.entries(innerTools)) {
if (cacheConfig.enabled.has(name)) {
// Type assertion needed because cached() adds methods that ToolSet allows
- (tools as Record)[name] = cached(tool, name, {
+ (innerTools as Record)[name] = cached(tool, name, {
store: cacheConfig.store,
ttl: cacheConfig.ttl,
debug: cacheConfig.debug,
@@ -310,12 +377,30 @@ export async function createAgentTools(
// Apply all layers to all tools
if (contextLayers.length > 0) {
- const wrapped = applyContextLayers(tools, contextLayers);
+ const wrapped = applyContextLayers(innerTools, contextLayers);
for (const [name, wrappedTool] of Object.entries(wrapped)) {
- (tools as Record)[name] = wrappedTool;
+ (innerTools as Record)[name] = wrappedTool;
}
}
+ const directToolsExposure =
+ config?.directTools ?? (config?.codemode ? "codemode-only" : "legacy");
+ const tools: ToolSet =
+ directToolsExposure === "legacy" ? { ...innerTools } : {};
+ if (directToolsExposure === "codemode-only") {
+ tools.UpdatePlan = innerTools.UpdatePlan;
+ if (innerTools.AskUser) tools.AskUser = innerTools.AskUser;
+ if (innerTools.EnterPlanMode)
+ tools.EnterPlanMode = innerTools.EnterPlanMode;
+ if (innerTools.ExitPlanMode) tools.ExitPlanMode = innerTools.ExitPlanMode;
+ if (innerTools.Skill) tools.Skill = innerTools.Skill;
+ }
+ let subagentStore: SubagentStore | undefined;
+ let subagentController: SubagentController | undefined;
+ let subagentRunnerCapabilities:
+ | ReturnType["capabilities"]
+ | undefined;
+
// Add Cloudflare Codemode tool after cache/context wrapping so generated code
// orchestrates the same policy-wrapped tools a model would call directly.
if (config?.codemode) {
@@ -337,11 +422,11 @@ export async function createAgentTools(
};
const { name, tool: rawCodemodeTool } = await createCodemodeTool(
- tools,
+ innerTools,
codemodeConfig,
);
- if (tools[name]) {
+ if (tools[name] || innerTools[name]) {
throw new Error(
`[bashkit] Codemode tool name "${name}" conflicts with an existing tool.`,
);
@@ -355,55 +440,39 @@ export async function createAgentTools(
tools[name] = codemodeTool;
}
- // Fetch model info from provider (hoisted before budget so both can share the data)
- let openRouterModels: Map | undefined;
-
- const shouldFetchOpenRouter =
- config?.modelRegistry?.provider === "openRouter" ||
- config?.budget?.pricingProvider === "openRouter";
-
- if (shouldFetchOpenRouter) {
- const apiKey = config?.modelRegistry?.apiKey ?? config?.budget?.apiKey;
-
- try {
- openRouterModels = await fetchOpenRouterModels(apiKey);
- } catch (err) {
- // Only fatal if budget needs it and has no modelPricing fallback
- if (config?.budget && !config.budget.modelPricing) {
- throw new Error(
- `[bashkit] Failed to fetch OpenRouter pricing and no modelPricing overrides provided. ` +
- `Either provide modelPricing in your budget config or ensure network access to OpenRouter. ` +
- `Original error: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }
- }
-
- // Derive pricing-only map from models map for budget tracker
- let openRouterPricing: Map | undefined;
- if (openRouterModels) {
- openRouterPricing = new Map(
- [...openRouterModels].map(([k, v]) => [k, v.pricing]),
- );
- }
-
- // Create budget tracker if configured
- let budget: BudgetTracker | undefined;
- if (config?.budget) {
- const { modelPricing, maxUsd } = config.budget;
-
- // Validate: at least one pricing source required
- // modelRegistry, pricingProvider, or modelPricing
- if (!openRouterPricing && !modelPricing) {
- throw new Error(
- "[bashkit] Budget requires either modelRegistry, pricingProvider, or modelPricing.",
- );
- }
-
- budget = createBudgetTracker(maxUsd, {
- modelPricing,
- openRouterPricing,
+ if (config?.subagents) {
+ subagentStore = config.subagents.store ?? createInMemorySubagentStore();
+ const runner =
+ config.subagents.runner ??
+ createAiSdkSubagentRunner({
+ ...config.subagents.runnerConfig,
+ model: config.subagents.model,
+ codemode: config.codemode,
+ });
+ subagentRunnerCapabilities = runner.capabilities;
+ subagentController = createSubagentController({
+ profiles: config.subagents.profiles,
+ defaultProfile: config.subagents.defaultProfile,
+ profileDefaults: config.subagents.profileDefaults,
+ store: subagentStore,
+ runner,
+ tools: innerTools,
+ eventSink: config.subagents.eventSink,
+ runtimeEventSink: config.runtime?.eventSink,
+ lifecycle: config.subagents.lifecycle,
+ budget,
+ cost: config.subagents.cost,
});
+ const subagentControlTools = createSubagentControlTools(
+ subagentController,
+ config.subagents.controlTools,
+ );
+ Object.assign(
+ tools,
+ contextLayers.length > 0
+ ? applyContextLayers(subagentControlTools, contextLayers)
+ : subagentControlTools,
+ );
}
return {
@@ -411,6 +480,17 @@ export async function createAgentTools(
planModeState,
planState,
budget,
+ subagentController,
+ subagentStore,
+ getSubagentControlPanelState:
+ subagentStore && subagentController
+ ? async () =>
+ createSubagentControlPanelState({
+ records: await subagentStore.list(),
+ capabilities: subagentRunnerCapabilities,
+ budget: budget?.getStatus(),
+ })
+ : undefined,
openRouterModels,
contextLayers,
};
diff --git a/src/tools/subagents/types.ts b/src/tools/subagents/types.ts
index 6ab6097..901724a 100644
--- a/src/tools/subagents/types.ts
+++ b/src/tools/subagents/types.ts
@@ -1,5 +1,6 @@
import type {
SubagentMetadata,
+ SubagentModelInfo,
SubagentRunResult,
SubagentStatus,
} from "../../subagents";
@@ -21,6 +22,7 @@ export interface CompactSubagentRecord {
task_name: string | null;
profile: string;
nickname: string | null;
+ model: SubagentModelInfo;
status: SubagentStatus;
parent_id: string | null;
depth: number;
@@ -38,6 +40,7 @@ export interface SpawnAgentOutput {
status: SubagentStatus;
profile: string;
nickname: string | null;
+ model: SubagentModelInfo;
}
export interface ListAgentsOutput {
@@ -71,6 +74,7 @@ export function compactSubagentRecord(
task_name: metadata.task_name,
profile: metadata.profile,
nickname: metadata.nickname,
+ model: metadata.model,
status: metadata.status,
parent_id: metadata.parent_id,
depth: metadata.depth,
diff --git a/src/types.ts b/src/types.ts
index e68d5fe..deb9225 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -5,7 +5,18 @@ import type { ExecutionPolicyConfig } from "./context/execution-policy";
import type { OutputPolicyConfig } from "./context/output-policy";
import type { PlanState, PlanUpdateContext, RuntimeEventSink } from "./runtime";
import type { SkillMetadata } from "./skills/types";
+import type {
+ AiSdkSubagentRunnerConfig,
+ SubagentCostPolicyInput,
+ SubagentEventSink,
+ SubagentLifecycleHooks,
+ SubagentProfileDefaults,
+ SubagentProfileInput,
+ SubagentRunner,
+ SubagentStore,
+} from "./subagents";
import type { CodemodeConfig } from "./tools/codemode";
+import type { SubagentControlToolConfig } from "./tools/subagents";
import type { ModelPricing } from "./utils/budget-tracking";
/**
@@ -195,6 +206,33 @@ export interface ContextConfig {
layers?: ContextLayer[];
}
+export type DirectToolExposure = "legacy" | "codemode-only";
+
+export interface SubagentConfig {
+ /** Default model for the in-process AI SDK subagent runner. */
+ model?: LanguageModel;
+ /** Named subagent profiles available to SpawnAgent. */
+ profiles?: SubagentProfileInput[];
+ /** Default profile name. Defaults to BashKit's worker profile. */
+ defaultProfile?: string;
+ /** Defaults layered into every profile. */
+ profileDefaults?: SubagentProfileDefaults;
+ /** Host-provided runner. If omitted, BashKit creates the in-process AI SDK runner. */
+ runner?: SubagentRunner;
+ /** Store for subagent metadata/events/mailbox/result records. */
+ store?: SubagentStore;
+ /** Subagent event sink. */
+ eventSink?: SubagentEventSink;
+ /** Lifecycle hooks for host audit, telemetry, or side effects. */
+ lifecycle?: SubagentLifecycleHooks;
+ /** Subagent execution and cost guardrails. */
+ cost?: SubagentCostPolicyInput;
+ /** Tool adapter options, such as currentAgentId for child self-target checks. */
+ controlTools?: SubagentControlToolConfig;
+ /** Extra options for the default AI SDK runner. */
+ runnerConfig?: Omit;
+}
+
export type AgentConfig = {
tools?: {
Bash?: ToolConfig;
@@ -218,6 +256,13 @@ export type AgentConfig = {
webFetch?: WebFetchConfig;
/** Include a Cloudflare Codemode tool that can orchestrate selected tools */
codemode?: CodemodeConfig;
+ /**
+ * Direct parent tool exposure. Defaults to "codemode-only" when codemode is
+ * configured and "legacy" otherwise.
+ */
+ directTools?: DirectToolExposure;
+ /** Include controller-backed subagent tools and state. */
+ subagents?: SubagentConfig;
/** Host-facing runtime state and event stream configuration. */
runtime?: RuntimeConfig;
/** Enable tool result caching */
diff --git a/tests/tools/index.test.ts b/tests/tools/index.test.ts
index c823e8a..b987c90 100644
--- a/tests/tools/index.test.ts
+++ b/tests/tools/index.test.ts
@@ -5,6 +5,7 @@ import {
type CodemodeExecutor,
} from "@/tools/index";
import * as bashkit from "@/index";
+import { createStaticSubagentRunner } from "@/subagents";
import type { WebFetchConfig } from "@/types";
import type { CachedTool } from "@/cache/cached";
import {
@@ -186,22 +187,32 @@ describe("createAgentTools", () => {
});
describe("codemode tool", () => {
- it("should include codemode when configured", async () => {
+ it("uses Codemode as the parent coding surface when configured", async () => {
+ const captured = { innerTools: [] as string[] };
const { tools } = await createAgentTools(sandbox, {
+ askUser: true,
codemode: {
executor: createExecutor(),
createCodeTool: async ({ tools: innerTools }) => {
if (Array.isArray(innerTools)) {
+ captured.innerTools = Object.keys(innerTools[0]?.tools ?? {});
const readTool = innerTools[0]?.tools.Read;
if (!readTool) throw new Error("expected Read tool");
return readTool;
}
+ captured.innerTools = Object.keys(innerTools);
return innerTools.Read;
},
},
});
expect(tools.codemode).toBeDefined();
+ expect(tools.UpdatePlan).toBeDefined();
+ expect(tools.AskUser).toBeDefined();
+ expect(tools.Read).toBeUndefined();
+ expect(tools.Bash).toBeUndefined();
+ expect(captured.innerTools).toContain("Read");
+ expect(captured.innerTools).toContain("Bash");
});
it("should not include codemode without config", async () => {
@@ -209,6 +220,65 @@ describe("createAgentTools", () => {
expect(tools.codemode).toBeUndefined();
});
+
+ it("can expose direct tools in explicit compatibility mode", async () => {
+ const { tools } = await createAgentTools(sandbox, {
+ directTools: "legacy",
+ codemode: {
+ executor: createExecutor(),
+ createCodeTool: async ({ tools: innerTools }) => {
+ if (Array.isArray(innerTools)) return innerTools[0].tools.Read;
+ return innerTools.Read;
+ },
+ },
+ });
+
+ expect(tools.codemode).toBeDefined();
+ expect(tools.Read).toBeDefined();
+ expect(tools.Bash).toBeDefined();
+ });
+ });
+
+ describe("subagent controls", () => {
+ it("adds controller-backed subagent tools and state when configured", async () => {
+ const { tools, subagentController, getSubagentControlPanelState } =
+ await createAgentTools(sandbox, {
+ subagents: {
+ runner: createStaticSubagentRunner({
+ status: "completed",
+ result: "done",
+ }),
+ profiles: [
+ {
+ name: "worker",
+ model: { modelId: "worker-model" } as WebFetchConfig["model"],
+ },
+ ],
+ },
+ });
+
+ expect(tools.SpawnAgent).toBeDefined();
+ expect(tools.ListAgents).toBeDefined();
+ expect(tools.WaitAgent).toBeDefined();
+ expect(tools.SendMessage).toBeDefined();
+ expect(tools.FollowupTask).toBeDefined();
+ expect(tools.InterruptAgent).toBeDefined();
+ expect(subagentController).toBeDefined();
+ expect(getSubagentControlPanelState).toBeDefined();
+
+ const spawned = await subagentController?.spawn({ task: "Do work" });
+ if (!spawned || "error" in spawned) throw new Error("expected spawn");
+ await subagentController?.wait({
+ agent: spawned.agent_id,
+ timeoutMs: 500,
+ });
+
+ const state = await getSubagentControlPanelState?.();
+ expect(state?.terminal_agents[0]).toMatchObject({
+ model: { id: "worker-model" },
+ status: "completed",
+ });
+ });
});
describe("web tools", () => {
From 1498ba9387335ee233a9aeedbd24a7d5abf68e8c Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 20:51:27 -0400
Subject: [PATCH 11/20] Rename inner tools to runtime tools
---
...06-15-001-feat-subagent-foundation-plan.md | 24 +++++-----
docs/src/app/codemode/page.tsx | 2 +-
src/subagents/AGENTS.md | 2 +-
src/subagents/tool-surface.ts | 4 +-
src/tools/AGENTS.md | 4 +-
src/tools/codemode.ts | 12 ++---
src/tools/index.ts | 47 ++++++++++---------
tests/tools/codemode.test.ts | 8 ++--
tests/tools/index.test.ts | 24 +++++-----
9 files changed, 64 insertions(+), 63 deletions(-)
diff --git a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
index 9a08638..ee0ba43 100644
--- a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
+++ b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
@@ -36,7 +36,7 @@ Codex has already solved the architectural split. Its multi-agent tools do not o
- Make Codemode the default model-facing coding harness.
- Make first-class subagent controls the default delegation interface.
-- Preserve direct BashKit tools as inner providers and explicit compatibility mode, not the normal public surface.
+- Preserve direct BashKit tools as runtime providers and explicit compatibility mode, not the normal public surface.
- Build a reusable `src/subagents/` architecture that library consumers can use directly without model-visible tools.
- Keep subagent execution safe through profile quarantine, shared cost controls, depth limits, concurrency limits, and bounded context inheritance.
- Expose typed state and events so hosts can build control panels without depending on internal controller objects.
@@ -71,7 +71,7 @@ Codex has already solved the architectural split. Its multi-agent tools do not o
- R10. Provide non-blocking tools for spawning, listing, waiting, messaging, follow-up tasks, and interrupting subagents.
- R11. Maintain UI/event visibility for subagent activity without forcing full child transcripts into parent model context.
- R12. Enforce cost controls across parent Codemode, child runs, nested Codemode calls, and parallel child executions.
-- R13. Use Codemode as the default orchestration tool inside subagents, with profile-level quarantine applied to Codemode inner providers.
+- R13. Use Codemode as the default orchestration tool inside subagents, with profile-level quarantine applied to Codemode runtime providers.
- R14. Provide a parent-agent control surface that exposes live state, supervision actions, budget usage, warnings, and concise activity history to host UIs.
- R15. Support lifecycle hooks for subagent start, stop, completion, failure, interruption, message delivery, and follow-up delivery.
- R16. Support controlled context inheritance so spawn can receive no parent context, full parent context, or a bounded recent-turn window.
@@ -95,7 +95,7 @@ Codex has already solved the architectural split. Its multi-agent tools do not o
- **Tools are primitives, not workflows:** `SpawnAgent`, `WaitAgent`, `SendMessage`, and `InterruptAgent` are atomic controls. Fan-out, tournament, verification, and loop-until-done patterns are composed by prompts or host workflow code.
- **Agent and UI share state:** The controller emits events and snapshots that host UIs can observe. The UI should not scrape model text to understand agent state.
- **Context is a budgeted resource:** Parent history should not silently flood every child. Spawn calls make context inheritance explicit.
-- **Quarantine is structural:** A restricted profile must not regain blocked tools through Codemode's inner providers, direct tool exposure, recursive subagents, or extra tools.
+- **Quarantine is structural:** A restricted profile must not regain blocked tools through Codemode's runtime providers, direct tool exposure, recursive subagents, or extra tools.
- **Completion is explicit:** The controller records terminal states and completion events. Parent agents should not infer completion from absence of tool calls.
- **Library boundaries stay clean:** Sandboxes execute commands and file IO. Subagents orchestrate model/tool execution. Host apps render UI.
@@ -104,11 +104,11 @@ Codex has already solved the architectural split. Its multi-agent tools do not o
## Key Technical Decisions
- KTD1. Split subagent core from tool adapters: create `src/subagents/` for profiles, identity, registry, controller, stores, events, state projections, policies, and runner contracts. Tool files call into this module.
-- KTD2. Make Codemode the default public harness: `createAgentTools` exposes a Codemode tool by default when a Codemode executor is supplied. Direct tools are inner providers and optional compatibility exposure.
+- KTD2. Make Codemode the default public harness: `createAgentTools` exposes a Codemode tool by default when a Codemode executor is supplied. Direct tools are runtime providers and optional compatibility exposure.
- KTD3. Make subagent controls the default delegation API: expose `SpawnAgent`, `ListAgents`, `SendMessage`, `FollowupTask`, `WaitAgent`, and `InterruptAgent` for parent model coordination.
- KTD4. Remove `Task` entirely in the breaking release: the one-shot delegation abstraction does not fit the controller-managed subagent model. Migrate callers to `SpawnAgent` plus `WaitAgent`.
- KTD5. Put Codemode inside subagents by default: children receive a profile-scoped Codemode tool that can orchestrate only the tools allowed by the profile.
-- KTD6. Apply quarantine at two layers: profile allowlists filter direct tools and Codemode inner providers. A restricted profile cannot regain `Write`, `Edit`, `Patch`, `Bash`, `SpawnAgent`, or `FollowupTask` through generated code.
+- KTD6. Apply quarantine at two layers: profile allowlists filter direct tools and Codemode runtime providers. A restricted profile cannot regain `Write`, `Edit`, `Patch`, `Bash`, `SpawnAgent`, or `FollowupTask` through generated code.
- KTD7. Model profiles as runtime configuration, not prompt strings: profiles resolve to model, prompt, tool policy, Codemode policy, context policy, cost policy, stop conditions, prepare-step, callbacks, and host metadata.
- KTD8. Use in-memory orchestration first with host-extensible persistence: the default store tracks live promises and terminal records in memory. A `SubagentStore` interface lets hosts persist metadata and results.
- KTD9. Treat messaging as mailbox semantics: `SendMessage` queues input, `FollowupTask` queues input and requests a turn, and `WaitAgent` observes status/mailbox changes.
@@ -220,7 +220,7 @@ flowchart TB
ParentSurface --> ParentCodemode["Codemode (default coding tool)"]
ParentSurface --> ControlTools["Subagent control tools"]
- ParentCodemode --> ParentProviders["Filtered parent inner providers"]
+ ParentCodemode --> ParentProviders["Filtered parent runtime providers"]
ControlTools --> Controller["SubagentController"]
Controller --> Registry["SubagentRegistry"]
@@ -234,7 +234,7 @@ flowchart TB
Runner --> ChildSurface["Child model tool surface"]
ChildSurface --> ChildCodemode["Profile-scoped Codemode"]
ChildSurface --> ChildDirect["Optional filtered direct tools"]
- ChildCodemode --> ChildProviders["Profile-filtered inner providers"]
+ ChildCodemode --> ChildProviders["Profile-filtered runtime providers"]
Events --> Projection["Control panel projection"]
Store --> Projection
@@ -387,8 +387,8 @@ Default behavior:
- If `codemode` is configured, expose Codemode as the primary coding tool.
- If `subagents` is configured, expose subagent control tools.
- Direct tools are hidden from the parent unless `exposeDirectTools` is configured.
-- Direct tools remain available as Codemode inner providers after safety filtering.
-- Child Codemode receives profile-filtered inner providers.
+- Direct tools remain available as Codemode runtime providers after safety filtering.
+- Child Codemode receives profile-filtered runtime providers.
### Core Controller
@@ -527,7 +527,7 @@ flowchart TB
Allowlist --> Filter
Denylist --> Filter
Filter --> Direct["Optional direct child tools"]
- Filter --> Providers["Codemode inner providers"]
+ Filter --> Providers["Codemode runtime providers"]
Providers --> ChildCodemode["Child Codemode"]
Direct --> ChildSurface["Child tool surface"]
ChildCodemode --> ChildSurface
@@ -813,7 +813,7 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
- **Requirements:** R1, R2, R6, R8, R9, R10, R12, R13, R15, R16, R21, R24
- **Dependencies:** U1, U2, U3, U4, U5, U6, U7
- **Files:** `src/types.ts`, `src/tools/index.ts`, `src/index.ts`, `tests/tools/index.test.ts`, `tests/context/integration.test.ts`
-- **Approach:** Reshape `createAgentTools` so Codemode config creates a Codemode-first surface. Direct tools remain as Codemode inner providers and explicit compatibility exposure through `directTools: "legacy"`. Add top-level `subagents` config for model defaults, profiles, limits, store, event sink, lifecycle hooks, context policy, and Codemode executor/config. Pass already-wrapped tools into Codemode and the controller.
+- **Approach:** Reshape `createAgentTools` so Codemode config creates a Codemode-first surface. Direct tools remain as Codemode runtime providers and explicit compatibility exposure through `directTools: "legacy"`. Add top-level `subagents` config for model defaults, profiles, limits, store, event sink, lifecycle hooks, context policy, and Codemode executor/config. Pass already-wrapped tools into Codemode and the controller.
- **Patterns to follow:** Config-presence pattern for `webSearch`, `webFetch`, `skill`, `codemode`, and `budget`; `context.extraTools` wrapping order in `src/tools/index.ts`; `selectCodemodeTools` safety filtering in `src/tools/codemode.ts`.
- **Test scenarios:** Codemode config creates Codemode-first parent surface; direct tool exposure requires explicit compatibility config; `subagents` creates control tools; missing model/executor returns clear construction error; context-wrapped tools are inherited; budget enters the controller; max concurrent agents rejects excess spawns; profile restrictions apply to direct tools and Codemode providers; exported types are available from `src/index.ts`.
- **Verification:** Factory tests describe Codemode-default, subagent-default, context-layer, profile-restricted Codemode, and legacy-adapter cases.
@@ -847,7 +847,7 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
- AE1. Given a host configures subagents, when the parent model receives tools, then `SpawnAgent`, `ListAgents`, `WaitAgent`, `SendMessage`, `FollowupTask`, and `InterruptAgent` are the model-visible delegation tools.
- AE2. Given a host configures Codemode, when the parent model receives tools, then Codemode is the default coding tool and direct sandbox tools are hidden unless compatibility mode requests them.
- AE3. Given the parent calls `SpawnAgent`, then it receives a stable handle and can later call `ListAgents` or `WaitAgent` without re-running the child in parent context.
-- AE4. Given a child subagent starts, then the child receives a Codemode tool whose inner providers are limited by the child's resolved profile.
+- AE4. Given a child subagent starts, then the child receives a Codemode tool whose runtime providers are limited by the child's resolved profile.
- AE5. Given two subagents run concurrently under one budget tracker, when both finish, then shared budget status includes both child usages.
- AE6. Given a read-only research profile, when it is spawned with `tools: ["Read", "Grep", "Glob"]`, then write and shell tools are unavailable directly and through Codemode-generated code.
- AE7. Given shared budget is exhausted or active-agent limit is reached, when the parent requests another spawn or follow-up task, then BashKit rejects it before starting a costly child turn.
diff --git a/docs/src/app/codemode/page.tsx b/docs/src/app/codemode/page.tsx
index 1a0e050..944a9a1 100644
--- a/docs/src/app/codemode/page.tsx
+++ b/docs/src/app/codemode/page.tsx
@@ -142,7 +142,7 @@ async () => {
Tool Selection
- Keep the inner tool set narrow. includeTools is the
+ Keep the runtime tool set narrow. includeTools is the
safest default because generated code can loop and fan out calls.
Output` for success and `Error` for errors:
- Skill -- Load specialized instructions from SKILL.md files
**Code Orchestration** (opt-in via config):
-- codemode -- A Cloudflare Codemode tool that lets the model write code to orchestrate a filtered set of BashKit tools, codemode-only extra tools, and optional named providers. When `codemode` is configured, `createAgentTools` defaults the parent-visible coding surface to Codemode and keeps direct coding/file tools as inner providers. Use `directTools: "legacy"` to expose the old direct parent surface alongside Codemode. Client-intervention tools (`AskUser`, `EnterPlanMode`, `ExitPlanMode`), tools without `execute`, and tools with `needsApproval` are excluded from every inner tool set. Thrown codemode execution failures are converted to `{ error }` tool results like other BashKit tools.
+- codemode -- A Cloudflare Codemode tool that lets the model write code to orchestrate a filtered set of BashKit tools, codemode-only extra tools, and optional named providers. When `codemode` is configured, `createAgentTools` defaults the parent-visible coding surface to Codemode and keeps direct coding/file tools as runtime providers. Use `directTools: "legacy"` to expose the old direct parent surface alongside Codemode. Client-intervention tools (`AskUser`, `EnterPlanMode`, `ExitPlanMode`), tools without `execute`, and tools with `needsApproval` are excluded from every runtime tool set. Thrown codemode execution failures are converted to `{ error }` tool results like other BashKit tools.
**Progress Tools** (default):
- UpdatePlan -- Canonical Codex-style checklist/progress tool. Updates runtime `PlanState` and emits `plan.updated` events when a runtime event sink is configured.
@@ -101,7 +101,7 @@ Each tool exports `Output` for success and `Error` for errors:
3. **Caching** (optional): `resolveCache()` wraps cacheable tools with `cached()` from cache module
4. **Model Registry** (optional): `createAgentTools()` fetches model info (pricing + context lengths) from a provider (e.g., OpenRouter). Data is shared with budget tracking and returned as `openRouterModels` in the result.
5. **Budget** (optional): `createAgentTools()` creates a `BudgetTracker` from config, using pricing derived from model registry or manual overrides. Returns it for wiring into `onStepFinish`/`stopWhen` and subagent controller policies.
-6. **Surface Split**: policy-wrapped `innerTools` feed Codemode and subagent runners. Parent-visible tools are Codemode-first when `codemode` is configured, with direct exposure only through `directTools: "legacy"`.
+6. **Surface Split**: policy-wrapped `runtimeTools` feed Codemode and subagent runners. Parent-visible tools are Codemode-first when `codemode` is configured, with direct exposure only through `directTools: "legacy"`.
7. **Export**: Tools surfaced via `src/index.ts` barrel export to package consumers
### Internal Dependencies
diff --git a/src/tools/codemode.ts b/src/tools/codemode.ts
index 6790361..e7d14d1 100644
--- a/src/tools/codemode.ts
+++ b/src/tools/codemode.ts
@@ -91,7 +91,7 @@ export interface CodemodeConfig {
excludeTools?: string[];
/** Test hook / advanced override. Defaults to @cloudflare/codemode/ai createCodeTool. */
createCodeTool?: CreateCodeTool;
- /** Observability hook for tools filtered out of the codemode inner tool set. */
+ /** Observability hook for tools filtered out of the Codemode runtime tool set. */
onToolExcluded?: (
toolName: string,
reason: CodemodeToolExclusionReason,
@@ -214,20 +214,20 @@ export async function createCodemodeTool(
): Promise<{
name: string;
tool: Tool;
- innerTools: ToolSet;
+ runtimeTools: ToolSet;
providers: CodemodeToolProvider[];
}> {
const defaultProviderTools = {
...tools,
...(config.tools ?? {}),
};
- const innerTools = selectCodemodeTools(defaultProviderTools, config);
+ const runtimeTools = selectCodemodeTools(defaultProviderTools, config);
const providers = [
- ...(Object.keys(innerTools).length > 0
+ ...(Object.keys(runtimeTools).length > 0
? [
{
name: config.namespace ?? DEFAULT_CODEMODE_TOOL_NAMESPACE,
- tools: innerTools,
+ tools: runtimeTools,
},
]
: []),
@@ -258,7 +258,7 @@ export async function createCodemodeTool(
return {
name: config.toolName ?? DEFAULT_CODEMODE_TOOL_NAME,
tool: wrapCodemodeTool(tool),
- innerTools,
+ runtimeTools,
providers,
};
}
diff --git a/src/tools/index.ts b/src/tools/index.ts
index ab67f2e..98d1900 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -258,7 +258,7 @@ export async function createAgentTools(
});
}
- const innerTools: ToolSet = {
+ const runtimeTools: ToolSet = {
// Core sandbox tools
Bash: createBashTool(sandbox, toolsConfig.Bash),
Read: createReadTool(sandbox, toolsConfig.Read),
@@ -275,14 +275,14 @@ export async function createAgentTools(
// Add AskUser tool if configured
if (config?.askUser) {
- innerTools.AskUser = createAskUserTool(
+ runtimeTools.AskUser = createAskUserTool(
config.askUser === true ? undefined : config.askUser,
);
}
// Add Patch tool if configured
if (config?.patch) {
- innerTools.Patch = createPatchTool(
+ runtimeTools.Patch = createPatchTool(
sandbox,
config.patch === true ? undefined : config.patch,
);
@@ -290,13 +290,13 @@ export async function createAgentTools(
// Add plan mode tools if configured
if (planModeState) {
- innerTools.EnterPlanMode = createEnterPlanModeTool(planModeState);
- innerTools.ExitPlanMode = createExitPlanModeTool();
+ runtimeTools.EnterPlanMode = createEnterPlanModeTool(planModeState);
+ runtimeTools.ExitPlanMode = createExitPlanModeTool();
}
// Add Skill tool if configured
if (config?.skill) {
- innerTools.Skill = createSkillTool({
+ runtimeTools.Skill = createSkillTool({
skills: config.skill.skills,
sandbox,
onActivate: config.skill.onActivate,
@@ -305,26 +305,26 @@ export async function createAgentTools(
// Add web tools if configured
if (config?.webSearch) {
- innerTools.WebSearch = createWebSearchTool(config.webSearch);
+ runtimeTools.WebSearch = createWebSearchTool(config.webSearch);
}
if (config?.webFetch) {
- innerTools.WebFetch = createWebFetchTool(config.webFetch);
+ runtimeTools.WebFetch = createWebFetchTool(config.webFetch);
}
// Merge extra tools from context config
if (config?.context?.extraTools) {
for (const [name, extraTool] of Object.entries(config.context.extraTools)) {
- (innerTools as Record)[name] = extraTool;
+ (runtimeTools as Record)[name] = extraTool;
}
}
// Apply caching if configured (inner wrapper — cache sits inside context)
const cacheConfig = resolveCache(config?.cache);
if (cacheConfig.store) {
- for (const [name, tool] of Object.entries(innerTools)) {
+ for (const [name, tool] of Object.entries(runtimeTools)) {
if (cacheConfig.enabled.has(name)) {
// Type assertion needed because cached() adds methods that ToolSet allows
- (innerTools as Record)[name] = cached(tool, name, {
+ (runtimeTools as Record)[name] = cached(tool, name, {
store: cacheConfig.store,
ttl: cacheConfig.ttl,
debug: cacheConfig.debug,
@@ -377,23 +377,24 @@ export async function createAgentTools(
// Apply all layers to all tools
if (contextLayers.length > 0) {
- const wrapped = applyContextLayers(innerTools, contextLayers);
+ const wrapped = applyContextLayers(runtimeTools, contextLayers);
for (const [name, wrappedTool] of Object.entries(wrapped)) {
- (innerTools as Record)[name] = wrappedTool;
+ (runtimeTools as Record)[name] = wrappedTool;
}
}
const directToolsExposure =
config?.directTools ?? (config?.codemode ? "codemode-only" : "legacy");
const tools: ToolSet =
- directToolsExposure === "legacy" ? { ...innerTools } : {};
+ directToolsExposure === "legacy" ? { ...runtimeTools } : {};
if (directToolsExposure === "codemode-only") {
- tools.UpdatePlan = innerTools.UpdatePlan;
- if (innerTools.AskUser) tools.AskUser = innerTools.AskUser;
- if (innerTools.EnterPlanMode)
- tools.EnterPlanMode = innerTools.EnterPlanMode;
- if (innerTools.ExitPlanMode) tools.ExitPlanMode = innerTools.ExitPlanMode;
- if (innerTools.Skill) tools.Skill = innerTools.Skill;
+ tools.UpdatePlan = runtimeTools.UpdatePlan;
+ if (runtimeTools.AskUser) tools.AskUser = runtimeTools.AskUser;
+ if (runtimeTools.EnterPlanMode)
+ tools.EnterPlanMode = runtimeTools.EnterPlanMode;
+ if (runtimeTools.ExitPlanMode)
+ tools.ExitPlanMode = runtimeTools.ExitPlanMode;
+ if (runtimeTools.Skill) tools.Skill = runtimeTools.Skill;
}
let subagentStore: SubagentStore | undefined;
let subagentController: SubagentController | undefined;
@@ -422,11 +423,11 @@ export async function createAgentTools(
};
const { name, tool: rawCodemodeTool } = await createCodemodeTool(
- innerTools,
+ runtimeTools,
codemodeConfig,
);
- if (tools[name] || innerTools[name]) {
+ if (tools[name] || runtimeTools[name]) {
throw new Error(
`[bashkit] Codemode tool name "${name}" conflicts with an existing tool.`,
);
@@ -456,7 +457,7 @@ export async function createAgentTools(
profileDefaults: config.subagents.profileDefaults,
store: subagentStore,
runner,
- tools: innerTools,
+ tools: runtimeTools,
eventSink: config.subagents.eventSink,
runtimeEventSink: config.runtime?.eventSink,
lifecycle: config.subagents.lifecycle,
diff --git a/tests/tools/codemode.test.ts b/tests/tools/codemode.test.ts
index 9117676..5971e5c 100644
--- a/tests/tools/codemode.test.ts
+++ b/tests/tools/codemode.test.ts
@@ -130,10 +130,10 @@ describe("createCodemodeTool", () => {
);
expect(result.name).toBe("codemode");
- expect(result.innerTools.Read).toBeDefined();
- expect(result.innerTools.AskUser).toBeUndefined();
+ expect(result.runtimeTools.Read).toBeDefined();
+ expect(result.runtimeTools.AskUser).toBeUndefined();
expect(result.providers).toEqual([
- { name: "bashkit", tools: result.innerTools },
+ { name: "bashkit", tools: result.runtimeTools },
]);
expect(createCodeTool).toHaveBeenCalledWith({
tools: result.providers,
@@ -328,7 +328,7 @@ describe("createAgentTools codemode config", () => {
expect(captured[0].ExitPlanMode).toBeUndefined();
});
- it("adds codemode-only extra tools to the inner tool set", async () => {
+ it("adds codemode-only extra tools to the runtime tool set", async () => {
const sandbox = createMockSandbox();
const captured: ToolSet[] = [];
diff --git a/tests/tools/index.test.ts b/tests/tools/index.test.ts
index b987c90..5ba315d 100644
--- a/tests/tools/index.test.ts
+++ b/tests/tools/index.test.ts
@@ -188,20 +188,20 @@ describe("createAgentTools", () => {
describe("codemode tool", () => {
it("uses Codemode as the parent coding surface when configured", async () => {
- const captured = { innerTools: [] as string[] };
+ const captured = { runtimeTools: [] as string[] };
const { tools } = await createAgentTools(sandbox, {
askUser: true,
codemode: {
executor: createExecutor(),
- createCodeTool: async ({ tools: innerTools }) => {
- if (Array.isArray(innerTools)) {
- captured.innerTools = Object.keys(innerTools[0]?.tools ?? {});
- const readTool = innerTools[0]?.tools.Read;
+ createCodeTool: async ({ tools: runtimeTools }) => {
+ if (Array.isArray(runtimeTools)) {
+ captured.runtimeTools = Object.keys(runtimeTools[0]?.tools ?? {});
+ const readTool = runtimeTools[0]?.tools.Read;
if (!readTool) throw new Error("expected Read tool");
return readTool;
}
- captured.innerTools = Object.keys(innerTools);
- return innerTools.Read;
+ captured.runtimeTools = Object.keys(runtimeTools);
+ return runtimeTools.Read;
},
},
});
@@ -211,8 +211,8 @@ describe("createAgentTools", () => {
expect(tools.AskUser).toBeDefined();
expect(tools.Read).toBeUndefined();
expect(tools.Bash).toBeUndefined();
- expect(captured.innerTools).toContain("Read");
- expect(captured.innerTools).toContain("Bash");
+ expect(captured.runtimeTools).toContain("Read");
+ expect(captured.runtimeTools).toContain("Bash");
});
it("should not include codemode without config", async () => {
@@ -226,9 +226,9 @@ describe("createAgentTools", () => {
directTools: "legacy",
codemode: {
executor: createExecutor(),
- createCodeTool: async ({ tools: innerTools }) => {
- if (Array.isArray(innerTools)) return innerTools[0].tools.Read;
- return innerTools.Read;
+ createCodeTool: async ({ tools: runtimeTools }) => {
+ if (Array.isArray(runtimeTools)) return runtimeTools[0].tools.Read;
+ return runtimeTools.Read;
},
},
});
From 636f7017bdb5a83af47d20ca4f379a47bdc4b130 Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 20:54:38 -0400
Subject: [PATCH 12/20] Remove parent direct tool compatibility switch
---
...06-15-001-feat-subagent-foundation-plan.md | 26 +++++++++----------
src/index.ts | 1 -
src/tools/AGENTS.md | 6 ++---
src/tools/index.ts | 7 ++---
src/types.ts | 7 -----
tests/tools/index.test.ts | 15 -----------
6 files changed, 17 insertions(+), 45 deletions(-)
diff --git a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
index ee0ba43..86590a2 100644
--- a/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
+++ b/docs/plans/2026-06-15-001-feat-subagent-foundation-plan.md
@@ -36,11 +36,11 @@ Codex has already solved the architectural split. Its multi-agent tools do not o
- Make Codemode the default model-facing coding harness.
- Make first-class subagent controls the default delegation interface.
-- Preserve direct BashKit tools as runtime providers and explicit compatibility mode, not the normal public surface.
+- Keep the factory surface binary: Codemode configured means Codemode-first; no Codemode means direct tools.
- Build a reusable `src/subagents/` architecture that library consumers can use directly without model-visible tools.
- Keep subagent execution safe through profile quarantine, shared cost controls, depth limits, concurrency limits, and bounded context inheritance.
- Expose typed state and events so hosts can build control panels without depending on internal controller objects.
-- Provide a breaking migration story from `Task` and direct tool exposure.
+- Provide a breaking migration story from `Task`, `TodoWrite`, and direct tool exposure alongside Codemode.
## Non-Goals
@@ -61,7 +61,7 @@ Codex has already solved the architectural split. Its multi-agent tools do not o
- R3. Replace `Task` as the primary subagent API with first-class subagent control tools and controller APIs.
- R4. Keep tool failures model-visible as `{ error: string }` results, except for construction-time configuration errors.
- R5. Use nullable tool input fields for optional parameters so OpenAI structured outputs remain compatible.
-- R6. Keep direct tool exposure available only as explicit compatibility or advanced configuration.
+- R6. Keep parent coding surface binary: direct tools when Codemode is absent, Codemode when Codemode is configured.
**Subagent Foundation**
@@ -362,7 +362,6 @@ The following sketches are directional contracts, not final implementation code.
export interface AgentConfig {
codemode?: CodemodeConfig;
subagents?: SubagentConfig;
- exposeDirectTools?: boolean | DirectToolExposureConfig;
budget?: BudgetConfig;
context?: ContextConfig;
}
@@ -378,15 +377,14 @@ export interface SubagentConfig {
contextInheritance?: SubagentContextPolicyInput;
lifecycle?: SubagentLifecycleHooks;
exposeControlTools?: boolean;
- legacyTask?: false | "deprecated-adapter";
}
```
Default behavior:
- If `codemode` is configured, expose Codemode as the primary coding tool.
+- If `codemode` is not configured, expose the direct BashKit tools.
- If `subagents` is configured, expose subagent control tools.
-- Direct tools are hidden from the parent unless `exposeDirectTools` is configured.
- Direct tools remain available as Codemode runtime providers after safety filtering.
- Child Codemode receives profile-filtered runtime providers.
@@ -813,10 +811,10 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
- **Requirements:** R1, R2, R6, R8, R9, R10, R12, R13, R15, R16, R21, R24
- **Dependencies:** U1, U2, U3, U4, U5, U6, U7
- **Files:** `src/types.ts`, `src/tools/index.ts`, `src/index.ts`, `tests/tools/index.test.ts`, `tests/context/integration.test.ts`
-- **Approach:** Reshape `createAgentTools` so Codemode config creates a Codemode-first surface. Direct tools remain as Codemode runtime providers and explicit compatibility exposure through `directTools: "legacy"`. Add top-level `subagents` config for model defaults, profiles, limits, store, event sink, lifecycle hooks, context policy, and Codemode executor/config. Pass already-wrapped tools into Codemode and the controller.
+- **Approach:** Reshape `createAgentTools` so Codemode config creates a Codemode-first surface. Direct tools remain as Codemode runtime providers, not parent-visible tools, when Codemode is configured. Without Codemode, `createAgentTools` exposes the direct BashKit tools. Add top-level `subagents` config for model defaults, profiles, limits, store, event sink, lifecycle hooks, context policy, and Codemode executor/config. Pass already-wrapped tools into Codemode and the controller.
- **Patterns to follow:** Config-presence pattern for `webSearch`, `webFetch`, `skill`, `codemode`, and `budget`; `context.extraTools` wrapping order in `src/tools/index.ts`; `selectCodemodeTools` safety filtering in `src/tools/codemode.ts`.
-- **Test scenarios:** Codemode config creates Codemode-first parent surface; direct tool exposure requires explicit compatibility config; `subagents` creates control tools; missing model/executor returns clear construction error; context-wrapped tools are inherited; budget enters the controller; max concurrent agents rejects excess spawns; profile restrictions apply to direct tools and Codemode providers; exported types are available from `src/index.ts`.
-- **Verification:** Factory tests describe Codemode-default, subagent-default, context-layer, profile-restricted Codemode, and legacy-adapter cases.
+- **Test scenarios:** Codemode config creates Codemode-first parent surface; no-Codemode config exposes direct tools; `subagents` creates control tools; missing model/executor returns clear construction error; context-wrapped tools are inherited; budget enters the controller; max concurrent agents rejects excess spawns; profile restrictions apply to direct tools and Codemode providers; exported types are available from `src/index.ts`.
+- **Verification:** Factory tests describe Codemode-default, no-Codemode direct surface, subagent-default, context-layer, and profile-restricted Codemode cases.
### U9. Add Profile Loading Helpers
@@ -845,13 +843,13 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
## Acceptance Examples
- AE1. Given a host configures subagents, when the parent model receives tools, then `SpawnAgent`, `ListAgents`, `WaitAgent`, `SendMessage`, `FollowupTask`, and `InterruptAgent` are the model-visible delegation tools.
-- AE2. Given a host configures Codemode, when the parent model receives tools, then Codemode is the default coding tool and direct sandbox tools are hidden unless compatibility mode requests them.
+- AE2. Given a host configures Codemode, when the parent model receives tools, then Codemode is the coding tool and direct sandbox tools are hidden from the parent surface.
- AE3. Given the parent calls `SpawnAgent`, then it receives a stable handle and can later call `ListAgents` or `WaitAgent` without re-running the child in parent context.
- AE4. Given a child subagent starts, then the child receives a Codemode tool whose runtime providers are limited by the child's resolved profile.
- AE5. Given two subagents run concurrently under one budget tracker, when both finish, then shared budget status includes both child usages.
- AE6. Given a read-only research profile, when it is spawned with `tools: ["Read", "Grep", "Glob"]`, then write and shell tools are unavailable directly and through Codemode-generated code.
- AE7. Given shared budget is exhausted or active-agent limit is reached, when the parent requests another spawn or follow-up task, then BashKit rejects it before starting a costly child turn.
-- AE8. Given a consumer still imports the legacy adapter, when they upgrade through the migration window, then their one-shot task can still run or fails with a clear migration error, depending on the chosen deprecation policy.
+- AE8. Given a consumer still imports removed `Task` or `TodoWrite` APIs, when they upgrade, then TypeScript and migration docs point them to `SpawnAgent`/`WaitAgent` and `UpdatePlan`.
- AE9. Given a future workflow harness needs fan-out-and-synthesize, when it uses the controller directly, then it can spawn multiple child runs, await terminal results, and synthesize without model-visible `Task` calls.
- AE10. Given the parent uses `context: { recent_turns: 3 }`, when the child starts, then it receives only the bounded recent context plus explicit spawn task and profile prompt.
- AE11. Given a host renders `SubagentControlPanelState`, when a child completes while the parent is not waiting, then the control panel still updates with terminal status, usage, and result reference.
@@ -877,7 +875,7 @@ BashKit is currently pre-1.0, but the contributor guide says breaking changes re
### Integration Tests
- `createAgentTools` Codemode-default behavior.
-- Direct tool compatibility mode.
+- No-Codemode direct tool surface.
- Subagent control tools with fake runner.
- Budget integration across parent and child.
- Context layers inherited by child tools.
@@ -925,7 +923,7 @@ This work changes BashKit's public API, default tool surface, docs, examples, Co
The `Sandbox` interface should remain unchanged. Subagents orchestrate model/tool execution. Sandboxes remain responsible for filesystem and process operations.
-Optional peer dependency strategy needs a release decision. If Codemode becomes the default harness, the package can either make Codemode a required peer for the default path or require hosts to opt into direct-tool compatibility when Codemode is unavailable. The cleaner API is Codemode-first with an explicit construction-time error when a caller asks for Codemode defaults but does not provide the executor.
+Optional peer dependency strategy needs a release decision. The factory should remain binary: hosts either configure Codemode and get a Codemode-first surface, or omit Codemode and get direct tools. The cleaner API is Codemode-first with an explicit construction-time error when a caller asks for Codemode but does not provide the executor.
---
@@ -934,7 +932,7 @@ Optional peer dependency strategy needs a release decision. If Codemode becomes
| Risk | Impact | Mitigation |
| --- | --- | --- |
| AI SDK cancellation support is limited | Interrupt may be best effort | Return explicit unsupported errors and keep status transitions truthful. |
-| Codemode dependency boundary changes | Consumers may need new setup | Document peer dependency and compatibility mode clearly. |
+| Codemode dependency boundary changes | Consumers may need new setup | Document peer dependency and the Codemode/no-Codemode factory split clearly. |
| Recursive spawning runs away | Cost and resource exhaustion | Enforce depth, active count, total count, and budget before spawn. |
| Quarantine bypass through Codemode | Restricted agents mutate state | Filter Codemode providers after profile resolution and test blocked tools. |
| Context bloat from fan-out | Cost spikes and parent context pressure | Make context inheritance explicit and default to bounded recent turns. |
diff --git a/src/index.ts b/src/index.ts
index 15eee3f..7bd2b88 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -308,7 +308,6 @@ export type {
BudgetConfig,
CacheConfig,
ContextConfig,
- DirectToolExposure,
ModelRegistryConfig,
ModelRegistryProvider,
PricingProvider,
diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md
index 91a449e..3764ecd 100644
--- a/src/tools/AGENTS.md
+++ b/src/tools/AGENTS.md
@@ -55,7 +55,7 @@ Each tool exports `Output` for success and `Error` for errors:
- Web tools: `WebSearchOutput | WebSearchError`, `WebFetchOutput | WebFetchError`
### Configuration Types
-- `AgentConfig` -- Top-level config for createAgentTools(), including `directTools` and `subagents`
+- `AgentConfig` -- Top-level config for createAgentTools(), including `codemode` and `subagents`
- `ToolConfig` -- Per-tool config (timeout, allowedPaths, maxFileSize, etc.)
- `AskUserConfig` -- AskUser AI SDK tool options
- `SkillConfig` -- Skill metadata and sandbox
@@ -81,7 +81,7 @@ Each tool exports `Output` for success and `Error` for errors:
- Skill -- Load specialized instructions from SKILL.md files
**Code Orchestration** (opt-in via config):
-- codemode -- A Cloudflare Codemode tool that lets the model write code to orchestrate a filtered set of BashKit tools, codemode-only extra tools, and optional named providers. When `codemode` is configured, `createAgentTools` defaults the parent-visible coding surface to Codemode and keeps direct coding/file tools as runtime providers. Use `directTools: "legacy"` to expose the old direct parent surface alongside Codemode. Client-intervention tools (`AskUser`, `EnterPlanMode`, `ExitPlanMode`), tools without `execute`, and tools with `needsApproval` are excluded from every runtime tool set. Thrown codemode execution failures are converted to `{ error }` tool results like other BashKit tools.
+- codemode -- A Cloudflare Codemode tool that lets the model write code to orchestrate a filtered set of BashKit tools, codemode-only extra tools, and optional named providers. When `codemode` is configured, `createAgentTools` makes Codemode the parent-visible coding surface and keeps direct coding/file tools as runtime providers. Client-intervention tools (`AskUser`, `EnterPlanMode`, `ExitPlanMode`), tools without `execute`, and tools with `needsApproval` are excluded from every runtime tool set. Thrown codemode execution failures are converted to `{ error }` tool results like other BashKit tools.
**Progress Tools** (default):
- UpdatePlan -- Canonical Codex-style checklist/progress tool. Updates runtime `PlanState` and emits `plan.updated` events when a runtime event sink is configured.
@@ -101,7 +101,7 @@ Each tool exports `Output` for success and `Error` for errors:
3. **Caching** (optional): `resolveCache()` wraps cacheable tools with `cached()` from cache module
4. **Model Registry** (optional): `createAgentTools()` fetches model info (pricing + context lengths) from a provider (e.g., OpenRouter). Data is shared with budget tracking and returned as `openRouterModels` in the result.
5. **Budget** (optional): `createAgentTools()` creates a `BudgetTracker` from config, using pricing derived from model registry or manual overrides. Returns it for wiring into `onStepFinish`/`stopWhen` and subagent controller policies.
-6. **Surface Split**: policy-wrapped `runtimeTools` feed Codemode and subagent runners. Parent-visible tools are Codemode-first when `codemode` is configured, with direct exposure only through `directTools: "legacy"`.
+6. **Surface Split**: policy-wrapped `runtimeTools` feed Codemode and subagent runners. Parent-visible tools are Codemode-first when `codemode` is configured; direct coding/file tools stay inside the runtime provider surface.
7. **Export**: Tools surfaced via `src/index.ts` barrel export to package consumers
### Internal Dependencies
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 98d1900..2ab522a 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -383,11 +383,8 @@ export async function createAgentTools(
}
}
- const directToolsExposure =
- config?.directTools ?? (config?.codemode ? "codemode-only" : "legacy");
- const tools: ToolSet =
- directToolsExposure === "legacy" ? { ...runtimeTools } : {};
- if (directToolsExposure === "codemode-only") {
+ const tools: ToolSet = config?.codemode ? {} : { ...runtimeTools };
+ if (config?.codemode) {
tools.UpdatePlan = runtimeTools.UpdatePlan;
if (runtimeTools.AskUser) tools.AskUser = runtimeTools.AskUser;
if (runtimeTools.EnterPlanMode)
diff --git a/src/types.ts b/src/types.ts
index deb9225..36d2793 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -206,8 +206,6 @@ export interface ContextConfig {
layers?: ContextLayer[];
}
-export type DirectToolExposure = "legacy" | "codemode-only";
-
export interface SubagentConfig {
/** Default model for the in-process AI SDK subagent runner. */
model?: LanguageModel;
@@ -256,11 +254,6 @@ export type AgentConfig = {
webFetch?: WebFetchConfig;
/** Include a Cloudflare Codemode tool that can orchestrate selected tools */
codemode?: CodemodeConfig;
- /**
- * Direct parent tool exposure. Defaults to "codemode-only" when codemode is
- * configured and "legacy" otherwise.
- */
- directTools?: DirectToolExposure;
/** Include controller-backed subagent tools and state. */
subagents?: SubagentConfig;
/** Host-facing runtime state and event stream configuration. */
diff --git a/tests/tools/index.test.ts b/tests/tools/index.test.ts
index 5ba315d..8ea86fd 100644
--- a/tests/tools/index.test.ts
+++ b/tests/tools/index.test.ts
@@ -219,21 +219,6 @@ describe("createAgentTools", () => {
const { tools } = await createAgentTools(sandbox);
expect(tools.codemode).toBeUndefined();
- });
-
- it("can expose direct tools in explicit compatibility mode", async () => {
- const { tools } = await createAgentTools(sandbox, {
- directTools: "legacy",
- codemode: {
- executor: createExecutor(),
- createCodeTool: async ({ tools: runtimeTools }) => {
- if (Array.isArray(runtimeTools)) return runtimeTools[0].tools.Read;
- return runtimeTools.Read;
- },
- },
- });
-
- expect(tools.codemode).toBeDefined();
expect(tools.Read).toBeDefined();
expect(tools.Bash).toBeDefined();
});
From 16b50d41fdde290778cc8e1256438a27f872d0da Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 21:00:15 -0400
Subject: [PATCH 13/20] Add subagent profile loader
---
src/index.ts | 13 ++
src/subagents/AGENTS.md | 7 +-
src/subagents/index.ts | 17 +++
src/subagents/profile-loader.schema.ts | 89 ++++++++++++
src/subagents/profile-loader.ts | 179 +++++++++++++++++++++++++
tests/subagents/profile-loader.test.ts | 140 +++++++++++++++++++
6 files changed, 443 insertions(+), 2 deletions(-)
create mode 100644 src/subagents/profile-loader.schema.ts
create mode 100644 src/subagents/profile-loader.ts
create mode 100644 tests/subagents/profile-loader.test.ts
diff --git a/src/index.ts b/src/index.ts
index 7bd2b88..8291bda 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -67,6 +67,7 @@ export type {
SubagentInterruptResult,
SubagentLifecycleHooks,
SubagentListFilter,
+ LoadedSubagentProfiles,
SubagentMailboxMessage,
SubagentMessageRequest,
SubagentMessageResult,
@@ -76,7 +77,10 @@ export type {
SubagentPath,
SubagentPolicyState,
SubagentProfileDefaults,
+ SubagentProfileFileReader,
SubagentProfileInput,
+ SubagentProfileLoaderOptions,
+ SubagentProfileModelResolver,
SubagentProfileRegistry,
SubagentRecord,
SubagentRegistry,
@@ -95,6 +99,12 @@ export type {
SubagentUsage,
SubagentWaitRequest,
SubagentWaitResult,
+ SerializedSubagentCodemodePolicy,
+ SerializedSubagentContextPolicy,
+ SerializedSubagentCostPolicy,
+ SerializedSubagentProfile,
+ SerializedSubagentProfileConfig,
+ SerializedSubagentProfileDefaults,
} from "./subagents";
export {
DEFAULT_SUBAGENT_CODEMODE_POLICY,
@@ -129,6 +139,9 @@ export {
isTerminalSubagentStatus,
jsonObjectFromUnknown,
jsonValueFromUnknown,
+ loadSubagentProfilesFromFile,
+ loadSubagentProfilesFromJson,
+ loadSubagentProfilesFromObject,
normalizeSubagentPath,
resetSubagentIdCounterForTests,
resolveSubagentContextPolicy,
diff --git a/src/subagents/AGENTS.md b/src/subagents/AGENTS.md
index 695594c..ea7d660 100644
--- a/src/subagents/AGENTS.md
+++ b/src/subagents/AGENTS.md
@@ -11,6 +11,8 @@ Foundation for controller-managed subagents. This module owns identity, path res
| `path.ts` | Task-name normalization and relative path resolution |
| `status.ts` | Status helpers and terminal-state checks |
| `profiles.ts` | Profile registry factory and profile resolution |
+| `profile-loader.ts` | JSON-safe profile config loading with host-provided model alias resolution |
+| `profile-loader.schema.ts` | Zod schemas and serialized profile config types |
| `profile-descriptions.ts` | Model-visible profile description generation |
| `model-info.ts` | Serializable model summary helpers for metadata and host snapshots |
| `tool-filter.ts` | Tool allowlist filtering plus denied-tool reject/hide policy wrappers |
@@ -66,8 +68,9 @@ Control panel state is a normalized host projection, not a UI framework. It incl
1. Add it to profile input and resolved profile types.
2. Resolve defaults in `profiles.ts`.
-3. Include model-visible text in `profile-descriptions.ts` when it helps routing.
-4. Add tests for defaulting and override behavior.
+3. Add it to `profile-loader.schema.ts` if the field is safe to serialize.
+4. Include model-visible text in `profile-descriptions.ts` when it helps routing.
+5. Add tests for defaulting, loading, and override behavior.
### Add a new guardrail
diff --git a/src/subagents/index.ts b/src/subagents/index.ts
index 74624a3..03985b0 100644
--- a/src/subagents/index.ts
+++ b/src/subagents/index.ts
@@ -14,6 +14,23 @@ export {
resolveSubagentCostPolicy,
} from "./profiles";
export { describeSubagentProfile } from "./profile-descriptions";
+export {
+ loadSubagentProfilesFromFile,
+ loadSubagentProfilesFromJson,
+ loadSubagentProfilesFromObject,
+ type LoadedSubagentProfiles,
+ type SubagentProfileFileReader,
+ type SubagentProfileLoaderOptions,
+ type SubagentProfileModelResolver,
+} from "./profile-loader";
+export type {
+ SerializedSubagentCodemodePolicy,
+ SerializedSubagentContextPolicy,
+ SerializedSubagentCostPolicy,
+ SerializedSubagentProfile,
+ SerializedSubagentProfileConfig,
+ SerializedSubagentProfileDefaults,
+} from "./profile-loader.schema";
export { filterSubagentTools } from "./tool-filter";
export {
buildSubagentMessages,
diff --git a/src/subagents/profile-loader.schema.ts b/src/subagents/profile-loader.schema.ts
new file mode 100644
index 0000000..a7c4add
--- /dev/null
+++ b/src/subagents/profile-loader.schema.ts
@@ -0,0 +1,89 @@
+import { z } from "zod";
+import type { JsonValue } from "./types";
+
+const jsonValueSchema: z.ZodType = z.lazy(() =>
+ z.union([
+ z.string(),
+ z.number(),
+ z.boolean(),
+ z.null(),
+ z.array(jsonValueSchema),
+ z.record(z.string(), jsonValueSchema),
+ ]),
+);
+
+export const serializedSubagentContextPolicySchema = z.union([
+ z.literal("none"),
+ z.literal("all"),
+ z.object({ recent_turns: z.number().int().nonnegative() }),
+ z.object({ mode: z.literal("none") }),
+ z.object({ mode: z.literal("all") }),
+ z.object({
+ mode: z.literal("recent"),
+ turns: z.number().int().nonnegative(),
+ }),
+]);
+
+export const serializedSubagentCodemodePolicySchema = z.object({
+ enabled: z.boolean().optional(),
+ exposeDirectTools: z.boolean().optional(),
+ includeTools: z.array(z.string()).optional(),
+ excludeTools: z.array(z.string()).optional(),
+});
+
+export const serializedSubagentCostPolicySchema = z.object({
+ maxUsd: z.number().positive().optional(),
+ maxActiveAgents: z.number().int().positive().optional(),
+ maxTotalAgents: z.number().int().positive().optional(),
+ maxDepth: z.number().int().nonnegative().optional(),
+ maxMailboxMessages: z.number().int().positive().optional(),
+ minWaitTimeoutMs: z.number().int().nonnegative().optional(),
+ maxWaitTimeoutMs: z.number().int().nonnegative().optional(),
+});
+
+const serializedSubagentProfileBaseSchema = z.object({
+ description: z.string().optional(),
+ nickname: z.string().optional(),
+ model: z.string().optional(),
+ system: z.string().optional(),
+ allowedTools: z.array(z.string()).optional(),
+ deniedTools: z.array(z.string()).optional(),
+ deniedBehavior: z.enum(["reject", "hide"]).optional(),
+ codemode: serializedSubagentCodemodePolicySchema.optional(),
+ context: serializedSubagentContextPolicySchema.optional(),
+ cost: serializedSubagentCostPolicySchema.optional(),
+ metadata: z.record(z.string(), jsonValueSchema).optional(),
+});
+
+export const serializedSubagentProfileSchema =
+ serializedSubagentProfileBaseSchema.extend({
+ name: z.string().min(1),
+ });
+
+export const serializedSubagentProfileDefaultsSchema =
+ serializedSubagentProfileBaseSchema;
+
+export const serializedSubagentProfileConfigSchema = z.object({
+ defaultProfile: z.string().optional(),
+ defaults: serializedSubagentProfileDefaultsSchema.optional(),
+ profiles: z.array(serializedSubagentProfileSchema),
+});
+
+export type SerializedSubagentContextPolicy = z.infer<
+ typeof serializedSubagentContextPolicySchema
+>;
+export type SerializedSubagentCodemodePolicy = z.infer<
+ typeof serializedSubagentCodemodePolicySchema
+>;
+export type SerializedSubagentCostPolicy = z.infer<
+ typeof serializedSubagentCostPolicySchema
+>;
+export type SerializedSubagentProfile = z.infer<
+ typeof serializedSubagentProfileSchema
+>;
+export type SerializedSubagentProfileDefaults = z.infer<
+ typeof serializedSubagentProfileDefaultsSchema
+>;
+export type SerializedSubagentProfileConfig = z.infer<
+ typeof serializedSubagentProfileConfigSchema
+>;
diff --git a/src/subagents/profile-loader.ts b/src/subagents/profile-loader.ts
new file mode 100644
index 0000000..72e8ff2
--- /dev/null
+++ b/src/subagents/profile-loader.ts
@@ -0,0 +1,179 @@
+import type { LanguageModel } from "ai";
+import type { ZodError } from "zod";
+import {
+ serializedSubagentProfileConfigSchema,
+ type SerializedSubagentProfile,
+ type SerializedSubagentProfileDefaults,
+} from "./profile-loader.schema";
+import type {
+ SubagentError,
+ SubagentProfileDefaults,
+ SubagentProfileInput,
+} from "./types";
+
+export interface LoadedSubagentProfiles {
+ profiles: SubagentProfileInput[];
+ defaults?: SubagentProfileDefaults;
+ defaultProfile?: string;
+}
+
+export type SubagentProfileModelResolver = (
+ modelAlias: string,
+) => LanguageModel | SubagentError | null | undefined;
+
+export interface SubagentProfileLoaderOptions {
+ /** Map serialized model aliases to live AI SDK models. */
+ models?: Record;
+ /** Advanced resolver for aliases that are not covered by models. */
+ resolveModel?: SubagentProfileModelResolver;
+}
+
+export interface SubagentProfileFileReader {
+ readFile(path: string): Promise;
+}
+
+function formatZodError(error: ZodError): string {
+ return error.issues
+ .map((issue) => {
+ const path = issue.path.length > 0 ? issue.path.join(".") : "root";
+ return `${path}: ${issue.message}`;
+ })
+ .join("; ");
+}
+
+function getErrorMessage(error: unknown): string {
+ if (error instanceof Error) return error.message;
+ return String(error);
+}
+
+function isSubagentError(value: unknown): value is SubagentError {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "error" in value &&
+ typeof value.error === "string"
+ );
+}
+
+function resolveModelAlias(
+ modelAlias: string | undefined,
+ options: SubagentProfileLoaderOptions,
+): LanguageModel | SubagentError | undefined {
+ if (!modelAlias) return undefined;
+
+ const mapped = options.models?.[modelAlias];
+ if (mapped) return mapped;
+
+ const resolved = options.resolveModel?.(modelAlias);
+ if (!resolved) {
+ return { error: `Unknown subagent model alias: ${modelAlias}` };
+ }
+ return resolved;
+}
+
+function toProfileInput(
+ profile: SerializedSubagentProfile,
+ options: SubagentProfileLoaderOptions,
+): SubagentProfileInput | SubagentError {
+ const model = resolveModelAlias(profile.model, options);
+ if (isSubagentError(model)) return model;
+
+ return {
+ name: profile.name,
+ description: profile.description,
+ nickname: profile.nickname,
+ model,
+ system: profile.system,
+ allowedTools: profile.allowedTools,
+ deniedTools: profile.deniedTools,
+ deniedBehavior: profile.deniedBehavior,
+ codemode: profile.codemode,
+ context: profile.context,
+ cost: profile.cost,
+ metadata: profile.metadata,
+ };
+}
+
+function toProfileDefaults(
+ defaults: SerializedSubagentProfileDefaults | undefined,
+ options: SubagentProfileLoaderOptions,
+): SubagentProfileDefaults | SubagentError | undefined {
+ if (!defaults) return undefined;
+
+ const model = resolveModelAlias(defaults.model, options);
+ if (isSubagentError(model)) return model;
+
+ return {
+ model,
+ system: defaults.system,
+ allowedTools: defaults.allowedTools,
+ deniedTools: defaults.deniedTools,
+ deniedBehavior: defaults.deniedBehavior,
+ codemode: defaults.codemode,
+ context: defaults.context,
+ cost: defaults.cost,
+ };
+}
+
+export function loadSubagentProfilesFromObject(
+ input: unknown,
+ options: SubagentProfileLoaderOptions = {},
+): LoadedSubagentProfiles | SubagentError {
+ const parsed = serializedSubagentProfileConfigSchema.safeParse(input);
+ if (!parsed.success) {
+ return {
+ error: `Invalid subagent profile config: ${formatZodError(parsed.error)}`,
+ };
+ }
+
+ const defaults = toProfileDefaults(parsed.data.defaults, options);
+ if (defaults && "error" in defaults) return defaults;
+
+ const profiles: SubagentProfileInput[] = [];
+ for (const profile of parsed.data.profiles) {
+ const runtimeProfile = toProfileInput(profile, options);
+ if ("error" in runtimeProfile) return runtimeProfile;
+ profiles.push(runtimeProfile);
+ }
+
+ return {
+ profiles,
+ defaults,
+ defaultProfile: parsed.data.defaultProfile,
+ };
+}
+
+export function loadSubagentProfilesFromJson(
+ json: string,
+ options: SubagentProfileLoaderOptions = {},
+): LoadedSubagentProfiles | SubagentError {
+ let input: unknown;
+ try {
+ input = JSON.parse(json);
+ } catch (error) {
+ return {
+ error: `Invalid subagent profile JSON: ${getErrorMessage(error)}`,
+ };
+ }
+
+ return loadSubagentProfilesFromObject(input, options);
+}
+
+export async function loadSubagentProfilesFromFile(
+ path: string,
+ reader: SubagentProfileFileReader,
+ options: SubagentProfileLoaderOptions = {},
+): Promise {
+ let json: string;
+ try {
+ json = await reader.readFile(path);
+ } catch (error) {
+ return {
+ error: `Failed to read subagent profile file ${path}: ${getErrorMessage(
+ error,
+ )}`,
+ };
+ }
+
+ return loadSubagentProfilesFromJson(json, options);
+}
diff --git a/tests/subagents/profile-loader.test.ts b/tests/subagents/profile-loader.test.ts
new file mode 100644
index 0000000..e142e7e
--- /dev/null
+++ b/tests/subagents/profile-loader.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it } from "vitest";
+import type { LanguageModel } from "ai";
+import {
+ createSubagentProfileRegistry,
+ loadSubagentProfilesFromFile,
+ loadSubagentProfilesFromJson,
+ loadSubagentProfilesFromObject,
+} from "@/subagents";
+
+function model(modelId: string): LanguageModel {
+ return { modelId } as LanguageModel;
+}
+
+describe("subagent profile loader", () => {
+ it("loads serialized profiles and resolves model aliases", () => {
+ const fast = model("fast-model");
+ const deep = model("deep-model");
+ const result = loadSubagentProfilesFromObject(
+ {
+ defaultProfile: "researcher",
+ defaults: {
+ model: "fast",
+ allowedTools: ["Read", "Grep"],
+ deniedTools: ["Write"],
+ codemode: { enabled: true },
+ context: { recent_turns: 2 },
+ cost: { maxActiveAgents: 2 },
+ },
+ profiles: [
+ {
+ name: "researcher",
+ description: "Read-only research",
+ nickname: "research",
+ model: "deep",
+ system: "Investigate without editing files.",
+ allowedTools: ["Glob"],
+ deniedBehavior: "reject",
+ codemode: { excludeTools: ["Bash"] },
+ metadata: { lane: "analysis" },
+ },
+ ],
+ },
+ { models: { fast, deep } },
+ );
+
+ if ("error" in result) throw new Error(result.error);
+ expect(result.defaultProfile).toBe("researcher");
+ expect(result.defaults?.model).toBe(fast);
+ expect(result.profiles[0]).toMatchObject({
+ name: "researcher",
+ description: "Read-only research",
+ nickname: "research",
+ system: "Investigate without editing files.",
+ allowedTools: ["Glob"],
+ deniedBehavior: "reject",
+ codemode: { excludeTools: ["Bash"] },
+ metadata: { lane: "analysis" },
+ });
+ expect(result.profiles[0].model).toBe(deep);
+
+ const registry = createSubagentProfileRegistry({
+ defaultProfile: result.defaultProfile,
+ defaults: result.defaults,
+ profiles: result.profiles,
+ });
+ const resolved = registry.resolve(undefined);
+ if ("error" in resolved) throw new Error(resolved.error);
+ expect(resolved.name).toBe("researcher");
+ expect(resolved.model).toBe(deep);
+ expect(resolved.allowedTools).toEqual(["Read", "Grep", "Glob"]);
+ expect(resolved.deniedTools).toEqual(["Write"]);
+ expect(resolved.context).toEqual({ mode: "recent", turns: 2 });
+ });
+
+ it("supports resolver callbacks for model aliases", () => {
+ const resolvedModel = model("callback-model");
+ const result = loadSubagentProfilesFromJson(
+ JSON.stringify({
+ profiles: [{ name: "worker", model: "callback" }],
+ }),
+ {
+ resolveModel: (modelAlias) =>
+ modelAlias === "callback" ? resolvedModel : undefined,
+ },
+ );
+
+ if ("error" in result) throw new Error(result.error);
+ expect(result.profiles[0].model).toBe(resolvedModel);
+ });
+
+ it("returns model-visible errors for invalid JSON and schema failures", () => {
+ expect(loadSubagentProfilesFromJson("{")).toMatchObject({
+ error: expect.stringContaining("Invalid subagent profile JSON"),
+ });
+
+ expect(
+ loadSubagentProfilesFromObject({
+ profiles: [{ name: "", context: { recent_turns: -1 } }],
+ }),
+ ).toMatchObject({
+ error: expect.stringContaining("Invalid subagent profile config"),
+ });
+ });
+
+ it("returns an error when a model alias is unknown", () => {
+ expect(
+ loadSubagentProfilesFromObject({
+ profiles: [{ name: "researcher", model: "missing" }],
+ }),
+ ).toEqual({
+ error: "Unknown subagent model alias: missing",
+ });
+ });
+
+ it("loads profile JSON through an injected file reader", async () => {
+ const result = await loadSubagentProfilesFromFile(
+ "/profiles/subagents.json",
+ {
+ readFile: async () =>
+ JSON.stringify({ profiles: [{ name: "reviewer" }] }),
+ },
+ );
+
+ expect(result).toMatchObject({
+ profiles: [{ name: "reviewer" }],
+ });
+ });
+
+ it("returns file read failures as error objects", async () => {
+ const result = await loadSubagentProfilesFromFile("/missing.json", {
+ readFile: async () => {
+ throw new Error("not found");
+ },
+ });
+
+ expect(result).toEqual({
+ error: "Failed to read subagent profile file /missing.json: not found",
+ });
+ });
+});
From 4712fa88ac0897e96d044d20bdd08540c505a95a Mon Sep 17 00:00:00 2001
From: jbreite
Date: Mon, 15 Jun 2026 22:08:15 -0400
Subject: [PATCH 14/20] Document subagent foundation migration
---
CHANGELOG.md | 22 ++++
README.md | 191 ++++++++++++++++++++--------
docs/src/app/api-reference/page.tsx | 9 +-
docs/src/app/codemode/page.tsx | 12 ++
docs/src/app/tools/page.tsx | 48 ++++++-
examples/codemode-subagents.ts | 86 +++++++++++++
examples/subagents.ts | 75 +++++++++++
7 files changed, 388 insertions(+), 55 deletions(-)
create mode 100644 CHANGELOG.md
create mode 100644 examples/codemode-subagents.ts
create mode 100644 examples/subagents.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..3970c5d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,22 @@
+# Changelog
+
+## Unreleased
+
+### Breaking
+
+- `createAgentTools` now has a binary coding surface: configure `codemode` to expose Codemode as the parent coding tool, or omit `codemode` to expose direct BashKit tools.
+- Removed the legacy `Task` tool. Use `SpawnAgent` plus `WaitAgent`.
+- Removed the legacy `TodoWrite` tool. Use `UpdatePlan`.
+
+### Added
+
+- Added controller-backed subagent tools and host-facing control panel state.
+- Added profile-scoped subagent policies for tool allowlists, denied-tool behavior, Codemode exposure, context inheritance, and cost/depth/concurrency limits.
+- Added JSON-safe subagent profile loading helpers: `loadSubagentProfilesFromObject`, `loadSubagentProfilesFromJson`, and `loadSubagentProfilesFromFile`.
+- Added normalized runtime events for tool execution, plan updates, approvals, file changes, command output, and agent lifecycle snapshots.
+
+### Migration
+
+- Replace one-shot `Task` flows with `SpawnAgent({ task, task_name })` followed by `WaitAgent({ agent })`.
+- Replace `TodoWrite` with `UpdatePlan`.
+- When adopting Codemode, move direct tool policy into Codemode `includeTools` / `excludeTools` and subagent profile policies.
diff --git a/README.md b/README.md
index 55c1102..ed2d871 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,25 @@ Agentic coding tools for Vercel AI SDK. Give AI agents the ability to execute co
- Search the web and fetch URLs
- Load skills on-demand via the [Agent Skills](https://agentskills.io) standard
-## Breaking Changes in v0.4.0
+## Breaking Changes
-### Nullable Types for OpenAI Compatibility
+### Next Breaking Release: Codemode, Subagents, and Progress
+
+BashKit is moving to a simpler coding surface:
+
+- If `codemode` is configured, the parent model receives the `codemode` tool as
+ its coding interface. Direct coding tools such as `Bash`, `Read`, `Write`,
+ `Edit`, `Glob`, and `Grep` remain available to Codemode as runtime providers.
+- If `codemode` is not configured, the parent model receives the direct BashKit
+ tools.
+- `Task` has been removed. Use `SpawnAgent` plus `WaitAgent`.
+- `TodoWrite` has been removed. Use `UpdatePlan`, which is included by
+ `createAgentTools()`.
+
+This is intentionally a breaking change. There is no `createAgentTools`
+compatibility switch that exposes direct tools alongside Codemode.
+
+### v0.4.0: Nullable Types for OpenAI Compatibility
All optional tool parameters now use `.nullable()` instead of `.optional()` in Zod schemas. This change enables compatibility with OpenAI's structured outputs, which require all properties to be in the `required` array.
@@ -71,7 +87,7 @@ import { generateText, stepCountIs } from 'ai';
const sandbox = createLocalSandbox({ cwd: '/tmp/workspace' });
// Create tools bound to the sandbox
-const { tools } = createAgentTools(sandbox);
+const { tools } = await createAgentTools(sandbox);
// Use with Vercel AI SDK
const result = await generateText({
@@ -103,7 +119,7 @@ const sandbox = await createVercelSandbox({
resources: { vcpus: 2 },
});
-const { tools } = createAgentTools(sandbox);
+const { tools } = await createAgentTools(sandbox);
const result = streamText({
model: anthropic('claude-sonnet-4-5'),
@@ -118,7 +134,7 @@ await sandbox.destroy();
## Available Tools
-### Default Tools (always included)
+### Direct Coding Tools
| Tool | Purpose | Key Inputs |
|------|---------|------------|
@@ -129,6 +145,10 @@ await sandbox.destroy();
| `Glob` | Find files by pattern | `pattern`, `path?` |
| `Grep` | Search file contents | `pattern`, `path?`, `output_mode?`, `-i?`, `-C?` |
+These tools are parent-visible when `codemode` is not configured. When
+`codemode` is configured, they move behind Codemode as runtime providers instead
+of appearing beside the `codemode` tool.
+
### Optional Tools (via config)
| Tool | Purpose | Config Key |
@@ -146,7 +166,7 @@ await sandbox.destroy();
| Tool | Purpose | Factory |
|------|---------|---------|
| `UpdatePlan` | Track task progress | Included by `createAgentTools()` |
-| `SpawnAgent` / `WaitAgent` | Control subagents | `createSubagentControlTools(controller, config?)` |
+| `SpawnAgent` / `WaitAgent` | Control subagents | `subagents: { ... }` or `createSubagentControlTools(controller, config?)` |
### Web Tools (require `parallel-web` peer dependency)
@@ -220,7 +240,7 @@ const reconnected = await createE2BSandbox({
You can configure tools with security restrictions and limits, and enable optional tools:
```typescript
-const { tools, planModeState } = createAgentTools(sandbox, {
+const { tools, planModeState } = await createAgentTools(sandbox, {
// Enable optional tools
askUser: true,
planMode: true, // Enables EnterPlanMode and ExitPlanMode
@@ -295,6 +315,11 @@ await streamText({
});
```
+When `codemode` is configured, `tools` contains `codemode` plus control and
+interactive tools such as `UpdatePlan`, `AskUser`, `EnterPlanMode`,
+`ExitPlanMode`, `Skill`, and subagent controls. Direct coding/file tools are not
+also exposed to the parent model.
+
By default, generated code calls BashKit tools through BashKit's `bashkit.*` namespace, for example `await bashkit.Grep(...)`. Use `providers` when you want additional named tool groups such as `repo.SummarizeFile(...)`.
Client-intervention tools are excluded from codemode automatically: `AskUser`, `EnterPlanMode`, `ExitPlanMode`, tools without `execute`, and tools with `needsApproval`. Top-level `includeTools` narrows the default `bashkit.*` namespace. Named providers can define their own `includeTools` or `excludeTools`.
@@ -317,7 +342,7 @@ Client-intervention tools are excluded from codemode automatically: `AskUser`, `
All tools support AI SDK v6 tool options:
```typescript
-const { tools } = createAgentTools(sandbox, {
+const { tools } = await createAgentTools(sandbox, {
tools: {
Bash: {
timeout: 30000,
@@ -351,6 +376,88 @@ The older one-shot `Task` tool has been removed from the breaking API. Migrate
`Task(description, prompt)` flows to `SpawnAgent(task, task_name?)` followed by
`WaitAgent(agent_id)`.
+```typescript
+import {
+ createAgentTools,
+ createLocalSandbox,
+ createStaticSubagentRunner,
+} from 'bashkit';
+
+const sandbox = createLocalSandbox({ cwd: '/tmp/workspace' });
+
+const { tools, getSubagentControlPanelState } = await createAgentTools(
+ sandbox,
+ {
+ subagents: {
+ profiles: [
+ {
+ name: 'researcher',
+ description: 'Read-only code research',
+ allowedTools: ['Read', 'Glob', 'Grep'],
+ deniedTools: ['Write', 'Edit', 'Bash'],
+ },
+ ],
+ runner: createStaticSubagentRunner({
+ status: 'completed',
+ result: 'Research summary',
+ }),
+ },
+ },
+);
+
+// Parent model can call SpawnAgent, ListAgents, WaitAgent, SendMessage,
+// FollowupTask, and InterruptAgent.
+console.log(Object.keys(tools));
+console.log(await getSubagentControlPanelState?.());
+```
+
+### Subagent Profile Files
+
+Profiles can be loaded from JSON-safe config and then passed to
+`createAgentTools`. Model entries are string aliases in the file; the host maps
+them to live AI SDK model objects.
+
+```json
+{
+ "defaultProfile": "researcher",
+ "defaults": {
+ "model": "fast",
+ "context": { "recent_turns": 3 },
+ "cost": { "maxDepth": 1 }
+ },
+ "profiles": [
+ {
+ "name": "researcher",
+ "description": "Read-only investigation",
+ "system": "Investigate and cite files. Do not edit.",
+ "allowedTools": ["Read", "Glob", "Grep", "WebSearch"],
+ "deniedTools": ["Write", "Edit", "Bash"]
+ }
+ ]
+}
+```
+
+```typescript
+import { loadSubagentProfilesFromJson } from 'bashkit';
+
+const loaded = loadSubagentProfilesFromJson(profileJson, {
+ models: {
+ fast: anthropic('claude-haiku-4'),
+ },
+});
+
+if ('error' in loaded) throw new Error(loaded.error);
+
+const { tools } = await createAgentTools(sandbox, {
+ subagents: {
+ profiles: loaded.profiles,
+ profileDefaults: loaded.defaults,
+ defaultProfile: loaded.defaultProfile,
+ model: anthropic('claude-sonnet-4-5'),
+ },
+});
+```
+
## Context Management
### Conversation Compaction
@@ -398,7 +505,7 @@ if (contextNeedsCompaction(status)) {
Cache tool execution results to avoid repeated expensive operations:
```typescript
-const { tools } = createAgentTools(sandbox, {
+const { tools } = await createAgentTools(sandbox, {
// Enable caching with defaults (LRU, 5min TTL)
cache: true,
});
@@ -407,7 +514,7 @@ const { tools } = createAgentTools(sandbox, {
### Cache Configuration Options
```typescript
-const { tools } = createAgentTools(sandbox, {
+const { tools } = await createAgentTools(sandbox, {
cache: {
// Custom TTL (default: 5 minutes)
ttl: 10 * 60 * 1000,
@@ -463,7 +570,7 @@ const redisStore: CacheStore = {
},
};
-const { tools } = createAgentTools(sandbox, {
+const { tools } = await createAgentTools(sandbox, {
cache: redisStore,
});
```
@@ -552,7 +659,7 @@ import {
} from 'bashkit';
const sandbox = createLocalSandbox({ cwd: '/tmp/workspace' });
-const { tools, planModeState } = createAgentTools(sandbox, { planMode: true });
+const { tools, planModeState } = await createAgentTools(sandbox, { planMode: true });
const wrappedTools = applyContextLayers(tools, [
// Gate: block Bash/Write/Edit while plan mode is active
@@ -643,7 +750,7 @@ import { discoverSkills, skillsToXml, createAgentTools, createLocalSandbox } fro
const skills = await discoverSkills();
const sandbox = createLocalSandbox({ cwd: '/tmp/workspace' });
-const { tools } = createAgentTools(sandbox);
+const { tools } = await createAgentTools(sandbox);
const result = await generateText({
model: anthropic('claude-sonnet-4-5'),
@@ -812,7 +919,7 @@ ${skillsToXml(skills)}
`;
// Create tools and run
-const { tools } = createAgentTools(sandbox);
+const { tools } = await createAgentTools(sandbox);
const result = await generateText({
model: anthropic('claude-sonnet-4-5'),
@@ -888,7 +995,7 @@ class DockerSandbox implements Sandbox {
}
const sandbox = new DockerSandbox();
-const { tools } = createAgentTools(sandbox);
+const { tools } = await createAgentTools(sandbox);
```
## Architecture
@@ -933,6 +1040,8 @@ const { tools } = createAgentTools(sandbox);
See the `examples/` directory for complete working examples:
- `basic.ts` - Full example with UpdatePlan and prompt caching
+- `subagents.ts` - Controller-backed subagent control and control-panel state
+- `codemode-subagents.ts` - Codemode-first parent surface with profile-loaded subagents
- `test-tools.ts` - Testing individual tools
- `test-web-tools.ts` - Web search and fetch examples
@@ -946,7 +1055,9 @@ Creates a set of agent tools bound to a sandbox instance.
- `sandbox` (Sandbox): Sandbox instance for code execution
- `config` (AgentConfig, optional): Configuration for tools and web tools
-**Returns:** Object with tool definitions compatible with Vercel AI SDK
+**Returns:** Promise resolving to tool definitions compatible with Vercel AI SDK,
+plus optional shared state such as `budget`, `planState`,
+`subagentController`, and `getSubagentControlPanelState`.
### Sandbox Factories
@@ -959,6 +1070,9 @@ Creates a set of agent tools bound to a sandbox instance.
- `createUpdatePlanTool(state, config?)` - Track progress with the canonical Codex-style plan state
- `createSubagentControlTools(controller, config?)` - Create Spawn/List/Wait/Message/Interrupt subagent controls
+- `loadSubagentProfilesFromJson(json, options?)` - Load JSON-safe subagent profile configs
+- `loadSubagentProfilesFromObject(input, options?)` - Validate profile config objects
+- `loadSubagentProfilesFromFile(path, reader, options?)` - Load profile config through a host-provided reader
### Optional Tools (also available via config)
@@ -1001,43 +1115,16 @@ Creates a set of agent tools bound to a sandbox instance.
- `createOutputPolicy(config?)` - Truncation + redirection hints + optional disk stash `ContextLayer`
- `createPrepareStep(config)` - Compose compaction + context-status + plan-mode hints into an AI SDK `PrepareStepFunction`
-## Future Roadmap
-
-The following features are planned for future releases:
-
-### Agent Profiles Loader
-
-Load pre-configured subagent types from JSON/TypeScript configs:
-
-```json
-// .bashkit/agents.json
-{
- "subagentTypes": {
- "research": {
- "systemPrompt": "You are a research specialist...",
- "tools": ["Read", "Grep", "Glob", "WebSearch"]
- },
- "coding": {
- "systemPrompt": "You are a coding expert...",
- "tools": ["Read", "Write", "Edit", "Bash"]
- }
- }
-}
-```
-
-Register loaded profiles with the subagent profile registry:
-```typescript
-import { createSubagentProfileRegistry } from 'bashkit';
-
-const registry = createSubagentProfileRegistry({
- profiles,
-});
-```
+## Migration Notes
-This will make it easy to:
-- Share agent configurations across projects
-- Standardize agent patterns within teams
-- Quickly set up specialized agents for different tasks
+- Replace `Task` with `SpawnAgent` plus `WaitAgent`.
+- Replace `TodoWrite` with `UpdatePlan`.
+- When adopting Codemode, expect direct coding tools to disappear from the
+ parent-visible tool set. They are still available inside generated Codemode
+ code through the configured runtime providers.
+- Use `loadSubagentProfilesFromJson`, `loadSubagentProfilesFromObject`, or
+ `loadSubagentProfilesFromFile` to share team-standard subagent profiles across
+ apps.
## Contributing
diff --git a/docs/src/app/api-reference/page.tsx b/docs/src/app/api-reference/page.tsx
index 16e960a..c85a88d 100644
--- a/docs/src/app/api-reference/page.tsx
+++ b/docs/src/app/api-reference/page.tsx
@@ -100,11 +100,18 @@ const { tools, budget } = await createAgentTools(sandbox, {
Cloudflare Codemode adapter. Adds a single codemode{" "}
- tool that can orchestrate selected executable tools. Excludes
+ tool that becomes the parent-visible coding surface. Direct coding
+ tools remain available as Codemode runtime providers rather than
+ appearing beside codemode. Excludes
client-intervention tools and tools with{" "}
needsApproval. Supports extra default-namespace tools
and named providers.
+
+ Controller-backed subagent configuration. Presence enables
+ SpawnAgent, ListAgents, WaitAgent, SendMessage, FollowupTask, and
+ InterruptAgent tools, plus optional control-panel state.
+
Enable tool result caching. Pass true for defaults or
an object for fine-grained control.
diff --git a/docs/src/app/codemode/page.tsx b/docs/src/app/codemode/page.tsx
index 944a9a1..2d0bfe6 100644
--- a/docs/src/app/codemode/page.tsx
+++ b/docs/src/app/codemode/page.tsx
@@ -29,6 +29,13 @@ export default function Codemode() {
policy-wrapped tools. That means context layers, plan-mode gates,
output policy, and sandbox restrictions keep applying.
+
+ The factory surface is binary: configure codemode and
+ the parent model gets Codemode as its coding tool; omit{" "}
+ codemode and the parent model gets direct BashKit
+ tools. There is no parent-surface compatibility mode that exposes
+ both.
+
@@ -165,6 +172,11 @@ async () => {
ExitPlanMode, tools without an execute{" "}
function, and tools with needsApproval.
+
+ Parent-visible control tools such as UpdatePlan and
+ subagent controls can still be exposed beside codemode.
+ Direct coding/file tools stay inside the runtime provider surface.
+
diff --git a/docs/src/app/tools/page.tsx b/docs/src/app/tools/page.tsx
index 637c784..4f89e00 100644
--- a/docs/src/app/tools/page.tsx
+++ b/docs/src/app/tools/page.tsx
@@ -279,12 +279,30 @@ export default function Tools() {
Use controller-backed subagent tools for delegation. SpawnAgent
starts separable work, ListAgents inspects status, and WaitAgent
- collects terminal results.
+ collects terminal results. Configure subagents through{" "}
+ createAgentTools to expose the control tools to the
+ parent model.
+
+ Profile files can be loaded with{" "}
+ loadSubagentProfilesFromJson,{" "}
+ loadSubagentProfilesFromObject, or{" "}
+ loadSubagentProfilesFromFile. Model names inside the
+ file are aliases that the host maps to live AI SDK model objects.
+