diff --git a/.changeset/wet-banks-sink.md b/.changeset/wet-banks-sink.md new file mode 100644 index 0000000..f67fd9e --- /dev/null +++ b/.changeset/wet-banks-sink.md @@ -0,0 +1,15 @@ +--- +"@mobile-reality/mdma-agui": patch +--- + +Bring the package README up to date with the bridge it ships. It still described the 0.2.x surface, +so 0.3.0 published with docs that were wrong in places: they claimed a user decision always resumes +via a fresh `addMessage` + `runAgent` turn (that is now only the fallback under the `'auto'` +`resumeMode`), and listed `createMdmaAgentBridge` as returning `{ documents, flush, dispose }` and +the hook as `{ documents, bridge }` — both omitting `activity`, `interrupts`, and `state`. + +Also documents what 0.3.0 added but never explained: the `CUSTOM` delivery channel, shared state and +reactive hydration, the activity feed, and human-in-the-loop interrupts — plus the six missing +options (`initialState`, `onActivity`, `onState`, `onInterrupt`, `resumeMode`, `now`), the +`MDMA_CUSTOM_EVENT_NAME` / `createDefaultRegistry` exports, and the `INTEGRATION_CALLED` (webhook) +and tasklist-completion decisions. Fixes a sentence left truncated by a stale link removal. diff --git a/packages/agui/README.md b/packages/agui/README.md index 69a0288..3e9e337 100644 --- a/packages/agui/README.md +++ b/packages/agui/README.md @@ -2,15 +2,20 @@ Bridge [MDMA](https://github.com/MobileReality/mdma) interactive documents onto the [AG-UI protocol](https://github.com/ag-ui-protocol/ag-ui). An AG-UI agent streams MDMA -(forms, tables, approval gates) as message text or a tool-call payload; this package renders it -live and routes the user's actions — submit, approve, deny — back into the agent run, closing the -human-in-the-loop. +(forms, tables, approval gates) as message text or on a dedicated `CUSTOM` event; this package +renders it live, hydrates it from the agent's shared state, and routes the user's actions — submit, +approve, deny — back into the run, closing the human-in-the-loop. MDMA owns the **decision surface** (validated, audited, PII-aware components); AG-UI owns the **control primitive** (suspend/resume via its `interrupt` building block). This adapter is the seam. +The two compose because MDMA components are **headless**: a document describes intent (fields, +types, actions) and takes its values from state. AG-UI already standardizes the three things that +needs — streaming documents, carrying shared state, and pausing for a human — so the bridge maps +each onto MDMA instead of inventing plumbing. + > **Layering.** AG-UI is transport; MDMA is payload. This package is a community-maintained -> adapter, not a framework integration — see [`mdma-agui-integration-plan.md`](../../mdma-agui-integration-plan.md). +> adapter, not a framework integration. ## Install @@ -42,20 +47,31 @@ export function Chat() { } ``` -For finer control, use the hook: +For finer control, use the hook. Alongside `documents` it surfaces the agent's activity, any +interrupts the run is parked on, and its shared state: ```tsx import { useMdmaAgentStream } from '@mobile-reality/mdma-agui/react'; import { MdmaDocument } from '@mobile-reality/mdma-renderer-react'; function Chat({ agent }) { - const { documents } = useMdmaAgentStream(agent, { - // Return false to resume the run yourself (e.g. resolve an AG-UI interrupt). + const { documents, activity, interrupts, state, bridge } = useMdmaAgentStream(agent, { + // Return false to resume the run yourself. onAction: async (action, message) => { console.log('user decided', action.type, 'in', message.messageId); }, }); - return documents.map((d) => ); + + return ( + <> + {interrupts.length > 0 && Waiting on: {interrupts.map((i) => i.id).join(', ')}} + {documents.map((d) => ( + + ))} + +
{JSON.stringify(state, null, 2)}
+ + ); } ``` @@ -68,51 +84,147 @@ import { createMdmaAgentBridge } from '@mobile-reality/mdma-agui'; const bridge = createMdmaAgentBridge(agent, { onDocument: (message) => renderSomewhere(message.ast, message.store), + onActivity: (item, feed) => renderActivity(feed), + onState: (state) => renderState(state), + onInterrupt: (pending) => renderGateBanner(pending), }); +bridge.documents; // ReadonlyMap +bridge.activity; // readonly MdmaActivity[] +bridge.interrupts; // readonly AguiInterrupt[] — what the run is parked on +bridge.state; // Readonly +await bridge.flush(); // force an immediate re-parse of buffered content + // later bridge.dispose(); ``` +## Two delivery channels + +MDMA can reach the bridge two ways, and both feed the *same* parse → store → render pipeline. Each +message reports which channel it arrived on via `message.source`. + +| Channel | How the agent sends it | When to use | +|---|---|---| +| **Inline** — `source: 'text'` | The document sits in the assistant's streamed prose (`TEXT_MESSAGE_CONTENT`). Re-parsed with throttling as it streams. | The agent writes MDMA as part of its reply. | +| **Out-of-band** — `source: 'custom'` | A `CUSTOM` event named `mdma` (`MDMA_CUSTOM_EVENT_NAME`), whose `value` is the markdown string or `{ messageId?, markdown }`. Parsed immediately — the text is already complete. | A tool-calling agent that puts the document in a tool argument, keeping prose and UI on separate channels so no markup leaks into the chat. | + +Either way the markdown still carries an `mdma` fence — same format, different channel. + +## Shared state + +Because components are headless, their values live in AG-UI's shared state. The bridge tracks +`STATE_SNAPSHOT` / `STATE_DELTA` (including JSON-patch deltas) as a `componentId → values` map and +hydrates MDMA stores from it — so a form the agent renders comes up **pre-filled** from what it +already knows. + +Hydration is **reactive**: state arriving *after* a component is already on screen is dispatched +into that live store too, so the agent can set a field the user is looking at without re-rendering +the form. + +`initialState` takes the same shape at startup — for restoring a persisted conversation so its +forms, approvals, and tasklists render populated. + +```ts +const bridge = createMdmaAgentBridge(agent, { + // Seed stores as they're created (e.g. a conversation fetched from your backend). + initialState: { 'signup-form': { email: 'ada@example.com' } }, + onState: (state) => console.log('agent knows', state), +}); +``` + +## Agentic activity + +Tool calls, run steps, and reasoning streams surface as their own ordered feed — **deliberately +separate** from MDMA. They never enter a document store, so agent chatter and rendered components +stay decoupled: render the feed as a timeline beside the documents, or ignore it entirely. + +| Field | Meaning | +|---|---| +| `id` | Stable across the item's lifetime (tool-call id, step handle, reasoning message id). | +| `kind` | `'tool'` · `'step'` · `'reasoning'` | +| `label` | The tool name, the step name, or `reasoning`. | +| `status` | `'running'` → `'done'` | +| `detail` | Streamed detail — accumulating tool args, the tool result, or the reasoning text. | + +## Human-in-the-loop + +When a run parks on AG-UI interrupts (a `RUN_FINISHED` carrying an `interrupt` outcome), the bridge +exposes the pending set as `bridge.interrupts` and fires `onInterrupt`. Answering the component an +interrupt refers to resolves **that** interrupt with `runAgent({ resume })`, so the parked run +continues with its state intact instead of starting a fresh turn. + +`resumeMode` controls how a user decision resumes the run: + +| Mode | Behavior | +|---|---| +| `'auto'` *(default)* | Resolve a matching interrupt if the run is parked on one; otherwise fall back to a fresh user turn. | +| `'interrupt'` | Only ever resolve a matching interrupt; if none matches, do nothing. | +| `'user-turn'` | Always open a fresh user turn (`addMessage` + `runAgent`). | + +For full control, `onAction` returning `false` hands resumption to you, and `resume` replaces the +built-in interrupt and user-turn paths entirely. + ## How it works **Stream → render.** On each `onTextMessageContentEvent`, the bridge reads the *accumulated* `textMessageBuffer` (no delta bookkeeping), gates on a cheap `containsMdma()` fence check, -throttles re-parsing (~150 ms), and feeds the AST into a document store. The store is created -**once per message** and updated in place with `store.updateAst()` afterward, so in-flight form -edits and focus survive streaming. "Latest content wins" guards async parse ordering. +throttles re-parsing (~150 ms), and feeds the AST into a document store. Out-of-band `CUSTOM` +documents go through the same path but parse immediately, since their text arrives complete. The +store is created **once per message** and updated in place with `store.updateAst()` afterward, so +in-flight form edits and focus survive streaming. "Latest content wins" guards async parse ordering. **Action → resume.** The bridge listens on `store.getEventBus().onAny()` and switches on the -decision events — `ACTION_TRIGGERED` (button / form submit), `APPROVAL_GRANTED`, and -`APPROVAL_DENIED` (approval-gate). By default it packages the decision as a user turn and calls -`agent.addMessage()` + `agent.runAgent()`. Return `false` from `onAction` to take over — e.g. -resolve AG-UI's native interrupt so the parked run resumes with state intact. +decision events — `ACTION_TRIGGERED` (button, form submit, tasklist completion), +`APPROVAL_GRANTED` / `APPROVAL_DENIED` (approval-gate), and `INTEGRATION_CALLED` (webhook trigger) +— then resumes according to `resumeMode`: resolving the matching interrupt where there is one, +otherwise packaging the decision as a user turn (`addMessage` + `runAgent`). + +A tasklist resumes the run only on the transition into *all items checked* (its `onComplete` +action), not on every toggle — individual `FIELD_CHANGED` edits are ignored, the same way +in-progress form typing is. A webhook routes its trigger and request shape (real HTTP execution is +handled by your agent or the webhook engine). ## API | Export | Description | |---|---| -| `createMdmaAgentBridge(agent, options)` | Headless bridge. Returns `{ documents, flush, dispose }`. | -| `parseMdma(markdown, { existingStore?, createRegistry? })` | Parse text → `{ ast, store }`, reusing a store when given. | +| `createMdmaAgentBridge(agent, options)` | Headless bridge → `{ documents, activity, interrupts, state, flush, dispose }`. | +| `MDMA_CUSTOM_EVENT_NAME` | The `CUSTOM` event name (`'mdma'`) carrying out-of-band documents. | +| `parseMdma(markdown, { existingStore?, createRegistry?, initialState? })` | Parse text → `{ ast, store }`, reusing a store when given. | | `containsMdma(text)` | Cheap gate: does the buffer contain an `mdma` fence? | -| `useMdmaAgentStream(agent, options)` *(./react)* | React hook → `{ documents, bridge }`. | +| `createDefaultRegistry()` | The default attachable registry used by new stores. | +| `useMdmaAgentStream(agent, options)` *(./react)* | React hook → `{ documents, activity, interrupts, state, bridge }`. | | `MdmaAgentView` *(./react)* | Drop-in component rendering every streamed document. | +Types: `MdmaAgentBridge`, `MdmaAgentBridgeOptions`, `MdmaMessageState`, `MdmaSourceOrigin`, +`MdmaActionEvent`, `MdmaActivity`, `MdmaActivityKind`, `MdmaActivityStatus`, `MdmaSharedState`, plus +the structural AG-UI types (`AguiAgent`, `AguiInterrupt`, `AguiResumeEntry`, …). + ### `options` (both `createMdmaAgentBridge` and the hook/view) -- `throttleMs?` — re-parse debounce window (default `150`). -- `createRegistry?` — attachable registry factory (defaults to the core attachables). -- `onDocument?` — fires when a message's store is created/updated (render hook). -- `onAction?` — fires on a user decision; return `false` to suppress the default resume. -- `resume?` — fully replace the default `addMessage` + `runAgent` resume. +| Option | Purpose | +|---|---| +| `onDocument?` | A message's store was created/updated from newly parsed MDMA — the render hook. | +| `onActivity?` | A tool call / step / reasoning item was created or advanced. Observational. | +| `onState?` | The agent's shared state changed. Observational. | +| `onInterrupt?` | The run parked on human-in-the-loop interrupts. | +| `onAction?` | A user decision fired; return `false` to suppress the default resume. | +| `resumeMode?` | How decisions resume the run — `'auto'` (default) · `'interrupt'` · `'user-turn'`. | +| `resume?` | Fully replace the built-in resume behavior. | +| `initialState?` | Seed component values when stores are first created. | +| `throttleMs?` | Re-parse debounce window (default `150`). | +| `createRegistry?` | Attachable registry factory (defaults to the core attachables). | +| `now?` | Injectable clock (ms). Defaults to `Date.now`; overridden in tests. | ## AG-UI coupling The headless core is written against a **minimal structural agent interface** in -[`src/types.ts`](src/types.ts) — the small slice of `@ag-ui/client`'s `AbstractAgent` / -`AgentSubscriber` and `@ag-ui/core`'s `Message` it touches (`subscribe`, `runAgent`, `addMessage`, -`onTextMessageContentEvent`). A real `HttpAgent` satisfies it by shape, so there is no hard build -dependency on AG-UI and all coupling is isolated to that one file. +[`src/types.ts`](src/types.ts) — the slice of `@ag-ui/client`'s `AbstractAgent` / `AgentSubscriber` +and `@ag-ui/core`'s `Message` it touches (`subscribe`, `runAgent`, `addMessage`, and the text, +custom, tool/step/reasoning, state, and run-lifecycle subscriber hooks). A real `HttpAgent` +satisfies it by shape, so there is no hard build dependency on AG-UI and all coupling is isolated +to that one file. It is **not a blind shim**: [`tests/agui-conformance.ts`](tests/agui-conformance.ts) asserts at type-check time (against the installed `@ag-ui/*`) that a real `AbstractAgent` is assignable to @@ -120,6 +232,12 @@ our `AguiAgent` and our subscriber is accepted by the real `AgentSubscriber`. If drifts, `pnpm typecheck` fails there — turning silent runtime drift into a build error. The conformance file is excluded from the published build. +## Example + +A runnable backend + React frontend using every piece above — a tool-calling agent, MDMA over +`CUSTOM` events, shared state, interrupts, and the activity feed — lives in +[`examples/integrations/ag-ui`](../../examples/integrations/ag-ui). + ## License MIT