From 5ebe824f7242e7c49974848173999655379766c9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 12 Oct 2026 19:26:53 +0200 Subject: [PATCH 1/2] feat(ai, ai-persistence): one subagent tool with sessionId continue subagents.tool 'single' gives the model one subagent tool that names the agent, takes input or a prompt, continues a stored child by sessionId through the new LoadChild capability, and starts a background child through binding.start. withPersistence provides LoadChild and stores the task and owner thread of each child. --- .changeset/single-subagent-tool.md | 8 + docs/chat/subagents.md | 91 ++++- packages/ai-persistence/src/middleware.ts | 16 + packages/ai-persistence/src/subagent-runs.ts | 214 +++++++++- .../tests/subagent-persistence-cards.test.ts | 325 +++++++++++++++ .../ai/src/activities/chat/agents/bound.ts | 15 + .../ai/src/activities/chat/agents/spawn.ts | 269 +++++++++++- packages/ai/src/activities/chat/index.ts | 19 +- .../activities/chat/middleware/load-child.ts | 22 + .../src/activities/chat/tools/tool-calls.ts | 12 +- packages/ai/src/index.ts | 8 + .../ai/tests/subagent-single-tool.test.ts | 383 ++++++++++++++++++ .../e2e/fixtures/subagents/single-tool.json | 59 +++ testing/e2e/src/lib/subagents-test.ts | 5 +- testing/e2e/src/routes/api.subagents-test.ts | 59 ++- testing/e2e/tests/subagents.spec.ts | 35 +- 16 files changed, 1521 insertions(+), 19 deletions(-) create mode 100644 .changeset/single-subagent-tool.md create mode 100644 packages/ai/src/activities/chat/middleware/load-child.ts create mode 100644 packages/ai/tests/subagent-single-tool.test.ts create mode 100644 testing/e2e/fixtures/subagents/single-tool.json diff --git a/.changeset/single-subagent-tool.md b/.changeset/single-subagent-tool.md new file mode 100644 index 0000000000..a595491ac9 --- /dev/null +++ b/.changeset/single-subagent-tool.md @@ -0,0 +1,8 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-persistence': minor +--- + +One subagent tool. With `subagents: { tool: 'single' }`, the model picks an agent by name in one `subagent` tool (`SubagentToolInput`). An agent with an `inputSchema` takes `input`, and an agent without one takes an optional `prompt`. A `sessionId` continues an earlier child of the same agent, and `background` starts the child through a host's `subagents.binding.start`. The tool also supports the `detach` field of the tool context. + +`@tanstack/ai` adds `LoadChildCapability`, `getLoadChild`, and `provideLoadChild`, so a persistence middleware can load a stored child. `withPersistence` in `@tanstack/ai-persistence` provides it: it stores the task of each child's first call and the thread that started it, and loads only the children of the calling thread. A tool result carries `subagentRunId` when the model can continue the child. diff --git a/docs/chat/subagents.md b/docs/chat/subagents.md index 0bf9d32bfb..3efb0afe6c 100644 --- a/docs/chat/subagents.md +++ b/docs/chat/subagents.md @@ -154,7 +154,7 @@ const stream = chat({ }) ``` -**Without a router.** The library adds one synthetic server tool per agent. The main model calls that tool. The public stream still emits `SUBAGENT_STARTED` / `SUBAGENT_FINISHED` (or `SUBAGENT_ERROR`) and nested parts. The UI does not treat spawn as a normal tool card. The child's events stream while the tool runs, and the child's text becomes the tool result. The child reads the conversation as it is at that tool call. +**Without a router.** The library adds one synthetic server tool per agent. The main model calls that tool. The public stream still emits `SUBAGENT_STARTED` / `SUBAGENT_FINISHED` (or `SUBAGENT_ERROR`) and nested parts. The UI does not treat spawn as a normal tool card. The child's events stream while the tool runs, and the child's text becomes the tool result. The child reads the conversation as it is at that tool call. For one tool that can start every agent, see [Give the model one subagent tool](#give-the-model-one-subagent-tool). ## Let the model write the brief @@ -206,6 +206,91 @@ const stream = chat({ To show the brief in the UI, see [Show the brief on a card](#show-the-brief-on-a-card). +## Give the model one subagent tool + +Without a router, the model gets one tool per agent, and each call starts a new child. The model cannot send a follow-up to a child that already answered. Set `tool: 'single'`. The model then gets one tool, `subagent`, and names the agent in each call. A call can also continue a child from an earlier turn. + +```ts +import { + chat, + chatParamsFromRequest, + defineAgent, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence' +import { z } from 'zod' + +const researcher = defineAgent({ + name: 'researcher', + description: 'Researches one question and returns sourced findings', + inputSchema: z.object({ question: z.string() }), + run: (ctx) => + ctx.chat({ + adapter: openaiText('gpt-6.1-sol'), + messages: [{ role: 'user', content: ctx.input.question }], + }), +}) + +const writer = defineAgent({ + name: 'writer', + description: 'Writes and edits drafts', + run: (ctx) => ctx.chat({ adapter: openaiText('gpt-6.1-sol') }), +}) + +// A continued child loads from this store. Use a durable backend in production. +const persistence = memoryPersistence() + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + const stream = chat({ + adapter: openaiText('gpt-6.1-sol'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + middleware: [withPersistence(persistence)], + subagents: { agents: [researcher, writer], tool: 'single' }, + }) + return toServerSentEventsResponse(stream) +} +``` + +The model fills these fields of the tool input: + +- `agent`: the agent name. Each call needs it. +- `input`: the input of an agent with `inputSchema`. That agent needs it. +- `prompt`: the task as text, for an agent without `inputSchema`. The child gets it as the last user message. +- `sessionId`: the `subagentRunId` of an earlier result. The call continues that child. +- `background`: `true` starts the child and returns at once. Only a host with `subagents.binding.start` can do this. + +On the client, each child is a `type: 'subagent'` part, the same as with one tool per agent. `part.subagent.name` is the agent name. + +### Continue a child + +With `withPersistence` on the parent `chat()`, each result gives the model the `subagentRunId` of the child. The model uses it in a later turn: + +1. The model calls `{ "agent": "writer", "prompt": "Draft a post about tides" }`. +2. It gets `{ "subagentRunId": "subagent-1767225600000-x7k2m9q", "result": "Tides rise twice a day..." }`. +3. In the next turn, it calls `{ "agent": "writer", "sessionId": "subagent-1767225600000-x7k2m9q", "prompt": "Make it shorter" }`. + +The writer gets the task of its first call, its stored transcript, then `Make it shorter` as the last user message. + +- `sessionId` needs `withPersistence` on the parent `chat()`. The stored child comes from that store. +- A thread can continue only its own children. An id from another thread gives the tool error `Unknown sessionId "..."`. +- The call must name the agent that the child ran under. Another agent gives the tool error `Session "..." belongs to agent "writer", not "researcher".` +- The stream uses the same `subagentRunId`, so the client shows the new work on the card with that id. + +### Wrong calls + +A wrong call returns to the model as a tool error, and the run continues. For example: + +- `Agent "researcher" takes input, not prompt.` +- `Agent "researcher" needs input.` +- `sessionId needs a persistence store.` +- `background needs a host that runs children in the background.` + +If an agent or a tool already has the name `subagent`, `chat()` throws before the run starts. + ## Return a value, or call any activity A child does not have to be a chat. It can make an image, or return a plain value that the parent model reads as the tool result. Call the activity on `ctx` and return what it gives you: @@ -406,7 +491,7 @@ export function CleanupPanel() { The resume uses the plan that the router picked in the first run. It does not call the router again. While the child waits, its card has `status: 'suspended'` and `interruptIds`. After you approve, the card shows the tool result and the child's reply. Client tools in a child work the same way. -The same flow works without a router. The child's tool call stays open until the resume, then the parent model reads the child's result. The result content carries the child's output or error; `subagentRunId` stays in subagent tracking events. +The same flow works without a router. The child's tool call stays open until the resume, then the parent model reads the child's result. The result content carries the child's output or error. It carries `subagentRunId` only when the model can continue the child: with a persistence store, or for a background child. Else the id stays in the subagent events. ## Middleware @@ -707,6 +792,8 @@ function Briefs({ message }: { message: UIMessage }) { A child that a router started has no `parentToolCallId`, so it has no brief. +With `tool: 'single'`, that tool call is a call of the `subagent` tool. The brief is in the `input` field or the `prompt` field of its input. + ### Style one child's parts The researcher's reasoning and tool calls use the root widgets by default. To make them look different on the researcher card only, pass widgets to that card's `Parts`: diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index 77b0a90b39..1e7df7cfb0 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -3,8 +3,10 @@ import { fromSpecTokenUsage, getDetachableRun, InterruptResumeValidationError, + LoadChildCapability, MetadataCapability, modelMessagesToUIMessages, + provideLoadChild, provideMetadata, readInterruptBinding, validateInterruptResumeBatch, @@ -2086,6 +2088,7 @@ export function withPersistence( const provides = [ PersistenceCapability, PersistenceCompletionCapability, + LoadChildCapability, ...(persistence.stores.metadata ? [MetadataCapability] : []), ...(wantsInterrupts ? [InterruptsCapability] : []), ] @@ -2150,6 +2153,16 @@ export function withPersistence( if (persistence.stores.metadata) { provideMetadata(ctx, persistence.stores.metadata) } + // The `subagent` tool reads stored children through this, to continue + // one by `sessionId`. Only the children of this chat can load. + provideLoadChild(ctx, (subagentRunId) => + subagentRuns.loadChild(subagentRunId, { + threadId: ctx.threadId, + ...(ctx.subagentRunId !== undefined && { + subagentRunId: ctx.subagentRunId, + }), + }), + ) let resolveCompletion: () => void = () => undefined let rejectCompletion: (error: unknown) => void = () => undefined @@ -2323,6 +2336,9 @@ export function withPersistence( await subagentRuns.chunk({ threadId: ctx.threadId, runId: ctx.runId, + ...(ctx.subagentRunId !== undefined && { + subagentRunId: ctx.subagentRunId, + }), chunk, }) const current = runState.get(ctx) diff --git a/packages/ai-persistence/src/subagent-runs.ts b/packages/ai-persistence/src/subagent-runs.ts index fcbac82553..1d9c499dae 100644 --- a/packages/ai-persistence/src/subagent-runs.ts +++ b/packages/ai-persistence/src/subagent-runs.ts @@ -37,6 +37,77 @@ function childStoreId(subagentRunId: string) { return `subagent:${subagentRunId}` } +/** + * Head metadata key for what a later call of the child needs: + * - `base`: how many stored messages came before the current call. A resume + * of that call keeps the number. + * - `parentThreadId`: the thread that started the child. Only that thread can + * continue it. + * - `prompt`: the task of the first call, the first user message of the child. + */ +const CHILD_KEY = 'tanstack:subagentChild' + +function storedMarker(messages: ReadonlyArray) { + const marker: unknown = messages[0]?.metadata?.[CHILD_KEY] + const read = (key: string) => + marker !== null && typeof marker === 'object' + ? Reflect.get(marker, key) + : undefined + const base = read('base') + const parentThreadId = read('parentThreadId') + const prompt = read('prompt') + return { + ...(typeof base === 'number' && { base }), + ...(typeof parentThreadId === 'string' && { parentThreadId }), + ...(typeof prompt === 'string' && { prompt }), + } +} + +/** The thread a chat writes its children under: its own child thread, if any. */ +function ownerThread(caller: { threadId: string; subagentRunId?: string }) { + return caller.subagentRunId === undefined + ? caller.threadId + : childStoreId(caller.subagentRunId) +} + +// ponytail: only the `subagent` tool has `prompt` and `input` fields. Core does +// not export its name. +const SINGLE_TOOL = 'subagent' + +/** One field of the JSON arguments of a `subagent` tool call. */ +function singleToolField(call: { args: string }, key: 'prompt' | 'input') { + let args: unknown + try { + args = JSON.parse(call.args) + } catch { + return undefined + } + return args !== null && typeof args === 'object' + ? Reflect.get(args, key) + : undefined +} + +/** + * The task of a child that a tool call started: the `prompt` of the `subagent` + * tool, else its `input` as JSON. A `subagent` call with neither has no task. + * Other tools pass their whole arguments. + */ +function callPrompt(call: { name: string; args: string }) { + if (call.args === '') return undefined + if (call.name !== SINGLE_TOOL) return call.args + const prompt = singleToolField(call, 'prompt') + if (typeof prompt === 'string') return prompt + const input = singleToolField(call, 'input') + return input === undefined ? undefined : JSON.stringify(input) +} + +/** The `prompt` of a `subagent` call that continues a child, if any. */ +function followUpPrompt(call: { name: string; args: string } | undefined) { + if (call?.name !== SINGLE_TOOL) return undefined + const prompt = singleToolField(call, 'prompt') + return typeof prompt === 'string' ? prompt : undefined +} + function readSubagentRunId(chunk: StreamChunk) { if (!('subagentRunId' in chunk)) return const id = chunk.subagentRunId @@ -170,6 +241,13 @@ export function storedSubagentInfo( return wireSubagentInfo(messages[0]) } +/** A stored child transcript without its card-only placeholder. */ +function withoutPlaceholder(messages: ReadonlyArray) { + return messages.filter( + (message) => storedSubagentInfo([message])?.placeholder !== true, + ) +} + type ChildNote = { name: string /** The thread and run that feed this note now. */ @@ -186,6 +264,12 @@ type ChildNote = { interruptIds?: Array error?: { message: string; code?: string } metadata?: Record + /** Stored messages from before the current call. A resume keeps it. */ + base: number + /** The thread that started this child. */ + parentThreadId: string + /** The task of the first call. */ + prompt?: string } export function createSubagentRunRecorder(stores: { @@ -203,6 +287,15 @@ export function createSubagentRunRecorder(stores: { // suspends. Dropped on abort. const answered = new Map>() const parentSavedAt = new Map() + // Name and arguments of each open tool call, by id. A child that a call + // starts reads its task from here. The run's end drops the calls that are + // still open. + const calls = new Map() + + /** Drop the open tool calls of a run that ended. */ + function dropCalls(runId: string) { + for (const [id, call] of calls) if (call.runId === runId) calls.delete(id) + } async function loadMessages(threadId: string) { return stores.messages.loadThread(threadId) @@ -243,14 +336,27 @@ export function createSubagentRunRecorder(stores: { const transcript = convertMessagesToModelMessages( withoutCards(note.processor.getMessages()), ) + const marker = { + [CHILD_KEY]: { + base: note.base, + parentThreadId: note.parentThreadId, + ...(note.prompt !== undefined && { prompt: note.prompt }), + }, + } const [first, ...rest] = transcript const head: ModelMessage = first - ? { ...first, metadata: withSubagentInfo(first.metadata, info) } + ? { + ...first, + metadata: { ...withSubagentInfo(first.metadata, info), ...marker }, + } : { id: `child:${subagentRunId}`, role: 'assistant', content: '', - metadata: withSubagentInfo(undefined, { ...info, placeholder: true }), + metadata: { + ...withSubagentInfo(undefined, { ...info, placeholder: true }), + ...marker, + }, } await stores.messages.saveThread(childStoreId(subagentRunId), [ head, @@ -297,10 +403,17 @@ export function createSubagentRunRecorder(stores: { } async function startChild( - input: { threadId: string; runId: string }, + input: { threadId: string; runId: string; subagentRunId?: string }, chunk: Extract, ) { const id = chunk.subagentRunId + const call = + chunk.parentToolCallId === undefined + ? undefined + : calls.get(chunk.parentToolCallId) + if (chunk.parentToolCallId !== undefined) { + calls.delete(chunk.parentToolCallId) + } const record = await stores.runs?.createOrResume({ runId: id, threadId: childStoreId(id), @@ -317,10 +430,30 @@ export function createSubagentRunRecorder(stores: { delete existing.interruptIds if (chunk.metadata !== undefined) existing.metadata = chunk.metadata } else { - // A resume continues the stored transcript. - const stored = (await loadMessages(childStoreId(id))).filter( - (message) => storedSubagentInfo([message])?.placeholder !== true, - ) + // A resume or a continue goes on from the stored transcript. + const raw = await loadMessages(childStoreId(id)) + const stored = withoutPlaceholder(raw) + const marker = storedMarker(raw) + // A resume keeps the base of the call it resumes. A new call starts + // after the whole stored transcript. + const resumes = storedSubagentInfo(raw)?.status === 'suspended' + // Only the first call gives the prompt. A later call keeps it. + const prompt = raw.length > 0 ? marker.prompt : call && callPrompt(call) + // A call that continues the child adds its prompt as a user message. + // Core does not stream it, so store it here. + const followUp = + raw.length > 0 && !resumes ? followUpPrompt(call) : undefined + const initial: Array = + followUp === undefined + ? stored + : [ + ...stored, + { + id: `${childStoreId(id)}:prompt:${stored.length}`, + role: 'user', + content: followUp, + }, + ] children.set(id, { name: chunk.name, threadId: input.threadId, @@ -337,9 +470,16 @@ export function createSubagentRunRecorder(stores: { ...(chunk.metadata !== undefined && { metadata: chunk.metadata }), processor: new StreamProcessor({ subagentRunId: id, - initialMessages: modelMessagesToUIMessages(stored), + initialMessages: modelMessagesToUIMessages(initial), }), status: 'running', + base: resumes ? (marker.base ?? 0) : stored.length, + // A nested child belongs to the thread of the child that started it. + parentThreadId: ownerThread({ + threadId: input.threadId, + subagentRunId: chunk.parentSubagentRunId ?? input.subagentRunId, + }), + ...(prompt !== undefined && { prompt }), }) } if (record && record.status !== 'running') { @@ -352,6 +492,46 @@ export function createSubagentRunRecorder(stores: { await saveChild(id) } + /** + * The stored transcript of a child, for a call that continues it by + * `sessionId`. It starts with the task of the first call as a user message. + * `undefined` when `caller` did not start a child with this id. While the + * child waits on an interrupt, it is the transcript from before that call: + * the resume adds the newer messages of the child itself. + */ + async function loadChild( + subagentRunId: string, + caller: { threadId: string; subagentRunId?: string }, + ) { + const stored = await loadMessages(childStoreId(subagentRunId)) + const marker = storedMarker(stored) + // The id can come from a prompt injection. Never load the child of an + // other thread. + if (marker.parentThreadId !== ownerThread(caller)) return undefined + const messages = withoutPlaceholder(stored) + const info = storedSubagentInfo(stored) + const waits = info?.status === 'suspended' + const transcript = + waits && marker.base !== undefined + ? messages.slice(0, marker.base) + : messages + const task: Array = + marker.prompt === undefined + ? [] + : [ + { + id: `${childStoreId(subagentRunId)}:prompt`, + role: 'user', + content: marker.prompt, + }, + ] + // The agent the child ran under. Only that agent can continue it. + return { + messages: [...task, ...transcript], + ...(info !== undefined && { agent: info.name }), + } + } + async function settleChild( chunk: Extract< StreamChunk, @@ -467,6 +647,8 @@ export function createSubagentRunRecorder(stores: { } return { + loadChild, + async start(input: { threadId: string runId: string @@ -491,9 +673,23 @@ export function createSubagentRunRecorder(stores: { async chunk(input: { threadId: string runId: string + /** Set when the chat that streams this chunk is a child itself. */ + subagentRunId?: string chunk: StreamChunk }) { const chunk = input.chunk + if (chunk.type === 'TOOL_CALL_START') { + calls.set(chunk.toolCallId, { + name: chunk.toolCallName, + args: '', + runId: input.runId, + }) + } + if (chunk.type === 'TOOL_CALL_ARGS') { + const call = calls.get(chunk.toolCallId) + if (call) call.args += chunk.delta + } + if (chunk.type === 'TOOL_CALL_RESULT') calls.delete(chunk.toolCallId) if (chunk.type === 'SUBAGENT_STARTED') { await startChild(input, chunk) return @@ -551,6 +747,7 @@ export function createSubagentRunRecorder(stores: { }, async finish(input: { threadId: string; runId: string }) { + dropCalls(input.runId) await commitAnswers(input.runId) await saveParents(input.threadId) await settleOpenChildren(input.runId, 'completed') @@ -562,6 +759,7 @@ export function createSubagentRunRecorder(stores: { }, async abort(input: { threadId: string; runId: string; error?: unknown }) { + dropCalls(input.runId) const aborted = input.error instanceof Error && input.error.name === 'AbortError' const message = diff --git a/packages/ai-persistence/tests/subagent-persistence-cards.test.ts b/packages/ai-persistence/tests/subagent-persistence-cards.test.ts index 0809cee0b4..80f045dee7 100644 --- a/packages/ai-persistence/tests/subagent-persistence-cards.test.ts +++ b/packages/ai-persistence/tests/subagent-persistence-cards.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import { EventType, chat, defineAgent, uiMessagesToWire } from '@tanstack/ai' import type { AnyTextAdapter, + ChatMiddleware, + DefinedAgent, ModelMessage, StreamChunk, SubagentPart, @@ -152,6 +154,151 @@ async function collect(stream: AsyncIterable) { return chunks } +/** One text message, start to end. */ +function textMessage( + messageId: string, + text: string, + ids: { subagentRunId?: string } = {}, +) { + return [ + { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: 'assistant', + ...ids, + timestamp: t, + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: text, + ...ids, + timestamp: t, + }, + { type: EventType.TEXT_MESSAGE_END, messageId, ...ids, timestamp: t }, + ] satisfies Array +} + +/** A child start, as the parent stream carries it. */ +function childStarted( + subagentRunId: string, + links: { parentToolCallId?: string; parentSubagentRunId?: string }, +) { + return { + type: EventType.SUBAGENT_STARTED, + subagentRunId, + name: 'writer', + ...links, + timestamp: t, + } satisfies StreamChunk +} + +/** Feed one parent run's chunks on thread `desk` to a recorder. */ +async function feed( + recorder: ReturnType, + runId: string, + chunks: ReadonlyArray, +) { + for (const chunk of chunks) { + await recorder.chunk({ threadId: 'desk', runId, chunk }) + } +} + +/** A child that writes one note per call and keeps the messages it saw. */ +function noteTaker() { + const seen: Array> = [] + const agent = defineAgent({ + name: 'writer', + description: 'Writes notes', + run: async function* (ctx) { + seen.push(ctx.messages) + yield* textMessage(`note-${seen.length}`, `Note ${seen.length}`) + }, + }) + return { agent, seen } +} + +/** A parent model that calls the `subagent` tool once, then stops. */ +function callingSubagent(toolCallId: string, args: object) { + return scriptedAdapter([ + [ + { + type: EventType.TOOL_CALL_START, + toolCallId, + toolCallName: 'subagent', + timestamp: t, + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta: JSON.stringify(args), + timestamp: t, + }, + { + type: EventType.RUN_FINISHED, + runId: 'model', + threadId: 'desk', + finishReason: 'tool_calls', + timestamp: t, + }, + ], + [ + { + type: EventType.RUN_FINISHED, + runId: 'model', + threadId: 'desk', + finishReason: 'stop', + timestamp: t, + }, + ], + ]) +} + +/** One parent turn with the single `subagent` tool. The thread is `desk`. */ +function subagentTurn( + middleware: ChatMiddleware, + agent: DefinedAgent, + turn: { runId: string; toolCallId: string; args: object; threadId?: string }, +) { + return collect( + chat({ + adapter: callingSubagent(turn.toolCallId, turn.args), + threadId: turn.threadId ?? 'desk', + runId: turn.runId, + messages: [{ id: `u-${turn.runId}`, role: 'user', content: 'Notes' }], + middleware: [middleware], + subagents: { agents: [agent], tool: 'single' }, + }), + ) +} + +/** The id of the first child a run started. */ +function startedId(chunks: ReadonlyArray) { + const started = chunks.find( + (chunk) => chunk.type === EventType.SUBAGENT_STARTED, + ) + if (started?.type !== EventType.SUBAGENT_STARTED) { + throw new Error('no child started') + } + return started.subagentRunId +} + +/** The parsed result of one tool call. */ +function toolResult(chunks: ReadonlyArray, toolCallId: string) { + const result = chunks.find( + (chunk) => + chunk.type === EventType.TOOL_CALL_RESULT && + chunk.toolCallId === toolCallId, + ) + if ( + result?.type !== EventType.TOOL_CALL_RESULT || + typeof result.content !== 'string' + ) { + throw new Error(`no text result for ${toolCallId}`) + } + return JSON.parse(result.content) +} + describe('persisted subagent cards', () => { it('keeps reasoning, tool calls, and interrupts across a reload', async () => { const persistence = memoryPersistence() @@ -499,4 +646,182 @@ describe('subagent run recorder', () => { code: 'provider_error', }) }) + + it('gives a nested child to the child that started it', async () => { + const { stores } = memoryPersistence() + const recorder = createSubagentRunRecorder({ + messages: stores.messages, + intervalMs: 0, + }) + await feed(recorder, 'run-1', [ + childStarted('outer', { parentToolCallId: 'call_1' }), + childStarted('inner', { + parentSubagentRunId: 'outer', + parentToolCallId: 'call_n', + }), + ...textMessage('m1', 'Inner notes', { subagentRunId: 'inner' }), + ]) + + // The outer child's chat can continue it. The top thread cannot. + const fromOuter = { threadId: 'desk:writer', subagentRunId: 'outer' } + expect((await recorder.loadChild('inner', fromOuter))?.messages).toEqual([ + expect.objectContaining({ role: 'assistant', content: 'Inner notes' }), + ]) + expect( + await recorder.loadChild('inner', { threadId: 'desk' }), + ).toBeUndefined() + }) + + it('loads a waiting continued child without the messages of the call it waits in', async () => { + const { stores } = memoryPersistence() + const recorder = createSubagentRunRecorder({ + messages: stores.messages, + runs: stores.runs, + intervalMs: 0, + }) + const waiting = { + type: EventType.SUBAGENT_FINISHED, + subagentRunId: 'child', + outcome: { type: 'suspended', interruptIds: ['approval'] }, + timestamp: t, + } satisfies StreamChunk + const old = [{ role: 'assistant', content: 'Old notes' }] + await feed(recorder, 'run-1', [ + childStarted('child', { parentToolCallId: 'call_1' }), + ...textMessage('m1', 'Old notes', { subagentRunId: 'child' }), + { + type: EventType.SUBAGENT_FINISHED, + subagentRunId: 'child', + timestamp: t, + }, + ]) + + // run-2 continues the child. It writes, then waits for an approval. The + // resume brings those newer messages itself. + await feed(recorder, 'run-2', [ + childStarted('child', { parentToolCallId: 'call_2' }), + ...textMessage('m2', 'Delete a?', { subagentRunId: 'child' }), + waiting, + ]) + const desk = { threadId: 'desk' } + expect((await recorder.loadChild('child', desk))?.messages).toMatchObject( + old, + ) + + // run-3 resumes that call, and the child waits again. + await feed(recorder, 'run-3', [ + childStarted('child', { parentToolCallId: 'call_2' }), + ...textMessage('m3', 'Delete b?', { subagentRunId: 'child' }), + waiting, + ]) + expect((await recorder.loadChild('child', desk))?.messages).toMatchObject( + old, + ) + }) + + it('starts a loaded child with the task of its first call, and names its agent', async () => { + const { stores } = memoryPersistence() + const recorder = createSubagentRunRecorder({ + messages: stores.messages, + intervalMs: 0, + }) + await feed(recorder, 'run-1', [ + { + type: EventType.TOOL_CALL_START, + toolCallId: 'call_1', + toolCallName: 'subagent', + timestamp: t, + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call_1', + delta: '{"agent":"writer","input":{"topic":"tides"}}', + timestamp: t, + }, + childStarted('child', { parentToolCallId: 'call_1' }), + ...textMessage('m1', 'Tide notes', { subagentRunId: 'child' }), + ]) + + const loaded = await recorder.loadChild('child', { threadId: 'desk' }) + expect(loaded?.messages).toMatchObject([ + { role: 'user', content: '{"topic":"tides"}' }, + { role: 'assistant', content: 'Tide notes' }, + ]) + // The `subagent` tool lets only this agent continue the child. + expect(loaded?.agent).toBe('writer') + }) + + it('loads nothing for an unknown child', async () => { + const { stores } = memoryPersistence() + const recorder = createSubagentRunRecorder({ messages: stores.messages }) + + expect( + await recorder.loadChild('missing', { threadId: 'desk' }), + ).toBeUndefined() + }) +}) + +describe('single subagent tool with persistence', () => { + it('continues a stored child by sessionId', async () => { + const middleware = withPersistence(memoryPersistence()) + const { agent, seen } = noteTaker() + const first = await subagentTurn(middleware, agent, { + runId: 'run-1', + toolCallId: 'call_1', + args: { agent: 'writer', prompt: 'Take notes' }, + }) + expect(toolResult(first, 'call_1')).toMatchObject({ + subagentRunId: startedId(first), + }) + const second = await subagentTurn(middleware, agent, { + runId: 'run-2', + toolCallId: 'call_2', + args: { agent: 'writer', sessionId: startedId(first), prompt: 'More' }, + }) + + expect(startedId(second)).toBe(startedId(first)) + // The child starts from its first task and its stored transcript, then + // the new prompt. The first message is a user message. + expect(seen[1]).toMatchObject([ + { role: 'user', content: 'Take notes' }, + { role: 'assistant', content: 'Note 1' }, + { role: 'user', content: 'More' }, + ]) + + // A third call also sees the prompt of the second call. + await subagentTurn(middleware, agent, { + runId: 'run-3', + toolCallId: 'call_3', + args: { agent: 'writer', sessionId: startedId(first), prompt: 'Again' }, + }) + expect(seen[2]).toMatchObject([ + { role: 'user', content: 'Take notes' }, + { role: 'assistant', content: 'Note 1' }, + { role: 'user', content: 'More' }, + { role: 'assistant', content: 'Note 2' }, + { role: 'user', content: 'Again' }, + ]) + }) + + it('does not continue a child of another thread', async () => { + const middleware = withPersistence(memoryPersistence()) + const { agent, seen } = noteTaker() + const first = await subagentTurn(middleware, agent, { + runId: 'run-1', + toolCallId: 'call_1', + args: { agent: 'writer', prompt: 'Take notes' }, + }) + const id = startedId(first) + const other = await subagentTurn(middleware, agent, { + threadId: 'other', + runId: 'run-2', + toolCallId: 'call_2', + args: { agent: 'writer', sessionId: id, prompt: 'Show me' }, + }) + + expect(toolResult(other, 'call_2')).toEqual({ + error: `Unknown sessionId "${id}".`, + }) + expect(seen).toHaveLength(1) + }) }) diff --git a/packages/ai/src/activities/chat/agents/bound.ts b/packages/ai/src/activities/chat/agents/bound.ts index c7746fc640..33716347d1 100644 --- a/packages/ai/src/activities/chat/agents/bound.ts +++ b/packages/ai/src/activities/chat/agents/bound.ts @@ -131,6 +131,21 @@ export interface SubagentBinding { * uses it when the call gives no `promptCache`. */ promptCache?: PromptCacheRetention + /** + * Starts a child without waiting for it and returns its id at once. The + * single `subagent` tool calls it for `background: true`. With + * `wake: true`, the host starts a new parent turn when the child ends. It + * does not pass down to nested children. + */ + start?: ( + call: { + agent: string + input?: unknown + prompt?: string + parentToolCallId?: string + }, + options: { wake: true }, + ) => Promise<{ subagentRunId: string }> } /** diff --git a/packages/ai/src/activities/chat/agents/spawn.ts b/packages/ai/src/activities/chat/agents/spawn.ts index 5c633f5e20..62d663bf4f 100644 --- a/packages/ai/src/activities/chat/agents/spawn.ts +++ b/packages/ai/src/activities/chat/agents/spawn.ts @@ -12,10 +12,18 @@ import { import { mergeStreams } from '../../../utilities/merge-streams' import { INTERRUPT_BINDING_METADATA_KEY } from '../../../interrupt-resume' import { EMIT_STREAM_CHUNK, SUBAGENT_TOOL } from '../tools/tool-calls' +import { + convertSchemaToJsonSchema, + validateWithStandardSchema, +} from '../tools/schema-converter' +import { getLoadChild } from '../middleware/load-child' +import type { LoadChild } from '../middleware/load-child' import type { SubagentToolOutcome } from '../tools/tool-calls' import type { SpecTokenUsage } from '../../../utilities/ag-ui-usage' import type { + InferSchemaType, Interrupt, + JSONSchema, ModelMessage, RunAgentResumeItem, StreamChunk, @@ -24,6 +32,7 @@ import type { SubagentStartedEvent, TokenUsage, Tool, + ToolExecutionContext, UIMessage, } from '../../../types' import { envProviderKeys } from '../../../byok/env-keys' @@ -97,6 +106,50 @@ export interface SubagentsBag< * apply: a nested chat in a child uses the root's budget. */ limits?: SubagentLimits + /** + * The tools the parent model gets. `per-agent` (the default) gives one tool + * per agent, with the agent's name. `single` gives one tool named + * `subagent`. It takes the agent name, plus `sessionId` to continue a + * stored child and `background` to start a child without waiting. + */ + tool?: 'per-agent' | 'single' +} + +/** The name of the one tool in `subagents: { tool: 'single' }`. */ +export const SINGLE_SUBAGENT_TOOL = 'subagent' + +/** Fields every call of the single `subagent` tool can add. */ +interface SubagentCallOptions { + /** The `subagentRunId` of a stored, finished child. The call continues it. */ + sessionId?: string + /** Start the child and return at once. Needs a host with `binding.start`. */ + background?: boolean +} + +/** One agent's call: `input` for an agent with `inputSchema`, else `prompt`. */ +type SubagentCallFor = TAgent extends DefinedAgent + ? ([NonNullable] extends [never] + ? { agent: TAgent['name']; prompt?: string } + : { + agent: TAgent['name'] + input: InferSchemaType> + }) & + SubagentCallOptions + : never + +/** + * The input of the single `subagent` tool (`subagents: { tool: 'single' }`). + * A union on `agent`: an agent with an `inputSchema` needs `input`, and an + * agent without one takes an optional `prompt`. + */ +export type SubagentToolInput> = + SubagentCallFor + +/** A call of the single `subagent` tool, after the wire schema check. */ +interface SubagentCall extends SubagentCallOptions { + agent: string + input?: unknown + prompt?: string } /** What the children of one parent run left behind for the parent terminal. */ @@ -117,6 +170,13 @@ export interface SpawnEntry { name: string /** The checked tool input for an agent with `inputSchema`. */ input?: unknown + /** Text for an agent without `inputSchema`. One user message at the end. */ + prompt?: string + /** A stored, finished child to run again from its transcript. */ + continued?: { + subagentRunId: string + messages: Array + } resume?: { subagentRunId: string /** The child's own messages from the interrupted run. */ @@ -279,7 +339,9 @@ function openAgentStream( ) { const agent = agentByName(bag.agents, entry.name) const resume = entry.resume - const subagentRunId = resume?.subagentRunId ?? createSubagentId() + const continued = entry.continued + const subagentRunId = + resume?.subagentRunId ?? continued?.subagentRunId ?? createSubagentId() // The parent binds child interrupts to its own run (see rebindInterrupts), // so the resumed child continues from the interrupted parent run id. if (resume !== undefined && ctx.interruptedRunId === undefined) { @@ -287,10 +349,29 @@ function openAgentStream( `Subagent "${entry.name}" has interrupt answers, but the run has no parentRunId. Pass the interrupted run id as parentRunId.`, ) } + // A continued child starts from its stored transcript, not from the parent + // messages. A prompt is one more user message at the end. + const base = continued?.messages ?? ctx.messages + const messages = + entry.prompt === undefined + ? base + : [ + ...base, + { role: 'user', content: entry.prompt } satisfies ModelMessage, + ] + // The card of a continued child also holds its stored messages. Keep only + // the messages that the stored transcript does not have. + const storedIds = new Set(continued?.messages.map((message) => message.id)) const resumed = resume !== undefined ? { - messages: [...ctx.messages, ...resume.messages], + messages: [ + ...messages, + ...resume.messages.filter( + (message) => + message.id === undefined || !storedIds.has(message.id), + ), + ], parentRunId: ctx.interruptedRunId, resume: resume.entries, } @@ -299,7 +380,7 @@ function openAgentStream( agent, { input: entry.input, - messages: resumed?.messages ?? ctx.messages, + messages: resumed?.messages ?? messages, ...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}), threadId: childThreadId(bag.sandbox, ctx.threadId, entry.name), runId: childRunId(ctx.parentRunId, subagentRunId), @@ -848,12 +929,17 @@ function messagesBeforeCall( /** * Record the parent messages when the model calls a subagent tool, so the - * child reads the conversation as it is at that call. + * child reads the conversation as it is at that call. Also read the run's + * `loadChild` service, so a call can continue a stored child. */ export function subagentCallMessages(names: ReadonlySet) { const byCall = new Map>() + let loadChild: LoadChild | undefined const middleware: ChatMiddleware = { name: 'subagent-call-messages', + onStart(ctx) { + loadChild = getLoadChild(ctx, { optional: true }) + }, onBeforeToolCall(ctx, hook) { if (!names.has(hook.toolName)) return undefined byCall.set( @@ -867,9 +953,84 @@ export function subagentCallMessages(names: ReadonlySet) { middleware, messagesFor: (toolCallId: string | undefined) => toolCallId === undefined ? undefined : byCall.get(toolCallId), + childLoader: () => loadChild, + } +} + +/** The JSON Schema of each agent `input`, by agent name. */ +function agentInputSchemas(agents: ReadonlyArray) { + const schemas = new Map() + for (const agent of agents) { + const schema = convertSchemaToJsonSchema(agent.inputSchema, { io: 'input' }) + if (schema) schemas.set(agent.name, schema) + } + return schemas +} + +/** + * The description and the wire schema of the single `subagent` tool. + * Providers need one object at the top, so `input` is an `anyOf` of the agent + * schemas. `execute` checks each field against the picked agent. + */ +function singleToolShape(agents: ReadonlyArray) { + const inputs = agentInputSchemas(agents) + const takesPrompt = agents.some((agent) => !inputs.has(agent.name)) + const lines = agents.map((agent) => { + const schema = inputs.get(agent.name) + const how = schema + ? `Pass \`input\`: ${JSON.stringify(schema)}` + : 'Pass `prompt`: the task as text.' + return `- ${agent.name}: ${agent.description} ${how}` + }) + const inputSchema: JSONSchema = { + type: 'object', + properties: { + agent: { type: 'string', enum: agents.map((agent) => agent.name) }, + ...(inputs.size > 0 && { input: { anyOf: [...inputs.values()] } }), + ...(takesPrompt && { prompt: { type: 'string' } }), + sessionId: { type: 'string' }, + background: { type: 'boolean' }, + }, + required: ['agent'], + } + return { + description: [ + 'Run a subagent. Set `agent` to one of these names:', + ...lines, + 'Set `sessionId` to the subagentRunId of an earlier result to continue that child.', + 'Set `background` to true to start the child and get its subagentRunId at once.', + ].join('\n'), + inputSchema, } } +/** The checked `input` of a single-tool call. A wrong field throws a tool error. */ +async function callInput(agent: DefinedAgent, call: SubagentCall) { + if (agent.inputSchema === undefined) { + if (call.input !== undefined) { + throw new Error(`Agent "${agent.name}" takes prompt, not input.`) + } + return undefined + } + if (call.prompt !== undefined) { + throw new Error(`Agent "${agent.name}" takes input, not prompt.`) + } + if (call.input === undefined) { + throw new Error(`Agent "${agent.name}" needs input.`) + } + const checked = await validateWithStandardSchema( + agent.inputSchema, + call.input, + ) + if (!checked.success) { + const issues = checked.issues.map((issue) => issue.message).join(', ') + throw new Error( + `Input validation failed for agent ${agent.name}: ${issues}`, + ) + } + return checked.data +} + export function createSyntheticSubagentTools( bag: SubagentsBag, parent: { @@ -887,6 +1048,8 @@ export function createSyntheticSubagentTools( abortSignal?: AbortSignal turn?: SubagentTurn sink: SubagentSink + /** The run's `loadChild` service. See subagentCallMessages. */ + childLoader?: () => LoadChild | undefined }, ): Array { // One budget per root run. A child run inherits its parent's budget. @@ -1020,9 +1183,107 @@ export function createSyntheticSubagentTools( ...(result !== undefined ? { result } : {}), ...(error !== undefined ? { error } : {}), ...(sink.interrupts.length > 0 ? { interrupts: sink.interrupts } : {}), + // Only a stored child can be continued, and only by the single tool. + ...(bag.tool === 'single' && + subagentRunId !== '' && + parent.childLoader?.() + ? { keepRunId: true as const } + : {}), } satisfies SubagentToolOutcome } + /** + * The stored child that a `sessionId` names, for `agent`. Throws a tool + * error, also when the child ran under another agent. + */ + async function storedChild(sessionId: string, agent: string) { + const load = parent.childLoader?.() + if (!load) throw new Error('sessionId needs a persistence store.') + const stored = await load(sessionId) + if (!stored) throw new Error(`Unknown sessionId "${sessionId}".`) + if (stored.agent !== undefined && stored.agent !== agent) { + throw new Error( + `Session "${sessionId}" belongs to agent "${stored.agent}", not "${agent}".`, + ) + } + return { subagentRunId: sessionId, messages: stored.messages } + } + + if (bag.tool === 'single') { + // A thrown error in `execute` goes back to the model as a tool error. + const single = { + name: SINGLE_SUBAGENT_TOOL, + ...singleToolShape(bag.agents), + [SUBAGENT_TOOL]: true, + execute: async (call: SubagentCall, context?: ToolExecutionContext) => { + const wrongType = + (call.prompt !== undefined && typeof call.prompt !== 'string') || + (call.sessionId !== undefined && + typeof call.sessionId !== 'string') || + (call.background !== undefined && + typeof call.background !== 'boolean') + if (wrongType) { + throw new Error( + 'prompt and sessionId must be strings, and background a boolean.', + ) + } + const agent = bag.agents.find((entry) => entry.name === call.agent) + if (!agent) throw new Error(`Unknown agent "${call.agent}".`) + const input = await callInput(agent, call) + const fields = { + ...(input !== undefined && { input }), + ...(call.prompt !== undefined && { prompt: call.prompt }), + } + const child = { name: agent.name, ...fields } + if (call.background === true) { + if (call.sessionId !== undefined) { + throw new Error('background cannot continue a sessionId.') + } + const start = bag.binding?.start + if (!start) { + throw new Error( + 'background needs a host that runs children in the background.', + ) + } + // A background child counts against the tree budget too. + const refusal = budget.reserve(active) + if (refusal !== undefined) throw new Error(refusal) + const { subagentRunId } = await start( + { + agent: agent.name, + ...fields, + ...(context?.toolCallId !== undefined && { + parentToolCallId: context.toolCallId, + }), + }, + { wake: true }, + ) + return { + subagentRunId, + text: '', + result: { status: 'started' }, + keepRunId: true, + } satisfies SubagentToolOutcome + } + const { sessionId } = call + const work = + sessionId === undefined + ? runChild(child, context) + : storedChild(sessionId, agent.name).then((continued) => + runChild({ ...child, continued }, context), + ) + // The host can move the child to the background. It keeps running. + const detach = context?.detach + if (!detach) return work + const moved = detach(work).then( + (text) => ({ subagentRunId: '', text }) satisfies SubagentToolOutcome, + ) + return Promise.race([work, moved]) + }, + } + return [single] + } + return bag.agents.map((agent) => ({ name: agent.name, description: agent.description, diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 3ba9ab9bd4..389ce82ea1 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -63,6 +63,7 @@ import { LazyToolManager } from './tools/lazy-tool-manager' import { assertUniqueToolNames } from './tools/unique-tool-names' import type { DefinedAgent } from './agents/define-agent' import { + SINGLE_SUBAGENT_TOOL, collectNamedText, createSubagentSink, createSyntheticSubagentTools, @@ -5659,6 +5660,17 @@ export function chat< ) } + const singleToolNameTaken = + options.subagents?.tool === 'single' && + [...options.subagents.agents, ...(options.tools ?? [])].some( + (entry) => entry.name === SINGLE_SUBAGENT_TOOL, + ) + if (singleToolNameTaken) { + throw new Error( + `subagents.tool 'single' adds a tool named "${SINGLE_SUBAGENT_TOOL}". Rename the agent or tool that uses this name.`, + ) + } + if (outputSchema && stream === true) { return runStreamingStructuredOutput( toRuntimeTextActivityOptions(options, { @@ -5925,11 +5937,16 @@ async function* streamTextChunks( const turn = readSubagentTurn(messages, options.resume) const sink = createSubagentSink() const calls = subagentCallMessages( - new Set(bag.agents.map((agent: DefinedAgent) => agent.name)), + new Set( + bag.tool === 'single' + ? [SINGLE_SUBAGENT_TOOL] + : bag.agents.map((agent: DefinedAgent) => agent.name), + ), ) const synthetic = createSyntheticSubagentTools(bag, { messages: turn?.before ?? messages, messagesFor: calls.messagesFor, + childLoader: calls.childLoader, threadId, runId, ...(options.parentRunId !== undefined && { diff --git a/packages/ai/src/activities/chat/middleware/load-child.ts b/packages/ai/src/activities/chat/middleware/load-child.ts new file mode 100644 index 0000000000..7dd555f130 --- /dev/null +++ b/packages/ai/src/activities/chat/middleware/load-child.ts @@ -0,0 +1,22 @@ +import { createCapability } from './capabilities' +import type { ModelMessage, UIMessage } from '../../../types' + +/** + * Loads the stored transcript of a finished subagent run by its + * `subagentRunId`. Returns `undefined` for an unknown id. A persistence + * middleware provides it. The single `subagent` tool reads it to continue a + * child by `sessionId`. When `agent` is set, only that agent can continue + * the child. + */ +export type LoadChild = (subagentRunId: string) => Promise< + | { + messages: Array + /** The name of the agent the child ran under. */ + agent?: string + } + | undefined +> + +export const LoadChildCapability = createCapability()('load-child') + +export const [getLoadChild, provideLoadChild] = LoadChildCapability diff --git a/packages/ai/src/activities/chat/tools/tool-calls.ts b/packages/ai/src/activities/chat/tools/tool-calls.ts index 82983b4a46..2b889b2934 100644 --- a/packages/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/ai/src/activities/chat/tools/tool-calls.ts @@ -70,6 +70,12 @@ export interface SubagentToolOutcome { error?: string /** Set when the child stopped for outside input. The tool call stays open. */ interrupts?: Array + /** + * The model gets `subagentRunId` in the result: it can continue the child + * (`sessionId`, with a persistence store) or a background child. Else the + * id stays in the subagent events only. + */ + keepRunId?: true } function isSubagentTool(tool: AnyTool): boolean { @@ -780,9 +786,13 @@ export async function* executeServerTool( subagentInterrupts?.push(...outcome.interrupts) return } + const runId = outcome.keepRunId + ? { subagentRunId: outcome.subagentRunId } + : {} const modelResult = outcome.error - ? { error: outcome.error } + ? { ...runId, error: outcome.error } : { + ...runId, // A string result is also the child's text: the model gets all of it. result: outcome.result === undefined || typeof outcome.result === 'string' diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 5b70896670..efce0cf0e1 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -125,6 +125,7 @@ export type { SubagentStep, SubagentStepsPlan, SubagentsBag, + SubagentToolInput, } from './activities/chat/agents/spawn' // For hosts that run an agent outside a parent chat turn. export { @@ -385,6 +386,13 @@ export type { AnyChatMiddleware, MetadataStore, } from './activities/chat/middleware/index' +// A persistence store gives the single `subagent` tool the stored children. +export { + LoadChildCapability, + getLoadChild, + provideLoadChild, +} from './activities/chat/middleware/load-child' +export type { LoadChild } from './activities/chat/middleware/load-child' // Locks are a distributed-mutex primitive — coordination, not chat state — and // live behind their own subpath: `@tanstack/ai/locks` (see ./locks.ts). diff --git a/packages/ai/tests/subagent-single-tool.test.ts b/packages/ai/tests/subagent-single-tool.test.ts new file mode 100644 index 0000000000..f46be2543b --- /dev/null +++ b/packages/ai/tests/subagent-single-tool.test.ts @@ -0,0 +1,383 @@ +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { z } from 'zod' +import { defineAgent } from '../src/activities/chat/agents/define-agent' +import { chat } from '../src/activities/chat' +import { provideLoadChild } from '../src/activities/chat/middleware/load-child' +import { + createSubagentSink, + createSyntheticSubagentTools, +} from '../src/activities/chat/agents/spawn' +import { EventType } from '../src/types' +import { collectChunks, createMockAdapter, ev, serverTool } from './test-utils' +import type { DefinedAgent } from '../src/activities/chat/agents/define-agent' +import type { SubagentToolInput } from '../src/activities/chat/agents/spawn' +import type { SubagentTurn } from '../src/activities/chat/agents/turn' +import type { SubagentBinding } from '../src/activities/chat/agents/bound' +import type { ChatMiddleware } from '../src/activities/chat/middleware/types' +import type { ModelMessage, StreamChunk, Tool, UIMessage } from '../src/types' + +/** What one child run saw. */ +interface ChildRun { + agent: string + input: unknown + messages: Array +} + +const runs: Array = [] + +const researcher = defineAgent({ + name: 'researcher', + description: 'Looks up facts', + inputSchema: z.object({ topic: z.string() }), + run: async (ctx) => { + runs.push({ agent: 'researcher', input: ctx.input, messages: ctx.messages }) + return `facts about ${ctx.input.topic}` + }, +}) + +const writer = defineAgent({ + name: 'writer', + description: 'Writes text', + run: async (ctx) => { + runs.push({ agent: 'writer', input: ctx.input, messages: ctx.messages }) + return 'draft' + }, +}) + +const agents = [researcher, writer] + +type Call = SubagentToolInput + +beforeEach(() => { + runs.length = 0 +}) + +/** + * A parent model that writes a line of text, calls the `subagent` tool once + * with `args`, then stops. + */ +function parentCalling(args: object) { + return createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.textStart(), + ev.textContent('Asking a subagent.'), + ev.textEnd(), + ev.toolStart('call_1', 'subagent'), + ev.toolArgs('call_1', JSON.stringify(args)), + ev.runFinished('tool_calls'), + ], + [ev.runStarted(), ev.runFinished('stop')], + ], + }) +} + +/** The parsed result of tool call `call_1`. */ +function toolResult(chunks: Array) { + for (const chunk of chunks) { + if ( + chunk.type === EventType.TOOL_CALL_RESULT && + chunk.toolCallId === 'call_1' && + typeof chunk.content === 'string' + ) { + return JSON.parse(chunk.content) + } + } + throw new Error('No result for call_1') +} + +/** Run a parent chat with the single tool. The model calls it with `args`. */ +async function callSubagent( + args: object, + options: { + middleware?: Array + binding?: SubagentBinding + runId?: string + } = {}, +) { + const { adapter, calls } = parentCalling(args) + const chunks = await collectChunks( + chat({ + adapter, + ...(options.runId !== undefined && { runId: options.runId }), + messages: [{ role: 'user', content: 'Go' }], + subagents: { + agents, + tool: 'single', + ...(options.binding && { binding: options.binding }), + }, + ...(options.middleware && { middleware: options.middleware }), + }) as AsyncIterable, + ) + return { chunks, calls, result: toolResult(chunks) } +} + +/** + * A fake persistence middleware that provides the `loadChild` service. Each + * stored child ran under the `writer` agent. + */ +function storedChildren( + stored: Map>, +): ChatMiddleware { + return { + name: 'stored-children', + setup(ctx) { + provideLoadChild(ctx, async (subagentRunId) => { + const messages = stored.get(subagentRunId) + return messages === undefined + ? undefined + : { messages, agent: 'writer' } + }) + }, + } +} + +const oldChild = new Map>([ + [ + 'subagent-old', + [ + { role: 'user', content: 'Draft a tide report' }, + { role: 'assistant', content: 'Tides rise twice a day.' }, + ], + ], +]) + +describe('subagents tool: single', () => { + it('gives the model one subagent tool and runs an agent with its input', async () => { + const call: Call = { agent: 'researcher', input: { topic: 'tides' } } + const { calls, result } = await callSubagent(call) + + const tools = calls[0]?.tools ?? [] + expect(tools.map((tool) => tool.name)).toEqual(['subagent']) + expect(tools[0]?.inputSchema).toMatchObject({ + type: 'object', + properties: { agent: { enum: ['researcher', 'writer'] } }, + required: ['agent'], + }) + expect(runs).toMatchObject([ + { agent: 'researcher', input: { topic: 'tides' } }, + ]) + expect(result.result).toBe('facts about tides') + }) + + it('adds the prompt as the last user message of a child without inputSchema', async () => { + const call: Call = { agent: 'writer', prompt: 'Write a haiku' } + await callSubagent(call) + + expect(runs).toHaveLength(1) + expect(runs[0]?.input).toBeUndefined() + expect(runs[0]?.messages).toMatchObject([ + { role: 'user', content: 'Go' }, + { role: 'assistant', content: 'Asking a subagent.' }, + { role: 'user', content: 'Write a haiku' }, + ]) + }) + + it('continues a stored child by sessionId', async () => { + const call: Call = { + agent: 'writer', + sessionId: 'subagent-old', + prompt: 'Make it shorter', + } + const { result } = await callSubagent(call, { + middleware: [storedChildren(oldChild)], + }) + + expect(runs[0]?.messages).toEqual([ + { role: 'user', content: 'Draft a tide report' }, + { role: 'assistant', content: 'Tides rise twice a day.' }, + { role: 'user', content: 'Make it shorter' }, + ]) + expect(result).toEqual({ subagentRunId: 'subagent-old', result: 'draft' }) + }) + + it('gives the text of each continue its own message', async () => { + const call: Call = { agent: 'writer', sessionId: 'subagent-old' } + const ids: Array = [] + for (const runId of ['run-1', 'run-2']) { + const { chunks } = await callSubagent(call, { + middleware: [storedChildren(oldChild)], + runId, + }) + for (const chunk of chunks) { + const isChildText = + chunk.type === EventType.TEXT_MESSAGE_START && + 'subagentRunId' in chunk && + chunk.subagentRunId === 'subagent-old' + if (isChildText) ids.push(chunk.messageId) + } + } + + expect(ids).toHaveLength(2) + expect(ids[0]).not.toBe(ids[1]) + }) + + it('gives a resumed continued child each stored message once', async () => { + const stored: Array = [ + { id: 'old-user', role: 'user', content: 'Draft a tide report' }, + { id: 'old-reply', role: 'assistant', content: 'Tides rise twice.' }, + ] + const waiting: ModelMessage = { + id: 'new-reply', + role: 'assistant', + content: 'Shorter like this?', + } + const turn: SubagentTurn = { + before: [], + children: [ + { + subagentRunId: 'subagent-old', + name: 'writer', + status: 'suspended', + parentToolCallId: 'call_1', + // The card holds the whole child: the stored messages, then the + // call that waits. + messages: [...stored, waiting], + text: '', + resume: [ + { interruptId: 'approval_1', status: 'resolved', payload: true }, + ], + }, + ], + rest: [], + } + const [tool] = createSyntheticSubagentTools( + { agents, tool: 'single' }, + { + messages: [], + threadId: 'thread-1', + runId: 'run-2', + interruptedRunId: 'run-1', + turn, + sink: createSubagentSink(), + childLoader: () => async () => ({ messages: stored }), + }, + ) + await tool?.execute?.( + { agent: 'writer', sessionId: 'subagent-old', prompt: 'Make it shorter' }, + { toolCallId: 'call_1', emitCustomEvent: () => {} }, + ) + + expect(runs[0]?.messages).toEqual([ + ...stored, + { role: 'user', content: 'Make it shorter' }, + waiting, + ]) + }) + + it('starts a background child through the start hook', async () => { + const start = vi.fn(async () => ({ subagentRunId: 'subagent-bg' })) + const call: Call = { agent: 'writer', prompt: 'Draft', background: true } + const { result } = await callSubagent(call, { binding: { start } }) + + expect(result).toEqual({ + subagentRunId: 'subagent-bg', + result: { status: 'started' }, + }) + expect(start).toHaveBeenCalledWith( + { agent: 'writer', prompt: 'Draft', parentToolCallId: 'call_1' }, + { wake: true }, + ) + expect(runs).toEqual([]) + }) + + const toolErrors: Array<[string, object, Array, string]> = [ + [ + 'a prompt for an agent with inputSchema', + { agent: 'researcher', prompt: 'tides' }, + [], + 'Agent "researcher" takes input, not prompt.', + ], + [ + 'no input for an agent with inputSchema', + { agent: 'researcher' }, + [], + 'Agent "researcher" needs input.', + ], + [ + 'an input for an agent without inputSchema', + { agent: 'writer', input: { topic: 'tides' } }, + [], + 'Agent "writer" takes prompt, not input.', + ], + [ + 'a sessionId without a persistence store', + { agent: 'writer', sessionId: 'subagent-old' }, + [], + 'sessionId needs a persistence store.', + ], + [ + 'an unknown sessionId', + { agent: 'writer', sessionId: 'subagent-missing' }, + [storedChildren(oldChild)], + 'Unknown sessionId "subagent-missing".', + ], + [ + 'a sessionId of a child of another agent', + { agent: 'researcher', sessionId: 'subagent-old', input: { topic: 'x' } }, + [storedChildren(oldChild)], + 'Session "subagent-old" belongs to agent "writer", not "researcher".', + ], + [ + 'background without a host', + { agent: 'writer', background: true }, + [], + 'background needs a host that runs children in the background.', + ], + [ + 'a background that is not a boolean', + { agent: 'writer', background: 'true' }, + [], + 'prompt and sessionId must be strings, and background a boolean.', + ], + [ + 'background with a sessionId', + { agent: 'writer', background: true, sessionId: 'subagent-old' }, + [], + 'background cannot continue a sessionId.', + ], + ] + it.each(toolErrors)( + 'returns a tool error for %s', + async (_label, args, middleware, error) => { + const { chunks, result } = await callSubagent(args, { middleware }) + + expect(result).toEqual({ error }) + expect(runs).toEqual([]) + // The parent run goes on after the tool error. + expect(chunks.some((chunk) => chunk.type === EventType.RUN_ERROR)).toBe( + false, + ) + }, + ) + + const namedSubagent = defineAgent({ ...writer, name: 'subagent' }) + const takenNames: Array<[string, Array, Array]> = [ + ['an agent', [researcher, namedSubagent], []], + ['a tool', agents, [serverTool('subagent', () => 'ok')]], + ] + it.each(takenNames)( + 'throws when %s is named subagent', + (_label, named, tools) => { + const { adapter } = parentCalling({}) + expect(() => + chat({ + adapter, + messages: [{ role: 'user', content: 'Go' }], + tools, + subagents: { agents: named, tool: 'single' }, + }), + ).toThrow(`subagents.tool 'single' adds a tool named "subagent"`) + }, + ) + + it('types the input as a union on agent', () => { + expectTypeOf< + Extract['input'] + >().toEqualTypeOf<{ topic: string }>() + expectTypeOf>().not.toHaveProperty( + 'input', + ) + }) +}) diff --git a/testing/e2e/fixtures/subagents/single-tool.json b/testing/e2e/fixtures/subagents/single-tool.json new file mode 100644 index 0000000000..6c3a892105 --- /dev/null +++ b/testing/e2e/fixtures/subagents/single-tool.json @@ -0,0 +1,59 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "[subagent-session] take squid notes", + "sequenceIndex": 0 + }, + "response": { + "toolCalls": [ + { + "name": "subagent", + "arguments": "{\"agent\":\"researcher\",\"prompt\":\"[subagent-session-task] Note one squid fact.\"}" + } + ] + } + }, + { + "match": { + "userMessage": "[subagent-session-task] Note one squid fact.", + "sequenceIndex": 0 + }, + "response": { + "content": "Squids have three hearts." + } + }, + { + "match": { + "userMessage": "[subagent-session] take squid notes", + "sequenceIndex": 1 + }, + "response": { + "toolCalls": [ + { + "name": "subagent", + "arguments": "{\"agent\":\"researcher\",\"sessionId\":\"first-child\"}" + } + ] + } + }, + { + "match": { + "userMessage": "[subagent-session-task] Note one squid fact.", + "sequenceIndex": 1 + }, + "response": { + "content": "Squids also have blue blood." + } + }, + { + "match": { + "userMessage": "[subagent-session] take squid notes", + "sequenceIndex": 2 + }, + "response": { + "content": "Both notes are ready." + } + } + ] +} diff --git a/testing/e2e/src/lib/subagents-test.ts b/testing/e2e/src/lib/subagents-test.ts index 3bd8b62dc6..93bc27a05b 100644 --- a/testing/e2e/src/lib/subagents-test.ts +++ b/testing/e2e/src/lib/subagents-test.ts @@ -25,6 +25,7 @@ export type SubagentScenario = | 'brief' | 'result' | 'limits' + | 'session' /** The user message for each scenario. It matches `fixtures/subagents`. */ export const SUBAGENT_PROMPTS: Record = { @@ -34,6 +35,7 @@ export const SUBAGENT_PROMPTS: Record = { brief: '[subagent-brief] compare squid facts', result: '[subagent-result] price vendor a', limits: '[subagent-limits] research two topics', + session: '[subagent-session] take squid notes', } export function isSubagentScenario(value: unknown): value is SubagentScenario { @@ -43,6 +45,7 @@ export function isSubagentScenario(value: unknown): value is SubagentScenario { value === 'tool' || value === 'brief' || value === 'result' || - value === 'limits' + value === 'limits' || + value === 'session' ) } diff --git a/testing/e2e/src/routes/api.subagents-test.ts b/testing/e2e/src/routes/api.subagents-test.ts index 29c4224265..5252024922 100644 --- a/testing/e2e/src/routes/api.subagents-test.ts +++ b/testing/e2e/src/routes/api.subagents-test.ts @@ -6,8 +6,14 @@ import { maxIterations, toServerSentEventsResponse, } from '@tanstack/ai' +import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence' import { z } from 'zod' -import type { DefinedAgent, ReasoningRequest, Tool } from '@tanstack/ai' +import type { + ChatMiddleware, + DefinedAgent, + ReasoningRequest, + Tool, +} from '@tanstack/ai' import { createTextAdapter } from '@/lib/providers' import { deleteLogs, @@ -35,6 +41,9 @@ import type { SubagentScenario } from '@/lib/subagents-test' * - `result`: no router. `pricer` calls the bound `ctx.chat` with * `stream: false`, so its `run` resolves to a string. The string streams as * the child's text and goes back to the parent model as the result. + * - `session`: no router, one `subagent` tool. The parent model starts + * `researcher`, then a second call continues it by its `sessionId`. + * `withPersistence` stores the child, so that call loads its transcript. */ function subagentsFor( scenario: SubagentScenario, @@ -117,9 +126,48 @@ function subagentsFor( const agents: Array = [pricer] return { agents } } + if (scenario === 'session') { + return { agents: [child('researcher', [])], tool: 'single' as const } + } return { agents: [child('researcher', [])] } } +/** + * aimock replies are fixed, so the second `subagent` call in + * `fixtures/subagents/single-tool.json` cannot hold the random id of the + * first child. It sends `sessionId: 'first-child'`. This middleware does what + * a real model does: it copies the `subagentRunId` of the first result. + */ +function fillSessionId(): ChatMiddleware { + let first: string | undefined + return { + name: 'fill-session-id', + onAfterToolCall(_ctx, { result }) { + if ( + first === undefined && + typeof result === 'object' && + result !== null && + 'subagentRunId' in result && + typeof result.subagentRunId === 'string' + ) { + first = result.subagentRunId + } + }, + onBeforeToolCall(_ctx, { args }) { + if ( + first === undefined || + typeof args !== 'object' || + args === null || + !('sessionId' in args) || + args.sessionId !== 'first-child' + ) { + return undefined + } + return { type: 'transformArgs', args: { ...args, sessionId: first } } + }, + } +} + export const Route = createFileRoute('/api/subagents-test')({ server: { handlers: { @@ -151,6 +199,15 @@ export const Route = createFileRoute('/api/subagents-test')({ ...(params.parentRunId ? { parentRunId: params.parentRunId } : {}), ...(params.resume ? { resume: params.resume } : {}), subagents: subagentsFor(scenario, aimockPort, testId), + // One run holds both calls, so a store per request is enough. + ...(scenario === 'session' + ? { + middleware: [ + withPersistence(memoryPersistence()), + fillSessionId(), + ], + } + : {}), agentLoopStrategy: maxIterations(4), abortController, }) diff --git a/testing/e2e/tests/subagents.spec.ts b/testing/e2e/tests/subagents.spec.ts index 6e08f62473..103dfef10a 100644 --- a/testing/e2e/tests/subagents.spec.ts +++ b/testing/e2e/tests/subagents.spec.ts @@ -14,7 +14,14 @@ async function run(page: Page) { function open( page: Page, - scenario: 'route' | 'approval' | 'tool' | 'brief' | 'result' | 'limits', + scenario: + | 'route' + | 'approval' + | 'tool' + | 'brief' + | 'result' + | 'limits' + | 'session', testId: string, aimockPort: number, ) { @@ -209,4 +216,30 @@ test.describe('subagents', () => { ), ).toBe(true) }) + + test('the single subagent tool continues a child by its sessionId', async ({ + page, + testId, + aimockPort, + }) => { + await open(page, 'session', testId, aimockPort) + await run(page) + + // The parent model runs a third time only after the second call gave it + // the child's result. + await expect(page.getByTestId('parent-text')).toHaveText( + 'Both notes are ready.', + ) + await expect(page.getByTestId('card-status-researcher')).toHaveText( + 'finished', + ) + const card = page.getByTestId('card-researcher') + await expect(card).toContainText('text:Squids have three hearts.') + // The second call has no prompt, so the child's last user message is the + // task it stored from the first call. Only that fixture gives this reply. + // A child that did not get its earlier messages would get no reply. + await expect(card).toContainText('text:Squids also have blue blood.') + // One card: the second call continued the same child. + await expect(card).toHaveCount(1) + }) }) From aa2b97cfbdeeaa868439e37826ce5c4fb3eddf0c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 13 Oct 2026 19:25:18 +0200 Subject: [PATCH 2/2] feat(ai, ai-persistence)!: every agent tool takes input fields, drop tool: 'single' Each per-agent tool now takes { input } (agent with inputSchema) or { prompt } (agent without), plus sessionId when a middleware provides LoadChildCapability and background when the host sets binding.start. This replaces the separate `subagents: { tool: 'single' }` mode, so continue and background work on the normal agent tools. - The agent schema sits under `input`. Its refs are rewritten and its definitions move to the top, so recursive schemas still resolve. - A result carries subagentRunId only when the tool takes sessionId. - ai-persistence reads prompt/input from any agent tool call. - Breaking: the tool-call shape changes. New migration page docs/migration/subagent-tool-input.md. --- .changeset/single-subagent-tool.md | 8 - .changeset/subagent-tool-input.md | 10 + docs/chat/subagents.md | 69 ++-- docs/config.json | 7 +- docs/migration/subagent-tool-input.md | 109 +++++++ .../src/routes/subagent-brief.tsx | 11 +- packages/ai-persistence/src/middleware.ts | 2 +- packages/ai-persistence/src/subagent-runs.ts | 30 +- .../tests/subagent-persistence-cards.test.ts | 26 +- .../ai/src/activities/chat/agents/bound.ts | 2 +- .../activities/chat/agents/define-agent.ts | 4 +- .../ai/src/activities/chat/agents/spawn.ts | 308 ++++++++---------- packages/ai/src/activities/chat/index.ts | 22 +- .../activities/chat/middleware/load-child.ts | 5 +- packages/ai/src/index.ts | 3 +- .../ai/tests/define-agent-results.test.ts | 2 +- packages/ai/tests/define-agent.test.ts | 32 +- packages/ai/tests/subagent-interrupts.test.ts | 4 +- ...test.ts => subagent-tool-envelope.test.ts} | 247 +++++++++----- testing/e2e/fixtures/subagents/basic.json | 8 +- .../{single-tool.json => session.json} | 8 +- testing/e2e/src/routes/api.subagents-test.ts | 11 +- testing/e2e/tests/subagents.spec.ts | 2 +- 23 files changed, 535 insertions(+), 395 deletions(-) delete mode 100644 .changeset/single-subagent-tool.md create mode 100644 .changeset/subagent-tool-input.md create mode 100644 docs/migration/subagent-tool-input.md rename packages/ai/tests/{subagent-single-tool.test.ts => subagent-tool-envelope.test.ts} (60%) rename testing/e2e/fixtures/subagents/{single-tool.json => session.json} (80%) diff --git a/.changeset/single-subagent-tool.md b/.changeset/single-subagent-tool.md deleted file mode 100644 index a595491ac9..0000000000 --- a/.changeset/single-subagent-tool.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@tanstack/ai': minor -'@tanstack/ai-persistence': minor ---- - -One subagent tool. With `subagents: { tool: 'single' }`, the model picks an agent by name in one `subagent` tool (`SubagentToolInput`). An agent with an `inputSchema` takes `input`, and an agent without one takes an optional `prompt`. A `sessionId` continues an earlier child of the same agent, and `background` starts the child through a host's `subagents.binding.start`. The tool also supports the `detach` field of the tool context. - -`@tanstack/ai` adds `LoadChildCapability`, `getLoadChild`, and `provideLoadChild`, so a persistence middleware can load a stored child. `withPersistence` in `@tanstack/ai-persistence` provides it: it stores the task of each child's first call and the thread that started it, and loads only the children of the calling thread. A tool result carries `subagentRunId` when the model can continue the child. diff --git a/.changeset/subagent-tool-input.md b/.changeset/subagent-tool-input.md new file mode 100644 index 0000000000..304eeeb5ec --- /dev/null +++ b/.changeset/subagent-tool-input.md @@ -0,0 +1,10 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-persistence': minor +--- + +**Breaking:** each agent tool takes its arguments in fields. The input of an agent with `inputSchema` moves under `input`: the model sends `{ "input": { "task": "..." } }`, not `{ "task": "..." }`. `ctx.input` in `run` does not change. An agent without `inputSchema` takes the task as text in `prompt`, and the child gets it as the last user message. To update a UI that reads the brief from the tool call, middleware that reads agent tool args, saved threads with a waiting subagent call, and recorded tool calls, see the [subagent tool input migration](https://tanstack.com/ai/latest/docs/migration/subagent-tool-input). + +Continue a child. With `withPersistence` on the parent `chat()`, a tool result gives the model the child's `subagentRunId`, and a later call with `sessionId` continues that child from its stored transcript. With a host that sets `subagents.binding.start`, `background: true` starts the child and returns at once. Each agent tool shows `sessionId` and `background` only when the run can use them, and supports the `detach` field of the tool context. + +`@tanstack/ai` adds `LoadChildCapability`, `getLoadChild`, and `provideLoadChild`, so a persistence middleware can load a stored child. `withPersistence` in `@tanstack/ai-persistence` provides it: it stores the task of each child's first call and the thread that started it, and loads only the children of the calling thread. diff --git a/docs/chat/subagents.md b/docs/chat/subagents.md index 3efb0afe6c..b58d9120d4 100644 --- a/docs/chat/subagents.md +++ b/docs/chat/subagents.md @@ -154,13 +154,13 @@ const stream = chat({ }) ``` -**Without a router.** The library adds one synthetic server tool per agent. The main model calls that tool. The public stream still emits `SUBAGENT_STARTED` / `SUBAGENT_FINISHED` (or `SUBAGENT_ERROR`) and nested parts. The UI does not treat spawn as a normal tool card. The child's events stream while the tool runs, and the child's text becomes the tool result. The child reads the conversation as it is at that tool call. For one tool that can start every agent, see [Give the model one subagent tool](#give-the-model-one-subagent-tool). +**Without a router.** The library adds one synthetic server tool per agent. The main model calls that tool. The public stream still emits `SUBAGENT_STARTED` / `SUBAGENT_FINISHED` (or `SUBAGENT_ERROR`) and nested parts. The UI does not treat spawn as a normal tool card. The child's events stream while the tool runs, and the child's text becomes the tool result. The child reads the conversation as it is at that tool call. The model can also pass the task as `prompt`. The child gets it as the last user message. For all fields of the tool input, see [The tool input](#the-tool-input). ## Let the model write the brief In a long thread, the child gets the full conversation at the tool call, with every earlier tool result. Each parallel child gets its own copy. The child must also find its task in that conversation. -Add `inputSchema` to the agent. The main model then writes the child's input when it calls the tool. `run` reads that input as `ctx.input`: +Add `inputSchema` to the agent. The main model then writes the child's input in the `input` field of the tool call, for example `{ "input": { "task": "Price Vendor A" } }`. `run` reads that input as `ctx.input`: ```ts import { chat, defineAgent } from '@tanstack/ai' @@ -206,9 +206,9 @@ const stream = chat({ To show the brief in the UI, see [Show the brief on a card](#show-the-brief-on-a-card). -## Give the model one subagent tool +## Continue a child -Without a router, the model gets one tool per agent, and each call starts a new child. The model cannot send a follow-up to a child that already answered. Set `tool: 'single'`. The model then gets one tool, `subagent`, and names the agent in each call. A call can also continue a child from an earlier turn. +Each tool call starts a new child. Sometimes the model must send a follow-up to a child that already answered, like "make it shorter". Add `withPersistence` to the parent `chat()`. Each result then gives the model the `subagentRunId` of the child, and the model continues that child with `sessionId`. ```ts import { @@ -219,18 +219,6 @@ import { } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence' -import { z } from 'zod' - -const researcher = defineAgent({ - name: 'researcher', - description: 'Researches one question and returns sourced findings', - inputSchema: z.object({ question: z.string() }), - run: (ctx) => - ctx.chat({ - adapter: openaiText('gpt-6.1-sol'), - messages: [{ role: 'user', content: ctx.input.question }], - }), -}) const writer = defineAgent({ name: 'writer', @@ -249,48 +237,38 @@ export async function POST(request: Request) { threadId: params.threadId, runId: params.runId, middleware: [withPersistence(persistence)], - subagents: { agents: [researcher, writer], tool: 'single' }, + subagents: { agents: [writer] }, }) return toServerSentEventsResponse(stream) } ``` -The model fills these fields of the tool input: - -- `agent`: the agent name. Each call needs it. -- `input`: the input of an agent with `inputSchema`. That agent needs it. -- `prompt`: the task as text, for an agent without `inputSchema`. The child gets it as the last user message. -- `sessionId`: the `subagentRunId` of an earlier result. The call continues that child. -- `background`: `true` starts the child and returns at once. Only a host with `subagents.binding.start` can do this. - -On the client, each child is a `type: 'subagent'` part, the same as with one tool per agent. `part.subagent.name` is the agent name. +The model continues the writer like this: -### Continue a child +1. The model calls `writer` with `{ "prompt": "Draft a post about tides" }`. +2. It gets `{ "subagentRunId": "subagent-1767225600000-x7k2m9q", "result": "Tides rise twice a day..." }`. +3. In a later turn, it calls `writer` with `{ "sessionId": "subagent-1767225600000-x7k2m9q", "prompt": "Make it shorter" }`. -With `withPersistence` on the parent `chat()`, each result gives the model the `subagentRunId` of the child. The model uses it in a later turn: +The writer gets the task of its first call, its stored transcript, then `Make it shorter` as the last user message. The stream uses the same `subagentRunId`, so the client shows the new work on the same card. -1. The model calls `{ "agent": "writer", "prompt": "Draft a post about tides" }`. -2. It gets `{ "subagentRunId": "subagent-1767225600000-x7k2m9q", "result": "Tides rise twice a day..." }`. -3. In the next turn, it calls `{ "agent": "writer", "sessionId": "subagent-1767225600000-x7k2m9q", "prompt": "Make it shorter" }`. +### The tool input -The writer gets the task of its first call, its stored transcript, then `Make it shorter` as the last user message. +Every agent tool takes the same fields: -- `sessionId` needs `withPersistence` on the parent `chat()`. The stored child comes from that store. -- A thread can continue only its own children. An id from another thread gives the tool error `Unknown sessionId "..."`. -- The call must name the agent that the child ran under. Another agent gives the tool error `Session "..." belongs to agent "writer", not "researcher".` -- The stream uses the same `subagentRunId`, so the client shows the new work on the card with that id. +- `input`: the input of an agent with `inputSchema`. That agent needs it. +- `prompt`: the task as text, for an agent without `inputSchema`. The child gets it as the last user message. +- `sessionId`: the `subagentRunId` of an earlier result. The call continues that child. The tool has this field only when `withPersistence` is on the parent `chat()`. +- `background`: `true` starts the child and returns its `subagentRunId` at once. The tool has this field only when the host sets `subagents.binding.start`. ### Wrong calls -A wrong call returns to the model as a tool error, and the run continues. For example: +A wrong call goes back to the model as a tool error, and the run continues. For example: - `Agent "researcher" takes input, not prompt.` -- `Agent "researcher" needs input.` -- `sessionId needs a persistence store.` +- `Unknown sessionId "..."`: no child of this thread has that id. A thread can continue only its own children. +- `Session "..." belongs to agent "writer", not "researcher".`: the call must use the tool of the agent that ran the child. - `background needs a host that runs children in the background.` -If an agent or a tool already has the name `subagent`, `chat()` throws before the run starts. - ## Return a value, or call any activity A child does not have to be a chat. It can make an image, or return a plain value that the parent model reads as the tool result. Call the activity on `ctx` and return what it gives you: @@ -759,7 +737,7 @@ When `run` imports server code, do not import the agent into the browser. Declar ### Show the brief on a card -When an agent has `inputSchema`, the brief is the input of the tool call that started the child. That `tool-call` part is on the same assistant message as the card. `part.subagent.parentToolCallId` is its id: +The brief is in the tool call that started the child: the `input` field for an agent with `inputSchema`, else the `prompt` field. That `tool-call` part is on the same assistant message as the card. `part.subagent.parentToolCallId` is its id: ```tsx import type { UIMessage } from '@tanstack/ai-react' @@ -767,7 +745,10 @@ import type { UIMessage } from '@tanstack/ai-react' function briefOf(message: UIMessage, toolCallId: string) { for (const part of message.parts) { if (part.type !== 'tool-call' || part.id !== toolCallId) continue - const input = part.input + const call = part.input + if (typeof call !== 'object' || call === null) continue + if ('prompt' in call && typeof call.prompt === 'string') return call.prompt + const input = 'input' in call ? call.input : undefined if ( typeof input === 'object' && input !== null && @@ -792,8 +773,6 @@ function Briefs({ message }: { message: UIMessage }) { A child that a router started has no `parentToolCallId`, so it has no brief. -With `tool: 'single'`, that tool call is a call of the `subagent` tool. The brief is in the `input` field or the `prompt` field of its input. - ### Style one child's parts The researcher's reasoning and tool calls use the root widgets by default. To make them look different on the researcher card only, pass widgets to that card's `Parts`: diff --git a/docs/config.json b/docs/config.json index 57f3c1b723..5a44d5ba1c 100644 --- a/docs/config.json +++ b/docs/config.json @@ -160,7 +160,7 @@ "label": "Subagents", "to": "chat/subagents", "addedAt": "2026-09-21", - "updatedAt": "2026-10-12" + "updatedAt": "2026-10-13" }, { "label": "Agentic Cycle", @@ -1458,6 +1458,11 @@ "to": "migration/reasoning-option", "addedAt": "2026-10-09" }, + { + "label": "Subagent tool input", + "to": "migration/subagent-tool-input", + "addedAt": "2026-10-13" + }, { "label": "TextPart markdown", "to": "migration/text-part-markdown", diff --git a/docs/migration/subagent-tool-input.md b/docs/migration/subagent-tool-input.md new file mode 100644 index 0000000000..460e3f3409 --- /dev/null +++ b/docs/migration/subagent-tool-input.md @@ -0,0 +1,109 @@ +--- +title: Subagent tool input +description: "Agent tools take their arguments in fields: the agent input moves under input. Update the brief on the card, saved threads, and recorded tool calls." +keywords: + - tanstack ai + - migration + - subagents + - defineAgent + - inputSchema +--- + +After the upgrade, a subagent card shows no brief, a middleware reads `undefined` from agent tool args, or a recorded tool call fails with `Agent "researcher" needs input.` The arguments of an agent tool moved into fields. The agent input is now in the `input` field of the tool call. + +Your agent code does not change. `run` still reads `ctx.input` as `{ task: string }`. + +## What the model sends + +Before: + +```json +{ "task": "Price Vendor A" } +``` + +After: + +```json +{ "input": { "task": "Price Vendor A" } } +``` + +An agent without `inputSchema` takes the task as text in `prompt`, for example `{ "prompt": "Draft a post about tides" }`. The other fields are `sessionId` and `background`. See [The tool input](../chat/subagents#the-tool-input). + +## Read the brief from the new field + +Your UI reads the brief from the `tool-call` part that started the child. Read the `input` field of that part first: + +```tsx +import type { UIMessage } from '@tanstack/ai-react' + +function briefOf(message: UIMessage, toolCallId: string) { + for (const part of message.parts) { + if (part.type !== 'tool-call' || part.id !== toolCallId) continue + const call = part.input + // The agent input is in the `input` field of the call. + const input = + typeof call === 'object' && call !== null && 'input' in call + ? call.input + : undefined + if ( + typeof input === 'object' && + input !== null && + 'task' in input && + typeof input.task === 'string' + ) { + return input.task + } + } + return undefined +} +``` + +## Update middleware that reads agent tool args + +Middleware gets the new arguments for an agent tool: + +- `onBeforeToolCall`: in `args`. Read the agent input from `args.input`. +- `onAfterToolCall`: in the JSON string `toolCall.function.arguments`. +- The approval `editedArgs`: the same fields. + +For example: + +```typescript +import { type ChatMiddleware } from '@tanstack/ai' + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null +} + +const logBriefs: ChatMiddleware = { + name: 'log-briefs', + onBeforeToolCall: (ctx, hookCtx) => { + if (hookCtx.toolName !== 'researcher' || !isRecord(hookCtx.args)) return + const input = hookCtx.args.input + if (isRecord(input)) console.log('brief:', input.task) + }, +} +``` + +A `transformArgs` decision for an agent tool also returns the new fields, for example `{ input: { task } }`. + +The agent tool checks `input` against `inputSchema` when the tool runs, after these hooks. A hook gets the input as the model wrote it, without schema defaults or transforms. + +## Finish open subagent calls before you upgrade + +A saved thread can have a subagent call that waits for an approval. That call keeps its old arguments. After the upgrade, the resume gives the tool error `Agent "researcher" needs input.` + +1. Before you deploy, let users approve or deny the waiting subagent calls. +2. Deploy the upgrade. + +A thread with no waiting subagent call needs no change. + +## Update recorded tool calls + +Test fixtures and mocks that call an agent tool need the new arguments: + +1. Find each recorded tool call whose name is an agent name. +2. Move its arguments under `input`: `{"task":"..."}` becomes `{"input":{"task":"..."}}`. +3. For an agent without `inputSchema`, keep `{}`, or send the task as `{"prompt":"..."}`. + +Run your tests again. The child gets the same `ctx.input` as before, and the card shows the brief again. diff --git a/examples/ts-react-chat/src/routes/subagent-brief.tsx b/examples/ts-react-chat/src/routes/subagent-brief.tsx index 704e83a75b..2021a72b40 100644 --- a/examples/ts-react-chat/src/routes/subagent-brief.tsx +++ b/examples/ts-react-chat/src/routes/subagent-brief.tsx @@ -7,11 +7,18 @@ export const Route = createFileRoute('/subagent-brief')({ component: SubagentBriefPage, }) -/** The `task` the parent model wrote on the tool call that started a child. */ +/** + * The `task` the parent model wrote on the tool call that started a child. + * The agent's input is the `input` field of the call. + */ function briefOf(message: UIMessage, toolCallId: string) { for (const part of message.parts) { if (part.type !== 'tool-call' || part.id !== toolCallId) continue - const input = part.input + const call = part.input + const input = + typeof call === 'object' && call !== null && 'input' in call + ? call.input + : undefined if ( typeof input === 'object' && input !== null && diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index 1e7df7cfb0..d71982be7b 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -2153,7 +2153,7 @@ export function withPersistence( if (persistence.stores.metadata) { provideMetadata(ctx, persistence.stores.metadata) } - // The `subagent` tool reads stored children through this, to continue + // The agent tools read stored children through this, to continue // one by `sessionId`. Only the children of this chat can load. provideLoadChild(ctx, (subagentRunId) => subagentRuns.loadChild(subagentRunId, { diff --git a/packages/ai-persistence/src/subagent-runs.ts b/packages/ai-persistence/src/subagent-runs.ts index 1d9c499dae..074bc9803e 100644 --- a/packages/ai-persistence/src/subagent-runs.ts +++ b/packages/ai-persistence/src/subagent-runs.ts @@ -70,12 +70,8 @@ function ownerThread(caller: { threadId: string; subagentRunId?: string }) { : childStoreId(caller.subagentRunId) } -// ponytail: only the `subagent` tool has `prompt` and `input` fields. Core does -// not export its name. -const SINGLE_TOOL = 'subagent' - -/** One field of the JSON arguments of a `subagent` tool call. */ -function singleToolField(call: { args: string }, key: 'prompt' | 'input') { +/** One field of the JSON arguments of an agent tool call. */ +function callField(call: { args: string }, key: 'prompt' | 'input') { let args: unknown try { args = JSON.parse(call.args) @@ -88,23 +84,23 @@ function singleToolField(call: { args: string }, key: 'prompt' | 'input') { } /** - * The task of a child that a tool call started: the `prompt` of the `subagent` - * tool, else its `input` as JSON. A `subagent` call with neither has no task. - * Other tools pass their whole arguments. + * The task of a child that a tool call started: the `prompt` of the agent + * tool, else its `input` as JSON. A call with neither has no task. */ -function callPrompt(call: { name: string; args: string }) { +// ponytail: a non-agent tool that starts a child with its own call id stores +// no task. Read its whole arguments here if a host needs that. +function callPrompt(call: { args: string }) { if (call.args === '') return undefined - if (call.name !== SINGLE_TOOL) return call.args - const prompt = singleToolField(call, 'prompt') + const prompt = callField(call, 'prompt') if (typeof prompt === 'string') return prompt - const input = singleToolField(call, 'input') + const input = callField(call, 'input') return input === undefined ? undefined : JSON.stringify(input) } -/** The `prompt` of a `subagent` call that continues a child, if any. */ -function followUpPrompt(call: { name: string; args: string } | undefined) { - if (call?.name !== SINGLE_TOOL) return undefined - const prompt = singleToolField(call, 'prompt') +/** The `prompt` of an agent tool call that continues a child, if any. */ +function followUpPrompt(call: { args: string } | undefined) { + if (call === undefined) return undefined + const prompt = callField(call, 'prompt') return typeof prompt === 'string' ? prompt : undefined } diff --git a/packages/ai-persistence/tests/subagent-persistence-cards.test.ts b/packages/ai-persistence/tests/subagent-persistence-cards.test.ts index 80f045dee7..90051eb85d 100644 --- a/packages/ai-persistence/tests/subagent-persistence-cards.test.ts +++ b/packages/ai-persistence/tests/subagent-persistence-cards.test.ts @@ -218,14 +218,14 @@ function noteTaker() { return { agent, seen } } -/** A parent model that calls the `subagent` tool once, then stops. */ +/** A parent model that calls the `writer` agent tool once, then stops. */ function callingSubagent(toolCallId: string, args: object) { return scriptedAdapter([ [ { type: EventType.TOOL_CALL_START, toolCallId, - toolCallName: 'subagent', + toolCallName: 'writer', timestamp: t, }, { @@ -254,7 +254,7 @@ function callingSubagent(toolCallId: string, args: object) { ]) } -/** One parent turn with the single `subagent` tool. The thread is `desk`. */ +/** One parent turn that calls the `writer` tool. The thread is `desk`. */ function subagentTurn( middleware: ChatMiddleware, agent: DefinedAgent, @@ -267,7 +267,7 @@ function subagentTurn( runId: turn.runId, messages: [{ id: `u-${turn.runId}`, role: 'user', content: 'Notes' }], middleware: [middleware], - subagents: { agents: [agent], tool: 'single' }, + subagents: { agents: [agent] }, }), ) } @@ -729,13 +729,13 @@ describe('subagent run recorder', () => { { type: EventType.TOOL_CALL_START, toolCallId: 'call_1', - toolCallName: 'subagent', + toolCallName: 'writer', timestamp: t, }, { type: EventType.TOOL_CALL_ARGS, toolCallId: 'call_1', - delta: '{"agent":"writer","input":{"topic":"tides"}}', + delta: '{"input":{"topic":"tides"}}', timestamp: t, }, childStarted('child', { parentToolCallId: 'call_1' }), @@ -747,7 +747,7 @@ describe('subagent run recorder', () => { { role: 'user', content: '{"topic":"tides"}' }, { role: 'assistant', content: 'Tide notes' }, ]) - // The `subagent` tool lets only this agent continue the child. + // Only this agent can continue the child. expect(loaded?.agent).toBe('writer') }) @@ -761,14 +761,14 @@ describe('subagent run recorder', () => { }) }) -describe('single subagent tool with persistence', () => { +describe('agent tools with persistence', () => { it('continues a stored child by sessionId', async () => { const middleware = withPersistence(memoryPersistence()) const { agent, seen } = noteTaker() const first = await subagentTurn(middleware, agent, { runId: 'run-1', toolCallId: 'call_1', - args: { agent: 'writer', prompt: 'Take notes' }, + args: { prompt: 'Take notes' }, }) expect(toolResult(first, 'call_1')).toMatchObject({ subagentRunId: startedId(first), @@ -776,7 +776,7 @@ describe('single subagent tool with persistence', () => { const second = await subagentTurn(middleware, agent, { runId: 'run-2', toolCallId: 'call_2', - args: { agent: 'writer', sessionId: startedId(first), prompt: 'More' }, + args: { sessionId: startedId(first), prompt: 'More' }, }) expect(startedId(second)).toBe(startedId(first)) @@ -792,7 +792,7 @@ describe('single subagent tool with persistence', () => { await subagentTurn(middleware, agent, { runId: 'run-3', toolCallId: 'call_3', - args: { agent: 'writer', sessionId: startedId(first), prompt: 'Again' }, + args: { sessionId: startedId(first), prompt: 'Again' }, }) expect(seen[2]).toMatchObject([ { role: 'user', content: 'Take notes' }, @@ -809,14 +809,14 @@ describe('single subagent tool with persistence', () => { const first = await subagentTurn(middleware, agent, { runId: 'run-1', toolCallId: 'call_1', - args: { agent: 'writer', prompt: 'Take notes' }, + args: { prompt: 'Take notes' }, }) const id = startedId(first) const other = await subagentTurn(middleware, agent, { threadId: 'other', runId: 'run-2', toolCallId: 'call_2', - args: { agent: 'writer', sessionId: id, prompt: 'Show me' }, + args: { sessionId: id, prompt: 'Show me' }, }) expect(toolResult(other, 'call_2')).toEqual({ diff --git a/packages/ai/src/activities/chat/agents/bound.ts b/packages/ai/src/activities/chat/agents/bound.ts index 1bbd723983..03898d0124 100644 --- a/packages/ai/src/activities/chat/agents/bound.ts +++ b/packages/ai/src/activities/chat/agents/bound.ts @@ -137,7 +137,7 @@ export interface SubagentBinding { promptCache?: PromptCacheRetention /** * Starts a child without waiting for it and returns its id at once. The - * single `subagent` tool calls it for `background: true`. With + * agent tools call it for `background: true`. With * `wake: true`, the host starts a new parent turn when the child ends. It * does not pass down to nested children. */ diff --git a/packages/ai/src/activities/chat/agents/define-agent.ts b/packages/ai/src/activities/chat/agents/define-agent.ts index 17dfdb1769..b69dcb2f80 100644 --- a/packages/ai/src/activities/chat/agents/define-agent.ts +++ b/packages/ai/src/activities/chat/agents/define-agent.ts @@ -132,7 +132,9 @@ export interface DefinedAgent< produces?: TProduces /** * The input the parent model writes when it calls this agent's tool, such - * as a short brief. `run` reads it as `ctx.input`. Tool mode only: a + * as a short brief. It is the `input` field of the tool call, and `run` + * reads it as `ctx.input`. Without it, the tool takes a text `prompt` + * instead. Tool mode only: a * `subagents.router` cannot start an agent that has `inputSchema`. */ inputSchema?: TInput diff --git a/packages/ai/src/activities/chat/agents/spawn.ts b/packages/ai/src/activities/chat/agents/spawn.ts index 62d663bf4f..d4f1bda820 100644 --- a/packages/ai/src/activities/chat/agents/spawn.ts +++ b/packages/ai/src/activities/chat/agents/spawn.ts @@ -21,7 +21,6 @@ import type { LoadChild } from '../middleware/load-child' import type { SubagentToolOutcome } from '../tools/tool-calls' import type { SpecTokenUsage } from '../../../utilities/ag-ui-usage' import type { - InferSchemaType, Interrupt, JSONSchema, ModelMessage, @@ -106,50 +105,20 @@ export interface SubagentsBag< * apply: a nested chat in a child uses the root's budget. */ limits?: SubagentLimits - /** - * The tools the parent model gets. `per-agent` (the default) gives one tool - * per agent, with the agent's name. `single` gives one tool named - * `subagent`. It takes the agent name, plus `sessionId` to continue a - * stored child and `background` to start a child without waiting. - */ - tool?: 'per-agent' | 'single' -} - -/** The name of the one tool in `subagents: { tool: 'single' }`. */ -export const SINGLE_SUBAGENT_TOOL = 'subagent' - -/** Fields every call of the single `subagent` tool can add. */ -interface SubagentCallOptions { - /** The `subagentRunId` of a stored, finished child. The call continues it. */ - sessionId?: string - /** Start the child and return at once. Needs a host with `binding.start`. */ - background?: boolean } -/** One agent's call: `input` for an agent with `inputSchema`, else `prompt`. */ -type SubagentCallFor = TAgent extends DefinedAgent - ? ([NonNullable] extends [never] - ? { agent: TAgent['name']; prompt?: string } - : { - agent: TAgent['name'] - input: InferSchemaType> - }) & - SubagentCallOptions - : never - /** - * The input of the single `subagent` tool (`subagents: { tool: 'single' }`). - * A union on `agent`: an agent with an `inputSchema` needs `input`, and an - * agent without one takes an optional `prompt`. + * The input of an agent tool. `input` is for an agent with `inputSchema`, + * `prompt` for an agent without one. `sessionId` continues a stored child, + * and `background` starts the child without waiting. */ -export type SubagentToolInput> = - SubagentCallFor - -/** A call of the single `subagent` tool, after the wire schema check. */ -interface SubagentCall extends SubagentCallOptions { - agent: string +interface SubagentCall { input?: unknown prompt?: string + /** The `subagentRunId` of a stored, finished child. The call continues it. */ + sessionId?: string + /** Start the child and return at once. Needs a host with `binding.start`. */ + background?: boolean } /** What the children of one parent run left behind for the parent terminal. */ @@ -957,54 +926,69 @@ export function subagentCallMessages(names: ReadonlySet) { } } -/** The JSON Schema of each agent `input`, by agent name. */ -function agentInputSchemas(agents: ReadonlyArray) { - const schemas = new Map() - for (const agent of agents) { - const schema = convertSchemaToJsonSchema(agent.inputSchema, { io: 'input' }) - if (schema) schemas.set(agent.name, schema) - } - return schemas -} - /** - * The description and the wire schema of the single `subagent` tool. - * Providers need one object at the top, so `input` is an `anyOf` of the agent - * schemas. `execute` checks each field against the picked agent. + * The wire schema of an agent tool. `input` holds the agent's `inputSchema`, + * and an agent without one takes `prompt`. `sessionId` and `background` are + * there only when the run can use them. */ -function singleToolShape(agents: ReadonlyArray) { - const inputs = agentInputSchemas(agents) - const takesPrompt = agents.some((agent) => !inputs.has(agent.name)) - const lines = agents.map((agent) => { - const schema = inputs.get(agent.name) - const how = schema - ? `Pass \`input\`: ${JSON.stringify(schema)}` - : 'Pass `prompt`: the task as text.' - return `- ${agent.name}: ${agent.description} ${how}` - }) - const inputSchema: JSONSchema = { +function agentToolSchema( + agent: DefinedAgent, + can: { sessionId: boolean; background: boolean }, +): JSONSchema { + const schema = convertSchemaToJsonSchema(agent.inputSchema, { io: 'input' }) + const nested = schema && (nestRefs(schema) as JSONSchema) + // A ref resolves from the top of the tool schema, so the definitions move up. + const { $defs, definitions, ...input } = nested ?? {} + return { type: 'object', properties: { - agent: { type: 'string', enum: agents.map((agent) => agent.name) }, - ...(inputs.size > 0 && { input: { anyOf: [...inputs.values()] } }), - ...(takesPrompt && { prompt: { type: 'string' } }), - sessionId: { type: 'string' }, - background: { type: 'boolean' }, + ...(nested ? { input } : { prompt: { type: 'string' } }), + ...(can.sessionId && { + sessionId: { + type: 'string', + description: + 'The subagentRunId of an earlier result. The call continues that child.', + }, + }), + ...(can.background && { + background: { + type: 'boolean', + description: + 'true starts the child and returns its subagentRunId at once.', + }, + }), }, - required: ['agent'], - } - return { - description: [ - 'Run a subagent. Set `agent` to one of these names:', - ...lines, - 'Set `sessionId` to the subagentRunId of an earlier result to continue that child.', - 'Set `background` to true to start the child and get its subagentRunId at once.', - ].join('\n'), - inputSchema, + required: nested ? ['input'] : [], + ...($defs !== undefined && { $defs }), + ...(definitions !== undefined && { definitions }), } } -/** The checked `input` of a single-tool call. A wrong field throws a tool error. */ +/** + * The refs of an agent schema point at its own top. In the tool schema it + * sits under `input`, so `#` and paths under it point at `input`. Refs into + * `$defs` and `definitions` stay, because those move to the top. + */ +function nestRefs(value: unknown): unknown { + if (Array.isArray(value)) return value.map(nestRefs) + if (value === null || typeof value !== 'object') return value + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + key === '$ref' && typeof entry === 'string' + ? nestedRef(entry) + : nestRefs(entry), + ]), + ) +} + +function nestedRef(ref: string) { + if (ref === '#') return '#/properties/input' + if (/^#\/(\$defs|definitions)\//.test(ref)) return ref + return ref.startsWith('#/') ? `#/properties/input${ref.slice(1)}` : ref +} + +/** The checked `input` of an agent tool call. A wrong field throws a tool error. */ async function callInput(agent: DefinedAgent, call: SubagentCall) { if (agent.inputSchema === undefined) { if (call.input !== undefined) { @@ -1024,9 +1008,7 @@ async function callInput(agent: DefinedAgent, call: SubagentCall) { ) if (!checked.success) { const issues = checked.issues.map((issue) => issue.message).join(', ') - throw new Error( - `Input validation failed for agent ${agent.name}: ${issues}`, - ) + throw new Error(`Input validation failed for tool ${agent.name}: ${issues}`) } return checked.data } @@ -1050,6 +1032,8 @@ export function createSyntheticSubagentTools( sink: SubagentSink /** The run's `loadChild` service. See subagentCallMessages. */ childLoader?: () => LoadChild | undefined + /** A middleware provides `loadChild`, so the tools take `sessionId`. */ + canContinue?: boolean }, ): Array { // One budget per root run. A child run inherits its parent's budget. @@ -1183,10 +1167,8 @@ export function createSyntheticSubagentTools( ...(result !== undefined ? { result } : {}), ...(error !== undefined ? { error } : {}), ...(sink.interrupts.length > 0 ? { interrupts: sink.interrupts } : {}), - // Only a stored child can be continued, and only by the single tool. - ...(bag.tool === 'single' && - subagentRunId !== '' && - parent.childLoader?.() + // Only a stored child can be continued, through a tool with `sessionId`. + ...(subagentRunId !== '' && parent.canContinue === true ? { keepRunId: true as const } : {}), } satisfies SubagentToolOutcome @@ -1209,96 +1191,78 @@ export function createSyntheticSubagentTools( return { subagentRunId: sessionId, messages: stored.messages } } - if (bag.tool === 'single') { - // A thrown error in `execute` goes back to the model as a tool error. - const single = { - name: SINGLE_SUBAGENT_TOOL, - ...singleToolShape(bag.agents), - [SUBAGENT_TOOL]: true, - execute: async (call: SubagentCall, context?: ToolExecutionContext) => { - const wrongType = - (call.prompt !== undefined && typeof call.prompt !== 'string') || - (call.sessionId !== undefined && - typeof call.sessionId !== 'string') || - (call.background !== undefined && - typeof call.background !== 'boolean') - if (wrongType) { - throw new Error( - 'prompt and sessionId must be strings, and background a boolean.', - ) - } - const agent = bag.agents.find((entry) => entry.name === call.agent) - if (!agent) throw new Error(`Unknown agent "${call.agent}".`) - const input = await callInput(agent, call) - const fields = { - ...(input !== undefined && { input }), - ...(call.prompt !== undefined && { prompt: call.prompt }), - } - const child = { name: agent.name, ...fields } - if (call.background === true) { - if (call.sessionId !== undefined) { - throw new Error('background cannot continue a sessionId.') - } - const start = bag.binding?.start - if (!start) { - throw new Error( - 'background needs a host that runs children in the background.', - ) - } - // A background child counts against the tree budget too. - const refusal = budget.reserve(active) - if (refusal !== undefined) throw new Error(refusal) - const { subagentRunId } = await start( - { - agent: agent.name, - ...fields, - ...(context?.toolCallId !== undefined && { - parentToolCallId: context.toolCallId, - }), - }, - { wake: true }, - ) - return { - subagentRunId, - text: '', - result: { status: 'started' }, - keepRunId: true, - } satisfies SubagentToolOutcome - } - const { sessionId } = call - const work = - sessionId === undefined - ? runChild(child, context) - : storedChild(sessionId, agent.name).then((continued) => - runChild({ ...child, continued }, context), - ) - // The host can move the child to the background. It keeps running. - const detach = context?.detach - if (!detach) return work - const moved = detach(work).then( - (text) => ({ subagentRunId: '', text }) satisfies SubagentToolOutcome, - ) - return Promise.race([work, moved]) - }, - } - return [single] + const can = { + sessionId: parent.canContinue === true, + background: bag.binding?.start !== undefined, } - + // A thrown error in `execute` goes back to the model as a tool error. return bag.agents.map((agent) => ({ name: agent.name, description: agent.description, - ...(agent.inputSchema !== undefined && { inputSchema: agent.inputSchema }), + inputSchema: agentToolSchema(agent, can), [SUBAGENT_TOOL]: true, - // The tool loop checks `input` against `inputSchema` before this runs. - // An agent without `inputSchema` still gets `{}` from the model. Its - // `ctx.input` stays undefined. - execute: (input: unknown, context?: unknown) => - runChild( - { - name: agent.name, - ...(agent.inputSchema !== undefined && { input }), - }, - context, - ), + execute: async (call: SubagentCall, context?: ToolExecutionContext) => { + const wrongType = + (call.prompt !== undefined && typeof call.prompt !== 'string') || + (call.sessionId !== undefined && typeof call.sessionId !== 'string') || + (call.background !== undefined && typeof call.background !== 'boolean') + if (wrongType) { + throw new Error( + 'prompt and sessionId must be strings, and background a boolean.', + ) + } + // An agent without `inputSchema` and without `prompt` leaves + // `ctx.input` undefined. The child reads the parent conversation. + const input = await callInput(agent, call) + const fields = { + ...(input !== undefined && { input }), + ...(call.prompt !== undefined && { prompt: call.prompt }), + } + const child = { name: agent.name, ...fields } + if (call.background === true) { + if (call.sessionId !== undefined) { + throw new Error('background cannot continue a sessionId.') + } + const start = bag.binding?.start + if (!start) { + throw new Error( + 'background needs a host that runs children in the background.', + ) + } + // A background child counts against the tree budget too. + const refusal = budget.reserve(active) + if (refusal !== undefined) throw new Error(refusal) + const { subagentRunId } = await start( + { + agent: agent.name, + ...fields, + ...(context?.toolCallId !== undefined && { + parentToolCallId: context.toolCallId, + }), + }, + { wake: true }, + ) + return { + subagentRunId, + text: '', + result: { status: 'started' }, + keepRunId: true, + } satisfies SubagentToolOutcome + } + const { sessionId } = call + const work = + sessionId === undefined + ? runChild(child, context) + : storedChild(sessionId, agent.name).then((continued) => + runChild({ ...child, continued }, context), + ) + // The host can move the child to the background. It keeps running. + const detach = context?.detach + if (!detach) return work + const moved = detach(work).then( + (text) => ({ subagentRunId: '', text }) satisfies SubagentToolOutcome, + ) + return Promise.race([work, moved]) + }, })) } diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 389ce82ea1..f909374b25 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -63,7 +63,6 @@ import { LazyToolManager } from './tools/lazy-tool-manager' import { assertUniqueToolNames } from './tools/unique-tool-names' import type { DefinedAgent } from './agents/define-agent' import { - SINGLE_SUBAGENT_TOOL, collectNamedText, createSubagentSink, createSyntheticSubagentTools, @@ -114,6 +113,7 @@ import { } from './messages' import { MiddlewareRunner } from './middleware/compose' import { getRunDetached } from './middleware/run-store' +import { LoadChildCapability } from './middleware/load-child' import { publishRunDetachedSignal } from '../../delivery-detach' import { publishRunDisconnectHandler } from '../../delivery-disconnect' import { provideSandboxRuntime } from './middleware/sandbox-runtime' @@ -5660,17 +5660,6 @@ export function chat< ) } - const singleToolNameTaken = - options.subagents?.tool === 'single' && - [...options.subagents.agents, ...(options.tools ?? [])].some( - (entry) => entry.name === SINGLE_SUBAGENT_TOOL, - ) - if (singleToolNameTaken) { - throw new Error( - `subagents.tool 'single' adds a tool named "${SINGLE_SUBAGENT_TOOL}". Rename the agent or tool that uses this name.`, - ) - } - if (outputSchema && stream === true) { return runStreamingStructuredOutput( toRuntimeTextActivityOptions(options, { @@ -5937,16 +5926,15 @@ async function* streamTextChunks( const turn = readSubagentTurn(messages, options.resume) const sink = createSubagentSink() const calls = subagentCallMessages( - new Set( - bag.tool === 'single' - ? [SINGLE_SUBAGENT_TOOL] - : bag.agents.map((agent: DefinedAgent) => agent.name), - ), + new Set(bag.agents.map((agent: DefinedAgent) => agent.name)), ) const synthetic = createSyntheticSubagentTools(bag, { messages: turn?.before ?? messages, messagesFor: calls.messagesFor, childLoader: calls.childLoader, + canContinue: (options.middleware ?? []).some((item) => + item.provides?.includes(LoadChildCapability), + ), threadId, runId, ...(options.parentRunId !== undefined && { diff --git a/packages/ai/src/activities/chat/middleware/load-child.ts b/packages/ai/src/activities/chat/middleware/load-child.ts index 7dd555f130..bd806011bf 100644 --- a/packages/ai/src/activities/chat/middleware/load-child.ts +++ b/packages/ai/src/activities/chat/middleware/load-child.ts @@ -4,9 +4,10 @@ import type { ModelMessage, UIMessage } from '../../../types' /** * Loads the stored transcript of a finished subagent run by its * `subagentRunId`. Returns `undefined` for an unknown id. A persistence - * middleware provides it. The single `subagent` tool reads it to continue a + * middleware provides it. The agent tools read it to continue a * child by `sessionId`. When `agent` is set, only that agent can continue - * the child. + * the child. A middleware that provides it must list `LoadChildCapability` + * in `provides`. Only then do the agent tools take `sessionId`. */ export type LoadChild = (subagentRunId: string) => Promise< | { diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index efce0cf0e1..73bea8621c 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -125,7 +125,6 @@ export type { SubagentStep, SubagentStepsPlan, SubagentsBag, - SubagentToolInput, } from './activities/chat/agents/spawn' // For hosts that run an agent outside a parent chat turn. export { @@ -386,7 +385,7 @@ export type { AnyChatMiddleware, MetadataStore, } from './activities/chat/middleware/index' -// A persistence store gives the single `subagent` tool the stored children. +// A persistence store gives the agent tools the stored children. export { LoadChildCapability, getLoadChild, diff --git a/packages/ai/tests/define-agent-results.test.ts b/packages/ai/tests/define-agent-results.test.ts index 5bf7cc577b..872952a7b7 100644 --- a/packages/ai/tests/define-agent-results.test.ts +++ b/packages/ai/tests/define-agent-results.test.ts @@ -344,7 +344,7 @@ describe('agent run context', () => { const { adapter: parent } = parentCalling( 'illustrator', - '{"prompt":"a cat"}', + '{"input":{"prompt":"a cat"}}', ) const chunks = await collectChunks( chat({ diff --git a/packages/ai/tests/define-agent.test.ts b/packages/ai/tests/define-agent.test.ts index ba676a0549..a8f763dc67 100644 --- a/packages/ai/tests/define-agent.test.ts +++ b/packages/ai/tests/define-agent.test.ts @@ -714,7 +714,10 @@ describe('chat({ subagents }) synthetic tools', () => { describe('defineAgent inputSchema', () => { const briefSchema = z.object({ task: z.string() }) - /** A parent model that calls `researcher` once per args string, then stops. */ + /** + * A parent model that calls `researcher` once per args string, then stops. + * The agent input goes under `input` in the tool call. + */ function parentCalling(...args: Array) { return createMockAdapter({ iterations: [ @@ -748,7 +751,9 @@ describe('defineAgent inputSchema', () => { it('shows the schema to the model and passes the input to run', async () => { const { agent, inputs } = briefResearcher() - const { adapter, calls } = parentCalling('{"task":"Price Vendor A"}') + const { adapter, calls } = parentCalling( + '{"input":{"task":"Price Vendor A"}}', + ) await collectChunks( chat({ @@ -761,7 +766,10 @@ describe('defineAgent inputSchema', () => { const tool = calls[0]?.tools?.find((entry) => entry.name === 'researcher') expect(tool?.inputSchema).toMatchObject({ type: 'object', - properties: { task: { type: 'string' } }, + properties: { + input: { type: 'object', properties: { task: { type: 'string' } } }, + }, + required: ['input'], }) expect(inputs).toEqual([{ task: 'Price Vendor A' }]) }) @@ -769,8 +777,8 @@ describe('defineAgent inputSchema', () => { it('gives each call to the same agent its own input', async () => { const { agent, inputs } = briefResearcher() const { adapter } = parentCalling( - '{"task":"Price Vendor A"}', - '{"task":"Price Vendor B"}', + '{"input":{"task":"Price Vendor A"}}', + '{"input":{"task":"Price Vendor B"}}', ) await collectChunks( @@ -792,7 +800,7 @@ describe('defineAgent inputSchema', () => { it('returns a bad input to the model and does not start the child', async () => { const { agent, inputs } = briefResearcher() - const { adapter } = parentCalling('{}') + const { adapter } = parentCalling('{"input":{}}') const chunks = await collectChunks( chat({ @@ -832,7 +840,9 @@ describe('defineAgent inputSchema', () => { inputs.push(ctx.input) }, }) - const { adapter, calls } = parentCalling('{"task":"Price Vendor A"}') + const { adapter, calls } = parentCalling( + '{"input":{"task":"Price Vendor A"}}', + ) await collectChunks( chat({ @@ -844,7 +854,7 @@ describe('defineAgent inputSchema', () => { const tool = calls[0]?.tools?.find((entry) => entry.name === 'researcher') expect(tool?.inputSchema).toMatchObject({ - properties: { task: { type: 'string' } }, + properties: { input: { properties: { task: { type: 'string' } } } }, }) expect(inputs).toEqual([{ task: 'Price Vendor A' }]) }) @@ -870,8 +880,10 @@ describe('defineAgent inputSchema', () => { ) const tool = calls[0]?.tools?.find((entry) => entry.name === 'researcher') - // The engine gives a tool with no schema an empty object schema. - expect(tool?.inputSchema?.properties ?? {}).toEqual({}) + // An agent without inputSchema takes only a prompt. + expect(tool?.inputSchema?.properties).toEqual({ + prompt: { type: 'string' }, + }) expect(inputs).toEqual([undefined]) }) diff --git a/packages/ai/tests/subagent-interrupts.test.ts b/packages/ai/tests/subagent-interrupts.test.ts index f7eb5408ee..1a0e39e76e 100644 --- a/packages/ai/tests/subagent-interrupts.test.ts +++ b/packages/ai/tests/subagent-interrupts.test.ts @@ -505,7 +505,7 @@ describe('subagent interrupts', () => { return run(ctx) }, }) - const parent = toolParent('{"folder":"old"}') + const parent = toolParent('{"input":{"folder":"old"}}') const subagents = { agents: [agent] } const first = await collectChunks( @@ -526,7 +526,7 @@ describe('subagent interrupts', () => { expect.objectContaining({ type: 'tool-call', id: 'call_p', - arguments: '{"folder":"old"}', + arguments: '{"input":{"folder":"old"}}', }), ) diff --git a/packages/ai/tests/subagent-single-tool.test.ts b/packages/ai/tests/subagent-tool-envelope.test.ts similarity index 60% rename from packages/ai/tests/subagent-single-tool.test.ts rename to packages/ai/tests/subagent-tool-envelope.test.ts index f46be2543b..6d89d801c4 100644 --- a/packages/ai/tests/subagent-single-tool.test.ts +++ b/packages/ai/tests/subagent-tool-envelope.test.ts @@ -1,20 +1,21 @@ -import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { defineAgent } from '../src/activities/chat/agents/define-agent' import { chat } from '../src/activities/chat' -import { provideLoadChild } from '../src/activities/chat/middleware/load-child' +import { + LoadChildCapability, + provideLoadChild, +} from '../src/activities/chat/middleware/load-child' import { createSubagentSink, createSyntheticSubagentTools, } from '../src/activities/chat/agents/spawn' import { EventType } from '../src/types' -import { collectChunks, createMockAdapter, ev, serverTool } from './test-utils' -import type { DefinedAgent } from '../src/activities/chat/agents/define-agent' -import type { SubagentToolInput } from '../src/activities/chat/agents/spawn' +import { collectChunks, createMockAdapter, ev } from './test-utils' import type { SubagentTurn } from '../src/activities/chat/agents/turn' import type { SubagentBinding } from '../src/activities/chat/agents/bound' import type { ChatMiddleware } from '../src/activities/chat/middleware/types' -import type { ModelMessage, StreamChunk, Tool, UIMessage } from '../src/types' +import type { ModelMessage, StreamChunk, UIMessage } from '../src/types' /** What one child run saw. */ interface ChildRun { @@ -46,17 +47,15 @@ const writer = defineAgent({ const agents = [researcher, writer] -type Call = SubagentToolInput - beforeEach(() => { runs.length = 0 }) /** - * A parent model that writes a line of text, calls the `subagent` tool once + * A parent model that writes a line of text, calls the tool of `agent` once * with `args`, then stops. */ -function parentCalling(args: object) { +function parentCalling(agent: string, args: object) { return createMockAdapter({ iterations: [ [ @@ -64,7 +63,7 @@ function parentCalling(args: object) { ev.textStart(), ev.textContent('Asking a subagent.'), ev.textEnd(), - ev.toolStart('call_1', 'subagent'), + ev.toolStart('call_1', agent), ev.toolArgs('call_1', JSON.stringify(args)), ev.runFinished('tool_calls'), ], @@ -87,8 +86,9 @@ function toolResult(chunks: Array) { throw new Error('No result for call_1') } -/** Run a parent chat with the single tool. The model calls it with `args`. */ +/** Run a parent chat. The model calls the tool of `agent` with `args`. */ async function callSubagent( + agent: string, args: object, options: { middleware?: Array @@ -96,7 +96,7 @@ async function callSubagent( runId?: string } = {}, ) { - const { adapter, calls } = parentCalling(args) + const { adapter, calls } = parentCalling(agent, args) const chunks = await collectChunks( chat({ adapter, @@ -104,7 +104,6 @@ async function callSubagent( messages: [{ role: 'user', content: 'Go' }], subagents: { agents, - tool: 'single', ...(options.binding && { binding: options.binding }), }, ...(options.middleware && { middleware: options.middleware }), @@ -122,6 +121,7 @@ function storedChildren( ): ChatMiddleware { return { name: 'stored-children', + provides: [LoadChildCapability], setup(ctx) { provideLoadChild(ctx, async (subagentRunId) => { const messages = stored.get(subagentRunId) @@ -143,17 +143,32 @@ const oldChild = new Map>([ ], ]) -describe('subagents tool: single', () => { - it('gives the model one subagent tool and runs an agent with its input', async () => { - const call: Call = { agent: 'researcher', input: { topic: 'tides' } } - const { calls, result } = await callSubagent(call) +/** The wire schema of each agent tool, by name. */ +function toolSchemas( + calls: Array<{ tools?: Array<{ name: string; inputSchema?: unknown }> }>, +) { + return Object.fromEntries( + (calls[0]?.tools ?? []).map((tool) => [tool.name, tool.inputSchema]), + ) +} + +describe('agent tool input', () => { + it('puts the agent schema under input, and gives an agent without one a prompt', async () => { + const { calls, result } = await callSubagent('researcher', { + input: { topic: 'tides' }, + }) - const tools = calls[0]?.tools ?? [] - expect(tools.map((tool) => tool.name)).toEqual(['subagent']) - expect(tools[0]?.inputSchema).toMatchObject({ + const schemas = toolSchemas(calls) + expect(Object.keys(schemas)).toEqual(['researcher', 'writer']) + expect(schemas.researcher).toMatchObject({ type: 'object', - properties: { agent: { enum: ['researcher', 'writer'] } }, - required: ['agent'], + properties: { input: { properties: { topic: { type: 'string' } } } }, + required: ['input'], + }) + expect(schemas.writer).toMatchObject({ + type: 'object', + properties: { prompt: { type: 'string' } }, + required: [], }) expect(runs).toMatchObject([ { agent: 'researcher', input: { topic: 'tides' } }, @@ -161,9 +176,82 @@ describe('subagents tool: single', () => { expect(result.result).toBe('facts about tides') }) + it('points the refs of a recursive input schema at input', async () => { + const node: z.ZodType<{ name: string; children?: Array }> = + z.object({ + name: z.string(), + get children() { + return z.array(node).optional() + }, + }) + const tree = defineAgent({ + name: 'tree', + description: 'Reads a tree', + inputSchema: node, + run: async (ctx) => { + runs.push({ agent: 'tree', input: ctx.input, messages: ctx.messages }) + return 'ok' + }, + }) + const { adapter, calls } = parentCalling('tree', { + input: { name: 'root', children: [{ name: 'leaf' }] }, + }) + await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'Go' }], + subagents: { agents: [tree] }, + }) as AsyncIterable, + ) + + expect(JSON.stringify(toolSchemas(calls).tree)).toContain( + '"$ref":"#/properties/input"', + ) + expect(runs[0]?.input).toEqual({ + name: 'root', + children: [{ name: 'leaf' }], + }) + }) + + it('has sessionId and background only when the run can use them', async () => { + const plain = await callSubagent('writer', {}) + const able = await callSubagent( + 'writer', + {}, + { + middleware: [storedChildren(oldChild)], + binding: { start: async () => ({ subagentRunId: 'unused' }) }, + }, + ) + + expect(toolSchemas(plain.calls).writer).not.toHaveProperty( + 'properties.sessionId', + ) + expect(toolSchemas(plain.calls).writer).not.toHaveProperty( + 'properties.background', + ) + expect(toolSchemas(able.calls).writer).toMatchObject({ + properties: { + sessionId: { type: 'string' }, + background: { type: 'boolean' }, + }, + }) + }) + + it('reads the conversation when an agent without inputSchema gets no prompt', async () => { + const { result } = await callSubagent('writer', {}) + + expect(runs[0]?.input).toBeUndefined() + expect(runs[0]?.messages).toMatchObject([ + { role: 'user', content: 'Go' }, + { role: 'assistant', content: 'Asking a subagent.' }, + ]) + // No persistence store, so the model gets no subagentRunId. + expect(result).toEqual({ result: 'draft' }) + }) + it('adds the prompt as the last user message of a child without inputSchema', async () => { - const call: Call = { agent: 'writer', prompt: 'Write a haiku' } - await callSubagent(call) + await callSubagent('writer', { prompt: 'Write a haiku' }) expect(runs).toHaveLength(1) expect(runs[0]?.input).toBeUndefined() @@ -175,14 +263,11 @@ describe('subagents tool: single', () => { }) it('continues a stored child by sessionId', async () => { - const call: Call = { - agent: 'writer', - sessionId: 'subagent-old', - prompt: 'Make it shorter', - } - const { result } = await callSubagent(call, { - middleware: [storedChildren(oldChild)], - }) + const { result } = await callSubagent( + 'writer', + { sessionId: 'subagent-old', prompt: 'Make it shorter' }, + { middleware: [storedChildren(oldChild)] }, + ) expect(runs[0]?.messages).toEqual([ { role: 'user', content: 'Draft a tide report' }, @@ -193,13 +278,13 @@ describe('subagents tool: single', () => { }) it('gives the text of each continue its own message', async () => { - const call: Call = { agent: 'writer', sessionId: 'subagent-old' } const ids: Array = [] for (const runId of ['run-1', 'run-2']) { - const { chunks } = await callSubagent(call, { - middleware: [storedChildren(oldChild)], - runId, - }) + const { chunks } = await callSubagent( + 'writer', + { sessionId: 'subagent-old' }, + { middleware: [storedChildren(oldChild)], runId }, + ) for (const chunk of chunks) { const isChildText = chunk.type === EventType.TEXT_MESSAGE_START && @@ -242,8 +327,8 @@ describe('subagents tool: single', () => { ], rest: [], } - const [tool] = createSyntheticSubagentTools( - { agents, tool: 'single' }, + const [, tool] = createSyntheticSubagentTools( + { agents }, { messages: [], threadId: 'thread-1', @@ -255,7 +340,7 @@ describe('subagents tool: single', () => { }, ) await tool?.execute?.( - { agent: 'writer', sessionId: 'subagent-old', prompt: 'Make it shorter' }, + { sessionId: 'subagent-old', prompt: 'Make it shorter' }, { toolCallId: 'call_1', emitCustomEvent: () => {} }, ) @@ -268,8 +353,11 @@ describe('subagents tool: single', () => { it('starts a background child through the start hook', async () => { const start = vi.fn(async () => ({ subagentRunId: 'subagent-bg' })) - const call: Call = { agent: 'writer', prompt: 'Draft', background: true } - const { result } = await callSubagent(call, { binding: { start } }) + const { result } = await callSubagent( + 'writer', + { prompt: 'Draft', background: true }, + { binding: { start } }, + ) expect(result).toEqual({ subagentRunId: 'subagent-bg', @@ -282,66 +370,86 @@ describe('subagents tool: single', () => { expect(runs).toEqual([]) }) - const toolErrors: Array<[string, object, Array, string]> = [ + const toolErrors: Array< + [string, string, object, Array, string] + > = [ [ 'a prompt for an agent with inputSchema', - { agent: 'researcher', prompt: 'tides' }, + 'researcher', + { prompt: 'tides' }, [], 'Agent "researcher" takes input, not prompt.', ], [ 'no input for an agent with inputSchema', - { agent: 'researcher' }, + 'researcher', + {}, [], 'Agent "researcher" needs input.', ], + [ + 'an input that fails the agent schema', + 'researcher', + { input: { topic: 3 } }, + [], + 'Input validation failed for tool researcher: Invalid input: expected string, received number', + ], [ 'an input for an agent without inputSchema', - { agent: 'writer', input: { topic: 'tides' } }, + 'writer', + { input: { topic: 'tides' } }, [], 'Agent "writer" takes prompt, not input.', ], [ 'a sessionId without a persistence store', - { agent: 'writer', sessionId: 'subagent-old' }, + 'writer', + { sessionId: 'subagent-old' }, [], 'sessionId needs a persistence store.', ], [ 'an unknown sessionId', - { agent: 'writer', sessionId: 'subagent-missing' }, + 'writer', + { sessionId: 'subagent-missing' }, [storedChildren(oldChild)], 'Unknown sessionId "subagent-missing".', ], [ 'a sessionId of a child of another agent', - { agent: 'researcher', sessionId: 'subagent-old', input: { topic: 'x' } }, + 'researcher', + { sessionId: 'subagent-old', input: { topic: 'x' } }, [storedChildren(oldChild)], 'Session "subagent-old" belongs to agent "writer", not "researcher".', ], [ 'background without a host', - { agent: 'writer', background: true }, + 'writer', + { background: true }, [], 'background needs a host that runs children in the background.', ], [ 'a background that is not a boolean', - { agent: 'writer', background: 'true' }, + 'writer', + { background: 'true' }, [], 'prompt and sessionId must be strings, and background a boolean.', ], [ 'background with a sessionId', - { agent: 'writer', background: true, sessionId: 'subagent-old' }, + 'writer', + { background: true, sessionId: 'subagent-old' }, [], 'background cannot continue a sessionId.', ], ] it.each(toolErrors)( 'returns a tool error for %s', - async (_label, args, middleware, error) => { - const { chunks, result } = await callSubagent(args, { middleware }) + async (_label, agent, args, middleware, error) => { + const { chunks, result } = await callSubagent(agent, args, { + middleware, + }) expect(result).toEqual({ error }) expect(runs).toEqual([]) @@ -351,33 +459,4 @@ describe('subagents tool: single', () => { ) }, ) - - const namedSubagent = defineAgent({ ...writer, name: 'subagent' }) - const takenNames: Array<[string, Array, Array]> = [ - ['an agent', [researcher, namedSubagent], []], - ['a tool', agents, [serverTool('subagent', () => 'ok')]], - ] - it.each(takenNames)( - 'throws when %s is named subagent', - (_label, named, tools) => { - const { adapter } = parentCalling({}) - expect(() => - chat({ - adapter, - messages: [{ role: 'user', content: 'Go' }], - tools, - subagents: { agents: named, tool: 'single' }, - }), - ).toThrow(`subagents.tool 'single' adds a tool named "subagent"`) - }, - ) - - it('types the input as a union on agent', () => { - expectTypeOf< - Extract['input'] - >().toEqualTypeOf<{ topic: string }>() - expectTypeOf>().not.toHaveProperty( - 'input', - ) - }) }) diff --git a/testing/e2e/fixtures/subagents/basic.json b/testing/e2e/fixtures/subagents/basic.json index ac46395da5..5fb3b5b82e 100644 --- a/testing/e2e/fixtures/subagents/basic.json +++ b/testing/e2e/fixtures/subagents/basic.json @@ -88,7 +88,7 @@ "toolCalls": [ { "name": "researcher", - "arguments": "{\"task\":\"[subagent-brief-task] List two squid facts.\"}" + "arguments": "{\"input\":{\"task\":\"[subagent-brief-task] List two squid facts.\"}}" } ] } @@ -119,7 +119,7 @@ "toolCalls": [ { "name": "pricer", - "arguments": "{\"task\":\"[subagent-result-task] Price Vendor A.\"}" + "arguments": "{\"input\":{\"task\":\"[subagent-result-task] Price Vendor A.\"}}" } ] } @@ -150,11 +150,11 @@ "toolCalls": [ { "name": "researcher", - "arguments": "{\"task\":\"[subagent-limits-task] squids\"}" + "arguments": "{\"input\":{\"task\":\"[subagent-limits-task] squids\"}}" }, { "name": "researcher", - "arguments": "{\"task\":\"[subagent-limits-task] octopuses\"}" + "arguments": "{\"input\":{\"task\":\"[subagent-limits-task] octopuses\"}}" } ] } diff --git a/testing/e2e/fixtures/subagents/single-tool.json b/testing/e2e/fixtures/subagents/session.json similarity index 80% rename from testing/e2e/fixtures/subagents/single-tool.json rename to testing/e2e/fixtures/subagents/session.json index 6c3a892105..17e8241d82 100644 --- a/testing/e2e/fixtures/subagents/single-tool.json +++ b/testing/e2e/fixtures/subagents/session.json @@ -8,8 +8,8 @@ "response": { "toolCalls": [ { - "name": "subagent", - "arguments": "{\"agent\":\"researcher\",\"prompt\":\"[subagent-session-task] Note one squid fact.\"}" + "name": "researcher", + "arguments": "{\"prompt\":\"[subagent-session-task] Note one squid fact.\"}" } ] } @@ -31,8 +31,8 @@ "response": { "toolCalls": [ { - "name": "subagent", - "arguments": "{\"agent\":\"researcher\",\"sessionId\":\"first-child\"}" + "name": "researcher", + "arguments": "{\"sessionId\":\"first-child\"}" } ] } diff --git a/testing/e2e/src/routes/api.subagents-test.ts b/testing/e2e/src/routes/api.subagents-test.ts index 5252024922..e3fd67ebf0 100644 --- a/testing/e2e/src/routes/api.subagents-test.ts +++ b/testing/e2e/src/routes/api.subagents-test.ts @@ -41,8 +41,8 @@ import type { SubagentScenario } from '@/lib/subagents-test' * - `result`: no router. `pricer` calls the bound `ctx.chat` with * `stream: false`, so its `run` resolves to a string. The string streams as * the child's text and goes back to the parent model as the result. - * - `session`: no router, one `subagent` tool. The parent model starts - * `researcher`, then a second call continues it by its `sessionId`. + * - `session`: no router. The parent model starts `researcher`, then a + * second `researcher` call continues it by its `sessionId`. * `withPersistence` stores the child, so that call loads its transcript. */ function subagentsFor( @@ -126,15 +126,12 @@ function subagentsFor( const agents: Array = [pricer] return { agents } } - if (scenario === 'session') { - return { agents: [child('researcher', [])], tool: 'single' as const } - } return { agents: [child('researcher', [])] } } /** - * aimock replies are fixed, so the second `subagent` call in - * `fixtures/subagents/single-tool.json` cannot hold the random id of the + * aimock replies are fixed, so the second `researcher` call in + * `fixtures/subagents/session.json` cannot hold the random id of the * first child. It sends `sessionId: 'first-child'`. This middleware does what * a real model does: it copies the `subagentRunId` of the first result. */ diff --git a/testing/e2e/tests/subagents.spec.ts b/testing/e2e/tests/subagents.spec.ts index 103dfef10a..d489bbe36b 100644 --- a/testing/e2e/tests/subagents.spec.ts +++ b/testing/e2e/tests/subagents.spec.ts @@ -217,7 +217,7 @@ test.describe('subagents', () => { ).toBe(true) }) - test('the single subagent tool continues a child by its sessionId', async ({ + test('an agent tool continues a child by its sessionId', async ({ page, testId, aimockPort,