From f0995076b1fdd3afeacb830a895848f9591c08d9 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 1 Oct 2026 11:01:47 +0300 Subject: [PATCH 1/2] LOU-Y1, LOU-Y3: sub-agents inherit the parent runtime; subagents option with a task tool LOU-Y3: `subagents` (a record of createAgent() agents with a new `description` field, or a `{ list, resolve }` catalog) on createAgent() and AgentExecutor.execute() registers ONE `task` tool ({ agent, prompt, description }) and an "Available sub-agents" prompt block. A sub-agent runs on the task prompt alone (isolated context); its final text plus a steps/finish-reason footer becomes the tool result, and failures (error, maxSteps, abort, unknown name) become error results that say why. `maxSubagentDepth` (default 1) bounds nesting: a run at the limit is not offered `task`. Parallel task calls run concurrently (LOU-V3). LOU-Y1: the `task` tool and createDelegateTool() share one delegation core (src/execution/delegation.ts). The child inherits the parent run's abort signal, trace exporter and span parent (invoke_agent under execute_tool), hooks (ctx.subagent set; a throwing hook halts the whole run), approval store, toolConcurrency (unless the child sets its own), sandbox, captureContent/redactContent and onEvent (events tagged with event.subagent); child token usage rolls into the parent's result.usage. The executor passes this through the tool's execute options object (no async-context globals, so the Worker bundle stays free of node:*). Approvals: a child call that needs approval pauses the WHOLE run. The child's paused snapshot is stored inside the parent's single approval record (ExecutionSnapshot.subagent, PendingApproval.subagentPath); the parent transcript keeps a placeholder result for the task call, so sibling results of the same turn are kept. resumeAfterApproval() re-enters the parent's tool call, resumes the child with the decision, and returns the child's final answer as the tool result (re-pausing if the child needs another approval), at any depth. If two children pause in one turn the run pauses on the first and the second gets an error result. Snapshots now store the agent as configured (before skills / sub-agents were applied) so resume can re-apply them. Co-Authored-By: Claude Opus 5.5 --- README.md | 28 +- docs/api-overview.md | 10 + docs/sub-agents.md | 231 ++++++++++ src/createAgent.ts | 62 ++- src/execution/AgentExecutor.ts | 118 +++++- src/execution/ApprovalGate.ts | 29 ++ src/execution/DelegationTool.ts | 24 +- src/execution/delegation.ts | 225 ++++++++++ src/execution/hooks.ts | 34 ++ src/execution/propagatingToolError.ts | 23 +- src/execution/resume.ts | 65 ++- src/execution/resumeSubagent.ts | 94 +++++ src/execution/sandboxGuard.ts | 6 +- src/execution/subagentInheritance.test.ts | 488 ++++++++++++++++++++++ src/execution/subagentRuntime.ts | 223 ++++++++++ src/execution/toolCallExecution.ts | 42 +- src/index.ts | 3 + src/skills/withSkills.ts | 44 +- src/subagents/index.ts | 1 + src/subagents/subagents.test.ts | 252 +++++++++++ src/subagents/types.ts | 34 ++ src/subagents/withSubagents.ts | 225 ++++++++++ 22 files changed, 2183 insertions(+), 78 deletions(-) create mode 100644 docs/sub-agents.md create mode 100644 src/execution/delegation.ts create mode 100644 src/execution/resumeSubagent.ts create mode 100644 src/execution/subagentInheritance.test.ts create mode 100644 src/execution/subagentRuntime.ts create mode 100644 src/subagents/index.ts create mode 100644 src/subagents/subagents.test.ts create mode 100644 src/subagents/types.ts create mode 100644 src/subagents/withSubagents.ts diff --git a/README.md b/README.md index d79abfcd8..667e6562e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ dashboard — any provider, any deploy target, no lock-in. - **Project instructions** — `createAgent({ projectInstructions: true })` appends the nearest `AGENTS.md` / `CLAUDE.md` to the instructions (opt-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 - **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 -- **Multi-agent delegation** — wrap a child agent as a tool with `createDelegateTool()`, with a `maxDepth` guard against delegation loops +- **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) - **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) @@ -231,6 +231,7 @@ console.log(session.id, session.messages.length); - [Testing](docs/testing.md) - unit-test agents deterministically with the scripted `mockModel` - [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 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/) @@ -385,10 +386,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'; @@ -602,7 +620,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 ca021b08f..a61e75ba3 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/sub-agents.md b/docs/sub-agents.md new file mode 100644 index 000000000..e23f3d330 --- /dev/null +++ b/docs/sub-agents.md @@ -0,0 +1,231 @@ +# 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` | Yes | The sub-agent's events are forwarded with an `event.subagent` field. | +| `toolConcurrency` | Yes, unless the sub-agent sets its own | | +| `sandbox` | Yes | | +| Token usage | Rolls up | Added to the lead's `result.usage`. | +| `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 + +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, so a UI can nest sub-agent activity under +the lead's `task` call (`event.subagent.toolCallId`). 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 of the part of a sub-agent that runs after the resume is not + 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/src/createAgent.ts b/src/createAgent.ts index ba4dacc99..687236a7a 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -29,6 +29,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 @@ -56,6 +59,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; /** @@ -196,6 +230,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); @@ -209,25 +245,29 @@ 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 spec: SubagentSpec = { + agent, + provider, + toolRegistry, + skills: config.skills, + subagents: config.subagents, + maxSubagentDepth: config.maxSubagentDepth, + maxSteps: config.maxSteps, + toolConcurrency: config.toolConcurrency, + }; const run = (input: string | Message[], signal?: AbortSignal): Promise => - AgentExecutor.execute({ - agent, - input, - provider, - toolRegistry, - skills: config.skills, - maxSteps: config.maxSteps, - toolConcurrency: config.toolConcurrency, - signal, - }); + AgentExecutor.execute({ ...spec, input, signal }); - return { + const simpleAgent: SimpleAgent = { async send(message: string, options: SendOptions = {}): Promise { return run(message, options.signal); }, 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 8885d7f5e..5fa39e653 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 { TraceExporter, withSpan } from './tracing'; import { @@ -19,7 +21,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, @@ -114,6 +122,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; } /** @@ -134,6 +149,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; @@ -411,16 +454,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 }; + } + /** * The actual execution loop, split out of execute() so the top-level * 'agent.run' span (LOU-E5) can wrap it via withSpan() while still @@ -626,6 +677,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({ @@ -644,13 +697,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); } /** @@ -661,7 +717,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); @@ -670,9 +727,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; } @@ -730,6 +791,14 @@ 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, + addUsage: (usage) => addUsage(state.usage, usage), + execute: (childOptions) => AgentExecutor.execute(childOptions), + }; const executed = await this.executeToolCall( toolCall, agent, @@ -741,7 +810,8 @@ export class AgentExecutor { sessionId, state.messages, signal, - onPrepared + onPrepared, + scope ); const parsedArgs = executed.args === undefined @@ -775,7 +845,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()` @@ -791,14 +861,27 @@ 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, }; + 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); this.emitEvent(onEvent, { type: 'finish', @@ -905,7 +988,8 @@ export class AgentExecutor { sessionId?: string, messages: Message[] = [], signal?: AbortSignal, - onPrepared?: (prepared: PreparedToolCall) => void + onPrepared?: (prepared: PreparedToolCall) => void, + scope?: ToolCallScope ): Promise { return runToolCall( toolCall, @@ -919,6 +1003,7 @@ export class AgentExecutor { sessionId, messages, signal, + scope, }, onPrepared ); @@ -952,6 +1037,7 @@ export class AgentExecutor { ); } assertToolConcurrency(options.toolConcurrency, 'AgentExecutor.execute'); + assertMaxSubagentDepth(options.maxSubagentDepth, 'AgentExecutor.execute'); } /** diff --git a/src/execution/ApprovalGate.ts b/src/execution/ApprovalGate.ts index 9ba0ebd3d..ba5a7cb7e 100644 --- a/src/execution/ApprovalGate.ts +++ b/src/execution/ApprovalGate.ts @@ -17,6 +17,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[]; } /** @@ -48,6 +54,29 @@ export interface ExecutionSnapshot { * execution at all. */ sessionId?: string; + /** + * 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 059c22d0c..58fc3e069 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'; @@ -89,6 +91,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; @@ -133,15 +142,12 @@ 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) + ); return { text: result.text, diff --git a/src/execution/delegation.ts b/src/execution/delegation.ts new file mode 100644 index 000000000..08417a4ef --- /dev/null +++ b/src/execution/delegation.ts @@ -0,0 +1,225 @@ +/** + * 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'; + +/** 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 }; + /** 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, + }); + + scope?.addUsage?.(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 snapshot = resume?.suspension.snapshot; + return snapshot && id === resume.decision.id ? { pending: snapshot.pendingToolCall, snapshot } : null; + }, + }; + 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 { + const runtime = scope?.runtime ?? {}; + return { + 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 6d9ed28ad..64a6569f8 100644 --- a/src/execution/resume.ts +++ b/src/execution/resume.ts @@ -18,6 +18,8 @@ import { NoopSandbox } from '../security/sandboxCore'; import { executeToolWithSandboxGuard } from './sandboxGuard'; import { HookRegistry } from './hooks'; import { toolErrorMessage } from './propagatingToolError'; +import { replaceToolResult, type ToolCallScope } from './subagentRuntime'; +import { resumeSubagentCall, type ResumeContext } from './resumeSubagent'; /** * Options passed through to the underlying AgentExecutor.execute() call @@ -113,13 +115,22 @@ 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, + 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, @@ -130,6 +141,35 @@ export async function resumeAfterApproval( }); } +/** + * 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, + 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 - @@ -196,7 +236,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 @@ -235,7 +276,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 @@ -270,7 +311,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 @@ -285,7 +327,8 @@ async function executeApprovedTool( toolDesc, args, sandbox, - executeOptions.signal + executeOptions.signal, + scope ), }; } catch (error) { diff --git a/src/execution/resumeSubagent.ts b/src/execution/resumeSubagent.ts new file mode 100644 index 000000000..5e5133818 --- /dev/null +++ b/src/execution/resumeSubagent.ts @@ -0,0 +1,94 @@ +/** + * 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 { 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; + /** `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, + 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 record = suspensionRecord(snapshot, { messages, steps: snapshot.steps }, suspension); + await ctx.approvalStore.save(record.pending, record.snapshot); + const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + 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 ff321aadc..f6f18f312 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'; /** * Execute `toolDesc` against `args`, honoring `requiresSandbox`: @@ -46,7 +47,8 @@ export async function executeToolWithSandboxGuard( toolDesc: ToolDescriptor, args: Record, sandbox: SandboxAdapter, - signal?: AbortSignal + signal?: AbortSignal, + scope?: ToolCallScope ): Promise { if (toolDesc.requiresSandbox) { if (!toolDesc.sandboxExecute) { @@ -63,5 +65,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 } 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..7bc512696 --- /dev/null +++ b/src/execution/subagentInheritance.test.ts @@ -0,0 +1,488 @@ +/** + * 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).toEqual({ promptTokens: 52, completionTokens: 7, totalTokens: 59 }); + }); +}); + +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'); + 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 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..fbc03acd1 --- /dev/null +++ b/src/execution/subagentRuntime.ts @@ -0,0 +1,223 @@ +/** + * 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 { 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 token usage to the parent run's total. */ + addUsage?: (usage: ExecutionResult['usage']) => 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 }, + 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, + 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 b0613e965..cde0720ce 100644 --- a/src/execution/toolCallExecution.ts +++ b/src/execution/toolCallExecution.ts @@ -13,6 +13,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 @@ -25,6 +27,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. */ @@ -39,6 +46,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; } /** @@ -202,7 +211,7 @@ async function executePrepared( if (prepared.requiresApproval) { return approvalOutcome(prepared); } - return doExecuteToolCall(prepared.toolCall, ctx.toolRegistry, ctx.sandbox, prepared.args, ctx.signal); + return doExecuteToolCall(prepared.toolCall, ctx, prepared.args); } /** @@ -297,11 +306,10 @@ function thrownToolFailure(toolCall: ToolCall, error: unknown): ToolCallOutcome */ async function doExecuteToolCall( toolCall: ToolCall, - toolRegistry: ToolRegistry | undefined, - sandbox: SandboxAdapter, - overrideArgs?: Record, - signal?: AbortSignal + ctx: ToolCallContext, + overrideArgs?: Record ): Promise { + const { toolRegistry } = ctx; if (!toolRegistry) { return toolFailure(toolCall, 'No tool registry available'); } @@ -330,8 +338,9 @@ async function doExecuteToolCall( toolCall.function.name, toolDesc, args, - sandbox, - signal + ctx.sandbox, + ctx.signal, + ctx.scope ); return { @@ -340,6 +349,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 ddfe59b44..35bce3b11 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'; + // Declarative agent spec file format (LOU-H9) export * from './spec'; 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..5136197dc --- /dev/null +++ b/src/subagents/subagents.test.ts @@ -0,0 +1,252 @@ +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); + }); + + 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).toEqual({ promptTokens: 103, completionTokens: 13, totalTokens: 116 }); + }); + + 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)); +} From ccd449c1fb733030391ecbfdbf1a45c064b26f04 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 1 Oct 2026 11:23:11 +0300 Subject: [PATCH 2/2] docs: regenerate llms.txt and llms-full.txt for sub-agents Co-Authored-By: Claude Opus 5.5 --- llms-full.txt | 312 +++++++++++++++++++++++++++++++++++++++++++++++++- llms.txt | 1 + 2 files changed, 308 insertions(+), 5 deletions(-) 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.