Nested Agentic LLM streaming
Complete product design, not a spike. Goal: in the Agent method execution tree, provide llm() / runtime.exec(..., execution_kind="llm") with per-node, per-channel real-time token streams, covering ExecutionStrip cards, right-side details, collapsible thinking, cancellation, reconnection, parallel LLMs, and both in-process and subprocess paths.Define the contract first, then implement the same design in phases—consistent with the context-manager design philosophy.
0. Current state and gaps
| Layer | Existing capability | Gap |
|---|---|---|
| Top-level chat stream | chat_response.data.type=stream_event, inner event.type ∈ {text,thinking,tool_*}; FE coalesces into the assistant reply using rAF | Unrelated to nested LLM nodes |
| Execution tree | tree_update polls at ~1.2s; LLM nodes node_type:"exec", opening nodes with status=running / closing nodes writes output+raw_reply | Before node closure, output is empty: TreeStep shows only the prompt note, not the growing reply. |
| Runtime stream | Runtime.on_stream already forwards provider text/thinking delta | The nested path does not propagate node_id to WS; subprocesses forward only existing envelopes through the queue. |
| FE TreeStep | note = parameter summary + duration + → short(output|raw_reply); detail-panel Prompt(params._content) / LLM Reply(raw_reply) | No Map<nodeId, PartialStream>, so running cards cannot display deltas. |
_event_parsing.agent_event_to_envelope, _exec_dag._poll / live_progress, runtime/history._open_model_call_node, apps/web/lib/net/chat-stream.ts, execution-strip.tsx#TreeStep, process_runner.run_agentic_in_subprocess.1. End-to-end product behavior
1.1 ExecutionStrip cards (TreeStep · LLM rows)
- Open node: an LLM row appears in the tree, running; the secondary note may still show a prompt summary (see §8 / §12).
- tokens: the main content is a continuously growing reply preview (text channel). Thinking defaults to a collapsed “Thinking…” label or last-line summary, consistent with top-level ThinkingStep.
- Close node: status becomes completed/error/cancelled; the note settles to
→ short(final)plus duration. Clear the stream buffer and return authority to tree-nodeoutput/raw_reply. - tree_update: still drives structure (child additions/removals and status changes); streamed text does not depend on the 1.2s poll and is driven by
llm_delta.
1.2 Right-side details panel
- Selecting a running LLM node: Prompt still reads
params._content; Reply prioritizes the full FE buffer with scrolling, falling back to tree fields if no buffer exists. - After completion: Reply = persisted
raw_reply/output; Thinking, if present, comes frommetadata.blocks.
1.3 Multiple parallel LLMs
Multiple LLM children of one RuntimeBlock may run concurrently. Every node_id has an independent buffer and seq space; UI rows grow independently without overwriting each other.
1.4 Cancellation
User Stop / execution.cancel → node status=cancelled; retain generated partial text as visible, persisted output; clear the buffer; show a “Cancelled” badge and reject further deltas for that node.
1.5 Reconnection / refresh
- After refresh, read the running LLM node’s throttled partial output from SessionDB (§5); the card immediately shows existing text and running state.
- If the run remains active, WS recovery resumes new
llm_delta/tree_updatewithout requiring historical-delta replay. - If the worker died, streaming-resume’s abort sweep marks the node aborted; UI shows partial output and aborted.
1.6 In-process and subprocess
User experience is identical. Only backend bridging differs (§7). Mode detection is diagnostic/logging only and does not branch frontend logic.
2. Complete event protocol
All events remain within the existing top-level envelope:
{
"type": "chat_response",
"data": { /* see below */ }
}
2.1 Add data.type = "llm_delta"
At the same level as stream_event / tree_update (nested LLM output is not top-level assistant-bubble text, so do not use inner event.type:"text" or accidentally write to reply.content).
// text channel
{
"type": "chat_response",
"data": {
"type": "llm_delta",
"session_id": "sess_7c2a",
"msg_id": "runtime_a1b2", // RuntimeBlock / live_progress reference (also called runtime_id)
"node_id": "call_9f3e", // DAG LLM Call.id (TreeStep aligns by path/id)
"channel": "text", // "text" | "thinking"
"delta": "Hello",
"seq": 12 // Optional; monotonically increasing within node_id+channel
}
}
// thinking channel
{
"type": "chat_response",
"data": {
"type": "llm_delta",
"session_id": "sess_7c2a",
"msg_id": "runtime_a1b2",
"node_id": "call_9f3e",
"channel": "thinking",
"delta": "First, decompose the requirements…",
"seq": 3
}
}
2.2 Related existing events (field names unchanged)
// Shortly after node opening / structural change (poll or forced flush)
{
"type": "chat_response",
"data": {
"type": "tree_update",
"session_id": "sess_7c2a",
"msg_id": "runtime_a1b2",
"function": "gui_agent",
"tree": {
"path": "…/gui_agent",
"name": "gui_agent",
"status": "running",
"children": [{
"path": "call_9f3e",
"name": "gpt-5",
"node_type": "exec",
"status": "running",
"params": { "_content": "Summarize the page…" },
"output": "",
"raw_reply": ""
}]
}
}
}
// Final tree_update after closure (root/child status=completed|error|cancelled)
// child.output / raw_reply = full text; FE treats it as authoritative and clears buffers
2.3 Lifecycle events (optional, but adopted here)
| Event | When | Purpose |
|---|---|---|
llm_delta | token | Only high-frequency path |
tree_update | Node open/close, subtree changes, ~1.2s, forced flush | Structure + terminal state + throttled partial-output mirror |
Do not add llm_open/llm_close | — | Express opening/closing through DAG + tree_update to avoid races between two channels. |
2.4 Ordering, coalescing, and backpressure
- Ordering: for the same
(node_id, channel), ifseqis present, append by seq; otherwise use arrival order. Drop out-of-order lower seq values rather than reordering history. - Server coalescing: combine deltas within a 16–32ms window for the same node before broadcast (still one
llm_deltawith concatenated delta). Never coalesce across nodes or channels. - FE coalesce: reuse the top-level rAF strategy—
Map<nodeId, PartialStream>writes occur immediately, while DOM/store stamps update at most once per frame. - Backpressure: bound the WS send queue (suggested: 512 frames per session). On overflow, discard intermediate text deltas first while retaining the latest snapshot frame (periodic full-snapshot llm_delta, using delta since the prior snapshot or a separate
snapshotfield). V1 skips intermediates on overflow but guarantees a complete final close tree. Thinking follows the same policy with more aggressive dropping. - Relationship to tree_update: deltas never write into the
treesubtree. §5 throttles partial text into nodes, which the next tree_update then includes.
3. Sequence diagram
sequenceDiagram
participant AF as Agent method
participant RT as Runtime.exec(llm)
participant DAG as SessionStore/DAG
participant BR as Broadcast(WS)
participant FE as Web FE
AF->>RT: llm(prompt)
RT->>DAG: _open_model_call_node (running)
Note over BR,FE: tree_update (child appears, output="")
BR-->>FE: tree_update
FE->>FE: TreeStep running; create PartialStream[nodeId]
loop tokens
RT->>BR: llm_delta(node_id, channel, delta, seq?)
BR-->>FE: llm_delta
FE->>FE: buffer.append; rAF updates note/details
end
RT->>DAG: _close_model_call_node(output, status)
Note over BR,FE: terminal tree_update (output=full text)
BR-->>FE: tree_update
FE->>FE: replace with tree text; clear PartialStream[nodeId]
4. FE state architecture
4.1 Recommended store shape
// Alongside the existing Zustand session store, or as a new slice in it
type PartialStream = {
text: string;
thinking: string;
lastSeqText?: number;
lastSeqThinking?: number;
updatedAt: number;
};
llmStreamsBySession: {
[sessionId: string]: {
// key = DAG node id (aligned with tree child.path / Call.id)
[nodeId: string]: PartialStream;
}
}
- Do not write high-frequency deltas into message-row
contextTree(avoid deep-cloning the entire tree). - TreeStep / DetailPanel reads buffers through
(sessionId, node.path|id). With a buffer and running node, display streaming output; otherwise read note/raw_reply from tree fields. - Isolate this from top-level
pendingDeltas(assistant reply): nested LLMs never write to reply.content.
4.2 TreeStep selection rules
| Condition | note / main preview |
|---|---|
| running ∧ text buffer exists | Primary: growing text (short tail or full scrollable text); prompt secondary (§8) |
| running ∧ no buffer | Keep current behavior: parameter summary; optionally show “Generating…” |
| terminal ∧ output/raw_reply exists | → short(output) + duration (current logic) |
| terminal ∧ no text | “No text output” |
| thinking | Separate collapsible row or in-Step fold; running shows the last line, done the first line (aligned with ThinkingStep) |
4.3 Clearing rules
- Node tree status ∈ {completed, error, cancelled, interrupted, aborted}
- or
clearSessionByMsgId/ session_loaded changes session - or RuntimeBlock unmounts with no running children
- Before clearing, if the tree has no final output yet, backfill once from the buffer to avoid losing the last tokens in a race.
5. Persistence policy (decision within this design)
metadata.streaming_thinking, folded into metadata.blocks on closure, and streaming fields are deleted. Terminal authority is _close_model_call_node.- Live authority: FE PartialStream (lowest latency).
- Recoverable authority: throttled node
output/raw_replysnapshots plus terminal full text. - Do not create child nodes or an append-only token table for every delta (non-goal).
- At the same level as
operations/streaming-resumealignment: placeholder + last_update_at; worker-restart abort sweeps also cover nested LLM nodes.
6. Failure / cancellation / error UX
| Situation | DAG | WS | UI |
|---|---|---|---|
| User cancellation | status=cancelled; output=generated partial | Stop deltas; terminal tree_update | Cancelled badge; retain partial; clear buffers |
| Provider error | status=error; error field; optional partial output | tree_update; optional runtime-error envelope | Red error note; Error block in details; retain partial for debugging |
| Timeout | Same as error + reason=timeout | Same as above | Distinguish “Timed out” in text |
| WS disconnect/reconnect | Partial output already persisted | Continue new deltas; no replay | Render persisted partial first, then resume streaming |
| Worker crash | abort sweep → aborted | None | Partial + aborted; streaming cannot resume |
| Sequence gaps | — | — | Ignore; terminal tree overwrites |
7. In-process vs Subprocess
In-process
Runtime.on_stream → wrap as llm_delta (fill current _active_llm_node_id)→ _broadcast_chat_response.
live_progress(..., on_event=None) directly broadcasts tree_update.
Subprocess (required for complete coverage)
The subprocess sends the same llm_delta envelope to mp.Queue; the parent drain thread on_event(env) → broadcasts through the existing placeholder/tree_update/goal_update bridge.
run_agentic_in_subprocess already has a queue; this design requires llm_delta to use the same bridge, never a direct subprocess WS connection.
Mode detection (diagnostics only)
- Parent:
process_runnerchecks whether thisexecution_idhas a liveProcess. - Child: whether
QueueTransport/ a subprocess event_queue is installed. - Log field
agentic_isolation: "inprocess"|"subprocess"; no frontend branching.
8. UI wording and behavior (default proposal)
- Primary card content: while streaming, show growing reply (text); after completion, an
→summary. - Prompt
_content: remains secondary by default. While running without tokens, show a short prompt; after tokens arrive, reply takes precedence. Full prompt remains available in details. - Thinking: collapsed by default, titled “Thinking”; show the latest line while running; click to expand full text in details or an inline fold.
- Do not copy nested LLM text into the outer assistant bubble.
9. Acceptance criteria (complete feature)
9.1 Manual
- A single
llm()inside an agentic function: node opens → card grows token by token → closure matches raw_reply and removes the buffer. - Dual thinking/text channels: thinking collapses while main text grows.
- Two parallel LLMs or consecutive loop calls: independent growing rows, no cross-contamination.
- Stop: deltas stop immediately; partial remains; status is cancelled.
- Mid-run refresh: partial is visible; new deltas resume if still running.
- Subprocess tools such as gui_agent provide the same UX as in-process execution.
- Details-panel Reply matches the card.
9.2 Suggested automation
- Unit: envelope shape (node_id/channel/seq), lower out-of-order seq rejection, and no cross-node coalescing.
- Unit: subprocess queue forwards llm_delta using mock Process/Queue.
- Component: TreeStep running+PartialStream snapshot; terminal buffer clearing.
- Contract: llm_delta in the chat-stream reducer does not contaminate reply.content.
- Optional E2E: fake provider emits fixed deltas; assert DOM text.
10. Non-goals (v1)
- Token-level audit logs or replayable delta recordings.
- Changing top-level chat
stream_event.textsemantics or complete TUI parity (TUI may later read and ignore llm_delta). - Per-field streaming highlights for structured output.
- Multimodal image deltas.
- Cross-session LLM stream subscriptions.
- Replacing tree_update with llm_delta.
11. Implementation phases (ordered delivery of one complete design, not a minimal release followed by reconsideration)
- Protocol and backend emission: in the
_active_llm_node_idcontext, Runtime converts on_stream to llm_delta; add broadcast helpers and envelope unit tests. - Subprocess bridge: confirm queue forwarding, add isolation-mode diagnostics, and test both paths.
- Throttled persistence: partial output/thinking on running nodes, including close-race tests.
- FE store + chat-stream: llmStreams slice, reducer, clearing rules, and no reply contamination.
- TreeStep + Detail: selection rules, thinking fold, and styling.
- Cancellation/reconnection/parallelism: complete §6 and §1 behavior and sign off the manual checklist.
- Documentation and check scripts: mark this page adopted; use check-*.mjs to preserve UI contracts where necessary.
12. Open questions (user decisions required · at most four)
Q1 · How prominent should the prompt be in the card note?
A) Reply fully replaces prompt after tokens arrive (default) B) Keep a short prompt on the left and reply on the right C) Show only reply while running; prompt only in details
Q2 · Should thinking expand by default?
A) Collapsed by default (default) B) Expand while running, collapse on completion C) Remember the user’s last choice
Q3 · Should partial persistence include thinking?
A) Throttle both text and thinking to 250ms (default) B) Persist text only; thinking only in terminal blocks C) Persist no partials, only terminal output (harms reconnection; not recommended)
Q4 · msg_id reference when a model-called agentic function is attached to an assistant reply?
A) Always use the runtime placeholder-row ID, consistent with current tree_update for display=runtime (default) B) Use the outer assistant msg_id and distinguish by node_id C) Include both msg_id+runtime_id fields
Choices are recorded in localStorage under key op.design.llmStream.decisions for review comparison, not as a formal vote.
Interactive demonstration · Card lifecycle
Simulation: open → llm_delta×N → tree_update close. See buffer lifecycle in the log on the right.
Two nodes append in parallel with isolated keys:
Cancel midway: retain partial, set cancelled status, and clear the buffer.
After refresh, render persisted partial first (example: “Persisted: Hello, world”), then continue deltas.
Only result, error, and retry_result frames finalize chat bookkeeping. Nested execution_stream frames belong to their stream reducer and never clear the parent run, overwrite reply text, or refresh branch metadata. A recovery response cannot itself trigger another identical recovery request. An owner_unavailable response ends the recovery request while preserving existing content; terminal nodes never return to syncing. Future stream events may request recovery again.