Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/wet-banks-sink.md
Original file line number Diff line number Diff line change
@@ -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.
172 changes: 145 additions & 27 deletions packages/agui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) => <MdmaDocument key={d.messageId} ast={d.ast} store={d.store} />);

return (
<>
{interrupts.length > 0 && <Banner>Waiting on: {interrupts.map((i) => i.id).join(', ')}</Banner>}
{documents.map((d) => (
<MdmaDocument key={d.messageId} ast={d.ast} store={d.store} />
))}
<ActivityFeed items={activity} />
<pre>{JSON.stringify(state, null, 2)}</pre>
</>
);
}
```

Expand All @@ -68,58 +84,160 @@ 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<string, MdmaMessageState>
bridge.activity; // readonly MdmaActivity[]
bridge.interrupts; // readonly AguiInterrupt[] — what the run is parked on
bridge.state; // Readonly<MdmaSharedState>
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
our `AguiAgent` and our subscriber is accepted by the real `AgentSubscriber`. If AG-UI's API
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