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 <shu.zongyu@xydigit.com>

* docs: refresh transcript hygiene map

Regenerate the docs map after documenting silent reasoning-only replay cleanup.

Co-authored-by: SunnyShu <shu.zongyu@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
SunnyShu
2026-07-21 15:34:15 +08:00
committed by GitHub
parent 5b3e01ba3e
commit 796b6fcdcf
4 changed files with 99 additions and 10 deletions

View File

@@ -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)

View File

@@ -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`

View File

@@ -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"),

View File

@@ -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 {