@@ -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,