@@ -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 a63350bfc..0b4008797 100644
--- a/packages/ai/src/activities/chat/agents/bound.ts
+++ b/packages/ai/src/activities/chat/agents/bound.ts
@@ -15,6 +15,8 @@ import type { GenerationMiddleware } from '../../middleware/types'
import type { AnyChatMiddleware } from '../middleware/types'
import type { PromptCacheRetention, RunAgentResumeItem } from '../../../types'
import type { SubagentRunInput } from './define-agent'
+import type { SubagentBudget } from './limits'
+import type { ProviderKeys } from '../../../byok/keyed'
/**
* The fields a child `chat()` needs, in one spread:
@@ -51,11 +53,37 @@ export interface SubagentBinding {
* child's `ctx.chat({ subagents })` passes it down to its own children.
*/
generationMiddleware?: ReadonlyArray
+ /**
+ * The subagent tree budget. A child's `ctx.chat({ subagents })` passes it
+ * down, so limits hold across the whole tree.
+ */
+ budget?: SubagentBudget
+ /**
+ * The provider keys an agent reads as `ctx.keys`. `subagents.keys` sets
+ * them. A child's `ctx.chat({ subagents })` passes them down to its own
+ * children. Without them, `ctx.keys` reads each provider's `env` names.
+ */
+ keys?: ProviderKeys
/**
* The prompt cache retention of the parent call. A child's `ctx.chat()`
* 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 }>
}
/**
@@ -132,12 +160,14 @@ export function createBoundActivities(
...(binding?.promptCache ? { promptCache: binding.promptCache } : {}),
abortController,
...options,
- // Nested children get the host middleware before their own.
+ // Nested children get the host middleware before their own, share
+ // the tree budget, and get the host keys unless the call sets its own.
...(options.subagents
? {
subagents: {
...options.subagents,
binding: {
+ ...(binding?.keys ? { keys: binding.keys } : {}),
...options.subagents.binding,
chatMiddleware: [
...chatMiddleware,
@@ -147,6 +177,7 @@ export function createBoundActivities(
...generationMiddleware,
...(options.subagents.binding?.generationMiddleware ?? []),
],
+ ...(binding?.budget ? { budget: binding.budget } : {}),
},
},
}
diff --git a/packages/ai/src/activities/chat/agents/define-agent.ts b/packages/ai/src/activities/chat/agents/define-agent.ts
index df0a0c0ec..8edf16529 100644
--- a/packages/ai/src/activities/chat/agents/define-agent.ts
+++ b/packages/ai/src/activities/chat/agents/define-agent.ts
@@ -11,6 +11,7 @@ import type {
} from '../../../types'
import type { AnyClientTool } from '../tools/tool-definition'
import type { BoundActivities, SubagentForward } from './bound'
+import type { ProviderKeys } from '../../../byok/keyed'
/**
* What the library knows about a child run before `run` starts.
@@ -55,11 +56,16 @@ export interface SubagentRunInput<
* - The activity functions (`ctx.chat`, `ctx.generateImage`, and the rest)
* take the same options as the plain functions and fill in the thread id,
* a run id, the abort signal, and any middleware a host adds.
+ * - `keys` finds provider keys. `await ctx.keys.adapter(adapter)` builds a
+ * `keyedAdapter(...)`. The keys come from `subagents.keys`, else from each
+ * provider's `env` names. A missing key throws an error that names the env
+ * var.
*/
export type SubagentRunContext =
SubagentRunInput &
BoundActivities & {
forward: SubagentForward
+ keys: ProviderKeys
}
/**
@@ -115,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/limits.ts b/packages/ai/src/activities/chat/agents/limits.ts
new file mode 100644
index 000000000..7210880ec
--- /dev/null
+++ b/packages/ai/src/activities/chat/agents/limits.ts
@@ -0,0 +1,75 @@
+/**
+ * Limits for a whole subagent tree. The root run makes one budget, and every
+ * child shares it, so a child cannot reset it.
+ */
+export interface SubagentLimits {
+ /** How deep the tree may grow. The root run is depth 0. */
+ maxDepth?: number
+ /** How many children one run may have running at once. */
+ maxConcurrent?: number
+ /** How many children the whole tree may start. */
+ maxCalls?: number
+ /** How long one child may run. Never later than its parent's own deadline. */
+ timeoutMs?: number
+}
+
+/** The shared budget of one subagent tree. */
+export class SubagentBudget {
+ private constructor(
+ readonly limits: SubagentLimits,
+ /** The depth of the run that owns this budget view. */
+ readonly depth: number,
+ private readonly shared: { calls: number },
+ /** Epoch ms after which this run's children must stop, if any. */
+ readonly deadline: number | undefined,
+ ) {}
+
+ /** A budget for a root run. */
+ static root(limits: SubagentLimits = {}): SubagentBudget {
+ return new SubagentBudget(limits, 0, { calls: 0 }, undefined)
+ }
+
+ /** How many children the tree started so far. */
+ get calls(): number {
+ return this.shared.calls
+ }
+
+ /**
+ * Reserve one child spawn. Returns the refusal message the model sees, or
+ * `undefined` when the child may start. `active` is how many children this
+ * run has running now.
+ */
+ reserve(active: number): string | undefined {
+ const { maxDepth, maxCalls, maxConcurrent } = this.limits
+ if (maxDepth !== undefined && this.depth + 1 > maxDepth) {
+ return `subagent limit reached (maxDepth ${maxDepth})`
+ }
+ if (maxCalls !== undefined && this.shared.calls >= maxCalls) {
+ return `subagent limit reached (maxCalls ${maxCalls})`
+ }
+ if (maxConcurrent !== undefined && active >= maxConcurrent) {
+ return `subagent limit reached (maxConcurrent ${maxConcurrent})`
+ }
+ this.shared.calls += 1
+ return undefined
+ }
+
+ /** Milliseconds a child started now may run, or `undefined` for no limit. */
+ childTimeout(now = Date.now()): number | undefined {
+ const own = this.limits.timeoutMs
+ const remaining =
+ this.deadline === undefined ? undefined : Math.max(0, this.deadline - now)
+ if (own === undefined) return remaining
+ return remaining === undefined ? own : Math.min(own, remaining)
+ }
+
+ /** The budget a child's own subagents use: one level deeper, same counters. */
+ child(deadline?: number): SubagentBudget {
+ return new SubagentBudget(
+ this.limits,
+ this.depth + 1,
+ this.shared,
+ deadline ?? this.deadline,
+ )
+ }
+}
diff --git a/packages/ai/src/activities/chat/agents/spawn.ts b/packages/ai/src/activities/chat/agents/spawn.ts
index 11e0e8ec6..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,10 +37,15 @@ import type {
SubagentStartedEvent,
TokenUsage,
Tool,
+ ToolExecutionContext,
UIMessage,
} from '../../../types'
+import { envProviderKeys } from '../../../byok/env-keys'
import { createBoundActivities } from './bound'
+import { SubagentBudget } from './limits'
import type { SubagentBinding } from './bound'
+import type { SubagentLimits } from './limits'
+import type { ProviderKeySource } from '../../../byok/env-keys'
import type {
DefinedAgent,
SubagentRunContext,
@@ -87,6 +105,78 @@ export interface SubagentsBag<
* (`ctx.chat`, `ctx.generateImage`, and the rest). Apps do not set it.
*/
binding?: SubagentBinding
+ /**
+ * Limits for the whole tree of children the model starts through tools:
+ * depth, total calls, children at once, and time per child. A refused
+ * start returns to the model as a tool error. Only the root chat's limits
+ * apply: a nested chat in a child uses the root's budget.
+ */
+ limits?: SubagentLimits
+ /**
+ * Provider keys that agents read as `ctx.keys`: an object by provider id,
+ * for example `{ openai: userKey }`, or a function that returns the key of
+ * a provider, for example `(provider) => getByokKey(request, provider)`.
+ * A missing or empty key falls back to the keys of the parent agent, then
+ * to the provider's `env` names. A child's `ctx.chat({ subagents })` passes
+ * them on. The keys it sets itself win for those providers.
+ */
+ 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. */
@@ -107,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. */
@@ -236,17 +333,42 @@ function agentByName(agents: ReadonlyArray, name: string) {
return agent
}
+/** The root budget of each bag that has no budget from its parent. */
+const rootBudgets = new WeakMap()
+
+/**
+ * The subagent tree budget of `bag`: the one its parent passed down, else
+ * one root budget per bag. The `limits` of a bag apply only at the root.
+ */
+function treeBudget(bag: SubagentsBag) {
+ const inherited = bag.binding?.budget
+ if (inherited) return inherited
+ let budget = rootBudgets.get(bag)
+ if (!budget) {
+ budget = SubagentBudget.root(bag.limits)
+ rootBudgets.set(bag, budget)
+ }
+ return budget
+}
+
function openAgentStream(
entry: SpawnEntry,
bag: SubagentsBag,
ctx: SpawnContext,
sink?: SubagentSink,
parentToolCallId?: string,
- binding: SubagentBinding | undefined = bag.binding,
+ // A child that a router picks does not count against the budget, but its
+ // own children do, one level deeper.
+ binding: SubagentBinding = {
+ ...bag.binding,
+ budget: treeBudget(bag).child(),
+ },
) {
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) {
@@ -254,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,
}
@@ -266,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),
@@ -494,8 +635,8 @@ function* valueResultChunks(
}
/**
- * Build the context `run` receives: the spawn input plus `forward` and the
- * bound activity functions.
+ * Build the context `run` receives: the spawn input plus `forward`, the
+ * provider keys, and the bound activity functions.
*/
function runContext(
agentName: string,
@@ -518,6 +659,7 @@ function runContext(
abortController,
...(binding?.promptCache ? { promptCache: binding.promptCache } : {}),
},
+ keys: binding?.keys ?? envProviderKeys,
...createBoundActivities(agentName, input, abortController, binding),
} satisfies SubagentRunContext
}
@@ -541,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,
@@ -549,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)
@@ -800,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(
@@ -819,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,
}
}
@@ -839,108 +1115,225 @@ 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.
+ const budget = treeBudget(bag)
+ let active = 0
+
+ /** Run one child for a tool call. The model gets its text or result. */
+ async function runChild(child: SpawnEntry, context: unknown) {
+ const toolContext = context as
+ | {
+ toolCallId?: string
+ [EMIT_STREAM_CHUNK]?: (chunk: StreamChunk) => void
+ }
+ | undefined
+ const toolCallId = toolContext?.toolCallId
+ const suspended = parent.turn?.children.find(
+ (turnChild) =>
+ turnChild.status === 'suspended' &&
+ turnChild.parentToolCallId !== undefined &&
+ turnChild.parentToolCallId === toolCallId,
+ )
+ const entry: SpawnEntry = {
+ ...child,
+ ...(suspended && {
+ resume: {
+ subagentRunId: suspended.subagentRunId,
+ messages: suspended.messages,
+ entries: suspended.resume,
+ text: suspended.text,
+ },
+ }),
+ }
+ const timeout = budget.childTimeout()
+ // A resumed child already counted when it first started. A parent with
+ // no time left starts no child.
+ const refusal = suspended
+ ? undefined
+ : timeout === 0
+ ? `subagent limit reached (timeoutMs ${budget.limits.timeoutMs ?? 0})`
+ : budget.reserve(active)
+ if (refusal !== undefined) {
+ return {
+ subagentRunId: '',
+ text: '',
+ error: refusal,
+ } satisfies SubagentToolOutcome
+ }
+ active += 1
+ const sink = createSubagentSink()
+ const link = linkAbort(parent.abortSignal)
+ let timedOut = false
+ const timer =
+ timeout === undefined
+ ? undefined
+ : setTimeout(() => {
+ timedOut = true
+ link.controller.abort(
+ new Error(`subagent timed out after ${timeout} ms`),
+ )
+ }, timeout)
+ const childBinding: SubagentBinding = {
+ ...bag.binding,
+ budget: budget.child(
+ timeout === undefined ? undefined : Date.now() + timeout,
+ ),
+ }
+ let subagentRunId = suspended?.subagentRunId ?? ''
+ let text = suspended?.text ?? ''
+ let error: string | undefined
+ let result: unknown
+ try {
+ for await (const chunk of openAgentStream(
+ entry,
+ bag,
+ {
+ messages: parent.messagesFor?.(toolCallId) ?? parent.messages,
+ abortSignal: link.controller.signal,
+ threadId: parent.threadId,
+ parentRunId: parent.runId,
+ ...(parent.interruptedRunId !== undefined
+ ? { interruptedRunId: parent.interruptedRunId }
+ : {}),
+ ...(parent.parentSubagentRunId !== undefined
+ ? { parentSubagentRunId: parent.parentSubagentRunId }
+ : {}),
+ },
+ sink,
+ toolCallId,
+ childBinding,
+ )) {
+ if (chunk.type === SUBAGENT_STARTED && subagentRunId === '') {
+ subagentRunId = chunk.subagentRunId
+ }
+ if (
+ chunk.type === EventType.TEXT_MESSAGE_CONTENT &&
+ 'subagentRunId' in chunk &&
+ chunk.subagentRunId === subagentRunId
+ ) {
+ text += chunk.delta
+ }
+ if (
+ chunk.type === SUBAGENT_ERROR &&
+ chunk.subagentRunId === subagentRunId
+ ) {
+ error = chunk.message
+ }
+ if (
+ chunk.type === SUBAGENT_FINISHED &&
+ chunk.subagentRunId === subagentRunId &&
+ chunk.result !== undefined
+ ) {
+ result = chunk.result
+ }
+ toolContext?.[EMIT_STREAM_CHUNK]?.(chunk)
+ }
+ } finally {
+ active -= 1
+ if (timer !== undefined) clearTimeout(timer)
+ link.dispose()
+ }
+ if (timedOut) error = `subagent timed out after ${timeout} ms`
+ parent.sink.usage.push(...sink.usage)
+ if (sink.total) {
+ parent.sink.total = parent.sink.total
+ ? addTokenUsage(parent.sink.total, sink.total)
+ : sink.total
+ }
+ return {
+ subagentRunId,
+ text,
+ ...(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.
- execute: async (input: unknown, context?: unknown) => {
- const toolContext = context as
- | {
- toolCallId?: string
- [EMIT_STREAM_CHUNK]?: (chunk: StreamChunk) => void
- }
- | undefined
- const toolCallId = toolContext?.toolCallId
- const suspended = parent.turn?.children.find(
- (child) =>
- child.status === 'suspended' &&
- child.parentToolCallId !== undefined &&
- child.parentToolCallId === toolCallId,
- )
- // An agent without `inputSchema` still gets `{}` from the model. Its
- // `ctx.input` stays undefined.
- const entry: SpawnEntry = {
- name: agent.name,
- ...(agent.inputSchema !== undefined && { input }),
- ...(suspended && {
- resume: {
- subagentRunId: suspended.subagentRunId,
- messages: suspended.messages,
- entries: suspended.resume,
- text: suspended.text,
- },
- }),
+ 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 sink = createSubagentSink()
- const link = linkAbort(parent.abortSignal)
- let subagentRunId = suspended?.subagentRunId ?? ''
- let text = suspended?.text ?? ''
- let error: string | undefined
- let result: unknown
- try {
- for await (const chunk of openAgentStream(
- entry,
- bag,
+ 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(
{
- messages: parent.messagesFor?.(toolCallId) ?? parent.messages,
- abortSignal: link.controller.signal,
- threadId: parent.threadId,
- parentRunId: parent.runId,
- ...(parent.interruptedRunId !== undefined
- ? { interruptedRunId: parent.interruptedRunId }
- : {}),
- ...(parent.parentSubagentRunId !== undefined
- ? { parentSubagentRunId: parent.parentSubagentRunId }
- : {}),
+ agent: agent.name,
+ ...fields,
+ ...(context?.toolCallId !== undefined && {
+ parentToolCallId: context.toolCallId,
+ }),
},
- sink,
- toolCallId,
- )) {
- if (chunk.type === SUBAGENT_STARTED && subagentRunId === '') {
- subagentRunId = chunk.subagentRunId
- }
- if (
- chunk.type === EventType.TEXT_MESSAGE_CONTENT &&
- 'subagentRunId' in chunk &&
- chunk.subagentRunId === subagentRunId
- ) {
- text += chunk.delta
- }
- if (
- chunk.type === SUBAGENT_ERROR &&
- chunk.subagentRunId === subagentRunId
- ) {
- error = chunk.message
- }
- if (
- chunk.type === SUBAGENT_FINISHED &&
- chunk.subagentRunId === subagentRunId &&
- chunk.result !== undefined
- ) {
- result = chunk.result
- }
- toolContext?.[EMIT_STREAM_CHUNK]?.(chunk)
- }
- } finally {
- link.dispose()
- }
- parent.sink.usage.push(...sink.usage)
- if (sink.total) {
- parent.sink.total = parent.sink.total
- ? addTokenUsage(parent.sink.total, sink.total)
- : sink.total
+ { wake: true },
+ )
+ return {
+ subagentRunId,
+ text: '',
+ result: { status: 'started' },
+ keepRunId: true,
+ } satisfies SubagentToolOutcome
}
- return {
- subagentRunId,
- text,
- ...(result !== undefined ? { result } : {}),
- ...(error !== undefined ? { error } : {}),
- ...(sink.interrupts.length > 0 ? { interrupts: sink.interrupts } : {}),
- } 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 3ba9ab9bd..691eca150 100644
--- a/packages/ai/src/activities/chat/index.ts
+++ b/packages/ai/src/activities/chat/index.ts
@@ -62,7 +62,9 @@ import { transformMessagesForReplay } from '../../utilities/replay-messages'
import { LazyToolManager } from './tools/lazy-tool-manager'
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,
@@ -113,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'
@@ -5650,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
@@ -5902,11 +5911,20 @@ async function* streamTextChunks(
engineRef: DeliveryEngineRef,
): AsyncIterable {
// Children inherit the retention of this call. Their key is their own
- // threadId.
+ // threadId. The keys of this call win over the keys of a parent agent,
+ // one provider at a time.
const bag = options.subagents && {
...options.subagents,
binding: {
...options.subagents.binding,
+ ...(options.subagents.keys
+ ? {
+ keys: providerKeys(
+ options.subagents.keys,
+ options.subagents.binding?.keys,
+ ),
+ }
+ : {}),
promptCache:
options.subagents.binding?.promptCache ?? options.promptCache.retention,
},
@@ -5930,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 && {
@@ -6313,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/activities/index.ts b/packages/ai/src/activities/index.ts
index 5e9322e87..cb8568b91 100644
--- a/packages/ai/src/activities/index.ts
+++ b/packages/ai/src/activities/index.ts
@@ -53,6 +53,7 @@ export type {
SubagentBinding,
SubagentForward,
} from './chat/agents/bound'
+export { SubagentBudget, type SubagentLimits } from './chat/agents/limits'
export { subagentRoute, type SubagentRouteOptions } from './chat/agents/route'
export type {
SubagentOrder,
diff --git a/packages/ai/src/byok.ts b/packages/ai/src/byok.ts
index 550a12891..010672f55 100644
--- a/packages/ai/src/byok.ts
+++ b/packages/ai/src/byok.ts
@@ -16,4 +16,8 @@ export {
} from './byok/errors'
export { maskKey, scrubSecrets } from './byok/scrub'
export { keyedAdapter, keyedAdapters, isKeyedAdapter } from './byok/keyed'
-export type { KeyedAdapter, KeyedAdapterResult } from './byok/keyed'
+export type {
+ KeyedAdapter,
+ KeyedAdapterResult,
+ ProviderKeys,
+} from './byok/keyed'
diff --git a/packages/ai/src/byok/env-keys.ts b/packages/ai/src/byok/env-keys.ts
new file mode 100644
index 000000000..ef068002f
--- /dev/null
+++ b/packages/ai/src/byok/env-keys.ts
@@ -0,0 +1,66 @@
+import { ByokMissingError } from './errors'
+import { envKey } from './get-key'
+import { isKeyedAdapter } from './keyed'
+import { resolveProviderId } from './providers'
+import type { ByokProvider } from './define-provider'
+import type { ProviderKeys } from './keyed'
+import type { ProviderId } from './providers'
+
+function missingKeyMessage(provider: ByokProvider | ProviderId) {
+ if (typeof provider === 'string') return `Missing ${provider} API key.`
+ const names = provider.env ?? []
+ const hint = names.length > 0 ? ` Set ${names.join(' or ')}.` : ''
+ return `Missing ${provider.label} API key.${hint}`
+}
+
+/**
+ * Where `subagents.keys` finds a key: an object by provider id, or a function
+ * that gets the provider (a BYOK descriptor or an id) and returns its key.
+ */
+export type ProviderKeySource =
+ | Readonly>
+ | ((
+ provider: ByokProvider | ProviderId,
+ ) => string | null | undefined | Promise)
+
+/**
+ * The `ctx.keys` of an agent: `keys`, else `fallback` (the keys of a parent
+ * agent), else each provider's `env` names. Not in `@tanstack/ai/byok`, so
+ * the client entry has no env access.
+ */
+export function providerKeys(
+ keys: ProviderKeySource = {},
+ fallback?: ProviderKeys,
+): ProviderKeys {
+ const get = async (provider: ByokProvider | ProviderId) => {
+ const own =
+ typeof keys === 'function'
+ ? await keys(provider)
+ : keys[resolveProviderId(provider)]
+ return (
+ own ||
+ (fallback ? await fallback.get(provider) : envKey(provider)) ||
+ null
+ )
+ }
+ const require = async (provider: ByokProvider | ProviderId) => {
+ const key = await get(provider)
+ if (!key) {
+ const error = new ByokMissingError(resolveProviderId(provider))
+ error.message = missingKeyMessage(provider)
+ throw error
+ }
+ return key
+ }
+ return {
+ get,
+ require,
+ adapter: async (adapter) =>
+ isKeyedAdapter(adapter)
+ ? adapter.create(await require(adapter.provider))
+ : adapter,
+ }
+}
+
+/** The `ctx.keys` of an agent with no keys set: each provider's `env` names. */
+export const envProviderKeys = providerKeys()
diff --git a/packages/ai/src/byok/get-key.ts b/packages/ai/src/byok/get-key.ts
index dec1a5b66..8b2318c16 100644
--- a/packages/ai/src/byok/get-key.ts
+++ b/packages/ai/src/byok/get-key.ts
@@ -3,6 +3,21 @@ import type { KeyedAdapter, KeyedAdapterResult } from './keyed'
import type { ByokProvider } from './define-provider'
import type { ProviderId } from './providers'
+/**
+ * Read `provider.env` in order and return the first value that is set. A
+ * slug has no env names. Where `process` is missing (a browser) it returns
+ * `null`.
+ */
+export function envKey(provider: ProviderId | ByokProvider) {
+ if (typeof provider === 'string') return null
+ const env = globalThis.process?.env
+ for (const name of provider.env ?? []) {
+ const value = env?.[name]
+ if (typeof value === 'string' && value.length > 0) return value
+ }
+ return null
+}
+
/**
* Read a key on the relay. Import from `@tanstack/ai/byok/server` so this
* `process.env` access is not in the client graph.
@@ -19,12 +34,7 @@ export function getByokKey(
const trimmed = value.trim()
if (trimmed.length > 0) return trimmed
}
- if (typeof provider === 'string') return null
- for (const name of provider.env ?? []) {
- const envValue = process.env[name]
- if (typeof envValue === 'string' && envValue.length > 0) return envValue
- }
- return null
+ return envKey(provider)
}
/**
diff --git a/packages/ai/src/byok/keyed.ts b/packages/ai/src/byok/keyed.ts
index 51ec7c099..f0c4b5d42 100644
--- a/packages/ai/src/byok/keyed.ts
+++ b/packages/ai/src/byok/keyed.ts
@@ -17,6 +17,25 @@ export interface KeyedAdapter {
readonly create: (key: string) => TAdapter
}
+/**
+ * Finds provider keys for a run. An agent reads it as `ctx.keys`. A host sets
+ * it on the binding and reads each user's own keys. Without a host,
+ * `ctx.keys` reads each provider's `env` names.
+ */
+export interface ProviderKeys {
+ /** The key for `provider`, or `null` when there is none. */
+ get: (provider: ByokProvider | ProviderId) => Promise
+ /** The key for `provider`. Throws when there is none. */
+ require: (provider: ByokProvider | ProviderId) => Promise
+ /**
+ * The adapter to call. A plain adapter comes back unchanged. A
+ * {@link KeyedAdapter} is built with the key from `require`.
+ */
+ adapter: (
+ adapter: TAdapter | KeyedAdapter,
+ ) => Promise
+}
+
/**
* Wrap an adapter factory that needs a provider key. The host finds the key
* and calls `create` just before each call, so the key is not in your code
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 8a6514f44..45985eb4f 100644
--- a/packages/ai/src/index.ts
+++ b/packages/ai/src/index.ts
@@ -99,13 +99,19 @@ export type {
SubagentBinding,
SubagentForward,
} from './activities/chat/agents/bound'
+export {
+ SubagentBudget,
+ type SubagentLimits,
+} from './activities/chat/agents/limits'
export {
keyedAdapter,
keyedAdapters,
isKeyedAdapter,
type KeyedAdapter,
type KeyedAdapterResult,
+ type ProviderKeys,
} from './byok/keyed'
+export type { ProviderKeySource } from './byok/env-keys'
export {
subagentRoute,
type SubagentRouteOptions,
@@ -377,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).
@@ -569,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/provider-keys.test.ts b/packages/ai/tests/provider-keys.test.ts
index 2bf0285b2..4ec9eac8b 100644
--- a/packages/ai/tests/provider-keys.test.ts
+++ b/packages/ai/tests/provider-keys.test.ts
@@ -7,7 +7,15 @@ import {
keyedAdapters,
} from '../src/byok'
import { getByokKey, keyedAdapterFromRequest } from '../src/byok/server'
-import { createMockAdapter } from './test-utils'
+import { defineAgent } from '../src/activities/chat/agents/define-agent'
+import { spawnAgentStream } from '../src/activities/chat/agents/spawn'
+import { chat } from '../src/activities/chat'
+import { collectChunks, createMockAdapter } from './test-utils'
+import type { AnyTextAdapter } from '../src/activities/chat/adapter'
+import type { SubagentBinding } from '../src/activities/chat/agents/bound'
+import type { DefinedAgent } from '../src/activities/chat/agents/define-agent'
+import type { KeyedAdapter, ProviderKeys } from '../src/byok'
+import type { ProviderKeySource } from '../src/byok/env-keys'
const acme = defineByokProvider({ id: 'acme', label: 'Acme' })
@@ -102,3 +110,262 @@ describe('keyedAdapters', () => {
expect(built).toEqual({ kind: 'image', key: 'sk-user' })
})
})
+
+const envName = 'TANSTACK_AI_PROVIDER_KEYS_TEST'
+const acmeEnv = defineByokProvider({ id: 'acme', label: 'Acme', env: envName })
+
+const input = {
+ input: undefined,
+ messages: [],
+ threadId: 'thread-1',
+ runId: 'run-1',
+ parentRunId: 'parent-1',
+ subagentRunId: 'child-1',
+}
+
+/** Provider keys a host would pass: every provider gets `key`. */
+function hostKeys(key: string): ProviderKeys {
+ return {
+ get: async () => key,
+ require: async () => key,
+ adapter: async (adapter) =>
+ isKeyedAdapter(adapter) ? adapter.create(key) : adapter,
+ }
+}
+
+/** An agent that records the `ctx.keys` its run gets. */
+function probe() {
+ const seen: Array = []
+ const agent = defineAgent({
+ name: 'probe',
+ description: 'Records ctx.keys',
+ run: (ctx) => {
+ seen.push(ctx.keys)
+ return Promise.resolve('ok')
+ },
+ })
+ return { agent, seen }
+}
+
+/** Run `agent` the way a host does, outside a parent chat turn. */
+function run(agent: DefinedAgent, binding?: SubagentBinding) {
+ return collectChunks(
+ spawnAgentStream(agent, input, undefined, undefined, binding),
+ )
+}
+
+/** The `ctx.keys` of one probe run. */
+async function runKeys(binding?: SubagentBinding) {
+ const { agent, seen } = probe()
+ await run(agent, binding)
+ const keys = seen[0]
+ if (!keys) throw new Error('the probe did not run')
+ return keys
+}
+
+/**
+ * The `ctx.keys` of a probe that a parent agent starts through its own
+ * `ctx.chat({ subagents })`. `own` is the binding that nested chat sets.
+ */
+async function nestedKeys(host: SubagentBinding, own?: SubagentBinding) {
+ const { agent: child, seen } = probe()
+ const { adapter } = createMockAdapter({ iterations: [] })
+ const lead = defineAgent({
+ name: 'lead',
+ description: 'Starts the probe',
+ run: (ctx) =>
+ ctx.chat({
+ adapter,
+ subagents: {
+ agents: [child],
+ router: () => 'probe',
+ ...(own ? { binding: own } : {}),
+ },
+ }),
+ })
+ await run(lead, host)
+ return seen[0]
+}
+
+describe('ctx.keys', () => {
+ const acmeEnvImage = keyedAdapter(acmeEnv, (key) => ({
+ kind: 'image' as const,
+ key,
+ }))
+
+ afterEach(() => vi.unstubAllEnvs())
+
+ it('is the provider keys of the binding', async () => {
+ const keys = hostKeys('sk-host')
+ expect(await runKeys({ keys })).toBe(keys)
+ })
+
+ it('reads the provider env var when the host sets no keys', async () => {
+ vi.stubEnv(envName, 'sk-env')
+ const keys = await runKeys()
+ expect(await keys.get(acmeEnv)).toBe('sk-env')
+ expect(await keys.require(acmeEnv)).toBe('sk-env')
+ // A bare provider id has no env names.
+ expect(await keys.get('acme')).toBeNull()
+ })
+
+ it('throws an error that names the env var when the key is missing', async () => {
+ const keys = await runKeys()
+ expect(await keys.get(acmeEnv)).toBeNull()
+ await expect(keys.require(acmeEnv)).rejects.toThrow(
+ `Missing Acme API key. Set ${envName}.`,
+ )
+ await expect(keys.require('acme')).rejects.toThrow('Missing acme API key.')
+ })
+
+ it('returns no key where process is missing', async () => {
+ const keys = await runKeys()
+ vi.stubGlobal('process', undefined)
+ // `get` reads the env before its first await, so the stub is gone
+ // before vitest needs `process` again.
+ const pending = keys.get(acmeEnv)
+ vi.unstubAllGlobals()
+ expect(await pending).toBeNull()
+ })
+
+ it('builds a keyed adapter with the key and returns a plain adapter unchanged', async () => {
+ vi.stubEnv(envName, 'sk-env')
+ const keys = await runKeys()
+ const { adapter: text } = createMockAdapter({})
+ const choice: AnyTextAdapter | KeyedAdapter = keyedAdapter(
+ acmeEnv,
+ () => text,
+ )
+
+ expect(await keys.adapter(acmeEnvImage)).toEqual({
+ kind: 'image',
+ key: 'sk-env',
+ })
+ expect(await keys.adapter(text)).toBe(text)
+ expect(await keys.adapter(choice)).toBe(text)
+ expectTypeOf(keys.adapter(acmeEnvImage)).resolves.toEqualTypeOf<{
+ kind: 'image'
+ key: string
+ }>()
+ expectTypeOf(keys.adapter(choice)).resolves.toEqualTypeOf()
+ })
+
+ it('gives a nested child the keys of the parent binding', async () => {
+ const keys = hostKeys('sk-host')
+ expect(await nestedKeys({ keys })).toBe(keys)
+ })
+
+ it('lets a nested chat pass its own keys', async () => {
+ const own = hostKeys('sk-own')
+ expect(await nestedKeys({ keys: hostKeys('sk-host') }, { keys: own })).toBe(
+ own,
+ )
+ })
+})
+
+/**
+ * The `ctx.keys` of a probe that `chat({ subagents: { keys } })` starts.
+ * With `nested`, a lead agent starts the probe through its own
+ * `ctx.chat({ subagents: { ...nested } })`.
+ */
+async function chatKeys(
+ keys: ProviderKeySource,
+ nested?: { keys?: ProviderKeySource },
+) {
+ const { agent: target, seen } = probe()
+ const { adapter } = createMockAdapter({ iterations: [] })
+ const lead = defineAgent({
+ name: 'lead',
+ description: 'Starts the probe',
+ run: (ctx) =>
+ ctx.chat({
+ adapter,
+ subagents: { agents: [target], router: () => 'probe', ...nested },
+ }),
+ })
+ const agents: Array = nested ? [lead] : [target]
+ const name = nested ? 'lead' : 'probe'
+ await collectChunks(
+ chat({
+ adapter,
+ messages: [{ role: 'user', content: 'Go' }],
+ subagents: { agents, router: () => name, keys },
+ }),
+ )
+ const found = seen[0]
+ if (!found) throw new Error('the probe did not run')
+ return found
+}
+
+describe('subagents.keys', () => {
+ afterEach(() => vi.unstubAllEnvs())
+
+ it('gives agents the key of each provider id', async () => {
+ vi.stubEnv(envName, 'sk-env')
+ const keys = await chatKeys({ acme: 'sk-user' })
+ const image = keyedAdapter(acmeEnv, (key) => ({ kind: 'image', key }))
+
+ expect(await keys.require(acmeEnv)).toBe('sk-user')
+ expect(await keys.get('acme')).toBe('sk-user')
+ expect(await keys.adapter(image)).toEqual({ kind: 'image', key: 'sk-user' })
+ })
+
+ it('falls back to the env var for a missing or null key', async () => {
+ vi.stubEnv(envName, 'sk-env')
+ expect(await (await chatKeys({ acme: null })).require(acmeEnv)).toBe(
+ 'sk-env',
+ )
+ expect(await (await chatKeys({ other: 'sk-other' })).get(acmeEnv)).toBe(
+ 'sk-env',
+ )
+ })
+
+ it('throws an error that names the env var when no key is found', async () => {
+ const keys = await chatKeys({ acme: '' })
+ await expect(keys.require(acmeEnv)).rejects.toThrow(
+ `Missing Acme API key. Set ${envName}.`,
+ )
+ })
+
+ it('gives a nested child the keys of the chat', async () => {
+ const keys = await chatKeys({ acme: 'sk-user' }, {})
+ expect(await keys.require(acmeEnv)).toBe('sk-user')
+ })
+
+ it('lets a nested chat set its own keys', async () => {
+ const keys = await chatKeys(
+ { acme: 'sk-user' },
+ { keys: { acme: 'sk-own' } },
+ )
+ expect(await keys.require(acmeEnv)).toBe('sk-own')
+ })
+
+ it('reads keys from a function, with the provider it gets', async () => {
+ vi.stubEnv(envName, 'sk-env')
+ const asked: Array = []
+ const keys = await chatKeys(async (provider) => {
+ asked.push(provider)
+ return provider === acmeEnv ? 'sk-fn' : null
+ })
+
+ expect(await keys.require(acmeEnv)).toBe('sk-fn')
+ expect(asked).toEqual([acmeEnv])
+ // The function has no key for a bare id, so the env name is not read.
+ expect(await keys.get('acme')).toBeNull()
+ })
+
+ it('falls back to the parent keys when a nested function has no key', async () => {
+ const keys = await chatKeys({ acme: 'sk-user' }, { keys: () => null })
+ expect(await keys.require(acmeEnv)).toBe('sk-user')
+ })
+
+ it('keeps the parent keys for providers that a nested chat does not set', async () => {
+ vi.stubEnv(envName, 'sk-env')
+ const keys = await chatKeys(
+ { acme: 'sk-user' },
+ { keys: { other: 'sk-other' } },
+ )
+ expect(await keys.require(acmeEnv)).toBe('sk-user')
+ expect(await keys.get('other')).toBe('sk-other')
+ })
+})
diff --git a/packages/ai/tests/subagent-limits.test.ts b/packages/ai/tests/subagent-limits.test.ts
new file mode 100644
index 000000000..9e74ad74c
--- /dev/null
+++ b/packages/ai/tests/subagent-limits.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, it, vi } from 'vitest'
+import { defineAgent } from '../src/activities/chat/agents/define-agent'
+import { SubagentBudget } from '../src/activities/chat/agents/limits'
+import { chat } from '../src/activities/chat'
+import { collectChunks, createMockAdapter, ev } from './test-utils'
+import type { StreamChunk } from '../src/types'
+
+/** A model that calls `name` once for each id, all in one turn, then stops. */
+function callsInOneTurn(name: string, ids: Array) {
+ return createMockAdapter({
+ iterations: [
+ [
+ ev.runStarted(),
+ ...ids.flatMap((id) => [ev.toolStart(id, name), ev.toolArgs(id, '{}')]),
+ ev.runFinished('tool_calls'),
+ ],
+ [ev.runStarted(), ev.runFinished('stop')],
+ ],
+ })
+}
+
+function results(chunks: Array): Record {
+ const out: Record = {}
+ for (const chunk of chunks) {
+ if (
+ chunk.type === 'TOOL_CALL_RESULT' &&
+ typeof chunk.content === 'string'
+ ) {
+ out[chunk.toolCallId] = JSON.parse(chunk.content)
+ }
+ }
+ return out
+}
+
+describe('SubagentBudget', () => {
+ it('counts calls across the tree and checks depth', () => {
+ const root = SubagentBudget.root({ maxCalls: 2, maxDepth: 1 })
+ expect(root.reserve(0)).toBeUndefined()
+ const child = root.child()
+ expect(child.reserve(0)).toBe('subagent limit reached (maxDepth 1)')
+ expect(root.reserve(0)).toBeUndefined()
+ expect(root.reserve(0)).toBe('subagent limit reached (maxCalls 2)')
+ expect(child.calls).toBe(2)
+ })
+
+ it('never gives a child more time than its parent has left', () => {
+ const root = SubagentBudget.root({ timeoutMs: 10_000 })
+ expect(root.childTimeout(0)).toBe(10_000)
+ const child = root.child(5_000)
+ expect(child.childTimeout(1_000)).toBe(4_000)
+ })
+})
+
+describe('subagent limits', () => {
+ it('refuses children past maxCalls with a tool error', async () => {
+ const run = vi.fn(async () => 'done')
+ const worker = defineAgent({ name: 'worker', description: 'Works', run })
+ const { adapter } = callsInOneTurn('worker', ['c1', 'c2', 'c3'])
+ const chunks = await collectChunks(
+ chat({
+ adapter,
+ messages: [{ role: 'user', content: 'Go' }],
+ subagents: { agents: [worker], limits: { maxCalls: 2 } },
+ }) as AsyncIterable,
+ )
+ const byId = results(chunks)
+ const refused = Object.values(byId).filter(
+ (result) => result.error !== undefined,
+ )
+ expect(refused).toHaveLength(1)
+ expect(refused[0].error).toBe('subagent limit reached (maxCalls 2)')
+ expect(run).toHaveBeenCalledTimes(2)
+ })
+
+ it('passes the budget to nested children through ctx.chat', async () => {
+ const grandchildRun = vi.fn(async () => 'deep')
+ const grandchild = defineAgent({
+ name: 'grandchild',
+ description: 'Deep',
+ run: grandchildRun,
+ })
+ const child = defineAgent({
+ name: 'child',
+ description: 'Calls a grandchild',
+ run: (ctx) =>
+ ctx.chat({
+ adapter: callsInOneTurn('grandchild', ['g1']).adapter,
+ subagents: { agents: [grandchild] },
+ }),
+ })
+ const { adapter } = callsInOneTurn('child', ['c1'])
+ const chunks = await collectChunks(
+ chat({
+ adapter,
+ messages: [{ role: 'user', content: 'Go' }],
+ subagents: { agents: [child], limits: { maxDepth: 1 } },
+ }) as AsyncIterable,
+ )
+ expect(grandchildRun).not.toHaveBeenCalled()
+ const grandchildResult = chunks.find(
+ (chunk) => chunk.type === 'TOOL_CALL_RESULT' && chunk.toolCallId === 'g1',
+ )
+ expect(JSON.stringify(grandchildResult)).toContain('maxDepth 1')
+ })
+
+ it('stops a child that runs past timeoutMs', async () => {
+ const slow = defineAgent({
+ name: 'slow',
+ description: 'Never finishes on its own',
+ run: (ctx) =>
+ new Promise((resolve) => {
+ ctx.abortSignal?.addEventListener('abort', () => resolve('stopped'))
+ }),
+ })
+ const { adapter } = callsInOneTurn('slow', ['c1'])
+ const chunks = await collectChunks(
+ chat({
+ adapter,
+ messages: [{ role: 'user', content: 'Go' }],
+ subagents: { agents: [slow], limits: { timeoutMs: 20 } },
+ }) as AsyncIterable,
+ )
+ expect(results(chunks).c1.error).toBe('subagent timed out after 20 ms')
+ })
+
+ it('refuses children past maxConcurrent in one turn', async () => {
+ let release!: () => void
+ const gate = new Promise((resolve) => {
+ release = resolve
+ })
+ const waiting = defineAgent({
+ name: 'waiting',
+ description: 'Waits',
+ run: async () => {
+ await gate
+ return 'ok'
+ },
+ })
+ setTimeout(() => release(), 20)
+ const { adapter } = callsInOneTurn('waiting', ['c1', 'c2', 'c3'])
+ const chunks = await collectChunks(
+ chat({
+ adapter,
+ messages: [{ role: 'user', content: 'Go' }],
+ subagents: { agents: [waiting], limits: { maxConcurrent: 2 } },
+ toolExecution: 'parallel',
+ }) as AsyncIterable,
+ )
+ const errors = Object.values(results(chunks))
+ .map((result) => result.error)
+ .filter(Boolean)
+ // The three calls start together. The third finds two running.
+ expect(errors).toEqual(['subagent limit reached (maxConcurrent 2)'])
+ })
+
+ it('counts the children of a routed child against the root limits', async () => {
+ const grandchildRun = vi.fn(async () => 'deep')
+ const grandchild = defineAgent({
+ name: 'grandchild',
+ description: 'Deep',
+ run: grandchildRun,
+ })
+ const child = defineAgent({
+ name: 'child',
+ description: 'Calls a grandchild',
+ run: (ctx) =>
+ ctx.chat({
+ adapter: callsInOneTurn('grandchild', ['g1']).adapter,
+ subagents: { agents: [grandchild] },
+ }),
+ })
+ const chunks = await collectChunks(
+ chat({
+ adapter: createMockAdapter({ iterations: [] }).adapter,
+ messages: [{ role: 'user', content: 'Go' }],
+ subagents: {
+ agents: [child],
+ router: () => 'child',
+ limits: { maxDepth: 1 },
+ },
+ }) as AsyncIterable,
+ )
+ expect(grandchildRun).not.toHaveBeenCalled()
+ expect(JSON.stringify(chunks)).toContain('maxDepth 1')
+ })
+})
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/basic.json b/testing/e2e/fixtures/subagents/basic.json
index 284943524..df15dad37 100644
--- a/testing/e2e/fixtures/subagents/basic.json
+++ b/testing/e2e/fixtures/subagents/basic.json
@@ -140,6 +140,72 @@
"response": {
"content": "Pricing is ready."
}
+ },
+ {
+ "match": {
+ "userMessage": "[subagent-limits] research two topics",
+ "sequenceIndex": 0
+ },
+ "response": {
+ "toolCalls": [
+ {
+ "name": "researcher",
+ "arguments": "{\"task\":\"[subagent-limits-task] squids\"}"
+ },
+ {
+ "name": "researcher",
+ "arguments": "{\"task\":\"[subagent-limits-task] octopuses\"}"
+ }
+ ]
+ }
+ },
+ {
+ "match": {
+ "userMessage": "[subagent-limits-task] squids"
+ },
+ "response": {
+ "content": "Squids have three hearts."
+ }
+ },
+ {
+ "match": {
+ "userMessage": "[subagent-limits] research two topics",
+ "sequenceIndex": 1
+ },
+ "response": {
+ "content": "One topic is done."
+ }
+ },
+ {
+ "match": {
+ "userMessage": "[subagent-keys] write a draft",
+ "sequenceIndex": 0
+ },
+ "response": {
+ "toolCalls": [
+ {
+ "name": "writer",
+ "arguments": "{\"task\":\"[subagent-keys-task] Write a draft.\"}"
+ }
+ ]
+ }
+ },
+ {
+ "match": {
+ "userMessage": "[subagent-keys-task] Write a draft."
+ },
+ "response": {
+ "content": "The draft is written."
+ }
+ },
+ {
+ "match": {
+ "userMessage": "[subagent-keys] write a draft",
+ "sequenceIndex": 1
+ },
+ "response": {
+ "content": "The writer is done."
+ }
}
]
}
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/providers.ts b/testing/e2e/src/lib/providers.ts
index 6b2fe8636..bac63a8b2 100644
--- a/testing/e2e/src/lib/providers.ts
+++ b/testing/e2e/src/lib/providers.ts
@@ -28,7 +28,8 @@ import type { AnyTextAdapter } from '@tanstack/ai'
import type { BytePlusChatModel } from '@tanstack/ai-byteplus'
import type { Feature, Provider } from '@/lib/types'
-const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010'
+export const LLMOCK_DEFAULT_BASE =
+ process.env.LLMOCK_URL || 'http://127.0.0.1:4010'
const DUMMY_KEY = 'sk-e2e-test-dummy-key'
/**
diff --git a/testing/e2e/src/lib/subagents-test.ts b/testing/e2e/src/lib/subagents-test.ts
index 1b50135a9..2748d182f 100644
--- a/testing/e2e/src/lib/subagents-test.ts
+++ b/testing/e2e/src/lib/subagents-test.ts
@@ -24,6 +24,12 @@ export type SubagentScenario =
| 'tool'
| 'brief'
| 'result'
+ | 'limits'
+ | 'session'
+ | 'keys'
+
+/** The key the `keys` scenario passes in `subagents.keys`. */
+export const SUBAGENT_E2E_KEY = 'sk-e2e-subagent-key'
/** The user message for each scenario. It matches `fixtures/subagents`. */
export const SUBAGENT_PROMPTS: Record = {
@@ -32,6 +38,9 @@ export const SUBAGENT_PROMPTS: Record = {
tool: '[subagent-tool] research squids',
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',
}
export function isSubagentScenario(value: unknown): value is SubagentScenario {
@@ -40,6 +49,9 @@ export function isSubagentScenario(value: unknown): value is SubagentScenario {
value === 'approval' ||
value === 'tool' ||
value === 'brief' ||
- value === 'result'
+ 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 b6306745c..0e17b63a3 100644
--- a/testing/e2e/src/routes/api.subagents-test.ts
+++ b/testing/e2e/src/routes/api.subagents-test.ts
@@ -3,12 +3,23 @@ import {
chat,
chatParamsFromRequestBody,
defineAgent,
+ keyedAdapter,
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 { createTextAdapter } from '@/lib/providers'
+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'
+import type { ByokProvider } from '@tanstack/ai/byok'
+import { LLMOCK_DEFAULT_BASE, createTextAdapter } from '@/lib/providers'
import {
deleteLogs,
isSubagentScenario,
@@ -30,11 +41,29 @@ import type { SubagentScenario } from '@/lib/subagents-test'
* child answers, and the parent reads its result.
* - `brief`: no router. The parent model writes a `task` for `researcher`,
* and the child's only message is that task.
+ * - `limits`: like `brief`, with `limits: { maxCalls: 1 }`. The parent model
+ * calls `researcher` twice in one turn. The second call gets a tool error.
+ * - `keys`: like `result`, but the page sends an `x-byok-openai` header.
+ * `subagents.keys` reads it with `getByokKey`, and `writer` builds its
+ * adapter with `ctx.keys.adapter(...)`. The child request carries that
+ * key. aimock hides `Authorization` in its journal, so the request also
+ * sends it as `x-e2e-authorization`.
* - `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) => {
+ const headers = new Headers(init?.headers)
+ headers.set('x-e2e-authorization', headers.get('authorization') ?? '')
+ return fetch(input, { ...init, headers })
+}
+
function subagentsFor(
+ request: Request,
scenario: SubagentScenario,
aimockPort: number | undefined,
testId: string | undefined,
@@ -78,7 +107,7 @@ function subagentsFor(
])
return { agents: [cleaner], router: () => 'cleaner' }
}
- if (scenario === 'brief') {
+ if (scenario === 'brief' || scenario === 'limits') {
const researcher = defineAgent({
name: 'researcher',
description: 'Researches one focused task',
@@ -96,7 +125,9 @@ function subagentsFor(
})
// Each branch returns its own bag, so give them one agent type.
const agents: Array = [researcher]
- return { agents }
+ return scenario === 'limits'
+ ? { agents, limits: { maxCalls: 1 } }
+ : { agents }
}
if (scenario === 'result') {
const pricer = defineAgent({
@@ -113,9 +144,70 @@ function subagentsFor(
const agents: Array = [pricer]
return { agents }
}
+ if (scenario === 'keys') {
+ const gpt = keyedAdapter(openaiByok, (key) =>
+ createOpenaiChat('gpt-4o', key, {
+ baseURL: `${LLMOCK_DEFAULT_BASE}/v1`,
+ fetch: echoAuthorization,
+ ...(testId ? { defaultHeaders: { 'X-Test-Id': testId } } : {}),
+ }),
+ )
+ const writer = defineAgent({
+ name: 'writer',
+ description: 'Writes a short draft',
+ inputSchema: z.object({ task: z.string() }),
+ run: async (ctx) =>
+ ctx.chat({
+ adapter: await ctx.keys.adapter(gpt),
+ messages: [{ role: 'user', content: ctx.input.task }],
+ stream: false,
+ }),
+ })
+ const agents: Array = [writer]
+ return {
+ agents,
+ keys: (provider: ByokProvider | string) => getByokKey(request, provider),
+ }
+ }
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: {
@@ -146,7 +238,16 @@ export const Route = createFileRoute('/api/subagents-test')({
runId: params.runId,
...(params.parentRunId ? { parentRunId: params.parentRunId } : {}),
...(params.resume ? { resume: params.resume } : {}),
- subagents: subagentsFor(scenario, aimockPort, testId),
+ 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 560aa680a..717b07a20 100644
--- a/testing/e2e/src/routes/subagents-test.tsx
+++ b/testing/e2e/src/routes/subagents-test.tsx
@@ -6,6 +6,7 @@ import {
SUBAGENT_PROMPTS,
deleteLogs,
isSubagentScenario,
+ SUBAGENT_E2E_KEY,
} from '@/lib/subagents-test'
const clientTools = [deleteLogs.client()] as const
@@ -27,7 +28,13 @@ function SubagentsTestPage() {
const { testId, aimockPort, scenario } = Route.useSearch()
const { messages, sendMessage, interrupts, isLoading } = useChat({
threadId: `subagents-${testId ?? 'manual'}-${scenario}`,
- connection: fetchServerSentEvents('/api/subagents-test'),
+ // The `keys` scenario sends the user's key the way a BYOK store does.
+ connection: fetchServerSentEvents(
+ '/api/subagents-test',
+ scenario === 'keys'
+ ? { headers: { 'x-byok-openai': SUBAGENT_E2E_KEY } }
+ : {},
+ ),
body: { scenario, testId, aimockPort },
tools: clientTools,
})
@@ -64,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 2461f4e8a..90f3709e9 100644
--- a/testing/e2e/tests/subagents.spec.ts
+++ b/testing/e2e/tests/subagents.spec.ts
@@ -14,7 +14,15 @@ async function run(page: Page) {
function open(
page: Page,
- scenario: 'route' | 'approval' | 'tool' | 'brief' | 'result',
+ scenario:
+ | 'route'
+ | 'approval'
+ | 'tool'
+ | 'brief'
+ | 'result'
+ | 'limits'
+ | 'session'
+ | 'keys',
testId: string,
aimockPort: number,
) {
@@ -154,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 ({
@@ -172,4 +184,104 @@ test.describe('subagents', () => {
'text:Vendor A costs 42 EUR.',
)
})
+
+ test('subagents.limits refuses a call past maxCalls with a tool error', async ({
+ page,
+ request,
+ testId,
+ aimockPort,
+ }) => {
+ await open(page, 'limits', testId, aimockPort)
+ await run(page)
+
+ await expect(page.getByTestId('parent-text')).toHaveText(
+ 'One topic is done.',
+ )
+ await expect(page.getByTestId('card-researcher')).toHaveCount(1)
+ await expect(page.getByTestId('card-researcher')).toContainText(
+ 'text:Squids have three hearts.',
+ )
+
+ const journal = await request.get(
+ `http://127.0.0.1:${aimockPort}/v1/_requests`,
+ )
+ const entries = (await journal.json()) as Array<{
+ headers?: Record
+ body?: {
+ messages?: Array<{ role?: string; content?: string | null }>
+ } | null
+ }>
+ const toolMessages = entries
+ .filter((entry) => entry.headers?.['x-test-id'] === testId)
+ .flatMap((entry) => entry.body?.messages ?? [])
+ .filter((message) => message.role === 'tool')
+ expect(
+ toolMessages.some((message) =>
+ message.content?.includes('subagent limit reached (maxCalls 1)'),
+ ),
+ ).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,
+ testId,
+ aimockPort,
+ }) => {
+ await open(page, 'keys', testId, aimockPort)
+ await run(page)
+
+ await expect(page.getByTestId('parent-text')).toHaveText(
+ 'The writer is done.',
+ )
+ await expect(page.getByTestId('card-writer')).toContainText(
+ 'text:The draft is written.',
+ )
+
+ const journal = await request.get(
+ `http://127.0.0.1:${aimockPort}/v1/_requests`,
+ )
+ const entries = (await journal.json()) as Array<{
+ headers?: Record
+ body?: {
+ messages?: Array<{ role?: string; content?: string | null }>
+ } | null
+ }>
+ const child = entries.find(
+ (entry) =>
+ entry.headers?.['x-test-id'] === testId &&
+ entry.body?.messages?.some((message) =>
+ message.content?.includes('[subagent-keys-task]'),
+ ),
+ )
+ expect(child?.headers?.['x-e2e-authorization']).toBe(
+ 'Bearer sk-e2e-subagent-key',
+ )
+ })
})