|
| 1 | +# Multi-message subagent cards (AG-UI) — Design |
| 2 | + |
| 3 | +**Status:** Approved (brainstorm 2026-06-18) |
| 4 | + |
| 5 | +**Goal:** Extend the AG-UI subagent cards so each subagent accumulates and renders a **full transcript** — multiple assistant text turns, reasoning, and tool calls (with results) — streamed live, instead of a single accumulating text blob. |
| 6 | + |
| 7 | +## Background |
| 8 | + |
| 9 | +F5 shipped subagent cards over AG-UI via native ACTIVITY events: a graph emits a `subagent_activity` CUSTOM event → an owned `ActivityEmittingAgent._dispatch_event` maps it to `ACTIVITY_SNAPSHOT`/`ACTIVITY_DELTA` → the L1 reducer (`libs/ag-ui`) accumulates activities generically → `toAgent()` projects `activityType==='subagent'` to the neutral `Subagent` contract → `chat-subagents`/`chat-subagent-card` render. |
| 10 | + |
| 11 | +Today the activity `content` carries a single accumulating `text` field; the projection synthesizes **one** `Message` from it (`to-agent.ts` `subagentFor`), and the card shows that latest text. This design carries the subagent's full message transcript instead. |
| 12 | + |
| 13 | +The neutral types already support a transcript: `Message` has `role: 'tool'`, `toolCallId`, `toolCallIds`, and `reasoning`; `ToolCall` has `{id, name, args, status, result?, error?}`. The only neutral-contract addition is exposing the subagent's tool calls. |
| 14 | + |
| 15 | +## Architecture (Approach 1 — fat activity content) |
| 16 | + |
| 17 | +One ACTIVITY per subagent (unchanged identity model), whose `content` carries the transcript as arrays; streamed via JSON-Patch DELTAs. The L1 reducer is **untouched** (it already applies arbitrary ACTIVITY patches). |
| 18 | + |
| 19 | +### A. Neutral contract (libs/chat) |
| 20 | + |
| 21 | +Add `toolCalls` to `Subagent` (`libs/chat/src/lib/agent/subagent.ts`): |
| 22 | + |
| 23 | +```ts |
| 24 | +export interface Subagent { |
| 25 | + toolCallId: string; |
| 26 | + name?: string; |
| 27 | + status: Signal<SubagentStatus>; |
| 28 | + messages: Signal<Message[]>; |
| 29 | + toolCalls: Signal<ToolCall[]>; // NEW — the subagent's tool calls (name/args/result) |
| 30 | + state: Signal<Record<string, unknown>>; |
| 31 | +} |
| 32 | +``` |
| 33 | + |
| 34 | +`Message` and `ToolCall` are unchanged. |
| 35 | + |
| 36 | +### B. Wire shape (L3 ↔ L1 activity `content`) |
| 37 | + |
| 38 | +``` |
| 39 | +{ toolCallId, name, status, |
| 40 | + messages: [{ id, role, content, toolCallIds?, reasoning? }, …], |
| 41 | + toolCalls: [{ id, name, args, status, result?, error? }, …] } |
| 42 | +``` |
| 43 | + |
| 44 | +DELTAs (RFC-6902 JSON-Patch), live: |
| 45 | +- `ACTIVITY_SNAPSHOT` on subagent start → `{toolCallId, name, status:'running', messages:[], toolCalls:[]}`. |
| 46 | +- New message: `{op:'add', path:'/messages/-', value:{id, role:'assistant', content:''}}`. |
| 47 | +- Token stream into the in-progress message: `{op:'replace', path:'/messages/<n>/content', value:<accumulated text>}` (text_so_far pattern — replace, since JSON-Patch has no string append). |
| 48 | +- Tool call: `{op:'add', path:'/toolCalls/-', value:{id, name, args, status:'running'}}` plus `{op:'replace', path:'/messages/<n>/toolCallIds', value:[…]}`. |
| 49 | +- Tool result: `{op:'replace', path:'/toolCalls/<k>/status', value:'complete'}` + `{op:'replace', path:'/toolCalls/<k>/result', value:<result>}`. |
| 50 | +- Finish: `{op:'replace', path:'/status', value:'complete'}`. |
| 51 | + |
| 52 | +The reducer's existing `applyPatch` handles all of these — **no reducer change**. |
| 53 | + |
| 54 | +### C. Projection (libs/ag-ui `to-agent.ts`) |
| 55 | + |
| 56 | +`subagentFor` maps the new arrays, with explicit back-compat: |
| 57 | + |
| 58 | +```ts |
| 59 | +messages: computed<Message[]>(() => { |
| 60 | + const c = entry.content(); |
| 61 | + if (Array.isArray(c['messages'])) { |
| 62 | + return (c['messages'] as RawMsg[]).map((m) => ({ |
| 63 | + id: m.id, role: m.role, content: m.content ?? '', |
| 64 | + ...(m.toolCallIds ? { toolCallIds: m.toolCallIds } : {}), |
| 65 | + ...(m.reasoning ? { reasoning: m.reasoning } : {}), |
| 66 | + })); |
| 67 | + } |
| 68 | + // Back-compat: single accumulating text (current shipped emitter). |
| 69 | + return [{ id, role: 'assistant', content: String(c['text'] ?? '') }]; |
| 70 | +}), |
| 71 | +toolCalls: computed<ToolCall[]>(() => { |
| 72 | + const c = entry.content(); |
| 73 | + return Array.isArray(c['toolCalls']) ? (c['toolCalls'] as ToolCall[]) : []; |
| 74 | +}), |
| 75 | +``` |
| 76 | + |
| 77 | +The stable per-subagent wrapper (keyed by messageId) and the prune loop are unchanged. |
| 78 | + |
| 79 | +### D. Card rendering (libs/chat `chat-subagent-card`, hybrid) |
| 80 | + |
| 81 | +Replace the single "latest message" block with an ordered transcript: |
| 82 | + |
| 83 | +```html |
| 84 | +@for (m of subagent().messages(); track m.id) { |
| 85 | + <div class="sac__msg" [attr.data-role]="m.role"> |
| 86 | + @if (m.reasoning) { <div class="sac__reasoning">{{ m.reasoning }}</div> } |
| 87 | + @if (textOf(m); as t) { <!-- markdown render of t --> } |
| 88 | + @for (tc of toolCallsFor(m); track tc.id) { |
| 89 | + <chat-tool-call-card [toolCall]="toToolCallInfo(tc)" /> |
| 90 | + } |
| 91 | + </div> |
| 92 | +} |
| 93 | +``` |
| 94 | + |
| 95 | +- `toolCallsFor(m)` resolves `m.toolCallIds` against `subagent().toolCalls()`. |
| 96 | +- Reuse the existing **`ChatToolCallCardComponent`** (per-call card; result included) — the "hard part" of tool rendering — without the `Agent`-coupled `chat-tool-calls` wrapper. |
| 97 | +- The in-progress (last) message's `content` updates in place; `track m.id` keeps the DOM stable (no `@for` re-creation). |
| 98 | +- Compact, nested styling distinct from the main thread. |
| 99 | + |
| 100 | +### E. L3 emission (examples/ag-ui graph) |
| 101 | + |
| 102 | +Extend `SubagentStreamHandler` + `activity_transform.py`: |
| 103 | +- on assistant token → `add` a message (first token) then `replace` its content (text_so_far); |
| 104 | +- on tool call → `add` to `toolCalls` + `replace` the assistant message's `toolCallIds`; |
| 105 | +- on tool result → `replace` that tool call's `result`/`status`. |
| 106 | + |
| 107 | +Transport unchanged from F5 (`adispatch_custom_event` → `subagent_activity` CUSTOM → `ActivityEmittingAgent._dispatch_event` → ACTIVITY_DELTA). `activity_transform` gains small pure patch-builders for messages/toolcalls. The research subagraph is already LLM-driven (reasoning + a search tool + a summary), so it produces a genuine multi-message transcript for the demo. |
| 108 | + |
| 109 | +The cockpit `ag-ui/subagents` capability stays on the single-text path (exercises the back-compat branch) — not migrated here. |
| 110 | + |
| 111 | +## Data flow |
| 112 | + |
| 113 | +``` |
| 114 | +research subgraph stream |
| 115 | + → SubagentStreamHandler (delineates messages/tool calls) |
| 116 | + → adispatch_custom_event('subagent_activity', {phase, ...}) |
| 117 | + → ActivityEmittingAgent._dispatch_event → activity_transform → ACTIVITY_SNAPSHOT/DELTA |
| 118 | + → L1 reducer applyPatch (generic, unchanged) → activities store |
| 119 | + → toAgent subagentFor → Subagent{ messages[], toolCalls[] } |
| 120 | + → chat-subagents → chat-subagent-card → ordered transcript + reused tool-call cards |
| 121 | +``` |
| 122 | + |
| 123 | +## Error handling |
| 124 | + |
| 125 | +- Malformed/partial wire entries: the projection defensively defaults (`content ?? ''`, non-array → `[]`); a message missing an `id` falls back to its index for `track`. |
| 126 | +- A tool result arriving before its call (out-of-order): `toolCallsFor` simply finds nothing yet; the card renders the call once it appears (id lookup, never positional). |
| 127 | +- Back-compat: emitters that still send `text` render as a single assistant message; emitters sending `messages` render the full transcript. Both paths are unit-tested. |
| 128 | + |
| 129 | +## Testing |
| 130 | + |
| 131 | +- **Unit (libs/ag-ui):** projection maps `content.messages`→`Message[]` and `content.toolCalls`→`ToolCall[]`; the `text` back-compat fallback; stable wrapper identity + content liveness across DELTAs. |
| 132 | +- **Unit (libs/chat):** `chat-subagent-card` renders an ordered transcript (≥2 messages), reasoning, and a reused tool-call card; updates live when the last message's content changes; `track m.id` stability. |
| 133 | +- **Unit (python):** `activity_transform` message/toolcall patch-builders produce the expected JSON-Patch ops for each phase. |
| 134 | +- **e2e (examples/ag-ui):** during a research run, a subagent card surfaces ≥2 messages and a tool-call card — durable-signal assertions (F5 e2e precedent; robust under aimock replay). |
| 135 | +- **Gates:** `ag-ui` + `chat` lint/test; examples/ag-ui e2e; cockpit `ag-ui/subagents` e2e still green (back-compat); Railway deploy regen if any `cockpit/ag-ui/*` source changes; api-docs regen (the `Subagent.toolCalls` addition is public API). |
| 136 | + |
| 137 | +## Scope guardrails (YAGNI) |
| 138 | + |
| 139 | +- No neutral-`Message` change; the L1 reducer is untouched. |
| 140 | +- `role:'tool'` result messages are not separately rendered (the tool-call card already shows the result). |
| 141 | +- The cockpit `ag-ui/subagents` capability is not migrated (it validates the back-compat branch). |
| 142 | +- No new chat-tool-calls decoupling refactor — reuse the per-call card directly. |
| 143 | + |
| 144 | +## Public API delta |
| 145 | + |
| 146 | +- `Subagent.toolCalls: Signal<ToolCall[]>` (additive). |
| 147 | +- No other public surface changes. |
0 commit comments