Skip to content

fix(agent-core): mark resampled tool-call-markup leaks as non-replayable - #166

Closed
jasonkneen wants to merge 1 commit into
stepfun-ai:mainfrom
jasonkneen:fix/harness-leak-retry-transcript
Closed

jasonkneen wants to merge 1 commit into
stepfun-ai:mainfrom
jasonkneen:fix/harness-leak-retry-transcript

Conversation

@jasonkneen

Copy link
Copy Markdown

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 local currentContext before resampling, but streamAssistantResponse had already emitted message_end for that leaked attempt with its raw, unmarked content. Agent.processEvents (packages/agent-core/src/agent.ts ~554-557) unconditionally pushes every message_end message onto Agent._state.messages, and AgentSession (packages/coding-agent/src/core/agent-session.ts ~761) persists it via sessionManager.appendMessage. Since a later prompt()/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

streamAssistantResponse no longer emits message_end itself; it still emits message_start/message_update and pushes the finalized message onto the loop's local context, but finalization (the message_end emit) is now owned by runLoop. For each attempt about to be resampled because of a markup leak, runLoop emits message_end with the message reclassified via a new markLeakedAttemptNonReplayable() helper: stopReason: "error", errorMessage: "Tool-call markup leaked into text; response was resampled.". providers/src/api/transform-messages.ts (~195) already skips assistant messages with stopReason === "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 errorMessage text deliberately does not match providers' transient-error retry patterns (isRetryableAssistantError in packages/providers/src/utils/retry.ts), and the marked message is never included in newMessages/agent_end.messages (only the final attempt is), so AgentSession's separate auto-retry (_willRetryAfterAgentEnd, _handlePostAgentRun) and features/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:

  • Extended "resamples a leaked turn and keeps only the good message in context" to assert the leaked attempt's message_end carries stopReason: "error" / a "leak"-matching errorMessage, while the resampled final message stays stopReason: "stop" with its real content.
  • Added "excludes the marked leaked attempt from a later turn built from persisted messages": builds the list of persisted message_end payloads (as a resumed session would), applies a replay-skip filter equivalent to transformMessages, and asserts the leaked markup text (tool_call) never appears while the good response (done cleanly) does.
  • Extended "stops after the bounded retries and commits the last attempt" (budget exhausted, still leaking) to assert the two superseded attempts are marked error while the final, budget-exhausted attempt stays unmarked (stopReason: "stop"), i.e. kept as-is.

Before the fix, all three assertions on stopReason === "error" failed with expected 'stop' to be 'error' (verified by reverting agent-loop.ts and re-running).

packages/coding-agent/test/agent-session-retry.test.ts:

  • Added "does not auto-retry a resampled tool-call markup leak": drives a full AgentSession.prompt() through a leaked-then-clean scripted stream and asserts AgentSession's own auto_retry_start/auto_retry_end events never fire and session.isRetrying stays false, i.e. the mark is not mistaken for a retryable provider error, while the leaked message is confirmed present in session.agent.state.messages with stopReason: "error".
  • The same test then issues a second, independent session.prompt("Again") and inspects the raw request built for that third model call: it still contains the marked leak object (coding-agent's own convertToLlm does not filter it — matching production, where the real per-provider dialect layer's transformMessages does 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. transformMessages itself 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 actual Agent.state.messagescreateContextSnapshot() call site the bug lived in.

Risk / behaviour change

  • AssistantMessage.stopReason/errorMessage for a resampled-away leaked attempt changes from the raw provider values (e.g. "stop" with the leaked text) to "error" with a fixed harness-authored errorMessage. 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.
  • streamAssistantResponse no longer emits message_end itself; it is only called from runLoop (two call sites), so no other caller depends on its previous auto-emit behavior.
  • No change to the final (successful, or budget-exhausted) message's stopReason/content — the retry budget, toolCallLeakRetries semantics, and turn_end/agent_end sequencing 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 current main; whichever merges later needs a small rebase.

https://claude.ai/code/session_01GUdnnHEaDThHUATSwXBpV9

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.
Copilot AI lite review requested due to automatic review settings September 23, 2026 11:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_end finalization 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.

@ZouR-Ma ZouR-Ma closed this Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants