0062: LLM Completion Streaming¶
- Status: Accepted
- Author: Chris Colinsky
- Created: 2026-06-09
- Accepted: 2026-06-20
- Targets: spec/llm-provider/spec.md (§5
complete()gains an optionalstreamflag — when set, the provider consumes the LLM's streaming wire response and emits per-chunk token events, while the call STILL returns the atomicResponse(return type unchanged); §6 gains a Streaming assembly contract specifying how the atomicResponseis assembled from the stream (content concatenated, tool-call argument deltas reassembled into completetool_calls, usage / finish_reason from the terminal chunk) so node bodies are agnostic to whether streaming was used; §8.1 OpenAI-compatible mapping gains a streaming sub-section (SSE wire handling); §5 also gains a Provider streaming support rule — a wire mapping that does not implement streaming MUST reject astream-set call withprovider_invalid_request, so the §8.2 Anthropic / §8.3 Gemini mappings reject streaming (defined behavior) until their streaming follow-ons land; §10 Out of scope — the "Streaming responses" item is lifted into scope, replaced by narrower deferrals); spec/graph-engine/spec.md (§6 — newLlmTokenEventtyped event variant on the observer event union, a within-call sub-event carrying per-chunk assistant content and reasoning deltas (discriminated by adelta_kindfield; tool-call argument deltas are reassembled into the atomicResponsebut not emitted as token events), correlated to the terminalLlmCompletionEventby sharedcall_id); spec/observability/spec.md (§5.5 + §8 — note that the bundled OTel and Langfuse observers do NOT renderLlmTokenEvent(no per-token spans / observations); trace recording stays atomic at the terminalLlmCompletionEvent, token events are for custom forwarding observers); plus new conformance fixtures underspec/observability/conformance/andspec/llm-provider/conformance/. - Related: 0006 (llm-provider core — the
complete()shape this extends), 0049 (typedLlmCompletionEvent— the terminal event the token stream precedes; the observer-union typed-event pattern this extends), 0058 (typedLlmFailedEvent— the terminal event for a mid-stream failure), 0057 (LlmCompletionEvent field-set extension — the identity / scoping baselineLlmTokenEventmirrors), 0037 / 0038 (Anthropic / Gemini wire mappings — streaming wire handling for these is deferred to follow-ons, same as §8.1 is the first concrete streaming mapping here) - Supersedes:
Summary¶
Lifts LLM response streaming from llm-provider §10 Out of scope into a normative
capability. Today complete() is atomic — it sends the request, awaits the full response,
and returns a Response. For user-facing surfaces (a chat UI rendering the answer
token-by-token over WebSocket / SSE) the atomic shape means the client waits the full call
duration (commonly ~800ms–2s) before seeing anything; streaming surfaces the first token in
~150–300ms, which is a structural UX requirement for chat.
This proposal lands two layers:
-
Provider streaming capability (llm-provider).
complete()gains an optionalstreamflag. When set, the provider MUST consume the LLM's streaming wire response and emit aLlmTokenEventper chunk as it arrives — genuinely incremental, not a post-hoc chunking of the final response. Crucially, the return type is unchanged:complete()still returns the atomicResponseat the end. Thestreamflag controls per-chunk event emission, NOT the return shape. A new §6 Streaming assembly contract specifies how the atomicResponseis reassembled from the stream (content concatenated, tool-call argument deltas reassembled into completetool_calls, usage / finish_reason from the terminal chunk) so node bodies, reducers, and the terminalLlmCompletionEventare all agnostic to whether streaming was used. -
Token-event observability (graph-engine §6 + observability). A new
LlmTokenEventtyped event on the observer event union, carrying per-chunk deltas. A route handler attaches an invocation-scoped observer that filters by node and forwardsevent.deltato the WebSocket. The bundled OTel and Langfuse observers do NOT render token events — trace-level recording stays atomic at the terminalLlmCompletionEvent(the Generation / span collapse the stream back to one input/output). Token-level emission is for the UI, not for traces.
The §10 item's own wording — "incremental delivery of assistant content and tool calls"
— is honored: a streamed response can carry tool-call argument deltas, and the provider MUST
reassemble them into the complete tool_calls on the atomic Response. This is distinct
from tool-execution observability (the caller running a tool after the model requested it),
which is a separate forthcoming proposal.
Motivation¶
The gap is foundational, not cosmetic. llm-provider §10 explicitly defers "Streaming
responses — incremental delivery of assistant content and tool calls." So there is no
streaming surface at any layer today. A downstream chat-agent consumer needs to stream the
final answer to a user-facing client; with an atomic complete() the client stares at a
spinner for the full call duration. First-token latency is the structural UX lever for chat,
and it is unreachable without provider-side streaming.
Observability alone is insufficient. A token-event on the observer union (the obvious "streaming" surface) presupposes the provider is genuinely streaming from the wire. Define only the event and an implementation could satisfy it by awaiting the full response and emitting fake chunks — which surfaces nothing earlier and defeats the entire point. So the load-bearing piece is the provider-side capability (the wire consumption + the genuine- incremental contract); the event is the surface on top of it.
Atomic recording must be preserved. Streaming is a delivery concern for the consumer, not a trace-shape change. A 500-token response must not fan out into 500 child spans, and the Langfuse Generation must still show one input/output payload. So the trace-level mapping (proposal 0024 / §8.4) is untouched — token events are consumed by the consumer's own forwarding observer, ignored by the bundled OTel / Langfuse observers.
Within-call, not cross-turn. Token streaming happens entirely within a single
complete() call (the model streams its response over the call's duration). It does NOT
cross invocation / turn boundaries the way a deferred tool execution might in an event-driven
runtime — a streamed complete() is one held-open wire connection within one step. This
keeps the contract simple: no suspend/resume composition is required for streaming.
Proposed change¶
llm-provider §5 — complete() gains an optional stream flag¶
complete()'s signature extends with an optional stream parameter (keyword-only, or
per-language idiomatic equivalent), default False / absent — preserving the v0.4.0 atomic
behavior exactly for callers that don't opt in.
When stream is set:
- The provider MUST consume the LLM's streaming wire response (SSE / chunked transfer per the provider's API) rather than awaiting a single atomic response body.
- The provider MUST emit a
LlmTokenEvent(graph-engine §6) on the observer delivery queue per chunk, as the chunk arrives — genuinely incremental. Implementations MUST NOT satisfy the contract by awaiting the full response and then emitting synthesized chunks; the first-token-latency benefit is the contract's purpose, and post-hoc chunking violates it. (This MUST states behavioral intent. Conformance can only verify the testable proxy — that the assembledResponseequals the ordered concatenation of the streamed deltas (below) — not that chunks crossed the wire incrementally; a faked implementation passes conformance while violating the contract's purpose. Implementations are nonetheless expected to drive emission from the live wire stream.) - The call STILL returns the atomic
Response(§6) at completion. The return type is unchanged —complete()returnsResponsewhether or notstreamis set. The flag governs per-chunk event emission, not the return shape. This is the load-bearing distinction from the rejected "return an async iterator when streaming" alternative (see Alternatives): node bodies, reducers, retry middleware, and the terminalLlmCompletionEventall see the same atomicResponseeither way. - When no observer is attached (direct provider use outside an invocation),
streamset is observably identical tostreamunset —complete()returns the same atomicResponseand there is no consumer for the token events. Implementations MAY still consume the wire incrementally for latency, but with no observer attached the behavior is indistinguishable from the atomic path.
Provider streaming support. Streaming is a per-§8.X-mapping capability, not a guaranteed
property of every provider. A provider whose wire-format mapping does NOT implement streaming
MUST reject a stream-set call at pre-send validation, raising provider_invalid_request (§7)
with a message identifying that the mapping does not support streaming. It MUST NOT silently
fall back to an atomic call (which would hide that the requested mode was unavailable) and MUST
NOT fail opaquely mid-call. This is the same mold as tool_choice validation — a request shape
a mapping cannot satisfy is a pre-send provider_invalid_request. The §8.1 OpenAI-compatible
mapping implements streaming (below) and so accepts stream-set calls; the §8.2 Anthropic and
§8.3 Gemini mappings do NOT implement streaming in this proposal and therefore reject
stream-set calls until their streaming wire handling lands in a follow-on. So this proposal
defines stream's behavior across all three mappings: OpenAI streams, Anthropic / Gemini reject
(pending their follow-ons).
Interaction with existing parameters:
tools/tool_choice— a streamed response MAY contain tool calls. The provider reassembles streamed tool-call argument deltas into completetool_callson the atomicResponseper the §6 assembly contract below.response_schema— when supplied withstreamset, the structuredparsedvalue is assembled and validated at the terminal (partial JSON cannot be validated mid-stream); token deltas carry the partial unparsed content.parsedon the atomicResponseis identical to the non-streamed case.retry(§7.1, call-level) — the §7.1 retry loop runs inside a singlecomplete()call (it produces N per-attempt OTel spans within one call). So a streamed call withretryset is onecomplete()call with onecall_id: each internal wire attempt streams its token events under that onecall_id; a transient is caught and retried internally — it does NOT raise out ofcomplete(), re-enter it, or emit a per-attempt typed event; only retry exhaustion raises the §7 category exception. The terminalLlmCompletionEvent(success) orLlmFailedEvent(exhausted) fires once at the end (per graph-engine §6's one-typed-event-per-complete()-call mutual exclusion). The token event'sattempt_indexis the node-level retry index (see the field table below) — it does NOT advance across §7.1 wire attempts (the node attempted the call once; the retry was internal). The per-wire-attempt index lives on the §7.1 OTel span (openarmature.llm.attempt_index), not on the token event.- Per-node retry (pipeline-utilities §6.1) —
RetryMiddlewarere-runs the whole node, re-callingcomplete()once per attempt. Each re-call is a freshcomplete()with its owncall_id; the streams are distinct calls, not wire attempts within one call. A consumer sees a newcall_idper node-run, and the node-levelattempt_indexadvances per run. - Multi-attempt streams (consumer guidance). A streamed call that restarts (either retry
layer) makes a forwarding observer see a prior attempt's partial tokens followed by the next
attempt's — restarted output from the end consumer's view. Under per-node retry this is
decidable: a fresh
call_id(and an advanced node-levelattempt_index) signals a new attempt, so a forwarding observer SHOULD reset its forwarded buffer when thecall_idchanges. Under call-level (§7.1) retry there is no token-event-level restart signal in v1 — the wire attempts share onecall_idand the node-levelattempt_indexdoes not advance; a forwarder that needs §7.1-restart detection consults the per-wire-attempt index on the §7.1 OTel span, or simply uses per-node retry for streamed UI calls. Token-event-level signaling of call-level-retry restarts is deferred (see Out of scope) — adding a per-wire-attempt field toLlmTokenEventis additive if a real consumer needs it.
llm-provider §6 — Streaming assembly contract¶
A new sub-section specifying how the atomic Response is assembled from the stream, so the
streamed and non-streamed paths produce structurally identical Response records:
- Content — the
message.contentis the ordered concatenation of the streamed content deltas, each emitted live asLlmTokenEvent(delta_kind="content"). - Reasoning — reasoning / thinking content (§3.1.4 / §3.1.5), when the provider streams it,
assembles into its blocks on the terminal
ResponseAND is emitted live asLlmTokenEvent(delta_kind="reasoning"), so a UI can render a live "thinking" element. Whether a provider streams reasoning is a per-§8.X-mapping capability (§8.1's OpenAI-compatible mapping recognizes thereasoning_content/reasoningextension field); the terminalResponsestays shape-identical to the non-streamed case (content + reasoning blocks present). - Tool calls — streamed tool-call argument deltas are reassembled into complete
ToolCallrecords (id,name,arguments) onmessage.tool_calls, in the order the provider streamed them. The reassembledargumentsMUST parse identically to the non-streamed case (a mapping when valid JSON;nullwhen unparseable, per §3's existing tool-call validation). This reassembly is provider-internal — the tool-call deltas are NOT emitted asLlmTokenEvents in v1 (only the completetool_callson the terminalLlmCompletionEventis surfaced; see Out of scope). - Usage / finish_reason — sourced from the terminal chunk (providers emit usage and the
finish reason on the final streamed event; OpenAI requires
stream_optionsto include usage — see §8.1 below). raw— the parsed provider response. For a streamed call,rawis the assembled representation of the streamed events (implementation-defined assembly; MUST be populated per §6's existingrawcontract). Within-implementation wire-byte stability (§8) applies to the assembled form.- Structural identity — a
Responseassembled from a stream MUST be indistinguishable in shape from aResponsereturned atomically for the equivalent non-streamed call. This is the contract that lets every downstream consumer (node bodies, reducers, the terminal typed events, the OTel / Langfuse mappings) ignore whether streaming was used.
llm-provider §8.1 — OpenAI-compatible streaming wire handling¶
A streaming sub-section under the OpenAI-compatible mapping (the first concrete streaming wire
mapping; Anthropic §8.2 and Gemini §8.3 streaming deferred to follow-ons — until then those
mappings reject stream-set calls per the §5 Provider streaming support rule):
- Request:
stream: truein the request body;stream_options: {include_usage: true}so the terminal chunk carries usage (OpenAI omits usage from streamed responses otherwise). - Wire: Server-Sent Events; each
data:line is a chunk withchoices[].deltacarrying either acontentdelta ortool_callsdeltas (each with anindex, and partialid/name/argumentsfields); the[DONE]sentinel terminates the stream. - Content deltas map to
LlmTokenEvent(delta_kind="content"); reasoning deltas (the OpenAI-compatiblereasoning_content/reasoningextension field) map toLlmTokenEvent(delta_kind="reasoning"). Tool-call deltas are reassembled intomessage.tool_callsper the §6 assembly contract but are NOT emitted as token events. finish_reasonis set on the last content-bearing chunk'schoices[].finish_reason; whenstream_options.include_usageis set, a final chunk with emptychoicescarriesusage, followed by the[DONE]sentinel.
The exact streamed-chunk shapes above (the finish_reason / usage chunk positioning, the
stream_options flag, the [DONE] sentinel, tool-call delta fields) are asserted from the
OpenAI streaming format and MUST be verified against current OpenAI streaming docs at Accept
before this §8.1 text becomes normative, per the external-dependency verification discipline
(docs/compatibility.md).
llm-provider §10 — lift the streaming deferral¶
Remove the "Streaming responses — incremental delivery of assistant content and tool calls" item (now in scope). Replace with the narrower deferrals this proposal does NOT cover (see Out of scope below): node-body stream consumption (async-iterator return), per-vendor streaming wire mappings beyond OpenAI-compatible, and streaming for non-completion provider operations.
graph-engine §6 — LlmTokenEvent typed event¶
A new typed event variant on the observer event union. Unlike LlmCompletionEvent /
LlmFailedEvent (and the embedding / rerank pairs), LlmTokenEvent is a within-call
sub-event, not a call-outcome event — it carries one delta of an in-progress call. It is
therefore unpaired: there is no LlmTokenFailedEvent. A streamed call that fails mid-
stream emits the partial token events it produced, then the terminal LlmFailedEvent
(proposal 0058) fires when the §7 category exception raises; the call's outcome is carried by
the terminal LlmCompletionEvent / LlmFailedEvent, not by the token events.
Field set — mirrors LlmCompletionEvent's identity / scoping baseline, plus the per-chunk
content delta. Request-side and response-side payload fields are deliberately absent (they are
invariant across the stream and live on the terminal LlmCompletionEvent; consumers correlate
via call_id):
| Field | Type | Description |
|---|---|---|
invocation_id |
string | The outer invocation's identifier, per observability §5.1. |
correlation_id |
string | null | Cross-backend correlation ID, per observability §3.1. |
node_name |
string | The user-defined node that issued the call. |
namespace |
sequence of strings | The calling node's namespace. |
attempt_index |
int | The node-level retry-attempt index (0 on the first attempt), sourced from the same per-node retry context as LlmCompletionEvent.attempt_index. It does NOT vary across §7.1 call-level wire attempts (those share one call_id and one node-level index — see the retry interaction note above); the per-wire-attempt index lives on the §7.1 OTel span, not here. |
fan_out_index |
int | null | Per pipeline-utilities §9. Null otherwise. |
branch_name |
string | null | Per pipeline-utilities §11. Null otherwise. |
provider |
string | The LLM provider identifier. |
model |
string | The model identifier the request was made against. |
call_id |
string | The per-call disambiguator minted by the implementation for this complete() call. Matches the call_id on the terminal LlmCompletionEvent / LlmFailedEvent for the same call — this is the linkage observers use to associate a token stream with its eventual completion. |
caller_invocation_metadata |
mapping | null | OPTIONAL field; same opt-in semantics as on LlmCompletionEvent. |
chunk_index |
int | Monotonic per call, starting at 0. Establishes delta ordering within the call's token stream. |
delta_kind |
string | The kind of delta this chunk carries: "content" (assistant answer text) or "reasoning" (chain-of-thought / thinking text, when the provider streams it). Forwarding observers route by kind (answer stream vs. live "thinking"). A "tool_call" value is reserved for live tool-argument streaming — NOT emitted in this version (tool-call deltas reassemble into the atomic Response, §6). |
delta |
string | The text delta for this chunk, of the kind named by delta_kind. Tool-call argument deltas are reassembled into Response.message.tool_calls (§6) and are NOT emitted as token events. |
Dispatch + ordering: LlmTokenEvents are dispatched on the observer delivery queue in
chunk_index order, all before the terminal LlmCompletionEvent for the same call.
Delivery follows the standard graph-engine §6 Event delivery rules — strict-serial across
the invocation, async-delivered. Token events fire ONLY when the call was made with stream
set; a non-streamed call emits no token events (backward-compatible). Like the other typed
variants, LlmTokenEvent carries no phase discriminator and is NOT subject to the phases
subscription filter; observers filter via type discrimination.
Privacy posture. The delta field carries model output (payload-bearing). The bundled OTel and Langfuse observers do NOT render token events (see below), so
there is no bundled-observer rendering surface to gate. Custom observers consuming token
events (the UI-forwarding case) are responsible for their own redaction posture, identical to
the custom-observer posture for LlmCompletionEvent (observability §9). The terminal
LlmCompletionEvent's payload remains gated by disable_provider_payload at the bundled
observers as today; streaming changes nothing there.
observability §5.5 + §8 — bundled observers ignore token events¶
A note in §5.5 (OTel) and §8 (Langfuse): the bundled observers do NOT render LlmTokenEvent
— no per-token spans, no per-token observations. Trace-level recording stays atomic at the
terminal LlmCompletionEvent: the OTel openarmature.llm.complete span and the Langfuse
Generation collapse the streamed deltas back into one input/output payload at end-of-call,
exactly as for a non-streamed call. A 500-token response produces one span / one Generation,
not 500 children. LlmTokenEvent exists for custom forwarding observers (per §9); the
backend mappings consume the terminal events only.
Conformance test impact¶
New fixtures under spec/observability/conformance/¶
0XX-llm-token-event-dispatch-on-stream— Acomplete(stream=...)call with a mocked streaming provider returning N content chunks. Asserts NLlmTokenEvents observed with monotonicchunk_index, the ordered concatenation ofdeltas equals the terminalLlmCompletionEvent's assembled content, and all token events share the terminal event'scall_id.0XX-llm-token-event-absent-without-stream— The same graph withstreamunset emits zeroLlmTokenEvents and oneLlmCompletionEvent(backward-compat / opt-in lockdown).0XX-streamed-tool-call-reassembles-no-token-events— A streamed response carrying tool-call argument deltas: asserts no tool-callLlmTokenEvents are emitted (token events carry content only), and the terminalLlmCompletionEvent's assembledResponse.message.tool_callsis complete and parses identically to the non-streamed equivalent (reassembly is provider-internal). Locks the "reassemble into the atomic Response, don't emit as token events" contract.0XX-llm-token-event-then-failure-mid-stream— A stream that errors partway: asserts the partial token events fire, then the terminalLlmFailedEvent(noLlmTokenFailedEvent), and the §7 exception raises out ofcomplete().0XX-llm-token-event-call-id-links-to-completion— Two streamed calls in one invocation: asserts each call's token events carry that call'scall_id, distinct across calls, each matching its terminalLlmCompletionEvent.0XX-llm-token-event-call-level-retry-one-call-id— A streamedcomplete(retry=...)call whose first wire attempt fails transiently and second succeeds: asserts all token events (across both wire attempts) share onecall_idand one node-levelattempt_index(the call-level wire attempt does NOT advance it — per the §7.1 interaction note), exactly one terminalLlmCompletionEventfires (no per-attempt typed event for the caught transient, per the §6 one-event-per-call mutual exclusion), and the assembledResponsereflects the successful attempt only. Locks the §7.1-vs-per-node distinction and the one-typed-event-per-complete()-call contract under streaming.0XX-otel-langfuse-atomic-under-stream— A streamed call with bundled OTel + Langfuse observers attached: asserts exactly oneopenarmature.llm.completespan and one Generation observation (no per-token children), with the full assembled input/output — i.e., token events are not rendered by the bundled observers.
New fixtures under spec/llm-provider/conformance/¶
0XX-openai-streaming-wire— The §8.1 streaming wire path: mocked SSE response (data:chunks withchoices[].delta,stream_optionsusage on the terminal chunk,[DONE]sentinel). Asserts the assembledResponse(content, tool_calls, usage, finish_reason) equals the equivalent non-streamed response.0XX-stream-unsupported-mapping-rejects— A provider mapping configured as streaming-unsupported (a synthetic mock standing in for a not-yet-streaming §8.X mapping such as Anthropic / Gemini): astream-set call raisesprovider_invalid_requestat pre-send validation, with no token events and no atomic fallback. Locks the §5 Provider streaming support rejection contract.
Final fixture numbers assigned at Accept.
Versioning¶
MINOR bump (pre-1.0). Additive at every surface that matters:
complete()'s newstreamparameter is optional, default-off — existing callers are unaffected; the atomic path is byte-for-byte the v0.4.0 behavior.- The §6 Streaming assembly contract describes how the streamed path produces a
Responsestructurally identical to the atomic path — no change to theResponseshape. LlmTokenEventis a new observer-union variant; observers that don't consume it are unaffected (it's opt-in via type discrimination, and only fires whenstreamis set).- Lifting the §10 Out of scope item is the one "removal," replaced by narrower deferrals.
Not a textual-only proposal: the reference implementation's OpenAI provider needs real streaming-wire consumption + reassembly, and the observer union grows a variant. Tentative spec version target deferred to Accept.
Alternatives considered¶
-
Invocation-level streaming API (
graph.stream_invocation(stream_node=...)). Reject — thestream_nodehint is invoke-time static, but in a multi-turn tool-use shape whether a given LLM call is the terminal user-facing one is decided at runtime by the model's tool-call output. A static node name can't express "stream whichever turn ends up terminal." It also forces single-consumer-per-invocation and new framework plumbing to route a node's tokens out. -
complete()returns an async iterator whenstreamis set. Reject — overloads the return type (ResponsevsAsyncIterator[Chunk]depending on a flag), hostile to static typing in both Python and TypeScript, and forces every node body to disambiguate at the call site. It also pushes "publish each chunk to a side-channel" plumbing into node bodies. The chosen design keeps the return typeResponseunconditionally and surfaces deltas via the observer union — the orchestration-free, type-stable shape. (Thestreamflag here controls event emission, not the return type; that is the whole distinction from this alternative.) -
Define
LlmTokenEventonly; leave provider streaming impl-defined. Reject — without a normative provider-side genuine-streaming contract, an implementation could await the full response and emit synthesized chunks, surfacing nothing earlier and defeating the first- token-latency purpose. The provider capability is the load-bearing half. -
A paired
LlmTokenFailedEvent. Reject — token events are within-call sub-events, not call outcomes. A call's failure is already carried by the terminalLlmFailedEvent(proposal 0058). Pairing a failure variant onto a sub-event would duplicate the outcome surface and create ambiguity about which event is authoritative for the call's result. -
Per-token OTel spans / Langfuse observations. Reject — a 500-token response would fan out into 500 child spans / observations, blowing up trace storage and UI rendering for no diagnostic gain. Trace recording stays atomic at the terminal event; token events are for the consumer's forwarding observer, not for the backend mappings.
-
Streaming via a
RuntimeConfigfield instead of acomplete()parameter. Reject — streaming is a per-call control-flow decision (this call streams to a UI; that internal call doesn't), more discoverable as an explicitcomplete()parameter than buried in the sampling-config record.RuntimeConfigcarries sampling parameters;streamis an operation mode.
Open questions¶
None remaining at draft time. The two questions surfaced during drafting are resolved in the proposal text above (collected here for retrieval).
Resolved at Draft:
- Node-body stream consumption — decided: v1 is observer-only. The actual demand (the
downstream agent streaming its answer to a UI) is the observer-forwarding case; the node body
gets the atomic
Responseat the end and doesn't need the stream. Direct node-body consumption (incremental parsing, early-stop) has no current consumer, and an async-iterator return mode is purely additive later. Deferred per Out of scope. - Token-event delta kinds — decided: content + reasoning token events (via a
delta_kinddiscriminator), tool-call deltas deferred.LlmTokenEventcarriesdelta_kind="content"(answer text) anddelta_kind="reasoning"(thinking text); the reasoning kind serves a live "thinking" UI element (a real consumer). Tool-call argument deltas are reassembled into the atomicResponsebut NOT emitted as token events: the "show tool progress live" case is better served by the tool-execution observability proposal'sToolCallEvent(the tool running) or the completetool_callson the terminalLlmCompletionEvent, not by streaming the model generating the request's args. Adelta_kind="tool_call"value is reserved for that follow-on. Deferred-tool-call -delta per Out of scope.
Out of scope¶
- Node-body direct stream consumption (async-iterator return) — v1 is observer-only; a node body consuming the stream directly (incremental parsing, early-stop) is deferred, with an opt-in iterator return offered alongside the atomic return if a consumer surfaces.
- Tool-call-delta streaming as token events —
LlmTokenEventcarries content only; tool-call argument deltas are reassembled into the atomicResponsebut not emitted as token events in v1. The "show tool progress live" UI case is served by the tool-execution observability proposal'sToolCallEvent(the tool running), not by streaming the model generating the request's arguments. A follow-on MAY add tool-call-delta emission (adelta_kinddiscriminator - tool-call fields on
LlmTokenEvent) if a concrete consumer needs it — additive. - Per-vendor streaming wire mappings beyond OpenAI-compatible — Anthropic §8.2 and Gemini
§8.3 streaming handling land as follow-ons, same per-vendor deferral pattern as the
embedding / rerank wire mappings. Until then those mappings reject
stream-set calls (§5 Provider streaming support) — so the behavior is defined, not undefined, for the not-yet-streaming mappings. - Streaming for non-completion provider operations — embedding / rerank streaming (some providers stream large result sets) is a separate concern; not v1.
- Per-node streaming of partial state updates — the graph-engine §7 Out of scope "Streaming outputs" item (per-node partial state deltas) is a different concern and stays out of scope; this proposal streams an LLM call's response, not a node's state.
- Tool-execution observability — observing the caller running a tool after the model
requested it (
ToolCallEvent) is a separate forthcoming proposal at a different layer; this proposal covers the model requesting tools in a streamed response (the tool-call argument deltas), not their execution. - First-token / inter-token latency telemetry — surfacing first-token-ms / inter-token-ms as span attributes is a possible follow-on; not load-bearing for v1.
- Token-event-level signaling of call-level-retry restarts — under §7.1 call-level retry,
the wire attempts share one
call_idand node-levelattempt_index, so a forwarding observer cannot detect a §7.1 restart from token events alone (the per-wire-attempt index is on the §7.1 OTel span). A follow-on MAY add a per-wire-attempt field toLlmTokenEventif a consumer needs token-event-level §7.1-restart detection; per-node retry restarts are already detectable via thecall_idchange.