From a60947fb3e92f45ea7eb2581da8877b10a8bebb2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 21 Jun 2026 21:54:31 +0800 Subject: [PATCH] fix(agents): keep live tool-result prompts cache-stable --- docs/gateway/config-agents.md | 7 +++-- docs/reference/token-use.md | 2 +- ...mpt.spawn-workspace.context-engine.test.ts | 26 ++++++++++++++--- .../embedded-agent-runner/run/attempt.ts | 11 ++------ .../tool-result-truncation.test.ts | 28 +++++++++++++++++++ .../tool-result-truncation.ts | 17 +++++++---- src/config/config.schema-regressions.test.ts | 14 ++++++++++ src/config/zod-schema.agent-runtime.ts | 2 +- 8 files changed, 84 insertions(+), 23 deletions(-) diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index de2597253cd4..43c9f53451b7 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -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. diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 1ed632617992..801c00846b58 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -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. diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts index 6ce0b2b8e57c..68d834e5ac19 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -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"); diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 077593828b15..eae686a64ba0 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -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 diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.test.ts b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts index cf9d51c62782..db91f7095b57 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.test.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts @@ -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", () => { diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.ts b/src/agents/embedded-agent-runner/tool-result-truncation.ts index 848b3d5f0d54..464e27a45bec 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.ts @@ -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", diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 45710d212399..c0814058a774 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -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: { diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 69f1066f01b0..141e897dbbd0 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -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()