diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 84676fdfe..8c4c22408 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -837,6 +837,7 @@ This is intended to reduce token usage for chatty agents that accumulate large t High level: - Never touches user/assistant messages. - Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned). +- Protects the bootstrap prefix (nothing before the first user message is pruned). - Modes: - `adaptive`: soft-trims oversized tool results (keep head/tail) when the estimated context ratio crosses `softTrimRatio`. Then hard-clears the oldest eligible tool results when the estimated context ratio crosses `hardClearRatio` **and** diff --git a/src/agents/pi-extensions/context-pruning.test.ts b/src/agents/pi-extensions/context-pruning.test.ts index 403d4735e..3d28c519e 100644 --- a/src/agents/pi-extensions/context-pruning.test.ts +++ b/src/agents/pi-extensions/context-pruning.test.ts @@ -141,6 +141,43 @@ describe("context-pruning", () => { expect(toolText(findToolResult(next, "t4"))).toContain("w".repeat(20_000)); expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]"); }); + + it("never prunes tool results before the first user message", () => { + const settings = computeEffectiveSettings({ + mode: "aggressive", + keepLastAssistants: 0, + hardClear: { placeholder: "[cleared]" }, + }); + if (!settings) throw new Error("expected settings"); + + const messages: AgentMessage[] = [ + makeAssistant("bootstrap tool calls"), + makeToolResult({ + toolCallId: "t0", + toolName: "read", + text: "x".repeat(20_000), + }), + makeAssistant("greeting"), + makeUser("u1"), + makeToolResult({ + toolCallId: "t1", + toolName: "bash", + text: "y".repeat(20_000), + }), + ]; + + const next = pruneContextMessages({ + messages, + settings, + ctx: { model: { contextWindow: 1000 } } as unknown as ExtensionContext, + isToolPrunable: () => true, + contextWindowTokensOverride: 1000, + }); + + expect(toolText(findToolResult(next, "t0"))).toBe("x".repeat(20_000)); + expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]"); + }); + it("mode aggressive clears eligible tool results before cutoff", () => { const messages: AgentMessage[] = [ makeUser("u1"), diff --git a/src/agents/pi-extensions/context-pruning/pruner.ts b/src/agents/pi-extensions/context-pruning/pruner.ts index 0341b2bbf..589cf1bb4 100644 --- a/src/agents/pi-extensions/context-pruning/pruner.ts +++ b/src/agents/pi-extensions/context-pruning/pruner.ts @@ -153,6 +153,13 @@ function findAssistantCutoffIndex( return null; } +function findFirstUserIndex(messages: AgentMessage[]): number | null { + for (let i = 0; i < messages.length; i++) { + if (messages[i]?.role === "user") return i; + } + return null; +} + function softTrimToolResultMessage(params: { msg: ToolResultMessage; settings: EffectiveContextPruningSettings; @@ -207,13 +214,20 @@ export function pruneContextMessages(params: { ); if (cutoffIndex === null) return messages; + // Bootstrap safety: never prune anything before the first user message. This protects initial + // "identity" reads (SOUL.md, USER.md, etc.) which typically happen before the first inbound user + // message exists in the session transcript. + const firstUserIndex = findFirstUserIndex(messages); + const pruneStartIndex = + firstUserIndex === null ? messages.length : firstUserIndex; + const isToolPrunable = params.isToolPrunable ?? makeToolPrunablePredicate(settings.tools); if (settings.mode === "aggressive") { let next: AgentMessage[] | null = null; - for (let i = 0; i < cutoffIndex; i++) { + for (let i = pruneStartIndex; i < cutoffIndex; i++) { const msg = messages[i]; if (!msg || msg.role !== "toolResult") continue; if (!isToolPrunable(msg.toolName)) continue; @@ -248,7 +262,7 @@ export function pruneContextMessages(params: { const prunableToolIndexes: number[] = []; let next: AgentMessage[] | null = null; - for (let i = 0; i < cutoffIndex; i++) { + for (let i = pruneStartIndex; i < cutoffIndex; i++) { const msg = messages[i]; if (!msg || msg.role !== "toolResult") continue; if (!isToolPrunable(msg.toolName)) continue;