fix(runtime): give a late steer an injection point in the turn it was aimed at - #3533
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a final steering drain so late messages can trigger another model step during tool-free turns.
Changes:
- Adds late-steering injection and continuation logic.
- Adds regression coverage verifying the steer reaches the next model request.
- A critical stop-after-step race remains unresolved when the drain is awaiting consumption.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Reviewed changes |
|---|---|
packages/runtime/src/ai-sdk-backend.ts |
Adds late-steering handling; requires a post-drain stop-state recheck. |
packages/runtime/src/__tests__/ai-sdk-backend.test.ts |
Adds late-steering regression coverage. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
… aimed at The agent loop drained steering only at the top of an iteration, and only iterated again when the step returned tool calls. A tool-free turn therefore has exactly one drain, before the model's first token, so a steer typed while the answer streams was never pulled — whether Steer worked depended on the model happening to call a tool afterwards. Drain once more before leaving the loop, and take another step when it injected something. A step-limited, stopped, or aborted turn skips it: its budget is spent, and the Host folds the message into the next Turn. The stop flags are re-read after that drain rather than reused from before it. The drain awaits a durable push, so an `after_step` stop can land while it is in flight; deciding from the stale value dispatched a provider step the user had already stopped. Reported by Copilot review on apache#3533 and confirmed reachable — the regression test fails with two provider calls without it. Generated-by: Claude Code Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
41f657c to
b561e29
Compare
|
Reviewed at The problem is precisely stated and I want to restate it because it is easy to under-rate: a tool-free turn runs exactly one provider step, and the only drain sits at the top of the loop — before the model has emitted a token. A steer typed while the answer streams therefore has no boundary left to land on, and whether "Steer" works at all ends up depending on whether the model happens to call a tool afterwards. That is an ordinary user path, not a constructed one. On shape. The new drain is not a second authority. It is the drain that the On frequency. This was the question worth asking, since the same-looking code in a different position is how these things go wrong. The extra drain runs only when the step returned zero tool calls and another step is still permitted — symmetric with the tool-call branch, which On the new On double-settlement. A pulled lease is in flight until On unbounded looping. With On the deliberately-skipped case. A step-limited turn does not drain, and one reviewer specifically checked whether that is a silent drop. It is not: skipping the drain means the lease is never pulled, so it stays in the queue and the Host folds it into the next Turn, exactly as the comment claims. This is the one place where "the code is right but the branch is unreachable" could have hidden, and it holds up. One process note, since it affects how much this review is worth: the two lines were asked to reach their conclusions without reading each other, and one of them independently arrived at the same answer through a different route — the On CI: no checks have been reported on this head, so there is no CI evidence yet — worth a maintainer kicking the runs off. That is the only thing standing between this and an approval from our side; the code review itself is clean. |
| if (mayTakeAnotherStep) { | ||
| // Last chance for a steer that landed after this turn's final | ||
| // tool-call boundary — including the only boundary a tool-free | ||
| // turn has, which precedes the model's first token. Without it the | ||
| // message is never pulled at all, and whether Steer works would | ||
| // depend on the model happening to call a tool afterwards (#3529). | ||
| // A step-limited turn deliberately skips this: its budget is spent, | ||
| // and the Host folds the message into the next Turn instead. | ||
| const injectedBefore = scope.injectedSteeringMessages.length; | ||
| await this.drainSteeringInto(scope, input, queue); | ||
| // Re-read the stop flags: the drain awaits a durable push, so an | ||
| // `after_step` stop or an abort can land while it is in flight, and | ||
| // `mayTakeAnotherStep` is stale by now. Stop wins — the message is | ||
| // already durable, so the Host folds it into the next Turn. | ||
| if ( | ||
| scope.injectedSteeringMessages.length > injectedBefore && | ||
| !scope.loopStopRequested && | ||
| !scope.aborted | ||
| ) { | ||
| currentStepMessageId = this.newId(); | ||
| continue agentLoop; | ||
| } |
There was a problem hiding this comment.
[P2] This new continuation edge has no durable-reader requirement, and without one the second request loses the assistant output that was just steered.
At the top of the loop (:2211-2220), the two branches are not equivalent:
if (this.input.loadTurnRuntimeEvents) {
requestMessages = await loadDurableTurnProjection(); // full projection, incl. the assistant turn
} else {
const missingSteering = steeringMessagesMissingFromBase(...);
if (missingSteering.length > 0)
requestMessages = [...requestMessages, ...missingSteering]; // steering only
}The no-reader branch only ever appends steering. It never appends the assistant message that was just flushed. So when this edge fires without a reader, the second provider call carries the original user prompt plus the steer envelope, and nothing else — the model is asked to correct or redirect work it cannot see. Repetition or self-contradiction is the natural outcome.
What makes this look like an oversight rather than a decision is the tool-continuation edge a hundred lines up, at :2683-2685:
if (continuationBudgetRemains && !this.input.loadTurnRuntimeEvents) {
throw new Error('durable current-run reader is required for tool continuation');
}That edge states the requirement outright and refuses to proceed. Its comment gives the reason — queue consumption alone does not prove the latest assistant facts are still readable. That reasoning applies just as directly here: this edge also continues the turn after an assistant step, and also needs the model to see what it just produced.
loadTurnRuntimeEvents is optional on the input interface, so the no-reader configuration is a supported contract, not a broken setup. The hosted path wires a runtime event store, which narrows exposure in production.
Either require the reader on this edge as the tool path does, or make the fallback projection genuinely equivalent by appending the flushed assistant message alongside the steering. As written, the fix changes "the steer is dropped" into "the turn continues with missing context", which is quieter but not obviously better.
Worth noting the new regression test only asserts that the second prompt contains the steer. Asserting that it also still contains the first assistant output would have caught this.
|
Correcting our earlier comment on this PR. In #issuecomment-5383862406 we reported no P0–P2 findings on Short version: without Our first pass verified that the drain re-reads the stop flags after the await, that the step budget stays closed, that the lease cannot double-settle, and that the steer is persisted before dispatch — all of which hold. What we did not do was follow the no-reader branch to the end and ask what the second prompt actually contains. That is the miss. The mechanism this PR adds is still the right one: a steer landing after the final tool-call boundary previously depended on the model happening to call a tool afterwards, which is not a contract. The remaining question is narrower — whether the fallback projection on the new edge should be made equivalent to the durable one, or whether the edge should require the reader the way the tool path does. CI remains terminal green on this head ( |
…on edge The no-reader projection at the top of the loop appends steering alone; it never appends the assistant output of the step just finished. Taking the new continuation edge without a reader therefore sent the model the original user prompt plus the steer envelope and nothing else — asking it to redirect work it could not see. Measured on a no-reader backend: the second request carried roles ["user","user"] with the assistant answer absent. Before this edge existed, a backend without `loadTurnRuntimeEvents` could never reach a second provider step — the tool-call edge refuses outright. Gate the edge on the reader to restore that invariant. It is skipped rather than throwing, so the turn still completes and the Host folds the message into the next Turn, exactly as before apache#3529. Tests: the injection test now runs on the durable harness and asserts the second request carries the first assistant answer as well as the steer; a new test pins the no-reader contract; the stop test moved onto the durable harness too, or it would have passed while exercising nothing. Reported by Astro-Han in review of apache#3533. Generated-by: Claude Code Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Confirmed — measured it on a no-reader backend before changing anything. The second request really does go out without the assistant turn: One thing that sharpens your point: before this edge existed, a backend without So the edge now requires the reader, matching the tool-call edge above it. One deliberate deviation: it is skipped rather than throwing. The tool path can throw because it has already emitted tool calls and cannot finish coherently without continuing. This edge can simply not fire — the turn completes and the Host folds the message into the next Turn, which is exactly the behaviour before #3529. Throwing here would turn turns that complete fine today into errors. Tests, all three of which your comment is responsible for:
Verified by removing the gate: the no-reader test fails |
1 similar comment
Confirmed — measured it on a no-reader backend before changing anything. The second request really does go out without the assistant turn: One thing that sharpens your point: before this edge existed, a backend without So the edge now requires the reader, matching the tool-call edge above it. One deliberate deviation: it is skipped rather than throwing. The tool path can throw because it has already emitted tool calls and cannot finish coherently without continuing. This edge can simply not fire — the turn completes and the Host folds the message into the next Turn, which is exactly the behaviour before #3529. Throwing here would turn turns that complete fine today into errors. Tests, all three of which your comment is responsible for:
Verified by removing the gate: the no-reader test fails |
Astro-Han
left a comment
There was a problem hiding this comment.
Review at exact head deb3ba768b83ec97316c056c7f073fc42db2bf1a.
APPROVE. This closes a real gap with a symmetric edge rather than a second mechanism, which is the right shape for it.
The bug is that a tool-free turn has exactly one steering boundary — the one at the top of the loop, before the model's first token. A steer arriving after that had no further boundary to be pulled at, so whether Steer worked at all depended on the model happening to call a tool afterwards. That is a normal user path, not an edge case: type a redirect while a long prose answer is streaming.
Three details make this correct rather than merely plausible:
- The durable-reader gate matches the tool-call edge's gate. Continuing the turn means the next request must carry the assistant output this step just produced, and only the ledger projection has it. Without
loadTurnRuntimeEventsthe new edge is skipped, not forced — appending steering alone would ask the model to redirect work it cannot see. Skipping preserves today's behaviour: the message stays durable and the Host folds it into the next Turn. - The stop flags are re-read after the drain.
drainSteeringIntoawaits a durable push, somayTakeAnotherStepis stale by the time it returns; anafter_stepstop or an abort can land inside that window. The code re-checksscope.loopStopRequestedandscope.abortedbefore continuing, and stop wins. Reading a stop flag once before an await is the usual way this class of change goes wrong. - The continue is conditional on something actually having been drained (
injectedSteeringMessages.length > injectedBefore), so an empty drain ends the turn instead of spending a step on nothing.
A step-limited turn deliberately does not take this edge — its budget is spent and the Host folds the message into the next Turn. That is the same policy the tool-call edge already applies, so the two boundaries stay consistent.
I checked the loop-safety question specifically: each extra step requires a newly drained message, and maxSteps / stop / abort remain the same backstops that bound the pre-existing tool-call edge. No new unbounded path.
The tests pin the three contracts that matter — reader present, reader absent (provider called once, zero steering echo, lease not acked), and an after_step stop racing the drain — and they assert observable behaviour rather than call shape.
Verification: exact-head test is completed/success. An earlier run on this head failed in bounded election does not launch a Candidate after handshake exhausts the deadline, a Runtime Host timing test with no reachable path from ai-sdk-backend.ts; a re-run on the same head is green, confirming that as unrelated flakiness rather than a defect here.
Summary
drainSteeringIntoruns only at the top of anagentLoopiteration, and the loop only iterates again when the step returned tool calls. A tool-free turn therefore has exactly one drain, and it happens before the model's first token — so a steer typed while the answer streams is never pulled. Whether "Steer" works at all depended on whether the model happened to call a tool afterwards, which the user cannot know when they press Enter.This drains once more before leaving the loop and takes another step when something was injected, so the message lands in the turn the user aimed it at. A step-limited, stopped, or aborted turn deliberately skips it — the budget is spent or the turn is ending on purpose, and the Host folds the message into the next Turn instead.
No protocol change:
drainSteeringIntoalready owns pull, injection and ack, andscope.injectedSteeringMessagesalready tells the caller whether anything landed.pullSteeringis a lease, so it cannot be used as a probe — draining and then continuing is what keeps the lease honest.Fixes #3529
Verification
New test
injects a steer that arrives after the turn last tool-call boundary: apullSteeringthat is empty on the first call and yields a message on the second — the real timing of pressing Enter after streaming starts — against a text-only model.It asserts the model is actually asked again with the steer, not just that the echo was emitted:
Checked against both weaker builds, per the repo's habit of proving a guard bites:
steering_messagecountexpected: 1, actual: 0doStreamCalls expected: 2, actual: 1— the echo and the ack both happen, and the user still never gets an answerRan:
ai-sdk-backend(205),fake-backend(4),overflow-reactive-recovery(43) — all green;message-coordinatorandexecution-host-messageinruntime-host(34) — green;biome checkon both changed files;tsc -p packages/runtime.agent-run-steering-recoveryfails 8/8 here, allEBUSY: resource busy or locked, unlink ...runtime.sqliteon teardown. Verified pre-existing: identical 8/8 against a cleanorigin/mainbuild. Windows-only fixture problem, unrelated to the change.Not run: Desktop and Playwright.
Review focus
The fake backend already drains between chunks and once after the last chunk, commented "Final stranded drain (grok-build safety): a steer that landed after the last boundary still lands in this turn instead of being lost". That safety net is why the suites were green while the real backend lost the message — this change gives
AiSdkBackendthe boundary the fake has always assumed.Independent of #3530: that one fixes what happens to a message that still ends up folded (step limit, stop, abort). Both are needed; neither subsumes the other.
AI use
Select exactly one:
Tool(s) and scope: Claude Code — investigation, root-cause tracing, the test, and the fix.
Checklist
Does this PR entail a change in behavior?