feat(streams): reject concurrent messages that break the tool-use loop - #48
Draft
tsuz wants to merge 1 commit into
Draft
feat(streams): reject concurrent messages that break the tool-use loop#48tsuz wants to merge 1 commit into
tsuz wants to merge 1 commit into
Conversation
A new user message arriving mid-turn corrupts the history the next LLM
call is built from. EnrichInputMessageProcessor rebuilds history as
`previousMessages + [lastInputMessage] + lastInputResponse`. When the
previous turn issued tools, lastInputResponse ends with an assistant
message carrying tool_use blocks, and the API requires the matching
tool_result blocks to come next. Those arrive as a role:"tool" record on
message-input. If a role:"user" message is appended instead, the LLM sees
a tool_use with no tool_result and returns 400 — killing the turn.
Add SessionConcurrencyGuardProcessor as admission control ahead of
enrichment. It consumes message-input and produces the admitted subset to
a new admitted-message-input topic, which enrichment now reads instead:
- role:"user" while the session is busy -> rejected, with a
status:"rejected" UserResponse emitted to message-output
- role:"tool" continuation -> always admitted, mid-turn included; it is
precisely the record that completes the pending tool_use blocks
- clean end-turn (endTurn, no tool calls) -> busy cleared
- wall-clock punctuator force-clears sessions idle beyond
SESSION_BUSY_TTL_MS (default 600s), so a crashed turn cannot wedge a
session permanently
Rejections are left-joined with the per-session reply-to route, mirroring
EndTurnProcessor, so a multi-agent caller fails fast through its HTTP
callback instead of hanging until its own timeout — which would also
leave a stale route the in-flight turn's reply could be mis-delivered to.
UserResponse gains a nullable `status` field. It is additive and the
record is @JsonIgnoreProperties(ignoreUnknown = true), so existing
consumers are unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
The problem
Nothing stopped a user from sending a second message while the agent was mid-turn, and doing so could kill the turn outright.
EnrichInputMessageProcessorrebuilds the history for the next LLM call as:When the previous turn issued tools,
lastInputResponseends with an assistant message carryingtool_useblocks. The API requires the matchingtool_resultblocks to come next. Those arrive as a separatemessage-inputrecord withrole:"tool"(written back byTransformToolUseDoneProcessor).If a
role:"user"message gets appended in that window instead, the LLM sees atool_usethat is not immediately followed by itstool_resultand returns 400 — the turn dies.There was no rate limiting or in-flight guard anywhere else, either: no debounce in the frontend, nothing in
chat-api. So the race was fully reachable by typing fast.The fix
SessionConcurrencyGuardProcessor— admission control placed before enrichment.The guard consumes
message-inputand republishes only the admitted subset to a newadmitted-message-inputtopic.EnrichInputMessageProcessornow sources from that topic instead ofmessage-input— so a rejected message never reaches enrichment at all.Busy lifecycle, per session
role:"user", session idlerole:"user", session busystatus:"rejected"UserResponsetomessage-outputrole:"tool"continuationendTurn, no tool calls)SESSION_BUSY_TTL_MS(default 600s)Two details worth calling out:
The
role:"tool"path is never rejected. That record is precisely the one that completes the pendingtool_useblocks — rejecting it would cause the exact failure this PR prevents.TransformToolUseDoneProcessorwrites it back tomessage-input, so it re-enters through the guard and is waved past.Rejections carry the reply-to route. They're left-joined with the per-session
reply-totable, mirroringEndTurnProcessor, so a multi-agent caller whose message is rejected fails fast through its HTTP callback rather than hanging until its own timeout — which would also leave a stale route that the in-flight turn's reply could be mis-delivered to.The TTL sweep is the anti-wedge net. Without it, a turn that crashed without emitting a clean end-turn would leave its session rejecting every message forever. 600s comfortably exceeds the longest legitimate quiet stretch inside a turn (
ASYNC_TOOL_TIMEOUT_MS, default 300s).API surface
UserResponsegains a nullablestatusfield —nullfor normal output,"rejected"for a busy rejection. Additive, and the record is@JsonIgnoreProperties(ignoreUnknown = true), so existing consumers are unaffected. It's the only definition in the repo (3 constructor call sites, all in this module).New topic
admitted-message-inputis added torequiredTopicsso it's auto-created on startup.Configuration
SESSION_BUSY_TTL_MS600000SESSION_GUARD_PUNCTUATE_INTERVAL_MS15000Verification
processor-apps/processing, including 10 new guard tests covering admit/reject, therole:"tool"exemption, busy clear on clean end-turn, busy retention while tools pend, TTL force-clear, and cross-session isolation.buildTopology()to confirm the wiring holds end-to-end, not just per-fragment:EnrichInputMessageProcessorTestnow feedsadmitted-message-inputdirectly; the guard is exercised separately in its own test.Follow-ups, not in this PR
🤖 Generated with Claude Code