diff --git a/apps/website/content/AGENTS.md.template b/apps/website/content/AGENTS.md.template index cb63070dc..6cabb0c97 100644 --- a/apps/website/content/AGENTS.md.template +++ b/apps/website/content/AGENTS.md.template @@ -70,5 +70,17 @@ export class ChatComponent { - Scoped config: re-provide `provideAgent({ apiUrl, assistantId })` in a component `providers` array for a subtree - Testing: use `MockAgentTransport` — never mock `injectAgent()` itself + +## Interrupts and recovery + +- Both adapters expose `interrupt()` and `submit({ resume })`; the backend defines the decision payload. +- AG-UI `auto` prefers native interrupt batches regardless of event order. Answer every pending native ID once; `status: 'cancelled'` entries omit `payload`. +- For Mastra, set `interruptTransport: 'mastra-command'`. The adapter sends the decision in `forwardedProps.command.resume` and observed correlation IDs in `command.interruptEvent`. The campsite example rejects with `{ approved: false }`. +- AG-UI restoration is opt-in through `persistence`: stable `threadId`, scoped namespace, and application-owned atomic compare-and-swap storage. Await `agent.ready` before rendering restored decisions. +- Capture `interruptSession().generation` when rendering and pass it as the `interruptGeneration` submit option to reject stale controls. +- Retry the retained decision only after proven non-dispatch or authoritative reconciliation. Configure `persistence.reconcile` and call `agent.reconcileInterrupt()` for uncertain outcomes; a network error alone does not prove non-execution. +- These recovery APIs are AG-UI extensions. LangGraph uses its own thread/checkpoint APIs. Client storage cannot recreate a lost backend checkpoint or guarantee exactly-once side effects. +- An approval card closing or local streaming stopping does not prove backend completion or cancellation. + ## Version check If this file is stale, fetch the latest: https://threadplane.ai/llms-full.txt diff --git a/apps/website/content/CLAUDE.md.template b/apps/website/content/CLAUDE.md.template index cb63070dc..6cabb0c97 100644 --- a/apps/website/content/CLAUDE.md.template +++ b/apps/website/content/CLAUDE.md.template @@ -70,5 +70,17 @@ export class ChatComponent { - Scoped config: re-provide `provideAgent({ apiUrl, assistantId })` in a component `providers` array for a subtree - Testing: use `MockAgentTransport` — never mock `injectAgent()` itself + +## Interrupts and recovery + +- Both adapters expose `interrupt()` and `submit({ resume })`; the backend defines the decision payload. +- AG-UI `auto` prefers native interrupt batches regardless of event order. Answer every pending native ID once; `status: 'cancelled'` entries omit `payload`. +- For Mastra, set `interruptTransport: 'mastra-command'`. The adapter sends the decision in `forwardedProps.command.resume` and observed correlation IDs in `command.interruptEvent`. The campsite example rejects with `{ approved: false }`. +- AG-UI restoration is opt-in through `persistence`: stable `threadId`, scoped namespace, and application-owned atomic compare-and-swap storage. Await `agent.ready` before rendering restored decisions. +- Capture `interruptSession().generation` when rendering and pass it as the `interruptGeneration` submit option to reject stale controls. +- Retry the retained decision only after proven non-dispatch or authoritative reconciliation. Configure `persistence.reconcile` and call `agent.reconcileInterrupt()` for uncertain outcomes; a network error alone does not prove non-execution. +- These recovery APIs are AG-UI extensions. LangGraph uses its own thread/checkpoint APIs. Client storage cannot recreate a lost backend checkpoint or guarantee exactly-once side effects. +- An approval card closing or local streaming stopping does not prove backend completion or cancellation. + ## Version check If this file is stale, fetch the latest: https://threadplane.ai/llms-full.txt diff --git a/apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx b/apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx index ff7ba3163..f12ead0ac 100644 --- a/apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx +++ b/apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx @@ -203,7 +203,7 @@ That distinction lives in the composition. 9. The graph continues to `issue_refund` and finishes. It is one thread and one persisted state. -If the operator closes the tab and returns later, the interrupt is still pending. +If the operator returns to the same thread and its server checkpoint is retained, the interrupt can still be pending. Preserve the thread ID and use durable backend storage; closing the tab does not itself configure restoration.
The chat after approval, showing the agent's draft summary and a confirmation: 'Refund of $47.50 issued to cus_a8x2k. Refund ID: re_demo__a8x2k.' diff --git a/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx b/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx index a073289c0..9d9db4c5f 100644 --- a/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +++ b/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx @@ -8,6 +8,8 @@ featured: true --- This is how to pause an AG-UI agent in Angular for human approval before it runs a high-stakes tool, using a `CUSTOM` `on_interrupt` event and the `` composition from `@threadplane/chat`. + +**Updated September 9, 2026:** This tutorial uses the LangGraph compatibility bridge. The adapter also supports native interrupt batches, which take precedence in `auto` mode; Mastra requires its explicit `mastra-command` profile. Resume now claims a pending batch, and uncertain delivery requires authoritative reconciliation before retry. Client restoration is opt-in and cannot replace the server checkpointer. The example is the same refund agent from [Human-in-the-Loop LangGraph Agents in Angular](/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular) — wired through the AG-UI adapter instead. The Angular component is byte-identical except the import. @@ -406,7 +408,7 @@ When the operator clicks Approve: this.agent.submit({ resume: { approved: true, amount: this.editAmount() ?? payload.amount } }); ``` -The adapter clears `agent.interrupt()` immediately for snappy UX, then forwards the resume: +The adapter claims the pending interrupt before dispatching the resume. The card closing is a local UI transition, not proof that the refund completed. For this compatibility bridge, the request carries: ```ts source.runAgent({ forwardedProps: { command: { resume: { approved: true, amount: 99 } } } }); @@ -430,7 +432,7 @@ Different wire, same Angular surface. The runtime-neutral `Agent` contract is not a marketing line. It is the reason this post existed without rewriting the component. ``, `agent.interrupt()`, and `submit({ resume })` are the stable surface. -`on_interrupt` and `forwardedProps.command.resume` are the AG-UI-specific wire details the adapter hides. +`on_interrupt` and `forwardedProps.command.resume` are this LangGraph bridge's compatibility wire details. Other AG-UI runtimes can require native correlated entries or an explicit command profile. My recommendation is simple: pick the adapter that matches your backend — LangGraph SDK direct → `@threadplane/langgraph`; anything AG-UI-fronted, including LangGraph-via-`ag-ui-langgraph` → `@threadplane/ag-ui`. Your chat surface does not pick. diff --git a/apps/website/content/blog/2026-08-31-we-measured-the-runtime-swap.mdx b/apps/website/content/blog/2026-08-31-we-measured-the-runtime-swap.mdx index aa7f53631..f5c66127f 100644 --- a/apps/website/content/blog/2026-08-31-we-measured-the-runtime-swap.mdx +++ b/apps/website/content/blog/2026-08-31-we-measured-the-runtime-swap.mdx @@ -86,7 +86,7 @@ It failed silently, which only means it took longer to notice. The fix was small once the cause was clear, and it landed in [#888](https://github.com/cacheplane/angular-agent-framework/pull/888). Both conventions are now recognized. -Within one run the first signal to arrive wins, because Mastra emits both and a doubled interrupt helps nobody. +Updated September 9, 2026: the adapter retains both forms in one session. Native batches take precedence in `auto` mode regardless of arrival order; the Mastra integration explicitly selects `mastra-command` so the resume includes its decision and suspended-tool correlation. I want to name the mistake precisely, because "we had a bug" is not the lesson. We built a protocol adapter and then tested it exclusively against one bridge implementation of that protocol. diff --git a/apps/website/content/blog/2026-08-31-what-changes-when-the-runtime-changes.mdx b/apps/website/content/blog/2026-08-31-what-changes-when-the-runtime-changes.mdx index 9652905cd..45d04a7ae 100644 --- a/apps/website/content/blog/2026-08-31-what-changes-when-the-runtime-changes.mdx +++ b/apps/website/content/blog/2026-08-31-what-changes-when-the-runtime-changes.mdx @@ -299,7 +299,7 @@ I believe it holds, because the contract is built on the protocol event vocabula But belief is not measurement, and I am not going to dress one up as the other. Two smaller caveats. -The interrupt path accepts two conventions — the protocol-standard `RUN_FINISHED` interrupt outcome, and the `CUSTOM` `on_interrupt` event the LangGraph bridge emits — and within a single run the first signal wins. +The interrupt path accepts two conventions — the protocol-standard `RUN_FINISHED` interrupt outcome, and the `CUSTOM` `on_interrupt` event the LangGraph bridge emits — and retains both in one session. Updated September 9, 2026: native batches take precedence in `auto` mode regardless of arrival order; Mastra uses the explicit `mastra-command` profile. And the subagent path rests on the protocol's `SUBAGENT_*` events, which the adapter consumes directly; each measured runtime reaches them through a small in-tree emitter, as [the measurement post](/blog/we-measured-the-runtime-swap#what-stayed-partial) records. *Editor's note: the measurement exists.* diff --git a/apps/website/content/docs/ag-ui/api/inject-agent.mdx b/apps/website/content/docs/ag-ui/api/inject-agent.mdx index 32e6b3224..c371ce1ba 100644 --- a/apps/website/content/docs/ag-ui/api/inject-agent.mdx +++ b/apps/website/content/docs/ag-ui/api/inject-agent.mdx @@ -76,6 +76,10 @@ The AG-UI adapter extends the neutral `Agent` contract with AG-UI-specific proto |-------|------|-------------| | `customEvents()` | `CustomStreamEvent[]` | Custom events emitted by the backend during a run. Accumulates per run; resets when `RUN_STARTED` arrives. | | `clientTools` | `ClientToolsCapability` | Browser client-tool catalog, pending calls, and result resolution used by ``. | +| `ready` | `Promise` | Resolves after configured persisted state is hydrated. | +| `interruptSession()` | `InterruptSessionSnapshot` | Full batch, generation, ownership phase, and retained resume attempt. | +| `reconcileInterrupt()` | `Promise` | Applies authoritative recovery using the application-provided persistence reconciler. | +| `dispose()` | `void` | Stops local work; providers call it on injector destruction. Does not cancel backend checkpoints. | | `subagents()` | `Map` | Subagent runs from `SUBAGENT_*` events, keyed by `subagentRunId`, plus the `ACTIVITY_*` convention (`activityType: 'subagent'`, keyed by `messageId`), projected to the neutral subagent contract. | `injectAgent()` returns the `AgUiAgent` type — the neutral `Agent` contract plus these AG-UI-specific fields — so they are reachable directly, no cast required: @@ -100,12 +104,16 @@ Use the runtime-neutral submit shape for normal chat input: await chat.submit({ message: 'Summarize this document' }); ``` -Resume an interrupt by passing a `resume` payload: +Resume a pending single interrupt by passing the payload expected by the backend: ```ts await chat.submit({ resume: { approved: true } }); ``` +Native batches require every pending ID exactly once; cancelled entries omit payload. Capture `chat.interruptSession().generation` when rendering approval controls and pass `{ interruptGeneration: generation }` as the second submit argument to reject stale decisions. For the Mastra integration, configure `interruptTransport: 'mastra-command'` on the provider. + +Await `chat.ready` before showing restored controls. `retry()` can resend the retained decision after a proven pre-dispatch failure; uncertain delivery requires `chat.reconcileInterrupt()` with authoritative backend evidence before another attempt. These extensions do not imply the same recovery API on the LangGraph adapter. + ## Regenerate semantics `regenerate(assistantMessageIndex)` has replace semantics: it keeps the user message before the selected assistant message, removes the selected assistant message and all later messages, syncs the rollback to the AG-UI source, then reruns with no new user message appended. diff --git a/apps/website/content/docs/ag-ui/api/provide-agent.mdx b/apps/website/content/docs/ag-ui/api/provide-agent.mdx index 68f4411e5..c79d0c6f0 100644 --- a/apps/website/content/docs/ag-ui/api/provide-agent.mdx +++ b/apps/website/content/docs/ag-ui/api/provide-agent.mdx @@ -46,9 +46,13 @@ const agent = injectAgent(); // AgUiAgent> | `url` | `string` | HTTP endpoint for the AG-UI backend agent. Required. | | `agentId` | `string` | Agent identifier, when the endpoint serves more than one agent. | | `threadId` | `string` | Thread to connect to on start. Omit to begin a fresh conversation. | +| `interruptTransport` | `InterruptTransport` | `auto` (default), `protocol`, `legacy-command`, or `mastra-command`. Native batches win in `auto`; select the Mastra command profile for the Mastra integration. | +| `persistence` | `AgUiInterruptPersistence` | Application-owned store and optional authoritative reconciler. Requires a stable configured `threadId` and scoped namespace. | | `headers` | `Record` | Extra HTTP headers sent with every request. | | `telemetry` | `AgentRuntimeTelemetrySink \| false` | Omit for automatic development-only collection, pass `false` to disable it, or pass an app-owned sink to receive the runtime lifecycle events yourself. | +With persistence enabled, await `agent.ready` before displaying restored approval controls. The store must implement atomic `compareAndSwap`; browser state alone cannot restore a lost backend checkpoint. The provider disposes the adapter when its injector is destroyed, stopping local work without cancelling backend checkpoints. + ## Static versus factory config Pass a plain `AgentConfig` object when the URL is known up front. Pass a `() => AgentConfig` factory when the config depends on runtime DI state — the factory runs inside an Angular injection context, so it may call `inject()` to read services, route params, or environment tokens. diff --git a/apps/website/content/docs/ag-ui/api/to-agent.mdx b/apps/website/content/docs/ag-ui/api/to-agent.mdx index 162d7c63a..c804f36b6 100644 --- a/apps/website/content/docs/ag-ui/api/to-agent.mdx +++ b/apps/website/content/docs/ag-ui/api/to-agent.mdx @@ -24,6 +24,8 @@ const agent = toAgent(source, { telemetry: myTelemetrySink }); | Option | Type | Description | |--------|------|-------------| +| `interruptTransport` | `InterruptTransport` | `auto` (default), `protocol`, `legacy-command`, or `mastra-command`. Native batches take precedence in `auto`. | +| `persistence` | `AgUiInterruptPersistence` | Application-owned durable storage and optional authoritative reconciliation. Requires a stable source `threadId` and scoped namespace. | | `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional app-owned sink. Supply one to receive runtime lifecycle events. | | `a2uiClientCapabilities` | `{ supportedCatalogIds: string[]; inlineCatalogs?: unknown[] }` | A2UI catalog negotiation to advertise to the agent. Seeded once into the AG-UI shared state under the `a2ui_client_capabilities` key, so every `RunAgentInput.state` carries it. Use `a2uiClientCapabilities()` from `@threadplane/chat` for the renderer's standard value. | @@ -33,12 +35,18 @@ const agent = toAgent(source, { telemetry: myTelemetrySink }); | Field | Type | Description | |-------|------|-------------| +| `ready` | `Promise` | Resolves after persisted state is hydrated; actions also wait for hydration. | +| `interruptSession()` | `InterruptSessionSnapshot` | Current batch, generation, phase, and retained attempt. | +| `reconcileInterrupt()` | `Promise` | Applies authoritative recovery through the configured persistence reconciler. An unknown result leaves recovery blocked. | +| `dispose()` | `void` | Stops local work and unsubscribes. Call when a directly created adapter is no longer needed; it does not cancel backend checkpoints. | | `customEvents()` | `Signal` | Custom events accumulated during a run. Resets at the start of each new run. | | `clientTools` | `ClientToolsCapability` | Browser client-tool catalog, pending calls, and result resolution. The chat composition uses this when you pass ``. | | `subagents()` | `Signal>` | Subagent runs from `SUBAGENT_*` events, keyed by `subagentRunId`, plus the `ACTIVITY_*` convention (`activityType: 'subagent'`, keyed by `messageId`), projected to the neutral subagent contract. | The standard `Agent` signals (`messages`, `status`, `isLoading`, `error`, `toolCalls`, `state`, `interrupt`) and actions (`submit`, `retry`, `stop`, `regenerate`) are all present. +Capture `interruptSession().generation` when rendering a decision and pass it to `submit(input, { interruptGeneration })` to reject stale controls. A proven pre-dispatch failure retains the exact decision for `retry()`; uncertain delivery requires authoritative reconciliation first. These recovery extensions belong to `AgUiAgent`, not the neutral `Agent` contract. + ## CustomStreamEvent `CustomStreamEvent` is the element type of `AgUiAgent.customEvents`: diff --git a/apps/website/content/docs/ag-ui/guides/interrupts.mdx b/apps/website/content/docs/ag-ui/guides/interrupts.mdx index 274b46f05..7545b26fe 100644 --- a/apps/website/content/docs/ag-ui/guides/interrupts.mdx +++ b/apps/website/content/docs/ag-ui/guides/interrupts.mdx @@ -1,5 +1,5 @@ --- -description: How the AG-UI interrupts example pauses a refund graph, emits a CUSTOM on_interrupt event, and resumes from an approval card in Angular +description: Approve and cancel AG-UI interrupts with generation-aware controls, native batches, and application-owned persistence and recovery --- # Interrupts @@ -47,7 +47,7 @@ Splitting extraction from narration is deliberate: the card needs typed fields, Notice the `kind` field on the payload, which is how the frontend tells this interrupt apart from any other one the graph might raise, and notice that the node treats a resume value that is not a dictionary, or whose `approved` is missing or false, as a rejection. -LangGraph resumes by running the interrupting node again, and `interrupt()` returns the resume value instead of pausing a second time. Every line above the `interrupt()` call therefore runs twice, so keep that stretch free of side effects. Reading state, as the example does, is safe; charging a card there is not. +LangGraph resumes by running the interrupting node again, and `interrupt()` returns the resume value instead of pausing a second time. Every line above the `interrupt()` call runs again, including on later retries, so keep that stretch free of side effects. Reading state, as the example does, is safe; charging a card there is not. ### Routing on the decision @@ -128,9 +128,22 @@ Other runtimes signal a pause differently: a `RUN_FINISHED` event carrying a nat ## Pending batches and transport selection -`interrupt()` is the runtime-neutral display projection. For a native batch its value is `{ interrupts, runId }`; use `interruptSession()` for the full batch and its lifecycle. The session includes `phase`, `generation`, `interrupts`, and, when present, the compatibility payload, originating `runId`, and saved resume `attempt`. +`interrupt()` is the runtime-neutral display projection. In `auto` or `protocol`, a native batch projects `{ interrupts, runId }`; explicit `legacy-command` and `mastra-command` profiles project the compatibility interrupt instead. Use `interruptSession()` for the full batch and its lifecycle. The session includes `phase`, `generation`, `interrupts`, and, when present, the compatibility payload, originating `runId`, and saved resume `attempt`. -The phases are `none`, `collecting`, `pending`, `claimed`, `resuming`, `acknowledged`, `uncertain`, and `recovery-required`. Render approval controls when the phase is `pending`. Submitting a decision claims that batch before dispatch, so a second submission cannot answer it concurrently. `RUN_STARTED` acknowledges the claim and clears the visible pending interrupt; it does not prove the resumed work completed. +Render new decision controls only for `pending` sessions without a retained attempt. Submitting a decision claims the batch before dispatch, so a second submission cannot answer it concurrently. + +| Phase | Meaning and UI behavior | +| --- | --- | +| `collecting` | The adapter is collecting the pause; wait for its committed boundary. | +| `pending` | Ready for a decision, or for retry of an already retained decision. | +| `claimed` | A decision owns this batch; disable new decisions. | +| `resuming` | The resume request was dispatched. | +| `acknowledged` | `RUN_STARTED` acknowledged the claim and cleared the visible interrupt. | +| `uncertain` | Dispatch or its outcome is unknown; reconcile before retry. | +| `recovery-required` | An acknowledged attempt failed or was restored; reconcile. | +| `none` | No unresolved interrupt session remains. | + +Acknowledgement and disappearance of the approval card do not prove backend completion. For one native interrupt, `submit({ resume: { approved: true } })` wraps the value as a resolved response to that interrupt. For several, supply one response per pending ID: @@ -156,6 +169,127 @@ Set `interruptTransport` in `provideAgent()` or `toAgent()` options when the bac | `legacy-command` | `forwardedProps.command.resume`. | | `mastra-command` | `forwardedProps.command.resume` plus `command.interruptEvent` containing `toolCallId` and the available `runId`. Requires the compatibility payload's `toolCallId`. | +Choose `mastra-command` explicitly for the current Mastra command backend, including when it emits both native and compatibility events. + +### A single decision with rendered-generation protection + +This is a complete standalone component for the compatibility refund payload above. Register `provideAgent({ url: '/agent', interruptTransport: 'legacy-command' })` in the application providers and mount the component alongside the chat. The payload guard validates data before displaying it. The computed view captures the generation when Angular renders the controls; its callback retains that generation even if an old control is invoked later. + +```typescript +import { Component, computed, signal } from '@angular/core'; +import { injectAgent } from '@threadplane/ag-ui'; + +interface RefundApproval { + kind: 'refund_approval'; + amount: number; + customer_id: string; + reason: string; +} + +function isRefund(value: unknown): value is RefundApproval { + if (typeof value !== 'object' || value === null) return false; + const item = value as Record; + return item['kind'] === 'refund_approval' && + typeof item['amount'] === 'number' && Number.isFinite(item['amount']) && + typeof item['customer_id'] === 'string' && + typeof item['reason'] === 'string'; +} + +@Component({ + selector: 'app-refund-decision', + standalone: true, + template: ` + @if (!hydrated()) {

Loading saved approval…

} + @if (error() || agent.error()?.message; as message) { +

{{ message }}

+ } + @if (busy() || agent.isLoading()) {

Working…

} + @if (hydrated()) { + @if (decision(); as view) { +

{{ view.payload.customer_id }}: {{ view.payload.amount }}

+

{{ view.payload.reason }}

+ + + } + } + `, +}) +export class RefundDecisionComponent { + readonly agent = injectAgent(); + readonly hydrated = signal(false); + readonly busy = signal(false); + readonly error = signal(''); + readonly decision = computed(() => { + const snapshot = this.agent.interruptSession(); + const payload = snapshot.legacy?.value; + if (snapshot.phase !== 'pending' || snapshot.attempt || !isRefund(payload)) { + return undefined; + } + const generation = snapshot.generation; + return { + payload, + answer: (approved: boolean) => this.answer(approved, generation), + }; + }); + + constructor() { void this.hydrate(); } + + private async hydrate(): Promise { + try { + await this.agent.ready; + this.hydrated.set(true); + } catch (error) { + this.error.set(error instanceof Error ? error.message : 'Restoration failed'); + } + } + + private async answer(approved: boolean, generation: number): Promise { + if (this.busy()) return; + this.busy.set(true); + this.error.set(''); + try { + await this.agent.submit( + { resume: { approved } }, + { interruptGeneration: generation }, + ); + } catch (error) { + this.error.set(error instanceof Error ? error.message : 'Decision failed'); + } finally { + this.busy.set(false); + } + } +} +``` + +`approved: false` is this application's rejection decision. It is not a protocol-level cancelled entry. An unrecognized payload intentionally produces no refund controls; route other payload kinds to their own renderer. + +### Cancelling a native batch + +This complete helper requires native interrupts and `interruptTransport: 'protocol'`. Call it while building the rendered view and bind the returned callback to its Cancel button; do not recreate it inside the click handler. Handle its rejected promise and disable the control while submitting, as in the component above. + +```typescript +import type { AgUiAgent } from '@threadplane/ag-ui'; + +export function cancellationForRenderedBatch(agent: AgUiAgent) { + const snapshot = agent.interruptSession(); + if (snapshot.phase !== 'pending' || snapshot.attempt || snapshot.interrupts.length === 0) { + throw new Error('An unclaimed pending native interrupt batch is required'); + } + const resume = snapshot.interrupts.map(({ id }) => ({ + interruptId: id, + status: 'cancelled' as const, + })); + return () => agent.submit( + { resume }, + { interruptGeneration: snapshot.generation }, + ); +} +``` + +Every observed ID appears exactly once, cancellation carries no payload, and a stale generation is rejected. This protocol cancellation differs from sending the Mastra application's `{ approved: false }` decision through `mastra-command`. + ## Failures, retry, and restoration A failed resume restores the committed protocol messages and state used by subsequent requests. Failed provisional messages may remain visible in the local transcript. Authoritative native snapshots define the committed boundary. A compatibility pause commits only at a terminal event or clean transport close. This client rollback cannot undo work already performed on the server. @@ -170,6 +304,163 @@ Await `agent.ready` before rendering restored approval controls; actions also wa The optional `isInputBlocked` signal disables ordinary chat input during hydration, reconciliation, and unresolved interrupt phases. The built-in composer preserves drafts while blocked; approval controls still submit through the resume path. +### Wiring application-owned persistence + +This complete provider helper accepts an application-owned persistence configuration. Use its returned providers in the application config, with the same thread ID and namespace when reopening the thread. The store and reconciler are dependencies you implement; neither is a bundled service. + +```typescript +import { provideAgent } from '@threadplane/ag-ui'; +import type { AgUiInterruptPersistence } from '@threadplane/ag-ui'; + +export function providersForRestoredThread( + threadId: string, + persistence: AgUiInterruptPersistence, +) { + return provideAgent({ + url: '/agent', + threadId, + persistence, + }); +} +``` + +### Test-only compare-and-swap store + +This complete TypeScript example is non-durable and single-process. Its comparison and replacement run synchronously with no intervening `await`, which makes them atomic only among callers sharing this store instance. It loses all records when the process exits. Production storage must enforce the comparison and write atomically across the clients it coordinates; a `localStorage` read followed by a write does not provide that guarantee across tabs. + +```typescript +import type { + AgUiInterruptPersistence, + AgUiThreadRecord, +} from '@threadplane/ag-ui'; + +export function createTestOnlyStore(): AgUiInterruptPersistence['store'] { + const records = new Map(); + return { + async load(key) { + return structuredClone(records.get(key) ?? null); + }, + async compareAndSwap(key, expectedRevision, next) { + const currentRevision = records.get(key)?.revision ?? null; + if (currentRevision !== expectedRevision) return false; + const nextRevision = expectedRevision === null ? 0 : expectedRevision + 1; + if (next.revision !== nextRevision) throw new Error('Invalid next revision'); + records.set(key, structuredClone(next)); + return true; + }, + }; +} + +export async function demonstrateSingleWinner(): Promise { + const store = createTestOnlyStore(); + const record: AgUiThreadRecord = { + version: 1, + namespace: 'test-only', + threadId: 'thread-1', + revision: 0, + committed: { state: {}, messages: [] }, + session: { phase: 'none', generation: 0, interrupts: [] }, + }; + const key = JSON.stringify([record.namespace, record.threadId]); + const results = await Promise.all([ + store.compareAndSwap(key, null, record), + store.compareAndSwap(key, null, record), + ]); + if (results.filter(Boolean).length !== 1) throw new Error('Expected one winner'); + const saved = await store.load(key); + if (saved?.revision !== 0) throw new Error('Unexpected saved revision'); +} +``` + +The adapter creates the namespace/thread key and increments record revisions. This example demonstrates client storage ownership only; it makes no claim about backend execution occurring exactly once. + +### Authoritative reconciliation + +The exact result union below comes from the exported persistence callback. This complete type-and-configuration helper leaves the authoritative lookup to your application: + +```typescript +import type { + AgUiInterruptPersistence, + InterruptSessionSnapshot, + ThreadSnapshot, +} from '@threadplane/ag-ui'; + +export type ReconciliationResult = + | { status: 'unknown' } + | { + status: 'pending' | 'acknowledged' | 'completed'; + committed: ThreadSnapshot; + session: InterruptSessionSnapshot; + }; + +export function persistenceForApplication( + namespace: string, + store: AgUiInterruptPersistence['store'], + reconcile: NonNullable, +): AgUiInterruptPersistence { + return { namespace, store, reconcile }; +} +``` + +Your backend's durable execution/checkpoint records must establish the outcome and supply enough information for your application to construct both snapshots. Preserve attempt identity and its captured decision when authoritatively reporting that the same attempt remains pending. Do not manufacture `pending` from a failed fetch or simply return the stored client record as proof. Return `unknown` when the outcome cannot be established; recovery remains blocked. Threadplane supplies no reconciliation HTTP endpoint. + +`pending` requires a pending session; `acknowledged` requires an acknowledged session. `completed` requires either `none`, or a newly paused `pending` batch with a greater generation and no retained attempt. A retained resume input survives reconciliation only when the attempt ID, run ID, and generation still match. A completed old action must never be resubmitted to answer a new pause. + +### Recovery controls + +The standalone component above already awaits `agent.ready` and displays hydration and submission errors. To add explicit reconciliation and retry, place these **component-member fragments** inside that class; they reuse its `agent`, `hydrated`, `busy`, and `error` fields and its `signal` import. Configure persistence with an authoritative reconciler first. + +```typescript +readonly retryAttemptId = signal(undefined); + +async reconcile(): Promise { + if (this.busy() || this.agent.isLoading()) return; + this.busy.set(true); + this.error.set(''); + this.retryAttemptId.set(undefined); + try { + await this.agent.reconcileInterrupt(); + this.hydrated.set(true); + const session = this.agent.interruptSession(); + if (session.phase === 'pending' && session.attempt) { + this.retryAttemptId.set(session.attempt.id); + } + } catch (error) { + this.error.set(error instanceof Error ? error.message : 'Recovery failed'); + } finally { + this.busy.set(false); + } +} + +async retryRetained(): Promise { + if (this.busy() || this.agent.isLoading()) return; + const session = this.agent.interruptSession(); + if (session.phase !== 'pending' || !session.attempt || + session.attempt.id !== this.retryAttemptId()) return; + this.busy.set(true); + this.error.set(''); + this.retryAttemptId.set(undefined); + try { + await this.agent.retry(); + } catch (error) { + this.error.set(error instanceof Error ? error.message : 'Retry failed'); + } finally { + this.busy.set(false); + } +} +``` + +Add this **template fragment** to that component's template. Reconciliation updates state and enables a separate retry button only for the retained pending attempt; it never submits a decision itself. + +```html + +@if (retryAttemptId() && agent.interruptSession().phase === 'pending') { + +} +``` + Providers dispose the adapter with their injector. If you create it with `toAgent()` directly, call `agent.dispose()` when finished. Disposal unsubscribes and stops local work; it does not delete or cancel backend checkpoints. ### Migration note @@ -180,14 +471,14 @@ Mixed native and compatibility events now select native transport by default reg ## Cross-adapter parity -The component code is the same; moving this component to a LangGraph deployment changes one import: +The basic demo card uses the shared display and decision contract. Its injection import changes when moving to LangGraph: ```diff - import { injectAgent } from '@threadplane/ag-ui'; + import { injectAgent } from '@threadplane/langgraph'; ``` -The `interrupt()` signal and `submit({ resume })` are part of the runtime-neutral `Agent` contract, and `` is written against it, so switching adapters is a provider change rather than a component rewrite. The [LangGraph interrupts guide](/docs/langgraph/guides/interrupts) covers the parts of the pattern that live above the wire: typed payloads, multi-step approvals, and timeout strategies. +The `interrupt()` signal and `submit({ resume })` are part of the runtime-neutral `Agent` contract, and `` is written against it. Configure the matching provider and preserve the backend's payload contract. The lifecycle-aware examples on this page additionally use AG-UI's `interruptSession()`, `interruptGeneration`, `ready`, and `reconcileInterrupt()` extensions; those are not interchangeable with LangGraph APIs. LangGraph restoration uses server checkpoints, and its retry replays a captured submission rather than an AG-UI persistence claim. The [LangGraph interrupts guide](/docs/langgraph/guides/interrupts) covers its typed payloads, multi-step approvals, and timeout strategies. ## What's Next diff --git a/apps/website/content/docs/ag-ui/guides/testing.mdx b/apps/website/content/docs/ag-ui/guides/testing.mdx index 264144b34..a8232c95b 100644 --- a/apps/website/content/docs/ag-ui/guides/testing.mdx +++ b/apps/website/content/docs/ag-ui/guides/testing.mdx @@ -212,3 +212,54 @@ describe('scripted AG-UI events', () => { ``` `customEvents()` is the AG-UI-specific signal — `toAgent()` returns an `AgUiAgent`, so it is reachable directly here without a cast, exactly as it is through `injectAgent()`. + +## Verify Mastra approval and cancellation with aimock + +The Mastra browser suite checks each decision at three points: the outgoing command contains the decision and observed correlation IDs, the backend tool result matches that decision, and the visible assistant reply agrees with the tool result. A disappearing approval card alone is insufficient evidence of completion. + +Run the six scenarios from the repository root with deterministic aimock replay: + +```sh +npx nx e2e cockpit-runtimes-mastra-angular +``` + +The application uses `provideAgent({ url: '/agent', interruptTransport: 'mastra-command' })`. Approval sends `command.resume: { approved: true }`; cancellation sends `{ approved: false }`. Both carry `command.interruptEvent: { toolCallId, runId }` under `forwardedProps`, using the suspended run's observed identifiers. + +Keep these continuation entries separate inside an aimock fixture's `fixtures` array: + +```json +[ + { + "match": { + "userMessage": "reserve", + "hasToolResult": true, + "toolResultContains": "Reserved North Pines" + }, + "response": { "content": "North Pines is reserved for 2 nights — confirmation TP-0288." } + }, + { + "match": { + "userMessage": "reserve", + "hasToolResult": true, + "toolResultContains": "Nothing was booked." + }, + "response": { "content": "The reservation for the North Pines campsite was not booked." } + } +] +``` + +The initial tool-call fixture must set `hasToolResult: false`. Matching a continuation only by the user prompt and presence of a tool result can replay an approval after cancellation. Match the backend outcome with `toolResultContains` so the two decisions cannot share a reply. + +For live-model verification, configure `OPENAI_API_KEY` in the environment and run each decision in a fresh process with a fresh capture directory: + +```sh +# Use new directories for every verification session. +AIMOCK_MODE=record AIMOCK_RECORD_DIR=/tmp/public-docs-approve-recordings \ + npx nx e2e cockpit-runtimes-mastra-angular --grep='Approve sends' +AIMOCK_MODE=record AIMOCK_RECORD_DIR=/tmp/public-docs-cancel-recordings \ + npx nx e2e cockpit-runtimes-mastra-angular --grep='Cancel sends' +``` + +Aimock can reuse newly recorded matches within a process, and generated matchers do not distinguish the two tool-result payloads. Separate processes and unused capture directories prevent the second decision from reusing the first decision's response. Each test run also uses a fresh local database. Confirm new upstream captures for each decision before treating a passing run as live-model evidence; replay alone demonstrates deterministic integration behavior. + +The verified scenario is North Pines for two nights, totaling $90. The local tool returns a synthetic confirmation on approval or `Nothing was booked.` on cancellation; it never contacts an external reservation service. Check both the backend result and the visible reply. Live provider wording varies, while replay wording is deterministic. The cancellation continuation was captured from `gpt-4o-mini` through aimock record mode on 2026-09-09. diff --git a/apps/website/content/docs/ag-ui/reference/event-mapping.mdx b/apps/website/content/docs/ag-ui/reference/event-mapping.mdx index 479c91289..d276e6006 100644 --- a/apps/website/content/docs/ag-ui/reference/event-mapping.mdx +++ b/apps/website/content/docs/ag-ui/reference/event-mapping.mdx @@ -77,7 +77,7 @@ The adapter exposes `messages`, `status`, `isLoading`, `error`, `toolCalls`, `st | --- | --- | --- | | `RUN_STARTED` | `status`, `isLoading`, `error`, `interrupt`, `interruptSession`, `customEvents`, `subagents` | Sets `status` to `running` and `isLoading` to `true`; clears the error, visible interrupt, custom events, subagents, and unfinished tool-argument buffer. A matching resume becomes `acknowledged`; the claim is retained until a terminal outcome, rather than treated as completed. Ignored when the run id does not match this delivery. | | `RUN_FINISHED` (no outcome, or a success outcome) | `status`, `isLoading`, `messages`, `interruptSession` | Settles the run and returns to `idle`. A collected compatibility interrupt becomes pending at this boundary; otherwise the run completes. Ignored on the same run-id gate as `RUN_STARTED`. | -| `RUN_FINISHED` (outcome `{ type: 'interrupt' }`) | `interrupt`, `interruptSession`, `status`, `isLoading` | Records the full native batch as pending, projects `{ id, value: { interrupts, runId }, resumable: true }`, and returns to `idle`. In default `auto` mode the native batch takes precedence over compatibility events in either arrival order. | +| `RUN_FINISHED` (outcome `{ type: 'interrupt' }`) | `interrupt`, `interruptSession`, `status`, `isLoading` | Records the full native batch as pending and returns to `idle`. In `auto` or `protocol`, projects `{ id, value: { interrupts, runId }, resumable: true }`; explicit command profiles project the compatibility interrupt. In default `auto` mode the native batch takes precedence over compatibility events in either arrival order. | | `RUN_ERROR` | `status`, `isLoading`, `error` | Ends the run as failed, sets `status` to `error`, stops loading, and stores the event's `message` as an error (the raw event when no message is present). Ignored on the same run-id gate as `RUN_STARTED`. | | `STEP_STARTED`, `STEP_FINISHED` | none | Not reduced. Node boundaries pass through untouched. | diff --git a/apps/website/content/docs/chat/components/chat-interrupt-panel.mdx b/apps/website/content/docs/chat/components/chat-interrupt-panel.mdx index 0ca85673b..d0ab803dc 100644 --- a/apps/website/content/docs/chat/components/chat-interrupt-panel.mdx +++ b/apps/website/content/docs/chat/components/chat-interrupt-panel.mdx @@ -74,9 +74,68 @@ The panel emits an `InterruptAction`, and the component turns the two actions it `submit({ resume })` continues the paused run instead of starting a new one, and whatever you put in `resume` is exactly what `interrupt()` returns on the server. -`chat-interrupt-panel` emits the action and stops there. Leaving an action unhandled, as this example leaves Edit and Respond, means the run stays paused and the panel stays on screen. Handle every button you are willing to show, or wrap the panel in your own template so the unused ones never appear. +`chat-interrupt-panel` emits the action and stops there. Leaving an action unhandled, as this example leaves Edit and Respond, means the run stays paused and the panel stays on screen. All four buttons always render; a wrapper does not hide individual actions. For a two-button UI, build custom controls with the lower level primitive below. +### A complete panel component + +This complete standalone Angular component uses the LangGraph provider configured above. The output binding is `(action)`. Accept and Ignore submit the flight tool's strings; Edit and Respond display an explanation and leave the run paused. The component hides the panel while submitting and displays rejected operations and agent errors. + +```typescript +import { Component, signal } from '@angular/core'; +import { ChatInterruptPanelComponent, type InterruptAction } from '@threadplane/chat'; +import { injectAgent } from '@threadplane/langgraph'; + +@Component({ + selector: 'app-flight-decision', + standalone: true, + imports: [ChatInterruptPanelComponent], + template: ` + @if (busy() || agent.isLoading()) { +

Working…

+ } @else { + + } + @if (notice()) {

{{ notice() }}

} + @if (error() || agent.error()?.message; as message) { +

{{ message }}

+ } + `, +}) +export class FlightDecisionComponent { + readonly agent = injectAgent(); + readonly busy = signal(false); + readonly error = signal(''); + readonly notice = signal(''); + + async onAction(action: InterruptAction): Promise { + if (this.busy() || this.agent.isLoading()) return; + this.notice.set(''); + if (action === 'edit' || action === 'respond') { + this.notice.set('This example supports Accept and Ignore only.'); + return; + } + this.busy.set(true); + this.error.set(''); + try { + if (action === 'accept') { + await this.agent.submit({ resume: 'confirm' }); + } else if (action === 'ignore') { + await this.agent.submit({ resume: 'cancel' }); + } + } catch (error) { + this.error.set(error instanceof Error ? error.message : 'Decision failed'); + } finally { + this.busy.set(false); + } + } +} +``` + +`confirm` and `cancel` belong to this flight tool's backend contract. A refund or Mastra tool may instead expect `{ approved: true }` or `{ approved: false }`; the button label does not define the resume payload. A protocol-native AG-UI cancelled entry has no payload and must identify the pending interrupt. + +The panel accepts the runtime-neutral `Agent`, but it does not coordinate batches, retain a rendered generation, hydrate storage, or reconcile uncertain outcomes. Applications using AG-UI's `interruptSession()`, `interruptGeneration`, `ready`, or `reconcileInterrupt()` need adapter-aware controls. LangGraph restores pending interrupts from its server checkpoint. In either adapter, disappearance of the panel alone is not evidence that the backend completed the decision. + ## Import ```typescript diff --git a/apps/website/content/docs/choosing-an-adapter/index.mdx b/apps/website/content/docs/choosing-an-adapter/index.mdx index 8cddd8b6b..68af03b7c 100644 --- a/apps/website/content/docs/choosing-an-adapter/index.mdx +++ b/apps/website/content/docs/choosing-an-adapter/index.mdx @@ -116,13 +116,16 @@ Shared state works on Strands, but it is snapshot-only and it is opt-in per tool AWS Strands and Microsoft Agent Framework signal an interrupt only through the protocol-standard `RUN_FINISHED` outcome, `{ type: 'interrupt', interrupts: [...] }`. The LangGraph bridge signals it only through a `CUSTOM` event named `on_interrupt`. Mastra emits both. -The adapter detects either convention, and within a single run the first signal wins. +The adapter retains both conventions in one interrupt session. In `auto` mode the native batch takes precedence regardless of arrival order. **Resume payloads are not portable, so the adapter shapes them per runtime.** -Mastra reads `forwardedProps.command.interruptEvent`, carrying a tool-call id and a run id. -AWS Strands and Microsoft Agent Framework both read the protocol-standard top-level `resume` array, one `{ interruptId, status, payload }` entry per interrupt; Microsoft additionally expects an entry for every pending interrupt. +Mastra reads the decision from `forwardedProps.command.resume` and the suspended tool's identifiers from `command.interruptEvent`. Configure `interruptTransport: 'mastra-command'` for this integration because it also emits native outcomes. +AWS Strands and Microsoft Agent Framework both read the protocol-standard top-level `resume` array. The adapter requires every pending interrupt ID exactly once. Resolved entries carry the decision as `payload`; cancelled entries omit it. The LangGraph bridge reads `forwardedProps.command.resume`. -You pass one neutral `submit({ resume })`, and the adapter derives the wire shape from how the interrupt arrived. +You pass one neutral `submit({ resume })`, and the adapter shapes the request using the observed batch and configured transport profile. The backend still defines what the decision payload means. + +**Recovery belongs to the adapter and backend.** +AG-UI can restore client state through an application-owned `persistence` store with atomic compare-and-swap and authoritative reconciliation. The native LangGraph adapter uses LangGraph's thread and checkpoint APIs. Neither a shared Angular approval component nor a stored client record guarantees exactly-once server execution or recreates a lost checkpoint. **The Mastra row is hosted on its own lane.** Its cells come from a real Mastra server driven with live model calls, and its transcripts are committed and replayed like the others. diff --git a/apps/website/content/docs/langgraph/guides/interrupts.mdx b/apps/website/content/docs/langgraph/guides/interrupts.mdx index 533414c25..5d6d37acf 100644 --- a/apps/website/content/docs/langgraph/guides/interrupts.mdx +++ b/apps/website/content/docs/langgraph/guides/interrupts.mdx @@ -1,5 +1,5 @@ --- -description: How the interrupts example drafts a refund, pauses at interrupt(), and resumes from an approval card, plus typed payloads and timeout strategies +description: Pause and resume a LangGraph refund workflow, with typed payloads, captured-command retry, server checkpoint restoration, and timeout strategies --- # Interrupts @@ -47,7 +47,7 @@ Splitting extraction from narration is deliberate: the card needs typed fields, Notice the `kind` field on the payload. It is not required by LangGraph; it is how the frontend tells this interrupt apart from any other one the graph might raise. Notice too that the node validates what came back: a resume value that is not a dictionary, or one whose `approved` is missing or false, is treated as a rejection. -LangGraph resumes by running the interrupting node again, and `interrupt()` returns the resume value instead of pausing a second time. Every line above the `interrupt()` call therefore runs twice, so keep that stretch free of side effects. Reading state, as the example does, is safe; charging a card there is not. +LangGraph resumes by running the interrupting node again, and `interrupt()` returns the resume value instead of pausing a second time. Every line above the `interrupt()` call runs again, including on later retries, so keep that stretch free of side effects. Reading state, as the example does, is safe; charging a card there is not. ### Routing on the decision @@ -131,6 +131,10 @@ LangGraph re-runs the interrupting node, `interrupt()` returns the resume value, Before retrying a resume whose server outcome is unknown, inspect the thread's current checkpoint. A transport failure does not prove the server rejected the decision. Checkpoint hydration restores pending interrupts when you reconnect to the same thread; retaining the thread ID and the server checkpoint is required. This adapter's retry support does not add a client claim ledger, compare-and-swap storage, or transactional rollback of streamed client state. +The retry is retained in the current client instance; reopening a browser restores the server checkpoint, not that instance's captured retry command. Inspect the restored pending interrupt and the backend outcome before deciding what action to offer. Neither a stopped stream nor a dismissed approval card proves that the backend completed or cancelled the work. + +The runtime-neutral contract supplies `interrupt()` for display and `submit({ resume })` for the application's decision. AG-UI's `persistence`, `ready`, `interruptSession()`, `interruptGeneration`, and `reconcileInterrupt()` are adapter-specific extensions, not LangGraph restoration options. Its `requestNotDispatched` retry rule also does not describe LangGraph's captured-command retry. Server checkpoint retention and safe handling of repeated side effects remain backend responsibilities. + ### Migration note Command-only resumes and updates now remain available to `retry()` even when the submitted message payload was null. Code that previously resubmitted the command manually can use `retry()` after confirming it is safe to repeat. Restore an existing thread through its checkpoint before offering an approval again, and keep side effects above `interrupt()` safe to re-execute. @@ -264,7 +268,7 @@ Avoid running server-side and client-side timeouts together. If both fire, the s -Because interrupts are checkpointed, the operator can close the browser, come back hours later, and still approve or reject the pending action. The graph state is frozen in the checkpoint store, not in browser memory. +With a durable server checkpointer and the same thread ID, the operator can close the browser and later restore an action that is still pending. A lost or expired checkpoint cannot be recreated from browser state; another operator or a server timeout may also have already answered it. ## What's Next diff --git a/apps/website/content/docs/runtimes/aws-strands/how-it-connects.mdx b/apps/website/content/docs/runtimes/aws-strands/how-it-connects.mdx index ad66cba7b..a8b27c20a 100644 --- a/apps/website/content/docs/runtimes/aws-strands/how-it-connects.mdx +++ b/apps/website/content/docs/runtimes/aws-strands/how-it-connects.mdx @@ -32,7 +32,7 @@ Strands signals an interrupt through the protocol-standard run outcome and never } ``` -This is the opposite of the LangGraph bridge, which signals interrupts only through a `CUSTOM` event named `on_interrupt` and never sets an outcome. The adapter detects either convention; within a single run, the first signal it sees wins. +This is the opposite of the LangGraph bridge, which signals interrupts only through a `CUSTOM` event named `on_interrupt` and never sets an outcome. The adapter retains both conventions in one session; native batches take precedence in `auto` mode regardless of arrival order. The reducer originally keyed interrupts on `on_interrupt` alone, which meant a Strands run finalized as a success with a dangling approval call and an undefined `interrupt()`. That was an adapter defect, and it is fixed. @@ -48,7 +48,7 @@ Strands reads resume data from the protocol-standard top-level `resume` array, o } ``` -Application code does not assemble that. You call the neutral `submit({ resume })`, and the adapter derives the wire shape from how the interrupt arrived. The same call against a Mastra backend produces `forwardedProps.command.interruptEvent` instead, and against the LangGraph bridge produces `forwardedProps.command.resume`. +Application code does not assemble that. You call the neutral `submit({ resume })`, and the adapter derives the wire shape from how the interrupt arrived. For Mastra, explicitly select `mastra-command`: the decision goes in `forwardedProps.command.resume` alongside the correlation fields in `command.interruptEvent`. The LangGraph bridge uses `forwardedProps.command.resume`. ## State is snapshot-only diff --git a/apps/website/content/docs/runtimes/getting-started/introduction.mdx b/apps/website/content/docs/runtimes/getting-started/introduction.mdx index 3c1c8cc04..bc3ba0649 100644 --- a/apps/website/content/docs/runtimes/getting-started/introduction.mdx +++ b/apps/website/content/docs/runtimes/getting-started/introduction.mdx @@ -76,7 +76,7 @@ export class App { } ``` -The examples in this repository wrap that same `provideAgent()` call in a factory, because they resolve their endpoint at runtime rather than hard-coding it, and each one renders a shared-state panel and an approval card around ``. Neither difference reaches the adapter: the configuration it receives is still a single `url`. +The examples in this repository wrap that same `provideAgent()` call in a factory, because they resolve their endpoint at runtime rather than hard-coding it, and each one renders a shared-state panel and an approval card around ``. The Mastra example also selects `interruptTransport: 'mastra-command'` so its decisions use the backend's command format even when native outcomes are present. What changes is the backend, its hosting lane, and the wire conventions it happens to use. The **How It Connects** page for each runtime records those conventions as they were measured. @@ -84,9 +84,9 @@ What changes is the backend, its hosting lane, and the wire conventions it happe Three differences turned up repeatedly, and each runtime page returns to them. -**Interrupts arrive by two different conventions.** AWS Strands and Microsoft Agent Framework signal an interrupt only through the protocol-standard `RUN_FINISHED` outcome. The LangGraph bridge signals it only through a `CUSTOM` event named `on_interrupt`. Mastra emits both. The adapter accepts either, and within a single run the first signal wins. +**Interrupts arrive by two different conventions.** AWS Strands and Microsoft Agent Framework signal an interrupt only through the protocol-standard `RUN_FINISHED` outcome. The LangGraph bridge signals it only through a `CUSTOM` event named `on_interrupt`. Mastra emits both. The adapter retains both conventions in one session; native batches take precedence in `auto` mode regardless of arrival order. -**Resume payloads are not portable.** The adapter derives the wire shape from how the interrupt arrived, so application code passes one neutral `submit({ resume })` regardless of runtime. +**Resume payloads are not portable.** The adapter uses the observed batch and configured transport profile to shape `submit({ resume })`. Mastra requires `mastra-command`; native batches require a decision for every pending ID. The backend defines the decision payload. **Subagents now stream on every runtime measured here** — AWS Strands, Microsoft Agent Framework, and Mastra. Each backend ships a small emitter that translates its native delegation signals into the protocol's `SUBAGENT_*` events, which `@threadplane/ag-ui` consumes directly. The per-runtime pages describe the emitter and link the wire capture behind each cell. diff --git a/apps/website/content/docs/runtimes/mastra/how-it-connects.mdx b/apps/website/content/docs/runtimes/mastra/how-it-connects.mdx index 4eec5993f..72135d829 100644 --- a/apps/website/content/docs/runtimes/mastra/how-it-connects.mdx +++ b/apps/website/content/docs/runtimes/mastra/how-it-connects.mdx @@ -1,6 +1,6 @@ --- title: How It Connects -description: Measured AG-UI wire behavior for Mastra: both interrupt conventions, an interruptEvent resume shape, and JSON-Patch state deltas. +description: Measured AG-UI wire behavior for Mastra: both interrupt conventions, correlated approval and cancellation commands, and JSON-Patch state deltas. --- # How Mastra Connects @@ -24,23 +24,59 @@ In your own application this is still an ordinary `provideAgent({ url: '/agent', Mastra is the only measured runtime that signals an interrupt twice: it emits a `CUSTOM` event named `on_interrupt` **and** finishes the run with the protocol-standard interrupt outcome. -AWS Strands and Microsoft Agent Framework emit only the outcome. The LangGraph bridge emits only `on_interrupt`. The adapter retains both forms in one session. The native batch drives the display projection, while `interruptSession().legacy?.value` preserves the Mastra suspend details used by the approval card. +AWS Strands and Microsoft Agent Framework emit only the outcome. The LangGraph bridge emits only `on_interrupt`. The adapter retains both forms in one session. With the explicit `mastra-command` profile, `interrupt()` projects the compatibility interrupt; `interruptSession().legacy?.value` also preserves its suspend details for the approval card. In `auto` or `protocol`, a native batch drives the display projection instead. -## Resume uses interruptEvent +## Resume carries a decision and correlation -Mastra reads resume data from `forwardedProps.command.interruptEvent`, carrying a tool-call id and a run id: +Mastra reads the decision from `forwardedProps.command.resume`. The adjacent `interruptEvent` identifies the suspended work by tool-call ID and run ID. These partial request bodies show both decisions; the identifiers are illustrative, and the adapter copies actual identifiers from the observed interrupt. + +Approval: + +```json +{ + "forwardedProps": { + "command": { + "resume": { "approved": true }, + "interruptEvent": { + "toolCallId": "observed-tool-call-id", + "runId": "observed-run-id" + } + } + } +} +``` + +Cancellation: ```json { "forwardedProps": { "command": { - "interruptEvent": { "toolCallId": "...", "runId": "..." } + "resume": { "approved": false }, + "interruptEvent": { + "toolCallId": "observed-tool-call-id", + "runId": "observed-run-id" + } } } } ``` -That is a third distinct shape. Strands and Microsoft Agent Framework read a top-level `resume` array; the LangGraph bridge reads `forwardedProps.command.resume`. Application code passes one neutral `submit({ resume })`. The Mastra provider selects `interruptTransport: 'mastra-command'` explicitly because this backend emits both compatibility and native interrupts, and `auto` otherwise selects the native wire shape. +Application code submits the decision without constructing correlation fields: + +```typescript +import { provideAgent } from '@threadplane/ag-ui'; + +provideAgent({ url: '/agent', interruptTransport: 'mastra-command' }); +// From the injected agent after the approval is pending: +// await agent.submit({ resume: { approved: true } }); +// Or, for cancellation: +// await agent.submit({ resume: { approved: false } }); +``` + +Strands and Microsoft Agent Framework read a top-level `resume` array; the LangGraph AG-UI bridge reads `forwardedProps.command.resume`. Mastra needs both the decision and correlation fields. Select `mastra-command` explicitly because this backend emits both compatibility and native interrupts, and `auto` otherwise selects the native wire shape. Mastra's `{ approved: false }` is an application decision, distinct from a native protocol entry with `status: 'cancelled'` and no payload. + +Approval and cancellation verification on 2026-09-09 covered the synthetic North Pines reservation for two nights, totaling $90. Approve returns a synthetic confirmation; Cancel returns a backend tool result containing `Nothing was booked.` and a consistent assistant reply. Neither path contacts an external reservation service. The earlier wire measurements remain dated 2026-08-31. ## Suspend and resume require persistent storage diff --git a/apps/website/content/docs/runtimes/mastra/overview.mdx b/apps/website/content/docs/runtimes/mastra/overview.mdx index e9ec0a4ee..21a0c56b1 100644 --- a/apps/website/content/docs/runtimes/mastra/overview.mdx +++ b/apps/website/content/docs/runtimes/mastra/overview.mdx @@ -11,7 +11,7 @@ description: How the Mastra example is built, from the Node service that serves The Run tab shows the prebuilt `` composition beside a panel that mirrors the agent's packing list. Three welcome suggestions set it up: "Start a packing list" seeds a titled list with a tent and two sleeping bags and the panel fills in as the agent writes it, "Check trail conditions" makes the agent call a backend tool, and "Reserve the campsite" asks for two nights at North Pines. -The reservation is the interesting one. It suspends the run rather than booking anything, and an approval card appears with the campsite, the number of nights, and the total. Approve resumes the agent with a confirmation number, and Cancel resumes it with a refusal. +The reservation pauses the run and displays North Pines, two nights, and a $90 total. Approve resumes with a synthetic confirmation; Cancel resumes with a tool result containing `Nothing was booked.` and an assistant reply agreeing with that result. Both paths were verified on 2026-09-09. The tool is local and does not make an external reservation. Ask for a weather forecast and the agent delegates to a second agent instead of answering itself. The child's answer streams into a subagent card in the transcript while it is still being written. @@ -29,7 +29,7 @@ Nothing in this tool is protocol-aware; the bridge turns the call into `TOOL_CAL ### Pausing a run for approval -A tool becomes a human-in-the-loop step by adding a `suspendSchema` and a `resumeSchema` to that same shape. The first call arrives with no resume data, so the body calls `suspend()` with the payload the approval card renders. The resumed call arrives with the operator's decision and either books the site or reports the refusal. +A tool becomes a human-in-the-loop step by adding a `suspendSchema` and a `resumeSchema` to that same shape. The first call arrives with no resume data, so the body calls `suspend()` with the payload the approval card renders. The resumed call arrives with the operator's decision and returns a synthetic confirmation or decline. @@ -81,15 +81,18 @@ The agent handed to the bridge is wrapped first, which is what makes the subagen ### The agent provider -`provideAgent()` registers the agent once for the whole application, and it is the only provider the `` composition requires. Nothing about it is Mastra-specific: this is the same config every AG-UI example uses. The example passes a factory because it resolves its endpoint at runtime from the host that serves the demo. +`provideAgent()` registers the agent once for the whole application. Mastra requires `interruptTransport: 'mastra-command'` because its backend consumes a command even though it also emits native interrupt outcomes. The example passes a factory because it resolves its endpoint at runtime from the host that serves the demo. Your own application passes the URL directly: ```typescript +import { provideAgent } from '@threadplane/ag-ui'; + provideAgent({ url: 'https://your-backend.example.com/agent', + interruptTransport: 'mastra-command', }); ``` diff --git a/apps/website/content/docs/runtimes/mastra/quickstart.mdx b/apps/website/content/docs/runtimes/mastra/quickstart.mdx index 545a650e1..19829ae88 100644 --- a/apps/website/content/docs/runtimes/mastra/quickstart.mdx +++ b/apps/website/content/docs/runtimes/mastra/quickstart.mdx @@ -76,12 +76,24 @@ The app opens with three welcome suggestions, one per surface. 1. **Start a packing list** submits *"Start a packing list titled 'Yosemite Weekend' with a tent (1) and two sleeping bags."* The agent writes the list into Mastra working memory, which reaches the frontend as a `STATE_SNAPSHOT` followed by real JSON-Patch `STATE_DELTA` events, and the side panel fills in as it is written. 2. **Check trail conditions** submits *"What are the conditions at Yosemite Valley right now?"* and the agent calls the backend tool `check_conditions`, which returns a fixed forecast so the demo behaves the same on every run. 3. **Reserve the campsite** submits *"Please reserve the North Pines campsite for 2 nights."* `reserve_campsite` calls Mastra's `suspend()` instead of booking anything, and an approval card shows the site, the number of nights, and the total. -4. Approve or cancel the card. The run resumes from the snapshot persisted in LibSQL and the agent either confirms the reservation with a confirmation number or reports that nothing was booked. +4. Approve or cancel the card for North Pines, two nights, $90. The run resumes from the snapshot persisted in LibSQL. Approve returns a synthetic confirmation; Cancel returns a backend tool result containing `Nothing was booked.` and a consistent assistant reply. This local tool does not contact an external reservation service. Both decisions were verified on 2026-09-09. 5. For the subagent surface, ask for a forecast — for example *"What is the weather forecast for North Pines this weekend?"* The agent delegates to the `weather_forecaster` child agent rather than answering itself, and the child's answer streams into a subagent card while it is still being written. +## Configure the resume transport + +The example config selects the command transport explicitly. Use the same selection in your own application: + +```typescript +import { provideAgent } from '@threadplane/ag-ui'; + +provideAgent({ url: '/agent', interruptTransport: 'mastra-command' }); +``` + +After an approval becomes pending, submit `{ resume: { approved: true } }` or `{ resume: { approved: false } }`. The adapter adds the observed tool-call and run IDs to the command. + ## Running the service's own tests ```bash diff --git a/apps/website/content/docs/runtimes/microsoft-agent-framework/how-it-connects.mdx b/apps/website/content/docs/runtimes/microsoft-agent-framework/how-it-connects.mdx index d102d807a..347394a37 100644 --- a/apps/website/content/docs/runtimes/microsoft-agent-framework/how-it-connects.mdx +++ b/apps/website/content/docs/runtimes/microsoft-agent-framework/how-it-connects.mdx @@ -32,13 +32,13 @@ A tool declares `approval_mode="always_require"`, and the bridge finishes the ru } ``` -This matches AWS Strands and differs from the LangGraph bridge, which signals interrupts only through a `CUSTOM` event named `on_interrupt`. The adapter detects either convention; within a single run, the first signal it sees wins. +This matches AWS Strands and differs from the LangGraph bridge, which signals interrupts only through a `CUSTOM` event named `on_interrupt`. The adapter retains both conventions in one session; native batches take precedence in `auto` mode regardless of arrival order. ## Resume must address every pending interrupt -Like Strands, this runtime reads the protocol-standard top-level `resume` array of `{ interruptId, status, payload }` entries. Unlike Strands, it expects an entry for **every** pending interrupt, not only the one the user just answered. +Like Strands, this runtime reads the protocol-standard top-level `resume` array of `{ interruptId, status, payload }` entries. The adapter requires an entry for **every** pending interrupt on either native backend. Cancelled entries must omit `payload`. -Application code does not assemble that. You call the neutral `submit({ resume })`, and the adapter derives the wire shape, including the entries for interrupts still outstanding. +For one pending interrupt, `submit({ resume: decision })` is shorthand for one resolved entry. For a batch, pass a `resume` array with a decision for every observed interrupt ID. The adapter validates correlation; it does not invent answers for outstanding interrupts. ## State streams predictively diff --git a/apps/website/content/docs/runtimes/microsoft-agent-framework/overview.mdx b/apps/website/content/docs/runtimes/microsoft-agent-framework/overview.mdx index 8811bef29..7d58d9804 100644 --- a/apps/website/content/docs/runtimes/microsoft-agent-framework/overview.mdx +++ b/apps/website/content/docs/runtimes/microsoft-agent-framework/overview.mdx @@ -119,9 +119,9 @@ That is not true of every runtime. On AWS Strands shared state is snapshot-only ## How the interrupt travels -Microsoft Agent Framework signals an interrupt only through the protocol-standard `RUN_FINISHED` outcome, `{ type: 'interrupt', interrupts: [...] }`. It never uses the LangGraph bridge's `CUSTOM` event named `on_interrupt`. The adapter detects either convention, and within a single run the first signal wins. +Microsoft Agent Framework signals an interrupt only through the protocol-standard `RUN_FINISHED` outcome, `{ type: 'interrupt', interrupts: [...] }`. It never uses the LangGraph bridge's `CUSTOM` event named `on_interrupt`. The adapter retains both conventions in one session; native batches take precedence in `auto` mode regardless of arrival order. -The resume payload is shaped the same way. Because the interrupt arrived as an outcome, `submit({ resume })` goes out as the protocol-standard top-level `resume` array, one `{ interruptId, status, payload }` entry per pending interrupt — and this bridge expects an entry for every pending interrupt, not only the one the user answered. You pass one neutral resume value and the adapter builds that array. +The resume payload is shaped the same way. Because the interrupt arrived as an outcome, `submit({ resume })` goes out as the protocol-standard top-level `resume` array, one `{ interruptId, status, payload }` entry per pending interrupt — and this bridge expects an entry for every pending interrupt, not only the one the user answered. A single value is shorthand only for a single pending interrupt. For multiple interrupts, supply an entry for each observed ID; the adapter does not invent the remaining decisions. ## What's Next diff --git a/apps/website/public/AGENTS.md b/apps/website/public/AGENTS.md index 1eecee3db..0fad5a52d 100644 --- a/apps/website/public/AGENTS.md +++ b/apps/website/public/AGENTS.md @@ -70,5 +70,17 @@ export class ChatComponent { - Scoped config: re-provide `provideAgent({ apiUrl, assistantId })` in a component `providers` array for a subtree - Testing: use `MockAgentTransport` — never mock `injectAgent()` itself + +## Interrupts and recovery + +- Both adapters expose `interrupt()` and `submit({ resume })`; the backend defines the decision payload. +- AG-UI `auto` prefers native interrupt batches regardless of event order. Answer every pending native ID once; `status: 'cancelled'` entries omit `payload`. +- For Mastra, set `interruptTransport: 'mastra-command'`. The adapter sends the decision in `forwardedProps.command.resume` and observed correlation IDs in `command.interruptEvent`. The campsite example rejects with `{ approved: false }`. +- AG-UI restoration is opt-in through `persistence`: stable `threadId`, scoped namespace, and application-owned atomic compare-and-swap storage. Await `agent.ready` before rendering restored decisions. +- Capture `interruptSession().generation` when rendering and pass it as the `interruptGeneration` submit option to reject stale controls. +- Retry the retained decision only after proven non-dispatch or authoritative reconciliation. Configure `persistence.reconcile` and call `agent.reconcileInterrupt()` for uncertain outcomes; a network error alone does not prove non-execution. +- These recovery APIs are AG-UI extensions. LangGraph uses its own thread/checkpoint APIs. Client storage cannot recreate a lost backend checkpoint or guarantee exactly-once side effects. +- An approval card closing or local streaming stopping does not prove backend completion or cancellation. + ## Version check If this file is stale, fetch the latest: https://threadplane.ai/llms-full.txt diff --git a/apps/website/public/CLAUDE.md b/apps/website/public/CLAUDE.md index 1eecee3db..0fad5a52d 100644 --- a/apps/website/public/CLAUDE.md +++ b/apps/website/public/CLAUDE.md @@ -70,5 +70,17 @@ export class ChatComponent { - Scoped config: re-provide `provideAgent({ apiUrl, assistantId })` in a component `providers` array for a subtree - Testing: use `MockAgentTransport` — never mock `injectAgent()` itself + +## Interrupts and recovery + +- Both adapters expose `interrupt()` and `submit({ resume })`; the backend defines the decision payload. +- AG-UI `auto` prefers native interrupt batches regardless of event order. Answer every pending native ID once; `status: 'cancelled'` entries omit `payload`. +- For Mastra, set `interruptTransport: 'mastra-command'`. The adapter sends the decision in `forwardedProps.command.resume` and observed correlation IDs in `command.interruptEvent`. The campsite example rejects with `{ approved: false }`. +- AG-UI restoration is opt-in through `persistence`: stable `threadId`, scoped namespace, and application-owned atomic compare-and-swap storage. Await `agent.ready` before rendering restored decisions. +- Capture `interruptSession().generation` when rendering and pass it as the `interruptGeneration` submit option to reject stale controls. +- Retry the retained decision only after proven non-dispatch or authoritative reconciliation. Configure `persistence.reconcile` and call `agent.reconcileInterrupt()` for uncertain outcomes; a network error alone does not prove non-execution. +- These recovery APIs are AG-UI extensions. LangGraph uses its own thread/checkpoint APIs. Client storage cannot recreate a lost backend checkpoint or guarantee exactly-once side effects. +- An approval card closing or local streaming stopping does not prove backend completion or cancellation. + ## Version check If this file is stale, fetch the latest: https://threadplane.ai/llms-full.txt diff --git a/apps/website/src/app/llms-full.txt/route.ts b/apps/website/src/app/llms-full.txt/route.ts index 4858d80f4..916e18470 100644 --- a/apps/website/src/app/llms-full.txt/route.ts +++ b/apps/website/src/app/llms-full.txt/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; +import { INTERRUPT_GUIDANCE } from '../../lib/interrupt-guidance'; import a2uiApiDocs from '../../../content/docs/a2ui/api/api-docs.json'; import langgraphApiDocs from '../../../content/docs/langgraph/api/api-docs.json'; import agUiApiDocs from '../../../content/docs/ag-ui/api/api-docs.json'; @@ -49,6 +50,7 @@ export async function GET() { '# Threadplane — Full Reference\n\nSee /llms.txt for a compact summary.\n', '## API Reference (TypeDoc)\n\n' + loadApiDocs(), '## Prompt Recipes\n\n' + loadAllPrompts(), + INTERRUPT_GUIDANCE, [ '## Common Gotchas', '', diff --git a/apps/website/src/app/llms.txt/route.ts b/apps/website/src/app/llms.txt/route.ts index eb1024670..1d0b75d99 100644 --- a/apps/website/src/app/llms.txt/route.ts +++ b/apps/website/src/app/llms.txt/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; +import { INTERRUPT_GUIDANCE } from '../../lib/interrupt-guidance'; function loadVersion(): string { const candidates = [ @@ -49,6 +50,8 @@ function buildLlmsTxt(): string { '- mockLangGraphAgent — testing utility with a writable signal-backed LangGraphAgent.', '- runAgentConformance / runAgentWithHistoryConformance — conformance suites for adapter authors.', '', + INTERRUPT_GUIDANCE, + '', '## Minimal LangGraph example', "import { provideAgent, injectAgent } from '@threadplane/langgraph';", "import { ChatComponent } from '@threadplane/chat';", diff --git a/apps/website/src/lib/interrupt-guidance.ts b/apps/website/src/lib/interrupt-guidance.ts new file mode 100644 index 000000000..3bacefba9 --- /dev/null +++ b/apps/website/src/lib/interrupt-guidance.ts @@ -0,0 +1,14 @@ +/** Shared interrupt guidance served by both machine-readable reference routes. */ +export const INTERRUPT_GUIDANCE = [ + '## Interrupts and recovery', + '', + 'Both adapters expose interrupt() and submit({ resume }); the backend defines the decision payload.', + 'AG-UI auto transport prefers native interrupt batches. Answer every native ID exactly once using { interruptId, status, payload }; cancelled entries must omit payload.', + 'For the Mastra integration, configure interruptTransport: "mastra-command". The adapter sends forwardedProps.command.resume plus command.interruptEvent with observed toolCallId and runId when available. Reject the campsite proposal with { approved: false }.', + 'The LangGraph AG-UI bridge uses forwardedProps.command.resume; the native LangGraph adapter uses its SDK command path. Shared UI does not imply identical persistence APIs.', + 'AG-UI persistence is opt-in: supply a stable threadId, scoped namespace, and application-owned store with atomic compareAndSwap. Await agent.ready before showing restored controls.', + 'Capture interruptSession().generation when rendering a decision and pass it as the interruptGeneration submit option to reject stale controls.', + 'A proven requestNotDispatched failure permits retry() of the retained decision. Uncertain delivery requires authoritative reconciliation through persistence.reconcile and agent.reconcileInterrupt(); do not blindly resend.', + 'ready, interruptSession, reconcileInterrupt, and persistence are AG-UI extensions, not guarantees of the neutral Agent contract. Client storage cannot recreate a lost backend checkpoint or guarantee exactly-once side effects.', + 'Closing an approval card or stopping local streaming does not prove backend cancellation or completion. Verify the backend outcome and the visible reply.', +].join('\n'); diff --git a/libs/ag-ui/README.md b/libs/ag-ui/README.md index 18f4470c8..4b6c8d309 100644 --- a/libs/ag-ui/README.md +++ b/libs/ag-ui/README.md @@ -69,7 +69,7 @@ export class AppComponent { } ``` -Both `@threadplane/langgraph` and `@threadplane/ag-ui` expose `provideAgent`/`injectAgent` with the same shape — consumer code is identical regardless of which adapter is wired in. +Both `@threadplane/langgraph` and `@threadplane/ag-ui` expose `provideAgent`/`injectAgent`. Components using the neutral `Agent` contract can share their UI; provider configuration and adapter-specific extensions differ. --- @@ -93,15 +93,25 @@ Which capabilities populate depends on the events the AG-UI backend emits. `subm ### Interrupts (human-in-the-loop) -`agent.interrupt()` is a `Signal` populated from AG-UI `CUSTOM` events with `name: 'on_interrupt'`. The reducer JSON-parses string-serialized `value` payloads automatically (e.g. `ag-ui-langgraph` ships interrupts via `dump_json_safe`), so consumers see the structured object directly. +`agent.interrupt()` is a `Signal` projected from native `RUN_FINISHED` interrupt outcomes or compatibility `CUSTOM on_interrupt` events. Native batches take display precedence in `auto` and `protocol`; explicit command profiles display the compatibility interrupt. String-serialized compatibility values are JSON-parsed automatically. -Resume with `agent.submit({ resume })` — this calls `runAgent({ forwardedProps: { command: { resume } } })`, and the server reads `forwarded_props.command.resume` (the `ag-ui-langgraph` convention). +Resume with `agent.submit({ resume })`. Select `interruptTransport` in `provideAgent()` to match the backend: -Pair with `` from `@threadplane/chat` for the approve/reject/edit UX: +| Profile | Resume transport | +| --- | --- | +| `auto` (default) | Prefers a native batch, including mixed native/compatibility delivery; otherwise detects Mastra correlation data or uses the legacy command. | +| `protocol` | Top-level `resume` entries, one per native interrupt ID. | +| `legacy-command` | `forwardedProps.command.resume`, as used by the LangGraph AG-UI bridge. | +| `mastra-command` | `forwardedProps.command.resume` plus `command.interruptEvent` containing the observed tool-call and run IDs. | + +The current Mastra backend requires explicit `mastra-command`: it emits native and compatibility interrupts but consumes the command transport. Native cancellation uses `{ interruptId, status: 'cancelled' }` without a payload; an application's `{ approved: false }` is a resolved decision with backend-defined meaning. + +Pair with `` from `@threadplane/chat` for Approve and Cancel controls. This single-decision example uses a backend that expects `{ approved: boolean }`: ```ts import { Component } from '@angular/core'; import { ChatComponent, ChatApprovalCardComponent } from '@threadplane/chat'; +import type { ChatApprovalAction } from '@threadplane/chat'; import { injectAgent } from '@threadplane/ag-ui'; @Component({ @@ -116,13 +126,14 @@ import { injectAgent } from '@threadplane/ag-ui'; }) export class App { protected readonly agent = injectAgent(); - onAction(a: 'approve' | 'cancel') { - void this.agent.submit({ resume: { approved: a === 'approve' } }); + onAction(action: ChatApprovalAction) { + if (action === 'edit') return; + void this.agent.submit({ resume: { approved: action === 'approve' } }); } } ``` -See `cockpit/ag-ui/interrupts` for a complete working example, and the [LangGraph interrupts guide](https://threadplane.ai/docs/langgraph/guides/interrupts) for the broader HITL contract — the same `Agent.interrupt` / `submit({ resume })` API works across both adapters. +See `cockpit/ag-ui/interrupts` for a complete working example, and the [LangGraph interrupts guide](https://threadplane.ai/docs/langgraph/guides/interrupts) for that adapter's behavior. Both share `interrupt()` and `submit({ resume })`; AG-UI's `interruptSession`, `ready`, `reconcileInterrupt()`, `dispose()`, `persistence` configuration, and `interruptGeneration` submit option are adapter extensions. Browser persistence cannot restore a lost server checkpoint or prove backend completion. ### Citations diff --git a/libs/chat/README.md b/libs/chat/README.md index 9884f2888..6744b85ca 100644 --- a/libs/chat/README.md +++ b/libs/chat/README.md @@ -110,12 +110,30 @@ Custom content templates for message bubbles, tool call rows, and citation cards ### Human-in-the-loop (interrupts) -`` surfaces the current `AgentInterrupt` from an agent and renders approve/reject controls. `` composes as a dialog for explicit approval workflows. Both emit typed action results (`InterruptAction`, `ChatApprovalAction`) that the caller submits back to the agent. +`` surfaces the current `AgentInterrupt` and emits `accept`, `edit`, `respond`, or `ignore` through its `action` output. `` emits `approve`, `cancel`, or `edit` (when enabled) for explicit approval workflows. The caller maps these actions to the backend's resume payload. ```html - + ``` +```typescript +import { injectAgent } from '@threadplane/langgraph'; +import type { InterruptAction } from '@threadplane/chat'; + +// Component members; add ChatInterruptPanelComponent to component imports. +readonly agent = injectAgent(); + +async onAction(action: InterruptAction): Promise { + if (action === 'accept') { + await this.agent.submit({ resume: 'confirm' }); + } else if (action === 'ignore') { + await this.agent.submit({ resume: 'cancel' }); + } +} +``` + +This handler implements only Accept and Ignore; the panel still renders all four buttons. Use a custom template for a two-button UI. The strings above match the flight demo's backend contract. Other tools may expect `{ approved: boolean }`; a button label does not define a universal resume payload. + ### Tool calls and subagents `` renders in-progress and completed tool calls. Customize per-call layout with `ChatToolCallTemplateDirective` — the `chatToolCallTemplate` input takes a tool name to match, or `"*"` for all; the template context exposes the `ToolCall` (`$implicit`) and its `status`: diff --git a/libs/langgraph/README.md b/libs/langgraph/README.md index 16971f0db..1b9b299cc 100644 --- a/libs/langgraph/README.md +++ b/libs/langgraph/README.md @@ -99,7 +99,7 @@ const pending = chat.interrupt(); // runtime-neutral interrupt value const raw = chat.langGraphInterrupts(); // raw LangGraph Interrupt[] ``` -Resume by calling `chat.submit(response)`. +Resume with `await chat.submit({ resume: response })`, where `response` matches the suspended tool's contract. LangGraph restores execution from its server checkpoint on the same thread. The shared `Agent` interface does not include AG-UI's browser persistence, interrupt session, or reconciliation extensions. ### Tool calls