fix(context-pruning): cover image-only tool-result pruning

This commit is contained in:
Frank Yang
2026-03-11 17:27:42 +08:00
parent a78674f115
commit d68d4362ee
4 changed files with 56 additions and 8 deletions

View File

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

View File

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

View File

@@ -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<AgentMessage, { role: "toolResult" }>;
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"),

View File

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