Problem
runtime.LocalRuntime.Steer enqueues into steerQueue, but the queue is only drained inside the RunStream loop — specifically after a batch of tool calls finishes and before the stop check (pkg/runtime/loop.go around line 425).
This leaves two gaps where a steered message can be stranded or delivered late:
- Idle-runtime race. A
Steer call can land between the moment the previous RunStream returns and the moment the next RunStream begins. The queue holds the message, but nothing is running to consume it. The caller has no signal that delivery is deferred.
- First-LLM-roundtrip miss. When a new RunStream starts, it builds
messages := sess.GetMessages(a) and calls the model without touching steerQueue. If that first turn is a plain text response with no tool calls, res.Stopped fires and the loop breaks — the pending steer message is never injected into this turn at all.
Combined, a steered message that arrived while the runtime was idle can be invisible to a subsequent short turn and only surface on a much later multi-round turn.
Why it matters
Any caller that builds message ingestion on top of WithSteerQueue needs to be able to enqueue a message and trust it will be picked up by the next turn, regardless of whether the runtime is currently active. Today that guarantee does not hold.
Proposed fix
Drain steerQueue at the top of the RunStream loop body, symmetrically with the existing mid-loop drain, before the first model call. Pseudocode:
// At the start of the for-loop, before the model call:
if steered := r.steerQueue.Drain(ctx); len(steered) > 0 {
for _, sm := range steered {
sess.AddMessage(session.UserMessage(wrap(sm), sm.MultiContent...))
events <- UserMessage(...)
}
}
This makes Steer idempotent with respect to runtime-liveness: the caller can always Enqueue and be sure the next turn picks it up, regardless of whether the runtime is currently looping.
Alternative / complement
Expose a documented contract that RunStream will call Drain once at entry, so any MessageQueue implementation can atomically inject messages into the session before the first model call.
Acceptance criteria
- A
Steer call made while no RunStream is active is consumed by the next RunStream's first turn.
- A
Steer call made during a short non-tool turn is still consumed within that same turn (or at worst triggers a follow-on turn) — not stranded until an unrelated future turn.
- No change to follow-up-queue semantics.
Problem
runtime.LocalRuntime.Steerenqueues intosteerQueue, but the queue is only drained inside the RunStream loop — specifically after a batch of tool calls finishes and before the stop check (pkg/runtime/loop.go around line 425).This leaves two gaps where a steered message can be stranded or delivered late:
Steercall can land between the moment the previous RunStream returns and the moment the next RunStream begins. The queue holds the message, but nothing is running to consume it. The caller has no signal that delivery is deferred.messages := sess.GetMessages(a)and calls the model without touchingsteerQueue. If that first turn is a plain text response with no tool calls,res.Stoppedfires and the loop breaks — the pending steer message is never injected into this turn at all.Combined, a steered message that arrived while the runtime was idle can be invisible to a subsequent short turn and only surface on a much later multi-round turn.
Why it matters
Any caller that builds message ingestion on top of
WithSteerQueueneeds to be able to enqueue a message and trust it will be picked up by the next turn, regardless of whether the runtime is currently active. Today that guarantee does not hold.Proposed fix
Drain
steerQueueat the top of the RunStream loop body, symmetrically with the existing mid-loop drain, before the first model call. Pseudocode:This makes
Steeridempotent with respect to runtime-liveness: the caller can alwaysEnqueueand be sure the next turn picks it up, regardless of whether the runtime is currently looping.Alternative / complement
Expose a documented contract that
RunStreamwill callDrainonce at entry, so anyMessageQueueimplementation can atomically inject messages into the session before the first model call.Acceptance criteria
Steercall made while no RunStream is active is consumed by the next RunStream's first turn.Steercall made during a short non-tool turn is still consumed within that same turn (or at worst triggers a follow-on turn) — not stranded until an unrelated future turn.