Merge pull request #3101 from DarkPhilosophy/fix/compaction-context-compression-aware
fix(compaction): floor context tokens by local estimate so payload compression can't suppress auto-compaction
This commit is contained in:
@@ -9,6 +9,10 @@
|
||||
|
||||
- Exported helper functions `normalizeMessagesForProvider` and `resolveOwnedDialectFromEnv` from `packages/agent/src/agent-loop.ts`.
|
||||
|
||||
### Added
|
||||
|
||||
- Added `compactionContextTokens(providerContextTokens, storedConversationEstimate)`: floors the provider-reported context tokens by a local estimate of the stored conversation for the compaction decision, so a `before_provider_request` payload transform (a compression extension, obfuscator, or inline snapcompact) that shrinks the request can no longer deflate provider usage below the true history size and suppress auto-compaction.
|
||||
|
||||
## [16.1.5] - 2026-06-19
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -228,6 +228,25 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett
|
||||
return contextTokens > thresholdTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context tokens to feed the compaction decision, floored by a local estimate of
|
||||
* the stored conversation.
|
||||
*
|
||||
* The provider-reported usage is normally ground truth, but a
|
||||
* `before_provider_request` payload transform — a compression extension (e.g.
|
||||
* Headroom), an obfuscator, or inline snapcompact — can shrink the request below
|
||||
* the real stored conversation. The provider then reports deflated prompt
|
||||
* tokens, so anchoring compaction purely on that usage lets the real history
|
||||
* grow unbounded until it overflows and native compaction can no longer run.
|
||||
* Flooring by the agent's own estimate of the stored conversation keeps the
|
||||
* compaction trigger honest regardless of on-wire compression. (Display/cost
|
||||
* accounting still uses the exact provider usage; only the compaction decision
|
||||
* takes the floor.)
|
||||
*/
|
||||
export function compactionContextTokens(providerContextTokens: number, storedConversationEstimate: number): number {
|
||||
return Math.max(Math.max(0, providerContextTokens), Math.max(0, storedConversationEstimate));
|
||||
}
|
||||
|
||||
export function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number {
|
||||
// Fixed token limit takes priority over percentage
|
||||
const thresholdTokens = settings.thresholdTokens;
|
||||
@@ -259,8 +278,15 @@ const IMAGE_TOKEN_ESTIMATE = 1200;
|
||||
* Estimate token count for a message using cl100k_base via the native
|
||||
* tokenizer. This is not Claude's first-party tokenizer (Anthropic doesn't
|
||||
* publish one) but is within ~5–10% across English/code text.
|
||||
*
|
||||
* `excludeEncryptedReasoning` drops opaque provider reasoning payloads
|
||||
* (`thinkingSignature`, `redactedThinking`) from the estimate. Those are billed
|
||||
* by the provider on replay, so the default counts them — but their *local*
|
||||
* byte size can diverge wildly from what the provider charges, so the
|
||||
* compaction floor (which only needs the reliably-countable, on-wire-compressible
|
||||
* content) excludes them to avoid false triggers on thinking-heavy turns.
|
||||
*/
|
||||
export function estimateTokens(message: AgentMessage): number {
|
||||
export function estimateTokens(message: AgentMessage, options?: { excludeEncryptedReasoning?: boolean }): number {
|
||||
const fragments: string[] = [];
|
||||
let extra = 0;
|
||||
if ((message as { role?: string }).role === "bashExecution") {
|
||||
@@ -296,14 +322,18 @@ export function estimateTokens(message: AgentMessage): number {
|
||||
// reasoning items, Anthropic signed thinking blocks, etc.). Without
|
||||
// counting it, this estimator can read ~half of the provider-reported
|
||||
// usage on thinking-heavy turns — see #2275 for the resulting
|
||||
// compaction-trigger / post-check metric divergence.
|
||||
if (block.thinkingSignature) fragments.push(block.thinkingSignature);
|
||||
// compaction-trigger / post-check metric divergence. The compaction
|
||||
// floor excludes it (its local byte size diverges from provider billing).
|
||||
if (block.thinkingSignature && !options?.excludeEncryptedReasoning) {
|
||||
fragments.push(block.thinkingSignature);
|
||||
}
|
||||
} else if (block.type === "toolCall") {
|
||||
fragments.push(block.name);
|
||||
fragments.push(JSON.stringify(block.arguments));
|
||||
} else if (block.type === "redactedThinking") {
|
||||
// Encrypted reasoning blob the provider still bills for on replay.
|
||||
fragments.push(block.data);
|
||||
// Encrypted reasoning blob the provider still bills for on replay;
|
||||
// excluded from the compaction floor for the same reason as above.
|
||||
if (!options?.excludeEncryptedReasoning) fragments.push(block.data);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
- Fixed the Alt+M model-configuration menu so role assignment remains selectable for models whose context window is smaller than the current session; the context-size disabling still applies to the Alt+P temporary active-model switch ([#2861](https://github.com/can1357/oh-my-pi/issues/2861)).
|
||||
- Fixed the advisor raising false `blocker`s in plan mode (e.g. "don't write a plan file") because it only saw a 120-char truncation of the injected plan-mode rules, which cut off at `NEVER create, edit, or delete files — excep…` and hid the "except the single plan file" carve-out. The advisor delta now expands the primary agent's constraint context (`plan-mode-context`, `plan-mode-reference`) verbatim inside an XML-escaped `<primary-context>` wrapper instead of a one-liner, and `AdvisorRuntime` dedupes the re-injected prompts so an unchanged copy collapses to a marker rather than re-feeding the full rules every turn.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed auto-compaction being suppressed when a `before_provider_request` extension shrinks the outgoing request below the real stored conversation (e.g. a context-compression proxy such as Headroom, or an aggressive obfuscator). The provider then reports deflated prompt tokens, so the threshold check never fired and the stored history grew unbounded until it overflowed the context window and could no longer be compacted at all. The compaction decision (both the pre-prompt and post-response paths) now floors the provider-reported context tokens by the agent's own local estimate of the stored conversation, so on-wire compression can no longer hide a too-large history from the auto-compactor. Context display and cost accounting still use the exact provider usage; only the compaction trigger takes the floor.
|
||||
|
||||
## [16.1.7] - 2026-06-20
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
collectEntriesForBranchSummary,
|
||||
collectShakeRegions,
|
||||
compact,
|
||||
compactionContextTokens,
|
||||
createCompactionSummaryMessage,
|
||||
DEFAULT_SHAKE_CONFIG,
|
||||
effectiveReserveTokens,
|
||||
@@ -8140,12 +8141,37 @@ export class AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local token estimate of the stored conversation (plus any pending messages),
|
||||
* independent of provider-reported usage. A `before_provider_request` hook
|
||||
* (e.g. a compression extension such as Headroom) or other on-wire payload
|
||||
* transform can shrink the request below the real stored conversation; the
|
||||
* provider then reports deflated prompt tokens, so anchoring the compaction
|
||||
* decision purely on that usage lets the real history grow unbounded until it
|
||||
* overflows and native compaction can no longer run. This estimate is the
|
||||
* floor the compaction decision respects so on-wire compression can never
|
||||
* suppress it.
|
||||
*/
|
||||
#estimateStoredContextTokens(pendingMessages: AgentMessage[] = []): number {
|
||||
// Exclude encrypted reasoning (thinkingSignature / redactedThinking): its
|
||||
// local byte size diverges from what the provider bills, so counting it here
|
||||
// would let a thinking-heavy turn falsely trip the floor. The provider usage
|
||||
// (the other arm of compactionContextTokens) already accounts for it.
|
||||
const opts = { excludeEncryptedReasoning: true } as const;
|
||||
return (
|
||||
computeNonMessageTokens(this) +
|
||||
this.messages.reduce((sum, msg) => sum + estimateTokens(msg, opts), 0) +
|
||||
pendingMessages.reduce((sum, msg) => sum + estimateTokens(msg, opts), 0)
|
||||
);
|
||||
}
|
||||
|
||||
#estimatePrePromptContextTokens(messages: AgentMessage[], contextWindow: number): number {
|
||||
const breakdown = this.getContextBreakdown({ contextWindow, pendingMessages: messages });
|
||||
return (
|
||||
breakdown?.usedTokens ??
|
||||
computeNonMessageTokens(this) + messages.reduce((sum, msg) => sum + estimateTokens(msg), 0)
|
||||
);
|
||||
const localEstimate = this.#estimateStoredContextTokens(messages);
|
||||
// Floor by the local estimate: a payload-shrinking before_provider_request
|
||||
// hook deflates the provider-anchored breakdown, which must not suppress
|
||||
// pre-prompt compaction (see #estimateStoredContextTokens).
|
||||
return compactionContextTokens(breakdown?.usedTokens ?? 0, localEstimate);
|
||||
}
|
||||
|
||||
async #runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise<void> {
|
||||
@@ -8175,7 +8201,10 @@ export class AgentSession {
|
||||
contextWindow,
|
||||
model: `${model.provider}/${model.id}`,
|
||||
});
|
||||
await this.#runAutoCompaction("threshold", false, false, false, { autoContinue: false });
|
||||
await this.#runAutoCompaction("threshold", false, false, false, {
|
||||
autoContinue: false,
|
||||
triggerContextTokens: contextTokens,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8310,6 +8339,12 @@ export class AgentSession {
|
||||
if (pruneResult) {
|
||||
contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
|
||||
}
|
||||
// Floor by the real stored-conversation estimate so a payload-shrinking
|
||||
// before_provider_request hook (e.g. a compression extension such as
|
||||
// Headroom) can't deflate the provider-reported usage below the true
|
||||
// history size and skip the threshold. The estimate runs after the prune
|
||||
// passes above, so it reflects the post-prune message set.
|
||||
contextTokens = compactionContextTokens(contextTokens, this.#estimateStoredContextTokens());
|
||||
if (shouldCompact(contextTokens, contextWindow, compactionSettings)) {
|
||||
// Try promotion first — if a larger model is available, switch instead of compacting
|
||||
const promoted = await this.#tryContextPromotion(assistantMessage);
|
||||
|
||||
@@ -483,6 +483,89 @@ describe("AgentSession handoff", () => {
|
||||
expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(0);
|
||||
expect(mock.calls).toHaveLength(1);
|
||||
});
|
||||
it("floors pre-prompt context-full checks by the stored conversation when provider usage is deflated", async () => {
|
||||
// Mirror of the provider-anchored test, but the large payload is real, on-wire-
|
||||
// compressible text (what a before_provider_request hook like Headroom shrinks),
|
||||
// NOT encrypted reasoning. The provider reports a deflated 1k prompt tokens, yet
|
||||
// the stored conversation is ~20k tokens — compaction MUST still fire.
|
||||
await session.dispose();
|
||||
authStorage.setRuntimeApiKey("openai", "test-key");
|
||||
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
|
||||
events = [];
|
||||
|
||||
const mock = createMockModel({
|
||||
id: "gpt-5.5",
|
||||
provider: "openai",
|
||||
contextWindow: 10_000,
|
||||
responses: [{ content: ["ok"], stopReason: "stop" }],
|
||||
});
|
||||
const seedUser: AgentMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "seed" }],
|
||||
timestamp: Date.now() - 2,
|
||||
};
|
||||
// ~20k tokens of plain text in a normal text block — counted by the floor.
|
||||
const bulkText = "alpha beta gamma delta epsilon ".repeat(3_000);
|
||||
const seedAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: bulkText }],
|
||||
api: mock.api,
|
||||
provider: "openai",
|
||||
model: mock.id,
|
||||
stopReason: "stop",
|
||||
// Deflated: a before_provider_request compressor shrank the request, so the
|
||||
// provider only billed ~1k prompt tokens for a ~20k-token conversation.
|
||||
usage: {
|
||||
input: 1_000,
|
||||
output: 10,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 1_010,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now() - 1,
|
||||
};
|
||||
sessionManager.appendMessage(seedUser);
|
||||
sessionManager.appendMessage(seedAssistant);
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model: mock, systemPrompt: ["Test"], tools: [], messages: [seedUser, seedAssistant] },
|
||||
streamFn: mock.stream,
|
||||
});
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settings: Settings.isolated({
|
||||
"compaction.enabled": true,
|
||||
"compaction.autoContinue": false,
|
||||
"compaction.strategy": "context-full",
|
||||
"compaction.thresholdTokens": 8_000,
|
||||
"contextPromotion.enabled": false,
|
||||
}),
|
||||
modelRegistry,
|
||||
});
|
||||
session.subscribe(event => {
|
||||
events.push(event);
|
||||
});
|
||||
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
|
||||
summary: "pre-prompt compacted",
|
||||
shortSummary: undefined,
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: {},
|
||||
}));
|
||||
|
||||
// Display still shows the provider-anchored (deflated) usage — only the
|
||||
// compaction decision takes the local floor.
|
||||
expect(session.getContextUsage({ contextWindow: 10_000 })?.tokens).toBe(1_000);
|
||||
|
||||
await session.prompt("small pending prompt");
|
||||
|
||||
// The floor (~20k from the stored text) exceeds the 8k threshold, so the
|
||||
// deflated 1k provider count no longer suppresses compaction.
|
||||
expect(compactSpy).toHaveBeenCalled();
|
||||
});
|
||||
it("counts current non-message token growth in provider-anchored pre-prompt checks", async () => {
|
||||
await session.dispose();
|
||||
authStorage.setRuntimeApiKey("openai", "test-key");
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
type CompactionSettings,
|
||||
calculateContextTokens,
|
||||
compact,
|
||||
compactionContextTokens,
|
||||
DEFAULT_COMPACTION_SETTINGS,
|
||||
estimateTokens,
|
||||
findCutPoint,
|
||||
getLastAssistantUsage,
|
||||
prepareCompaction,
|
||||
@@ -287,6 +289,74 @@ describe("shouldCompact", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("compactionContextTokens", () => {
|
||||
it("floors deflated provider usage by the stored-conversation estimate", () => {
|
||||
// A before_provider_request compression extension (e.g. Headroom) shrinks the
|
||||
// request, so the provider reports far fewer prompt tokens than the real
|
||||
// stored conversation. The compaction decision must use the larger value.
|
||||
expect(compactionContextTokens(20_000, 90_000)).toBe(90_000);
|
||||
});
|
||||
|
||||
it("keeps provider usage when it already exceeds the local estimate", () => {
|
||||
// Without compression the provider count is ground truth and typically >= the
|
||||
// cl100k local estimate; the floor must never lower it.
|
||||
expect(compactionContextTokens(85_000, 80_000)).toBe(85_000);
|
||||
});
|
||||
|
||||
it("clamps negative inputs to zero", () => {
|
||||
expect(compactionContextTokens(-5, -10)).toBe(0);
|
||||
expect(compactionContextTokens(-5, 100)).toBe(100);
|
||||
});
|
||||
|
||||
it("lets a deflated provider count still trigger compaction via the floor", () => {
|
||||
const settings: CompactionSettings = { enabled: true, reserveTokens: 10000, keepRecentTokens: 20000 };
|
||||
// Post-compression provider count is under threshold — raw, it would NOT compact.
|
||||
expect(shouldCompact(20_000, 100_000, settings)).toBe(false);
|
||||
// Floored by the real stored-conversation estimate (95k) it correctly compacts.
|
||||
expect(shouldCompact(compactionContextTokens(20_000, 95_000), 100_000, settings)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateTokens excludeEncryptedReasoning (compaction floor)", () => {
|
||||
it("drops encrypted reasoning from the floor estimate but counts it by default", () => {
|
||||
const blob = "blob ".repeat(8_000); // large opaque encrypted-reasoning payload
|
||||
const msg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "short", thinkingSignature: blob },
|
||||
{ type: "text", text: "done" },
|
||||
],
|
||||
usage: createMockUsage(0, 0),
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
};
|
||||
const withBlob = estimateTokens(msg);
|
||||
const flooredEstimate = estimateTokens(msg, { excludeEncryptedReasoning: true });
|
||||
// Default counts the blob (providers bill it on replay); the floor excludes it,
|
||||
// so a thinking-heavy turn can't falsely trip compaction on local byte size.
|
||||
expect(withBlob).toBeGreaterThan(flooredEstimate + 1_000);
|
||||
expect(flooredEstimate).toBeLessThan(50); // just "short" + "done"
|
||||
});
|
||||
|
||||
it("still counts tool-result text (the content on-wire compression shrinks)", () => {
|
||||
const big = "alpha beta gamma ".repeat(2_000);
|
||||
const toolMsg = {
|
||||
role: "toolResult",
|
||||
toolCallId: "t1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: big }],
|
||||
timestamp: Date.now(),
|
||||
} as unknown as AgentMessage;
|
||||
// Even with the floor option, tool-result content is fully counted — that is
|
||||
// exactly what a before_provider_request compressor (e.g. Headroom) shrinks,
|
||||
// so the floor must still see its real size.
|
||||
expect(estimateTokens(toolMsg, { excludeEncryptedReasoning: true })).toBeGreaterThan(1_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote compaction setting", () => {
|
||||
it("forwards an explicit initiator override to local summarization requests", async () => {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { Agent } from "@oh-my-pi/pi-agent-core";
|
||||
import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
|
||||
import type { AssistantMessage, ImageContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
@@ -345,5 +346,60 @@ describe("AgentSession shake", () => {
|
||||
);
|
||||
expect(fullStart).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back after pre-prompt shake when the floored stored conversation remains over threshold", async () => {
|
||||
session.settings.set("compaction.strategy", "shake");
|
||||
session.settings.set("compaction.thresholdTokens", 8_000);
|
||||
session.settings.set("compaction.keepRecentTokens", 1);
|
||||
session.settings.set("contextPromotion.enabled", false);
|
||||
|
||||
const seedUser: AgentMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "seed" }],
|
||||
timestamp: Date.now() - 2,
|
||||
};
|
||||
const bulkText = "alpha beta gamma delta epsilon ".repeat(3_000);
|
||||
const seedAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: bulkText }],
|
||||
...apiInfo,
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1_000,
|
||||
output: 10,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 1_010,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now() - 1,
|
||||
};
|
||||
sessionManager.appendMessage(seedUser);
|
||||
sessionManager.appendMessage(seedAssistant);
|
||||
session.agent.replaceMessages([seedUser, seedAssistant]);
|
||||
|
||||
const shakeSpy = vi
|
||||
.spyOn(session, "shake")
|
||||
.mockResolvedValue({ mode: "elide", toolResultsDropped: 1, blocksDropped: 0, tokensFreed: 10 });
|
||||
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
|
||||
summary: "pre-prompt shake fallback compacted",
|
||||
shortSummary: undefined,
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: {},
|
||||
}));
|
||||
vi.spyOn(session.agent, "prompt").mockImplementation(async () => {});
|
||||
|
||||
expect(session.getContextUsage({ contextWindow: 200_000 })?.tokens).toBe(1_000);
|
||||
|
||||
await session.prompt("small pending prompt", { skipCompactionCheck: true });
|
||||
|
||||
expect(shakeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(compactSpy).toHaveBeenCalled();
|
||||
const fullStart = events.find(
|
||||
event => event.type === "auto_compaction_start" && (event as { action?: string }).action === "context-full",
|
||||
);
|
||||
expect(fullStart).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user