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.

4 min read
9 sections
Edit this page

An agent that works in silence for forty seconds looks broken. Progress narration puts a second model beside the run:

text
▸ 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:

python
agent = Agent(
    llm=main_llm,
    progress_summaries=True,
    decision_llm=cheap_llm,     # optional; falls back to `llm`
)

What it looks like

progress_summaries=True Live
DecisionReading guests.csv to collect the confirmed guests and their plus-ones.
Read guests.csv✓ read_file · 3.3ms — 4 lines1 tool · 3.3ms
ObservedDana Kim is confirmed with no plus-ones and Luis Marin is confirmed with one.
DecisionNow reading venue.txt for the capacity to compare that headcount against.
Read venue.txt✓ read_file · 3.6ms — 1 line1 tool · 3.6ms
ObservedThe venue seats 3.
Three people against a capacity of three — they fit.
6,985 tokens · claude-opus-5 · narrator: claude-haiku-4.5

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.

python
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

json
{
  "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
  }
}
json
{
  "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:

python
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:

python
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.

SurfaceShows
Tree├─ Decision and ├─ Observed branches
Live panelAn accented step with a DECISION / OBSERVED label
Timelineagent_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:

json
{"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:

json
{"type": "final_answer", "payload": {"content": "…", "format": "markdown"}}

See also