mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
fix(agents): keep live tool-result prompts cache-stable
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+22
-4
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user