From d68d4362ee9946abecf090e8b27b51ab68c5510a Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Wed, 11 Mar 2026 17:27:42 +0800 Subject: [PATCH] fix(context-pruning): cover image-only tool-result pruning --- CHANGELOG.md | 1 + .../pi-extensions/context-pruning.test.ts | 15 +++++--- .../context-pruning/pruner.test.ts | 35 +++++++++++++++++++ .../pi-extensions/context-pruning/pruner.ts | 13 +++++-- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aba53517973..0c0a488e44b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai - Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting. - Telegram/final preview delivery followup: keep ambiguous missing-`message_id` finals only when a preview was already visible, while first-preview/no-id cases still fall back so Telegram users do not lose the final reply. (#41932) thanks @hougangdev. - Agents/Azure OpenAI Responses: include the `azure-openai` provider in the Responses API store override so Azure OpenAI multi-turn cron jobs and embedded agent runs no longer fail with HTTP 400 "store is set to false". (#42934, fixes #42800) Thanks @ademczuk. +- Agents/context pruning: prune image-only tool results during soft-trim, align context-pruning coverage with the new tool-result contract, and extend historical image cleanup to the same screenshot-heavy session path. (#42212) Thanks @MoerAI. ## 2026.3.8 diff --git a/src/agents/pi-extensions/context-pruning.test.ts b/src/agents/pi-extensions/context-pruning.test.ts index 7812f5db00a..9dedff97def 100644 --- a/src/agents/pi-extensions/context-pruning.test.ts +++ b/src/agents/pi-extensions/context-pruning.test.ts @@ -358,21 +358,26 @@ describe("context-pruning", () => { expect(toolText(findToolResult(next, "t2"))).toContain("y".repeat(20_000)); }); - it("skips tool results that contain images (no soft trim, no hard clear)", () => { + it("replaces image blocks in tool results during soft trim", () => { const messages: AgentMessage[] = [ makeUser("u1"), makeImageToolResult({ toolCallId: "t1", toolName: "exec", - text: "x".repeat(20_000), + text: "visible tool text", }), ]; - const next = pruneWithAggressiveDefaults(messages); + const next = pruneWithAggressiveDefaults(messages, { + hardClearRatio: 10.0, + hardClear: { enabled: false, placeholder: "[cleared]" }, + softTrim: { maxChars: 200, headChars: 100, tailChars: 100 }, + }); const tool = findToolResult(next, "t1"); - expect(tool.content.some((b) => b.type === "image")).toBe(true); - expect(toolText(tool)).toContain("x".repeat(20_000)); + expect(tool.content.some((b) => b.type === "image")).toBe(false); + expect(toolText(tool)).toContain("[image removed during context pruning]"); + expect(toolText(tool)).toContain("visible tool text"); }); it("soft-trims across block boundaries", () => { diff --git a/src/agents/pi-extensions/context-pruning/pruner.test.ts b/src/agents/pi-extensions/context-pruning/pruner.test.ts index 57a5c9f50f7..a847bff0e8c 100644 --- a/src/agents/pi-extensions/context-pruning/pruner.test.ts +++ b/src/agents/pi-extensions/context-pruning/pruner.test.ts @@ -165,6 +165,41 @@ describe("pruneContextMessages", () => { ); }); + it("replaces image-only tool results with placeholders even when text trimming is not needed", () => { + const messages: AgentMessage[] = [ + makeUser("summarize this"), + makeToolResult([{ type: "image", data: "img", mimeType: "image/png" }]), + makeAssistant([{ type: "text", text: "done" }]), + ]; + + const result = pruneContextMessages({ + messages, + settings: { + ...DEFAULT_CONTEXT_PRUNING_SETTINGS, + keepLastAssistants: 1, + softTrimRatio: 0, + hardClearRatio: 10, + hardClear: { + ...DEFAULT_CONTEXT_PRUNING_SETTINGS.hardClear, + enabled: false, + }, + softTrim: { + maxChars: 5_000, + headChars: 2_000, + tailChars: 2_000, + }, + }, + ctx: CONTEXT_WINDOW_1M, + isToolPrunable: () => true, + contextWindowTokensOverride: 1, + }); + + const toolResult = result[1] as Extract; + expect(toolResult.content).toEqual([ + { type: "text", text: "[image removed during context pruning]" }, + ]); + }); + it("hard-clears image-containing tool results once ratios require clearing", () => { const messages: AgentMessage[] = [ makeUser("summarize this"), diff --git a/src/agents/pi-extensions/context-pruning/pruner.ts b/src/agents/pi-extensions/context-pruning/pruner.ts index 0bb24b5b2a7..a0f4458f6d4 100644 --- a/src/agents/pi-extensions/context-pruning/pruner.ts +++ b/src/agents/pi-extensions/context-pruning/pruner.ts @@ -205,18 +205,25 @@ function softTrimToolResultMessage(params: { settings: EffectiveContextPruningSettings; }): ToolResultMessage | null { const { msg, settings } = params; - const parts = hasImageBlocks(msg.content) + const hasImages = hasImageBlocks(msg.content); + const parts = hasImages ? collectPrunableToolResultSegments(msg.content) : collectTextSegments(msg.content); const rawLen = estimateJoinedTextLength(parts); if (rawLen <= settings.softTrim.maxChars) { - return null; + if (!hasImages) { + return null; + } + return { ...msg, content: [asText(parts.join("\n"))] }; } const headChars = Math.max(0, settings.softTrim.headChars); const tailChars = Math.max(0, settings.softTrim.tailChars); if (headChars + tailChars >= rawLen) { - return null; + if (!hasImages) { + return null; + } + return { ...msg, content: [asText(parts.join("\n"))] }; } const head = takeHeadFromJoinedText(parts, headChars);