Runner
Once a run has been accepted and a working
RunState exists, the runner takes
over. It turns that state into one or more model calls, executes tool work, and
decides whether control stays local or moves to another agent.
Runner validates run dependencies,
starts execution, and completes the caller’s stream.
RunLoop owns one run’s model and
tool turns, history, and cleanup.
AgentDelivery creates child
agents, delivers delegated input, and closes child streams.
RunResult records what came out of the
loop, RunnerHooks exposes useful
observation points, and
DefaultAgentTool plus
DefaultHandoff are the bridge
that lets delegation appear to the model as part of the same tool surface. If
shims are configured, the runner also consumes the prepared turn they build for
each model call.
Runner does not persist conversation state. It does own process-local
execution guards such as retry limits, max turns, and generic spawned-agent
depth/thread limits. Those spawned-agent limits apply inside the current runner
process; distributed runtimes need their own coordinator-level limits if work
crosses a process boundary.
The runner is used both by:
- the runtime-facing
agentlane.harness.Agent - the higher-level local
agentlane.harness.agents.DefaultAgent, which uses that lower-level agent for the simplerrun(...),run_stream(...), andrun_events(...)surfaces
The Loop
At a high level, one run looks like this:
- prepare the next turn from instructions and current history
- let shims adjust that prepared turn
- build the next request
- call the model
- record the raw response
- inspect the response
- either finish, execute tools, or hand off to another agent
queued run input
|
v
+---------------------------+
| DefaultAgent.run(...) |
| or run_stream(...) |
+-------------+-------------+
|
v
+---------------------------+
| AgentLifecycle |
| passes working RunState |
+-------------+-------------+
|
v
+---------------------------+
| Runner.run |
+-------------+-------------+
|
v
+---------------------------+
| prepare turn |
| shims may mutate it |
+-------------+-------------+
|
v
+---------------------------+
| build request |
| instructions + history |
+-------------+-------------+
|
v
+---------------------------+
| call model |
+-------------+-------------+
|
v
+---------------------------+
| inspect response |
+------+------+-------------+
| |
| +--------------------+
| |
v v
+--------------+ +-------------------+
| execute tools | | transfer handoff |
+------+-------+ +---------+---------+
| |
v v
next model turn downstream run
The lifecycle owns queueing and persistence around this loop. The runner owns the loop itself.
Prepared Turns And Shims
Before each model call, the runner works from one
PreparedTurn.
That object carries:
- the working
RunState(which itself holds the persisted instructions and history) - the effective visible tools for the turn
- the effective model arguments for the turn
- per-run transient state shared across shim callbacks
Instructions are not a separate top-level field. They live inside the run state
and are read or mutated through the run state, including the
set_system_instruction(...) and append_system_instruction(...) helpers on
the prepared turn.
If bound shims exist, they are called in descriptor order to adjust that
prepared turn before the runner builds the canonical message list. They may
also replace that final message list for one model call when
transform_messages(...) is needed.
The canonical request builder is shared with conversation compaction through
agentlane.harness.compaction.render_request_messages(...). Compactors should
use that helper when estimating or summarizing history so their view of the
request matches the runner’s next model call.
Streaming
The runner also owns live model streaming for one run.
At the lowest streaming level the harness does not define a second event model.
It reuses ModelStreamEvent
directly and adds one small harness handle:
RunStream. A higher-level harness
event model is layered on top of these raw model events through
run_events(...); see Run Events below.
That split is deliberate:
ModelStreamEventis the live per-model-call event typeRunStream.result()is the whole-run completion point
One streamed harness run may cross multiple model calls because of tools or
first-class handoff, so one run_stream(...) may emit more than one
ModelStreamEventKind.COMPLETED. The final whole-run result is still
RunResult.
Streaming remains local to the harness in this step. The runner and lifecycle
handle it without changing runtime send_message(...) delivery semantics.
Streaming Boundaries
The streamed runner behavior has a few important boundaries:
- first-class handoff keeps the stream going because control transfers to the delegated agent
- agent-as-tool remains internal, so the child agent’s own model events are not surfaced on the parent stream
- streaming calls do not use the runner’s outer retry wrapper after events have started emitting, because replaying another provider attempt on top of partial output would be incorrect
- if you send work through
runtime.send_message(...), you still receive one final result after the run finishes - live per-event streaming is available through the local harness streaming
APIs such as
DefaultAgent.run_stream(...)
Run Events
Alongside run(...) and run_stream(...), the runner exposes a third entry
point: run_events(...), which returns a
RunEventStream. This is a
higher-level harness event model layered over the raw ModelStreamEvent
stream.
run_events(...) accepts an optional approval_events async iterator. When
provided, brokered tool-approval lifecycle events are forwarded into the same
stream as RunToolApprovalEvent.
For complete JSON event payloads in a custom transport, use
the event’s to_dict() or dumps() methods.
They preserve the source kind and complete nested payload, including model and
approval wrappers, without applying display limits or selecting only UI fields.
The host owns framing, access control, and stream cleanup.
For local TypeScript apps that need these events from a Python backend process,
use the stdio bridge documented in
Process Bridge. It uses the same source
RunEvent objects with its own event names and selected fields. That protocol
is unchanged and is not an alias of native to_dict() or dumps() output.
Each emitted item is a RunEvent, a union tagged by RunEventKind:
RunModelStreamEventwraps one underlyingModelStreamEventRunAgentStartEvent/RunAgentEndEventmark agent run boundariesRunLLMStartEvent/RunLLMEndEventmark model requestsRunToolStartEvent/RunToolEndEventmark tool callsRunToolApprovalEventwraps one brokered approval eventRunHandoffStartEvent/RunHandoffEndEventmark first-class handoff control transfersRunStateSnapshotEventcarries a compactRunStateSnapshotat stable run boundariesRunPlanUpdatedEventcarries the structured plan after a successful first-party plan-tool call
RunStateSnapshotEvent boundaries are named by RunStateSnapshotBoundary:
run_start, turn_prepared, tool_round_end, and run_end. The snapshot
itself is compact (turn_count, history_length, response_count, and a copy
of shim_state) rather than the full working state.
Working history contains the
ephemeral messages of the run, so
history_length at run_start, turn_prepared, and tool_round_end counts
them. The runner removes them before run_end, so history_length at
run_end equals the length of the committed history.
Tool success and failure
RunToolEndEvent carries a framework-derived outcome alongside the raw
result:
ok: bool— whether the runner considers the call successful.error: ToolError | None— a typed failure payload (message, optional stablekind) whenokisFalse.
Consumers read event.ok / event.error directly instead of inferring success
from result wording or reflecting over tool-specific result fields. The runner
derives the outcome with tool_outcome(result), which recognizes, in order:
ToolFailure— the one public, typed way a tool implementation signals failure.ToolFailureis astrsubclass carrying aToolError, so the model still sees its text verbatim (the default formatter rendersstrvalues unchanged) while the framework reads the structured error. The first-partybashtool returnsToolFailurefor timed-out, cancelled, and non-zero-exit outcomes.- A captured
BaseException— a raised-and-captured tool error. - A mapping with a truthy
errorentry — the structured-mapping convention third-party tools commonly return.
Anything else is treated as ok. First-party tools that render operational
errors as plain strings stay ok because result wording is not a contract; a
tool that needs a failure marked returns ToolFailure.
ToolFailure, ToolError, the derived ToolOutcome, and the tool_outcome
helper are exported from agentlane.harness:
from agentlane.harness import ToolError, ToolFailure, tool_outcome
A tool handler signals a typed failure by returning ToolFailure(text=..., error=ToolError(message=..., kind=...)). The text is what the model sees; the
error is what RunToolEndEvent.error carries.
Plan-updated events
After a successful first-party plan-tool call, the runner emits a
RunPlanUpdatedEvent carrying the structured plan as a tuple of RunPlanItem
(step, status) plus the optional model-supplied explanation and the
originating tool_call. Consumers render plan UX from this typed payload
instead of string-matching the plan tool’s success message. The public
constants PLAN_TOOL_NAME and PLAN_UPDATED_MESSAGE are exported from
agentlane.harness.tools for callers that still need the wire literals.
Lineage and scope semantics
Every task-carrying run event exposes lineage so consumers do not latch the root task id and infer subagents by comparison:
parent_task_id: str | None— the parent run’s task id, orNonefor the stream root.is_root: bool— whether the event belongs to the stream’s root run.
The emitter latches the first agent run’s task_id as the stream root. In the
default single-agent-plus-delegation topology every event in one stream is a
root event, because a delegated child runs in its own runtime turn and surfaces
to the parent only as a tool call.
RunToolStartEvent / RunToolEndEvent additionally carry is_delegation: bool, set structurally for agent-as-tool and handoff calls (by runner
tool-definition type, not by name heuristics), so consumers identify delegation
without an app-side tool-name registry.
Two scope contracts matter for consumers that aggregate run telemetry:
RunAgentEndEventfires for child tasks too, carrying that child’sresult, when an application shares run hooks across agents. Treating it as “the whole run ended” records child telemetry as root telemetry; gate root-only aggregation onis_root.- State snapshots are root-stream-only by contract: a delegated child never
emits
RunStateSnapshotEventinto the parent stream.
Stream Cancellation And Closure
For DefaultAgent, a cancelled run(...) reports a RuntimeError with
delivery status canceled. The run_stream(...) and run_events(...)
interfaces report cancellation with asyncio.CancelledError. Cancelled runs
do not commit partial RunState. Cancelling a run on a shared runtime leaves
that runtime available for later runs. Shim cleanup finishes before the next
delivery for the same agent starts.
run_events(...) and run_stream(...) both return a stream handle that
exposes aclose() (inherited from the shared
BaseRunStream). The
cancellation contract is:
-
aclose()stops the underlying run. When you pass acancellation_tokentorun_events(...), the runner wirestoken.cancelas the stream’son_closehook. Callingawait stream.aclose()therefore cancels that token, which propagates through the async run chain and stops the in-flight provider request.aclose()also runs the stream’s cleanup callbacks (cancelling the internal run task and any approval-forwarding task) and ends the iterator. Without acancellation_token,aclose()still ends the iterator and runs cleanups, but there is no token to cancel, so a blocking provider call already in flight is not interrupted by closure alone. -
Cancelling the consuming task does not close the stream. If you iterate the stream from a task and cancel that task, the
CancelledErrorunwinds yourasync for, but it does not callaclose()for you. The underlying run task and provider request keep going until they finish or are cancelled another way. To stop the run when a consumer goes away, callaclose()explicitly — typically in afinallyaround the consumption loop, or by cancelling the samecancellation_tokenyou handed torun_events(...). -
result()must be retrieved afteraclose().aclose()fails the stream’s result future withasyncio.CancelledError. Awaitresult()(and swallowCancelledError) after closing so asyncio does not log a “Future exception was never retrieved” warning. The sharedclose_stream_callback/_close_streamhelpers in_stream_base.pyfollow exactly this close-then-drain pattern.
The recommended host pattern is to own the cancellation_token, pass it into
run_events(...), consume the stream in a try/finally, and call
await stream.aclose() in the finally so a dying consumer always stops the
provider-side request:
token = CancellationToken()
stream = await agent.run_events(prompt, cancellation_token=token)
try:
async for event in stream:
handle(event)
finally:
await stream.aclose()
with contextlib.suppress(asyncio.CancelledError):
await stream.result()
Cooperative cancellation for blocking I/O
CancellationToken is
cooperative: it cannot interrupt a blocking call already running in a worker
thread. Its surface is is_cancelled (poll), await wait_cancelled() (await
the request), link_future(future) (cancel an asyncio.Future/task when the
token is cancelled), and cancel().
For asyncio.to_thread(...)-style blocking calls (for example a synchronous
HTTP client, or urllib.request.urlopen), the intended pattern is:
- Check
token.is_cancelledbefore starting the blocking call and return early if cancellation is already requested. - Run the blocking call in a worker thread, keeping any per-call timeout short enough that a cancelled run does not linger longer than that timeout.
- Check
token.is_cancelledagain after the call returns and discard the result if cancellation happened while it was in flight.
A blocking call such as urlopen(...) cannot be interrupted mid-flight by the
token, so a cancelled run may linger up to that call’s own timeout before the
worker thread unblocks. Where you control the awaitable instead of a blocking
thread call, prefer token.link_future(task) so the token cancels it directly.
For cancellable network work, wrap the request in an asyncio.Task and link it,
or use an async HTTP client whose request future the token can cancel.
Run Result And State
RunResult records what came out of the
loop. Its fields are final_output, responses, turn_count, and an optional
run_state.
RunState is the resumable shape. It
holds instructions, history, responses, persisted shim_state
(ShimState), and turn_count. It is
also one of the accepted RunInput forms (alongside a plain str and a
list[RunHistoryItem]), so a completed run’s run_state can be passed back in
to resume the conversation. The persisted shim_state survives across resumes.
RunResult.run_state never contains ephemeral messages. Only
RunResult.run_state and AgentSnapshot are durable views of a run. Do not
persist history that you copy from hooks or from RunLLMStartEvent.messages,
because it can contain ephemeral messages.
turn_count counts turns started, not completed: the runner increments it at
the start of each turn, before shims prepare the turn and before the model is
called. While turn N is being prepared or executed, turn_count is N, so the
first turn observes turn_count == 1; after the run finishes it equals the
total number of turns that ran (RunResult.turn_count is this final value).
Gate first-turn logic on turn_count == 1 rather than reading it as a
completed-turns count.
Request Ownership
One of the runner’s main jobs is deciding how a high-level run turns into a canonical model request.
That means the runner is the place where:
- prepared instructions are combined with accumulated run history
- any one-turn context items are added
- visible tools are attached to the request
- the structured-output schema is forwarded
- model arguments are passed through
This is why the harness public API does not require application code to build raw message dictionaries itself.
Tool Calls
When the model returns tool calls, the runner appends that assistant turn to the working history, executes the tool calls, appends tool results, and asks the model again.
Ordinary executable tools run through
ToolExecutor. The runner also
enforces tool visibility and loop-safety limits so a run cannot keep requesting
the same tools forever.
For each ordinary executable tool call, the runner also builds an explicit
ToolExecutionContext containing the run id, agent name, and model tool-call
id. ToolExecutor passes that context to the tool handler; permission policies
and approval callbacks can use it for audit or UI correlation without relying
on hidden process-local state. The executor accepts these contexts as a mapping
keyed by model tool-call id because one model response can contain multiple
tool calls. The tool handler receives only its own single context.
Live Run-State View
The runner also stamps a read-only view of the live run onto each ordinary
tool’s context as context.run_state, typed as
RunStateView and implemented by
LiveRunStateView. It lets a tool
observe the run it is executing inside without an app-built side channel:
run_state.task_id— the stable task identity for the run.context.run_idis guaranteed to equalstr(run_state.task_id), so per-run stores may be keyed by either field consistently. This is the documented, stable relationship betweenrun_idand task identity.run_state.shim_state— the live persisted shim-owned state, read-only. The view reads through to current values, so state a shim writes earlier in the run is visible to later tool calls.run_state.active_skill_names— the names of skills active for the run. A skills shim records these under ashim_statekey ending inACTIVE_SKILL_NAMES_STATE_KEY_SUFFIX(the shim name is the key prefix, so multiple skills shims never collide); the accessor unions every such key. Tools that need active-skill context can read this instead of reaching for a private state key.
context.run_state is None when a tool runs outside a runner loop (for
example a direct tool.run(...) call). The view is read-only; a tool that
needs to persist state should do so through its own shim, never by mutating the
run.
The read-only boundary is enforced at the top-level shim_state mapping:
inserting, replacing, or deleting keys through context.run_state.shim_state
raises TypeError. Values stored under those keys are ordinary shim-owned
objects. Shims that expose nested containers should expose immutable values when
they need stronger protection.
Agent-As-Tool
Agent-as-tool uses the same model-facing pattern as any other tool: the model selects a tool name and arguments, and the framework validates those arguments before doing anything else.
The important difference is what happens next. Instead of calling a local function, the runner routes the work to another agent and converts the child result back into a tool result for the caller’s loop.
Use this pattern when the caller should continue after the delegated work returns.
The first-party base agent tool is the generic spawned-helper form of this
pattern. It accepts a one-word logging/tracing name plus a complete task
instruction, spawns a fresh helper, and returns that helper’s result as tool
text. Generic spawned helpers do not inherit the parent conversation or parent
system prompt. They inherit parent direct tools by default and reuse the
parent’s descriptor shims, so shim-contributed tools and prompt guidance flow
through the same prepared-turn path used by parent agents. The Runner carries
process-local depth and live-agent limits to prevent runaway recursion.
The runner resolves child-visible direct tools with ToolConfig.
INHERIT_TOOLS merges parent and child-local tools, OVERRIDE_TOOLS replaces
or clears parent tools, and RESTRICT_TOOLS.only(...) filters inherited parent
tools by name before adding child-local tools. Bare Tools(...) and None
preserve legacy override behavior.
agent_max_depth is inclusive for generic spawned agents. A direct child has
depth 1, so Runner(agent_max_depth=4) allows delegated agents through depth
4 and rejects the next nested spawn attempt.
Handoffs
Handoffs also appear to the model as tool-like choices, but their semantics are different. A handoff transfers control. The original agent does not resume afterward.
That distinction is the reason the runner handles handoffs itself instead of treating them as normal tool execution.
Use a handoff when the next agent should take over the conversation rather than act as a subroutine.
Hooks And Retries
RunnerHooks give you lifecycle
callback points during the run, such as agent start and end, model calls, and
tool calls.
What a hook does at those points is up to the author. Common uses include tracing, logging, metrics, policy checks, database writes, script execution, and other application-specific side effects.
Public hook inputs accept either one hook instance or an ordered sequence of hooks. When more than one hook is present, the harness composes them internally and forwards callbacks in order.
For bound agents, that composition is resolved once per concrete agent instance:
- explicit developer-supplied hooks run first, in the order provided
- shim-contributed hooks run second, in shim descriptor order
That keeps the runner model simple. It still receives one resolved hook object for the run, even when several hook implementations are active behind it.
The runner can also add an outer retry layer. That retry is intentionally narrow. Provider-specific retries still belong in the model client layer.