mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user