fix(agents): keep live tool-result prompts cache-stable

This commit is contained in:
Vincent Koc
2026-06-21 21:54:31 +08:00
parent ac5d219be3
commit a60947fb3e
8 changed files with 84 additions and 23 deletions
+4 -3
View File
@@ -249,9 +249,10 @@ Shared defaults for bounded runtime context surfaces.
- `toolResultMaxChars`: advanced live tool-result ceiling used for persisted
results and overflow recovery. Leave unset for the model-context auto cap:
`16000` chars below 100K tokens, `32000` chars at 100K+ tokens, and `64000`
chars at 200K+ tokens. The effective cap is still limited to about 30% of the
model context window. `openclaw doctor --deep` prints the effective cap, and
doctor warns only when an explicit override is stale or has no effect.
chars at 200K+ tokens. Explicit values up to `1000000` are accepted for
long-context models, but the effective cap is still limited to about 30% of
the model context window. `openclaw doctor --deep` prints the effective cap,
and doctor warns only when an explicit override is stale or has no effect.
- `postCompactionMaxChars`: AGENTS.md excerpt cap used during post-compaction
refresh injection.
+1 -1
View File
@@ -54,7 +54,7 @@ for bounded runtime excerpts and injected runtime-owned blocks. They are
separate from bootstrap limits, startup-context limits, and skills prompt
limits.
`toolResultMaxChars` is an advanced ceiling. When it is unset, OpenClaw chooses
`toolResultMaxChars` is an advanced ceiling (up to `1000000` characters). When it is unset, OpenClaw chooses
the live tool-result cap from the effective model context window: `16000` chars
below 100K tokens, `32000` chars at 100K+ tokens, and `64000` chars at 200K+
tokens, still bounded by the runtime context-share guard.
@@ -2840,7 +2840,7 @@ describe("runEmbeddedAttempt tool-result guard budget wiring", () => {
).toBe(1_000_000);
});
it("bounds aggregate tool-result prompt history without rewriting append results", async () => {
it("preserves the cacheable prefix while bounding current prompt 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) {
@@ -2880,7 +2880,7 @@ describe("runEmbeddedAttempt tool-result guard budget wiring", () => {
agents: {
defaults: {
contextLimits: {
toolResultMaxChars: 1_000,
toolResultMaxChars: 2_000,
},
},
list: [{ id: "main" }],
@@ -2902,6 +2902,18 @@ describe("runEmbeddedAttempt tool-result guard budget wiring", () => {
};
};
session.prompt = async (_prompt, options) => {
for (let index = 0; index < 8; index += 1) {
session.messages.push({
role: "toolResult",
toolCallId: `current_call_${index}`,
toolName: "process",
content: [
{ type: "text", text: `current ${index}: ${"current output ".repeat(300)}` },
],
isError: false,
timestamp: 100 + index,
} as AgentMessage);
}
promptHandlerMessages = session.messages.map((message) => message as AgentMessage);
options?.preflightResult?.(true);
await session.agent.streamFn?.({} as never, { messages: session.messages } as never, {});
@@ -2913,8 +2925,14 @@ describe("runEmbeddedAttempt tool-result guard budget wiring", () => {
expect(sumToolResultTextChars(sessionMessages)).toBeGreaterThan(4_000);
expect(sumToolResultTextChars(promptHandlerMessages)).toBeGreaterThan(4_000);
expect(sumToolResultTextChars(submittedMessages)).toBeLessThanOrEqual(4_000);
expect(JSON.stringify(submittedMessages)).toContain("truncated");
const submittedCurrentPromptMessages = submittedMessages.slice(sessionMessages.length);
expect(submittedMessages.slice(0, sessionMessages.length)).toEqual(sessionMessages);
expect(
submittedCurrentPromptMessages
.filter((message) => message.role === "toolResult")
.every((message) => sumToolResultTextChars([message]) <= 2_000),
).toBe(true);
expect(JSON.stringify(submittedCurrentPromptMessages)).toContain("truncated");
expect(afterTurn).toHaveBeenCalledTimes(1);
expect(sumToolResultTextChars(afterTurnMessages)).toBeGreaterThan(4_000);
expect(JSON.stringify(afterTurnMessages)).not.toContain("truncated");
@@ -542,8 +542,6 @@ export {
};
const MAX_BTW_SNAPSHOT_MESSAGES = 100;
const PROMPT_TOOL_RESULT_AGGREGATE_CAP_MULTIPLIER = 4;
function pluginMetadataSnapshotCoversProvider(
snapshot: PluginMetadataSnapshot | undefined,
provider: string,
@@ -4176,17 +4174,14 @@ export async function runEmbeddedAttempt(
activeSession.messages,
contextTokenBudget,
promptToolResultMaxChars,
promptToolResultMaxChars * PROMPT_TOOL_RESULT_AGGREGATE_CAP_MULTIPLIER,
null,
);
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
}) ` +
`(maxChars=${promptToolResultMaxChars}) ` +
`sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`,
);
}
@@ -4717,7 +4712,7 @@ export async function runEmbeddedAttempt(
messages,
contextTokenBudget,
promptToolResultMaxChars,
promptToolResultMaxChars * PROMPT_TOOL_RESULT_AGGREGATE_CAP_MULTIPLIER,
null,
);
return providerPromptHistoryTruncation.truncatedCount > 0
? providerPromptHistoryTruncation.messages
@@ -437,6 +437,34 @@ describe("truncateOversizedToolResultsInMessages", () => {
medium.length * 3,
);
});
it("keeps prompt projections byte-stable as history grows", () => {
const prefix = [
makeToolResult("p".repeat(15_000), "prefix_1"),
makeToolResult("q".repeat(15_000), "prefix_2"),
];
const suffix = [
makeToolResult("x".repeat(15_000), "current_1"),
makeToolResult("y".repeat(15_000), "current_2"),
];
const messages = [...prefix, ...suffix];
const first = truncateOversizedToolResultsInMessages(messages, 128_000, 12_000, null);
const second = truncateOversizedToolResultsInMessages(
[...messages, makeToolResult("z".repeat(15_000), "current_3")],
128_000,
12_000,
null,
);
expect(first.truncatedCount).toBe(4);
expect(second.truncatedCount).toBe(5);
expect(second.messages.slice(0, messages.length)).toEqual(first.messages);
expect(second.messages.every((message) => getToolResultTextLength(message) <= 12_000)).toBe(
true,
);
expect(messages).toEqual([...prefix, ...suffix]);
});
});
describe("truncateOversizedToolResultsInSession", () => {
@@ -338,17 +338,22 @@ export function truncateOversizedToolResultsInMessages(
messages: AgentMessage[],
contextWindowTokens: number,
maxCharsOverride?: number,
aggregateMaxCharsOverride?: number,
aggregateMaxCharsOverride?: number | null,
): { messages: AgentMessage[]; truncatedCount: number } {
const maxChars = Math.max(
1,
maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens),
);
const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars(
contextWindowTokens,
maxChars,
aggregateMaxCharsOverride,
);
// Live prompt assembly disables aggregate rewriting so unchanged history stays byte-stable
// for provider prefix caches; recovery and persisted-session callers keep the aggregate guard.
const aggregateBudgetChars =
aggregateMaxCharsOverride === null
? Number.POSITIVE_INFINITY
: calculateRecoveryAggregateToolResultChars(
contextWindowTokens,
maxChars,
aggregateMaxCharsOverride,
);
const branch = messages.map((message, index) => ({
id: `message-${index}`,
type: "message",
@@ -147,6 +147,20 @@ describe("config schema regressions", () => {
expect(res.ok).toBe(false);
});
it("accepts 1M-character tool result caps for long-context agents", () => {
const res = validateConfigObject({
agents: {
defaults: {
contextLimits: {
toolResultMaxChars: 1_000_000,
},
},
},
});
expect(res.ok).toBe(true);
});
it("accepts agents.defaults and agents.list contextLimits overrides", () => {
const res = validateConfigObject({
agents: {
+1 -1
View File
@@ -297,7 +297,7 @@ export const AgentContextLimitsSchema = z
.object({
memoryGetMaxChars: z.number().int().min(1).max(250_000).optional(),
memoryGetDefaultLines: z.number().int().min(1).max(5_000).optional(),
toolResultMaxChars: z.number().int().min(1).max(250_000).optional(),
toolResultMaxChars: z.number().int().min(1).max(1_000_000).optional(),
postCompactionMaxChars: z.number().int().min(1).max(50_000).optional(),
})
.strict()