Progress narration
A second, cheap model that says what the agent is doing while it does it — decisions before each step, observations after, from observable actions only.
An agent that works in silence for forty seconds looks broken. Progress narration puts a second model beside the run:
▸ Reading guests.csv to collect the confirmed guests and their plus-ones.
← Three entries: Dana confirmed, Luis confirmed with one, Sam maybe.
▸ Reading venue.txt for the capacity to compare that headcount against.
← The venue seats 3.
▸ Writing a Python script that totals the guests and compares the capacity.
← The script came back with 3 people against a capacity of 3 — they fit.Turn it on per agent:
agent = Agent(
llm=main_llm,
progress_summaries=True,
decision_llm=cheap_llm, # optional; falls back to `llm`
)What it looks like
Two models, two jobs: the agent works, and a cheap one says what is happening. Neither sees the other's private state.
Cost, plainly
Each step adds one real LLM call before the tools and one after. A run
that made ten calls makes thirty with narration on. That is why it is off by
default, and why decision_llm exists — narration is a job for a small fast
model, not the one doing the work.
Agent(llm=opus, decision_llm=haiku, progress_summaries=True)What the narrator can see
Deliberately little:
- the tools that were selected, and the arguments they were given
- what those tools returned
- the visible messages in the conversation
And deliberately not:
- the system prompt — skipped explicitly, so instructions you keep private stay private
reasoning_content— never read, so a thinking block cannot leak into a user-facing string- tools — the narrator is called with
tools=[]; a narrator that can act is not a narrator
Long values are bounded before they are sent, so a 50,000-character tool result costs a few hundred tokens to describe rather than blowing the context window.
The events
{
"type": "agent_decision",
"payload": {
"summary": "Reading guests.csv to collect the confirmed guests.",
"next_action": "call_tools",
"tools": [{"name": "read_file", "arguments": {"path": "guests.csv"}}],
"iteration": 1,
"generated_by_model": true
}
}{
"type": "agent_observation",
"payload": {
"summary": "Three entries: Dana confirmed, Luis confirmed with one, Sam maybe.",
"next_action": "evaluate_results",
"iteration": 1,
"generated_by_model": true
}
}A decision with no further tools carries "next_action": "finish".
Both arrive on agent.stream() like everything else:
for event in agent.stream(prompt):
if event.type == "agent_decision":
print("▸", event.payload["summary"])
elif event.type == "agent_observation":
print("←", event.payload["summary"])When narration fails
It never takes the run with it. A timeout, a dead provider, an unparseable
reply — each emits progress_summary_failed with the error and the run
continues:
elif event.type == "progress_summary_failed":
log.warning("no summary: %s", event.payload["error"])How it renders
Decisions and observations are rows of their own everywhere — narration about a run is not the answer to the user, and collapsing the two would say so falsely.
| Surface | Shows |
|---|---|
| Tree | ├─ Decision and ├─ Observed branches |
| Live panel | An accented step with a DECISION / OBSERVED label |
| Timeline | agent_decision / agent_observation steps |
Tool groups
With narration on there is prose between every call, and the transcript's usual rule — prose breaks a work run — would put every single call in a row of its own. So the runtime declares a tool group per iteration, and the renderers group by it:
{"type": "tool_group_started", "group_id": "tool_group_1", "tool_count": 2,
"tools": [{"name": "read_file", "call_id": "call_1_1"},
{"name": "grep_files", "call_id": "call_1_2"}]}Every tool_called and tool_completed inside carries the same group_id,
so a UI can draw one expandable box per iteration — 2 tools · 13.0ms —
however much was said in between.
A group holds as many calls as the model asked for in that turn. Models that batch tool calls fill them; models that emit one call per turn produce groups of one, which is the model's behaviour rather than the renderer's.
The final answer
final_answer is emitted just before run_completed, so a client does not
have to infer which event carries the answer:
{"type": "final_answer", "payload": {"content": "…", "format": "markdown"}}See also
- The UI timeline — the frontend-shaped feed
- The tree view
- Agent — Streaming — the full event vocabulary