The graph stream should support low-level event streams and high-level projection streams.
Core stream modes:
values: full state values after each stepupdates: per-node/per-task state updatesmessages: harness message or token deltas emitted by model nodescustom: arbitrary user stream writes from inside nodescheckpoints: checkpoint payloadstasks: task start and task result payloadsdebug: checkpoints plus task internalsevents: all graph lifecycle events
Typed stream part:
pub enum StreamPart<State, Output> {
Values {
namespace: Vec<String>,
data: Output,
interrupts: Vec<Interrupt>,
},
Updates {
namespace: Vec<String>,
data: IndexMap<NodeId, StateUpdate>,
},
Messages {
namespace: Vec<String>,
message: Message,
metadata: StreamMetadata,
},
Custom {
namespace: Vec<String>,
data: serde_json::Value,
},
Checkpoint {
namespace: Vec<String>,
data: CheckpointPayload<State>,
},
Tasks {
namespace: Vec<String>,
data: TaskStreamPayload,
},
Debug {
namespace: Vec<String>,
data: DebugPayload<State>,
},
}Event stream:
pub enum GraphEvent {
RunStarted { run_id: RunId, graph_id: GraphId },
RunStreamingStarted { run_id: RunId },
StepStarted { step: usize, active: Vec<NodeId> },
TaskStarted { task_id: TaskId, node: NodeId, triggers: Vec<String> },
TaskCompleted { task_id: TaskId, node: NodeId },
TaskCached { task_id: TaskId, node: NodeId },
TaskFailed { task_id: TaskId, node: NodeId, error: String },
StateUpdated { node: NodeId, update: serde_json::Value },
RouteSelected { node: NodeId, routes: Vec<RouteTarget> },
ContextForked { parent_task_id: TaskId, child_task_id: TaskId },
ContextForkJoined { parent_task_id: TaskId, child_task_id: TaskId },
SubgraphStarted { node: NodeId, child_run_id: RunId, namespace: Vec<String> },
SubgraphCompleted { node: NodeId, child_run_id: RunId },
SubAgentStarted { node: NodeId, agent: ComponentId, child_run_id: RunId },
SubAgentCompleted { node: NodeId, agent: ComponentId, child_run_id: RunId },
RecursionDepthChanged { depth: usize },
CheckpointSaved { checkpoint_id: CheckpointId },
InterruptEmitted { interrupt: Interrupt },
RunDraining { run_id: RunId, reason: String },
RunCompleted { run_id: RunId },
RunFailed { run_id: RunId, error: String },
Custom { name: String, payload: serde_json::Value },
}Streaming requirements:
- graph runs can be consumed as an async stream
- streaming does not require waiting for final state
- every streamed event carries run id, thread id, namespace, step, and node/task metadata when available
- subgraph streams preserve nested namespaces
- harness streams from model/tool/sub-agent nodes are forwarded with graph node context
- subscribers can filter graph events, harness events, sub-agent events, state updates, task payloads, messages, and checkpoints
- a typed run stream should expose final output, interrupted status, and pending interrupts even when the caller only subscribed to a subset of projections
The design above is the target shape; the current implementation is a
scoped-down but real subset built around crate::stream:
GraphEventEnvelope { run_id, task_id, ns, seq, event }(stream/types.rs) wraps everyGraphEventthe executor emits.GraphEventSink::emittakes the envelope, not the bare event —nsis the emitting graph instance's checkpoint namespace (empty at the top level, one segment deeper per level of subgraph embedding) andseqis a per-instance monotonic counter (CompiledGraph::sequence, anArc<AtomicU64>). The counter is shared across a clone that only swapsevent_sink(journal wrapping keeps counting where the plain run left off) but is reset to a fresh one for a subgraph embedded as a node (subgraph::namespaced/CompiledGraph::with_fresh_sequence) — the deepernsalready disambiguates that stream, so nothing is gained by chaining the parent's sequence into it.task_idisNoneuntil per-task correlation ids exist end to end (docs/runtime-comparison/feature-gaps.mdD4); adding it later is additive.StreamMode::{Tasks, Checkpoints}are implemented.GraphEvent::mode()mapsTaskScheduled/TaskStarted/TaskCompleted/NodeStarted/NodeCompleted/NodeFailed/NodeRetryScheduledontoTasks,CheckpointSaved/CheckpointRestoredontoCheckpoints; every other kind (including the plain run/step lifecycle events) is debug-only detail.stream::project::project_graph_event(event, modes)applies that mapping.GraphEvent::TaskStarted/TaskCompleted { cached }are new variants emitted alongsideNodeStarted/NodeCompleted/NodeFailedat the same boundary;cachedis alwaysfalsetoday (no per-node task cache exists yet — D2).StreamProjection(stream/project.rs) is the cross-source fold this doc's "harness streams … are forwarded with graph node context" bullet calls for:fold_graph_event/fold_agent_eventtake aGraphEventEnvelopeand a harnessAgentEventrespectively and append intomessages,tool_calls, andsubagents— each aVec<Cursored<T>>sharing one monotoniccursoracross all three views.StreamProjection::since(cursor)is the late-attach replay primitive: a consumer that connects after a run is already underway asks for everything past the cursor it last saw instead of re-reading history. OnlyGraphEvent::SubgraphStarted/SubgraphCompletedproject from the graph side today (assubagentsentries); the harness side covers model deltas (AgentEvent::ModelDelta→messages), tool lifecycle (ToolStarted/ToolCompleted/ToolFailed→tool_calls), and sub-agent lifecycle (SubAgentStarted/SubAgentCompleted→subagents).- Not yet implemented: the typed
StreamPart<State, Output>enum, thevalues/updates/customprojections from live graph state (those still require the graph-state side channel this doc's header note already calls out),RunStreamingStarted,ContextForkJoined, andRunDraining. A subgraph node does not automatically inherit its parent'sevent_sink— nested observability today requires configuring the same sink on both explicitly (seesubgraph::test::nested_subgraph_run_yields_envelopes_with_correct_namespace_depth_and_seqfor the pattern); full automatic propagation is future work alongside D4.