Streaming and Events
Convert streaming deltas into observable, replayable events.
What exactly changes in this chapter?
Keep the same loop while turning one response into ordered streaming deltas.
- One model.response event per delta
- Accumulated text and an explicit final-chunk marker
- One model turn still appends exactly one assistant message.
s02 · Streaming and Events
A transport chunk is “bytes arriving now.” An assistant message is “what the model finally said.” Never confuse the two.
What you will build
s01 established a bounded loop, but its response still looked like one returned string. Real models usually stream: text arrives incrementally, tool-call arguments may be fragmented, and the connection can fail halfway through. This chapter converts transport deltas into stable events while keeping conversation state clean.
By the end, you should be able to:
- Distinguish provider chunks, observable events, and the final assistant message.
- Explain why a UI may update token by token while
messagesis appended only once. - Design an ordered, correlated, redaction-aware event sequence.
- Use
deltaandaccumulatedto detect missing, duplicated, or out-of-order data. - Run the deterministic streaming demo and verify that its projections agree.
The problem: streaming creates two timescales
A non-streaming request has only “before” and “after.” A streaming request introduces two different clocks:
- Transport time: when chunks arrive, retry, or disconnect.
- Conversation time: which assistant message the completed turn contributes.
Appending one assistant message per chunk would create a history the model never produced:
assistant: "Observe "
assistant: "events, "
assistant: "not hidden state."Those were not three model turns. They were three deliveries for one turn. The correct final state is:
assistant: "Observe events, not hidden state."Streaming is a transport concern; message history is a conversation concern; the event stream is an observability concern.
Mental model: one fact, three projections
One model turn appears on three surfaces:
| Surface | Consumer | Incremental? | What remains |
|---|---|---|---|
| Provider stream | Adapter | Yes | Native chunks or deltas |
| Trace events | UI, eval, audit | Yes | Observable state transitions |
messages | Next model turn | No | One complete assistant message |
The data flow is:
provider chunks
"Observe " ─┐
"events, " ─┼─> accumulator ─> "Observe events, not hidden state."
"not..." ─┘ │
├─> model.response event × 3
└─> assistant message × 1Events are not hidden-state dumps
A trace records facts the harness actually observes: deltas, accumulated text, tool requests, execution results, and stop reasons. It does not record invisible chain-of-thought, and it should not contain credentials, full environment variables, or unredacted file contents.
Four properties of a reliable event stream
- Ordered:
sequenceincreases strictly within a session. - Correlated: parent IDs or tool-call IDs connect related work.
- Explainable: actor identifies user, model, harness, or tool.
- Publishable: redaction and provenance state the evidence boundary.
Turn chunks into events step by step
Step 1: make the model double return chunks
The demo declares three deterministic deltas:
model = ScriptedModel([
ModelTurn(chunks=(
"Observe ",
"events, ",
"not hidden state.",
), stop=True)
])ModelTurn.resolved_content defines final text:
@property
def resolved_content(self) -> str:
return "".join(self.chunks) if self.chunks else self.contentThe adapter may consume any provider-native chunk type, but it must present a stable internal representation to the harness.
Step 2: keep an accumulator for visible progress
AgentRunner processes each chunk:
accumulated = ""
for index, chunk in enumerate(turn.chunks):
accumulated += chunk
self._emit(
"model.response",
actor_kind="model",
actor_id=self.config.model_id,
payload={
"turn": turn_number,
"delta": chunk,
"accumulated": accumulated,
"final": index == len(turn.chunks) - 1,
"tool_calls": [],
},
)delta is useful for transport analysis; accumulated lets a player display the current complete text. A production store may keep only deltas to save space, but then the player must rebuild the projection reliably.
Step 3: append one message after the stream
After all events, the loop appends resolved_content:
final_text = turn.resolved_content
messages.append(Message(role="assistant", content=final_text))That line is outside the chunk loop. Its position enforces “three response events, one assistant message.”
Step 4: preserve causality in the trace
AgentRunner._emit() uses the previous event as the next parent:
event_id = self.trace.emit(
event_type,
parent_event_id=self._parent_event_id,
**kwargs,
)
self._parent_event_id = event_idThe teaching run is a linear chain. Parallel tools create a branching DAG, where “the previous event” is no longer enough.
Step 5: mark the final delta explicitly
The last event carries final: true. Consumers do not need an inactivity timeout or a socket close to guess whether business-level completion occurred.
Run it and compare the projections
Execute:
python3 -m curriculum.lessons.s02_events_streaming.demoInspect the three model.response events:
| Number | delta | accumulated | final |
|---|---|---|---|
| 1 | Observe | Observe | false |
| 2 | events, | Observe events, | false |
| 3 | not hidden state. | Observe events, not hidden state. | true |
Then verify the final run state:
runner, trace = build_demo()
result = runner.run("Stream one observability rule.")
assert result.final_text == "Observe events, not hidden state."
assert result.messages[-1].role == "assistant"
assert result.messages[-1].content == result.final_textCheck projection consistency
The final message, last accumulated field, and result.final_text must match:
responses = [
event for event in result.events
if event["type"] == "model.response"
]
assert responses[-1]["payload"]["accumulated"] == result.final_textA failed assertion means transport and conversation state have diverged.
From events to a player
A trace player does not need to understand the model SDK. It consumes normalized events and maintains a cursor:
cursor = 0
visible = events[:cursor + 1]
current = visible[-1]That enables:
- Pause at any observable state.
- Replay the user-visible sequence.
- Compare event types, results, and stop reasons across runs.
Do not treat timestamps as perfect causality. Distributed clocks drift; sequence, parent relationships, and correlation IDs are stronger evidence.
Common streaming failure modes
| Failure | Risk | Required policy |
|---|---|---|
| Duplicate chunk | Repeated text or corrupted arguments | Provider event-ID dedupe or idempotent accumulation |
| Out-of-order chunk | Invalid JSON or a regressing UI | Sequence validation, rejection, or buffering |
| Disconnect | Partial text mistaken for completion | Separate interrupted/error terminal state |
| Empty chunk | Consumer assumes no progress | Permit empty deltas while preserving sequence |
| Fragmented tool JSON | Incomplete arguments execute early | Wait for content-block completion before validation |
| Slow consumer | Memory grows without bound | Bounded queues, backpressure, sampling, or persistence |
| Sensitive text in traces | Published data leaks secrets | Field-level redaction before storage |
Exercises
A. Empty delta
Make the second chunk an empty string. Acceptance: sequence remains continuous, final text has no duplication, and the final response still has final: true.
B. Projection property test
For any tuple of chunks, assert:
- Response-event count equals chunk count.
- Every
accumulatedvalue is a prefix of the next one. - The final
accumulatedequals"".join(chunks). - Only one new assistant message appears.
C. Interruption contract
Design a teaching stream_error field on ModelTurn; do not overload stop=True. Acceptance: traces distinguish normal completion from transport interruption, and you document whether partial text enters messages.
D. Tool-call assembler
Design an accumulator for {"path", : "README, and .md"}. Dispatch to s03 only after valid JSON and a content-block end signal both exist.
Deep dive: production event-bus choices
The teaching implementation keeps events in an in-memory list. A production system must also answer:
- Persist first or push to the UI first?
- Resume a reconnect from event ID, sequence, or timestamp?
- Retain every token delta or aggregate high-frequency events?
- Represent parent relationships as a DAG when tools run concurrently?
- Redact in the adapter, event bus, or storage layer?
- Let an old player read a newer event schema?
A useful layering is: provider adapter parses protocol, harness emits semantics, transport delivers, storage retains, and UI projects. No layer should reverse-engineer meaning that its upstream layer failed to declare.
A good event system does not “record everything.” It records enough observable fact under a stable contract and clearly states what was never recorded.
Checkpoint
Before s03, make sure you can answer:
- Why can’t three chunks become three assistant messages?
- Which consumers need
deltaand which needaccumulated? - Why is socket close not business completion?
- Why does a linear parent chain fail with parallel tools?
- Should trace redaction happen before or after publication?
The next chapter lets the model produce structured ToolCall values. The harness must validate names and arguments, execute handlers, record results, and feed them into the next turn.
How does the teaching harness map to real agents?
The comparison focuses on event boundaries and consumable protocols, not on inferring internals from streaming UI behavior.
directThe pinned Codex protocol defines typed events for turn lifecycle, streamed content, tools, approvals, compaction, hooks, and collaborative-agent activity, and the turn implementation emits those events through the session boundary.
source + source · reviewedPi · Event Streampi-source-session-eventsPi combines a live typed AgentEvent stream with an append-only JSONL session tree whose entries carry id and parentId, allowing the active context to move between branches without deleting prior history.
source + source · reviewedReasonix · Event Streamreasonix-source-event-logReasonix designates an append-only events JSONL file as transcript authority and keeps derived context, event-index, display-index, conflict, checkpoint, and job artifacts in separate sidecars.
source + source · reviewedsession.start
Actor reference-agent produced sequence 0. Redaction status is clean.
{
"max_turns": 8,
"tool_count": 0
}1 events are visible; hidden chain-of-thought is not part of the trace.
Download JSONLNot just “try it”: know exactly what passes.
The long-form exercises leave room to explore; these commands protect the chapter's executable baseline.
Verify the streaming trace
Three deltas produce three ordered events and the last is explicitly final.
python3 -m curriculum.golden verify s02-events-streamingTest the streaming message contract
Deltas remain replayable without polluting the next-turn message history.
python3 -m unittest curriculum.tests.test_vertical_slice.VerticalSliceTests.test_s02_streaming_becomes_events -vNot pseudocode: this is the program the lesson actually runs.
Every important step in the tutorial can be checked against this source and its automated tests.
from curriculum.harness import AgentRunner, ModelTurn, ScriptedModel
from curriculum.lessons.common import lesson_trace, print_run
def build_demo() -> tuple[AgentRunner, object]:
trace = lesson_trace("s02-events-streaming")
model = ScriptedModel(
[ModelTurn(chunks=("Observe ", "events, ", "not hidden state."), stop=True)]
)
return AgentRunner(model=model, trace=trace), trace
def main() -> None:
runner, trace = build_demo()
result = runner.run("Stream one observability rule.")
print_run(result.final_text, trace)
if __name__ == "__main__":
main()
Open the file on GitHub ↗