From d80a8eb3adb6b26fb345fdc7b658dbd39f085e35 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 30 Apr 2026 04:57:45 +0100 Subject: [PATCH] fix(agents): drop metadata-only replay turns Fixes #74745 --- CHANGELOG.md | 1 + ...ed-runner.sanitize-session-history.test.ts | 53 ++++++++++++ .../pi-embedded-runner/replay-history.test.ts | 31 +++++++ .../pi-embedded-runner/replay-history.ts | 85 +++++++++++-------- 4 files changed, 136 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b901eca4974..2226f0343b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Channels/Discord: keep read-only allowlist/default-target accessors from resolving SecretRef-backed bot tokens, so status and channel summaries no longer fail when tokens are only available in gateway runtime. (#74737) Thanks @eusine. +- Agents/output: drop copied inbound metadata-only assistant replay turns before provider replay instead of synthesizing a placeholder, so Telegram and other channels cannot receive `[assistant copied inbound metadata omitted]` as model output. Fixes #74745. Thanks @adamwdear and @Marvae. - Channels/groups: preserve observe-only turn suppression for prepared dispatch paths and restore deprecated channel turn runtime aliases, so passive observer/group flows stay silent while older plugins keep compiling. Thanks @vincentkoc. - Feishu/Bitable: clean up newly created placeholder rows whose fields contain only default empty values while preserving meaningful link, attachment, user, number, boolean, and location values during create-app cleanup. (#73920) Carries forward #40602. Thanks @boat2moon. - macOS app: keep attach-only mode and the Debug Settings launchd toggle marker-only, so launching with `--attach-only`/`--no-launchd` no longer uninstalls the Gateway LaunchAgent or drops active sessions. (#72174) Thanks @DolencLuka. diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index 91ad06c239d..85d8a6e3309 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -1131,6 +1131,59 @@ describe("sanitizeSessionHistory", () => { ]); }); + it("drops metadata-only assistant replay turns before provider validation", async () => { + setNonGoogleModelApi(); + + const metadataOnlyText = [ + "Conversation info (untrusted metadata):", + "```json", + '{"chat_id":"channel:123","sender":"OpenClaw"}', + "```", + ].join("\n"); + const messages = castAgentMessages([ + { + role: "user", + content: [{ type: "text", text: "First" }], + timestamp: nextTimestamp(), + }, + makeAssistantMessage([{ type: "text", text: metadataOnlyText }]), + { + role: "user", + content: [{ type: "text", text: "Second" }], + timestamp: nextTimestamp(), + }, + ]); + + const sanitized = await sanitizeSessionHistory({ + messages, + modelApi: "anthropic-messages", + provider: "anthropic", + modelId: "claude-sonnet-4-6", + sessionManager: makeMockSessionManager(), + sessionId: TEST_SESSION_ID, + }); + expect(sanitized.map((msg) => msg.role)).toEqual(["user", "user"]); + expect(JSON.stringify(sanitized)).not.toContain("assistant copied inbound metadata omitted"); + + const validated = await validateReplayTurns({ + messages: sanitized, + modelApi: "anthropic-messages", + provider: "anthropic", + modelId: "claude-sonnet-4-6", + sessionId: TEST_SESSION_ID, + }); + expect(validated).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "First" }, + { type: "text", text: "Second" }, + ], + timestamp: expect.any(Number), + }, + ]); + }); + it("strips prior assistant reasoning for Gemma 4 OpenAI-compatible replay", async () => { setNonGoogleModelApi(); diff --git a/src/agents/pi-embedded-runner/replay-history.test.ts b/src/agents/pi-embedded-runner/replay-history.test.ts index 30ff9488654..f77f3b2604b 100644 --- a/src/agents/pi-embedded-runner/replay-history.test.ts +++ b/src/agents/pi-embedded-runner/replay-history.test.ts @@ -3,6 +3,10 @@ import { describe, expect, it } from "vitest"; import { normalizeAssistantReplayContent } from "./replay-history.js"; const FALLBACK_TEXT = "[assistant turn failed before producing content]"; +const COPIED_INBOUND_METADATA_ONLY_TEXT = `Conversation info (untrusted metadata): +\`\`\`json +{"message_id":"msg-abc","sender":"+1555000"} +\`\`\``; function bedrockAssistant( content: unknown, @@ -134,6 +138,33 @@ describe("normalizeAssistantReplayContent", () => { expect(wrapped.content).toEqual([{ type: "text", text: "plain string content" }]); }); + it("drops metadata-only legacy string assistant content from replay", () => { + const messages = [ + userMessage("first"), + bedrockAssistant(COPIED_INBOUND_METADATA_ONLY_TEXT), + userMessage("second"), + ]; + const out = normalizeAssistantReplayContent(messages); + expect(out).toEqual([messages[0], messages[2]]); + expect(JSON.stringify(out)).not.toContain("assistant copied inbound metadata omitted"); + }); + + it("drops metadata-only assistant text blocks without fabricating placeholder output", () => { + const toolCall = { type: "toolCall", id: "call_1", name: "read", arguments: {} }; + const messages = [ + userMessage("hi"), + bedrockAssistant([ + { type: "text", text: COPIED_INBOUND_METADATA_ONLY_TEXT }, + { type: "text", text: `${COPIED_INBOUND_METADATA_ONLY_TEXT}\n\nVisible reply` }, + toolCall, + ]), + ]; + const out = normalizeAssistantReplayContent(messages); + const normalized = out[1] as AgentMessage & { content: unknown[] }; + expect(normalized.content).toEqual([{ type: "text", text: "Visible reply" }, toolCall]); + expect(JSON.stringify(out)).not.toContain("assistant copied inbound metadata omitted"); + }); + it("filters openclaw delivery-mirror and gateway-injected assistant messages from replay", () => { const messages = [ userMessage("hello"), diff --git a/src/agents/pi-embedded-runner/replay-history.ts b/src/agents/pi-embedded-runner/replay-history.ts index 5318bd43e3f..7d0fd21b13f 100644 --- a/src/agents/pi-embedded-runner/replay-history.ts +++ b/src/agents/pi-embedded-runner/replay-history.ts @@ -234,7 +234,6 @@ function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[] // content and, on Bedrock or strict OpenAI-compatible providers, can also // trigger turn-ordering rejections. const TRANSCRIPT_ONLY_OPENCLAW_MODELS = new Set(["delivery-mirror", "gateway-injected"]); -const OMITTED_INBOUND_METADATA_TEXT = "[assistant copied inbound metadata omitted]"; function sanitizeUserReplayContent(message: AgentMessage): AgentMessage | null { if (!message || message.role !== "user") { @@ -282,6 +281,49 @@ function isTranscriptOnlyOpenclawAssistant(message: AgentMessage): boolean { ); } +function normalizeAssistantReplayTextContent(message: AgentMessage, replayContent: string) { + const strippedText = stripInboundMetadata(replayContent); + if (!strippedText.trim()) { + return null; + } + return { + ...message, + content: [{ type: "text", text: strippedText }], + } as AgentMessage; +} + +function normalizeAssistantReplayBlockContent(message: AgentMessage, replayContent: unknown[]) { + let touched = false; + const sanitizedContent: unknown[] = []; + for (const block of replayContent) { + if (!block || typeof block !== "object") { + sanitizedContent.push(block); + continue; + } + const text = (block as { text?: unknown }).text; + if (typeof text !== "string") { + sanitizedContent.push(block); + continue; + } + const strippedText = stripInboundMetadata(text); + if (strippedText === text) { + sanitizedContent.push(block); + continue; + } + touched = true; + if (strippedText.trim()) { + sanitizedContent.push({ ...block, text: strippedText }); + } + } + if (!touched) { + return message; + } + if (sanitizedContent.length === 0) { + return null; + } + return { ...message, content: sanitizedContent } as AgentMessage; +} + export function normalizeAssistantReplayContent(messages: AgentMessage[]): AgentMessage[] { let touched = false; const out: AgentMessage[] = []; @@ -308,44 +350,19 @@ export function normalizeAssistantReplayContent(messages: AgentMessage[]): Agent } const replayContent = (message as { content?: unknown }).content; if (typeof replayContent === "string") { - const strippedText = stripInboundMetadata(replayContent); - out.push({ - ...message, - content: [ - { - type: "text", - text: strippedText.trim() ? strippedText : OMITTED_INBOUND_METADATA_TEXT, - }, - ], - }); + const normalized = normalizeAssistantReplayTextContent(message, replayContent); + if (normalized) { + out.push(normalized); + } touched = true; continue; } if (Array.isArray(replayContent)) { - let contentTouched = false; - const sanitizedContent = replayContent.map((block) => { - if (!block || typeof block !== "object") { - return block; + const normalized = normalizeAssistantReplayBlockContent(message, replayContent); + if (normalized !== message) { + if (normalized) { + out.push(normalized); } - const text = (block as { text?: unknown }).text; - if (typeof text !== "string") { - return block; - } - const strippedText = stripInboundMetadata(text); - if (strippedText === text) { - return block; - } - contentTouched = true; - return { - ...block, - text: strippedText.trim() ? strippedText : OMITTED_INBOUND_METADATA_TEXT, - }; - }); - if (contentTouched) { - out.push({ - ...message, - content: sanitizedContent, - }); touched = true; continue; }