Skip to content

Commit 29b5563

Browse files
authored
fix: strip adjacent function response scaffolding (#82155)
Summary: - Strip adjacent function_response workflow output after stripped XML tool-call scaffolding. - Cover multiline, compact, dangling, chained, prose-like, and same-line-tail response forms. - Add regression coverage for the production sanitizeUserFacingText path and the shared assistant-visible-text sanitizer. Verification: - node scripts/run-vitest.mjs src/shared/text/assistant-visible-text.test.ts src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts -- --reporter=verbose - git diff --check origin/main...HEAD - /Users/steipete/Projects/agent-scripts/skills/codex-review/scripts/codex-review --mode branch --base origin/main --full-access --output /tmp/codex-review-82155-rerun.txt --parallel-tests "node scripts/run-vitest.mjs src/shared/text/assistant-visible-text.test.ts src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts -- --reporter=verbose" - GitHub Real behavior proof: https://github.com/openclaw/openclaw/actions/runs/25926897171
1 parent 9c38948 commit 29b5563

4 files changed

Lines changed: 331 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323
- Agents/auto-reply: honor `agents.defaults.silentReply` and per-surface group silent-reply policy when generic agent-run failure fallbacks decide whether to send visible fallback text. Fixes #82060. (#82086) Thanks @taozengabc.
2424
- Discord: render channel topic context as structured untrusted metadata in reply prompts and stop duplicating inbound message bodies or exposing raw `EXTERNAL_UNTRUSTED_CONTENT` envelopes. Fixes #82168. Thanks @ronan-dandelion-cult.
2525
- Codex app-server: arm the short idle watchdog as soon as Codex accepts a turn, so accepted turns with no current-turn progress release the OpenClaw session lane before the outer model timeout. Fixes #82129. Thanks @Francois3d.
26+
- Agents/replies: also strip `<function_response>` workflow output when it becomes visible after an adjacent stripped tool-call XML block, closing the remaining sanitizer leak from #47444.
2627
- Control UI/WebChat: focus the composer when users click the visible input chrome and restore larger, labeled desktop composer controls while preserving compact mobile taps. Fixes #45656. Thanks @BunsDev.
2728
- Discord: suppress generated link embeds on outbound messages by default so agent-sent URLs stay as plain links unless `channels.discord.suppressEmbeds` is disabled.
2829
- System events: keep owner downgrades in structured metadata while rendering queued prompt text as plain `System:` lines, preserving least-privilege wakeups without prompt-visible trust labels. (#82067)

src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,100 @@ describe("sanitizeUserFacingText", () => {
293293
expect(sanitizeUserFacingText(input)).toBe("Before\n\nAfter");
294294
});
295295

296+
it("strips function response wrappers adjacent to stripped function calls", () => {
297+
const input = [
298+
'<function_calls><invoke name="exec">internal</invoke></function_calls><function_response>',
299+
'Searching for: "what skills matter most in the age of AI"',
300+
"</function_response>",
301+
"After",
302+
].join("\n");
303+
304+
expect(sanitizeUserFacingText(input)).toBe("After");
305+
});
306+
307+
it("strips function response wrappers adjacent to inline stripped function calls", () => {
308+
const input = [
309+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls><function_response>',
310+
'Searching for: "what skills matter most in the age of AI"',
311+
"</function_response>",
312+
"After",
313+
].join("\n");
314+
315+
expect(sanitizeUserFacingText(input)).toBe("Checking. \nAfter");
316+
});
317+
318+
it("strips compact function response wrappers after newline-separated function calls", () => {
319+
const input = [
320+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls>',
321+
"<function_response>ok</function_response>",
322+
"After",
323+
].join("\n");
324+
325+
expect(sanitizeUserFacingText(input)).toBe("Checking. \n\nAfter");
326+
});
327+
328+
it("strips compact dangling function response wrappers adjacent to function calls", () => {
329+
const input =
330+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls><function_response>raw output';
331+
332+
expect(sanitizeUserFacingText(input)).toBe("Checking. ");
333+
});
334+
335+
it("strips same-line function response payloads with leading spaces", () => {
336+
const input =
337+
'<function_calls><invoke name="exec">internal</invoke></function_calls><function_response> raw output</function_response>\nAfter';
338+
339+
expect(sanitizeUserFacingText(input)).toBe("After");
340+
});
341+
342+
it("strips same-line function response payloads that start like prose", () => {
343+
const input =
344+
'<function_calls><invoke name="exec">internal</invoke></function_calls><function_response> is enabled</function_response>\nAfter';
345+
346+
expect(sanitizeUserFacingText(input)).toBe("After");
347+
});
348+
349+
it("strips adjacent function response payloads that match explanation wording", () => {
350+
const input =
351+
'<function_calls><invoke name="exec">internal</invoke></function_calls><function_response> response wrapper secret</function_response>\nAfter';
352+
353+
expect(sanitizeUserFacingText(input)).toBe("After");
354+
});
355+
356+
it("strips dangling same-line function response payloads with leading spaces", () => {
357+
const input =
358+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls><function_response> raw output';
359+
360+
expect(sanitizeUserFacingText(input)).toBe("Checking. ");
361+
});
362+
363+
it("strips chained function response wrappers adjacent to stripped function calls", () => {
364+
const input = [
365+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls><function_response>',
366+
"first result",
367+
"</function_response><function_response>",
368+
"second result",
369+
"</function_response>",
370+
"After",
371+
].join("\n");
372+
373+
expect(sanitizeUserFacingText(input)).toBe("Checking. \nAfter");
374+
});
375+
376+
it("strips compact chained function response wrappers adjacent to stripped function calls", () => {
377+
const input =
378+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls><function_response>first</function_response><function_response>second</function_response>\nAfter';
379+
380+
expect(sanitizeUserFacingText(input)).toBe("Checking. \nAfter");
381+
});
382+
383+
it("strips compact function response wrappers before same-line visible replies", () => {
384+
const input =
385+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls><function_response>raw</function_response> Done.';
386+
387+
expect(sanitizeUserFacingText(input)).toBe("Checking. Done.");
388+
});
389+
296390
it("preserves literal tool-call tag examples in user-facing prose", () => {
297391
const input = "Use `<tool_call>` to describe the XML tag in docs.";
298392
expect(sanitizeUserFacingText(input)).toBe(input);

src/shared/text/assistant-visible-text.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,21 @@ describe("stripAssistantInternalScaffolding", () => {
151151
expectVisibleText("Before\n<function_response>\nraw command output\n", "Before\n");
152152
});
153153

154+
it("preserves inline multi-line function_response examples in prose", () => {
155+
expectVisibleText(
156+
[
157+
"Before <function_response>",
158+
'Searching for: "what skills matter most in the age of AI"',
159+
"</function_response> After",
160+
].join("\n"),
161+
[
162+
"Before <function_response>",
163+
'Searching for: "what skills matter most in the age of AI"',
164+
"</function_response> After",
165+
].join("\n"),
166+
);
167+
});
168+
154169
it("strips <tool_result> closed with mismatched </tool_call> and preserves trailing text", () => {
155170
expectVisibleText(
156171
'Prefix\n<tool_result> {"output": "data"} </tool_call>\nSuffix',
@@ -411,6 +426,13 @@ describe("stripAssistantInternalScaffolding", () => {
411426
);
412427
});
413428

429+
it("preserves inline closed function_response examples in prose", () => {
430+
expectVisibleText(
431+
"Use <function_response>ok</function_response> to describe the response wrapper.",
432+
"Use <function_response>ok</function_response> to describe the response wrapper.",
433+
);
434+
});
435+
414436
it("preserves line-leading function_response prose examples", () => {
415437
expectVisibleText(
416438
"<function_response> is the response wrapper.",
@@ -589,6 +611,142 @@ describe("stripToolCallXmlTags", () => {
589611
"prefix suffix",
590612
);
591613
});
614+
615+
it("strips function_response adjacent to an opt-in stripped function_calls block", () => {
616+
const input = [
617+
'<function_calls><invoke name="exec">internal</invoke></function_calls><function_response>',
618+
'Searching for: "what skills matter most in the age of AI"',
619+
"</function_response>",
620+
"After",
621+
].join("\n");
622+
623+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe("\nAfter");
624+
});
625+
626+
it("strips function_response adjacent to an inline stripped function_calls block", () => {
627+
const input = [
628+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls><function_response>',
629+
'Searching for: "what skills matter most in the age of AI"',
630+
"</function_response>",
631+
"After",
632+
].join("\n");
633+
634+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe(
635+
"Checking. \nAfter",
636+
);
637+
});
638+
639+
it("strips compact function_response after a newline-separated stripped function_calls block", () => {
640+
const input = [
641+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls>',
642+
"<function_response>ok</function_response>",
643+
"After",
644+
].join("\n");
645+
646+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe(
647+
"Checking. \n\nAfter",
648+
);
649+
});
650+
651+
it("strips dangling function_response adjacent to a stripped function_calls block", () => {
652+
const input = [
653+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls><function_response>',
654+
'Searching for: "what skills matter most in the age of AI"',
655+
].join("\n");
656+
657+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe("Checking. ");
658+
});
659+
660+
it("strips compact dangling function_response adjacent to a stripped function_calls block", () => {
661+
const input =
662+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls><function_response>raw output';
663+
664+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe("Checking. ");
665+
});
666+
667+
it("strips same-line function_response payloads with leading spaces", () => {
668+
const input =
669+
'<function_calls><invoke name="exec">internal</invoke></function_calls><function_response> raw output</function_response>\nAfter';
670+
671+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe("\nAfter");
672+
});
673+
674+
it("strips same-line function_response payloads that start like prose", () => {
675+
const input =
676+
'<function_calls><invoke name="exec">internal</invoke></function_calls><function_response> is enabled</function_response>\nAfter';
677+
678+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe("\nAfter");
679+
});
680+
681+
it("strips dangling same-line function_response payloads with leading spaces", () => {
682+
const input =
683+
'<function_calls><invoke name="exec">internal</invoke></function_calls><function_response> raw output';
684+
685+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe("");
686+
});
687+
688+
it("strips function_response-looking prose adjacent to a stripped tool-call block", () => {
689+
const input =
690+
'<tool_call>{"name":"exec"}</tool_call>\n\n<function_response> is the response wrapper.';
691+
692+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe("\n\n");
693+
});
694+
695+
it("strips closed function_response-looking prose adjacent to a stripped tool-call block", () => {
696+
const input =
697+
'<tool_call>{"name":"exec"}</tool_call>\n<function_response> is the response wrapper; close it with </function_response>.';
698+
699+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe("\n.");
700+
});
701+
702+
it("strips adjacent function_response payloads that match explanation wording", () => {
703+
const input =
704+
'<function_calls><invoke name="exec">internal</invoke></function_calls><function_response> response wrapper secret</function_response>\nAfter';
705+
706+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe("\nAfter");
707+
});
708+
709+
it("strips compact function_response wrappers while preserving same-line prose tails", () => {
710+
const input =
711+
'<tool_call>{"name":"exec"}</tool_call>\n\n<function_response>ok</function_response> is the response wrapper.';
712+
713+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe(
714+
"\n\n is the response wrapper.",
715+
);
716+
});
717+
718+
it("strips chained function_response blocks adjacent to a stripped function_calls block", () => {
719+
const input = [
720+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls><function_response>',
721+
"first result",
722+
"</function_response><function_response>",
723+
"second result",
724+
"</function_response>",
725+
"After",
726+
].join("\n");
727+
728+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe(
729+
"Checking. \nAfter",
730+
);
731+
});
732+
733+
it("strips compact chained function_response blocks adjacent to a stripped function_calls block", () => {
734+
const input =
735+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls><function_response>first</function_response><function_response>second</function_response>\nAfter';
736+
737+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe(
738+
"Checking. \nAfter",
739+
);
740+
});
741+
742+
it("strips compact function_response before same-line visible replies", () => {
743+
const input =
744+
'Checking. <function_calls><invoke name="exec">internal</invoke></function_calls><function_response>raw</function_response> Done.';
745+
746+
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe(
747+
"Checking. Done.",
748+
);
749+
});
592750
});
593751

594752
describe("stripMinimaxToolCallXml", () => {

0 commit comments

Comments
 (0)