How it works
Two views exist over one run. Agent.stream_text() yields the answer's text as
plain strings; Agent.run_stream() yields typed RunEvents and exposes the
final RunResult. Both use the same engine, tools, and permission checks as
agent.run().
Text deltas are provisional. A retried model call restarts the answer and emits
ATTEMPT_RESET: discard the text already shown, and treat the final
RunResult as authoritative.
Stalled streams and continued replies
A stream that never starts (RetryPolicy.stream_first_event_timeout_seconds, on
Anthropic), stalls for RetryPolicy.stream_idle_timeout_seconds, or ends
without the provider saying it is complete, is retried the same way, with an
ATTEMPT_RESET. See Streams that stall or end
early.
A reply cut off at its token budget and then continued is not a reset. The continuation streams after the text already shown, and the final output joins the pieces.
Stream text
import asyncio
from harnessx import Agent, AgentConfig, RunFailed
agent = Agent(config=AgentConfig(system_prompt="You are helpful."))
async def main():
async with agent:
try:
async for chunk in agent.stream_text(
"Tell me a story",
on_reset=lambda: print("\n[answer restarted]"),
):
print(chunk, end="", flush=True)
except RunFailed as exc:
print("run failed:", exc.error["message"])
asyncio.run(main())
A run that does not end with a whole answer raises a RunError out of
stream_text(): RunFailed, RunTruncated when the reply hit the token
budget, RunAwaitingInput when a tool needs approval, or RunCancelled. The
on_reset callback fires on ATTEMPT_RESET; without it the answer simply
starts over in the stream.
With fallbacks configured in AgentConfig.fallbacks, failover inside the chain
happens only before the first chunk, and a provider switch is not itself a
reset event. After a chunk arrives, a transient failure goes back to the
engine, and any further attempt resets as above. See Retries & fallback
models.
Refusals and the step limit
stream_text() also raises RunRefused when the model declined or a filter
stopped it, and RunLimitReached when the run reached its step limit.
RunTruncated is raised only after truncation recovery has run out.
Stop a stream early
Breaking out of the loop early leaves the run to be cancelled when the generator
closes. Wrap the generator in contextlib.aclosing() to make that immediate:
import contextlib
async def first_line(agent, prompt):
text = ""
async with contextlib.aclosing(agent.stream_text(prompt)) as chunks:
async for chunk in chunks:
text += chunk
if "\n" in text:
break
return text.partition("\n")[0]
Stream events
import asyncio
from harnessx import Agent, AgentConfig, RunEventType
agent = Agent(config=AgentConfig(system_prompt="You are helpful."))
async def main():
async with agent, agent.run_stream("Tell me a story") as stream:
async for event in stream:
if event.type == RunEventType.TEXT_DELTA:
print(event.data, end="", flush=True)
elif event.type == RunEventType.TOOL_CALL_START:
print(f"\n> calling {event.data.name}({event.data.input})")
elif event.type == RunEventType.TOOL_RESULT:
if event.data.is_error:
print(f" tool error: {event.data.content}")
elif event.type == RunEventType.TURN_COMPLETE:
print()
result = await stream.result()
result.raise_for_status()
asyncio.run(main())
Use the stream's async context manager to close or cancel an abandoned stream.
Durable runs also emit approval and recovery events. RunEventType members:
| Event | event.data |
|---|---|
TEXT_DELTA |
Answer text fragment |
TEXT_COMPLETE |
Finished text of a turn |
THINKING_DELTA |
Reasoning text fragment |
TOOL_CALL_DELTA |
ToolCallDelta(id, name, partial_json) while the model writes a tool call; see below |
TOOL_CALL_START |
Tool name and input, when the call is complete and about to run |
TOOL_CALL_COMPLETE |
Finished call |
TOOL_RESULT |
Content and is_error |
TURN_COMPLETE |
End of a model turn |
ATTEMPT_RESET |
Restarted answer |
WAITING |
{"on": "model", "seconds": ...} while a model call is slow, {"on": "retry", ...} during a long wait before a retry; see below |
TODOS_UPDATED |
The whole task list; see below |
APPROVAL_REQUIRED |
A tool call that needs approval (durable runs) |
RECOVERY_REQUIRED |
A tool call whose outcome is unknown (durable runs) |
GAP |
{"reason": "event_history_unavailable"} when a Temporal run's event history cannot be read |
ERROR |
Run error |
RUN_RESULT |
Final RunResult |
Show a tool call while it is being written
A model can spend a while writing a single tool call, say a whole report passed
to write_file. TOOL_CALL_DELTA lets you show something in the meantime. The
first delta for a call carries the tool's name and an empty partial_json,
which is a good moment to show "calling write_file…". Each delta after that
carries the next piece of the arguments, and joined together they make the
call's JSON. TOOL_CALL_START still comes once the call is complete and about
to run.
from harnessx import RunEventType
async def show_tool_calls(agent, prompt):
async with agent.run_stream(prompt) as stream:
async for event in stream:
if event.type == RunEventType.TOOL_CALL_DELTA and not event.data.partial_json:
print(f"\n[writing a call to {event.data.name}...]")
elif event.type == RunEventType.TOOL_CALL_START:
print(f"[running {event.data.name}]")
return await stream.result()
Anthropic, OpenAI, Azure and OpenRouter send the arguments in pieces. Gemini
sends each call in one go, so you get two deltas: one with the name and one
with all the arguments. Handle them like text deltas: when ATTEMPT_RESET
arrives, drop anything you showed from that attempt. stream_text() leaves them
out, and so does a stream whose reply is buffered by response middleware.
RunStream.text(on_reset=...) is the same text view over any event stream,
including a durable runtime.run_stream():
async def text_only(stream):
async with stream:
async for chunk in stream.text():
print(chunk, end="", flush=True)
AgentRuntime offers the same three verbs: runtime.run(),
runtime.run_stream(), and runtime.stream_text().
Show that a slow call is still working
A provider that is slow rather than broken sends nothing: no text, no error, no
retry. To the stream it looks like a hung agent. WAITING events fill that
silence. The first arrives after first_after_seconds without a chunk, then
one every repeat_every_seconds, each carrying the seconds waited so far.
from harnessx import Agent, AgentConfig, ProgressPolicy, RunEventType
agent = Agent(config=AgentConfig(
progress=ProgressPolicy(first_after_seconds=10.0, repeat_every_seconds=15.0),
))
async def show_progress(agent, prompt):
async with agent.run_stream(prompt) as stream:
async for event in stream:
if event.type == RunEventType.WAITING:
print(f"\n[still waiting on the {event.data['on']}: {event.data['seconds']}s]")
elif event.type == RunEventType.TEXT_DELTA:
print(event.data, end="", flush=True)
return await stream.result()
Those values are the defaults. ProgressPolicy(first_after_seconds=None) turns
the events off. You get the same events, with "on": "retry", while the agent
waits before trying again, for example after a server's Retry-After. They are notices, not deadlines: nothing is cancelled, and the
call is still bounded by RetryPolicy.call_timeout_seconds. stream_text()
skips them.
Try it with no API key: uv run python examples/01-basics/progress_and_waiting.py.
Task list updates
RunEventType.TODOS_UPDATED fires when the agent changes its task
list with write_todos.
event.data is a dict with the whole list every time: todos (each
{"content", "status"}), completed, total, and in_progress, the item being
worked on or None.
from harnessx import RunEventType
async def show_plan(agent, prompt):
async with agent.run_stream(prompt) as stream:
async for event in stream:
if event.type == RunEventType.TODOS_UPDATED:
plan = event.data
print(f"[{plan['completed']}/{plan['total']}] {plan['in_progress'] or ''}")
return await stream.result()
The event is checked once per batch of tool calls, so a durable or Temporal run reports the plan exactly as a direct one does.