diff --git a/.changeset/ag-ui-interrupts.md b/.changeset/ag-ui-interrupts.md new file mode 100644 index 000000000..af939a21e --- /dev/null +++ b/.changeset/ag-ui-interrupts.md @@ -0,0 +1,24 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-preact': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-angular': minor +'@tanstack/ai-persistence': patch +'@tanstack/ai-persistence-drizzle': patch +'@tanstack/ai-persistence-prisma': patch +'@tanstack/ai-persistence-cloudflare': patch +--- + +Adopt the AG-UI interrupt lifecycle for tool approvals, generic responses, and +client-tool execution, with typed bound resolvers, atomic batches, structured +errors, persistence-backed recovery, and exact continuation replay. + +This changes native approval and client-tool streams from legacy custom events +to snapshot-plus-`RUN_FINISHED` interrupt outcomes. Deprecated +`pendingInterrupts`, `addToolApprovalResponse`, raw `resumeInterrupts`, and +legacy event readers remain as limited compatibility surfaces for migration; +`addToolResult` remains supported. diff --git a/docs/architecture/approval-flow-processing.md b/docs/architecture/approval-flow-processing.md index 7838c9b14..58be2f7d4 100644 --- a/docs/architecture/approval-flow-processing.md +++ b/docs/architecture/approval-flow-processing.md @@ -16,7 +16,7 @@ Tool approval is an interrupt-and-resume protocol. A run that needs user input ends with one canonical event: ```ts -{ +const interruptTerminal = { type: 'RUN_FINISHED', runId: 'run-1', threadId: 'thread-1', @@ -26,17 +26,29 @@ ends with one canonical event: interrupts: [ { id: 'approval-1', - reason: 'approval_required', - metadata: { kind: 'approval' }, + reason: 'tool_call', + toolCallId: 'call-1', + responseSchema: { + oneOf: [ + { type: 'object', properties: { approved: { const: true } } }, + { type: 'object', properties: { approved: { const: false } } }, + ], + }, }, ], }, } ``` -The canonical event stream is the only approval event stream. Server state -persistence stores the interrupt record; SSE delivery durability separately -assigns opaque resume offsets to delivered events. +The canonical event stream is the only native approval event stream. It works +ephemerally without persistence. When server state persistence is configured, +it stores the complete descriptor/binding batch before the terminal is exposed; +SSE delivery durability separately assigns opaque resume offsets to delivered +events. Native paths do not emit `approval-requested` or +`tool-input-available` custom events. + +See [Interrupts](../interrupts/overview) for the public server/client guide and +[Migrate to AG-UI interrupts](../interrupts/migration) for deprecated readers. ## Responsibilities @@ -44,27 +56,65 @@ assigns opaque resume offsets to delivered events. | --- | --- | | Tool definition | Declares `needsApproval: true` for a sensitive operation. | | Chat engine | Stops before tool execution and emits the interrupt outcome. | -| Chat persistence middleware | Creates pending interrupt records, marks the run interrupted, and snapshots messages. | -| Chat client | Exposes `pendingInterrupts`, preserves `resumeState`, and sends typed resume entries. | -| Application UI | Explains the operation and resolves or cancels each interrupt. | +| Chat persistence middleware | Optionally opens the descriptor/binding batch atomically, marks the run interrupted, and snapshots authoritative messages. | +| Chat client | Binds descriptors to typed methods, stages drafts, and submits one exact resume batch. | +| Application UI | Explains the operation and uses `resolveInterrupt`, `cancel`, or root batch controls. | | Delivery adapter | Optionally replays SSE events by opaque adapter-owned offsets. | +## Descriptor to continuation pipeline + +The base invariant is **descriptor → validate all → continuation → history**. +Durable mode inserts **open batch → compare-and-swap → receipt** around that +flow: + +1. The engine builds public descriptors and bindings. Output includes + `MESSAGES_SNAPSHOT`, optional `STATE_SNAPSHOT`, and the interrupt + `RUN_FINISHED` terminal. When persistence is configured, it first opens the + entire batch atomically and assigns its generation. +2. The client binds only descriptors whose reason, tool identity, call ID, + schema hashes, interrupted run, and generation match its tool registry. + Anything untrusted degrades to `generic` rather than gaining a typed tool + resolver. +3. Item methods validate and stage local drafts. The submit boundary contains + every pending interrupt ID exactly once. +4. The client submits a fresh run with the full current message history, the + interrupted `parentRunId`, and the complete resume batch. +5. The server validates **all** payloads, edited inputs, outputs, hashes, and + correlation before executing anything. Ephemeral mode reconstructs the + expected batch from client-provided history and current tool definitions. + Durable mode instead uses stored authoritative state and also validates + expiry and generation. +6. Durable mode uses one transaction to compare the current interrupted run and + generation, store the canonical resolution fingerprint, and record the + continuation receipt. Ephemeral mode proceeds directly after validation. +7. Resumed tool calls emit results only; they do not replay synthetic tool-call + start/argument events. Successful history belongs to the continuation run. + +With persistence, an exact retry returns the recorded continuation and a stale +or different submission returns authoritative recovery state. Ephemeral mode +does not provide replay, exactly-once, restart, or cross-instance guarantees; +its message history is validated but remains client-provided input. + ## Server setup -Define the tool and add state persistence before handling requests: +Define the tool normally. The following route opts into state persistence for +durable recovery and concurrency guarantees; omit the persistence imports, +store, and middleware for the zero-configuration ephemeral flow: ```ts // tools.ts import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' -export const deleteProject = toolDefinition({ +export const deleteProjectDefinition = toolDefinition({ name: 'delete_project', description: 'Delete a project permanently', inputSchema: z.object({ projectId: z.string() }), outputSchema: z.object({ deleted: z.boolean() }), needsApproval: true, -}).server(async ({ projectId }) => { +}) + +export const deleteProject = deleteProjectDefinition.server(async ({ projectId }) => { await deleteProjectFromDatabase(projectId) return { deleted: true } }) @@ -96,6 +146,7 @@ export async function POST(request: Request) { messages: params.messages, threadId: params.threadId, runId: params.runId, + parentRunId: params.parentRunId, ...(params.resume ? { resume: params.resume } : {}), tools: [deleteProject], middleware: [withChatPersistence(persistence)], @@ -105,10 +156,11 @@ export async function POST(request: Request) { } ``` -There is no feature flag. Middleware behavior follows the stores present on the -`AIPersistence` object. `interrupts` requires `runs`; invalid store -combinations fail at compile time when statically known and at runtime for -untyped JavaScript. +Persistence is not required to emit or resolve interrupts. When middleware is +present, behavior follows the stores on the `AIPersistence` object. +`interrupts` requires `runs` within that optional durable configuration; +invalid store combinations fail at compile time when statically known and at +runtime for untyped JavaScript. ## Client state machine @@ -117,60 +169,52 @@ A single approval follows this sequence: 1. The model emits a tool call. 2. The client tool-call part reaches `approval-requested`. 3. The run ends with `RUN_FINISHED.outcome.type === 'interrupt'`. -4. `useChat` exposes the descriptor in `pendingInterrupts` and retains - `{ threadId, runId }` in `resumeState`. -5. The UI calls `resumeInterrupts(...)` with a resolution for every pending - interrupt. -6. The next request carries those values in `RunAgentInput.resume`. -7. Persistence validates and resolves the stored records before the engine - continues the tool call. +4. `useChat` exposes a bound item in `interrupts`. +5. The UI calls `resolveInterrupt(...)` or `cancel()`; a singleton + submits immediately, while a multi-item batch waits for every valid draft. +6. The next request carries a fresh `runId`, the interrupted `parentRunId`, and + the exact AG-UI `resume` array. +7. The server validates the full set before the engine continues the tool call. + With persistence, it also commits the set atomically against authoritative + state. Normal input is rejected at step 4. This prevents a second branch from being created while the existing run still waits for a decision. -## Real React approval UI +## React approval UI -Use the values returned by `useChat`. Rendering the actual -`pendingInterrupts` array keeps ids and types connected to the hook that owns -the run. +Use the bound values returned by `useChat`. Rendering `interrupts` keeps IDs, +tool types, drafts, and errors connected to the hook that owns the run. -```tsx +```tsx group=approval-ui +import type { ItemInterruptError } from '@tanstack/ai' import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { deleteProjectDefinition } from './tools' export function ApprovalQueue() { const chat = useChat({ id: 'project-chat', threadId: 'project-thread', connection: fetchServerSentEvents('/api/chat'), + tools: [deleteProjectDefinition] as const, }) return (
- {chat.pendingInterrupts.map((interrupt) => ( + {chat.interrupts.map((interrupt) => (

Approval required: {interrupt.reason}

- - + {interrupt.kind === 'tool-approval' ? ( + + ) : null} + + {interrupt.errors.map((error: ItemInterruptError) => ( +

+ {error.message} +

+ ))}
))}
@@ -178,38 +222,30 @@ export function ApprovalQueue() { } ``` -For a batch, send one entry per pending interrupt in one call: - -```tsx -import type { RunAgentResumeItem } from '@tanstack/ai/client' - -function resolveInterrupt( - interruptId: string, - approved: boolean, -): RunAgentResumeItem { - return approved - ? { - interruptId, - status: 'resolved', - payload: { approved: true }, - } - : { interruptId, status: 'cancelled' } -} +For a batch, stage every resolution in one synchronous root callback: +```tsx group=approval-ui function ResolveAll({ approved }: { approved: boolean }) { const chat = useChat({ threadId: 'project-thread', connection: fetchServerSentEvents('/api/chat'), + tools: [deleteProjectDefinition] as const, }) return ( - + {interrupt.kind === 'tool-approval' ? ( + + ) : null} + ))} + {interruptErrors.map((error) => ( +
  • {error.message}
  • + ))} +
  • + +
  • ) } ``` -The server still validates that every resume entry matches a pending -interrupt. Normal new input is rejected while interrupts remain unresolved. +The server still validates every entry and commits the exact batch atomically. +Normal new input is rejected while interrupts remain unresolved. + +Recovery is opt-in. Configure explicit application-owned recovery and winning +continuation URLs; connection adapters never infer them from the chat URL: + +```ts +import { + createInterruptContinuationLoader, + createInterruptStateFetcher, + fetchServerSentEvents, +} from '@tanstack/ai-client' + +export const connection = fetchServerSentEvents('/api/chat', { + interruptStateFetcher: createInterruptStateFetcher( + '/api/interrupts/recovery', + ), + continuationLoader: createInterruptContinuationLoader( + '/api/interrupts/continuation', + ), +}) +``` + +Exact retries use the stored fingerprint and join the winning continuation. +Stale tabs recover the authoritative generation instead of executing a second +continuation. See [Migrate to AG-UI interrupts](../interrupts/migration) for V1 +compatibility and rollout steps. ## SSR and custom adapters diff --git a/docs/config.json b/docs/config.json index b6427d169..3c5b66e35 100644 --- a/docs/config.json +++ b/docs/config.json @@ -102,13 +102,13 @@ "label": "Client Tools", "to": "tools/client-tools", "addedAt": "2026-04-15", - "updatedAt": "2026-07-08" + "updatedAt": "2026-07-14" }, { "label": "Tool Approval Flow", "to": "tools/tool-approval", "addedAt": "2026-04-15", - "updatedAt": "2026-07-09" + "updatedAt": "2026-07-14" }, { "label": "Lazy Tool Discovery", @@ -178,7 +178,43 @@ "label": "Persistence", "to": "chat/persistence", "addedAt": "2026-06-02", - "updatedAt": "2026-07-10" + "updatedAt": "2026-07-14" + } + ] + }, + { + "label": "Interrupts", + "children": [ + { + "label": "Overview", + "to": "interrupts/overview", + "addedAt": "2026-07-16" + }, + { + "label": "Tool Approval", + "to": "interrupts/tool-approval", + "addedAt": "2026-07-16" + }, + { + "label": "Multiple Interrupts", + "to": "interrupts/multiple", + "addedAt": "2026-07-16" + }, + { + "label": "Generic Interrupts", + "to": "interrupts/generic", + "addedAt": "2026-07-16" + }, + { + "label": "Persistence & Recovery", + "to": "interrupts/persistence", + "addedAt": "2026-07-16" + }, + { + "label": "Migration", + "to": "interrupts/migration", + "addedAt": "2026-07-14", + "updatedAt": "2026-07-16" } ] }, @@ -417,7 +453,7 @@ "label": "Delivery Durability", "to": "persistence/delivery-durability", "addedAt": "2026-07-09", - "updatedAt": "2026-07-10" + "updatedAt": "2026-07-14" }, { "label": "Persistence Controls", @@ -483,7 +519,7 @@ "label": "Custom Stores", "to": "persistence/custom-stores", "addedAt": "2026-07-03", - "updatedAt": "2026-07-10" + "updatedAt": "2026-07-14" }, { "label": "Persistence Internals", @@ -536,6 +572,12 @@ "label": "Typed Pre-Configured Options", "to": "advanced/typed-options", "addedAt": "2026-05-25" + }, + { + "label": "Approval Flow Processing", + "to": "architecture/approval-flow-processing", + "addedAt": "2026-04-15", + "updatedAt": "2026-07-15" } ] }, @@ -901,7 +943,8 @@ { "label": "toolDefinition", "to": "reference/functions/toolDefinition", - "addedAt": "2026-04-15" + "addedAt": "2026-04-15", + "updatedAt": "2026-07-14" }, { "label": "uiMessageToModelMessages", diff --git a/docs/interrupts/generic.md b/docs/interrupts/generic.md new file mode 100644 index 000000000..4989d0381 --- /dev/null +++ b/docs/interrupts/generic.md @@ -0,0 +1,104 @@ +--- +title: Generic Interrupts +id: interrupts-generic +order: 4 +description: "Resolve a schema-driven application pause by validating the wire responseSchema at runtime before submitting." +keywords: + - tanstack ai + - generic interrupt + - responseSchema + - fromJSONSchema + - resolveInterrupt +--- + +# Generic Interrupts + +A generic interrupt is any application pause that isn't tied to a tool. It may +carry a Draft 2020-12 `responseSchema`. Because that schema arrives over the +wire, its payload is `unknown` at compile time — so you validate it at runtime +before resolving. You have a schema-bearing `generic` item; you want a form that +only submits valid input. + +## Render a validating form + +Convert the received schema to a validator with `z.fromJSONSchema`, parse the +editor value as `unknown`, and resolve only on success: + +```tsx +// app/generic-interrupt.tsx +import { useState } from 'react' +import type { GenericAGUIInterrupt } from '@tanstack/ai-client' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { z } from 'zod' + +// A generic item owns its own editor state, so give each one its own component. +function GenericInterruptForm({ + interrupt, +}: { + interrupt: GenericAGUIInterrupt +}) { + const [value, setValue] = useState('') + const [errors, setErrors] = useState>([]) + + const submit = () => { + if (!interrupt.responseSchema) { + setErrors(['This interrupt has no response schema.']) + return + } + let candidate: unknown + try { + candidate = JSON.parse(value) + } catch { + setErrors(['Enter valid JSON.']) + return + } + const result = z.fromJSONSchema(interrupt.responseSchema).safeParse(candidate) + if (!result.success) { + setErrors(result.error.issues.map((issue) => issue.message)) + return + } + interrupt.resolveInterrupt(result.data) + setErrors([]) + } + + return ( +
    +

    {interrupt.message ?? interrupt.reason}

    +