mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 19:01:42 +00:00
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:
@@ -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[] = [];
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user