fix: bound aggregate prompt tool results

Bound aggregate tool-result history at the provider prompt boundary without rewriting persisted session entries.

Provider-visible prompt history now trims older aggregate tool results before newer evidence, while canonical session history, slash/extension command handlers, and context-engine afterTurn snapshots stay unmodified.

Co-authored-by: luyifan <al3060388206@gmail.com>
This commit is contained in:
ooiuuii
2026-05-28 21:25:02 +01:00
committed by GitHub
parent 4cbce8458d
commit f49a3e4c26
4 changed files with 240 additions and 30 deletions
@@ -66,6 +66,32 @@ function requireRecords(value: unknown, label: string): Array<Record<string, unk
return value as Array<Record<string, unknown>>;
}
function sumToolResultTextChars(messages: AgentMessage[]): number {
return messages.reduce((sum, message) => {
if (message.role !== "toolResult") {
return sum;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) {
return sum;
}
return (
sum +
content.reduce((blockSum, block) => {
if (
block &&
typeof block === "object" &&
(block as { type?: unknown }).type === "text" &&
typeof (block as { text?: unknown }).text === "string"
) {
return blockSum + (block as { text: string }).text.length;
}
return blockSum;
}, 0)
);
}, 0);
}
function findRecord(
records: Array<Record<string, unknown>>,
predicate: (record: Record<string, unknown>) => boolean,
@@ -2376,4 +2402,84 @@ describe("runEmbeddedAttempt tool-result guard budget wiring", () => {
.contextWindowTokens,
).toBe(1_000_000);
});
it("bounds aggregate tool-result prompt history without rewriting append results", async () => {
const toolText = "process output ".repeat(70);
const sessionMessages: AgentMessage[] = [{ role: "user", content: "seed", timestamp: 1 }];
for (let index = 0; index < 8; index += 1) {
const toolCallId = `call_${index}`;
sessionMessages.push({
role: "assistant",
content: [{ type: "toolCall", id: toolCallId, name: "process", input: {} }],
timestamp: 2 + index * 2,
} as unknown as AgentMessage);
sessionMessages.push({
role: "toolResult",
toolCallId,
toolName: "process",
content: [{ type: "text", text: `${index}: ${toolText}` }],
isError: false,
timestamp: 3 + index * 2,
} as AgentMessage);
}
let submittedMessages: AgentMessage[] = [];
let promptHandlerMessages: AgentMessage[] = [];
let afterTurnMessages: AgentMessage[] = [];
const afterTurn = vi.fn(async ({ messages }: { messages: AgentMessage[] }) => {
afterTurnMessages = messages;
});
await createContextEngineAttemptRunner({
contextEngine: {
...createContextEngineBootstrapAndAssemble(),
afterTurn,
},
sessionKey,
tempPaths,
sessionMessages,
attemptOverrides: {
contextTokenBudget: 128_000,
config: {
agents: {
defaults: {
contextLimits: {
toolResultMaxChars: 1_000,
},
},
list: [{ id: "main" }],
},
} as OpenClawConfig,
},
createSession: () => {
const session = createDefaultEmbeddedSession({ initialMessages: sessionMessages });
session.agent.streamFn = async (_model, context) => {
const providerMessages = (context as { messages?: AgentMessage[] } | undefined)?.messages;
submittedMessages = providerMessages ?? [];
return {
async result() {
return doneMessage;
},
[Symbol.asyncIterator]() {
return (async function* () {})();
},
};
};
session.prompt = async (_prompt, options) => {
promptHandlerMessages = session.messages.map((message) => message as AgentMessage);
options?.preflightResult?.(true);
await session.agent.streamFn?.({} as never, { messages: session.messages } as never, {});
session.messages = [...session.messages, doneMessage];
};
return session;
},
});
expect(sumToolResultTextChars(sessionMessages)).toBeGreaterThan(4_000);
expect(sumToolResultTextChars(promptHandlerMessages)).toBeGreaterThan(4_000);
expect(sumToolResultTextChars(submittedMessages)).toBeLessThanOrEqual(4_000);
expect(JSON.stringify(submittedMessages)).toContain("truncated");
expect(afterTurn).toHaveBeenCalledTimes(1);
expect(sumToolResultTextChars(afterTurnMessages)).toBeGreaterThan(4_000);
expect(JSON.stringify(afterTurnMessages)).not.toContain("truncated");
});
});
+56 -11
View File
@@ -179,9 +179,7 @@ import {
buildEmptyExplicitToolAllowlistError,
collectExplicitToolAllowlistSources,
} from "../../tool-allowlist-guard.js";
import {
filterRuntimeCompatibleTools,
} from "../../tool-schema-projection.js";
import { filterRuntimeCompatibleTools } from "../../tool-schema-projection.js";
import { logRuntimeToolSchemaQuarantine } from "../../tool-schema-quarantine.js";
import {
addClientToolsToToolSearchCatalog,
@@ -266,6 +264,7 @@ import {
} from "../tool-result-context-guard.js";
import {
resolveLiveToolResultMaxChars,
truncateOversizedToolResultsInMessages,
truncateOversizedToolResultsInSessionManager,
} from "../tool-result-truncation.js";
import { splitSdkTools } from "../tool-split.js";
@@ -460,6 +459,7 @@ export {
};
const MAX_BTW_SNAPSHOT_MESSAGES = 100;
const PROMPT_TOOL_RESULT_AGGREGATE_CAP_MULTIPLIER = 4;
function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } {
const content = (msg as { content?: unknown }).content;
@@ -3434,6 +3434,31 @@ export async function runEmbeddedAttempt(
activeSession.agent.state.messages = filteredMessages;
}
prePromptMessageCount = activeSession.messages.length;
const contextTokenBudget = params.contextTokenBudget ?? DEFAULT_CONTEXT_TOKENS;
const promptToolResultMaxChars = resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: params.config,
agentId: sessionAgentId,
});
let promptHistoryMessages = activeSession.messages;
const promptToolResultTruncation = truncateOversizedToolResultsInMessages(
activeSession.messages,
contextTokenBudget,
promptToolResultMaxChars,
promptToolResultMaxChars * PROMPT_TOOL_RESULT_AGGREGATE_CAP_MULTIPLIER,
);
if (promptToolResultTruncation.truncatedCount > 0) {
promptHistoryMessages = promptToolResultTruncation.messages;
log.info(
`[tool-result-truncation] Truncated ${promptToolResultTruncation.truncatedCount} ` +
`tool result(s) for prompt history ` +
`(maxChars=${promptToolResultMaxChars} ` +
`aggregateBudgetChars=${
promptToolResultMaxChars * PROMPT_TOOL_RESULT_AGGREGATE_CAP_MULTIPLIER
}) ` +
`sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`,
);
}
const promptSubmission = resolveRuntimeContextPromptParts({
effectivePrompt: promptForRuntimeContextSplit,
@@ -3470,8 +3495,8 @@ export async function runEmbeddedAttempt(
const runtimeContextMessageForCurrentTurn =
buildRuntimeContextCustomMessage(runtimeContextForHook);
const messagesForCurrentPrompt = runtimeContextMessageForCurrentTurn
? [...activeSession.messages, runtimeContextMessageForCurrentTurn]
: activeSession.messages;
? [...promptHistoryMessages, runtimeContextMessageForCurrentTurn]
: promptHistoryMessages;
const hookMessagesForCurrentPrompt = normalizeMessagesForCurrentPromptBoundary({
messages: messagesForCurrentPrompt,
prompt: promptForModel,
@@ -3705,7 +3730,6 @@ export async function runEmbeddedAttempt(
const promptLen = effectivePrompt.length;
const sessionSummary = summarizeSessionContext(activeSession.messages);
const reserveTokens = settingsManager.getCompactionReserveTokens();
const contextTokenBudget = params.contextTokenBudget ?? DEFAULT_CONTEXT_TOKENS;
emitTrustedDiagnosticEvent({
type: "context.assembled",
runId: params.runId,
@@ -3789,11 +3813,7 @@ export async function runEmbeddedAttempt(
prompt: promptForModel,
contextTokenBudget,
reserveTokens,
toolResultMaxChars: resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: params.config,
agentId: sessionAgentId,
}),
toolResultMaxChars: promptToolResultMaxChars,
});
if (preemptiveCompaction) {
contextBudgetStatus = buildPrePromptContextBudgetStatus({
@@ -3901,6 +3921,29 @@ export async function runEmbeddedAttempt(
if (normalizedReplayMessages !== activeSession.messages) {
activeSession.agent.state.messages = normalizedReplayMessages;
}
const installProviderPromptHistoryTransform = (): (() => void) => {
const baseStreamFn = activeSession.agent.streamFn;
const providerPromptStreamFn = wrapStreamFnWithMessageTransform(
baseStreamFn,
(messages) => {
const providerPromptHistoryTruncation = truncateOversizedToolResultsInMessages(
messages,
contextTokenBudget,
promptToolResultMaxChars,
promptToolResultMaxChars * PROMPT_TOOL_RESULT_AGGREGATE_CAP_MULTIPLIER,
);
return providerPromptHistoryTruncation.truncatedCount > 0
? providerPromptHistoryTruncation.messages
: messages;
},
);
activeSession.agent.streamFn = providerPromptStreamFn;
return () => {
if (activeSession.agent.streamFn === providerPromptStreamFn) {
activeSession.agent.streamFn = baseStreamFn;
}
};
};
finalPromptText = promptForSession;
trajectoryRecorder?.recordEvent("prompt.submitted", {
prompt: promptForModel,
@@ -3928,6 +3971,7 @@ export async function runEmbeddedAttempt(
captureCurrentPromptForModel = true;
}
};
const cleanupProviderPromptHistoryTransform = installProviderPromptHistoryTransform();
try {
if (promptSubmission.runtimeOnly) {
await promptActiveSession(promptForSession, {
@@ -3956,6 +4000,7 @@ export async function runEmbeddedAttempt(
}
}
} finally {
cleanupProviderPromptHistoryTransform();
cleanupModelPromptTransform();
}
}
@@ -422,6 +422,37 @@ describe("truncateOversizedToolResultsInMessages", () => {
expect(text.length).toBeLessThan(500_000);
}
});
it("bounds aggregate tool-result text in prompt history without rewriting callers", () => {
const medium = "alpha beta gamma delta epsilon ".repeat(800);
const messages: AgentMessage[] = [
makeUserMessage("hello"),
makeAssistantMessage("calling tools"),
makeToolResult(medium, "call_1"),
makeToolResult(medium, "call_2"),
makeToolResult(medium, "call_3"),
];
const { messages: result, truncatedCount } = truncateOversizedToolResultsInMessages(
messages,
128_000,
12_000,
12_000,
);
const totalChars = result.reduce(
(sum, message) =>
sum + (message.role === "toolResult" ? getToolResultTextLength(message) : 0),
0,
);
expect(truncatedCount).toBeGreaterThan(0);
expect(totalChars).toBeLessThanOrEqual(12_000);
expect(result[0]).toBe(messages[0]);
expect(result[1]).toBe(messages[1]);
expect(messages.reduce((sum, message) => sum + getToolResultTextLength(message), 0)).toBe(
medium.length * 3,
);
});
});
describe("truncateOversizedToolResultsInSession", () => {
@@ -490,7 +521,7 @@ describe("truncateOversizedToolResultsInSession", () => {
).toBe(false);
});
it("prefers truncating newer aggregate tool-result entries before older larger ones", async () => {
it("prefers truncating older aggregate tool-result entries before newer results", async () => {
const dir = await createTmpDir();
const sm = SessionManager.create(dir, dir);
sm.appendMessage(makeUserMessage("hello"));
@@ -526,9 +557,9 @@ describe("truncateOversizedToolResultsInSession", () => {
entry.type === "message" ? getFirstToolResultText(entry.message) : "",
);
expect(afterTexts[0]).toBe(beforeTexts[0]);
expect(afterTexts[1]).not.toBe(beforeTexts[1]);
expect(afterTexts[1]).toContain("truncated");
expect(afterTexts[0]).not.toBe(beforeTexts[0]);
expect(afterTexts[0]).toContain("truncated");
expect(afterTexts[1]).toBe(beforeTexts[1]);
});
it("allows persisted-session recovery truncation to shrink below the old 2k floor", async () => {
@@ -336,33 +336,51 @@ export function truncateOversizedToolResultsInMessages(
messages: AgentMessage[],
contextWindowTokens: number,
maxCharsOverride?: number,
aggregateMaxCharsOverride?: number,
): { messages: AgentMessage[]; truncatedCount: number } {
const maxChars = Math.max(
1,
maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens),
);
let truncatedCount = 0;
const result = messages.map((msg) => {
if ((msg as { role?: string }).role !== "toolResult") {
return msg;
}
const textLength = getToolResultTextLength(msg);
if (textLength <= maxChars) {
return msg;
}
truncatedCount++;
return truncateToolResultMessage(msg, maxChars);
const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars(
contextWindowTokens,
maxChars,
aggregateMaxCharsOverride,
);
const branch = messages.map((message, index) => ({
id: `message-${index}`,
type: "message",
message,
}));
const plan = buildToolResultReplacementPlan({
branch,
maxChars,
aggregateBudgetChars,
minKeepChars: RECOVERY_MIN_KEEP_CHARS,
});
if (plan.replacements.length === 0) {
return { messages, truncatedCount: 0 };
}
return { messages: result, truncatedCount };
const replacementIds = new Set(plan.replacements.map((replacement) => replacement.entryId));
const replacedBranch = applyToolResultReplacementsToBranch(branch, plan.replacements);
return {
messages: replacedBranch.map((entry) => entry.message as AgentMessage),
truncatedCount: replacementIds.size,
};
}
function calculateRecoveryAggregateToolResultChars(
contextWindowTokens: number,
maxCharsOverride?: number,
aggregateMaxCharsOverride?: number,
): number {
return Math.max(1, maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens));
return Math.max(
1,
aggregateMaxCharsOverride ??
maxCharsOverride ??
calculateMaxToolResultChars(contextWindowTokens),
);
}
export type ToolResultReductionPotential = {
@@ -433,9 +451,10 @@ function buildAggregateToolResultReplacements(params: {
let remainingReduction = totalChars - params.aggregateBudgetChars;
const replacements: Array<{ entryId: string; message: AgentMessage }> = [];
// Spend aggregate reduction on older entries first so fresh tool output stays intact.
for (const candidate of candidates.toSorted((a, b) => {
if (a.index !== b.index) {
return b.index - a.index;
return a.index - b.index;
}
return b.textLength - a.textLength;
})) {
@@ -590,6 +609,7 @@ export function estimateToolResultReductionPotential(params: {
messages: AgentMessage[];
contextWindowTokens: number;
maxCharsOverride?: number;
aggregateMaxCharsOverride?: number;
}): ToolResultReductionPotential {
const { messages, contextWindowTokens } = params;
const maxChars = Math.max(
@@ -599,6 +619,7 @@ export function estimateToolResultReductionPotential(params: {
const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars(
contextWindowTokens,
maxChars,
params.aggregateMaxCharsOverride,
);
const branch = messages.map((message, index) => ({
id: `message-${index}`,
@@ -643,6 +664,7 @@ function truncateOversizedToolResultsInExistingSessionManager(params: {
sessionManager: SessionManager;
contextWindowTokens: number;
maxCharsOverride?: number;
aggregateMaxCharsOverride?: number;
sessionFile?: string;
sessionId?: string;
sessionKey?: string;
@@ -655,6 +677,7 @@ function truncateOversizedToolResultsInExistingSessionManager(params: {
const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars(
contextWindowTokens,
maxChars,
params.aggregateMaxCharsOverride,
);
const branch = sessionManager.getBranch() as ToolResultBranchEntry[];
@@ -705,6 +728,7 @@ async function truncateOversizedToolResultsInTranscriptState(params: {
sessionFile: string;
contextWindowTokens: number;
maxCharsOverride?: number;
aggregateMaxCharsOverride?: number;
sessionId?: string;
sessionKey?: string;
config?: SessionWriteLockAcquireTimeoutConfig;
@@ -717,6 +741,7 @@ async function truncateOversizedToolResultsInTranscriptState(params: {
const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars(
contextWindowTokens,
maxChars,
params.aggregateMaxCharsOverride,
);
const branch = state.getBranch() as ToolResultBranchEntry[];
@@ -771,6 +796,7 @@ export function truncateOversizedToolResultsInSessionManager(params: {
sessionManager: SessionManager;
contextWindowTokens: number;
maxCharsOverride?: number;
aggregateMaxCharsOverride?: number;
sessionFile?: string;
sessionId?: string;
sessionKey?: string;
@@ -788,6 +814,7 @@ export async function truncateOversizedToolResultsInSession(params: {
sessionFile: string;
contextWindowTokens: number;
maxCharsOverride?: number;
aggregateMaxCharsOverride?: number;
sessionId?: string;
sessionKey?: string;
config?: SessionWriteLockAcquireTimeoutConfig;
@@ -805,6 +832,7 @@ export async function truncateOversizedToolResultsInSession(params: {
state,
contextWindowTokens,
maxCharsOverride: params.maxCharsOverride,
aggregateMaxCharsOverride: params.aggregateMaxCharsOverride,
sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey,