diff --git a/README.md b/README.md index a9c4c7b3d..039ced5ac 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ dashboard — any provider, any deploy target, no lock-in. - **Parallel tool calls** — when the model asks for several tools in one turn they run concurrently (cap it with `toolConcurrency`, or `1` for sequential), and results still reach the transcript in the model's call order - **Usage & cost** — every result carries `usage` (tokens, USD cost, per-model breakdown, delegated children included, continuing across resumes) and `formatUsage(result.usage)` for logs - **Skills** — `defineSkill()` / `loadSkills('./skills')`: only each skill's name and description sit in the system prompt; the model loads the full markdown on demand through an auto-registered `load_skill` tool +- **Sub-agents** — `createAgent({ subagents: { researcher, writer } })` gives the lead one `task` tool: each sub-agent runs on the task prompt alone (isolated context) with its own instructions, model and tools, several tasks in one turn run in parallel, and sub-agents inherit the lead run's abort signal, hooks, tracing, approvals and events (`maxSubagentDepth`, dynamic `{ list, resolve }` catalogs; `createDelegateTool()` for a single hand-wired child) — see [Sub-agents](docs/sub-agents.md) - **Agents as directories** — `loadAgentDir('./my-agent')` turns `instructions.md`, `tools/*.ts`, `skills/` and `subagents//` into the same object `createAgent()` returns, so you can start with files and drop to code later — see [Agent directories](docs/agent-directories.md) -- **Multi-agent delegation** — wrap a child agent as a tool with `createDelegateTool()`, with a `maxDepth` guard against delegation loops - **Pre/post hooks** — a `HookRegistry` of `AgentHook`s that can inspect or mutate a tool call/LLM generate step, or throw to abort it, run sandboxed by Agent Forge's hook editor - **Guardrails** — fail-closed, concurrently-run checks (secret scan, diff size, test/lint commands) that gate a fixer agent's patch before it's used - **Workspace tools** — `createFsTools()` (`read_file`, `write_file`, `edit_file`, `list_dir`, `glob`, `grep`) and `createShellTool()` over pluggable `FsProvider`/`ShellProvider` backends (`NodeWorkspace`, `MemoryWorkspace`, Docker-backed `SandboxShell`), with paths confined to the workspace root (symlinks included) and shell commands approval-gated by default — see [Workspace tools](docs/workspace-tools.md) @@ -257,6 +257,7 @@ console.log(session.id, session.messages.length); - [Evals](docs/evals.md) - trajectory evals with `defineEval()`, datasets, `mockModel`, judge evals, `loushy eval` with JUnit/JSON reports - [Sessions](docs/sessions.md) - multi-turn conversations: `agent.session()`, `MemorySessionStore`, `FileSessionStore` - [Skills](docs/skills.md) - on-demand instructions: `defineSkill()`, `loadSkills()`, how they save context +- [Sub-agents](docs/sub-agents.md) - the `subagents` option and its `task` tool, what sub-agents inherit, approvals inside sub-agents - [Agent directories](docs/agent-directories.md) - define an agent as a folder: layout, mapping to `createAgent()`, moving to code, security - [Agent Forge](docs/agent-forge.md) - the visual dashboard (`loushy studio`): quickstart, first-agent walkthrough, hook authoring - Full guides site: [linuxdevil.github.io/agent-sdk-docs](https://linuxdevil.github.io/agent-sdk-docs/) @@ -412,10 +413,27 @@ await AgentExecutor.execute({ agent, input, provider, sessionId: 'session-123', await AgentExecutor.execute({ agent, input: 'continue', provider, sessionId: 'session-123', checkpointStore }); ``` -### Multi-agent delegation +### Sub-agents and delegation -Wrap a child `AgentConfig` as a tool so a parent agent can delegate a task to -it, running the child through `AgentExecutor.execute()` under the hood: +Give a lead agent named sub-agents; it delegates with one auto-registered +`task` tool, and each sub-agent sees only the task prompt: + +```typescript +import { createAgent } from '@loushy/build-ai-agent'; + +const researcher = createAgent({ provider, instructions: 'You research.', description: 'Finds and summarizes sources' }); +const writer = createAgent({ provider, instructions: 'You write.', description: 'Turns notes into an article' }); + +const lead = createAgent({ provider, instructions: 'You coordinate.', subagents: { researcher, writer } }); +const { text } = await lead.send('Write a short article about bicycles.'); +``` + +See [Sub-agents](docs/sub-agents.md) for catalogs, depth limits and how hooks, +tracing and approvals carry into sub-agents. + +To wire one child `AgentConfig` as a tool yourself, use `createDelegateTool()` +(it runs the child through `AgentExecutor.execute()` and inherits the parent +run the same way): ```typescript import { AgentExecutor, createDelegateTool, ToolRegistry, AgentType } from '@loushy/build-ai-agent'; @@ -658,7 +676,7 @@ npm run pipeline:demo:trigger # POSTs a synthetic error to kick it off - **`resumeAfterApproval()`**, **`StorageServiceApprovalStore`** - human-in-the-loop - **`LocalStorageCheckpointStore`** - durable execution -- **`createDelegateTool()`** - multi-agent delegation +- **`createDelegateTool()`** - delegate to one child agent through a tool (see `subagents` for named sub-agents) - **`runGuardrails()`**, **`secretScanGuardrail`**, **`createDiffSizeGuardrail()`**, **`createCommandGuardrail()`** - guardrails - **`withSpan()`**, **`TraceExporter`** - tracing - **`defineEval()`**, **`exactMatch`**, **`toolCallOrder`**, **`budget`**, **`llmJudge()`** - evals diff --git a/docs/api-overview.md b/docs/api-overview.md index 3eaf0c930..a7b4272b2 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -19,6 +19,16 @@ npm run docs:build # writes docs/api/index.html | `resumeAfterApproval()` | Resume an execution paused for human approval. | | `createDelegateTool()` | Wrap a child agent as a tool for multi-agent delegation. | +### Sub-agents + +Pass `subagents: { researcher, writer }` (agents from `createAgent()` with a +`description`, or a `{ list, resolve }` catalog) to `createAgent()` or +`AgentExecutor.execute()`: the lead gets one `task` tool and a prompt listing, +each sub-agent runs on the task prompt alone, and it inherits the lead run's +signal, hooks (`ctx.subagent`), tracing, approval store and `onEvent` +(`event.subagent`). `maxSubagentDepth` (default 1) bounds nesting. See +[Sub-agents](./sub-agents.md). + ### Skills Pass `skills: [defineSkill({ name, description, content }), ...(await loadSkills(dir))]` to diff --git a/docs/streaming.md b/docs/streaming.md index 458937f09..8ab56aa22 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -71,6 +71,7 @@ Every event has these fields: | `seq` | number | `0` for the first event, then `+1` per event, with no gaps. | | `timestamp` | string | When the event was emitted, ISO 8601 (`2026-10-01T09:30:00.000Z`). | | `v` | `1` | Schema version, exported as `AGENT_EVENT_SCHEMA_VERSION`. | +| `subagent` | object, optional | Only on events of a sub-agent's run - see [Sub-agents](#sub-agents). | The event types and their extra fields: @@ -158,6 +159,39 @@ if (isAgentEvent(received)) console.log(render(received), toolNames([received])) removed, renamed or retyped). New event types and new optional fields can be added without changing `v`, so ignore event types you do not know. +## Sub-agents + +When the agent delegates with the `task` tool or a `createDelegateTool()` +tool (see [Sub-agents](./sub-agents.md)), the sub-agent's run is streamed +inside the same stream: its steps, `text.delta`s, `text.done`s, tool events +and errors appear between the lead's `tool.start` and `tool.done` (or +`tool.error`) for that call, each with a `subagent` field: + +```json +{ "name": "researcher", "depth": 1, "toolCallId": "call_1", "description": "find sources" } +``` + +`toolCallId` is the lead's tool call that started the sub-agent; a sub-agent +of a sub-agent has `depth: 2` and the enclosing one as `parent`. Events +without `subagent` are the top-level run's: the ordering guarantees above hold +for them, and separately for each sub-agent's steps (several sub-agents +running in parallel interleave). `run.start` and `run.done` belong to the +top-level run only, so they still come exactly once. A sub-agent that pauses +for approval is reported once, by the top-level `approval.requested` (which +carries the sub-agent's call). + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const researcher = createAgent({ provider, instructions: 'You research.', description: 'Finds sources' }); +const lead = createAgent({ provider, instructions: 'You coordinate.', subagents: { researcher } }); + +for await (const event of lead.stream('Write about bicycles')) { + const who = event.subagent ? `[${event.subagent.name}] ` : ''; + if (event.type === 'text.delta') process.stdout.write(who + event.text); +} +``` + ## Token streaming and providers When the provider implements `stream()` (all built-in providers and diff --git a/docs/sub-agents.md b/docs/sub-agents.md new file mode 100644 index 000000000..45e990c9d --- /dev/null +++ b/docs/sub-agents.md @@ -0,0 +1,237 @@ +# Sub-agents + +A sub-agent is a separate agent - its own instructions, model, tools and +skills - that a lead agent hands a self-contained task to. The sub-agent +starts with a clean context (it sees only the task prompt, never the lead's +conversation), works through as many steps as it needs, and its final answer +comes back to the lead as one tool result. + +## Sub-agents, skills or flows? + +| Use | When | +| --- | --- | +| **Skills** ([docs](./skills.md)) | The *same* agent needs extra instructions for some tasks. Cheap: a skill is text loaded into the current conversation. | +| **Sub-agents** | A task needs different tools, a different model, or a long tool-heavy exploration you do not want in the lead's context. The lead decides at run time whether and what to delegate; independent tasks run in parallel. | +| **Flows** | The steps and their order are known up front (a pipeline), so they should not be left to a model's judgment. | + +## Quick start + +Give each sub-agent a `description` (the lead model reads it to choose), then +pass them to the lead as `subagents`: + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const researcher = createAgent({ + provider, + instructions: 'You research a question and report your sources.', + description: 'Finds and summarizes sources', +}); +const writer = createAgent({ + provider, + instructions: 'You write clear, short articles.', + description: 'Turns notes into a polished article', +}); + +const lead = createAgent({ + provider, + instructions: 'You coordinate research and writing.', + subagents: { researcher, writer }, +}); + +const { text } = await lead.send('Write a short article about the history of the bicycle.'); +``` + +`AgentExecutor.execute()` takes the same `subagents` and `maxSubagentDepth` +options. Use it when you need hooks, tracing or approvals on the lead run - +sub-agents inherit those (see [Inheritance](#what-a-sub-agent-inherits)). + +## How it works + +With `subagents`, the lead agent gets: + +1. An **Available sub-agents** block in its system prompt: one line per + sub-agent (`- name: description`), plus a sentence saying that a sub-agent + sees only the prompt it is given. +2. ONE tool, `task`, with the input + `{ agent: , prompt: string, description: string }` + (`description` is a 3-5 word label used in events and hooks). + +When the lead calls `task`, the named sub-agent runs on `prompt` alone. The +tool result is the sub-agent's final text plus a small footer the lead can +use: + +```text +The first bicycles appeared in the 1810s ... + +[sub-agent 'researcher': 3 step(s), finish reason 'stop'] +``` + +If the sub-agent does not finish - it throws, runs out of `maxSteps`, or is +aborted - the lead gets an error result (`isError: true`) that says why, for +example `Sub-agent 'researcher' used all 10 of its steps (maxSteps) without +giving a final answer.` An unknown agent name is also an error result listing +the valid names. Sub-agent token usage is added to the lead's `result.usage`. + +Errors at setup time are thrown with a fix: a sub-agent without a +`description`, a value that is not a `createAgent()` agent, or a tool of your +own already named `task`. + +## Parallel tasks + +Tool calls of one model turn run concurrently (see `toolConcurrency`), so when +the lead calls `task` several times in one turn, those sub-agents run in +parallel. Results reach the lead's transcript in the order the model made the +calls. Cap it with `toolConcurrency` on the lead (`1` runs them one at a time). + +## Dynamic catalogs + +Instead of a fixed record, pass a catalog with `list()` and `resolve(name)`. +`list()` is called at the start of every run that offers the `task` tool, so +the set can change between runs; `resolve()` is called when the lead picks a +name (return `undefined` for an unknown one). + +```ts +import { createAgent, type SubagentCatalog } from '@loushy/build-ai-agent'; + +const experts = new Map([ + ['researcher', createAgent({ provider, instructions: 'You research.' })], +]); + +const catalog: SubagentCatalog = { + list: async () => [{ name: 'researcher', description: 'Finds and summarizes sources' }], + resolve: async (name) => experts.get(name), +}; + +const lead = createAgent({ provider, instructions: 'You coordinate.', subagents: catalog }); +``` + +## Depth + +`maxSubagentDepth` (default `1`) bounds how deep sub-agents nest. With the +default, the lead can call sub-agents but they cannot call sub-agents of their +own: a run at the limit is simply not offered the `task` tool, even if it was +created with `subagents`. `maxSubagentDepth: 2` lets the lead's sub-agents +delegate once more. The top-level run's value applies to the whole tree; a +sub-agent's own `maxSubagentDepth` only matters when it runs as a top-level +agent. + +## What a sub-agent inherits + +The sub-agent keeps its own instructions, model/provider, tools, skills and +`maxSteps`, and its context is isolated. From the run that called it, it +inherits: + +| Runtime setting | Inherited? | Notes | +| --- | --- | --- | +| Abort `signal` | Yes | Aborting the lead aborts its sub-agents. | +| Tracing (`exporter`) | Yes | The sub-agent's `invoke_agent` span is a child of the lead's `execute_tool task` span. `captureContent` and `redactContent` are inherited too. | +| Hooks (`hooks`) | Yes | They run on the sub-agent's model calls and tool calls, with `ctx.subagent` set (see below). A hook that throws inside a sub-agent halts the whole run. | +| Approval store (`approvalStore`) | Yes | See [Approvals](#approvals-inside-a-sub-agent). | +| `onEvent` and `stream()` | Yes | The sub-agent's events are forwarded with a `subagent` field. | +| `toolConcurrency` | Yes, unless the sub-agent sets its own | | +| `sandbox` | Yes | | +| Token usage | Rolls up | Added to the lead's `result.usage` (totals, `byModel`, and `usage.delegated`). | +| `maxSubagentDepth` | The remaining budget | See [Depth](#depth). | +| `onLLMRequest`, `onToolCall` and the other single-run callbacks | No | They describe one run; use hooks or `onEvent` to observe sub-agents. | +| `sessionId` / `checkpointStore` | No | A sub-agent is not checkpointed on its own. If the process dies while a sub-agent runs, the resumed lead runs that `task` call again. | +| Conversation history | No | The sub-agent sees only the task prompt. | + +`createDelegateTool()` children inherit the same way. + +### Hooks inside sub-agents + +A parent's hooks apply to its sub-agents by default. `ctx.subagent` tells a +hook it is running inside one - the sub-agent's `name`, its `depth` (1 for a +sub-agent of the top-level run), the lead's `toolCallId` that started it, and +the enclosing sub-agent as `parent` when nested deeper. A hook that should only +see the top-level run returns early: + +```ts +import { HookRegistry } from '@loushy/build-ai-agent'; + +const hooks = new HookRegistry(); +hooks.register({ + name: 'audit', + preToolCall(ctx) { + if (ctx.subagent) return; // top-level tool calls only + console.log('tool call', ctx.toolName, ctx.args); + }, +}); +``` + +### Events + +`agent.stream()` / `AgentExecutor.stream()` on the lead stream each +sub-agent's run inside the same stream - steps, text deltas, tool events and +errors - with a `subagent` field on every one of its events, so a UI can nest +sub-agent activity under the lead's `task` call (`subagent.toolCallId`). See +[Streaming: sub-agents](./streaming.md#sub-agents). + +With `onEvent` on the lead, every event a sub-agent emits (`start`, +`tool-call`, `tool-result`, `text-complete`, `finish`, ...) reaches the same +listener with `event.subagent` set. The lead's own events have no `subagent` +field. + +## Approvals inside a sub-agent + +When a sub-agent calls a tool that `needsApproval`, the **whole run pauses**: +the lead's `execute()` resolves with `finishReason: 'awaiting-approval'` and +an `approvalId`, and the approval store holds ONE record whose pending call is +the sub-agent's call (`toolName`, `args`) with `subagentPath` naming the +sub-agents it runs inside (e.g. `['researcher']`). Approve or reject it with +`resumeAfterApproval()`, passing the same `subagents` option as the paused run: + +```ts +import { AgentExecutor, resumeAfterApproval, ToolRegistry, createAgent } from '@loushy/build-ai-agent'; + +const subagents = { researcher: createAgent({ provider, instructions: 'You research.', description: 'Researches' }) }; + +const paused = await AgentExecutor.execute({ agent, input: 'Go', provider, subagents, approvalStore }); +if (paused.finishReason === 'awaiting-approval' && paused.approvalId) { + const result = await resumeAfterApproval( + { id: paused.approvalId, approved: true }, + approvalStore, + new ToolRegistry(), + provider, + { subagents } + ); + console.log(result.text); +} +``` + +Resuming runs (or rejects) the sub-agent's pending call, lets the sub-agent +finish, returns its final answer to the lead as the `task` result, and then +continues the lead. If the sub-agent needs another approval, the run pauses +again with a new `approvalId`. This works at any depth and for +`createDelegateTool()` children (pass the registry that holds the delegate +tool). + +Guarantees and limits: + +- No call runs twice and none is lost: the sub-agent's paused state is stored + inside the lead's approval record (plain JSON, so any `ApprovalStore` + works), and the approved call runs exactly once, on resume. +- Other tool calls of the same lead turn that finished keep their results and + are not run again. +- One approval is pending per run. If two sub-agents pause in the same turn, + the run pauses on the first (in call order); the second sub-agent stops and + the lead gets an error result for that `task` call saying its call was not + run, so it can ask again after the approval. +- A `task` call's pre/post tool hooks fire again on resume, like the hooks of + any approved tool. +- Approvals need an `approvalStore` on the lead run, which `createAgent()` + does not take yet; use `AgentExecutor.execute()` for the lead. Without one, + the sub-agent's call becomes an error result and nothing runs. +- Token usage keeps adding up across the pause: the sub-agent's usage before + the pause is in the paused result, and what it spends after the resume is + added to the resumed lead's `result.usage`. + +## `createDelegateTool()` + +`createDelegateTool({ agent, provider, toolRegistry })` wraps one child agent as +a tool you name and register yourself, with its own `maxDepth` guard. It runs +on the same delegation core as `task`, so it inherits the parent runtime the +same way, and its result shape (`{ text, usage }`) is unchanged. Prefer +`subagents` for new code: one tool, a prompt listing, parallel tasks and depth +limits come for free. diff --git a/llms-full.txt b/llms-full.txt index 126bd81e3..88d5d9d41 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -27,8 +27,8 @@ dashboard — any provider, any deploy target, no lock-in. - **Parallel tool calls** — when the model asks for several tools in one turn they run concurrently (cap it with `toolConcurrency`, or `1` for sequential), and results still reach the transcript in the model's call order - **Usage & cost** — every result carries `usage` (tokens, USD cost, per-model breakdown, delegated children included, continuing across resumes) and `formatUsage(result.usage)` for logs - **Skills** — `defineSkill()` / `loadSkills('./skills')`: only each skill's name and description sit in the system prompt; the model loads the full markdown on demand through an auto-registered `load_skill` tool +- **Sub-agents** — `createAgent({ subagents: { researcher, writer } })` gives the lead one `task` tool: each sub-agent runs on the task prompt alone (isolated context) with its own instructions, model and tools, several tasks in one turn run in parallel, and sub-agents inherit the lead run's abort signal, hooks, tracing, approvals and events (`maxSubagentDepth`, dynamic `{ list, resolve }` catalogs; `createDelegateTool()` for a single hand-wired child) — see [Sub-agents](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sub-agents.md) - **Agents as directories** — `loadAgentDir('./my-agent')` turns `instructions.md`, `tools/*.ts`, `skills/` and `subagents//` into the same object `createAgent()` returns, so you can start with files and drop to code later — see [Agent directories](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/agent-directories.md) -- **Multi-agent delegation** — wrap a child agent as a tool with `createDelegateTool()`, with a `maxDepth` guard against delegation loops - **Pre/post hooks** — a `HookRegistry` of `AgentHook`s that can inspect or mutate a tool call/LLM generate step, or throw to abort it, run sandboxed by Agent Forge's hook editor - **Guardrails** — fail-closed, concurrently-run checks (secret scan, diff size, test/lint commands) that gate a fixer agent's patch before it's used - **Workspace tools** — `createFsTools()` (`read_file`, `write_file`, `edit_file`, `list_dir`, `glob`, `grep`) and `createShellTool()` over pluggable `FsProvider`/`ShellProvider` backends (`NodeWorkspace`, `MemoryWorkspace`, Docker-backed `SandboxShell`), with paths confined to the workspace root (symlinks included) and shell commands approval-gated by default — see [Workspace tools](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/workspace-tools.md) @@ -255,6 +255,7 @@ console.log(session.id, session.messages.length); - [Evals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/evals.md) - trajectory evals with `defineEval()`, datasets, `mockModel`, judge evals, `loushy eval` with JUnit/JSON reports - [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md) - multi-turn conversations: `agent.session()`, `MemorySessionStore`, `FileSessionStore` - [Skills](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/skills.md) - on-demand instructions: `defineSkill()`, `loadSkills()`, how they save context +- [Sub-agents](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sub-agents.md) - the `subagents` option and its `task` tool, what sub-agents inherit, approvals inside sub-agents - [Agent directories](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/agent-directories.md) - define an agent as a folder: layout, mapping to `createAgent()`, moving to code, security - [Agent Forge](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/agent-forge.md) - the visual dashboard (`loushy studio`): quickstart, first-agent walkthrough, hook authoring - Full guides site: [linuxdevil.github.io/agent-sdk-docs](https://linuxdevil.github.io/agent-sdk-docs/) @@ -410,10 +411,27 @@ await AgentExecutor.execute({ agent, input, provider, sessionId: 'session-123', await AgentExecutor.execute({ agent, input: 'continue', provider, sessionId: 'session-123', checkpointStore }); ``` -### Multi-agent delegation +### Sub-agents and delegation -Wrap a child `AgentConfig` as a tool so a parent agent can delegate a task to -it, running the child through `AgentExecutor.execute()` under the hood: +Give a lead agent named sub-agents; it delegates with one auto-registered +`task` tool, and each sub-agent sees only the task prompt: + +```typescript +import { createAgent } from '@loushy/build-ai-agent'; + +const researcher = createAgent({ provider, instructions: 'You research.', description: 'Finds and summarizes sources' }); +const writer = createAgent({ provider, instructions: 'You write.', description: 'Turns notes into an article' }); + +const lead = createAgent({ provider, instructions: 'You coordinate.', subagents: { researcher, writer } }); +const { text } = await lead.send('Write a short article about bicycles.'); +``` + +See [Sub-agents](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sub-agents.md) for catalogs, depth limits and how hooks, +tracing and approvals carry into sub-agents. + +To wire one child `AgentConfig` as a tool yourself, use `createDelegateTool()` +(it runs the child through `AgentExecutor.execute()` and inherits the parent +run the same way): ```typescript import { AgentExecutor, createDelegateTool, ToolRegistry, AgentType } from '@loushy/build-ai-agent'; @@ -656,7 +674,7 @@ npm run pipeline:demo:trigger # POSTs a synthetic error to kick it off - **`resumeAfterApproval()`**, **`StorageServiceApprovalStore`** - human-in-the-loop - **`LocalStorageCheckpointStore`** - durable execution -- **`createDelegateTool()`** - multi-agent delegation +- **`createDelegateTool()`** - delegate to one child agent through a tool (see `subagents` for named sub-agents) - **`runGuardrails()`**, **`secretScanGuardrail`**, **`createDiffSizeGuardrail()`**, **`createCommandGuardrail()`** - guardrails - **`withSpan()`**, **`TraceExporter`** - tracing - **`defineEval()`**, **`exactMatch`**, **`toolCallOrder`**, **`budget`**, **`llmJudge()`** - evals @@ -1165,6 +1183,16 @@ npm run docs:build # writes docs/api/index.html | `resumeAfterApproval()` | Resume an execution paused for human approval. | | `createDelegateTool()` | Wrap a child agent as a tool for multi-agent delegation. | +### Sub-agents + +Pass `subagents: { researcher, writer }` (agents from `createAgent()` with a +`description`, or a `{ list, resolve }` catalog) to `createAgent()` or +`AgentExecutor.execute()`: the lead gets one `task` tool and a prompt listing, +each sub-agent runs on the task prompt alone, and it inherits the lead run's +signal, hooks (`ctx.subagent`), tracing, approval store and `onEvent` +(`event.subagent`). `maxSubagentDepth` (default 1) bounds nesting. See +[Sub-agents](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sub-agents.md). + ### Skills Pass `skills: [defineSkill({ name, description, content }), ...(await loadSkills(dir))]` to @@ -3263,6 +3291,7 @@ Every event has these fields: | `seq` | number | `0` for the first event, then `+1` per event, with no gaps. | | `timestamp` | string | When the event was emitted, ISO 8601 (`2026-10-01T09:30:00.000Z`). | | `v` | `1` | Schema version, exported as `AGENT_EVENT_SCHEMA_VERSION`. | +| `subagent` | object, optional | Only on events of a sub-agent's run - see [Sub-agents](#sub-agents). | The event types and their extra fields: @@ -3350,6 +3379,39 @@ if (isAgentEvent(received)) console.log(render(received), toolNames([received])) removed, renamed or retyped). New event types and new optional fields can be added without changing `v`, so ignore event types you do not know. +## Sub-agents + +When the agent delegates with the `task` tool or a `createDelegateTool()` +tool (see [Sub-agents](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sub-agents.md)), the sub-agent's run is streamed +inside the same stream: its steps, `text.delta`s, `text.done`s, tool events +and errors appear between the lead's `tool.start` and `tool.done` (or +`tool.error`) for that call, each with a `subagent` field: + +```json +{ "name": "researcher", "depth": 1, "toolCallId": "call_1", "description": "find sources" } +``` + +`toolCallId` is the lead's tool call that started the sub-agent; a sub-agent +of a sub-agent has `depth: 2` and the enclosing one as `parent`. Events +without `subagent` are the top-level run's: the ordering guarantees above hold +for them, and separately for each sub-agent's steps (several sub-agents +running in parallel interleave). `run.start` and `run.done` belong to the +top-level run only, so they still come exactly once. A sub-agent that pauses +for approval is reported once, by the top-level `approval.requested` (which +carries the sub-agent's call). + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const researcher = createAgent({ provider, instructions: 'You research.', description: 'Finds sources' }); +const lead = createAgent({ provider, instructions: 'You coordinate.', subagents: { researcher } }); + +for await (const event of lead.stream('Write about bicycles')) { + const who = event.subagent ? `[${event.subagent.name}] ` : ''; + if (event.type === 'text.delta') process.stdout.write(who + event.text); +} +``` + ## Token streaming and providers When the provider implements `stream()` (all built-in providers and @@ -3477,6 +3539,246 @@ for await (const event of run) { const paused = await run.result; // paused.finishReason === 'awaiting-approval' ``` +# Sub-agents + +Source: docs/sub-agents.md + +A sub-agent is a separate agent - its own instructions, model, tools and +skills - that a lead agent hands a self-contained task to. The sub-agent +starts with a clean context (it sees only the task prompt, never the lead's +conversation), works through as many steps as it needs, and its final answer +comes back to the lead as one tool result. + +## Sub-agents, skills or flows? + +| Use | When | +| --- | --- | +| **Skills** ([docs](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/skills.md)) | The *same* agent needs extra instructions for some tasks. Cheap: a skill is text loaded into the current conversation. | +| **Sub-agents** | A task needs different tools, a different model, or a long tool-heavy exploration you do not want in the lead's context. The lead decides at run time whether and what to delegate; independent tasks run in parallel. | +| **Flows** | The steps and their order are known up front (a pipeline), so they should not be left to a model's judgment. | + +## Quick start + +Give each sub-agent a `description` (the lead model reads it to choose), then +pass them to the lead as `subagents`: + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const researcher = createAgent({ + provider, + instructions: 'You research a question and report your sources.', + description: 'Finds and summarizes sources', +}); +const writer = createAgent({ + provider, + instructions: 'You write clear, short articles.', + description: 'Turns notes into a polished article', +}); + +const lead = createAgent({ + provider, + instructions: 'You coordinate research and writing.', + subagents: { researcher, writer }, +}); + +const { text } = await lead.send('Write a short article about the history of the bicycle.'); +``` + +`AgentExecutor.execute()` takes the same `subagents` and `maxSubagentDepth` +options. Use it when you need hooks, tracing or approvals on the lead run - +sub-agents inherit those (see [Inheritance](#what-a-sub-agent-inherits)). + +## How it works + +With `subagents`, the lead agent gets: + +1. An **Available sub-agents** block in its system prompt: one line per + sub-agent (`- name: description`), plus a sentence saying that a sub-agent + sees only the prompt it is given. +2. ONE tool, `task`, with the input + `{ agent: , prompt: string, description: string }` + (`description` is a 3-5 word label used in events and hooks). + +When the lead calls `task`, the named sub-agent runs on `prompt` alone. The +tool result is the sub-agent's final text plus a small footer the lead can +use: + +```text +The first bicycles appeared in the 1810s ... + +[sub-agent 'researcher': 3 step(s), finish reason 'stop'] +``` + +If the sub-agent does not finish - it throws, runs out of `maxSteps`, or is +aborted - the lead gets an error result (`isError: true`) that says why, for +example `Sub-agent 'researcher' used all 10 of its steps (maxSteps) without +giving a final answer.` An unknown agent name is also an error result listing +the valid names. Sub-agent token usage is added to the lead's `result.usage`. + +Errors at setup time are thrown with a fix: a sub-agent without a +`description`, a value that is not a `createAgent()` agent, or a tool of your +own already named `task`. + +## Parallel tasks + +Tool calls of one model turn run concurrently (see `toolConcurrency`), so when +the lead calls `task` several times in one turn, those sub-agents run in +parallel. Results reach the lead's transcript in the order the model made the +calls. Cap it with `toolConcurrency` on the lead (`1` runs them one at a time). + +## Dynamic catalogs + +Instead of a fixed record, pass a catalog with `list()` and `resolve(name)`. +`list()` is called at the start of every run that offers the `task` tool, so +the set can change between runs; `resolve()` is called when the lead picks a +name (return `undefined` for an unknown one). + +```ts +import { createAgent, type SubagentCatalog } from '@loushy/build-ai-agent'; + +const experts = new Map([ + ['researcher', createAgent({ provider, instructions: 'You research.' })], +]); + +const catalog: SubagentCatalog = { + list: async () => [{ name: 'researcher', description: 'Finds and summarizes sources' }], + resolve: async (name) => experts.get(name), +}; + +const lead = createAgent({ provider, instructions: 'You coordinate.', subagents: catalog }); +``` + +## Depth + +`maxSubagentDepth` (default `1`) bounds how deep sub-agents nest. With the +default, the lead can call sub-agents but they cannot call sub-agents of their +own: a run at the limit is simply not offered the `task` tool, even if it was +created with `subagents`. `maxSubagentDepth: 2` lets the lead's sub-agents +delegate once more. The top-level run's value applies to the whole tree; a +sub-agent's own `maxSubagentDepth` only matters when it runs as a top-level +agent. + +## What a sub-agent inherits + +The sub-agent keeps its own instructions, model/provider, tools, skills and +`maxSteps`, and its context is isolated. From the run that called it, it +inherits: + +| Runtime setting | Inherited? | Notes | +| --- | --- | --- | +| Abort `signal` | Yes | Aborting the lead aborts its sub-agents. | +| Tracing (`exporter`) | Yes | The sub-agent's `invoke_agent` span is a child of the lead's `execute_tool task` span. `captureContent` and `redactContent` are inherited too. | +| Hooks (`hooks`) | Yes | They run on the sub-agent's model calls and tool calls, with `ctx.subagent` set (see below). A hook that throws inside a sub-agent halts the whole run. | +| Approval store (`approvalStore`) | Yes | See [Approvals](#approvals-inside-a-sub-agent). | +| `onEvent` and `stream()` | Yes | The sub-agent's events are forwarded with a `subagent` field. | +| `toolConcurrency` | Yes, unless the sub-agent sets its own | | +| `sandbox` | Yes | | +| Token usage | Rolls up | Added to the lead's `result.usage` (totals, `byModel`, and `usage.delegated`). | +| `maxSubagentDepth` | The remaining budget | See [Depth](#depth). | +| `onLLMRequest`, `onToolCall` and the other single-run callbacks | No | They describe one run; use hooks or `onEvent` to observe sub-agents. | +| `sessionId` / `checkpointStore` | No | A sub-agent is not checkpointed on its own. If the process dies while a sub-agent runs, the resumed lead runs that `task` call again. | +| Conversation history | No | The sub-agent sees only the task prompt. | + +`createDelegateTool()` children inherit the same way. + +### Hooks inside sub-agents + +A parent's hooks apply to its sub-agents by default. `ctx.subagent` tells a +hook it is running inside one - the sub-agent's `name`, its `depth` (1 for a +sub-agent of the top-level run), the lead's `toolCallId` that started it, and +the enclosing sub-agent as `parent` when nested deeper. A hook that should only +see the top-level run returns early: + +```ts +import { HookRegistry } from '@loushy/build-ai-agent'; + +const hooks = new HookRegistry(); +hooks.register({ + name: 'audit', + preToolCall(ctx) { + if (ctx.subagent) return; // top-level tool calls only + console.log('tool call', ctx.toolName, ctx.args); + }, +}); +``` + +### Events + +`agent.stream()` / `AgentExecutor.stream()` on the lead stream each +sub-agent's run inside the same stream - steps, text deltas, tool events and +errors - with a `subagent` field on every one of its events, so a UI can nest +sub-agent activity under the lead's `task` call (`subagent.toolCallId`). See +[Streaming: sub-agents](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/streaming.md#sub-agents). + +With `onEvent` on the lead, every event a sub-agent emits (`start`, +`tool-call`, `tool-result`, `text-complete`, `finish`, ...) reaches the same +listener with `event.subagent` set. The lead's own events have no `subagent` +field. + +## Approvals inside a sub-agent + +When a sub-agent calls a tool that `needsApproval`, the **whole run pauses**: +the lead's `execute()` resolves with `finishReason: 'awaiting-approval'` and +an `approvalId`, and the approval store holds ONE record whose pending call is +the sub-agent's call (`toolName`, `args`) with `subagentPath` naming the +sub-agents it runs inside (e.g. `['researcher']`). Approve or reject it with +`resumeAfterApproval()`, passing the same `subagents` option as the paused run: + +```ts +import { AgentExecutor, resumeAfterApproval, ToolRegistry, createAgent } from '@loushy/build-ai-agent'; + +const subagents = { researcher: createAgent({ provider, instructions: 'You research.', description: 'Researches' }) }; + +const paused = await AgentExecutor.execute({ agent, input: 'Go', provider, subagents, approvalStore }); +if (paused.finishReason === 'awaiting-approval' && paused.approvalId) { + const result = await resumeAfterApproval( + { id: paused.approvalId, approved: true }, + approvalStore, + new ToolRegistry(), + provider, + { subagents } + ); + console.log(result.text); +} +``` + +Resuming runs (or rejects) the sub-agent's pending call, lets the sub-agent +finish, returns its final answer to the lead as the `task` result, and then +continues the lead. If the sub-agent needs another approval, the run pauses +again with a new `approvalId`. This works at any depth and for +`createDelegateTool()` children (pass the registry that holds the delegate +tool). + +Guarantees and limits: + +- No call runs twice and none is lost: the sub-agent's paused state is stored + inside the lead's approval record (plain JSON, so any `ApprovalStore` + works), and the approved call runs exactly once, on resume. +- Other tool calls of the same lead turn that finished keep their results and + are not run again. +- One approval is pending per run. If two sub-agents pause in the same turn, + the run pauses on the first (in call order); the second sub-agent stops and + the lead gets an error result for that `task` call saying its call was not + run, so it can ask again after the approval. +- A `task` call's pre/post tool hooks fire again on resume, like the hooks of + any approved tool. +- Approvals need an `approvalStore` on the lead run, which `createAgent()` + does not take yet; use `AgentExecutor.execute()` for the lead. Without one, + the sub-agent's call becomes an error result and nothing runs. +- Token usage keeps adding up across the pause: the sub-agent's usage before + the pause is in the paused result, and what it spends after the resume is + added to the resumed lead's `result.usage`. + +## `createDelegateTool()` + +`createDelegateTool({ agent, provider, toolRegistry })` wraps one child agent as +a tool you name and register yourself, with its own `maxDepth` guard. It runs +on the same delegation core as `task`, so it inherits the parent runtime the +same way, and its result shape (`{ text, usage }`) is unchanged. Prefer +`subagents` for new code: one tool, a prompt listing, parallel tasks and depth +limits come for free. + # Testing agents Source: docs/testing.md diff --git a/llms.txt b/llms.txt index 5b8b7f742..a62e121ae 100644 --- a/llms.txt +++ b/llms.txt @@ -15,6 +15,7 @@ - [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md): `agent.send(text)` is single-turn: every call starts from an empty history. - [Skills](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/skills.md): A skill is a named chunk of instructions (a markdown document) that the model loads only when it needs it. - [Streaming](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/streaming.md): `agent.stream()` runs an agent exactly like `agent.send()` and reports the run as a stream of typed events: model text as it arrives, every tool call, step boundaries, approval requests, and a final `run.done`. +- [Sub-agents](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sub-agents.md): A sub-agent is a separate agent - its own instructions, model, tools and skills - that a lead agent hands a self-contained task to. - [Testing agents](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/testing.md): `@loushy/build-ai-agent/testing` ships `mockModel`: a scripted, deterministic `LLMProvider` for unit tests. - [Workspace tools](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/workspace-tools.md): Workspace tools give an agent a file system and a shell, so you can build coding agents in the style of Claude Code. diff --git a/src/createAgent.ts b/src/createAgent.ts index b0a55a203..66e97f5f4 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -30,6 +30,9 @@ import type { Message } from './providers/llm'; import { AgentSession, type SessionOptions } from './session/AgentSession'; import { loadProjectInstructions } from './projectInstructions'; import { basename } from 'node:path'; +import type { Subagents } from './subagents/types'; +import { assertMaxSubagentDepth, assertNoTaskTool, assertSubagents, registerSubagent } from './subagents/withSubagents'; +import type { SubagentSpec } from './execution/delegation'; /** * Options for createAgent() that do not depend on how the instructions and @@ -57,6 +60,37 @@ export interface CreateAgentBase { skills?: readonly Skill[]; /** Optional agent name; defaults to 'agent'. */ name?: string; + /** + * What this agent does, in a sentence (LOU-Y3). Required when the agent is + * used as a sub-agent: the lead model reads it to decide which sub-agent + * gets a task. + * + * @example + * ```ts + * const researcher = createAgent({ instructions: 'You research...', description: 'Finds and summarizes sources', provider }); + * ``` + */ + description?: string; + /** + * Sub-agents this agent can delegate to (LOU-Y3): `createAgent()` agents + * keyed by name (each with a `description`), or a `{ list, resolve }` + * catalog. Registers one `task` tool and lists the sub-agents in the system + * prompt. Each sub-agent sees only the task prompt and runs with its own + * instructions, model and tools. Several `task` calls in one turn run in + * parallel. See docs/sub-agents.md. + * + * @example + * ```ts + * const lead = createAgent({ instructions: 'You coordinate...', provider, subagents: { researcher, writer } }); + * ``` + */ + subagents?: Subagents; + /** + * How deep sub-agents may nest. Defaults to 1: this agent's sub-agents + * cannot call sub-agents of their own (they are not offered the `task` + * tool). The top-level agent's value applies to the whole tree. + */ + maxSubagentDepth?: number; /** Optional maxSteps passed through to AgentExecutor.execute(). */ maxSteps?: number; /** @@ -213,6 +247,8 @@ export interface SimpleAgent { */ export function createAgent(config: CreateAgentConfig = {}): SimpleAgent { assertToolConcurrency(config.toolConcurrency, 'createAgent'); + assertMaxSubagentDepth(config.maxSubagentDepth, 'createAgent'); + assertSubagents(config.subagents, 'createAgent'); const instructions = withProjectInstructions(resolveInstructions(config), config.projectInstructions); const provider = resolveModelSource(config); @@ -226,21 +262,27 @@ export function createAgent(config: CreateAgentConfig = {}): SimpleAgent { // With an explicit provider, `model` is a per-agent model setting. if (config.provider && config.model) builder.setSettings({ model: config.model }); const agent = builder.build(); + if (config.subagents) assertNoTaskTool(agent, toolRegistry); - const executeOptions = (input: string | Message[], signal?: AbortSignal): ExecuteOptions => ({ + const spec: SubagentSpec = { agent, - input, provider, toolRegistry, skills: config.skills, + subagents: config.subagents, + maxSubagentDepth: config.maxSubagentDepth, maxSteps: config.maxSteps, toolConcurrency: config.toolConcurrency, + }; + const executeOptions = (input: string | Message[], signal?: AbortSignal): ExecuteOptions => ({ + ...spec, + input, signal, }); const run = (input: string | Message[], signal?: AbortSignal): Promise => AgentExecutor.execute(executeOptions(input, signal)); - return { + const simpleAgent: SimpleAgent = { async send(message: string, options: SendOptions = {}): Promise { return run(message, options.signal); }, @@ -249,6 +291,8 @@ export function createAgent(config: CreateAgentConfig = {}): SimpleAgent { }, session: (options?: SessionOptions) => new AgentSession(run, options), }; + registerSubagent(simpleAgent, { spec, description: config.description }); + return simpleAgent; } /** Appends the nearest AGENTS.md / CLAUDE.md to `instructions` when `projectInstructions` is set. */ diff --git a/src/execution/AgentExecutor.ts b/src/execution/AgentExecutor.ts index 3acbdfd0f..b8041e247 100644 --- a/src/execution/AgentExecutor.ts +++ b/src/execution/AgentExecutor.ts @@ -5,12 +5,14 @@ import type { Skill } from '../skills/defineSkill'; import { withSkills } from '../skills/withSkills'; +import type { Subagents } from '../subagents/types'; +import { assertMaxSubagentDepth, withSubagents } from '../subagents/withSubagents'; import { nanoid } from 'nanoid'; import { LLMProvider, Message, ToolCall, GenerateOptions, GenerateResult, ToolDefinition } from '../providers'; import { AgentConfig } from '../types'; import { ToolRegistry } from '../tools'; import { SandboxAdapter, NoopSandbox } from '../security/sandboxCore'; -import { ApprovalStore, ExecutionSnapshot, PendingApproval } from './ApprovalGate'; +import { ApprovalStore, ExecutionSnapshot, PendingApproval, SubagentSuspension } from './ApprovalGate'; import { CheckpointStore } from './checkpoint'; import type { CallUsage, RunUsage, StepUsage } from '../models/usage'; import { mergeDelegatedUsage } from './runUsage'; @@ -21,7 +23,13 @@ import { resolveCaptureContent, toolSpanInit, } from './genAiSpans'; -import { HookRegistry } from './hooks'; +import { HookRegistry, type SubagentInfo } from './hooks'; +import { + baseAgentOf, + settleSuspensions, + suspensionRecord, + type ToolCallScope, +} from './subagentRuntime'; import { isAbortError } from './errors'; import { PreparedToolCall, @@ -118,6 +126,13 @@ export interface ExecutionEvent { * reason to `controller.abort(reason)`). */ abortReason?: unknown; + /** + * LOU-Y1: set on events forwarded from a sub-agent (a child run started + * by the `task` tool or a `createDelegateTool()` tool): which sub-agent + * emitted it and which of this run's tool calls started it. Absent on the + * run's own events. + */ + subagent?: SubagentInfo; } /** @@ -138,6 +153,34 @@ export interface ExecuteOptions { * AgentExecutor.execute({ agent, input, provider, skills: [defineSkill({ name, description, content })] }); */ skills?: readonly Skill[]; + /** + * Sub-agents (LOU-Y3) this agent can delegate to: a record of + * `createAgent()` agents keyed by name (each needs a `description`), or a + * dynamic `{ list, resolve }` catalog (listed at the start of each run). + * Registers ONE `task` tool (`{ agent, prompt, description }`) and lists the + * sub-agents in the system prompt. A sub-agent sees only the `prompt`, and + * inherits this run's signal, hooks, tracing, approval store, + * `toolConcurrency` and `onEvent` listener (see docs/sub-agents.md). Throws + * if a tool named `task` is already registered. + * + * @example + * AgentExecutor.execute({ agent, input, provider, subagents: { researcher, writer } }); + */ + subagents?: Subagents; + /** + * How deep sub-agents may nest (LOU-Y3). Defaults to 1: the lead agent can + * call sub-agents, but they cannot call sub-agents of their own. A run at + * the limit is simply not offered the `task` tool. The top-level run's + * value applies to the whole tree. + */ + maxSubagentDepth?: number; + /** + * Span id to parent this run's `invoke_agent` span to (LOU-Y1). Set + * automatically for a sub-agent, whose span becomes a child of the + * parent's `execute_tool` span; set it yourself to nest a run inside your + * own trace. + */ + parentSpanId?: string; streaming?: boolean; maxSteps?: number; temperature?: number; @@ -428,16 +471,24 @@ export class AgentExecutor { exporter, init.name, init.attributes, - async (agentSpan) => - this.runAgentLoop( - { ...options, ...withSkills(options.agent, options.toolRegistry, options.skills) }, - agentSpan.id - ), - undefined, + async (agentSpan) => this.runAgentLoop(await this.withExtensions(options), agentSpan.id), + options.parentSpanId, init.kind ); } + /** Applies `skills` and `subagents`: their prompt blocks and their tools. */ + private static async withExtensions(options: ExecuteOptions): Promise { + const skilled = withSkills(options.agent, options.toolRegistry, options.skills); + const extended = await withSubagents( + skilled.agent, + skilled.toolRegistry, + options.subagents, + options.maxSubagentDepth + ); + return { ...options, ...extended }; + } + /** * Runs the agent like {@link AgentExecutor.execute} - same options, same * loop, same `ExecutionResult` - and streams the run as typed @@ -702,6 +753,8 @@ export class AgentExecutor { ): Promise { const { onEvent } = options; state.toolCalls.push(...toolCalls); + // LOU-Y1: tool calls whose sub-agent paused for approval, in call order. + const suspensions: SubagentSuspension[] = []; // Add assistant message with tool calls state.messages.push({ @@ -720,13 +773,16 @@ export class AgentExecutor { }, onComplete: (toolResult) => this.emitEvent(onEvent, { type: 'tool-result', timestamp: new Date(), toolResult }), - record: (toolCall, outcome) => pushToolResult(state, toolCall, outcome), + record: (toolCall, outcome) => { + pushToolResult(state, toolCall, outcome); + if (outcome.subagent) suspensions.push(outcome.subagent); + }, persist: () => saveStepCheckpoint(options, state), }, options.signal ); - return this.settleToolBatch(options, state, batch); + return this.settleToolBatch(options, state, batch, suspensions); } /** @@ -737,7 +793,8 @@ export class AgentExecutor { private static settleToolBatch( options: ExecuteOptions, state: AgentRunState, - batch: ToolBatchResult + batch: ToolBatchResult, + suspensions: SubagentSuspension[] ): Promise | undefined { if (options.signal?.aborted) { pushAbortedBatchResults(state, batch.unrecorded); @@ -746,9 +803,13 @@ export class AgentExecutor { if (batch.failure) { throw batch.failure.error; } + const suspension = settleSuspensions(state.messages, suspensions, Boolean(batch.approval)); if (batch.approval) { return this.pauseForApproval(options, state, batch.approval.toolCall, batch.approval.outcome); } + if (suspension) { + return this.savePause(options, state, suspensionRecord(options, state, suspension)); + } return undefined; } @@ -806,6 +867,13 @@ export class AgentExecutor { init.attributes, async (toolSpan) => { const toolCallStart = Date.now(); + // LOU-Y1: a sub-agent started by this tool call inherits from this run. + const scope: ToolCallScope = { + runtime: options, + toolCallId: toolCall.id, + spanId: toolSpan.id, + execute: (childOptions) => AgentExecutor.execute(childOptions), + }; const executed = await this.executeToolCall( toolCall, agent, @@ -819,7 +887,8 @@ export class AgentExecutor { signal, onPrepared, // LOU-V5: a delegated child's usage is added to this run's totals. - (child) => mergeDelegatedUsage(state.usage, child) + (child) => mergeDelegatedUsage(state.usage, child), + scope ); const parsedArgs = executed.args === undefined @@ -853,7 +922,7 @@ export class AgentExecutor { toolCall: ToolCall, toolResult: ToolCallOutcome ): Promise { - const { agent, approvalStore, sessionId, onEvent } = options; + const { agent, approvalStore, sessionId } = options; if (!approvalStore) { throw new Error( `Tool '${toolResult.toolName}' requires approval but no approvalStore was provided to AgentExecutor.execute()` @@ -869,15 +938,28 @@ export class AgentExecutor { createdAt: new Date().toISOString(), }; const snapshot: ExecutionSnapshot = { - agent, + // The agent as configured: resume re-applies skills and sub-agents. + agent: baseAgentOf(agent), currentMessages: state.messages, pendingToolCall: pending, steps: state.steps, sessionId, usage: structuredClone(state.usage), }; + return this.savePause(options, state, { pending, snapshot }); + } - await approvalStore.save(pending, snapshot); + /** + * Saves an approval record and ends this execute() call with an + * 'awaiting-approval' result. + */ + private static async savePause( + options: ExecuteOptions, + state: AgentRunState, + { pending, snapshot }: { pending: PendingApproval; snapshot: ExecutionSnapshot } + ): Promise { + const { approvalStore, onEvent } = options; + await approvalStore?.save(pending, snapshot); runEventsOf(options)?.approvalRequested(pending); this.emitEvent(onEvent, { @@ -986,7 +1068,8 @@ export class AgentExecutor { messages: Message[] = [], signal?: AbortSignal, onPrepared?: (prepared: PreparedToolCall) => void, - onDelegatedUsage?: ToolCallContext['onDelegatedUsage'] + onDelegatedUsage?: ToolCallContext['onDelegatedUsage'], + scope?: ToolCallScope ): Promise { return runToolCall( toolCall, @@ -1001,6 +1084,7 @@ export class AgentExecutor { messages, signal, onDelegatedUsage, + scope, }, onPrepared ); @@ -1037,6 +1121,7 @@ export class AgentExecutor { ); } assertToolConcurrency(options.toolConcurrency, caller); + assertMaxSubagentDepth(options.maxSubagentDepth, caller); } /** diff --git a/src/execution/ApprovalGate.ts b/src/execution/ApprovalGate.ts index b6502e901..dda4ce44f 100644 --- a/src/execution/ApprovalGate.ts +++ b/src/execution/ApprovalGate.ts @@ -18,6 +18,12 @@ export interface PendingApproval { args: Record; agentId?: string; createdAt: string; + /** + * LOU-Y1: set when the call belongs to a sub-agent - the names of the + * sub-agents it runs inside, outermost first (e.g. `['researcher']`). + * Absent for the top-level agent's own tool calls. + */ + subagentPath?: string[]; } /** @@ -51,6 +57,29 @@ export interface ExecutionSnapshot { sessionId?: string; /** Usage the paused run had spent (LOU-V5), so the resumed run continues its totals. */ usage?: RunUsage; + /** + * LOU-Y1: set when the run paused because a sub-agent it called paused for + * approval. `pendingToolCall` is then the sub-agent's call, and + * `resumeAfterApproval()` re-enters the sub-agent instead of running a tool + * of this run. + */ + subagent?: SubagentSuspension; +} + +/** + * A tool call of a paused run (a `task` or delegate tool call) whose + * sub-agent is itself paused for approval. Plain data, so it is stored with + * the rest of the snapshot. + */ +export interface SubagentSuspension { + /** The parent's tool call that is waiting on the sub-agent. */ + toolCallId: string; + toolName: string; + args: Record; + /** The sub-agent's name. */ + agentName: string; + /** The sub-agent's own paused run (which may itself wait on a sub-agent). */ + snapshot: ExecutionSnapshot; } /** diff --git a/src/execution/DelegationTool.ts b/src/execution/DelegationTool.ts index d0fc82291..4a300826f 100644 --- a/src/execution/DelegationTool.ts +++ b/src/execution/DelegationTool.ts @@ -7,7 +7,9 @@ import { z } from 'zod'; import { tool } from 'ai'; import { AsyncLocalStorage } from 'node:async_hooks'; -import { AgentExecutor, PropagatingToolError } from './AgentExecutor'; +import { PropagatingToolError } from './propagatingToolError'; +import { runSubagent } from './delegation'; +import { AgentExecutor } from './AgentExecutor'; import { LLMProvider, Message } from '../providers'; import { AgentConfig, ToolDescriptor } from '../types'; import { ToolRegistry } from '../tools'; @@ -90,6 +92,13 @@ export interface DelegateAgentResult { * Create a ToolDescriptor that lets an agent delegate a task to a child * agent, running the child through AgentExecutor.execute() and returning * its final text/usage. + * + * LOU-Y1: the child inherits the calling run's runtime - abort signal, + * hooks (with `ctx.subagent` set), trace parent, approval store (a child + * call that needs approval pauses the whole run; `resumeAfterApproval()` + * finishes it), `toolConcurrency`, `onEvent` (events tagged with + * `subagent`) - and its token usage is added to the parent run's. See + * docs/sub-agents.md; for named sub-agents prefer the `subagents` option. */ export function createDelegateTool(opts: DelegateAgentOptions): ToolDescriptor { const maxDepth = opts.maxDepth ?? 3; @@ -134,19 +143,16 @@ export function createDelegateTool(opts: DelegateAgentOptions): ToolDescriptor { : [{ role: 'user', content: task }]; return delegationDepthStorage.run(currentDepth + 1, async () => { - const result = await AgentExecutor.execute({ - agent: opts.agent, - input: childInput, - provider: opts.provider, - toolRegistry: opts.toolRegistry, - maxSteps: opts.maxSteps, + const result = await runSubagent( + { agent: opts.agent, provider: opts.provider, toolRegistry: opts.toolRegistry, maxSteps: opts.maxSteps }, // LOU-V1: aborting the parent run aborts the child with it. - signal: options?.abortSignal, - }); + { name: opts.agent.name, input: childInput, toolOptions: options }, + (childOptions) => AgentExecutor.execute(childOptions) + ); - // LOU-V5: the child's full usage rolls up into the parent run's - // totals (`usage.delegated`); the model only sees the token counts. - options?.onDelegatedUsage?.(result.usage); + // LOU-V5: runSubagent() rolled the child's full usage into the + // parent run's totals (`usage.delegated`); the model only sees the + // token counts. const { promptTokens, completionTokens, totalTokens } = result.usage; return { text: result.text, diff --git a/src/execution/agentEvents.ts b/src/execution/agentEvents.ts index c87bd8079..4f9890284 100644 --- a/src/execution/agentEvents.ts +++ b/src/execution/agentEvents.ts @@ -8,6 +8,7 @@ */ import type { ExecutionFinishReason } from './AgentExecutor'; +import type { SubagentInfo } from './hooks'; /** * Version of the {@link AgentEvent} schema, carried on every event as `v`. @@ -51,6 +52,15 @@ export interface AgentEventBase { timestamp: string; /** Schema version, always {@link AGENT_EVENT_SCHEMA_VERSION}. */ v: typeof AGENT_EVENT_SCHEMA_VERSION; + /** + * LOU-Y1: set on events of a sub-agent's run (started by the `task` tool or + * a `createDelegateTool()` tool): which sub-agent, and the tool call of this + * run that started it. Absent on the top-level run's own events, and never + * set on `run.start`/`run.done` (they mark the top-level run only). The step + * ordering guarantees hold for the top-level events and, separately, for + * each sub-agent's events. + */ + subagent?: SubagentInfo; } /** First event of every run. */ diff --git a/src/execution/agentRun.ts b/src/execution/agentRun.ts index 598aae6ae..2581298c2 100644 --- a/src/execution/agentRun.ts +++ b/src/execution/agentRun.ts @@ -18,6 +18,7 @@ import { nanoid } from 'nanoid'; import type { GenerateOptions, GenerateResult, LLMProvider } from '../providers'; import type { ExecuteOptions, ExecutionEvent, ExecutionResult } from './AgentExecutor'; import type { PendingApproval } from './ApprovalGate'; +import type { SubagentInfo } from './hooks'; import { AGENT_EVENT_SCHEMA_VERSION, AgentEvent, @@ -71,6 +72,8 @@ export interface RunEventSink { approvalRequested(pending: PendingApproval): void; /** Obtains one model step - streamed when the provider can. */ generate(provider: LLMProvider, request: GenerateOptions): Promise; + /** LOU-Y1: the sink for a sub-agent's run, whose events carry `subagent`. */ + forSubagent(subagent: SubagentInfo): RunEventSink; } /** Options key under which a streaming run hands the loop its {@link RunEventSink}. */ @@ -131,6 +134,9 @@ function toJsonValue(value: unknown): unknown { } } +/** A finished model step's finish reason and measured usage (LOU-V5). */ +type MeasuredStep = { finishReason: GenerateResult['finishReason']; usage: Usage; estimated: boolean; costUsd?: number }; + class AgentRunImpl implements AgentRun { readonly runId = nanoid(); readonly result: Promise; @@ -141,7 +147,6 @@ class AgentRunImpl implements AgentRun { private closed = false; private iterated = false; private wake: (() => void) | undefined; - private stepResult: { finishReason: GenerateResult['finishReason']; usage: Usage; estimated: boolean; costUsd?: number } | undefined; private lastError: unknown; constructor(start: RunStarter, signal: AbortSignal | undefined) { @@ -187,10 +192,12 @@ class AgentRunImpl implements AgentRun { } } - private emit(payload: AgentEventPayload): void { + /** Queues an event; `subagent` tags one forwarded from a sub-agent's run (LOU-Y1). */ + private emit(payload: AgentEventPayload, subagent?: SubagentInfo): void { if (this.closed) return; const event = { ...payload, + ...(subagent && { subagent }), runId: this.runId, seq: this.seq++, timestamp: new Date().toISOString(), @@ -220,10 +227,18 @@ class AgentRunImpl implements AgentRun { throw error; } - /** Maps the loop's `onEvent` callbacks to AgentEvents (`abort`/`finish` are covered by `run.done`). */ + /** + * Maps the loop's `onEvent` callbacks to AgentEvents (`abort`/`finish` are + * covered by `run.done`). Events forwarded from a sub-agent (LOU-Y1) keep + * their `subagent` tag; its `start`/`finish` are left out, since + * `run.start`/`run.done` mark the top-level run only (the sub-agent's run + * spans the parent's `tool.start`/`tool.done` of the calling tool). + */ private translate(event: ExecutionEvent): void { + const { subagent } = event; switch (event.type) { case 'start': + if (subagent) break; this.emit({ type: 'run.start', agentName: event.agentName ?? '', @@ -231,84 +246,108 @@ class AgentRunImpl implements AgentRun { }); break; case 'text-complete': - this.emit({ type: 'text.done', text: event.text ?? '' }); + this.emit({ type: 'text.done', text: event.text ?? '' }, subagent); break; case 'tool-call': - if (event.toolCall) this.toolStarted(event.toolCall); + if (event.toolCall) this.toolStarted(event.toolCall, subagent); break; case 'tool-result': - if (event.toolResult) this.toolSettled(event.toolResult); + if (event.toolResult) this.toolSettled(event.toolResult, subagent); break; case 'error': - this.lastError = event.error; - this.emit({ type: 'error', error: toEventError(event.error) }); + if (!subagent) this.lastError = event.error; + this.emit({ type: 'error', error: toEventError(event.error) }, subagent); break; } } - private toolStarted(toolCall: NonNullable): void { - this.toolStarts.set(toolCall.id, Date.now()); + private toolStarted(toolCall: NonNullable, subagent?: SubagentInfo): void { + this.toolStarts.set(toolStartKey(toolCall.id, subagent), Date.now()); const args = parseToolArguments(toolCall, {}); - this.emit({ - type: 'tool.start', - toolCallId: toolCall.id, - toolName: toolCall.function.name, - args: (typeof args === 'object' && args !== null ? args : {}) as Record, - }); + this.emit( + { + type: 'tool.start', + toolCallId: toolCall.id, + toolName: toolCall.function.name, + args: (typeof args === 'object' && args !== null ? args : {}) as Record, + }, + subagent + ); } - private toolSettled(outcome: NonNullable): void { + private toolSettled(outcome: NonNullable, subagent?: SubagentInfo): void { const { toolCallId, toolName } = outcome; - const durationMs = Date.now() - (this.toolStarts.get(toolCallId) ?? Date.now()); + const durationMs = Date.now() - (this.toolStarts.get(toolStartKey(toolCallId, subagent)) ?? Date.now()); if (outcome.error === undefined) { - this.emit({ type: 'tool.done', toolCallId, toolName, result: toJsonValue(outcome.result), durationMs }); + this.emit({ type: 'tool.done', toolCallId, toolName, result: toJsonValue(outcome.result), durationMs }, subagent); return; } const name = (outcome.result as { error?: unknown } | null)?.error; - this.emit({ - type: 'tool.error', - toolCallId, - toolName, - error: { name: typeof name === 'string' ? name : 'Error', message: outcome.error }, - durationMs, - }); + this.emit( + { + type: 'tool.error', + toolCallId, + toolName, + error: { name: typeof name === 'string' ? name : 'Error', message: outcome.error }, + durationMs, + }, + subagent + ); } - private sink(): RunEventSink { + /** + * The sink for the top-level run, or (LOU-Y1) for a sub-agent's run, whose + * events carry `subagent`. A sub-agent's approval request is reported by + * the top-level run it pauses, so its own sink does not emit one. + */ + private sink(subagent?: SubagentInfo): RunEventSink { + let stepResult: MeasuredStep | undefined; return { stepStart: (step) => { - this.stepResult = undefined; - this.emit({ type: 'step.start', step }); + stepResult = undefined; + this.emit({ type: 'step.start', step }, subagent); }, stepDone: (step, finishReason) => { - const measured = this.stepResult; - this.emit({ - type: 'step.done', - step, - finishReason: finishReason ?? measured?.finishReason ?? 'error', - ...(measured && { usage: toEventUsage({ ...measured.usage, estimated: measured.estimated, costUsd: measured.costUsd }) }), - }); + const measured = stepResult; + this.emit( + { + type: 'step.done', + step, + finishReason: finishReason ?? measured?.finishReason ?? 'error', + ...(measured && { + usage: toEventUsage({ ...measured.usage, estimated: measured.estimated, costUsd: measured.costUsd }), + }), + }, + subagent + ); }, - approvalRequested: (pending) => + approvalRequested: (pending) => { + if (subagent) return; this.emit({ type: 'approval.requested', approvalId: pending.id, toolCallId: pending.toolCallId, toolName: pending.toolName, args: toJsonValue(pending.args) as Record, - }), + }); + }, generate: async (provider, request) => { - const generated = await this.generateStep(provider, request); + const generated = await this.generateStep(provider, request, subagent); const measured = measureUsage(request.model ?? provider.name, request.messages, generated); - this.stepResult = { finishReason: generated.finishReason, ...measured, usage: measured.usage }; + stepResult = { finishReason: generated.finishReason, ...measured, usage: measured.usage }; return generated; }, + forSubagent: (child) => this.sink(subagent ? { ...child, depth: subagent.depth + 1, parent: subagent } : child), }; } /** Streams the step when the provider can; otherwise one `generate()` and a single `text.delta`. */ - private async generateStep(provider: LLMProvider, request: GenerateOptions): Promise { - const onTextDelta = (text: string) => this.emit({ type: 'text.delta', text }); + private async generateStep( + provider: LLMProvider, + request: GenerateOptions, + subagent: SubagentInfo | undefined + ): Promise { + const onTextDelta = (text: string) => this.emit({ type: 'text.delta', text }, subagent); if (canStream(provider, request)) { return generateViaStream(provider, request, onTextDelta); } @@ -318,6 +357,16 @@ class AgentRunImpl implements AgentRun { } } +/** Identifies a sub-agent run within the stream: the chain of tool calls that started it. */ +function subagentKey(subagent: SubagentInfo): string { + return subagent.parent ? `${subagentKey(subagent.parent)}/${subagent.toolCallId}` : subagent.toolCallId; +} + +/** Tool call ids are only unique per run, so a sub-agent's are keyed by its chain. */ +function toolStartKey(toolCallId: string, subagent: SubagentInfo | undefined): string { + return subagent ? `${subagentKey(subagent)}:${toolCallId}` : toolCallId; +} + /** * Starts a run and returns its {@link AgentRun} handle. `signal` (the * caller's) and an early `break` both abort the run. diff --git a/src/execution/delegation.ts b/src/execution/delegation.ts new file mode 100644 index 000000000..e4be8494c --- /dev/null +++ b/src/execution/delegation.ts @@ -0,0 +1,239 @@ +/** + * The delegation core shared by the `task` tool (LOU-Y3) and + * `createDelegateTool()`: runs a child agent for one tool call of a parent + * run, inheriting the parent's runtime (LOU-Y1). + * + * The child keeps its own instructions, model/provider, tools, skills and + * maxSteps, and sees only the input it is given (never the parent's + * transcript). From the parent run it inherits, unless the child sets its + * own: the abort signal, the trace exporter and span parent, the hooks + * (tagged with `ctx.subagent`), the approval store (a paused child pauses the + * parent), `toolConcurrency`, the sandbox and content-capture settings, and + * the `onEvent` listener (events tagged with `event.subagent`). Its token + * usage is added to the parent's. + */ + +import type { LLMProvider, Message } from '../providers'; +import type { AgentConfig } from '../types'; +import { ToolRegistry } from '../tools/ToolRegistry'; +import type { Skill } from '../skills/defineSkill'; +import type { Subagents } from '../subagents/types'; +import type { ExecuteOptions, ExecutionEvent, ExecutionResult } from './AgentExecutor'; +import type { ResumeExecuteOptions } from './resume'; +import type { ApprovalStore, ExecutionSnapshot } from './ApprovalGate'; +import type { ToolConcurrency } from './toolBatch'; +import { HookRegistry, type AgentHook, type HookContext, type SubagentInfo } from './hooks'; +import { markPropagating } from './propagatingToolError'; +import { SubagentApprovalPause, subagentBudget, toolCallScopeOf, type ToolCallScope } from './subagentRuntime'; +import { RUN_EVENTS, runEventsOf, type StreamingExecuteOptions } from './agentRun'; +import type { ToolRunContext } from './sandboxGuard'; + +/** Everything needed to run an agent as a child: its own configuration. */ +export interface SubagentSpec { + agent: AgentConfig; + provider: LLMProvider; + toolRegistry?: ToolRegistry; + skills?: readonly Skill[]; + subagents?: Subagents; + maxSubagentDepth?: number; + maxSteps?: number; + toolConcurrency?: ToolConcurrency; +} + +/** One child run requested by a parent tool call. */ +export interface SubagentRequest { + /** The name the parent knows the child by (used in events, hooks and errors). */ + name: string; + input: Message[]; + /** + * The options object the parent tool's `execute(args, options)` received: + * its `abortSignal`, and the parent run it belongs to. + */ + toolOptions?: { abortSignal?: AbortSignal } & ToolRunContext; + /** Short label of the task, for events and hooks. */ + description?: string; +} + +/** + * Runs (or, inside resumeAfterApproval(), resumes) a child agent for the + * current tool call. Throws {@link SubagentApprovalPause} when the child + * pauses for approval, so the executor can pause the parent run. + * `execute` runs the child when the tool was not started by an agent run + * (e.g. a test calling a delegate tool's `execute` directly). + */ +export async function runSubagent( + spec: SubagentSpec, + request: SubagentRequest, + execute?: (options: ExecuteOptions) => Promise +): Promise { + const scope = toolCallScopeOf(request.toolOptions); + const info = subagentInfo(scope, request); + const capture = captureApproval(scope); + const options = childOptions(spec, scope, info, capture.store, request.toolOptions?.abortSignal); + const run = scope?.execute ?? execute; + if (!run) { + throw new Error(`Sub-agent '${request.name}' can only be started by a tool call of an agent run.`); + } + + const resume = scope?.resume; + const result = + resume && capture.store + ? await resume.run(resume.decision, capture.store, spec.toolRegistry ?? new ToolRegistry(), spec.provider, options) + : await run({ + ...options, + agent: spec.agent, + input: request.input, + provider: spec.provider, + toolRegistry: spec.toolRegistry, + }); + + // LOU-V5: the child's usage rolls up into the parent run's totals. + const reportUsage = scope?.onDelegatedUsage ?? request.toolOptions?.onDelegatedUsage; + reportUsage?.(result.usage); + const paused = capture.saved(); + if (result.finishReason === 'awaiting-approval' && paused) { + throw new SubagentApprovalPause(request.name, paused); + } + return result; +} + +function subagentInfo(scope: ToolCallScope | undefined, request: SubagentRequest): SubagentInfo { + return { + name: request.name, + depth: 1, + toolCallId: scope?.toolCallId ?? '', + ...(request.description ? { description: request.description } : {}), + }; +} + +/** + * The child's approval store: it records the child's pause instead of + * persisting it (the parent persists one record for the whole run), and on + * resume hands back the child's paused state. Undefined when the parent run + * has no approval store. + */ +function captureApproval(scope: ToolCallScope | undefined): { + store?: ApprovalStore; + saved: () => ExecutionSnapshot | undefined; +} { + let saved: ExecutionSnapshot | undefined; + if (!scope?.runtime.approvalStore) { + return { saved: () => saved }; + } + const resume = scope.resume; + const store: ApprovalStore = { + save: async (_pending, snapshot) => { + saved = snapshot; + }, + resolve: async (id) => { + const paused = resume?.suspension.snapshot; + if (!paused || id !== resume.decision.id) return null; + // The child's usage up to the pause already rolled into the parent's + // totals; the resumed child reports only what it spends from here. + const snapshot = { ...paused, usage: undefined }; + return { pending: snapshot.pendingToolCall, snapshot }; + }, + }; + return { store, saved: () => saved }; +} + +/** The child's execute options: inherited runtime, overridden by the child's own settings. */ +function childOptions( + spec: SubagentSpec, + scope: ToolCallScope | undefined, + info: SubagentInfo, + approvalStore: ApprovalStore | undefined, + abortSignal: AbortSignal | undefined +): ResumeExecuteOptions { + const runtime = scope?.runtime ?? {}; + return { + ...inheritedObservability(scope, info), + approvalStore, + sandbox: runtime.sandbox, + signal: abortSignal ?? runtime.signal, + toolConcurrency: spec.toolConcurrency ?? runtime.toolConcurrency, + skills: spec.skills, + subagents: spec.subagents, + // The top-level run's limit bounds the whole tree: one level used up here. + maxSubagentDepth: Math.max(0, subagentBudget(runtime.maxSubagentDepth) - 1), + maxSteps: spec.maxSteps, + }; +} + +/** Tracing, content capture, hooks and events, as inherited from the parent run. */ +function inheritedObservability( + scope: ToolCallScope | undefined, + info: SubagentInfo +): ResumeExecuteOptions & Pick { + const runtime = scope?.runtime ?? {}; + // A streaming parent (agent.stream()) gets the child's steps and text deltas too. + const sink = runEventsOf(runtime as StreamingExecuteOptions)?.forSubagent(info); + return { + ...(sink && { [RUN_EVENTS]: sink }), + exporter: runtime.exporter, + parentSpanId: scope?.spanId, + captureContent: runtime.captureContent, + redactContent: runtime.redactContent, + hooks: runtime.hooks && hooksForSubagent(runtime.hooks, info), + onEvent: runtime.onEvent && forwardEvents(runtime.onEvent, info), + }; +} + +/** + * Adds `outer` (a depth-1 sub-agent of this run) as the outermost sub-agent + * of an innermost-first chain, one level deeper each. + */ +function nestSubagent(inner: SubagentInfo | undefined, outer: SubagentInfo): SubagentInfo { + return inner ? { ...inner, depth: inner.depth + 1, parent: nestSubagent(inner.parent, outer) } : outer; +} + +/** The child's `onEvent`: the parent's listener, with each event tagged with `subagent`. */ +function forwardEvents( + onEvent: (event: ExecutionEvent) => void, + info: SubagentInfo +): (event: ExecutionEvent) => void { + return (event) => onEvent({ ...event, subagent: nestSubagent(event.subagent, info) }); +} + +type HookMethod = 'preToolCall' | 'postToolCall' | 'preGenerate' | 'postGenerate'; +const HOOK_METHODS: readonly HookMethod[] = ['preToolCall', 'postToolCall', 'preGenerate', 'postGenerate']; +type AnyHookMethod = (ctx: HookContext, ...rest: unknown[]) => void | Promise; + +/** + * The parent's hooks, as seen by the child: each context gets `subagent` + * set (once, however many hooks run on it), and an error a hook throws + * halts the whole run - not just the sub-agent - like it does in the parent. + */ +function hooksForSubagent(hooks: HookRegistry, info: SubagentInfo): HookRegistry { + const tagged = new WeakSet(); + const tag = (ctx: HookContext): HookContext => { + if (!tagged.has(ctx)) { + tagged.add(ctx); + ctx.subagent = nestSubagent(ctx.subagent, info); + } + return ctx; + }; + const child = new HookRegistry(); + for (const hook of hooks.list()) { + child.register(decorateHook(hook, tag)); + } + return child; +} + +function decorateHook(hook: AgentHook, tag: (ctx: HookContext) => HookContext): AgentHook { + const decorated: Partial> = {}; + for (const method of HOOK_METHODS) { + const fn = hook[method] as AnyHookMethod | undefined; + if (fn) { + decorated[method] = async (ctx, ...rest) => { + try { + await fn.call(hook, tag(ctx), ...rest); + } catch (error) { + markPropagating(error); + throw error; + } + }; + } + } + return { name: hook.name, ...decorated } as AgentHook; +} diff --git a/src/execution/hooks.ts b/src/execution/hooks.ts index 1b59f4609..e38bd1653 100644 --- a/src/execution/hooks.ts +++ b/src/execution/hooks.ts @@ -59,6 +59,40 @@ export interface HookContext { messages: Message[]; /** Free-form bag for hook-to-hook or hook-to-caller data passing. */ metadata?: Record; + /** + * LOU-Y1: set when the hook fires inside a sub-agent (a child run started + * by the `task` tool or a `createDelegateTool()` tool). A parent run's + * hooks apply to its sub-agents' model calls and tool calls too; a hook + * that should only see the top-level run can return early on it. + * + * @example + * ```ts + * const audit: AgentHook = { + * name: 'audit', + * preToolCall(ctx) { + * if (ctx.subagent) return; // top-level calls only + * console.log(ctx.toolName); + * }, + * }; + * ``` + */ + subagent?: SubagentInfo; +} + +/** + * Which sub-agent a hook call or an execution event comes from (LOU-Y1). + */ +export interface SubagentInfo { + /** The sub-agent's name: the `task` tool's `agent`, or the delegated agent's name. */ + name: string; + /** 1 for a sub-agent of the top-level run, 2 for a sub-agent of that sub-agent, and so on. */ + depth: number; + /** The parent run's tool call that started this sub-agent. */ + toolCallId: string; + /** The `task` call's short `description` label, when there is one. */ + description?: string; + /** The sub-agent that started this one, when it is nested deeper than depth 1. */ + parent?: SubagentInfo; } /** Context passed to `AgentHook.preToolCall` / `postToolCall`. */ diff --git a/src/execution/propagatingToolError.ts b/src/execution/propagatingToolError.ts index 65480c39f..de42b30eb 100644 --- a/src/execution/propagatingToolError.ts +++ b/src/execution/propagatingToolError.ts @@ -22,6 +22,27 @@ */ export class PropagatingToolError extends Error {} +const propagatingErrors = new WeakSet(); + +/** + * Makes an error that is not a {@link PropagatingToolError} propagate like + * one - used for a hook error thrown inside a sub-agent (LOU-Y1), which must + * halt the whole run, not become the sub-agent tool's error result. + */ +export function markPropagating(error: unknown): void { + if (typeof error === 'object' && error !== null) { + propagatingErrors.add(error); + } +} + +/** Whether a thrown tool error must propagate out of execute() instead of becoming a tool result. */ +export function isPropagatingToolError(error: unknown): boolean { + return ( + error instanceof PropagatingToolError || + (typeof error === 'object' && error !== null && propagatingErrors.has(error)) + ); +} + /** * Converts a thrown tool error into the message string used for the * conversational `{error}` tool-result - EXCEPT for a @@ -33,7 +54,7 @@ export class PropagatingToolError extends Error {} * deferred, post-approval tool execution so both handle it identically. */ export function toolErrorMessage(error: unknown): string { - if (error instanceof PropagatingToolError) { + if (isPropagatingToolError(error)) { throw error; } return (error as Error | undefined)?.message ?? String(error); diff --git a/src/execution/resume.ts b/src/execution/resume.ts index c450c6a18..97787a1e0 100644 --- a/src/execution/resume.ts +++ b/src/execution/resume.ts @@ -18,6 +18,10 @@ import { NoopSandbox } from '../security/sandboxCore'; import { executeToolWithSandboxGuard } from './sandboxGuard'; import { HookRegistry } from './hooks'; import { toolErrorMessage } from './propagatingToolError'; +import { replaceToolResult, type ToolCallScope } from './subagentRuntime'; +import type { RunUsage } from '../models/usage'; +import { emptyRunUsage, mergeDelegatedUsage, restoreRunUsage } from './runUsage'; +import { resumeSubagentCall, type ResumeContext } from './resumeSubagent'; /** * Options passed through to the underlying AgentExecutor.execute() call @@ -114,13 +118,23 @@ export async function resumeAfterApproval( const staleBusinessState = await clearStaleCheckpoint(snapshot.sessionId, checkpointStore); - const toolMessage = decision.approved - ? await runApprovedToolCall(pending, snapshot, messages, toolRegistry, executeOptions) - : toolResultMessage(pending, { - error: 'Tool execution was rejected by the reviewer', - note: decision.note, - }, true); - messages.push(toolMessage); + const ctx: ResumeContext = { + decision, + approvalStore, + snapshot, + messages, + toolRegistry, + executeOptions, + usage: snapshot.usage ? restoreRunUsage(snapshot.usage) : emptyRunUsage(), + execute: (options) => AgentExecutor.execute(options), + resumeRun: resumeAfterApproval, + }; + const step = await decidedToolMessage(ctx, pending); + if ('paused' in step) { + return step.paused; + } + // LOU-Y1: a call whose sub-agent paused already has a placeholder result. + replaceToolResult(messages, step.message); return continueResumedRun(snapshot, messages, { provider, @@ -128,9 +142,40 @@ export async function resumeAfterApproval( executeOptions, checkpointStore, staleBusinessState, + usage: ctx.usage, }); } +/** + * The `tool` message for the decided call: the approved tool's result, the + * rejection - or, when the run paused on a sub-agent, the sub-agent's final + * answer after resuming it with the decision (LOU-Y1). + */ +async function decidedToolMessage( + ctx: ResumeContext, + pending: PendingApproval +): Promise<{ message: Message } | { paused: ExecutionResult }> { + const { snapshot, messages, executeOptions } = ctx; + const runApproved = (call: PendingApproval, registry: ToolRegistry, scope: ToolCallScope): Promise => + runApprovedToolCall(call, snapshot, messages, registry, executeOptions, scope); + if (snapshot.subagent) { + return resumeSubagentCall(ctx, snapshot.subagent, runApproved); + } + if (!ctx.decision.approved) { + return { + message: toolResultMessage(pending, { error: 'Tool execution was rejected by the reviewer', note: ctx.decision.note }, true), + }; + } + // A sub-agent the approved tool starts inherits this resumed run's runtime. + const scope: ToolCallScope = { + runtime: { ...executeOptions, approvalStore: ctx.approvalStore }, + toolCallId: pending.toolCallId, + onDelegatedUsage: (child) => mergeDelegatedUsage(ctx.usage, child), + execute: ctx.execute, + }; + return { message: await runApproved(pending, ctx.toolRegistry, scope) }; +} + /** * Defense in depth: a checkpoint under this sessionId (if any) was * written before this pause and is therefore stale by construction - @@ -197,7 +242,8 @@ async function runApprovedToolCall( snapshot: ExecutionSnapshot, messages: Message[], toolRegistry: ToolRegistry, - executeOptions: ResumeExecuteOptions + executeOptions: ResumeExecuteOptions, + scope?: ToolCallScope ): Promise { const toolDesc = toolRegistry.get(pending.toolName); // A `requiresSandbox` tool may have no `tool.execute` implementation at @@ -236,7 +282,7 @@ async function runApprovedToolCall( await hooks.runPreToolCall(hookCtx); } - const { result, toolError } = await executeApprovedTool(pending, toolDesc, hookArgs, executeOptions); + const { result, toolError } = await executeApprovedTool(pending, toolDesc, hookArgs, executeOptions, scope); // Fires (with the settled result/error) regardless of how the tool // settled - matching AgentHook.postToolCall's documented contract @@ -271,7 +317,8 @@ async function executeApprovedTool( pending: PendingApproval, toolDesc: ToolDescriptor, args: Record, - executeOptions: ResumeExecuteOptions + executeOptions: ResumeExecuteOptions, + scope?: ToolCallScope ): Promise<{ result: unknown; toolError?: string }> { try { // Mirrors AgentExecutor.executeToolCall()'s fail-closed handling of @@ -286,7 +333,9 @@ async function executeApprovedTool( toolDesc, args, sandbox, - executeOptions.signal + executeOptions.signal, + undefined, + scope ), }; } catch (error) { @@ -345,6 +394,7 @@ function continueResumedRun( executeOptions: ResumeExecuteOptions; checkpointStore?: CheckpointStore; staleBusinessState: unknown; + usage: RunUsage; } ): Promise { const { executeOptions } = run; @@ -384,7 +434,8 @@ function continueResumedRun( // snapshot.steps is the step count AgentExecutor.execute() had already // reached (see ExecutionSnapshot) at the moment it paused for approval. initialSteps: snapshot.steps, - // LOU-V5: and usage totals, rather than restarting them at zero. - initialUsage: snapshot.usage, + // LOU-V5: and usage totals, rather than restarting them at zero + // (including a resumed sub-agent's usage, LOU-Y1). + initialUsage: run.usage, }); } diff --git a/src/execution/resumeSubagent.ts b/src/execution/resumeSubagent.ts new file mode 100644 index 000000000..5ba89c45e --- /dev/null +++ b/src/execution/resumeSubagent.ts @@ -0,0 +1,99 @@ +/** + * resumeAfterApproval() for a run that paused because one of its sub-agents + * paused (LOU-Y1). The decision is for the sub-agent's call, so instead of + * running a tool of this run, the parent's tool call (`task` or a delegate + * tool) is re-entered: it resumes the sub-agent with the decision (which + * runs or rejects the sub-agent's pending call and lets it finish), and the + * sub-agent's final answer becomes that tool call's result. If the + * sub-agent pauses again, the run pauses again with a new approval record. + */ + +import type { Message } from '../providers'; +import type { ToolRegistry } from '../tools'; +import { withSubagents } from '../subagents/withSubagents'; +import type { ApprovalDecision, ApprovalStore, ExecutionSnapshot, PendingApproval, SubagentSuspension } from './ApprovalGate'; +import type { ExecuteOptions, ExecutionResult } from './AgentExecutor'; +import type { RunUsage } from '../models/usage'; +import { mergeDelegatedUsage } from './runUsage'; +import type { ResumeExecuteOptions } from './resume'; +import { + SubagentApprovalPause, + suspensionRecord, + toSuspension, + type ResumeRun, + type ToolCallScope, +} from './subagentRuntime'; + +/** What resuming a paused run needs from resumeAfterApproval(). */ +export interface ResumeContext { + decision: ApprovalDecision; + approvalStore: ApprovalStore; + snapshot: ExecutionSnapshot; + messages: Message[]; + toolRegistry: ToolRegistry; + executeOptions: ResumeExecuteOptions; + /** The resumed run's usage so far: a resumed sub-agent's usage is added to it (LOU-V5). */ + usage: RunUsage; + /** `AgentExecutor.execute` and `resumeAfterApproval`, for the sub-agent. */ + execute: (options: ExecuteOptions) => Promise; + resumeRun: ResumeRun; +} + +/** Runs an approved tool call of the paused run and returns its `tool` message. */ +type RunApprovedToolCall = (pending: PendingApproval, toolRegistry: ToolRegistry, scope: ToolCallScope) => Promise; + +/** + * Re-enters the tool call whose sub-agent paused. Resolves to its `tool` + * message, or to the result to return when the sub-agent paused again. + */ +export async function resumeSubagentCall( + ctx: ResumeContext, + suspension: SubagentSuspension, + runApproved: RunApprovedToolCall +): Promise<{ message: Message } | { paused: ExecutionResult }> { + const { executeOptions } = ctx; + // The `task` tool is per run: rebuild it from the same `subagents` option. + const { toolRegistry } = await withSubagents( + ctx.snapshot.agent, + ctx.toolRegistry, + executeOptions.subagents, + executeOptions.maxSubagentDepth + ); + const parentCall: PendingApproval = { + ...ctx.snapshot.pendingToolCall, + toolCallId: suspension.toolCallId, + toolName: suspension.toolName, + args: suspension.args, + }; + const scope: ToolCallScope = { + runtime: { ...executeOptions, approvalStore: ctx.approvalStore }, + toolCallId: suspension.toolCallId, + onDelegatedUsage: (child) => mergeDelegatedUsage(ctx.usage, child), + execute: ctx.execute, + resume: { decision: ctx.decision, suspension, run: ctx.resumeRun }, + }; + try { + return { message: await runApproved(parentCall, toolRegistry ?? ctx.toolRegistry, scope) }; + } catch (error) { + if (!(error instanceof SubagentApprovalPause)) throw error; + return { paused: await pauseAgain(ctx, toSuspension(error, suspension)) }; + } +} + +/** Saves a new approval record for the sub-agent's next pending call. */ +async function pauseAgain(ctx: ResumeContext, suspension: SubagentSuspension): Promise { + const { snapshot, messages } = ctx; + const { usage } = ctx; + const record = suspensionRecord(snapshot, { messages, steps: snapshot.steps, usage }, suspension); + await ctx.approvalStore.save(record.pending, record.snapshot); + ctx.executeOptions.onEvent?.({ type: 'finish', timestamp: new Date(), finishReason: 'awaiting-approval', usage }); + return { + text: '', + messages, + toolCalls: [], + usage, + finishReason: 'awaiting-approval', + steps: snapshot.steps, + approvalId: record.pending.id, + }; +} diff --git a/src/execution/sandboxGuard.ts b/src/execution/sandboxGuard.ts index 1c3343544..ac372fe1d 100644 --- a/src/execution/sandboxGuard.ts +++ b/src/execution/sandboxGuard.ts @@ -21,6 +21,7 @@ import type { ToolExecutionOptions } from 'ai'; import { ToolDescriptor } from '../types'; import { SandboxAdapter } from '../security/sandboxCore'; +import { bindToolCallScope, type ToolCallScope } from './subagentRuntime'; import type { RunUsage } from '../models/usage'; /** Extra context the executor hands a tool next to the 'ai' SDK's own execute options (LOU-V5). */ @@ -54,7 +55,8 @@ export async function executeToolWithSandboxGuard( args: Record, sandbox: SandboxAdapter, signal?: AbortSignal, - runContext?: ToolRunContext + runContext?: ToolRunContext, + scope?: ToolCallScope ): Promise { if (toolDesc.requiresSandbox) { if (!toolDesc.sandboxExecute) { @@ -71,5 +73,7 @@ export async function executeToolWithSandboxGuard( // The 'ai' SDK types toolCallId/messages as required, but tools invoked // here are not part of an 'ai' SDK generation, so only abortSignal is set. const executeOptions = { abortSignal: signal, ...runContext } as ToolExecutionOptions; + // LOU-Y1: lets a delegate/`task` tool's sub-agent inherit from this run. + bindToolCallScope(executeOptions, scope); return toolDesc.tool.execute ? toolDesc.tool.execute(args, executeOptions) : null; } diff --git a/src/execution/subagentInheritance.test.ts b/src/execution/subagentInheritance.test.ts new file mode 100644 index 000000000..edb75f329 --- /dev/null +++ b/src/execution/subagentInheritance.test.ts @@ -0,0 +1,513 @@ +/** + * LOU-Y1: a child agent run (the `task` tool, or createDelegateTool()) + * inherits the parent run's runtime. + */ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { AgentExecutor, type ExecutionEvent, type ExecuteOptions } from './AgentExecutor'; +import { createDelegateTool } from './DelegationTool'; +import { HookRegistry } from './hooks'; +import { resumeAfterApproval } from './resume'; +import type { ApprovalStore, ResolvedApproval } from './ApprovalGate'; +import type { Span, TraceExporter } from './tracing'; +import { createAgent } from '../createAgent'; +import { defineTool } from '../tools/defineTool'; +import { ToolRegistry } from '../tools/ToolRegistry'; +import { AgentType } from '../types'; +import { mockModel, type MockTurn } from '../testing'; +import type { Message } from '../providers'; + +const lead = { name: 'lead', agentType: AgentType.SmartAssistant, prompt: 'You coordinate.' }; + +const task = (agent: string, prompt = 'do it', id = `${agent}-call`) => ({ + name: 'task', + args: { agent, prompt, description: `${agent} task` }, + id, +}); + +/** A JSON round-tripping store, so snapshots must survive serialization. */ +function memoryApprovals() { + const records = new Map(); + const store: ApprovalStore = { + save: async (pending, snapshot) => { + records.set(pending.id, JSON.stringify({ pending, snapshot })); + }, + resolve: async (id) => { + const record = records.get(id); + records.delete(id); + return record ? (JSON.parse(record) as ResolvedApproval) : null; + }, + }; + const only = (): ResolvedApproval => { + expect(records.size).toBe(1); + return JSON.parse([...records.values()][0]) as ResolvedApproval; + }; + return { store, records, only }; +} + +function lookupTool(log: string[] = []) { + return defineTool({ + name: 'lookup', + description: 'Looks something up', + input: z.object({}), + execute: () => { + log.push('lookup'); + return 'looked up'; + }, + }); +} + +/** A sub-agent with a `send` tool that needs approval; `sent` counts executions. */ +function approvingChild(name: string, turns: MockTurn[]) { + const sent: string[] = []; + const send = defineTool({ + name: 'send', + description: 'Sends a message', + input: z.object({ to: z.string() }), + needsApproval: true, + execute: ({ to }) => { + sent.push(to); + return `sent to ${to}`; + }, + }); + const model = mockModel(turns); + const agent = createAgent({ name, provider: model, tools: [send], description: `The ${name}` }); + return { agent, model, sent }; +} + +function toolResults(messages: readonly Message[]): Message[] { + return messages.filter((m) => m.role === 'tool'); +} + +function run(options: Omit & { input?: string }) { + return AgentExecutor.execute({ agent: lead, input: 'go', ...options }); +} + +describe('sub-agents inherit the parent runtime (LOU-Y1)', () => { + it("runs the parent's hooks on the child's model and tool calls, tagged with ctx.subagent", async () => { + const seen: string[] = []; + const subagentsSeenBySecondHook: unknown[] = []; + const hooks = new HookRegistry(); + hooks.register({ + name: 'spy', + preGenerate: (ctx) => void seen.push(`generate@${ctx.subagent?.name ?? 'lead'}`), + preToolCall: (ctx) => { + const where = ctx.subagent ? `${ctx.subagent.name}:${ctx.subagent.depth}:${ctx.subagent.toolCallId}` : 'lead'; + seen.push(`${ctx.toolName}@${where}`); + }, + }); + hooks.register({ name: 'second', preToolCall: (ctx) => void subagentsSeenBySecondHook.push(ctx.subagent) }); + const researcher = createAgent({ + name: 'researcher', + provider: mockModel([{ toolCalls: [{ name: 'lookup' }] }, 'found']), + tools: [lookupTool()], + description: 'Researches', + }); + + await run({ provider: mockModel([{ toolCalls: [task('researcher')] }, 'done']), subagents: { researcher }, hooks }); + + expect(seen).toEqual([ + 'generate@lead', + 'task@lead', + 'generate@researcher', + 'lookup@researcher:1:researcher-call', + 'generate@researcher', + 'generate@lead', + ]); + // Tagged once per call, however many hooks run on it. + expect(subagentsSeenBySecondHook[1]).toEqual({ + name: 'researcher', + depth: 1, + toolCallId: 'researcher-call', + description: 'researcher task', + }); + }); + + it('a hook that throws inside a sub-agent halts the whole run', async () => { + const halt = new Error('rate limit hit'); + const hooks = new HookRegistry(); + hooks.register({ + name: 'limiter', + preToolCall: (ctx) => { + if (ctx.toolName === 'lookup') throw halt; + }, + }); + const researcher = createAgent({ + provider: mockModel([{ toolCalls: [{ name: 'lookup' }] }, 'found']), + tools: [lookupTool()], + description: 'Researches', + }); + + await expect( + run({ provider: mockModel([{ toolCalls: [task('researcher')] }, 'done']), subagents: { researcher }, hooks }) + ).rejects.toBe(halt); + }); + + it("parents the child's invoke_agent span to the parent's execute_tool span", async () => { + const spans: Span[] = []; + const exporter: TraceExporter = { onSpanStart: (span) => spans.push(span), onSpanEnd: () => undefined }; + const researcher = createAgent({ name: 'researcher', provider: mockModel(['found']), description: 'Researches' }); + + await run({ provider: mockModel([{ toolCalls: [task('researcher')] }, 'done']), subagents: { researcher }, exporter }); + + const taskSpan = spans.find((s) => s.name === 'execute_tool task'); + const childSpan = spans.find((s) => s.name === 'invoke_agent researcher'); + const leadSpan = spans.find((s) => s.name === 'invoke_agent lead'); + expect(leadSpan?.parentId).toBeUndefined(); + expect(taskSpan?.parentId).toBe(leadSpan?.id); + expect(childSpan?.parentId).toBe(taskSpan?.id); + expect(spans.find((s) => s.name.startsWith('chat') && s.parentId === childSpan?.id)).toBeDefined(); + }); + + it("aborting the parent's signal aborts the child and the whole run", async () => { + const controller = new AbortController(); + let received: AbortSignal | undefined; + const stop = defineTool({ + name: 'stop', + description: 'Cancels the run', + input: z.object({}), + execute: (_args, { abortSignal }) => { + received = abortSignal; + controller.abort(); + return 'stopping'; + }, + }); + const childModel = mockModel([{ toolCalls: [{ name: 'stop' }] }, 'never']); + const researcher = createAgent({ provider: childModel, tools: [stop], description: 'Researches' }); + + const result = await run({ + provider: mockModel([{ toolCalls: [task('researcher')] }, 'never']), + subagents: { researcher }, + signal: controller.signal, + }); + + expect(received).toBe(controller.signal); + expect(childModel.calls).toHaveLength(1); + expect(result.finishReason).toBe('aborted'); + }); + + it("forwards the child's events to the parent's onEvent, tagged with the parent tool call", async () => { + const events: ExecutionEvent[] = []; + const researcher = createAgent({ + name: 'researcher', + provider: mockModel([{ toolCalls: [{ name: 'lookup' }] }, 'found']), + tools: [lookupTool()], + description: 'Researches', + }); + + await run({ + provider: mockModel([{ toolCalls: [task('researcher')] }, 'done']), + subagents: { researcher }, + onEvent: (event) => events.push(event), + }); + + const forwarded = events.filter((e) => e.subagent); + expect(forwarded.map((e) => e.type)).toEqual(['start', 'tool-call', 'tool-result', 'text-complete', 'finish']); + expect(forwarded[1].toolCall?.function.name).toBe('lookup'); + expect(forwarded[0].subagent).toEqual({ + name: 'researcher', + depth: 1, + toolCallId: 'researcher-call', + description: 'researcher task', + }); + expect(events.filter((e) => !e.subagent && e.type === 'start')).toHaveLength(1); + }); + + it('tags events of a sub-agent of a sub-agent with the whole chain', async () => { + const events: ExecutionEvent[] = []; + const helper = createAgent({ name: 'helper', provider: mockModel(['helped']), description: 'Helps' }); + const researcher = createAgent({ + provider: mockModel([{ toolCalls: [task('helper')] }, 'researched']), + description: 'Researches', + subagents: { helper }, + }); + + await run({ + provider: mockModel([{ toolCalls: [task('researcher')] }, 'done']), + subagents: { researcher }, + maxSubagentDepth: 2, + onEvent: (event) => events.push(event), + }); + + const helperStart = events.find((e) => e.type === 'start' && e.subagent?.name === 'helper'); + expect(helperStart?.subagent).toEqual({ + name: 'helper', + depth: 2, + toolCallId: 'helper-call', + description: 'helper task', + parent: { name: 'researcher', depth: 1, toolCallId: 'researcher-call', description: 'researcher task' }, + }); + }); + + it("inherits toolConcurrency unless the child sets its own", async () => { + async function maxConcurrentChildCalls(childConcurrency?: number) { + let active = 0; + let max = 0; + const slow = defineTool({ + name: 'slow', + description: 'Takes a moment', + input: z.object({}), + execute: async () => { + active++; + max = Math.max(max, active); + await new Promise((resolve) => setTimeout(resolve, 10)); + active--; + return 'ok'; + }, + }); + const researcher = createAgent({ + provider: mockModel([{ toolCalls: [{ name: 'slow' }, { name: 'slow' }] }, 'found']), + tools: [slow], + description: 'Researches', + toolConcurrency: childConcurrency, + }); + await run({ provider: mockModel([{ toolCalls: [task('researcher')] }, 'done']), subagents: { researcher }, toolConcurrency: 1 }); + return max; + } + + expect(await maxConcurrentChildCalls()).toBe(1); + expect(await maxConcurrentChildCalls(2)).toBe(2); + }); + + it('createDelegateTool children inherit too: hooks, and usage rolled up into the parent', async () => { + const seen: string[] = []; + const hooks = new HookRegistry(); + hooks.register({ name: 'spy', preGenerate: (ctx) => void seen.push(ctx.subagent?.name ?? 'parent') }); + const child = { name: 'Billing', agentType: AgentType.SmartAssistant }; + const registry = new ToolRegistry(); + registry.register( + 'delegate', + createDelegateTool({ agent: child, provider: mockModel([{ text: 'refunded', usage: { inputTokens: 50, outputTokens: 5 } }]) }) + ); + + const result = await AgentExecutor.execute({ + agent: { ...lead, tools: { delegate: { tool: 'delegate' } } }, + input: 'refund me', + provider: mockModel([ + { toolCalls: [{ name: 'delegate', args: { task: 'refund' } }], usage: { inputTokens: 1, outputTokens: 1 } }, + { text: 'done', usage: { inputTokens: 1, outputTokens: 1 } }, + ]), + toolRegistry: registry, + hooks, + }); + + expect(seen).toEqual(['parent', 'Billing', 'parent']); + expect(result.usage).toMatchObject({ inputTokens: 52, outputTokens: 7, totalTokens: 59, modelCalls: 3 }); + expect(result.usage.delegated).toMatchObject({ inputTokens: 50, outputTokens: 5, runs: 1 }); + }); +}); + +describe('approval inside a sub-agent (LOU-Y1)', () => { + it('pauses the whole run on the child call, and resume runs it exactly once and finishes both runs', async () => { + const approvals = memoryApprovals(); + const { agent: researcher, model: childModel, sent } = approvingChild('researcher', [ + { toolCalls: [{ name: 'send', args: { to: 'ana' } }] }, + 'sent!', + ]); + const leadModel = mockModel([{ toolCalls: [task('researcher')] }, 'done']); + const subagents = { researcher }; + + const paused = await run({ provider: leadModel, subagents, approvalStore: approvals.store }); + + expect(paused.finishReason).toBe('awaiting-approval'); + expect(sent).toEqual([]); + const { pending } = approvals.only(); + expect(pending).toMatchObject({ id: paused.approvalId, toolName: 'send', args: { to: 'ana' }, subagentPath: ['researcher'] }); + + const result = await resumeAfterApproval({ id: pending.id, approved: true }, approvals.store, new ToolRegistry(), leadModel, { + subagents, + }); + + expect(sent).toEqual(['ana']); + expect(result.finishReason).toBe('stop'); + expect(result.text).toBe('done'); + // Child usage before the pause (kept in the snapshot) and after the resume both roll up. + expect(result.usage.delegated?.runs).toBe(2); + expect(result.usage.modelCalls).toBe(4); + expect(approvals.records.size).toBe(0); + // The child resumed with its own transcript; the parent got the child's final answer as the task result. + expect(toolResults(childModel.calls[1].messages as Message[])[0].content).toContain('sent to ana'); + const taskResults = toolResults(result.messages); + expect(taskResults).toHaveLength(1); + expect(taskResults[0].content).toContain("sent!\\n\\n[sub-agent 'researcher': 2 step(s)"); + expect(JSON.stringify(result.messages)).not.toContain('awaiting-approval'); + }); + + it('a streaming lead reports the child call in one top-level approval.requested', async () => { + const approvals = memoryApprovals(); + const { agent: researcher } = approvingChild('researcher', [{ toolCalls: [{ name: 'send', args: { to: 'ana' } }] }]); + + const run = AgentExecutor.stream({ + agent: lead, + input: 'go', + provider: mockModel([{ toolCalls: [task('researcher')] }]), + subagents: { researcher }, + approvalStore: approvals.store, + }); + const events = []; + for await (const event of run) events.push(event); + + const requested = events.filter((e) => e.type === 'approval.requested'); + expect(requested).toHaveLength(1); + expect(requested[0]).toMatchObject({ approvalId: (await run.result).approvalId, toolName: 'send', args: { to: 'ana' } }); + expect(requested[0].subagent).toBeUndefined(); + expect(events.at(-1)).toMatchObject({ type: 'run.done', finishReason: 'awaiting-approval' }); + }); + + it('a rejection reaches the child, which carries on without running the call', async () => { + const approvals = memoryApprovals(); + const { agent: researcher, model: childModel, sent } = approvingChild('researcher', [ + { toolCalls: [{ name: 'send', args: { to: 'ana' } }] }, + 'could not send', + ]); + const leadModel = mockModel([{ toolCalls: [task('researcher')] }, 'done']); + + const paused = await run({ provider: leadModel, subagents: { researcher }, approvalStore: approvals.store }); + const result = await resumeAfterApproval( + { id: paused.approvalId!, approved: false, note: 'no' }, + approvals.store, + new ToolRegistry(), + leadModel, + { subagents: { researcher } } + ); + + expect(sent).toEqual([]); + expect(toolResults(childModel.calls[1].messages as Message[])[0].content).toContain('rejected by the reviewer'); + expect(toolResults(result.messages)[0].content).toContain('could not send'); + }); + + it('pauses again when the resumed child needs another approval', async () => { + const approvals = memoryApprovals(); + const { agent: researcher, sent } = approvingChild('researcher', [ + { toolCalls: [{ name: 'send', args: { to: 'ana' } }] }, + { toolCalls: [{ name: 'send', args: { to: 'bo' } }] }, + 'both sent', + ]); + const leadModel = mockModel([{ toolCalls: [task('researcher')] }, 'done']); + const options = { subagents: { researcher } }; + + const first = await run({ provider: leadModel, ...options, approvalStore: approvals.store }); + const second = await resumeAfterApproval({ id: first.approvalId!, approved: true }, approvals.store, new ToolRegistry(), leadModel, options); + + expect(second.finishReason).toBe('awaiting-approval'); + expect(sent).toEqual(['ana']); + expect(approvals.only().pending).toMatchObject({ id: second.approvalId, args: { to: 'bo' } }); + + const done = await resumeAfterApproval({ id: second.approvalId!, approved: true }, approvals.store, new ToolRegistry(), leadModel, options); + + expect(sent).toEqual(['ana', 'bo']); + expect(done.text).toBe('done'); + expect(toolResults(done.messages)[0].content).toContain('both sent'); + }); + + it('resumes a sub-agent of a sub-agent', async () => { + const approvals = memoryApprovals(); + const { agent: helper, sent } = approvingChild('helper', [{ toolCalls: [{ name: 'send', args: { to: 'ana' } }] }, 'helper sent']); + const researcher = createAgent({ + provider: mockModel([{ toolCalls: [task('helper')] }, 'researcher done']), + description: 'Researches', + subagents: { helper }, + }); + const leadModel = mockModel([{ toolCalls: [task('researcher')] }, 'done']); + const options = { subagents: { researcher }, maxSubagentDepth: 2 }; + + const paused = await run({ provider: leadModel, ...options, approvalStore: approvals.store }); + expect(approvals.only().pending.subagentPath).toEqual(['researcher', 'helper']); + + const result = await resumeAfterApproval({ id: paused.approvalId!, approved: true }, approvals.store, new ToolRegistry(), leadModel, options); + + expect(sent).toEqual(['ana']); + expect(result.text).toBe('done'); + expect(toolResults(result.messages)[0].content).toContain('researcher done'); + }); + + it('keeps the results of sibling tool calls that finished in the same turn, without re-running them', async () => { + const approvals = memoryApprovals(); + const { agent: researcher } = approvingChild('researcher', [{ toolCalls: [{ name: 'send', args: { to: 'ana' } }] }, 'sent']); + const writerModel = mockModel(['draft ready']); + const writer = createAgent({ provider: writerModel, description: 'Writes' }); + const leadModel = mockModel([{ toolCalls: [task('researcher'), task('writer')] }, 'done']); + const options = { subagents: { researcher, writer } }; + + const paused = await run({ provider: leadModel, ...options, approvalStore: approvals.store }); + const result = await resumeAfterApproval({ id: paused.approvalId!, approved: true }, approvals.store, new ToolRegistry(), leadModel, options); + + expect(writerModel.calls).toHaveLength(1); + const results = toolResults(result.messages); + expect(results.map((m) => m.toolCallId)).toEqual(['researcher-call', 'writer-call']); + expect(results[0].content).toContain('sent'); + expect(results[1].content).toContain('draft ready'); + }); + + it('when two sub-agents pause in one turn, pauses on the first and tells the model the second did not run', async () => { + const approvals = memoryApprovals(); + const first = approvingChild('first', [{ toolCalls: [{ name: 'send', args: { to: 'ana' } }] }, 'first sent']); + const second = approvingChild('second', [{ toolCalls: [{ name: 'send', args: { to: 'bo' } }] }, 'second sent']); + const leadModel = mockModel([{ toolCalls: [task('first'), task('second')] }, 'done']); + const options = { subagents: { first: first.agent, second: second.agent } }; + + const paused = await run({ provider: leadModel, ...options, approvalStore: approvals.store }); + + expect(approvals.only().pending.subagentPath).toEqual(['first']); + const secondResult = toolResults(paused.messages)[1]; + expect(secondResult.isError).toBe(true); + expect(secondResult.content).toContain("Sub-agent 'second' needed approval to run 'send'"); + + await resumeAfterApproval({ id: paused.approvalId!, approved: true }, approvals.store, new ToolRegistry(), leadModel, options); + + expect(first.sent).toEqual(['ana']); + expect(second.sent).toEqual([]); + }); + + it('works for createDelegateTool children', async () => { + const approvals = memoryApprovals(); + const sent: string[] = []; + const childRegistry = new ToolRegistry(); + childRegistry.register( + defineTool({ + name: 'send', + description: 'Sends', + input: z.object({}), + needsApproval: true, + execute: () => { + sent.push('x'); + return 'sent'; + }, + }) + ); + const child = { name: 'Mailer', agentType: AgentType.SmartAssistant, tools: { send: { tool: 'send' } } }; + const registry = new ToolRegistry(); + registry.register( + 'delegate', + createDelegateTool({ agent: child, provider: mockModel([{ toolCalls: [{ name: 'send' }] }, 'mail sent']), toolRegistry: childRegistry }) + ); + const parent = { ...lead, tools: { delegate: { tool: 'delegate' } } }; + const parentModel = mockModel([{ toolCalls: [{ name: 'delegate', args: { task: 'mail' } }] }, 'done']); + + const paused = await AgentExecutor.execute({ + agent: parent, + input: 'go', + provider: parentModel, + toolRegistry: registry, + approvalStore: approvals.store, + }); + expect(approvals.only().pending.subagentPath).toEqual(['Mailer']); + + const result = await resumeAfterApproval({ id: paused.approvalId!, approved: true }, approvals.store, registry, parentModel); + + expect(sent).toEqual(['x']); + expect(result.text).toBe('done'); + expect(JSON.parse(toolResults(result.messages)[0].content as string)).toMatchObject({ text: 'mail sent' }); + }); + + it('without an approval store, the child call becomes an error result and nothing runs', async () => { + const { agent: researcher, sent } = approvingChild('researcher', [{ toolCalls: [{ name: 'send', args: { to: 'ana' } }] }]); + + const result = await run({ provider: mockModel([{ toolCalls: [task('researcher')] }, 'done']), subagents: { researcher } }); + + expect(sent).toEqual([]); + const [taskResult] = toolResults(result.messages); + expect(taskResult.isError).toBe(true); + expect(taskResult.content).toContain('requires approval but no approvalStore'); + }); +}); diff --git a/src/execution/subagentRuntime.ts b/src/execution/subagentRuntime.ts new file mode 100644 index 000000000..cd30ea572 --- /dev/null +++ b/src/execution/subagentRuntime.ts @@ -0,0 +1,229 @@ +/** + * Runtime plumbing that lets a child agent run (a sub-agent started by the + * `task` tool or by `createDelegateTool()`) inherit from the parent run that + * called it (LOU-Y1). + * + * The executor binds a {@link ToolCallScope} - the parent run's runtime + * options, the calling tool call and its span - to the options object every + * tool's `execute(args, options)` receives, so the delegation core can read + * it without anything going through the tool arguments (which the model + * controls) and without async-context globals (unavailable on Workers). + */ + +import type { LLMProvider, Message } from '../providers'; +import type { AgentConfig } from '../types'; +import type { ToolRegistry } from '../tools/ToolRegistry'; +import type { + ApprovalDecision, + ApprovalStore, + ExecutionSnapshot, + PendingApproval, + SubagentSuspension, +} from './ApprovalGate'; +import type { ExecuteOptions, ExecutionResult } from './AgentExecutor'; +import type { RunUsage } from '../models/usage'; +import type { ResumeExecuteOptions } from './resume'; +import { PropagatingToolError } from './propagatingToolError'; + +/** The parent run options a child run inherits. */ +export type InheritedRuntime = Pick< + ExecuteOptions, + | 'hooks' + | 'exporter' + | 'captureContent' + | 'redactContent' + | 'approvalStore' + | 'toolConcurrency' + | 'sandbox' + | 'signal' + | 'onEvent' + | 'maxSubagentDepth' +>; + +/** What the executor knows about the tool call that is running. */ +export interface ToolCallScope { + runtime: InheritedRuntime; + toolCallId: string; + /** The tool call's `execute_tool` span, when traced. */ + spanId?: string; + /** + * Adds a child run's usage to the parent's totals - set by + * resumeAfterApproval(); the executor hands tools `onDelegatedUsage` + * in their execute options instead (LOU-V5). + */ + onDelegatedUsage?: (usage: RunUsage) => void; + /** Runs a child agent (`AgentExecutor.execute`). */ + execute: (options: ExecuteOptions) => Promise; + /** Set by resumeAfterApproval() when this call re-enters a paused sub-agent. */ + resume?: { decision: ApprovalDecision; suspension: SubagentSuspension; run: ResumeRun }; +} + +/** `resumeAfterApproval()`, handed to the delegation core to resume a child. */ +export type ResumeRun = ( + decision: ApprovalDecision, + approvalStore: ApprovalStore, + toolRegistry: ToolRegistry, + provider: LLMProvider, + options: ResumeExecuteOptions +) => Promise; + +const toolCallScopes = new WeakMap(); + +/** Binds `scope` to the options object a tool's `execute` is called with. */ +export function bindToolCallScope(toolOptions: object, scope: ToolCallScope | undefined): void { + if (scope) { + toolCallScopes.set(toolOptions, scope); + } +} + +/** The tool call a tool's `execute` options belong to, when the executor started it. */ +export function toolCallScopeOf(toolOptions: unknown): ToolCallScope | undefined { + return typeof toolOptions === 'object' && toolOptions !== null ? toolCallScopes.get(toolOptions) : undefined; +} + +/** Default `maxSubagentDepth`: sub-agents cannot start sub-agents of their own. */ +const DEFAULT_MAX_SUBAGENT_DEPTH = 1; + +/** + * How many more levels of sub-agents a run may start. A sub-agent's run gets + * its parent's budget minus one, so the top-level run's `maxSubagentDepth` + * bounds the whole tree. + */ +export function subagentBudget(maxSubagentDepth: number | undefined): number { + return maxSubagentDepth ?? DEFAULT_MAX_SUBAGENT_DEPTH; +} + +/** + * Thrown by the delegation core when a child run paused for approval. The + * executor turns it into a placeholder result for the calling tool call and + * pauses the parent run once the turn's tool calls are done. + */ +export class SubagentApprovalPause extends PropagatingToolError { + constructor( + readonly agentName: string, + readonly snapshot: ExecutionSnapshot + ) { + super(`Sub-agent '${agentName}' is waiting for approval of '${snapshot.pendingToolCall.toolName}'`); + this.name = 'SubagentApprovalPause'; + } +} + +/** The paused child call deepest in a chain of suspensions. */ +function leafPending(snapshot: ExecutionSnapshot): PendingApproval { + return snapshot.pendingToolCall; +} + +/** + * The tool result recorded for a tool call whose sub-agent paused. It is + * replaced by the real result when the run is resumed. + */ +export function suspendedToolResult(suspension: SubagentSuspension): Record { + return { + status: 'awaiting-approval', + message: `Sub-agent '${suspension.agentName}' is waiting for a human to approve '${leafPending(suspension.snapshot).toolName}'. No result yet.`, + }; +} + +/** + * Builds the suspension record for a tool call whose sub-agent paused, from + * the call and the args it ran with. + */ +export function toSuspension( + pause: SubagentApprovalPause, + call: Pick +): SubagentSuspension { + return { + toolCallId: call.toolCallId, + toolName: call.toolName, + args: call.args, + agentName: pause.agentName, + snapshot: pause.snapshot, + }; +} + +/** + * The pending approval saved for a run paused on a sub-agent: the child's + * own pending call (so a reviewer sees what actually needs approving), with + * the chain of sub-agent names it runs inside. + */ +function pendingForSuspension(suspension: SubagentSuspension): PendingApproval { + const child = leafPending(suspension.snapshot); + return { ...child, subagentPath: [suspension.agentName, ...(child.subagentPath ?? [])] }; +} + +/** The approval record that pauses a parent run on a suspended sub-agent. */ +export function suspensionRecord( + run: { agent: AgentConfig; sessionId?: string }, + state: { messages: Message[]; steps: number; usage: RunUsage }, + suspension: SubagentSuspension +): { pending: PendingApproval; snapshot: ExecutionSnapshot } { + const pending = pendingForSuspension(suspension); + return { + pending, + snapshot: { + agent: baseAgentOf(run.agent), + currentMessages: state.messages, + pendingToolCall: pending, + steps: state.steps, + sessionId: run.sessionId, + usage: structuredClone(state.usage), + subagent: suspension, + }, + }; +} + +/** + * Picks the suspended tool call (if any) the run pauses on after a turn: the + * first in call order - or none when the turn already pauses on a tool that + * needs approval itself. Only one approval can be pending per run, so the + * result of every other suspended call is rewritten into an error saying its + * sub-agent's call was never run (the sub-agent's paused state is dropped). + */ +export function settleSuspensions( + messages: Message[], + suspensions: readonly SubagentSuspension[], + pausingForTool: boolean +): SubagentSuspension | undefined { + const [first, ...rest] = suspensions; + for (const dropped of pausingForTool ? suspensions : rest) { + replaceToolResult(messages, { + role: 'tool', + content: JSON.stringify({ + error: `Sub-agent '${dropped.agentName}' needed approval to run '${leafPending(dropped.snapshot).toolName}' while this run was already pausing for another approval, so that call was not run and the sub-agent stopped. Call it again once the pending approval is resolved.`, + }), + name: dropped.toolName, + toolCallId: dropped.toolCallId, + toolName: dropped.toolName, + isError: true, + }); + } + return pausingForTool ? undefined : first; +} + +/** Replaces the tool result recorded for `message.toolCallId`, or appends `message` when there is none. */ +export function replaceToolResult(messages: Message[], message: Message): void { + const index = messages.findIndex((m) => m.role === 'tool' && m.toolCallId === message.toolCallId); + if (index === -1) { + messages.push(message); + } else { + messages[index] = message; + } +} + +const baseAgents = new WeakMap(); + +/** + * Returns `agent` with `changes` applied, remembering the original so that + * an approval snapshot stores the agent as the caller configured it (resume + * re-applies skills and sub-agents itself). + */ +export function extendAgent(agent: AgentConfig, changes: Partial): AgentConfig { + const extended = { ...agent, ...changes }; + baseAgents.set(extended, baseAgentOf(agent)); + return extended; +} + +/** The agent as configured, before skills or sub-agents were applied. */ +export function baseAgentOf(agent: AgentConfig): AgentConfig { + return baseAgents.get(agent) ?? agent; +} diff --git a/src/execution/toolCallExecution.ts b/src/execution/toolCallExecution.ts index e02312a52..40d53ec2c 100644 --- a/src/execution/toolCallExecution.ts +++ b/src/execution/toolCallExecution.ts @@ -14,6 +14,8 @@ import { HookRegistry, ToolCallHookContext } from './hooks'; import { toolErrorMessage, toolErrorResult } from './propagatingToolError'; import { ToolArgumentsValidationError, validateToolArguments } from './toolArgsValidation'; import type { ExecuteOptions } from './AgentExecutor'; +import type { SubagentSuspension } from './ApprovalGate'; +import { SubagentApprovalPause, suspendedToolResult, toSuspension, type ToolCallScope } from './subagentRuntime'; /** * Settled outcome of one tool call, as emitted in the 'tool-result' event @@ -26,6 +28,11 @@ export interface ToolCallOutcome { error?: string; requiresApproval?: boolean; args?: Record; + /** + * LOU-Y1: set when the tool started a sub-agent that paused for approval. + * `result` is then a placeholder; the run pauses once the turn is done. + */ + subagent?: SubagentSuspension; } /** Everything a tool call needs from the surrounding execute() run. */ @@ -40,6 +47,8 @@ export interface ToolCallContext { messages: Message[]; /** LOU-V1: the run's signal, handed to the tool as `abortSignal`. */ signal?: AbortSignal; + /** LOU-Y1: this call, as seen by a sub-agent the tool starts. */ + scope?: ToolCallScope; /** LOU-V5: where a delegated child's usage is reported (see ToolRunContext). */ onDelegatedUsage?: ToolRunContext['onDelegatedUsage']; } @@ -205,9 +214,7 @@ async function executePrepared( if (prepared.requiresApproval) { return approvalOutcome(prepared); } - return doExecuteToolCall(prepared.toolCall, ctx.toolRegistry, ctx.sandbox, prepared.args, ctx.signal, { - onDelegatedUsage: ctx.onDelegatedUsage, - }); + return doExecuteToolCall(prepared.toolCall, ctx, prepared.args); } /** @@ -302,12 +309,10 @@ function thrownToolFailure(toolCall: ToolCall, error: unknown): ToolCallOutcome */ async function doExecuteToolCall( toolCall: ToolCall, - toolRegistry: ToolRegistry | undefined, - sandbox: SandboxAdapter, - overrideArgs?: Record, - signal?: AbortSignal, - runContext?: ToolRunContext + ctx: ToolCallContext, + overrideArgs?: Record ): Promise { + const { toolRegistry } = ctx; if (!toolRegistry) { return toolFailure(toolCall, 'No tool registry available'); } @@ -336,9 +341,10 @@ async function doExecuteToolCall( toolCall.function.name, toolDesc, args, - sandbox, - signal, - runContext + ctx.sandbox, + ctx.signal, + { onDelegatedUsage: ctx.onDelegatedUsage }, + ctx.scope ); return { @@ -347,6 +353,25 @@ async function doExecuteToolCall( result, }; } catch (error) { + if (error instanceof SubagentApprovalPause) { + return suspendedOutcome(toolCall, overrideArgs ?? {}, error); + } return thrownToolFailure(toolCall, error); } } + +/** The outcome of a tool call whose sub-agent paused for approval. */ +function suspendedOutcome( + toolCall: ToolCall, + args: Record, + pause: SubagentApprovalPause +): ToolCallOutcome { + const subagent = toSuspension(pause, { toolCallId: toolCall.id, toolName: toolCall.function.name, args }); + return { + toolCallId: toolCall.id, + toolName: toolCall.function.name, + result: suspendedToolResult(subagent), + args, + subagent, + }; +} diff --git a/src/index.ts b/src/index.ts index 0092e6fa9..56f417f12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,6 +62,9 @@ export * from './projectInstructions'; // Skills: progressive disclosure of instructions (LOU-Y2) export * from './skills'; +// Sub-agents: the `subagents` option and its `task` tool (LOU-Y3) +export * from './subagents'; + // Filesystem agent loader: an agent as a directory (LOU-Y5) export * from './agentDir'; diff --git a/src/skills/withSkills.ts b/src/skills/withSkills.ts index 1b62e0b2d..9ba32dd08 100644 --- a/src/skills/withSkills.ts +++ b/src/skills/withSkills.ts @@ -1,8 +1,9 @@ import { z } from 'zod'; import type { AgentConfig } from '../types'; -import { defineTool } from '../tools/defineTool'; +import { defineTool, type DefinedTool } from '../tools/defineTool'; import { ToolRegistry } from '../tools/ToolRegistry'; import type { Skill } from './defineSkill'; +import { extendAgent } from '../execution/subagentRuntime'; /** Name of the tool the model uses to load a skill's full content. */ const LOAD_SKILL_TOOL = 'load_skill'; @@ -52,31 +53,40 @@ function assertUsable(skills: readonly Skill[], agent: AgentConfig, registry?: T } /** - * Applies skills to an agent run: appends the "Available skills" block to the - * system prompt and adds a `load_skill` tool. Inputs are not mutated; with no - * skills they are returned as is. + * Adds `tool` and a system-prompt `block` to an agent run, without mutating + * the inputs. Shared by skills and sub-agents (the `task` tool). */ -export function withSkills( +export function withPromptTool( agent: AgentConfig, toolRegistry: ToolRegistry | undefined, - skills: readonly Skill[] | undefined -): { agent: AgentConfig; toolRegistry: ToolRegistry | undefined } { - if (!skills || skills.length === 0) return { agent, toolRegistry }; - assertUsable(skills, agent, toolRegistry); - + tool: DefinedTool, + block: string +): { agent: AgentConfig; toolRegistry: ToolRegistry } { const registry = new ToolRegistry(); for (const [name, descriptor] of Object.entries(toolRegistry?.getAll() ?? {})) { registry.register(name, descriptor); } - registry.register(createLoadSkillTool(skills)); - - const block = skillsPromptBlock(skills); + registry.register(tool); return { - agent: { - ...agent, + agent: extendAgent(agent, { prompt: agent.prompt ? `${agent.prompt}\n\n${block}` : block, - tools: { ...agent.tools, [LOAD_SKILL_TOOL]: { tool: LOAD_SKILL_TOOL } }, - }, + tools: { ...agent.tools, [tool.name]: { tool: tool.name } }, + }), toolRegistry: registry, }; } + +/** + * Applies skills to an agent run: appends the "Available skills" block to the + * system prompt and adds a `load_skill` tool. Inputs are not mutated; with no + * skills they are returned as is. + */ +export function withSkills( + agent: AgentConfig, + toolRegistry: ToolRegistry | undefined, + skills: readonly Skill[] | undefined +): { agent: AgentConfig; toolRegistry: ToolRegistry | undefined } { + if (!skills || skills.length === 0) return { agent, toolRegistry }; + assertUsable(skills, agent, toolRegistry); + return withPromptTool(agent, toolRegistry, createLoadSkillTool(skills), skillsPromptBlock(skills)); +} diff --git a/src/subagents/index.ts b/src/subagents/index.ts new file mode 100644 index 000000000..b420aab5a --- /dev/null +++ b/src/subagents/index.ts @@ -0,0 +1 @@ +export type { SubagentCatalog, SubagentSummary, Subagents } from './types'; diff --git a/src/subagents/subagents.test.ts b/src/subagents/subagents.test.ts new file mode 100644 index 000000000..97ee92760 --- /dev/null +++ b/src/subagents/subagents.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, vi } from 'vitest'; +import { z } from 'zod'; +import { createAgent } from '../createAgent'; +import { defineTool } from '../tools/defineTool'; +import { mockModel } from '../testing'; +import type { Message } from '../providers'; +import type { SubagentCatalog } from './types'; + +const task = (agent: string, prompt: string, id?: string) => ({ + name: 'task', + args: { agent, prompt, description: `${agent} task` }, + ...(id ? { id } : {}), +}); + +function toolMessages(messages: readonly Message[]): Message[] { + return messages.filter((m) => m.role === 'tool'); +} + +describe('subagents option and the task tool (LOU-Y3)', () => { + it('runs a sub-agent on only the task prompt and returns its answer with a metadata footer', async () => { + const researcherModel = mockModel(['Paris is the capital of France.']); + const researcher = createAgent({ + provider: researcherModel, + instructions: 'You research.', + description: 'Finds and summarizes sources', + }); + const leadModel = mockModel([{ text: 'Let me ask.', toolCalls: [task('researcher', 'What is the capital of France?')] }, 'Paris.']); + const lead = createAgent({ provider: leadModel, instructions: 'You coordinate.', subagents: { researcher } }); + + const result = await lead.send('Tell me about France, my secret is 42'); + + // Isolated context: the child sees its own system prompt and the task prompt only. + expect(researcherModel.calls[0].messages).toEqual([ + { role: 'system', content: 'You research.' }, + { role: 'user', content: 'What is the capital of France?' }, + ]); + expect(JSON.stringify(researcherModel.calls[0].messages)).not.toContain('secret'); + + // The lead sees the sub-agent list and the single `task` tool. + const leadRequest = leadModel.calls[0]; + expect(leadRequest.messages[0].content).toContain('## Available sub-agents'); + expect(leadRequest.messages[0].content).toContain('- researcher: Finds and summarizes sources'); + expect(leadRequest.tools?.map((t) => t.function.name)).toEqual(['task']); + + const [toolResult] = toolMessages(result.messages); + const text = JSON.parse(toolResult.content as string) as string; + expect(text).toBe("Paris is the capital of France.\n\n[sub-agent 'researcher': 1 step(s), finish reason 'stop']"); + expect(result.text).toBe('Paris.'); + }); + + it('runs several task calls of one turn in parallel', async () => { + let started = 0; + let releaseAll!: () => void; + const bothStarted = new Promise((resolve) => (releaseAll = resolve)); + const rendezvous = defineTool({ + name: 'rendezvous', + description: 'Waits until both sub-agents are running', + input: z.object({}), + execute: async () => { + started++; + if (started === 2) releaseAll(); + await Promise.race([ + bothStarted, + new Promise((_, reject) => setTimeout(() => reject(new Error('sub-agents did not run in parallel')), 2000)), + ]); + return 'met'; + }, + }); + const child = (name: string) => + createAgent({ + name, + provider: mockModel([{ toolCalls: [{ name: 'rendezvous' }] }, `${name} done`]), + tools: [rendezvous], + description: `The ${name}`, + }); + const leadModel = mockModel([{ toolCalls: [task('researcher', 'a', 't1'), task('writer', 'b', 't2')] }, 'all done']); + const lead = createAgent({ provider: leadModel, subagents: { researcher: child('researcher'), writer: child('writer') } }); + + const result = await lead.send('go'); + + expect(result.text).toBe('all done'); + const results = toolMessages(result.messages); + expect(results.map((m) => m.toolCallId)).toEqual(['t1', 't2']); + expect(results[0].content).toContain('researcher done'); + expect(results[1].content).toContain('writer done'); + expect(results.some((m) => m.isError)).toBe(false); + }); + + it("streams the sub-agent's events inside the lead's agent.stream(), tagged with subagent", async () => { + const researcher = createAgent({ + name: 'researcher', + provider: mockModel([{ toolCalls: [{ name: 'lookup' }] }, 'found it']), + tools: [defineTool({ name: 'lookup', description: 'Looks up', input: z.object({}), execute: () => 'data' })], + description: 'Researches', + }); + const lead = createAgent({ + name: 'lead', + provider: mockModel([{ toolCalls: [task('researcher', 'look', 'lead-1')] }, 'done']), + subagents: { researcher }, + }); + + const events = []; + for await (const event of lead.stream('go')) events.push(event); + + const tag = { name: 'researcher', depth: 1, toolCallId: 'lead-1', description: 'researcher task' }; + const child = events.filter((e) => e.subagent).map((e) => [e.type, e.subagent]); + // (The child's text is streamed: one or more text.delta, collapsed here.) + const types = child.map(([type]) => type).filter((type, i, all) => type !== 'text.delta' || all[i - 1] !== type); + expect(types).toEqual([ + 'step.start', + 'tool.start', + 'tool.done', + 'step.done', + 'step.start', + 'text.delta', + 'text.done', + 'step.done', + ]); + expect(child.every(([, subagent]) => JSON.stringify(subagent) === JSON.stringify(tag))).toBe(true); + // run.start / run.done mark the top-level run only, exactly once. + expect(events.filter((e) => e.type === 'run.start' || e.type === 'run.done').every((e) => !e.subagent)).toBe(true); + const top = events.filter((e) => !e.subagent); + expect(top[0].type).toBe('run.start'); + expect(top.at(-1)).toMatchObject({ type: 'run.done', text: 'done' }); + expect(events.at(-1)).toBe(top.at(-1)); + expect(events.map((e) => e.seq)).toEqual(events.map((_, i) => i)); + // The child runs inside the lead's task call. + const taskStart = events.findIndex((e) => e.type === 'tool.start' && !e.subagent); + const taskDone = events.findIndex((e) => e.type === 'tool.done' && !e.subagent); + expect(events.findIndex((e) => e.subagent)).toBeGreaterThan(taskStart); + expect(events.findLastIndex((e) => e.subagent)).toBeLessThan(taskDone); + }); + + describe('maxSubagentDepth', () => { + function nestedTree(maxSubagentDepth?: number) { + const helperModel = mockModel(['helped']); + const helper = createAgent({ + provider: helperModel, + description: 'Helps', + subagents: { deeper: createAgent({ provider: mockModel(['x']), description: 'Deeper' }) }, + }); + const researcherModel = mockModel([{ toolCalls: [task('helper', 'help me')] }, 'researched']); + const researcher = createAgent({ provider: researcherModel, description: 'Researches', subagents: { helper } }); + const leadModel = mockModel([{ toolCalls: [task('researcher', 'research')] }, 'done']); + const lead = createAgent({ provider: leadModel, subagents: { researcher }, maxSubagentDepth }); + return { lead, leadModel, researcherModel, helperModel }; + } + + it('defaults to 1: sub-agents are not offered the task tool', async () => { + const { lead, researcherModel } = nestedTree(); + await lead.send('go'); + expect(researcherModel.calls[0].tools).toBeUndefined(); + expect(researcherModel.calls[0].messages[0].content ?? '').not.toContain('Available sub-agents'); + }); + + it('with 2, sub-agents can delegate once more, and the limit holds deeper down', async () => { + const { lead, researcherModel, helperModel } = nestedTree(2); + const result = await lead.send('go'); + expect(researcherModel.calls[0].tools?.map((t) => t.function.name)).toEqual(['task']); + // The helper sits at depth 2 = the root's limit: it has sub-agents but no task tool. + expect(helperModel.calls[0].tools).toBeUndefined(); + expect(toolMessages(researcherModel.calls[1].messages as Message[])[0].content).toContain('helped'); + expect(result.text).toBe('done'); + }); + + it('with 0, not even the lead gets the task tool', async () => { + const { lead, leadModel } = nestedTree(0); + await lead.send('go').catch(() => undefined); + expect(leadModel.calls[0].tools).toBeUndefined(); + }); + + it('rejects an invalid value', () => { + expect(() => createAgent({ provider: mockModel([]), maxSubagentDepth: -1 })).toThrow(/maxSubagentDepth/); + }); + }); + + it('lists a dynamic catalog at the start of every run and resolves names on demand', async () => { + const researcher = createAgent({ provider: mockModel(['found it'], { onExhausted: 'repeat-last' }) }); + const catalog: SubagentCatalog = { + list: vi.fn(async () => [{ name: 'researcher', description: 'Looks things up' }]), + resolve: vi.fn(async (name: string) => (name === 'researcher' ? researcher : undefined)), + }; + const leadModel = mockModel([{ toolCalls: [task('researcher', 'look')] }, 'done', 'hi again']); + const lead = createAgent({ provider: leadModel, subagents: catalog }); + + const result = await lead.send('go'); + await lead.send('again'); + + expect(catalog.list).toHaveBeenCalledTimes(2); + expect(catalog.resolve).toHaveBeenCalledWith('researcher'); + expect(leadModel.calls[0].messages[0].content).toContain('- researcher: Looks things up'); + expect(toolMessages(result.messages)[0].content).toContain('found it'); + }); + + it('gives the lead a structured error listing the valid names for an unknown sub-agent', async () => { + const researcher = createAgent({ provider: mockModel(['x']), description: 'Researches' }); + const leadModel = mockModel([{ toolCalls: [task('nope', 'do it')] }, 'ok']); + const lead = createAgent({ provider: leadModel, subagents: { researcher } }); + + const result = await lead.send('go'); + + const [toolResult] = toolMessages(result.messages); + expect(toolResult.isError).toBe(true); + expect(toolResult.content).toContain('researcher'); + expect(toolResult.content).toContain('nope'); + }); + + it('gives the lead an error result saying why when a sub-agent runs out of steps', async () => { + const looping = defineTool({ name: 'again', description: 'loops', input: z.object({}), execute: () => 'more' }); + const researcher = createAgent({ + provider: mockModel([{ text: 'still working', toolCalls: [{ name: 'again' }] }], { onExhausted: 'repeat-last' }), + tools: [looping], + maxSteps: 2, + description: 'Researches', + }); + const lead = createAgent({ provider: mockModel([{ toolCalls: [task('researcher', 'go')] }, 'ok']), subagents: { researcher } }); + + const [toolResult] = toolMessages((await lead.send('go')).messages); + + expect(toolResult.isError).toBe(true); + expect(toolResult.content).toContain("Sub-agent 'researcher' used all 2 of its steps"); + expect(toolResult.content).toContain('still working'); + }); + + it('gives the lead an error result when a sub-agent fails', async () => { + const researcher = createAgent({ provider: mockModel([{ error: new Error('model is down') }]), description: 'Researches' }); + const lead = createAgent({ provider: mockModel([{ toolCalls: [task('researcher', 'go')] }, 'ok']), subagents: { researcher } }); + + const [toolResult] = toolMessages((await lead.send('go')).messages); + + expect(toolResult.isError).toBe(true); + expect(toolResult.content).toContain("Sub-agent 'researcher' failed:"); + }); + + it("adds sub-agents' token usage to the lead's", async () => { + const researcher = createAgent({ + provider: mockModel([{ text: 'r', usage: { inputTokens: 100, outputTokens: 10 } }]), + description: 'Researches', + }); + const lead = createAgent({ + provider: mockModel([ + { toolCalls: [task('researcher', 'go')], usage: { inputTokens: 1, outputTokens: 1 } }, + { text: 'done', usage: { inputTokens: 2, outputTokens: 2 } }, + ]), + subagents: { researcher }, + }); + + const { usage } = await lead.send('go'); + + expect(usage).toMatchObject({ inputTokens: 103, outputTokens: 13, totalTokens: 116, modelCalls: 3 }); + }); + + describe('configuration errors', () => { + const researcher = createAgent({ provider: mockModel([]), description: 'Researches' }); + + it('requires a description on every sub-agent', () => { + const undescribed = createAgent({ provider: mockModel([]) }); + expect(() => createAgent({ provider: mockModel([]), subagents: { undescribed } })).toThrow( + /sub-agent 'undescribed' has no description/ + ); + }); + + it('only accepts createAgent() agents', () => { + const fake = { send: async () => ({}) } as unknown as typeof researcher; + expect(() => createAgent({ provider: mockModel([]), subagents: { fake } })).toThrow(/not an agent created with createAgent/); + }); + + it('refuses a user tool already named task', () => { + const own = defineTool({ name: 'task', description: 'mine', input: z.object({}), execute: () => 'x' }); + expect(() => createAgent({ provider: mockModel([]), tools: [own], subagents: { researcher } })).toThrow( + /a tool named 'task' is already registered/ + ); + }); + + it('rejects a catalog that lists a sub-agent without a description', async () => { + const lead = createAgent({ + provider: mockModel(['x']), + subagents: { list: () => [{ name: 'a', description: ' ' }], resolve: () => undefined }, + }); + await expect(lead.send('go')).rejects.toThrow(/without a description/); + }); + + it('rejects a catalog that lists a name twice', async () => { + const lead = createAgent({ + provider: mockModel(['x']), + subagents: { list: () => [{ name: 'a', description: 'A' }, { name: 'a', description: 'A' }], resolve: () => undefined }, + }); + await expect(lead.send('go')).rejects.toThrow(/the name 'a' twice/); + }); + + it('offers no task tool for an empty catalog', async () => { + const model = mockModel(['x']); + await createAgent({ provider: model, subagents: { list: () => [], resolve: () => undefined } }).send('go'); + expect(model.calls[0].tools).toBeUndefined(); + }); + }); +}); diff --git a/src/subagents/types.ts b/src/subagents/types.ts new file mode 100644 index 000000000..c719755dc --- /dev/null +++ b/src/subagents/types.ts @@ -0,0 +1,34 @@ +import type { SimpleAgent } from '../createAgent'; + +/** A sub-agent as listed to the lead agent: its name and what it is for. */ +export interface SubagentSummary { + /** The name the lead passes as the `task` tool's `agent`. */ + name: string; + /** What the sub-agent does; shown to the lead model so it can pick one. */ + description: string; +} + +/** + * A dynamic set of sub-agents (LOU-Y3). `list()` is called at the start of + * every run that offers the `task` tool; `resolve(name)` when the lead calls + * `task` with that name (return `undefined` for an unknown name). + * + * @example + * ```ts + * const catalog: SubagentCatalog = { + * list: async () => [{ name: 'researcher', description: 'Finds and summarizes sources' }], + * resolve: async (name) => (name === 'researcher' ? researcher : undefined), + * }; + * ``` + */ +export interface SubagentCatalog { + list(): readonly SubagentSummary[] | Promise; + resolve(name: string): SimpleAgent | undefined | Promise; +} + +/** + * The sub-agents a lead agent can delegate to with the `task` tool: a record + * of `createAgent()` agents keyed by name (each needs a `description`), or a + * {@link SubagentCatalog}. + */ +export type Subagents = Readonly> | SubagentCatalog; diff --git a/src/subagents/withSubagents.ts b/src/subagents/withSubagents.ts new file mode 100644 index 000000000..dafcb8bfc --- /dev/null +++ b/src/subagents/withSubagents.ts @@ -0,0 +1,225 @@ +/** + * The `subagents` option (LOU-Y3): registers ONE `task` tool through which a + * lead agent delegates work to named sub-agents, and lists them (name + + * description) in the lead's system prompt. Sub-agents run through the + * shared delegation core (`runSubagent()`), so they inherit the lead run's + * runtime (LOU-Y1). + */ + +import { z } from 'zod'; +import type { AgentConfig } from '../types'; +import type { ToolRegistry } from '../tools/ToolRegistry'; +import { defineTool, type DefinedTool } from '../tools/defineTool'; +import { withPromptTool } from '../skills/withSkills'; +import { runSubagent, type SubagentSpec } from '../execution/delegation'; +import { subagentBudget } from '../execution/subagentRuntime'; +import { isPropagatingToolError } from '../execution/propagatingToolError'; +import type { ExecutionResult } from '../execution/AgentExecutor'; +import type { SubagentCatalog, SubagentSummary, Subagents } from './types'; + +/** Name of the tool the lead model delegates with. */ +const TASK_TOOL = 'task'; + +/** Same default as `ExecuteOptions.maxSteps`. */ +const DEFAULT_MAX_STEPS = 10; + +/** An agent usable as a sub-agent: its run configuration and description. */ +interface RegisteredSubagent { + spec: SubagentSpec; + description?: string; +} + +const registeredSubagents = new WeakMap(); + +/** Makes `agent` (a `createAgent()` result) usable as a sub-agent. */ +export function registerSubagent(agent: object, registration: RegisteredSubagent): void { + registeredSubagents.set(agent, registration); +} + +function isCatalog(subagents: Subagents): subagents is SubagentCatalog { + const candidate = subagents as Partial; + return typeof candidate.list === 'function' && typeof candidate.resolve === 'function'; +} + +function registrationOf(agent: unknown, name: string, caller: string): RegisteredSubagent { + const registration = typeof agent === 'object' && agent !== null ? registeredSubagents.get(agent) : undefined; + if (!registration) { + throw new Error( + `${caller}: sub-agent '${name}' is not an agent created with createAgent(). ` + + `Example: subagents: { ${name}: createAgent({ instructions, description, provider }) }` + ); + } + return registration; +} + +/** + * Throws a descriptive error unless `value` is a valid `maxSubagentDepth` + * (`undefined` means "use the default" and is accepted). + */ +export function assertMaxSubagentDepth(value: unknown, caller: string): void { + if (value === undefined || (typeof value === 'number' && Number.isInteger(value) && value >= 0)) { + return; + } + throw new Error( + `${caller}: 'maxSubagentDepth' must be a whole number >= 0, got ${String(value)}. ` + + 'Use 1 (the default) to let only the lead agent delegate, 2 to let its sub-agents delegate too.' + ); +} + +/** + * Checks a `subagents` option up front: every agent of a record must come + * from createAgent() and have a `description`. A catalog is checked per run. + */ +export function assertSubagents(subagents: Subagents | undefined, caller: string): void { + if (!subagents || isCatalog(subagents)) { + return; + } + for (const [name, agent] of Object.entries(subagents)) { + if (!registrationOf(agent, name, caller).description?.trim()) { + throw new Error( + `${caller}: sub-agent '${name}' has no description. The lead model picks a sub-agent by its description - ` + + `add one: createAgent({ ..., description: 'Finds and summarizes sources' }).` + ); + } + } +} + +function assertSummaries(summaries: readonly SubagentSummary[]): void { + const seen = new Set(); + for (const { name, description } of summaries) { + if (!name || seen.has(name)) { + throw new Error(`subagents: catalog list() returned ${name ? `the name '${name}' twice` : 'an empty name'}. Names must be unique and non-empty.`); + } + if (!description?.trim()) { + throw new Error(`subagents: catalog list() returned sub-agent '${name}' without a description. Add one so the lead model can pick it.`); + } + seen.add(name); + } +} + +async function listSubagents(subagents: Subagents): Promise { + if (isCatalog(subagents)) { + const summaries = [...(await subagents.list())]; + assertSummaries(summaries); + return summaries; + } + assertSubagents(subagents, 'subagents'); + return Object.entries(subagents).map(([name, agent]) => ({ + name, + description: registeredSubagents.get(agent)?.description ?? '', + })); +} + +async function resolveSubagent(subagents: Subagents, name: string, names: readonly string[]): Promise { + const agent = !names.includes(name) + ? undefined + : isCatalog(subagents) + ? await subagents.resolve(name) + : subagents[name]; + if (!agent) { + throw new Error(`Unknown sub-agent '${name}'. Valid sub-agents: ${names.join(', ')}.`); + } + return registrationOf(agent, name, 'task'); +} + +function subagentsPromptBlock(summaries: readonly SubagentSummary[]): string { + return [ + '## Available sub-agents', + '', + `Delegate a self-contained task to one of these with the \`${TASK_TOOL}\` tool. A sub-agent sees only the prompt you give it, not this conversation, so include everything it needs. Several \`${TASK_TOOL}\` calls in one turn run in parallel.`, + '', + ...summaries.map((s) => `- ${s.name}: ${s.description.replace(/\s+/g, ' ').trim()}`), + ].join('\n'); +} + +/** Why a sub-agent run that did not finish normally failed. */ +function failureReason(name: string, result: ExecutionResult, maxSteps: number): string { + const last = result.text ? ` Its last text: ${result.text}` : ''; + if (result.finishReason === 'aborted') { + return `Sub-agent '${name}' was aborted before it finished.${last}`; + } + if (result.steps >= maxSteps) { + return `Sub-agent '${name}' used all ${maxSteps} of its steps (maxSteps) without giving a final answer.${last}`; + } + return `Sub-agent '${name}' ended with finish reason '${result.finishReason}' without a final answer.${last}`; +} + +/** The `task` tool result: the sub-agent's final text plus a metadata footer. Throws when it did not finish. */ +function taskResult(name: string, result: ExecutionResult, maxSteps: number): string { + if (result.finishReason !== 'stop' && result.finishReason !== 'length') { + throw new Error(failureReason(name, result, maxSteps)); + } + const footer = `[sub-agent '${name}': ${result.steps} step(s), finish reason '${result.finishReason}']`; + return result.text ? `${result.text}\n\n${footer}` : footer; +} + +async function runTask( + subagents: Subagents, + names: readonly string[], + args: { agent: string; prompt: string; description: string }, + toolOptions: { abortSignal?: AbortSignal } | undefined +): Promise { + const { spec } = await resolveSubagent(subagents, args.agent, names); + let result: ExecutionResult; + try { + result = await runSubagent(spec, { + name: args.agent, + input: [{ role: 'user', content: args.prompt }], + toolOptions, + description: args.description, + }); + } catch (error) { + if (isPropagatingToolError(error)) throw error; + throw new Error(`Sub-agent '${args.agent}' failed: ${(error as Error | undefined)?.message ?? String(error)}`); + } + return taskResult(args.agent, result, spec.maxSteps ?? DEFAULT_MAX_STEPS); +} + +function createTaskTool(subagents: Subagents, summaries: readonly SubagentSummary[]): DefinedTool { + const names = summaries.map((s) => s.name); + return defineTool({ + name: TASK_TOOL, + description: + 'Delegate a self-contained task to a sub-agent listed under "Available sub-agents" and get its final answer back. ' + + 'The sub-agent sees only your prompt, not this conversation.', + input: z.object({ + agent: z.enum(names as [string, ...string[]]).describe('Name of the sub-agent, exactly as listed'), + prompt: z.string().describe('Complete instructions for the sub-agent, including all context it needs'), + description: z.string().describe('A short (3-5 word) label for this task'), + }), + execute: (args, ctx) => runTask(subagents, names, args, ctx), + }); +} + +/** Throws when the agent already has a tool named `task`. */ +export function assertNoTaskTool(agent: AgentConfig, toolRegistry: ToolRegistry | undefined): void { + if (toolRegistry?.has(TASK_TOOL) || agent.tools?.[TASK_TOOL]) { + throw new Error( + `subagents: a tool named '${TASK_TOOL}' is already registered, but agents with sub-agents get one automatically. ` + + `Rename your tool, or remove the 'subagents' option.` + ); + } +} + +/** + * Applies `subagents` to an agent run: adds the `task` tool and the + * "Available sub-agents" prompt block - unless this run is already at the + * sub-agent depth limit (see `maxSubagentDepth`), or the catalog is empty. + * Inputs are not mutated. + */ +export async function withSubagents( + agent: AgentConfig, + toolRegistry: ToolRegistry | undefined, + subagents: Subagents | undefined, + maxSubagentDepth: number | undefined +): Promise<{ agent: AgentConfig; toolRegistry: ToolRegistry | undefined }> { + if (!subagents || subagentBudget(maxSubagentDepth) <= 0) { + return { agent, toolRegistry }; + } + assertNoTaskTool(agent, toolRegistry); + const summaries = await listSubagents(subagents); + if (summaries.length === 0) { + return { agent, toolRegistry }; + } + return withPromptTool(agent, toolRegistry, createTaskTool(subagents, summaries), subagentsPromptBlock(summaries)); +}