fix(agent-core): mark resampled tool-call-markup leaks as non-replayable - #166
Closed
jasonkneen wants to merge 1 commit into
Closed
jasonkneen wants to merge 1 commit into
jasonkneen wants to merge 1 commit into
Conversation
Invariant: a leaked attempt that the loop decides to resample must never be replayed to the model, only kept for observability. Cause: streamAssistantResponse always emitted message_end for a leaked attempt before agent-loop.ts's leak-retry code got a chance to pop it from the local currentContext. Agent.processEvents pushes every message_end message onto Agent._state.messages unconditionally, and AgentSession persists it via sessionManager.appendMessage, so the raw `<tool_call>`/`<function=...>` markup text survived into state/session history and was replayed on the next turn (or a resumed session), teaching the model the broken format. Fix: streamAssistantResponse no longer emits message_end itself; runLoop now owns finalization. Before resampling a leaked attempt, runLoop marks it via a new markLeakedAttemptNonReplayable() helper (stopReason: "error", errorMessage: "Tool-call markup leaked into text; response was resampled.") and emits message_end for that marked copy. providers' transformMessages already skips assistant messages with stopReason "error"/"aborted" when building the next request, so this keeps the leaked markup out of every future request while the message itself still lands in state/session history. The final attempt for a turn (resampled success, or the last leaked attempt once the retry budget is exhausted) is committed unmarked, unchanged from before. The custom errorMessage text does not match providers' transient-error retry patterns, and the marked message never enters newMessages/agent_end, so neither AgentSession's auto-retry nor step-stream-recovery can fire on it.
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
No unresolved blocking issues were identified.
Review effort: Lite
Findings: None
What changed in this PR
Prevents resampled tool-call markup leaks from being replayed while preserving observability.
Changes:
- Defers
message_endfinalization to classify superseded attempts as non-replayable errors. - Adds regression tests for replay filtering, retry behavior, and persistence.
| File | Description |
|---|---|
packages/coding-agent/test/agent-session-retry.test.ts |
Verifies session persistence and auto-retry behavior. |
packages/agent-core/test/agent-loop.test.ts |
Verifies leak retry and replay filtering. |
packages/agent-core/src/agent-loop.ts |
Marks leaked attempts and centralizes message finalization. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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.
Problem
packages/agent-core/src/agent-loop.ts(~212-226) resamples an assistant turn when a serving-side tool parser leaks raw tool-call markup into plain text (isToolCallMarkupLeak). The loop pops the leaked message out of its own localcurrentContextbefore resampling, butstreamAssistantResponsehad already emittedmessage_endfor that leaked attempt with its raw, unmarked content.Agent.processEvents(packages/agent-core/src/agent.ts~554-557) unconditionally pushes everymessage_endmessage ontoAgent._state.messages, andAgentSession(packages/coding-agent/src/core/agent-session.ts~761) persists it viasessionManager.appendMessage. Since a laterprompt()/continue()rebuilds context from_state.messages(and a resumed session replays persisted history), the raw<tool_call>/<function=...>markup text from the leaked attempt was fed back to the model on the next turn — contradicting the "resample the identical context" intent and teaching the model the broken format.Fix
streamAssistantResponseno longer emitsmessage_enditself; it still emitsmessage_start/message_updateand pushes the finalized message onto the loop's local context, but finalization (themessage_endemit) is now owned byrunLoop. For each attempt about to be resampled because of a markup leak,runLoopemitsmessage_endwith the message reclassified via a newmarkLeakedAttemptNonReplayable()helper:stopReason: "error",errorMessage: "Tool-call markup leaked into text; response was resampled.".providers/src/api/transform-messages.ts(~195) already skips assistant messages withstopReason === "error" | "aborted"when building the next request, so this single reclassification removes the leaked markup from every future request built from this context (same-turn retry, next turn, and a resumed session), while the message itself still lands in state/session history for observability. The final attempt for a turn — the resampled success, or the last leaked attempt once the retry budget (toolCallLeakRetries, default 2) is exhausted — is emitted unmarked, exactly as before.The custom
errorMessagetext deliberately does not matchproviders' transient-error retry patterns (isRetryableAssistantErrorinpackages/providers/src/utils/retry.ts), and the marked message is never included innewMessages/agent_end.messages(only the final attempt is), soAgentSession's separate auto-retry (_willRetryAfterAgentEnd,_handlePostAgentRun) andfeatures/step-stream-recovery.ts's stream-interruption recovery never see it as the "last assistant message" and cannot fire on it.Test
packages/agent-core/test/agent-loop.test.ts:message_endcarriesstopReason: "error"/ a "leak"-matchingerrorMessage, while the resampled final message staysstopReason: "stop"with its real content.message_endpayloads (as a resumed session would), applies a replay-skip filter equivalent totransformMessages, and asserts the leaked markup text (tool_call) never appears while the good response (done cleanly) does.errorwhile the final, budget-exhausted attempt stays unmarked (stopReason: "stop"), i.e. kept as-is.Before the fix, all three assertions on
stopReason === "error"failed withexpected 'stop' to be 'error'(verified by revertingagent-loop.tsand re-running).packages/coding-agent/test/agent-session-retry.test.ts:AgentSession.prompt()through a leaked-then-clean scripted stream and assertsAgentSession's ownauto_retry_start/auto_retry_endevents never fire andsession.isRetryingstaysfalse, i.e. the mark is not mistaken for a retryable provider error, while the leaked message is confirmed present insession.agent.state.messageswithstopReason: "error".session.prompt("Again")and inspects the raw request built for that third model call: it still contains the marked leak object (coding-agent's ownconvertToLlmdoes not filter it — matching production, where the real per-provider dialect layer'stransformMessagesdoes that job just before the wire request), but applying the equivalent replay-skip filter (stopReason === "error" | "aborted") to that same request removes the leaked text entirely while keeping the good response.transformMessagesitself is an internal, unexported helper of each provider dialect module and isn't reachable from agent-core/coding-agent tests without a build or a new cross-package alias, so this is the closest in-repo equivalent to asserting the real wire request at the actualAgent.state.messages→createContextSnapshot()call site the bug lived in.Risk / behaviour change
AssistantMessage.stopReason/errorMessagefor a resampled-away leaked attempt changes from the raw provider values (e.g."stop"with the leaked text) to"error"with a fixed harness-authorederrorMessage. Any UI/telemetry that renders every persisted assistant message will now show this attempt as an error turn instead of a normal one — this is intentional (it was a failed/discarded attempt) and matches how other synthetic failures (e.g.Agent.handleRunFailure) are represented.streamAssistantResponseno longer emitsmessage_enditself; it is only called fromrunLoop(two call sites), so no other caller depends on its previous auto-emit behavior.stopReason/content — the retry budget,toolCallLeakRetriessemantics, andturn_end/agent_endsequencing are unchanged.Merge note
This is one of five independent PRs that edit
packages/agent-core/src/agent-loop.ts(tool-result cap, strict tool args, leak-retry transcript, turn cap, agentLoop rejection). Each is based on currentmain; whichever merges later needs a small rebase.https://claude.ai/code/session_01GUdnnHEaDThHUATSwXBpV9