refactor(agents): share presentation record sanitization (#147851)

This commit is contained in:
Vincent Koc 2026-09-14 13:24:42 +08:00 • committed by GitHub
parent dba4da1f8b
commit da39dbae88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 99 additions and 37 deletions

View file

@ -1967,7 +1967,7 @@ src/agents/tools/in-process-gateway.ts 1
src/agents/tools/media-generate-tool-actions-shared.ts 1
src/agents/tools/message-tool-discovery.ts 3
src/agents/tools/message-tool-execution.ts 3
src/agents/tools/message-tool-visible-content.ts 9
src/agents/tools/message-tool-visible-content.ts 6
src/agents/tools/mobile-ui-tool.ts 5
src/agents/tools/music-generate-tool.ts 2
src/agents/tools/nodes-tool.ts 2

View file

@ -106,6 +106,18 @@ function sanitizePresentationTextFieldsResult(
return { value };
}
let suppressionReason: VisibleTextSuppressionReason | undefined;
const sanitizeRecordArray = (entries: unknown[], field: "label" | "name") =>
entries.map((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return entry;
}
// SAFETY: The guard establishes a non-null, non-array record before cloning.
const sanitized = { ...(entry as Record<string, unknown>) };
// Keep sanitizing after suppression; the first reason only labels the outcome.
const reason = sanitizeStringParam(sanitized, field, bootPrompt);
suppressionReason ??= reason;
return sanitized;
});
const presentation = { ...(value as Record<string, unknown>) };
if (typeof presentation.title === "string") {
const sanitized = sanitizeUserVisibleToolTextResult(presentation.title, bootPrompt);
@ -229,18 +241,7 @@ function sanitizePresentationTextFieldsResult(
});
}
if (Array.isArray(sanitizedBlock.options)) {
sanitizedBlock.options = sanitizedBlock.options.map((option) => {
if (!option || typeof option !== "object" || Array.isArray(option)) {
return option;
}
const sanitizedOption = { ...(option as Record<string, unknown>) };
if (typeof sanitizedOption.label === "string") {
const sanitized = sanitizeUserVisibleToolTextResult(sanitizedOption.label, bootPrompt);
sanitizedOption.label = sanitized.text;
suppressionReason ??= sanitized.suppressionReason;
}
return sanitizedOption;
});
sanitizedBlock.options = sanitizeRecordArray(sanitizedBlock.options, "label");
}
if (Array.isArray(sanitizedBlock.categories)) {
sanitizedBlock.categories = sanitizedBlock.categories.map((category) => {
@ -253,32 +254,10 @@ function sanitizePresentationTextFieldsResult(
});
}
if (Array.isArray(sanitizedBlock.segments)) {
sanitizedBlock.segments = sanitizedBlock.segments.map((segment) => {
if (!segment || typeof segment !== "object" || Array.isArray(segment)) {
return segment;
}
const sanitizedSegment = { ...(segment as Record<string, unknown>) };
if (typeof sanitizedSegment.label === "string") {
const sanitized = sanitizeUserVisibleToolTextResult(sanitizedSegment.label, bootPrompt);
sanitizedSegment.label = sanitized.text;
suppressionReason ??= sanitized.suppressionReason;
}
return sanitizedSegment;
});
sanitizedBlock.segments = sanitizeRecordArray(sanitizedBlock.segments, "label");
}
if (Array.isArray(sanitizedBlock.series)) {
sanitizedBlock.series = sanitizedBlock.series.map((series) => {
if (!series || typeof series !== "object" || Array.isArray(series)) {
return series;
}
const sanitizedSeries = { ...(series as Record<string, unknown>) };
if (typeof sanitizedSeries.name === "string") {
const sanitized = sanitizeUserVisibleToolTextResult(sanitizedSeries.name, bootPrompt);
sanitizedSeries.name = sanitized.text;
suppressionReason ??= sanitized.suppressionReason;
}
return sanitizedSeries;
});
sanitizedBlock.series = sanitizeRecordArray(sanitizedBlock.series, "name");
}
return sanitizedBlock;
});

View file

@ -41,6 +41,7 @@ import { readEmbeddedMessageDeliveryFact } from "../embedded-agent-message-deliv
import { createOpenClawTools } from "../openclaw-tools.js";
import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js";
import { createMessageTool } from "./message-tool-execution.js";
import { sanitizeMessageToolVisiblePayload } from "./message-tool-visible-content.js";
import { runSessionsSendA2AFlow } from "./sessions-send-tool.a2a.js";
type CreateMessageTool = typeof createMessageTool;
@ -4553,6 +4554,12 @@ describe("message tool reasoning tag sanitization", () => {
xLabel: "<think>axis rationale</think>Day",
yLabel: "<think>axis rationale</think>Milliseconds",
},
{
type: "chart",
chartType: "pie",
title: "Traffic",
segments: [{ label: "<think>segment rationale</think>Primary", value: 1 }],
},
],
},
},
@ -4586,10 +4593,86 @@ describe("message tool reasoning tag sanitization", () => {
xLabel: "Day",
yLabel: "Milliseconds",
},
{
type: "chart",
chartType: "pie",
title: "Traffic",
segments: [{ label: "Primary", value: 1 }],
},
],
});
});
it.each([true, false])(
"sanitizes every presentation record array while retaining the first reason (option suppressed: %s)",
(suppressOption) => {
const internalContext =
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
const inboundContext = [
markInboundContextLabel("Conversation info:"),
"```json",
'{"chat_id":"group:test","sender_id":"test-sender"}',
"```",
].join("\n");
const metadata = { retained: true };
const option = { label: suppressOption ? internalContext : " Choice ", metadata };
const nonString = { label: 7 };
const invalidArray = ["<think>unchanged</think>"];
const presentation = {
blocks: [
{
options: [option, null, invalidArray, nonString],
categories: [inboundContext],
segments: [
{ label: internalContext, value: 1 },
{ label: "<think>segment rationale</think>Slice", value: 2 },
],
series: [
{ name: internalContext, values: [1] },
{ name: "<think>series rationale</think>Trend", values: [2] },
],
},
],
};
const original = structuredClone(presentation);
const params = { presentation };
expect(sanitizeMessageToolVisiblePayload(params)).toBe(
suppressOption ? "internal_runtime_context_echo" : "inbound_metadata_echo",
);
const block = params.presentation.blocks[0];
expect(block).toEqual({
options: [
{ label: suppressOption ? "" : " Choice ", metadata },
null,
invalidArray,
nonString,
],
categories: [""],
segments: [
{ label: "", value: 1 },
{ label: "Slice", value: 2 },
],
series: [
{ name: "", values: [1] },
{ name: "Trend", values: [2] },
],
});
expect(params.presentation).not.toBe(presentation);
expect(block).not.toBe(presentation.blocks[0]);
for (const field of ["options", "segments", "series"] as const) {
expect(block?.[field]).not.toBe(presentation.blocks[0]?.[field]);
}
expect(block?.options[0]).not.toBe(option);
expect(block?.options[2]).toBe(invalidArray);
expect(block?.options[3]).not.toBe(nonString);
expect(block?.segments[0]).not.toBe(presentation.blocks[0]?.segments[0]);
expect(block?.series[0]).not.toBe(presentation.blocks[0]?.series[0]);
expect(presentation).toEqual(original);
},
);
it("strips internal runtime context from visible presentation fields before sending (#53732)", async () => {
mockSendResult({ channel: "slack", to: "slack:C123" });