diff --git a/.changeset/subagent-tool-input.md b/.changeset/subagent-tool-input.md new file mode 100644 index 000000000..a45cbc8dd --- /dev/null +++ b/.changeset/subagent-tool-input.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-persistence': minor +'@tanstack/ai-client': minor +--- + +Continue a child. With `withPersistence` on the parent `chat()`, an agent tool result gives the model the child's `subagentRunId`, and a later call of the same agent tool 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. These fields sit next to the agent's own fields, and each agent tool has them only when the run can use them. When the run uses `sessionId` or `background`, that name is reserved: `chat()` throws when an agent's `inputSchema` uses it. An agent without `inputSchema` takes the task as text in `prompt`, and the child gets it as the last user message. Agent tools also support the `detach` field of the tool context. + +The card keeps the checked input of an agent with `inputSchema`. On the client, `part.subagent.input` has it, typed from `inputSchema` when the agent declaration goes to `useChat({ subagents })`. + +`@tanstack/ai` adds `LoadChildCapability`, `getLoadChild`, and `provideLoadChild`, so a persistence middleware can load a stored child. A provider lists `LoadChildCapability` in `provides`. `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 d65931a86..c2b6af768 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. 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 @@ -206,6 +206,71 @@ const stream = chat({ To show the brief in the UI, see [Show the brief on a card](#show-the-brief-on-a-card). +## Continue a child + +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 { + chat, + chatParamsFromRequest, + defineAgent, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence' + +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: [writer] }, + }) + return toServerSentEventsResponse(stream) +} +``` + +The model continues the writer like this: + +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" }`. + +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. + +### The tool input + +Every agent tool takes the agent's own fields, plus two more: + +- The fields of `inputSchema`, for an agent with one. The tool checks them against the schema. +- `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`. + +When the run uses `sessionId` or `background`, that name is reserved. If an `inputSchema` has a field with that name, `chat()` throws before the run starts. + +### Wrong calls + +A wrong call goes back to the model as a tool error, and the run continues. For example: + +- `Input validation failed for tool researcher: ...`: the fields do not match `inputSchema`. +- `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.` + ## 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: @@ -393,7 +458,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 @@ -661,38 +726,39 @@ 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: +When an agent has `inputSchema`, the card keeps the checked input that the parent model wrote, as `part.subagent.input`. Put `inputSchema` in the shared declaration, and pass the declaration to `useChat` to type it: -```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 input = part.input - if ( - typeof input === 'object' && - input !== null && - 'task' in input && - typeof input.task === 'string' - ) { - return input.task - } - } - return undefined -} +```typescript title="agents.ts" +import { z } from 'zod' + +export const researcher = { + name: 'researcher', + description: 'Researches one focused question and returns sourced findings', + inputSchema: z.object({ task: z.string() }), +} as const +``` + +```tsx title="briefs.tsx" +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { researcher } from './agents' -function Briefs({ message }: { message: UIMessage }) { - return message.parts.map((part) => { - if (part.type !== 'subagent') return null - const toolCallId = part.subagent.parentToolCallId - if (toolCallId === undefined) return null - return

{briefOf(message, toolCallId)}

+export function Briefs() { + const { messages } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + subagents: [researcher], }) + return messages.flatMap((message) => + message.parts.map((part) => + part.type === 'subagent' ? ( +

{part.subagent.input?.task}

+ ) : null, + ), + ) } ``` -A child that a router started has no `parentToolCallId`, so it has no brief. +- `part.subagent.input` is the same value as `ctx.input` in `run`, with the output type of the schema. Here it is `{ task: string } | undefined`. +- It is `undefined` for an agent without `inputSchema`. ### Style one child's parts diff --git a/examples/ts-react-chat/src/lib/researcher-agent.ts b/examples/ts-react-chat/src/lib/researcher-agent.ts new file mode 100644 index 000000000..9529e92f2 --- /dev/null +++ b/examples/ts-react-chat/src/lib/researcher-agent.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +// Shared by the server and the browser, so it holds no server code. The +// server adds `run` in `routes/api.subagent-brief.ts`. The browser passes it +// to `useChat`, which types `part.subagent.input` from `inputSchema`. +export const researcher = { + name: 'researcher', + description: + 'Researches one focused question and returns short findings. It cannot see this chat, so the task must hold every detail it needs.', + inputSchema: z.object({ + task: z + .string() + .describe( + 'What to find and what to return. The researcher sees only this text.', + ), + }), +} as const diff --git a/examples/ts-react-chat/src/routes/api.subagent-brief.ts b/examples/ts-react-chat/src/routes/api.subagent-brief.ts index 0f229eb84..3175a98cf 100644 --- a/examples/ts-react-chat/src/routes/api.subagent-brief.ts +++ b/examples/ts-react-chat/src/routes/api.subagent-brief.ts @@ -6,23 +6,14 @@ import { toServerSentEventsResponse, } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' +import { researcher as researcherDeclaration } from '../lib/researcher-agent' /** * The parent model writes the researcher's brief. The researcher's only * message is that brief, not the parent conversation. */ const researcher = defineAgent({ - name: 'researcher', - description: - 'Researches one focused question and returns short findings. It cannot see this chat, so the task must hold every detail it needs.', - inputSchema: z.object({ - task: z - .string() - .describe( - 'What to find and what to return. The researcher sees only this text.', - ), - }), + ...researcherDeclaration, run: (ctx) => { console.log( '[subagent-brief] child input:', diff --git a/examples/ts-react-chat/src/routes/subagent-brief.tsx b/examples/ts-react-chat/src/routes/subagent-brief.tsx index 704e83a75..86dd2c8a1 100644 --- a/examples/ts-react-chat/src/routes/subagent-brief.tsx +++ b/examples/ts-react-chat/src/routes/subagent-brief.tsx @@ -1,29 +1,13 @@ import { createFileRoute } from '@tanstack/react-router' import { useState } from 'react' import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { researcher } from '../lib/researcher-agent' import type { UIMessage } from '@tanstack/ai-react' export const Route = createFileRoute('/subagent-brief')({ component: SubagentBriefPage, }) -/** The `task` the parent model wrote on the tool call that started a child. */ -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 - if ( - typeof input === 'object' && - input !== null && - 'task' in input && - typeof input.task === 'string' - ) { - return input.task - } - } - return undefined -} - function childText(messages: ReadonlyArray) { return messages .flatMap((message) => @@ -37,6 +21,8 @@ function childText(messages: ReadonlyArray) { function SubagentBriefPage() { const { messages, sendMessage, isLoading } = useChat({ connection: fetchServerSentEvents('/api/subagent-brief'), + // Types `part.subagent.input` from the researcher's inputSchema. + subagents: [researcher], }) const [input, setInput] = useState( 'We picked three sea animals earlier: squid, octopus, and cuttlefish. Find how many hearts each one has.', @@ -63,7 +49,6 @@ function SubagentBriefPage() { ) } if (part.type !== 'subagent') return null - const toolCallId = part.subagent.parentToolCallId return (
@@ -73,10 +58,7 @@ function SubagentBriefPage() {
- Brief:{' '} - {toolCallId === undefined - ? '(none)' - : (briefOf(message, toolCallId) ?? '(none)')} + Brief: {part.subagent.input?.task ?? '(none)'}

{childText(part.subagent.messages)} diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index a94a3d3b0..4a3c19c54 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -8,6 +8,7 @@ import { mergeMetadata, normalizeToUIMessage, restoreInboundChunk, + subagentInputOf, tanstackMetadata, validateWithStandardSchema, } from '@tanstack/ai/client' @@ -3251,6 +3252,14 @@ export class ChatClient< } private syncSubagentHandles(): void { + // The card metadata carries the checked input of an agent with + // `inputSchema`. The handle shows it as `input`. + // A card copied from a handle can hold an old `input`. Read it fresh. + const fieldsOf = (subagent: SubagentHandle) => { + const { input: _old, ...card } = subagent + const input = subagentInputOf(card.metadata) + return input === undefined ? card : { ...card, input } + } // Every card, nested ones included, gets a handle. const cards = collectSubagentParts(this.processor.getMessages()) const present = new Set(cards.map((part) => part.subagent.id)) @@ -3269,12 +3278,12 @@ export class ChatClient< for (const key of Object.keys(existing)) { Reflect.deleteProperty(existing, key) } - Object.assign(existing, part.subagent, { stop }) + Object.assign(existing, fieldsOf(part.subagent), { stop }) part.subagent = existing continue } const handle: SubagentHandle = { - ...part.subagent, + ...fieldsOf(part.subagent), stop: () => { this.stopSubagent(id) }, diff --git a/packages/ai-client/src/types.ts b/packages/ai-client/src/types.ts index e92d914d8..5cbca7c5f 100644 --- a/packages/ai-client/src/types.ts +++ b/packages/ai-client/src/types.ts @@ -643,6 +643,11 @@ export interface SubagentHandle extends SubagentHandleData { * keeps going. */ stop?: () => void + /** + * The checked input of an agent with `inputSchema`: what the parent model + * wrote when it started this child. Set by ChatClient from the card. + */ + input?: unknown } export interface SubagentPart { @@ -660,6 +665,8 @@ export type SubagentClientAgent = { description?: string tools?: ReadonlyArray<{ name: string }> interrupts?: ReadonlyArray> + /** Types `part.subagent.input`. */ + inputSchema?: unknown outputSchema?: SchemaInput } @@ -687,13 +694,28 @@ type AgentOutputData = TAgent extends { outputSchema?: infer TSchema } : unknown : unknown +// The card holds the checked value, so a Standard Schema gives its output type. +type AgentInputData = TAgent extends { inputSchema?: infer TSchema } + ? [Exclude] extends [never] + ? undefined + : Exclude extends { + '~standard': { types?: { output: infer TOutput } } + } + ? TOutput + : Exclude extends SchemaInput + ? InferSchemaType> + : unknown + : undefined + /** One child handle. `name` is the discriminant. */ export type SubagentHandleOf = Omit< SubagentHandle, - 'name' | 'messages' + 'name' | 'messages' | 'input' > & { name: TAgent['name'] messages: Array, AgentOutputData>> + /** Unset on a card saved before the input was kept. */ + input?: AgentInputData } export type SubagentHandles< diff --git a/packages/ai-client/tests/chat-client-subagents.test.ts b/packages/ai-client/tests/chat-client-subagents.test.ts index cb17450e3..a4c3a0a17 100644 --- a/packages/ai-client/tests/chat-client-subagents.test.ts +++ b/packages/ai-client/tests/chat-client-subagents.test.ts @@ -25,12 +25,13 @@ function runFinished(): StreamChunk { } } -function subagentStarted(): StreamChunk { +function subagentStarted(metadata?: Record): StreamChunk { return { type: EventType.SUBAGENT_STARTED, subagentRunId: 'sub-1', name: 'researcher', timestamp: now(), + ...(metadata && { metadata }), } } @@ -93,6 +94,27 @@ describe('ChatClient subagents', () => { expect(part.subagent.stop).toBe(subagents[0]?.stop) }) + it('gives the handle the checked input from the card metadata', async () => { + const chunks: Array = [ + runStarted(), + subagentStarted({ + tanstack: { subagentInput: { task: 'Find squids' } }, + }), + childTextStart(), + childTextContent('Hello'), + subagentFinished(), + runFinished(), + ] + + const client = new ChatClient({ + connection: createMockConnectionAdapter({ chunks }), + }) + await client.sendMessage('hi') + + // The handle keeps the input after later chunks update the card. + expect(client.getSubagents()[0]?.input).toEqual({ task: 'Find squids' }) + }) + it('keeps status error after stop when SUBAGENT_FINISHED arrives later', async () => { let release = () => {} const gate = new Promise((resolve) => { @@ -253,6 +275,7 @@ describe('ChatClient subagent handles for restored messages', () => { name: 'researcher', status: 'finished', messages: [], + metadata: { tanstack: { subagentInput: { task: 'Saved' } } }, }, }, ], @@ -261,7 +284,11 @@ describe('ChatClient subagent handles for restored messages', () => { }) const [handle] = client.getSubagents() - expect(handle).toMatchObject({ id: 'sub-restored', name: 'researcher' }) + expect(handle).toMatchObject({ + id: 'sub-restored', + name: 'researcher', + input: { task: 'Saved' }, + }) expect(typeof handle?.stop).toBe('function') const part = client.getMessages()[0]?.parts[0] expect(part?.type === 'subagent' ? part.subagent : undefined).toBe(handle) diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index 77b0a90b3..d71982be7 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 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, { + 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 fcbac8255..e6f3d5be5 100644 --- a/packages/ai-persistence/src/subagent-runs.ts +++ b/packages/ai-persistence/src/subagent-runs.ts @@ -3,6 +3,7 @@ import { convertMessagesToModelMessages, modelMessagesToUIMessages, subagentHostMessageId, + subagentInputOf, wireSubagentInfo, } from '@tanstack/ai' import type { @@ -37,6 +38,55 @@ 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) +} + +/** The `prompt` of an agent tool call, if any. */ +function promptOf(call: { args: string } | undefined) { + if (call === undefined) return undefined + let args: unknown + try { + args = JSON.parse(call.args) + } catch { + return undefined + } + const prompt = + args !== null && typeof args === 'object' + ? Reflect.get(args, 'prompt') + : undefined + return typeof prompt === 'string' ? prompt : undefined +} + function readSubagentRunId(chunk: StreamChunk) { if (!('subagentRunId' in chunk)) return const id = chunk.subagentRunId @@ -170,6 +220,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 +243,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 +266,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 +315,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 +382,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 +409,39 @@ 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' + // An agent with `inputSchema` has its checked input on the card. An + // agent without one gets the `prompt` of the call as a user message. + const checked = subagentInputOf(chunk.metadata) + // Only the first call gives the task. A later call keeps it. + const prompt = + raw.length > 0 + ? marker.prompt + : checked === undefined + ? promptOf(call) + : JSON.stringify(checked) + // Core does not stream that user message, so store it here. + const followUp = + raw.length > 0 && !resumes && checked === undefined + ? promptOf(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 +458,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 +480,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 +635,8 @@ export function createSubagentRunRecorder(stores: { } return { + loadChild, + async start(input: { threadId: string runId: string @@ -491,9 +661,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 +735,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 +747,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 0809cee0b..0671294ef 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 `writer` agent tool once, then stops. */ +function callingSubagent(toolCallId: string, args: object) { + return scriptedAdapter([ + [ + { + type: EventType.TOOL_CALL_START, + toolCallId, + toolCallName: 'writer', + 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 that calls the `writer` 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] }, + }), + ) +} + +/** 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,220 @@ 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: 'writer', + timestamp: t, + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call_1', + delta: '{"topic":"tides"}', + timestamp: t, + }, + // Core puts the checked input of an agent with inputSchema on the card. + { + ...childStarted('child', { parentToolCallId: 'call_1' }), + metadata: { tanstack: { subagentInput: { topic: 'tides' } } }, + }, + ...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' }, + ]) + // Only this agent can continue the child. + expect(loaded?.agent).toBe('writer') + }) + + it('stores the input of an agent whose schema has a prompt field as JSON', 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: 'writer', + timestamp: t, + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call_1', + delta: '{"prompt":"a cat"}', + timestamp: t, + }, + { + ...childStarted('child', { parentToolCallId: 'call_1' }), + metadata: { tanstack: { subagentInput: { prompt: 'a cat' } } }, + }, + ...textMessage('m1', 'Cat image', { subagentRunId: 'child' }), + ]) + + const loaded = await recorder.loadChild('child', { threadId: 'desk' }) + // The child got `ctx.input`, not a user message with the text. + expect(loaded?.messages[0]).toMatchObject({ + role: 'user', + content: '{"prompt":"a cat"}', + }) + }) + + 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('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: { 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: { 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: { 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: { prompt: 'Take notes' }, + }) + const id = startedId(first) + const other = await subagentTurn(middleware, agent, { + threadId: 'other', + runId: 'run-2', + toolCallId: 'call_2', + args: { sessionId: id, prompt: 'Show me' }, + }) + + expect(toolResult(other, 'call_2')).toEqual({ + error: `Unknown sessionId "${id}".`, + }) + expect(seen).toHaveLength(1) + }) }) diff --git a/packages/ai-react/tests/use-chat-subagent-types.test.ts b/packages/ai-react/tests/use-chat-subagent-types.test.ts index 4e404c9f3..585892670 100644 --- a/packages/ai-react/tests/use-chat-subagent-types.test.ts +++ b/packages/ai-react/tests/use-chat-subagent-types.test.ts @@ -40,3 +40,50 @@ it('useChat types subagent parts from the server agents', () => { } void check }) + +it('types part.subagent.input from the agent inputSchema', () => { + const check = () => { + const inputSchema = { + '~standard': { + version: 1 as const, + vendor: 'test', + types: { input: { task: '' }, output: { task: '' } }, + validate: () => ({ value: { task: '' } }), + jsonSchema: { + input: () => ({ + type: 'object', + properties: { task: { type: 'string' } }, + }), + output: () => ({}), + }, + }, + } + const researcher = defineAgent({ + name: 'researcher', + description: 'Looks up facts', + inputSchema, + run: async function* () {}, + }) + const writer = defineAgent({ + name: 'writer', + description: 'Writes the draft', + run: async function* () {}, + }) + + const chat = useChat({ + connection: { connect: async function* () {} }, + subagents: [researcher, writer], + }) + + const part = chat.messages[0]?.parts[0] + if (part?.type !== 'subagent') return + if (part.subagent.name === 'researcher') { + expectTypeOf(part.subagent.input).toEqualTypeOf< + { task: string } | undefined + >() + } else { + expectTypeOf(part.subagent.input).toEqualTypeOf() + } + } + void check +}) diff --git a/packages/ai/src/activities/chat/agents/bound.ts b/packages/ai/src/activities/chat/agents/bound.ts index 1655fd993..0b4008797 100644 --- a/packages/ai/src/activities/chat/agents/bound.ts +++ b/packages/ai/src/activities/chat/agents/bound.ts @@ -69,6 +69,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 + * 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. + */ + 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/define-agent.ts b/packages/ai/src/activities/chat/agents/define-agent.ts index 7a2417749..8edf16529 100644 --- a/packages/ai/src/activities/chat/agents/define-agent.ts +++ b/packages/ai/src/activities/chat/agents/define-agent.ts @@ -121,8 +121,11 @@ 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 - * `subagents.router` cannot start an agent that has `inputSchema`. + * as a short brief. `run` reads it as `ctx.input`, and the client card + * as `part.subagent.input`. When the run uses `sessionId` or + * `background`, that name is reserved. Without it, the tool takes a text + * `prompt`. Tool mode only: a `subagents.router` cannot start an agent + * that has `inputSchema`. */ inputSchema?: TInput tools?: TTools diff --git a/packages/ai/src/activities/chat/agents/spawn.ts b/packages/ai/src/activities/chat/agents/spawn.ts index e6309333e..4bd735459 100644 --- a/packages/ai/src/activities/chat/agents/spawn.ts +++ b/packages/ai/src/activities/chat/agents/spawn.ts @@ -10,13 +10,26 @@ import { withTanstackMetadata, } from '../../../utilities/merge-metadata' import { mergeStreams } from '../../../utilities/merge-streams' +import { SUBAGENT_INPUT_KEY } from '../../../utilities/subagent-wire' import { INTERRUPT_BINDING_METADATA_KEY } from '../../../interrupt-resume' import { EMIT_STREAM_CHUNK, SUBAGENT_TOOL } from '../tools/tool-calls' +import { + convertSchemaToJsonSchema, + isStandardSchema, +} 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 { + StandardJSONSchemaV1, + StandardSchemaV1, +} from '@standard-schema/spec' import type { Interrupt, + JSONSchema, ModelMessage, + SchemaInput, RunAgentResumeItem, StreamChunk, SubagentErrorEvent, @@ -24,6 +37,7 @@ import type { SubagentStartedEvent, TokenUsage, Tool, + ToolExecutionContext, UIMessage, } from '../../../types' import { envProviderKeys } from '../../../byok/env-keys' @@ -109,6 +123,62 @@ export interface SubagentsBag< keys?: ProviderKeySource } +/** + * Fields an agent tool takes next to the agent's own fields, when the run + * can use them. An `inputSchema` cannot use a name that the run uses. + * - `sessionId`: the `subagentRunId` of a stored child. The call continues + * it. Needs a middleware that provides `LoadChildCapability`. + * - `background`: start the child and return at once. Needs `binding.start`. + */ +const RESERVED_FIELDS = ['sessionId', 'background'] as const + +type ReservedField = (typeof RESERVED_FIELDS)[number] + +/** The reserved fields that a run uses. */ +function reservedFields( + bag: Pick, + canContinue: boolean, +) { + const can: Record = { + sessionId: canContinue, + background: bag.binding?.start !== undefined, + } + return RESERVED_FIELDS.filter((name) => can[name]) +} + +/** Throws when an agent's `inputSchema` uses a reserved field of the run. */ +export function assertNoReservedFields( + bag: Pick, + canContinue: boolean, +) { + const names = reservedFields(bag, canContinue) + for (const agent of names.length > 0 ? bag.agents : []) { + const schema = convertSchemaToJsonSchema(agent.inputSchema, { io: 'input' }) + const taken = names.find((name) => schema?.properties?.[name]) + if (taken !== undefined) { + throw new Error( + `Agent "${agent.name}" uses the reserved input field "${taken}". Rename that field.`, + ) + } + } +} + +/** Split the reserved fields off a tool call. */ +function splitReserved(value: unknown, names: ReadonlyArray) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return { fields: value, reserved: {} as Record } + } + const fields: Record = {} + const reserved: Record = {} + for (const [key, entry] of Object.entries(value)) { + const target = (names as ReadonlyArray).includes(key) + ? reserved + : fields + target[key] = entry + } + return { fields, reserved } +} + /** What the children of one parent run left behind for the parent terminal. */ export interface SubagentSink { interrupts: Array @@ -127,6 +197,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. */ @@ -289,7 +366,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) { @@ -297,10 +376,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, } @@ -309,7 +407,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), @@ -585,7 +683,7 @@ export async function* spawnAgentStream( // No parentSubagentRunId here, also for a nested child: the chat that // started this child lists it as a direct child. attributeChunk adds the id // one level up, in the stream of the parent child. - yield { + const started = { type: SUBAGENT_STARTED, subagentRunId: id, name: agent.name, @@ -593,6 +691,10 @@ export async function* spawnAgentStream( ...(parentToolCallId !== undefined ? { parentToolCallId } : {}), timestamp: Date.now(), } satisfies SubagentStartedEvent + // The card keeps the checked input, so a UI can show the brief. + yield input.input === undefined + ? started + : withTanstackMetadata(started, { [SUBAGENT_INPUT_KEY]: input.input }) // No yield between here and the `try`, so `finally` always disposes it. const link = linkAbort(input.abortSignal) @@ -844,12 +946,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( @@ -863,6 +970,131 @@ export function subagentCallMessages(names: ReadonlySet) { middleware, messagesFor: (toolCallId: string | undefined) => toolCallId === undefined ? undefined : byCall.get(toolCallId), + childLoader: () => loadChild, + } +} + +/** + * The wire schema of an agent tool: the agent's `inputSchema`, or a `prompt` + * for an agent without one. The reserved fields of the run sit next to + * those fields. + */ +function agentToolSchema( + agent: DefinedAgent, + names: ReadonlyArray, +): JSONSchema { + const schema = convertSchemaToJsonSchema(agent.inputSchema, { + io: 'input', + }) ?? { + type: 'object', + properties: { prompt: { type: 'string' } }, + } + // ponytail: a schema with no top-level properties (a top-level anyOf) gets + // no sessionId or background. Wrap it in allOf if one needs them. + if (schema.properties === undefined || names.length === 0) return schema + return { + ...schema, + properties: { + ...schema.properties, + ...(names.includes('sessionId') && { + sessionId: { + type: 'string', + description: + 'The subagentRunId of an earlier result. The call continues that child.', + }, + }), + ...(names.includes('background') && { + background: { + type: 'boolean', + description: + 'true starts the child and returns its subagentRunId at once.', + }, + }), + }, + } +} + +/** + * The input schema of an agent tool. The tool loop checks a Standard Schema + * before middleware runs, as for any tool. With reserved fields, a wrapper + * checks the agent's fields and passes the reserved ones through. + */ +function agentToolInput( + agent: DefinedAgent, + names: ReadonlyArray, +): SchemaInput { + const inner = agent.inputSchema + if (isStandardSchema(inner) && names.length === 0) return inner + const schema = agentToolSchema(agent, names) + if (!isStandardSchema(inner)) return schema + const toJson = () => schema + const wrapped: StandardSchemaV1 & StandardJSONSchemaV1 = { + '~standard': { + version: 1, + vendor: 'tanstack-ai', + validate: (value) => { + const { fields, reserved } = splitReserved(value, names) + // ponytail: a schema that turns the input into a non-object (a + // top-level transform) loses the reserved fields. + const add = (result: StandardSchemaV1.Result) => + result.issues !== undefined || + Object.keys(reserved).length === 0 || + typeof result.value !== 'object' || + result.value === null || + Array.isArray(result.value) + ? result + : { + value: { + ...(result.value as Record), + ...reserved, + }, + } + const result = inner['~standard'].validate(fields) + return result instanceof Promise ? result.then(add) : add(result) + }, + jsonSchema: { input: toJson, output: toJson }, + }, + } + return wrapped +} + +/** + * Split a checked agent tool call into the agent's input and the reserved + * fields. A reserved field of the wrong type throws a tool error. + */ +function readCall( + agent: DefinedAgent, + args: unknown, + names: ReadonlyArray, +): { + input?: unknown + prompt?: string + sessionId?: string + background: boolean +} { + const { fields, reserved } = splitReserved(args, names) + const { sessionId, background } = reserved + const prompt = + agent.inputSchema === undefined && + typeof fields === 'object' && + fields !== null + ? Reflect.get(fields, 'prompt') + : undefined + const wrongType = + (prompt !== undefined && typeof prompt !== 'string') || + (sessionId !== undefined && typeof sessionId !== 'string') || + (background !== undefined && typeof background !== 'boolean') + if (wrongType) { + throw new Error( + 'prompt and sessionId must be strings, and background a boolean.', + ) + } + return { + // The tool loop checked these fields against `inputSchema`. + ...(agent.inputSchema !== undefined && { input: fields }), + ...(typeof prompt === 'string' && { prompt }), + ...(typeof sessionId === 'string' && { sessionId }), + background: background === true, } } @@ -883,6 +1115,10 @@ export function createSyntheticSubagentTools( abortSignal?: AbortSignal turn?: SubagentTurn 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. @@ -1016,24 +1252,88 @@ export function createSyntheticSubagentTools( ...(result !== undefined ? { result } : {}), ...(error !== undefined ? { error } : {}), ...(sink.interrupts.length > 0 ? { interrupts: sink.interrupts } : {}), + // Only a stored child can be continued, through a tool with `sessionId`. + ...(subagentRunId !== '' && parent.canContinue === true + ? { 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 } + } + + const names = reservedFields(bag, parent.canContinue === true) + // 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: agentToolInput(agent, names), [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 (args: unknown, context?: ToolExecutionContext) => { + const call = readCall(agent, args, names) + const fields = { + ...(call.input !== undefined && { input: call.input }), + ...(call.prompt !== undefined && { prompt: call.prompt }), + } + const child = { name: agent.name, ...fields } + if (call.background) { + 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 ac7ef6d1c..691eca150 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -64,6 +64,7 @@ import { assertUniqueToolNames } from './tools/unique-tool-names' import type { DefinedAgent } from './agents/define-agent' import { providerKeys } from '../../byok/env-keys' import { + assertNoReservedFields, collectNamedText, createSubagentSink, createSyntheticSubagentTools, @@ -114,6 +115,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' @@ -5651,6 +5653,12 @@ export function chat< if (options.tools) { assertUniqueToolNames(options.tools) } + if (options.subagents) { + assertNoReservedFields( + options.subagents, + continuesChildren(readRuntimeMiddleware(options.middleware)), + ) + } const { outputSchema, stream } = options @@ -5940,6 +5948,8 @@ async function* streamTextChunks( const synthetic = createSyntheticSubagentTools(bag, { messages: turn?.before ?? messages, messagesFor: calls.messagesFor, + childLoader: calls.childLoader, + canContinue: continuesChildren(options.middleware), threadId, runId, ...(options.parentRunId !== undefined && { @@ -6323,6 +6333,15 @@ function withPlan( return withTanstackMetadata(chunk, { [SUBAGENT_PLAN_KEY]: plan }) } +/** A middleware provides `loadChild`, so agent tools take `sessionId`. */ +function continuesChildren( + middleware: ReadonlyArray<{ provides?: ReadonlyArray }> | undefined, +) { + return (middleware ?? []).some((item) => + item.provides?.includes(LoadChildCapability), + ) +} + function readRoutedSubagentPersistence( middleware: | ReadonlyArray<{ 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 000000000..bd806011b --- /dev/null +++ b/packages/ai/src/activities/chat/middleware/load-child.ts @@ -0,0 +1,23 @@ +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 agent tools read it to continue a + * child by `sessionId`. When `agent` is set, only that agent can continue + * 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< + | { + 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 82983b4a4..2b889b293 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/client.ts b/packages/ai/src/client.ts index 19ba1ecb9..e3bcbf4c0 100644 --- a/packages/ai/src/client.ts +++ b/packages/ai/src/client.ts @@ -323,6 +323,7 @@ export type { } from './activities/chat/stream/index' export { uiMessagesToWire } from './utilities/ag-ui-wire' +export { subagentInputOf } from './utilities/subagent-wire' export { mergeMetadata, tanstackMetadata, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index a5ad53c84..45985eb4f 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -383,6 +383,13 @@ export type { AnyChatMiddleware, MetadataStore, } from './activities/chat/middleware/index' +// A persistence store gives the agent tools 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). @@ -575,6 +582,7 @@ export { uiMessagesToWire } from './utilities/ag-ui-wire' export type { SubagentWireInfo } from './utilities/subagent-wire' export { subagentHostMessageId, + subagentInputOf, wireSubagentInfo, } from './utilities/subagent-wire' export { mergeMetadata, withTanstackMetadata } from './utilities/merge-metadata' diff --git a/packages/ai/src/utilities/subagent-wire.ts b/packages/ai/src/utilities/subagent-wire.ts index db2a79e04..1df463d5e 100644 --- a/packages/ai/src/utilities/subagent-wire.ts +++ b/packages/ai/src/utilities/subagent-wire.ts @@ -34,6 +34,18 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +/** Key of a child's checked input in `SUBAGENT_STARTED` metadata (`metadata.tanstack`). */ +export const SUBAGENT_INPUT_KEY = 'subagentInput' + +/** + * The checked input of a child that an agent with `inputSchema` got, read + * from the `metadata` of its card or of its `SUBAGENT_STARTED` event. + */ +export function subagentInputOf(metadata: unknown): unknown { + if (!isRecord(metadata) || !isRecord(metadata.tanstack)) return undefined + return metadata.tanstack[SUBAGENT_INPUT_KEY] +} + export function wireSubagentRunId(message: unknown): string | undefined { if (!isRecord(message) || 'parts' in message) return undefined const id = message.subagentRunId diff --git a/packages/ai/tests/define-agent.test.ts b/packages/ai/tests/define-agent.test.ts index ba676a054..b8e2a87d7 100644 --- a/packages/ai/tests/define-agent.test.ts +++ b/packages/ai/tests/define-agent.test.ts @@ -870,8 +870,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-tool-input.test.ts b/packages/ai/tests/subagent-tool-input.test.ts new file mode 100644 index 000000000..ac2f30bac --- /dev/null +++ b/packages/ai/tests/subagent-tool-input.test.ts @@ -0,0 +1,512 @@ +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 { + 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 } 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, 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] + +beforeEach(() => { + runs.length = 0 +}) + +/** + * A parent model that writes a line of text, calls the tool of `agent` once + * with `args`, then stops. + */ +function parentCalling(agent: string, args: object) { + return createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.textStart(), + ev.textContent('Asking a subagent.'), + ev.textEnd(), + ev.toolStart('call_1', agent), + 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. The model calls the tool of `agent` with `args`. */ +async function callSubagent( + agent: string, + args: object, + options: { + middleware?: Array + binding?: SubagentBinding + runId?: string + } = {}, +) { + const { adapter, calls } = parentCalling(agent, args) + const chunks = await collectChunks( + chat({ + adapter, + ...(options.runId !== undefined && { runId: options.runId }), + messages: [{ role: 'user', content: 'Go' }], + subagents: { + agents, + ...(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', + provides: [LoadChildCapability], + 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.' }, + ], + ], +]) + +/** 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('takes the agent schema fields, and gives an agent without one a prompt', async () => { + const { calls, chunks, result } = await callSubagent('researcher', { + topic: 'tides', + }) + + const schemas = toolSchemas(calls) + expect(Object.keys(schemas)).toEqual(['researcher', 'writer']) + expect(schemas.researcher).toMatchObject({ + type: 'object', + properties: { topic: { type: 'string' } }, + required: ['topic'], + }) + expect(schemas.writer).toEqual({ + type: 'object', + properties: { prompt: { type: 'string' } }, + }) + expect(runs).toMatchObject([ + { agent: 'researcher', input: { topic: 'tides' } }, + ]) + expect(result.result).toBe('facts about tides') + // The card keeps the checked input for the UI. + expect( + chunks.find((chunk) => chunk.type === EventType.SUBAGENT_STARTED), + ).toMatchObject({ + metadata: { tanstack: { subagentInput: { topic: 'tides' } } }, + }) + }) + + it('takes sessionId next to the fields of a strict schema', async () => { + const strict = defineAgent({ + name: 'strict', + description: 'Takes a strict input', + inputSchema: z.object({ topic: z.string() }).strict(), + run: async (ctx) => { + runs.push({ agent: 'strict', input: ctx.input, messages: ctx.messages }) + return 'ok' + }, + }) + const { adapter } = parentCalling('strict', { + topic: 'tides', + sessionId: 'subagent-missing', + }) + const chunks = await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'Go' }], + middleware: [storedChildren(oldChild)], + subagents: { agents: [strict] }, + }) as AsyncIterable, + ) + + // The schema check passes. The unknown sessionId is the only error. + expect(toolResult(chunks)).toEqual({ + error: 'Unknown sessionId "subagent-missing".', + }) + }) + + const taken = defineAgent({ + name: 'taken', + description: 'Uses sessionId', + inputSchema: z.object({ sessionId: z.string() }), + run: async (ctx) => { + runs.push({ agent: 'taken', input: ctx.input, messages: ctx.messages }) + return 'ok' + }, + }) + + it('checks the agent fields before middleware, with sessionId in the args', async () => { + const deep = defineAgent({ + name: 'deep', + description: 'Takes a depth', + inputSchema: z.object({ + topic: z.string(), + depth: z.number().default(2), + }), + run: async (ctx) => { + runs.push({ agent: 'deep', input: ctx.input, messages: ctx.messages }) + return 'ok' + }, + }) + const seen: Array = [] + const spy: ChatMiddleware = { + name: 'spy', + onBeforeToolCall: (_ctx, hook) => { + seen.push(hook.args) + }, + } + const { adapter } = parentCalling('deep', { topic: 'tides' }) + await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'Go' }], + middleware: [storedChildren(oldChild), spy], + subagents: { agents: [deep] }, + }) as AsyncIterable, + ) + + // The schema default is in place when middleware runs. + expect(seen).toEqual([{ topic: 'tides', depth: 2 }]) + expect(runs[0]?.input).toEqual({ topic: 'tides', depth: 2 }) + }) + + it('throws when an inputSchema uses a reserved field that the run uses', () => { + const { adapter } = parentCalling('taken', {}) + + expect(() => + chat({ + adapter, + messages: [{ role: 'user', content: 'Go' }], + middleware: [storedChildren(oldChild)], + subagents: { agents: [taken] }, + }), + ).toThrow( + 'Agent "taken" uses the reserved input field "sessionId". Rename that field.', + ) + }) + + it('keeps a reserved name as an agent field when the run does not use it', async () => { + const { adapter } = parentCalling('taken', { sessionId: 'mine' }) + await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'Go' }], + subagents: { agents: [taken] }, + }) as AsyncIterable, + ) + + expect(runs[0]?.input).toEqual({ sessionId: 'mine' }) + }) + + 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 () => { + await callSubagent('writer', { prompt: 'Write a haiku' }) + + 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 { 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' }, + { 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 ids: Array = [] + for (const runId of ['run-1', 'run-2']) { + 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 && + '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 }, + { + messages: [], + threadId: 'thread-1', + runId: 'run-2', + interruptedRunId: 'run-1', + turn, + sink: createSubagentSink(), + childLoader: () => async () => ({ messages: stored }), + canContinue: true, + }, + ) + await tool?.execute?.( + { 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 { result } = await callSubagent( + 'writer', + { prompt: 'Draft', background: true }, + { 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 host: SubagentBinding = { + start: async () => ({ subagentRunId: 'unused' }), + } + const toolErrors: Array< + [ + string, + string, + object, + { middleware?: Array; binding?: SubagentBinding }, + string, + ] + > = [ + [ + 'a missing field of the agent schema', + 'researcher', + {}, + {}, + 'Input validation failed for tool researcher: Validation failed: Invalid input: expected string, received undefined', + ], + [ + 'a field that fails the agent schema', + 'researcher', + { topic: 3 }, + {}, + 'Input validation failed for tool researcher: Validation failed: Invalid input: expected string, received number', + ], + [ + 'an unknown sessionId', + 'writer', + { sessionId: 'subagent-missing' }, + { middleware: [storedChildren(oldChild)] }, + 'Unknown sessionId "subagent-missing".', + ], + [ + 'a sessionId of a child of another agent', + 'researcher', + { sessionId: 'subagent-old', topic: 'x' }, + { middleware: [storedChildren(oldChild)] }, + 'Session "subagent-old" belongs to agent "writer", not "researcher".', + ], + [ + 'a background that is not a boolean', + 'writer', + { background: 'true' }, + { binding: host }, + 'prompt and sessionId must be strings, and background a boolean.', + ], + [ + 'background with a sessionId', + 'writer', + { background: true, sessionId: 'subagent-old' }, + { middleware: [storedChildren(oldChild)], binding: host }, + 'background cannot continue a sessionId.', + ], + ] + it.each(toolErrors)( + 'returns a tool error for %s', + async (_label, agent, args, options, error) => { + const { chunks, result } = await callSubagent(agent, args, options) + + 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, + ) + }, + ) +}) diff --git a/testing/e2e/fixtures/subagents/session.json b/testing/e2e/fixtures/subagents/session.json new file mode 100644 index 000000000..17e8241d8 --- /dev/null +++ b/testing/e2e/fixtures/subagents/session.json @@ -0,0 +1,59 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "[subagent-session] take squid notes", + "sequenceIndex": 0 + }, + "response": { + "toolCalls": [ + { + "name": "researcher", + "arguments": "{\"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": "researcher", + "arguments": "{\"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 fe05f1889..2748d182f 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' | 'keys' /** The key the `keys` scenario passes in `subagents.keys`. */ @@ -38,6 +39,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', keys: '[subagent-keys] write a draft', } @@ -49,6 +51,7 @@ export function isSubagentScenario(value: unknown): value is SubagentScenario { value === 'brief' || value === 'result' || value === 'limits' || + value === 'session' || value === 'keys' ) } diff --git a/testing/e2e/src/routes/api.subagents-test.ts b/testing/e2e/src/routes/api.subagents-test.ts index fda6e1116..0e17b63a3 100644 --- a/testing/e2e/src/routes/api.subagents-test.ts +++ b/testing/e2e/src/routes/api.subagents-test.ts @@ -7,8 +7,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 { createOpenaiChat } from '@tanstack/ai-openai' import { openaiByok } from '@tanstack/ai-openai/byok' import { getByokKey } from '@tanstack/ai/byok/server' @@ -45,6 +51,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. 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. */ /** Send `Authorization` again as `x-e2e-authorization`, which aimock keeps. */ const echoAuthorization: typeof fetch = (input, init) => { @@ -163,6 +172,42 @@ function subagentsFor( return { agents: [child('researcher', [])] } } +/** + * 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. + */ +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: { @@ -194,6 +239,15 @@ export const Route = createFileRoute('/api/subagents-test')({ ...(params.parentRunId ? { parentRunId: params.parentRunId } : {}), ...(params.resume ? { resume: params.resume } : {}), subagents: subagentsFor(request, 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/src/routes/subagents-test.tsx b/testing/e2e/src/routes/subagents-test.tsx index 5d8e63c1e..717b07a20 100644 --- a/testing/e2e/src/routes/subagents-test.tsx +++ b/testing/e2e/src/routes/subagents-test.tsx @@ -71,6 +71,9 @@ function SubagentsTestPage() { {cards.map((card) => (

{card.status} + + {card.input === undefined ? '' : JSON.stringify(card.input)} +
    {describeParts(card.messages).map((line, index) => (
  • {line}
  • diff --git a/testing/e2e/tests/subagents.spec.ts b/testing/e2e/tests/subagents.spec.ts index 471417d58..90f3709e9 100644 --- a/testing/e2e/tests/subagents.spec.ts +++ b/testing/e2e/tests/subagents.spec.ts @@ -21,6 +21,7 @@ function open( | 'brief' | 'result' | 'limits' + | 'session' | 'keys', testId: string, aimockPort: number, @@ -161,6 +162,10 @@ test.describe('subagents', () => { await expect(page.getByTestId('card-researcher')).toContainText( 'text:Squids have three hearts and blue blood.', ) + // The card keeps the checked input the parent model wrote. + await expect(page.getByTestId('card-input-researcher')).toHaveText( + JSON.stringify({ task: '[subagent-brief-task] List two squid facts.' }), + ) }) test('a child whose run resolves to a string reports it to the parent', async ({ @@ -217,6 +222,32 @@ test.describe('subagents', () => { ).toBe(true) }) + test('an agent 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) + }) + test('subagents.keys gives the child its key', async ({ page, request,