OpenProgram · Design · Runtime

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.

Authoritative draft · Open questions require user decisions Aligned with existing chat_response / stream_event / tree_update No product code · No commit Mac path unreachable → stored in repository docs

0. Current state and gaps

LayerExisting capabilityGap
Top-level chat streamchat_response.data.type=stream_event, inner event.type ∈ {text,thinking,tool_*}; FE coalesces into the assistant reply using rAFUnrelated to nested LLM nodes
Execution treetree_update polls at ~1.2s; LLM nodes node_type:"exec", opening nodes with status=running / closing nodes writes output+raw_replyBefore node closure, output is empty: TreeStep shows only the prompt note, not the growing reply.
Runtime streamRuntime.on_stream already forwards provider text/thinking deltaThe nested path does not propagate node_id to WS; subprocesses forward only existing envelopes through the queue.
FE TreeStepnote = 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.
Evidence references: _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)

1.2 Right-side details panel

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

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)

EventWhenPurpose
llm_deltatokenOnly high-frequency path
tree_updateNode open/close, subtree changes, ~1.2s, forced flushStructure + 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

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]
FE buffer lifecycle: create on the first llm_delta for node_id (or pre-create an empty buffer when a running exec node appears) → append on every delta → clear on terminal tree_update, cancellation, session unload, or node disappearance.

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;
  }
}

4.2 TreeStep selection rules

Conditionnote / main preview
running ∧ text buffer existsPrimary: growing text (short tail or full scrollable text); prompt secondary (§8)
running ∧ no bufferKeep current behavior: parameter summary; optionally show “Generating…”
terminal ∧ output/raw_reply exists→ short(output) + duration (current logic)
terminal ∧ no text“No text output”
thinkingSeparate collapsible row or in-Step fold; running shows the last line, done the first line (aligned with ThinkingStep)

4.3 Clearing rules

5. Persistence policy (decision within this design)

Decision (v1): each WS token is not written to the event log; DAG LLM nodes throttle partial writes of text to ~250ms. Thinking is throttled into metadata.streaming_thinking, folded into metadata.blocks on closure, and streaming fields are deleted. Terminal authority is _close_model_call_node.

6. Failure / cancellation / error UX

SituationDAGWSUI
User cancellationstatus=cancelled; output=generated partialStop deltas; terminal tree_updateCancelled badge; retain partial; clear buffers
Provider errorstatus=error; error field; optional partial outputtree_update; optional runtime-error envelopeRed error note; Error block in details; retain partial for debugging
TimeoutSame as error + reason=timeoutSame as aboveDistinguish “Timed out” in text
WS disconnect/reconnectPartial output already persistedContinue new deltas; no replayRender persisted partial first, then resume streaming
Worker crashabort sweep → abortedNonePartial + 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)

8. UI wording and behavior (default proposal)

See open question Q1 on pinning the prompt to the note’s left side. Use this default until decided.

9. Acceptance criteria (complete feature)

9.1 Manual

  1. A single llm() inside an agentic function: node opens → card grows token by token → closure matches raw_reply and removes the buffer.
  2. Dual thinking/text channels: thinking collapses while main text grows.
  3. Two parallel LLMs or consecutive loop calls: independent growing rows, no cross-contamination.
  4. Stop: deltas stop immediately; partial remains; status is cancelled.
  5. Mid-run refresh: partial is visible; new deltas resume if still running.
  6. Subprocess tools such as gui_agent provide the same UX as in-process execution.
  7. Details-panel Reply matches the card.

9.2 Suggested automation

10. Non-goals (v1)

11. Implementation phases (ordered delivery of one complete design, not a minimal release followed by reconsideration)

  1. Protocol and backend emission: in the _active_llm_node_id context, Runtime converts on_stream to llm_delta; add broadcast helpers and envelope unit tests.
  2. Subprocess bridge: confirm queue forwarding, add isolation-mode diagnostics, and test both paths.
  3. Throttled persistence: partial output/thinking on running nodes, including close-race tests.
  4. FE store + chat-stream: llmStreams slice, reducer, clearing rules, and no reply contamination.
  5. TreeStep + Detail: selection rules, thinking fold, and styling.
  6. Cancellation/reconnection/parallelism: complete §6 and §1 behavior and sign off the manual checklist.
  7. Documentation and check scripts: mark this page adopted; use check-*.mjs to preserve UI contracts where necessary.
No phase may unilaterally narrow product semantics, such as deferring subprocess support or persistence, which would turn it into a spike. Change this design before code if semantics must change.

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

idle

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.