From 796b6fcdcf0e005b63ebc46de30e853fc8f0df49 Mon Sep 17 00:00:00 2001 From: SunnyShu Date: Tue, 21 Jul 2026 15:34:15 +0800 Subject: [PATCH] fix(agents): drop orphaned thinking blocks when NO_REPLY text leaves an assistant message with only thinking content (#99620) (#99772) * fix(agents): drop silent reasoning-only replay turns Prevent NO_REPLY cleanup from leaving signed reasoning that merges into the next assistant tool-use turn and bricks strict-provider sessions. Related: #99620 Co-authored-by: SunnyShu * docs: refresh transcript hygiene map Regenerate the docs map after documenting silent reasoning-only replay cleanup. Co-authored-by: SunnyShu --------- Co-authored-by: Peter Steinberger --- docs/docs_map.md | 2 +- docs/reference/transcript-hygiene.md | 18 +++-- .../replay-history.test.ts | 72 +++++++++++++++++++ .../embedded-agent-runner/replay-history.ts | 17 ++++- 4 files changed, 99 insertions(+), 10 deletions(-) diff --git a/docs/docs_map.md b/docs/docs_map.md index 42d8bea4b434..dda783bf1221 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -9126,7 +9126,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Global rule: image sanitization - H2: Global rule: malformed tool calls - H2: Global rule: tool result pairing - - H2: Global rule: incomplete reasoning-only turns + - H2: Global rule: incomplete or silent reasoning-only turns - H2: Global rule: inter-session input provenance - H2: Provider matrix (current behavior) - H2: Historical behavior (pre-2026.1.22) diff --git a/docs/reference/transcript-hygiene.md b/docs/reference/transcript-hygiene.md index d295b892b729..111bb80844ec 100644 --- a/docs/reference/transcript-hygiene.md +++ b/docs/reference/transcript-hygiene.md @@ -121,15 +121,21 @@ Implementation: `sanitizeToolUseResultPairing` in --- -## Global rule: incomplete reasoning-only turns +## Global rule: incomplete or silent reasoning-only turns -Assistant turns that hit the provider output limit with only thinking or -redacted-thinking content are omitted from the in-memory replay copy. Such -turns contain incomplete provider state and may carry a partial thinking -signature. +Assistant turns are omitted from the in-memory replay copy when they contain +only thinking or redacted-thinking content after either of these events: + +- The provider output limit ends the turn with incomplete reasoning state. +- Silent-reply cleanup removes the turn's only visible `NO_REPLY` text. + +The silent-reply cleanup prevents hidden reasoning from merging into a later +assistant tool-use turn when strict providers rebuild the conversation. Empty length turns remain unchanged, as do length turns with visible text, -tool calls, or unknown content blocks. Stored transcripts are not rewritten. +tool calls, or unknown content blocks. Silent-reply turns with tool calls or +unknown content blocks also remain unchanged. Stored transcripts are not +rewritten. Implementation: `normalizeAssistantReplayContent` in `src/agents/embedded-agent-runner/replay-history.ts` diff --git a/src/agents/embedded-agent-runner/replay-history.test.ts b/src/agents/embedded-agent-runner/replay-history.test.ts index 9c05bf3686ba..7a8b7f147c3e 100644 --- a/src/agents/embedded-agent-runner/replay-history.test.ts +++ b/src/agents/embedded-agent-runner/replay-history.test.ts @@ -312,6 +312,78 @@ describe("normalizeAssistantReplayContent", () => { expect(out).toEqual([messages[0], messages[2]]); }); + it.each([ + [ + "directly", + "NO_REPLY", + { type: "thinking", thinking: "yield reasoning", thinkingSignature: "sig_yield" }, + ], + [ + "after metadata removal", + `${COPIED_INBOUND_METADATA_ONLY_TEXT}\n\nNO_REPLY`, + { type: "redacted_thinking", data: "redacted-yield" }, + ], + ])("drops thinking-only silent replies %s (#99620)", (_label, text, reasoning) => { + const messages = [ + userMessage("hi"), + bedrockAssistant([reasoning, { type: "text", text }], "stop"), + ]; + + expect(normalizeAssistantReplayContent(messages)).toStrictEqual([messages[0]]); + }); + + it("drops silent thinking residue before a follow-up tool turn (#99620)", () => { + const nextToolTurn = bedrockAssistant( + [ + { type: "thinking", thinking: "next reasoning", thinkingSignature: "sig_next" }, + { type: "toolCall", id: "call_1", name: "exec", arguments: {} }, + ], + "toolUse", + ); + const messages = [ + userMessage("hi"), + bedrockAssistant( + [ + { type: "thinking", thinking: "yield reasoning", thinkingSignature: "sig_yield" }, + { type: "text", text: "NO_REPLY" }, + ], + "stop", + ), + nextToolTurn, + userMessage("tool result"), + ]; + + expect(normalizeAssistantReplayContent(messages)).toEqual([ + messages[0], + nextToolTurn, + messages[3], + ]); + }); + + it.each([ + ["tool calls", { type: "toolCall", id: "call_1", name: "exec", arguments: {} }], + ["unknown blocks", { customType: "legacy_data", data: "preserve me" }], + ])("preserves silent-reply turns with %s", (_label, companion) => { + const messages = [ + userMessage("hi"), + bedrockAssistant( + [ + { type: "thinking", thinking: "useful reasoning", thinkingSignature: "sig" }, + companion, + { type: "text", text: "NO_REPLY" }, + ], + "stop", + ), + ]; + + const out = normalizeAssistantReplayContent(messages); + expect(out).toHaveLength(2); + expect((out[1] as { content: unknown[] }).content).toEqual([ + { type: "thinking", thinking: "useful reasoning", thinkingSignature: "sig" }, + companion, + ]); + }); + it("strips copied runtime context from assistant replay text", () => { const messages = [ userMessage("first"), diff --git a/src/agents/embedded-agent-runner/replay-history.ts b/src/agents/embedded-agent-runner/replay-history.ts index d31b21e25b28..b650bed1ee87 100644 --- a/src/agents/embedded-agent-runner/replay-history.ts +++ b/src/agents/embedded-agent-runner/replay-history.ts @@ -32,7 +32,10 @@ import { validateGeminiTurns, } from "../embedded-agent-helpers.js"; import { resolveImageSanitizationLimits } from "../image-sanitization.js"; -import { isReasoningOnlyLengthAssistantTurn } from "../replay-turn-classification.js"; +import { + hasOnlyAssistantReasoningContent, + isReasoningOnlyLengthAssistantTurn, +} from "../replay-turn-classification.js"; import type { AgentMessage } from "../runtime/index.js"; import { sanitizeToolCallInputs, @@ -227,6 +230,7 @@ function normalizeAssistantReplayTextContent(message: AgentMessage, replayConten function normalizeAssistantReplayBlockContent(message: AgentMessage, replayContent: unknown[]) { let touched = false; + let removedSilentText = false; const sanitizedContent: unknown[] = []; for (const block of replayContent) { if (!block || typeof block !== "object") { @@ -244,14 +248,18 @@ function normalizeAssistantReplayBlockContent(message: AgentMessage, replayConte sanitizedContent.push(block); } else { touched = true; + removedSilentText = true; } continue; } touched = true; const trimmed = strippedText.trim(); - if (trimmed && !isSilentReplyPayloadText(trimmed, SILENT_REPLY_TOKEN)) { + const isSilentText = + trimmed.length > 0 && isSilentReplyPayloadText(trimmed, SILENT_REPLY_TOKEN); + if (trimmed && !isSilentText) { sanitizedContent.push({ ...block, text: strippedText }); } + removedSilentText ||= isSilentText; } if (!touched) { return message; @@ -259,7 +267,10 @@ function normalizeAssistantReplayBlockContent(message: AgentMessage, replayConte if (sanitizedContent.length === 0) { return null; } - return { ...message, content: sanitizedContent } as AgentMessage; + const normalized = { ...message, content: sanitizedContent } as AgentMessage; + // A silent reply has no visible assistant output. Do not let its signed + // reasoning merge into the next assistant turn during strict replay. + return removedSilentText && hasOnlyAssistantReasoningContent(normalized) ? null : normalized; } function isBareDeliveryMirrorDuplicate(out: AgentMessage[], next: AssistantReplayMessage): boolean {