|
| 1 | +# Interrupts (Human-in-the-Loop) |
| 2 | + |
| 3 | +Interrupts let your AG-UI agent pause mid-run and hand control to a human. The agent proposes an action, the run freezes, your Angular UI shows an approval dialog, the user decides, and the agent resumes with the human's decision. This guide covers the AG-UI adapter specifics. For the broader conceptual model — lifecycle stages, timeout strategies, typed payloads — see the [LangGraph interrupts guide](/docs/langgraph/guides/interrupts). |
| 4 | + |
| 5 | +## The Wire Format |
| 6 | + |
| 7 | +AG-UI interrupts arrive as a `CUSTOM` event with `name: "on_interrupt"`: |
| 8 | + |
| 9 | +```json |
| 10 | +{ |
| 11 | + "type": "CUSTOM", |
| 12 | + "name": "on_interrupt", |
| 13 | + "value": "{\"kind\":\"refund_approval\",\"amount\":47.50,\"customer_id\":\"cus_a8x2k\",\"reason\":\"Duplicate charge\"}" |
| 14 | +} |
| 15 | +``` |
| 16 | + |
| 17 | +Two things to note: |
| 18 | + |
| 19 | +- The `value` is a **JSON string**, not an object. The `ag-ui-langgraph` Python package serializes the interrupt payload via `dump_json_safe` before emitting the event. |
| 20 | +- The adapter `JSON.parse`s the string automatically. Consumers always see the structured object — you never need to parse it yourself. |
| 21 | + |
| 22 | +**Structuring the payload:** Use a `kind` field so `<chat-approval-card matchKind="…">` can match the right interrupt: |
| 23 | + |
| 24 | +```python |
| 25 | +decision = interrupt({ |
| 26 | + "kind": "refund_approval", |
| 27 | + "amount": amount, |
| 28 | + "customer_id": customer_id, |
| 29 | + "reason": reason, |
| 30 | +}) |
| 31 | +``` |
| 32 | + |
| 33 | +## Reading the Interrupt in Your Component |
| 34 | + |
| 35 | +`injectAgent()` exposes a `interrupt()` signal that is populated whenever the adapter receives an `on_interrupt` CUSTOM event. Pair it with `<chat-approval-card>` from `@threadplane/chat` to render an approval dialog without manual event wiring: |
| 36 | + |
| 37 | +```typescript |
| 38 | +import { Component } from '@angular/core'; |
| 39 | +import { ChatComponent, ChatApprovalCardComponent } from '@threadplane/chat'; |
| 40 | +import { injectAgent } from '@threadplane/ag-ui'; |
| 41 | +import type { ChatApprovalAction } from '@threadplane/chat'; |
| 42 | + |
| 43 | +@Component({ |
| 44 | + standalone: true, |
| 45 | + imports: [ChatComponent, ChatApprovalCardComponent], |
| 46 | + changeDetection: ChangeDetectionStrategy.OnPush, |
| 47 | + template: ` |
| 48 | + <chat [agent]="agent" /> |
| 49 | + <chat-approval-card |
| 50 | + [agent]="agent" |
| 51 | + matchKind="refund_approval" |
| 52 | + title="Refund approval required" |
| 53 | + (action)="onAction($event)" |
| 54 | + /> |
| 55 | + `, |
| 56 | +}) |
| 57 | +export class RefundApprovalComponent { |
| 58 | + protected readonly agent = injectAgent(); |
| 59 | + |
| 60 | + onAction(action: ChatApprovalAction): void { |
| 61 | + if (action === 'approve') { |
| 62 | + void this.agent.submit({ resume: { approved: true } }); |
| 63 | + } else if (action === 'cancel') { |
| 64 | + void this.agent.submit({ resume: { approved: false } }); |
| 65 | + } |
| 66 | + } |
| 67 | +} |
| 68 | +``` |
| 69 | + |
| 70 | +`matchKind` filters on `interrupt().value.kind`. The card renders only when the active interrupt matches — other interrupt kinds are ignored. |
| 71 | + |
| 72 | +## Resuming |
| 73 | + |
| 74 | +Call `agent.submit({ resume })` with your decision object: |
| 75 | + |
| 76 | +```typescript |
| 77 | +// Approve |
| 78 | +void this.agent.submit({ resume: { approved: true } }); |
| 79 | + |
| 80 | +// Reject |
| 81 | +void this.agent.submit({ resume: { approved: false } }); |
| 82 | + |
| 83 | +// Approve with an edited field |
| 84 | +void this.agent.submit({ resume: { approved: true, amount: 35.00 } }); |
| 85 | +``` |
| 86 | + |
| 87 | +Under the hood, `submit({ resume })` calls `runAgent({ forwardedProps: { command: { resume } } })`. The server receives `forwarded_props.command.resume` — the convention the [`ag-ui-langgraph`](https://pypi.org/project/ag-ui-langgraph/) package reads to resume the LangGraph checkpoint. |
| 88 | + |
| 89 | +<Callout type="info" title="Backend reads forwarded_props"> |
| 90 | +In your LangGraph node, `interrupt({...})` returns the `resume` value directly. You do not need to unwrap `forwarded_props` yourself — `ag-ui-langgraph` does that before resuming the graph. |
| 91 | +</Callout> |
| 92 | + |
| 93 | +## End-to-End Example |
| 94 | + |
| 95 | +`cockpit/ag-ui/interrupts` is a complete Angular + Python example: a refund-authorization agent that drafts a refund, pauses for operator approval, and issues (or cancels) based on the decision. |
| 96 | + |
| 97 | +**Angular component** (`cockpit/ag-ui/interrupts/angular/src/app/interrupts.component.ts`): |
| 98 | + |
| 99 | +```typescript |
| 100 | +import { Component, ChangeDetectionStrategy, signal } from '@angular/core'; |
| 101 | +import { |
| 102 | + ChatComponent, |
| 103 | + ChatApprovalCardComponent, |
| 104 | + type ChatApprovalAction, |
| 105 | +} from '@threadplane/chat'; |
| 106 | +import { injectAgent } from '@threadplane/ag-ui'; |
| 107 | + |
| 108 | +@Component({ |
| 109 | + standalone: true, |
| 110 | + imports: [ChatComponent, ChatApprovalCardComponent], |
| 111 | + changeDetection: ChangeDetectionStrategy.OnPush, |
| 112 | + template: ` |
| 113 | + <chat [agent]="agent" /> |
| 114 | + <chat-approval-card |
| 115 | + [agent]="agent" |
| 116 | + matchKind="refund_approval" |
| 117 | + title="Refund approval required" |
| 118 | + [showEdit]="true" |
| 119 | + (action)="onAction($event)" |
| 120 | + /> |
| 121 | + `, |
| 122 | +}) |
| 123 | +export class InterruptsComponent { |
| 124 | + protected readonly agent = injectAgent(); |
| 125 | + |
| 126 | + protected onAction(action: ChatApprovalAction): void { |
| 127 | + if (action === 'approve') { |
| 128 | + void this.agent.submit({ resume: { approved: true } }); |
| 129 | + } else if (action === 'cancel') { |
| 130 | + void this.agent.submit({ resume: { approved: false } }); |
| 131 | + } |
| 132 | + } |
| 133 | +} |
| 134 | +``` |
| 135 | + |
| 136 | +**Python graph** (`cockpit/ag-ui/interrupts/python/src/graph.py`) uses `ag-ui-langgraph` to front a standard LangGraph graph: |
| 137 | + |
| 138 | +```python |
| 139 | +from langgraph.types import interrupt |
| 140 | +from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint |
| 141 | + |
| 142 | +def request_approval(state): |
| 143 | + decision = interrupt({ |
| 144 | + "kind": "refund_approval", |
| 145 | + "amount": state["amount"], |
| 146 | + "customer_id": state["customer_id"], |
| 147 | + "reason": state["reason"], |
| 148 | + }) |
| 149 | + approved = isinstance(decision, dict) and decision.get("approved") |
| 150 | + return {"decision_approved": approved} |
| 151 | +``` |
| 152 | + |
| 153 | +The `LangGraphAgent` wrapper handles streaming the `CUSTOM on_interrupt` event and reading `forwarded_props.command.resume` on resume. Refer to [`ag-ui-langgraph` on PyPI](https://pypi.org/project/ag-ui-langgraph/) for installation and configuration. |
| 154 | + |
| 155 | +## Cross-Adapter Parity |
| 156 | + |
| 157 | +The consumer Angular code is byte-identical except the `injectAgent` import: |
| 158 | + |
| 159 | +```diff |
| 160 | +- import { injectAgent } from '@threadplane/langgraph'; |
| 161 | ++ import { injectAgent } from '@threadplane/ag-ui'; |
| 162 | +``` |
| 163 | + |
| 164 | +`<chat-approval-card>`, the `interrupt()` signal, and `submit({ resume })` are part of the runtime-neutral `Agent` contract from `@threadplane/chat`. Switching adapters is a provider change, not a component rewrite. See the [LangGraph interrupts guide](/docs/langgraph/guides/interrupts) for the full HITL pattern including multi-step approvals, typed payloads, and timeout strategies. |
0 commit comments