followup / steer / inject: Dual-Queue Inbox
While the Agent is working and you want to say something: which queue the message joins, and when it gets handled. Three APIs share one send(); they differ by only two parameters.
Mid-run, the user speaks. Engineering has three options: interrupt and restart, queue until it finishes, or quietly slip the words in. Most harnesses only do the first two. DeepSeek Harness (DSH below) makes the third a formal API too—three semantics, one send() entry, distinguished by two parameters.
Play first, then learn. Think of a Turn (one full unit of work) as a bus run, and a Step (one model request) as stop-by-stop driving. The next-turn queue is people waiting for the next bus; the next-step queue is people cutting into the current run. While the Agent is running, hit the three buttons below to see which queue a message lands in and at which stop it is picked up. The caption explains what each step is doing.
Agent is idle; both queues are empty. Hit the buttons above, or hit Play to watch the full flow.
What problem it solves.With a single message queue, user speech has only two fates: interrupt, or wait in line. Everyday failure mode: the Agent is editing ten files as planned; at file three you see it went sideways. Interrupt and the first two files were wasted; queue and you watch it ruin all ten. You only wanted to add one sentence—both options make you trade away current progress.
What the idea is.First split the loop into two layers. A Turn is one full unit of work; a Step is one model request plus the tool runs it triggers—usually several Steps per Turn. The split is practical: messages need a delivery point finer than a whole turn, and the next chance the model can see new text is the start of the next Step. Then encode timing as two parameters: target picks the queue (next-turn waits for the next bus; next-step cuts into the current run), wakeup decides whether to wake the driver (start immediately if the Agent is idle). All three APIs are parameter presets on send()—three lines each.
followupNext thing—wait until this turn finishes
steerCorrect course—don’t tear it down and restart
injectSlip in info—don’t push it to work
Why it lasts.These three semantics are a taxonomy of interruption—language-independent. Rewrite any agent system and you still answer the same two questions: wait for the current task to finish, or cut in? If you cut in, should that immediately trigger action? As long as model calls have turn boundaries, the three-way split holds. Rewrite in Rust or Python and parameter names change; the categories don’t. Shipping the taxonomy as an API gives user speech a third fate—treating interrupt granularity as a product capability.
Source: packages/core/agent-loop/src/agent.ts lines 113–132 (send and the three alias methods).
What problem it solves.Queues exist; next is who takes messages and how. If many call sites can read the same queue, two accidents eventually hit: the loop claims once and a plugin claims again—same message enters history twice; or you read it, crash mid-handle, and after restart the message is gone. Failure mode: crash recovery replays the log, one steer is consumed twice, the model gets two identical instructions and applies the same change twice.
What the idea is.DSH’s answer is claim. Before each Step starts, the loop calls claim once and atomically takes every message from the next-step queue; at a turn boundary it also takes one next-turn message. Note: one—three rapid followups become three separate Turns. The take is logged as a pure delete event, so a message has only two states: still in a queue, or owned by some Turn—no in-between. Crash recovery replays deletes and never double-consumes. Easy mistake: a batch rejected by a pre-step plugin is not put back; claim happens before the verdict, rejection opens no new Step, and the turn ends as blocked.
Why it lasts.Claiming is decades-old message-queue consensus. Databases call it SELECT FOR UPDATE; SQS calls it visibility timeout—read and ownership fused into one atomic act. Whenever a system has multiple potential consumers and must recover after crashes, claiming is the standard answer. DSH simply moved that consensus into the agent loop; a few source rewrites later, this take pattern won’t change.
Source: packages/core/agent/src/inbox.ts lines 71–78 (claim itself); packages/core/agent-loop/src/agent.ts line 229 (called at each step start), lines 266–269 (reject branch—rejected batches do not requeue).
What problem it solves.You hit Esc to abort the current activity, then immediately send a steer. steer means cut into the next Step of the current Turn—but that Turn is dying, so its next Step will never come. Enqueue as-is and you get one of two bad endings: the message sits forever with no pickup and the session wedges; or you force it into a winding-down turn—i.e. a dying turn—with unpredictable behavior.
What the idea is.Before enqueue, send() checks the scene: this message wants a wake, and the current activity was aborted? Rewrite the delivery target to next-turn, latch the wake request, and after the aborted activity finishes cleanup and state settles to idle, replay the wake and start a brand-new bus. The check happens before enqueue, so the queue never holds a message nobody will pick up. inject does not request wake, so it skips this downgrade—still joins next-step and rides along at the first stop of the new bus.
Why it lasts.A classic concurrency problem: the event arrives while its target is dying. The answer is classic too: don’t chase an exiting executor—reschedule to the next stable boundary. OS signals to an exiting process, Actor systems messaging a shutting-down Actor—same pattern. DSH puts it at the send() entry; the location may move in refactors, the judgment won’t.
Source: packages/core/agent-loop/src/agent.ts lines 113–120 (wakingAfterAbort check and target rewrite), lines 164–193 (wakeDriver latch and replay). Two more main-loop behaviors: line 299 (Turn stays open while next-step is non-empty—open another Step), line 324 (after a Turn ends, if the queue still has items, continue to the next turn).
All three have queues; they differ in interjection granularity.
DeepSeek HarnessDual queues + three semantics
Two durable queues; three semantics share one send() entry. inject—interject without interrupting—is its own formal API: deliver, don’t wake, don’t interrupt; claimed at the next natural Step boundary.
Claude CodeAbort current stream + message queue
User interrupt ends the generator: Ctrl+C triggers .return(), nested generators wind down together. Mid-run input joins a queued command stream and is consumed after the current stream ends—no step-level interjection. Source: claude-code-sourcemap-main/study/chapters/01-architecture.md. Full scheduler source isn’t public; conclusions are inferences from published evidence.
Grok BuildSingle queue + CancellationToken
Each Session is an Actor on its own thread (see the in-site Session Actor lesson); cancel uses cooperative CancellationToken cleanup. One queue only: items wait sorted by position; running_prompt_id marks the one in flight. No mid-run interjection semantics matching inject. Source: crates/codegen/xai-prompt-queue/src/types.rs lines 44–56.
Bottom line: only DSH ships inject as a public API—interject without interrupting and without waiting for the whole turn to end—so the model can see the message at the next Step of the current Turn.
Walk through a mistimed inject
The Agent is streaming Turn 3 Step 2; a plugin calls inject(). Walk through: what’s the earliest moment this message is claimed? If Step 2 was already the last step of the turn, is it dropped, or does it keep the Turn alive for one more step? If the Agent is already idle, when is it consumed? Then verify in the demo above.
send() parameter presets. claim is an atomic handoff: a message is either in a queue or owned by a Turn; rejects don’t requeue. Wake input after abort is always rerouted to next-turn, because a dead Turn has no next Step.