From abd6cbbae1b1bb28ec86d778c79e45ede4aa5cbd Mon Sep 17 00:00:00 2001 From: Qiyuan Zhang Date: Sun, 12 Jul 2026 03:35:19 +0800 Subject: [PATCH] fix: preserve tool-result text across media fallback and context pressure (#99756) * fix: keep text tool results ahead of stale media placeholders * fix(agents): preserve fresh results before runtime context * fix(agents): defer fresh results during aggregate recovery * fix(google): preserve omitted media fallback * fix(openai): preserve omitted media fallback * fix(agents): narrow tool-result recovery scope * fix(agents): keep one fresh-result recovery path * chore: keep sweep changelog neutral --------- Co-authored-by: AcosX Co-authored-by: Acos_X Co-authored-by: Colin Johnson Co-authored-by: Peter Steinberger --- .../tool-result-truncation.test.ts | 202 ++++++++++++++++++ .../tool-result-truncation.ts | 29 ++- 2 files changed, 226 insertions(+), 5 deletions(-) diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.test.ts b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts index 38c9346495f4..38bc337d9b68 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.test.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts @@ -7,9 +7,11 @@ import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import type { AssistantMessage, ToolResultMessage, UserMessage } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { convertToLlm } from "../../../packages/agent-core/src/harness/messages.js"; import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { formatFullOutputFooter } from "../sessions/tools/tool-contracts.js"; import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; +import { buildRuntimeContextCustomMessage } from "./run/runtime-context-prompt.js"; import { getEmbeddedSessionPromptState, testing as sessionPromptStateTesting, @@ -724,6 +726,206 @@ describe("truncateOversizedToolResultsInMessages", () => { expect(totalChars).toBeLessThanOrEqual(32_000); }); + it("preserves fresh tool results through a trailing runtime context carrier", () => { + const projectionState = createToolResultPromptProjectionState(); + const history: AgentMessage[] = []; + for (let index = 0; index < 50; index++) { + history.push(makeAssistantMessage(`call ${index}`)); + history.push(makeToolResult("x".repeat(4_000), `history_${index}`)); + } + history.push(makeUserMessage("run echo with extra context")); + + const first = truncateOversizedToolResultsInMessages( + history, + 1_000_000, + 8_000, + 32_000, + projectionState, + ); + expect(first.truncatedCount).toBeGreaterThan(0); + + const freshOutput = "OC99756_EXEC_MARKER_".padEnd(4_000, "x"); + const runtimeContextMessage = buildRuntimeContextCustomMessage("runtime context refresh"); + if (!runtimeContextMessage) { + throw new Error("expected runtime context message"); + } + const providerMessages = convertToLlm([ + ...history, + makeAssistantMessage("running exec"), + makeToolResult(freshOutput, "fresh_exec"), + runtimeContextMessage, + ] as AgentMessage[]) as AgentMessage[]; + const providerCarrier = providerMessages.at(-1) as + | (AgentMessage & { runtimeContextCarrier?: boolean }) + | undefined; + expect(providerCarrier?.runtimeContextCarrier).toBe(true); + + const second = truncateOversizedToolResultsInMessages( + providerMessages, + 1_000_000, + 8_000, + 32_000, + projectionState, + ); + + const freshResult = second.messages.find( + (message): message is ToolResultMessage => + message.role === "toolResult" && message.toolCallId === "fresh_exec", + ); + const historicalResults = second.messages.filter( + (message): message is ToolResultMessage => + message.role === "toolResult" && message.toolCallId.startsWith("history_"), + ); + const firstHistoricalLengths = new Map( + first.messages.flatMap((message) => + message.role === "toolResult" && message.toolCallId.startsWith("history_") + ? [[message.toolCallId, getToolResultTextLength(message)] as const] + : [], + ), + ); + const totalChars = second.messages.reduce( + (sum, message) => + sum + (message.role === "toolResult" ? getToolResultTextLength(message) : 0), + 0, + ); + expect(freshResult && getFirstToolResultText(freshResult)).toBe(freshOutput); + expect( + historicalResults.some( + (message) => + getToolResultTextLength(message) < + (firstHistoricalLengths.get(message.toolCallId) ?? Number.POSITIVE_INFINITY), + ), + ).toBe(true); + expect(second.aggregateTruncatedCount).toBeGreaterThan(0); + expect(second.aggregatePressureEngaged).toBe(true); + expect(totalChars).toBeLessThanOrEqual(32_000); + }); + + it("preserves multiple fresh tool results before queued steering", () => { + const projectionState = createToolResultPromptProjectionState(); + const history: AgentMessage[] = []; + for (let index = 0; index < 50; index++) { + history.push(makeAssistantMessage(`call ${index}`)); + history.push(makeToolResult("x".repeat(4_000), `history_${index}`)); + } + history.push(makeUserMessage("run several commands")); + + const first = truncateOversizedToolResultsInMessages( + history, + 1_000_000, + 8_000, + 32_000, + projectionState, + ); + expect(first.truncatedCount).toBeGreaterThan(0); + + const freshOutputs = [ + "OC99241_SHORT_SENTINEL_".padEnd(234, "s"), + "OC99241_LONG_SENTINEL_".padEnd(4_000, "l"), + ]; + const second = truncateOversizedToolResultsInMessages( + [ + ...history, + makeAssistantMessage("running tools"), + makeToolResult(freshOutputs[0]!, "fresh_short"), + makeToolResult(freshOutputs[1]!, "fresh_long"), + makeUserMessage("queued steering after tool execution"), + ], + 1_000_000, + 8_000, + 32_000, + projectionState, + ); + + const freshResults = second.messages.filter( + (message): message is ToolResultMessage => + message.role === "toolResult" && message.toolCallId.startsWith("fresh_"), + ); + const historicalResults = second.messages.filter( + (message): message is ToolResultMessage => + message.role === "toolResult" && message.toolCallId.startsWith("history_"), + ); + const firstHistoricalLengths = new Map( + first.messages.flatMap((message) => + message.role === "toolResult" && message.toolCallId.startsWith("history_") + ? [[message.toolCallId, getToolResultTextLength(message)] as const] + : [], + ), + ); + const totalChars = second.messages.reduce( + (sum, message) => + sum + (message.role === "toolResult" ? getToolResultTextLength(message) : 0), + 0, + ); + + expect(freshResults.map(getFirstToolResultText)).toEqual(freshOutputs); + expect( + historicalResults.some( + (message) => + getToolResultTextLength(message) < + (firstHistoricalLengths.get(message.toolCallId) ?? Number.POSITIVE_INFINITY), + ), + ).toBe(true); + expect(second.aggregateTruncatedCount).toBeGreaterThan(0); + expect(second.aggregatePressureEngaged).toBe(true); + expect(totalChars).toBeLessThanOrEqual(32_000); + }); + + it("shrinks deferred fresh results when frozen history cannot satisfy the hard cap", () => { + const projectionState = createToolResultPromptProjectionState(); + const history: AgentMessage[] = [ + makeToolResult("a".repeat(4_000), "history_a"), + makeToolResult("b".repeat(4_000), "history_b"), + makeUserMessage("establish a frozen projection baseline"), + ]; + const first = truncateOversizedToolResultsInMessages( + history, + 1_000_000, + 8_000, + 100, + projectionState, + ); + expect(first.aggregatePressureEngaged).toBe(true); + + const freshOutput = "OC99241_HARD_CAP_SENTINEL_".padEnd(4_000, "f"); + const runtimeContextMessage = buildRuntimeContextCustomMessage("hard-cap runtime context"); + if (!runtimeContextMessage) { + throw new Error("expected runtime context message"); + } + const providerMessages = convertToLlm([ + ...history, + makeToolResult(freshOutput, "fresh_hard_cap"), + runtimeContextMessage, + ] as AgentMessage[]) as AgentMessage[]; + expect( + (providerMessages.at(-1) as { runtimeContextCarrier?: boolean } | undefined) + ?.runtimeContextCarrier, + ).toBe(true); + const second = truncateOversizedToolResultsInMessages( + providerMessages, + 1_000_000, + 8_000, + 100, + projectionState, + ); + const freshResult = second.messages.find( + (message): message is ToolResultMessage => + message.role === "toolResult" && message.toolCallId === "fresh_hard_cap", + ); + const freshText = freshResult ? getFirstToolResultText(freshResult) : ""; + const totalChars = second.messages.reduce( + (sum, message) => + sum + (message.role === "toolResult" ? getToolResultTextLength(message) : 0), + 0, + ); + + expect(freshText.length).toBeGreaterThan(0); + expect(freshText.length).toBeLessThan(freshOutput.length); + expect(second.aggregateTruncatedCount).toBeGreaterThan(0); + expect(second.aggregatePressureEngaged).toBe(true); + expect(totalChars).toBeLessThanOrEqual(100); + }); + it("caps oversized fresh trailing tool results without clearing them for aggregate recovery", () => { const projectionState = createToolResultPromptProjectionState(); const history: AgentMessage[] = []; diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.ts b/src/agents/embedded-agent-runner/tool-result-truncation.ts index f9892123c46c..3f8681ee2fa1 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.ts @@ -553,6 +553,7 @@ export function truncateOversizedToolResultsInMessages( const projectionKeys = projectionState ? getToolResultProjectionKeys(messages, projectionState) : []; + const hasFrozenProjectionBaseline = (projectionState?.frozen.size ?? 0) > 0; const branch = messages.map((message, index) => { const projectionKey = projectionKeys[index]; const projectedMessage = projectionKey @@ -576,6 +577,13 @@ export function truncateOversizedToolResultsInMessages( !projectionKey || !projectionState?.frozen.has(projectionKey) || (projectedMessage !== undefined && mergedMessage === message), + // Steering and follow-up messages can follow fresh tool results before dispatch. + // Reduce frozen history first so message position cannot make fresh output disappear. + deferAggregateRecovery: + projectionKey !== undefined && + projectionState !== undefined && + hasFrozenProjectionBaseline && + !projectionState.frozen.has(projectionKey), }; }); const plan = buildToolResultReplacementPlan({ @@ -661,6 +669,7 @@ type ToolResultBranchEntry = { type: string; message?: AgentMessage; aggregateEligible?: boolean; + deferAggregateRecovery?: boolean; }; type ToolResultReplacement = { @@ -808,7 +817,13 @@ function buildAggregateToolResultReplacements(params: { ( item, ): item is { - entry: { id: string; type: string; message: AgentMessage; aggregateEligible?: boolean }; + entry: { + id: string; + type: string; + message: AgentMessage; + aggregateEligible?: boolean; + deferAggregateRecovery?: boolean; + }; index: number; } => item.entry.type === "message" && @@ -822,7 +837,8 @@ function buildAggregateToolResultReplacements(params: { spillSourceMessage: params.spillSourceBranch?.[item.index]?.message ?? item.entry.message, textLength: getToolResultTextLength(item.entry.message), aggregateEligible: item.entry.aggregateEligible !== false, - protectedFromAggregateRecovery: protectedEntryIds.has(item.entry.id), + deferredByFreshProjection: item.entry.deferAggregateRecovery === true, + protectedByTrailingBatch: protectedEntryIds.has(item.entry.id), })) .filter((item) => item.textLength > 0); @@ -845,7 +861,7 @@ function buildAggregateToolResultReplacements(params: { let remainingReduction = totalChars - params.aggregateBudgetChars; const replacements: Array<{ entryId: string; message: AgentMessage }> = []; const aggregateRecoveryCandidates = candidates - .filter((item) => !item.protectedFromAggregateRecovery) + .filter((item) => !item.deferredByFreshProjection && !item.protectedByTrailingBatch) .toSorted((a, b) => { if (a.index !== b.index) { return a.index - b.index; @@ -854,9 +870,12 @@ function buildAggregateToolResultReplacements(params: { }); const recoveryCandidates = [ ...aggregateRecoveryCandidates.filter((item) => item.aggregateEligible), - // Frozen bytes move only after fresh output is exhausted and the hard aggregate - // guard still overflows; starting from the frozen projection makes this shrink-only. + // Start from frozen projections before touching deferred fresh results. Reusing their + // projected text keeps this shrink-only and preserves prompt-cache stability. ...aggregateRecoveryCandidates.filter((item) => !item.aggregateEligible), + ...candidates.filter( + (item) => item.deferredByFreshProjection && !item.protectedByTrailingBatch, + ), ]; // Spend aggregate reduction on older entries first so fresh tool output stays intact.