Steering seams
A steering seam is a point during a running agent loop where the runtime
checks for pending out-of-band influence: a queued user message, a system
reminder, an inbox feedback note, or a revocation. Every drain in the agent
loop routes through agent_stage(agent, session_id, stage, input?). Its
AgentStage argument is a closed vocabulary, so the seam catalog is checked by
the type system rather than recovered by grepping the loop body.
What you can inject#
Three orthogonal channels feed into the loop:
| Channel | Producer | Drained from | Renders as |
|---|---|---|---|
| Bridge injections | session/inject and session/remind over ACP; agent_session_push_bridge_injection for Harn-driven hosts | agent_stage at every bridge seam (see below) | New user message or system reminder in the transcript |
| Inbox feedback | In-pipeline agent_session_inject_feedback, agent_session_post_event, command policy, MCP server hooks, stall diagnostics | agent_stage at pre_compact / post_compact | User-role messages |
| Direct transcript inject | transcript.inject_reminder, internal agent_session_inject | Appended directly when called | Whatever shape the caller built |
Bridge injections carry a mode — interrupt_immediate, finish_step,
audit_only — that decides which seams drain it.
Note on
audit_only. This mode was previously calledwait_for_completion. The rename (harn#2212) is truth-in-advertising: reminders queued with this mode land in the transcript atloop_exitbut are never rendered into a model prompt. Hosts that need the model to react to a reminder before the agent terminates must usefinish_step, which drains at every iteration boundary.
The seam catalog#
agent_stage receives exactly these AgentStage values, in this order, per
iteration:
| Kind | Where | Bridge modes drained | Inbox? |
|---|---|---|---|
iteration_start | Top of each iteration, after iteration_start event | interrupt_immediate, finish_step | no |
pre_compact | Just before agent_autocompact_if_needed | — | yes |
post_compact | Just after agent_autocompact_if_needed | — | yes |
pre_tool_dispatch | After __invoke_llm returns, before __dispatch_tool_calls | interrupt_immediate only | no |
iteration_end | Stalled-turn-end-judge "done" path, before the loop falls through to terminal | interrupt_immediate, finish_step | no |
post_tool_dispatch | After every successful turn dispatch | interrupt_immediate, finish_step | no |
daemon_idle_pre | Daemon idle wait, before sleep | interrupt_immediate only | no |
daemon_idle_post | Daemon idle wait, after sleep | interrupt_immediate only | no |
loop_exit | After the loop body exits, before finalize | audit_only only (transcript audit, never rendered) | no |
Every stage pass emits a LoopCheckpoint event carrying iteration,
kind, delivered (bridge injections drained at this seam),
inbox_delivered (feedback notes drained), and dispatch_skipped.
pre_tool_dispatch is the new "stop" seam#
The seam that didn't exist before #2211: between the LLM returning a tool call and the dispatcher actually firing it.
When a host pushes an interrupt_immediate-mode injection (via ACP
session/remind or agent_session_push_bridge_injection), the
pre_tool_dispatch checkpoint drains it and returns dispatch_skipped: true.
The loop:
- Skips
__dispatch_tool_callsentirely — the tool batch does not run. - Records usage for the iteration so cost/token accounting stays honest.
- Emits
iteration_endwithdispatch_skipped: trueandskip_reason: "interrupt_immediate"in the turn info. - Continues to the next iteration, where the injected reminder is already in the transcript and visible to the model on its next prompt build.
In other words, interrupt_immediate finally means "stop before the next
tool fires", not "land at the next iteration boundary anyway."
The same interrupt_immediate injection arriving at iteration_start or
post_tool_dispatch is still drained, but those seams sit between iterations
where no tool is pending — there's nothing to skip, the injection just lands in
the transcript and the next prompt sees it.
Observe stage events#
Plugin authors observe seams through one canonical builtin:
harness.agent.register_checkpoint_hook(
["pre_tool_dispatch", "iteration_end"],
{ event ->
harness.stdio.log(
"seam fired:", event.kind, "delivered:", event.delivered,
)
},
)
kinds accepts a single seam name, a list of seam names, or nil / "*" for
every seam. The handler receives the LoopCheckpoint payload directly.
Under the hood this registers a loop_checkpoint session hook with a
pattern derived from kinds —
harness.agent.register_session_hook("loop_checkpoint", ...) works too, with explicit pattern syntax (kind=="pre_tool_dispatch",
kind=~"^(iteration_start|loop_exit)$").
Migration from the old drain sites#
Pre-#2211 code called harness.agent.drain_bridge_injections(session_id, checkpoint) directly at several sites. Those low-level calls bypass the
AgentStage contract, LoopCheckpoint event, and hook fan-out. Runtime loop
code should cross agent_stage; hosts and plugins should observe the projected
event through harness.agent.register_checkpoint_hook.
For one-off hooks on a single seam the
harness.agent.register_session_hook("loop_checkpoint", pattern, ...)
plumbing is still available, but harness.agent.register_checkpoint_hook covers every seam in one
call and exposes the dispatch_skipped signal that per-event hooks never see.
Mid-tool preemption#
Steering at iteration boundaries handles "stop before the next tool fires."
For the case where a tool is already in flight and the host wants to abort
that call — e.g. one click cancels a runaway git push --force without
losing the rest of the session — Harn ships
harness.agent.cancel_in_flight_tool_call(session_id, call_id, opts?) and the
matching
ACP method session/cancel_tool_call. Both share a per-call cancellation
registry keyed by (session_id, call_id):
cancel_in_flight_tool_call(
"sess_abc",
"call_42",
{reason: "user clicked stop", inject_reminder: true, timeout_ms: 5000},
)
// → {status: "cancelled" | "already_cancelled" | "not_found" | "timeout",
// call_id: "call_42", tool: "git_push", reason: "user clicked stop"}
The cancelled call returns to the loop shaped as
status: "cancelled" — distinct from status: "error" — so the model can
distinguish "the host stopped me" from "the tool failed." Tools written
against tokio's drop semantics unwind immediately; tools that hold
non-droppable resources (a spawn_blocking thread, an external process
without kill_on_drop) can additionally observe the cancellation handle
via the registry and shut down cooperatively.
What's still out of scope#
These were called out as separate issues in #2211 and are not yet shipped:
- Bridge-level dedupe-key collapsing. Multiple
interrupt_immediateinjections with the samededupe_keyare drained as separate transcript events instead of collapsing.
audit_onlyreminders that drain atloop_exitand never render to the model are now expected behavior, not a bug — see harn#2212. Usefinish_stepif you want the model to see the reminder before the loop terminates.
Host-supplied facts: steering the detectors, not the transcript#
Everything above steers the loop by putting content into it: a message, a reminder, a cancellation. There's a second, quieter way the host influences a run, and it's worth understanding as its own thing: feeding the loop's detectors the facts they can't observe on their own.
A stall detector can count turns and spot a repeated error. It cannot see your test runner. So when the question is "is this agent actually making progress?", the honest answer needs a fact only the host holds: did the build get closer to green this turn? Harn's design splits that cleanly. Harn owns the mechanism: the rule for when repetition means "stuck," the budget math for when to extend or cut. The host owns the facts: whether verification advanced, whether a delivered fix landed, how much longer this job deserves.
You supply those facts through callbacks the loop calls each turn, not through injected transcript content. The difference matters. An injected reminder is a message the model reads and reacts to. A supplied fact is an input to a detector's decision; the model never sees it. One steers by persuasion, the other by measurement.
This split is why "writes are not progress" is enforceable. The loop cannot tell a productive edit from a flailing one, but your host can. It knows whether the verifier moved. Hand the loop that one number and its no-progress detector stops mistaking motion for progress. The same shape applies to a fix that was delivered but didn't land, and to a smart timeout that extends a progressing run and cuts a stalled one.
The callbacks, their signatures, and the exact decisions they drive live in Host-supplied facts. The mechanism they feed (governors and detectors) is Agent governors and detectors.
Cross-references#
- System reminders — the user-facing API for queuing reminders.
- ACP
session/inject_reminderRFC — the protocol-side proposal that depends on theinterrupt_immediatesemantics this page describes. - Agent lifecycle — suspend, resume, and self-park interact with steering but are not themselves steering seams.