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 <AcosX@users.noreply.github.com>
Co-authored-by: Acos_X <acos_x@AcosXMacBook-Pro.local>
Co-authored-by: Colin Johnson <colin@solvely.net>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Qiyuan Zhang
2026-07-12 03:35:19 +08:00
committed by GitHub
parent 57e83fb63c
commit abd6cbbae1
2 changed files with 226 additions and 5 deletions

View File

@@ -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[] = [];

View File

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