fix(agents): drop metadata-only replay turns

Fixes #74745
This commit is contained in:
Peter Steinberger
2026-04-30 04:57:45 +01:00
parent bb44909262
commit d80a8eb3ad
4 changed files with 136 additions and 34 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string>(["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;
}