Chapter 41: Steering a Running Agent

A follow-up is not “just another message.” It crosses a live concurrency boundary, changes which turn is being answered, and still needs its own delivery contract.


Quick Track (Master the core in 5 minutes)

  1. Separate accepted, committed, and completed — they are three different moments
  2. Drain follow-ups at an iteration boundary so the model sees them before choosing more tools
  3. Close the end-of-run race atomically: consume the late follow-up or make it start a fresh run
  4. Track the inbound message each answer belongs to; one merged run may produce several replies
  5. Send delivery_ack only after the reply was delivered, so failed delivery remains replayable

Public-source snapshot: implementation details are examples from public Kocoro origin/main at commit 4ec6772, reviewed 2026-07-27. Treat the invariant as the lesson and re-check constants against current source.

41.1 Starting with the Follow-Up That Arrived at “Done”

An agent has spent twelve minutes refactoring a service. While it is composing the final answer, the user sends:

Also update the migration and rerun the integration test.

There are two obvious implementations, and both lose.

Start a second run immediately and the two loops edit the same worktree concurrently. Queue the message for later and let the first run return, and the user gets “done” while the requested migration is still untouched. Peek at the in-memory queue just before returning and you still have a race: the follow-up can arrive one instruction after the peek and one instruction before route teardown. The API reported success, but no run owns the message anymore.

Then comes the quieter bug. Suppose the running loop does absorb the follow-up. Which inbound message should the channel reply target — the original refactor request or “also update the migration”? If you track only a session ID, the answer lands under whichever message the transport happens to remember.

Steering is therefore three contracts at once: concurrency, conversation boundaries, and reply addressing.

41.2 Accepted Is Not Committed Is Not Completed

Use three verbs, because using one is how messages disappear.

Accepted means the router found an active run and placed the item in its buffered injection channel. InjectOK says ownership transferred; it does not say the model has seen the text.

Committed means the loop drained the item at an iteration boundary, built a real user turn, appended it to the live conversation, and fired lifecycle hooks. Only now may a queued draft become a normal user bubble. If the message came from a durable mailbox row, this is also when that row can be retired — after the text has a home in the transcript, not when it merely entered RAM.

Completed means the answer for that inbound item was delivered. Only after delivery may the transport drop its replay entry.

accepted              committed                    completed
router  inject queue  live user turn  model ...  reply delivered  ack

Every arrow can fail independently. Give each state one owner and recovery becomes possible; collapse them into “received” and every crash produces an argument about what that word meant.

41.3 Drain at a Decision Boundary

The active run owns a mailbox. At the top of an iteration, it drains available follow-ups without blocking, filters retracted items, batches survivors into one user turn, and appends that turn before the next main model request.

That boundary is deliberate. Drain while tools are executing and you mutate context underneath an action already chosen. Drain only after the next model response and the agent may run another expensive or destructive tool before noticing the user's correction. The iteration boundary is the moment after prior effects are known and before the next action is selected.

Committing also updates latestUserText, so Skill discovery and deferred-tool continuation reason about the new instruction rather than the original one. Steering that changes the transcript but leaves side systems reading stale user text is only half implemented.

Several pending follow-ups may be batched into one user turn. That is a product decision, not a transport accident. If you want one answer per message, do not batch. If you batch, define which inbound ID owns the combined reply and what happens to the others.

41.4 Close the Last-Message Race

Top-of-loop draining is not enough. The hardest arrival is the one from §41.1: while the model is composing its terminal answer.

A naïve len(queue) > 0 check cannot close this race. The queue may be empty at the check and receive a message before teardown. The public runtime uses a final drain owned by the route lock:

under one lock:
  if a surviving follow-up exists:
      drain it and keep the run open
  else:
      close the injection window

Now a racing sender has only two legal outcomes. It lands before the atomic drain and the current run commits it inline, or it observes the closed window and starts a fresh run. It cannot receive “accepted” and then lose its owner.

Retraction belongs inside the same boundary. A queued draft the user cancelled must not survive the final drain and re-open a loop that had already finished. “Empty after filtering” means return; “at least one survivor” means continue.

This is the broader lesson from Chapter 40: durability is not just writing state. It is making ownership transitions atomic enough that exactly one component knows what must happen next.

41.5 Every Answer Needs an Address

One physical Run can now contain several logical user turns. That means “the run's message ID” is no longer a sufficient concept.

The loop starts with the primary inbound ID. When it commits a follow-up, it advances replyCloudMessageID to the new item's ID and records that ID in the pending-ack set. If a completed answer is about to be superseded by another injected turn, the loop captures the old reply ID before advancing and emits the answer through OnIntermediateAnswer.

The ordering is load-bearing:

capture old reply target
 commit superseding follow-up
 deliver old turn's answer to old target
→ continue under new target

Advance first and the original answer appears under the follow-up. Never emit the intermediate answer and it disappears entirely. Address only by session and rapid messages from two people collapse into one ambiguous thread.

When several items are drained into one combined turn, the snapshot assigns the combined answer to the last non-empty inbound ID and keeps the earlier absorbed IDs in the acknowledgement set. A different product may choose differently. What matters is that the policy is explicit and tested at the public transport seam.

41.6 Delivery Acknowledgement Is Not Mailbox Acceptance

The inbound transport is at-least-once. Cloud retains an unacknowledged message in a replay buffer and can deliver it again after reconnect.

delivery_ack means one narrow thing: the terminal reply was successfully delivered to the user. The daemon sends the reply first, then acknowledges every absorbed inbound ID. If reply delivery fails, it sends no ack, so reconnect can replay the input rather than silently lose an answer the user never saw.

That is why the earlier wording matters. An item entering the injection queue must not emit delivery_ack. An item being committed to context must not emit it either. Both events happen before the user has an answer.

Deduplication still needs durable identity. Replay may hand the daemon the same inbound ID again; the system must recognize whether that item has an active owner, a completed reply, or genuinely needs another run. At-least-once transport plus idempotent handling gives reliable delivery. Pretending the transport is exactly-once gives duplicates.

41.7 Steering, Retraction, and Interrupt Are Different Operations

An ordinary follow-up says: keep the run, add this information before the next decision.

A retraction says: this not-yet-committed queued item should disappear. It needs a client message ID and a tombstone that wins races with delayed delivery.

An interrupt says: stop the current run. It travels through cancellation, not through the prompt. “Stop” appended as text can arrive only after the model has selected another tool — far too late for a real cancel.

An interrupt-and-send operation is different again: cancel the old run, then start the replacement request as a fresh owner. The router treats a route in cancelPending as inactive so the replacement does not bounce against a loop that is already dying.

Keep these paths separate. A single “steer” API with a mode string tends to make cancellation wait on mailbox drain, makes retraction look like a new instruction, and gives each branch a different idea of whether the message was accepted.

41.8 Snapshot Evidence

ObservationSource at 4ec6772
Injection commit fires when a follow-up is drained, not merely acceptedloop.go L670
Injected messages carry mailbox, cloud, and client identities separatelyloop.go L716
One commit path builds and appends the real user turnloop.go L3106
Normal drain happens at the top of an iterationloop.go L3223
Atomic final drain closes the end-of-run raceloop.go L4213
Reply target advances to the last inbound item processedloop.go L1498
Pending IDs are acknowledged only after final deliveryloop.go L1520
delivery_ack is sent only after SendReply succeedsclient.go L537
Router enqueue and teardown drain share one ownership lockrouter.go L599

These describe one dated implementation, not a universal messaging contract.

41.9 Common Pitfalls

Acknowledging on enqueue. A crash after acceptance but before a reply now loses the message permanently.

Appending follow-ups during tool execution. The next action was already selected from older context.

Checking queue length before return. A peek cannot close the arrival-versus-teardown race.

Tracking one ID per run. Merged turns send answers to the wrong inbound message or drop intermediate answers.

Marking mailbox rows consumed before transcript commit. A crash leaves neither a queued item nor a conversation record.

Using prompt text as cancellation. The loop can act again before it reads “stop.”

Treating replay as a transport bug. Replay is how an unacknowledged reply failure is recovered. Deduplicate by durable identity.

Letting a follow-up change CWD silently. The active run already owns a project context. A different target needs a new run, not a mid-turn mutation.

Key Points

  1. Accepted, committed, and completed are separate states. Name all three and give each one owner.
  2. Drain before the next decision. Steering must reach context before another tool is selected.
  3. Close teardown atomically. A late message belongs to the current run or a fresh run, never to neither.
  4. Address every logical turn. One loop may produce several answers under several inbound IDs.
  5. Ack after delivery. Unacknowledged replay is recovery, not duplication to wish away.

Next: Chapter 42 asks the matching time question: when a running turn is quiet, which phase actually owns the wait?

Cite this article
Zhang, Wayland (2026). Chapter 41: Steering a Running Agent. In AI Agent Architecture: From Single Agent to Enterprise Multi-Agent Systems. https://waylandz.com/ai-agent-book-en/chapter-41-steering-a-running-agent/
@incollection{zhang2026aiagent_en_chapter-41-steering-a-running-agent,
  author = {Zhang, Wayland},
  title = {Chapter 41: Steering a Running Agent},
  booktitle = {AI Agent Architecture: From Single Agent to Enterprise Multi-Agent Systems},
  year = {2026},
  url = {https://waylandz.com/ai-agent-book-en/chapter-41-steering-a-running-agent/}
}