DeepSeek Harness · Sessions & Loops

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.

Course goalAfter this lesson, you can explain three things: which two parameters separate followup, steer, and inject; when a queued message is claimed; and why speaking again after an abort is force-rerouted to the next turn.

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.

Interactive demo · Queue simulator

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.

Idle

Agent is idle; both queues are empty. Hit the buttons above, or hit Play to watch the full flow.

Event log (simulated)
Try this: hit inject while the Agent is idle—the message stays in the queue. That is what “no wake” means. The timeline is a teaching simulation. Source: packages/core/agent-loop/src/agent.ts lines 113–132.
Idea 1 · Interjections come in three kinds; granularity is a product capability

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.

followup = queue + wake  ·  steer = cut in + wake  ·  inject = cut in + stay quiet

followupNext thing—wait until this turn finishes

When to useThis line is unrelated to the current task—it’s the next job. You don’t want to disturb work in progress.
Which queuenext-turn queue. Wait until the current Turn fully finishes; picked up when the next bus departs—one message per bus.
Wake or notWake. Even a sleeping Agent is woken and starts a new bus right away.
Everyday example: “After this change, please add the tests too.”

steerCorrect course—don’t tear it down and restart

When to useYou want to correct work in progress without sacrificing progress already made. Drifted direction, or an extra constraint—use this.
Which queuenext-step queue. After the current step finishes, the model sees them at the start of the next step—claimed all at once.
Wake or notWake. Even an idle Agent can be woken directly.
Everyday example: “Wait—write the config in YAML, not JSON.”

injectSlip in info—don’t push it to work

When to useA plugin wants to slip the model an environment note without triggering an empty run. Human course-correction is steer; environment narration is inject.
Which queuenext-step queue—same as steer. Picked up along the way at the next natural stop.
Wake or notNo wake. If the Agent is asleep, the message stays in the queue until something else wakes it.
Everyday example: “By the way, the user just switched the branch to main.”

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).

Idea 2 · Claiming messages must be a claim protocol

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.

send(msg, target, wakeup) next-turn queue take 1 per turn next-step queue take all per step claim() atomic claim Step start
Teaching diagram. The take is logged as a pure delete: a message is either in a queue or owned by a Turn—no in-between.

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).

Idea 3 · Wake after abort must downgrade

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.

A message that wants wake steer or followup arrives Pre-enqueue check Was current activity aborted? No Aborted Enqueue as targeted steer still joins next-step Retarget next-turn Latch wake; new Turn after settle
Teaching diagram. Downgrade is decided before enqueue, so the queue never holds a message nobody will pick up.

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).

Side-by-side · Mid-run interjections at three shops

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.

Classroom Exercise
01

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.

Takeaway:DSH encodes speech timing as data with two durable queues—next-turn / next-step; the three APIs are just 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.