mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:21:35 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user