Conversation
Owner
Author
|
Note on CI: no checks will appear on this PR, and that is expected rather than a failure. The workflow's four commands were run locally against this branch instead, in the same order the workflow runs them: All four pass. The test run was 1388 passed, 0 failed, 128 ignored, across 39 test binaries, with the ignored set unchanged from the base commit. Once janfeddersen-wq#66 lands upstream and this rebases onto |
Downstream consumers drive agent turns through AgentStream, which until
now exposed only events and cancel(). A message typed while the agent is
working had no way to reach the running turn: the caller had to wait for
the current run to finish and start a new one.
This adds SteeringQueue, a cheaply cloneable handle over a tokio
unbounded mpsc. The caller keeps a clone wherever input arrives and
passes another to the run via RunOptions::steering, mirroring how
message_history is attached. The stream claims the queue's single
receiver for the run and drains it, FIFO and without blocking, at the
tool-call boundary: after the step's tool returns join the history and
before the next model request. That is the same boundary Claude Code
uses, and it is the only point where the model is guaranteed to see
fresh user intent without corrupting message alternation.
Each drained text becomes its own ModelRequest with a user prompt part
and emits AgentStreamEvent::SteeringDelivered { step, text } after the
step's ToolExecuted events and before the next RequestStart, so a
consumer can persist the user message in transcript order. Nothing is
drained before the first model request, and a run that never crosses a
tool boundary delivers nothing; leftovers stay queued and a later run
from the same queue delivers them, so the queue survives turns rather
than silently eating input.
Both AgentStream::new and AgentStream::new_with_cancel share the drain
through one helper because the two spawned loops are physically
distinct bodies and steering must behave identically in each.
AgentStreamEvent is not #[non_exhaustive], so the new variant is
source-breaking for exhaustive matches; per the workspace convention
that means a minor bump to 0.4.0 across crates, recorded in the
changelog.
acoliver
force-pushed
the
feature/steering-input
branch
from
September 6, 2026 01:49
c0e7327 to
9db0235
Compare
acoliver
changed the base branch from
feature/issue-65-open-responses
to
main
September 6, 2026 01:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A message typed while an agent is working cannot currently reach the running turn.
AgentStreamexposes events andcancel()and nothing else, so a caller who wants to redirect the agent has to kill the turn and throw away the work it has already produced.This adds the input channel, drained at the tool-call boundary: after a step's tool returns are appended to the history and before the loop issues the next model request. That is the same boundary Claude Code uses.
API
SteeringQueue: cheaply cloneable,Default, backed by a tokio unbounded mpsc.steer(String) -> boolreturns false only if the queue is closed, so input cannot be lost silently.pending_len()reports texts enqueued but not yet delivered.RunOptions::steering(queue), mirroringmessage_history.AgentStreamEvent::SteeringDelivered { step, text }, emitted once per delivered text, after that step'sToolExecutedevents and before the nextRequestStart, so a consumer can persist the user message in transcript order.SteeringReceiverispub(crate). A run claims the single receiver for its lifetime, which makes "one consumer" a property of the type rather than a rule in the docs.Delivery rules
Nothing is drained before the first model request, and nothing is drained at a text-only end of turn. A run that never crosses a tool-call boundary delivers nothing and leaves the text queued. The receiver parks itself back on the originating queue when dropped, so leftovers survive the run that claimed them and a later run from the same queue delivers them at its first boundary. All four rules are in the rustdoc with a compiling doctest.
Both spawn loops drain.
AgentStream::newandAgentStream::new_with_canceleach carry their own copy of the agent loop, and a drain added to only one would work in every test that usesnewwhile silently doing nothing for callers that usenew_with_cancel. The drain itself is one shared helper,deliver_queued_steering, called from both.Breaking change and version
AgentStreamEventis not#[non_exhaustive], so the new variant is source-breaking for downstreammatchsites that enumerate every variant without a wildcard. Following the CHANGELOG convention for this 0.x workspace, that is a minor bump: 0.3.0 to 0.4.0 across all crates, as 0.3.0 itself was released. The bump is a proposal, not a decision. Say the word and I will re-cut it however suits your release process.No dependency was added.
parking_lotwas already declared forserdes-ai-agent.Tests
Six unit tests, five integration tests, one doctest. The integration tests steer from inside the tool executor closure, so the text genuinely arrives mid-turn rather than being pre-loaded before the run:
ToolExecuted<SteeringDelivered< nextRequestStart)pending_len()still reports the textnew_with_cancelpath delivers at the boundaryNo sleeps; enqueueing from the tool closure makes the ordering deterministic.
Verification
The workflow's exact commands:
cargo check --workspace --all-features,cargo fmt --all -- --check,cargo clippy --workspace --all-features -- -D warnings,cargo test --workspace --all-features. All pass, 1388 passed / 0 failed.Note on the base branch
This targets
feature/issue-65-open-responsesrather thanmainbecause PersonalAgent pins all eightserdes-ai-*crates to a single rev, andserdes-ai-responsesexists only on that branch. Once janfeddersen-wq#66 lands upstream this rebases ontomainas a standalone change; it has no dependency on the Responses work beyond sharing a base.Separately, and not addressed here:
e675674on the base branch committedtmp/ocr-pr66/review.json(76 KB),tmp/ocr-pr66/review.stderrandtmp/commitmsg.txt, which look like scratch files. The fork's.gitignorecovers*.logbut nottmp/. Worth removing before janfeddersen-wq#66 goes upstream.