feat(coding-agent): support per-model compaction token budgets

Closes #8133
This commit is contained in:
Vegard Stikbakke 2026-09-07 10:19:53 +02:00
parent 400d6905ce
commit 46bde88a1c
9 changed files with 592 additions and 72 deletions

View file

@ -5,6 +5,7 @@
### Added
- Added `ctx.modelRegistry.stream()` and `streamSimple()` for extension model calls through configured providers with resolved authentication ([#8964](https://github.com/earendil-works/pi/issues/8964)).
- Added per-model `reserveTokens` and `keepRecentTokens` settings through `compaction.modelOverrides`, with ordinary compaction settings as fallback ([#8133](https://github.com/earendil-works/pi-mono/issues/8133)).
### Changed

View file

@ -288,7 +288,7 @@ pi.on("session_before_compact", async (event, ctx) => {
// preparation.fileOps - extracted file operations
// preparation.tokensBefore - context tokens before compaction
// preparation.firstKeptEntryId - where kept messages start
// preparation.settings - compaction settings
// preparation.settings - effective settings after applying model overrides
// branchEntries - all entries on current branch (for custom state)
// reason - "manual" (/compact), "threshold", or "overflow"
@ -416,3 +416,29 @@ Configure compaction in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settin
| `keepRecentTokens` | `20000` | Recent tokens to keep (not summarized) |
Disable auto-compaction with `"enabled": false`. You can still compact manually with `/compact`.
### Per-model overrides
Use `compaction.modelOverrides` to tune token budgets for different models:
```json
{
"compaction": {
"reserveTokens": 16384,
"keepRecentTokens": 20000,
"modelOverrides": {
"some-provider/big-model": {
"reserveTokens": 400000
}
}
}
}
```
For a model with a 1M context window, this override triggers compaction above 600K tokens and keeps the ordinary 20000 recent tokens. Other models retain the ordinary 16384-token reserve. `reserveTokens` also influences summarization output limits, capped by the model's maximum output tokens; it is not solely a trigger threshold.
Keys are exact, case-sensitive `provider/modelId` values, including any slashes within the model ID. Each `reserveTokens` and `keepRecentTokens` value falls back independently from the model override to the ordinary setting to the built-in default. Values must be non-negative safe integers. Invalid values in the matching model override produce an error when read; only omitted fields fall back to the ordinary setting. Model override entries must be objects. Invalid ordinary token settings produce an error when read, even if the active model has a valid override. Only omitted ordinary values use built-in defaults. `enabled` remains global, not model-specific.
These resolved values are used for manual compaction, all automatic threshold checks, overflow recovery, and extension-visible `preparation.settings`. Model switches affect subsequent checks and compactions without changing ordinary settings. Compaction already in progress uses the model and settings captured for that operation. Branch summarization settings are unaffected.
Overrides work in both global and project settings. The files merge recursively before lookup, so a global model-specific value beats a project-wide fallback; a project must override that model entry to change it. See [settings.md](settings.md#per-model-compaction-overrides) for details.

View file

@ -118,6 +118,7 @@ Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--off
| `compaction.enabled` | boolean | `true` | Enable auto-compaction |
| `compaction.reserveTokens` | number | `16384` | Tokens reserved for LLM response |
| `compaction.keepRecentTokens` | number | `20000` | Recent tokens to keep (not summarized) |
| `compaction.modelOverrides` | object | - | Per-model `reserveTokens` and `keepRecentTokens` overrides keyed by exact `"provider/modelId"` |
```json
{
@ -129,6 +130,37 @@ Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--off
}
```
#### Per-model compaction overrides
```json
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000,
"modelOverrides": {
"some-provider/big-model": {
"reserveTokens": 400000
},
"local/small-model": {
"reserveTokens": 2048,
"keepRecentTokens": 4096
}
}
}
}
```
Keys match exact, case-sensitive `provider/modelId` values, not names or glob patterns. Model IDs may contain slashes (for example, `openrouter/anthropic/claude-sonnet-4`).
Each token setting resolves independently: matching model override → ordinary `compaction` setting → built-in default. In the example, `some-provider/big-model` keeps the ordinary 20000 recent tokens. Token values must be non-negative safe integers. Invalid values in the matching model override produce an error when read; only omitted fields fall back to the ordinary setting. Model override entries must be objects. Invalid ordinary token settings produce an error when read, even if the active model has a valid override. Only omitted ordinary values use built-in defaults. Zero is accepted, but `reserveTokens: 0` leaves no response margin and also sets the summarization output budget to zero.
Global and project settings merge recursively **before** model lookup. A project can override one field for a model without replacing its other fields or other models. A global model-specific value takes precedence over a project-wide fallback; override the same model entry in the project to change it.
`enabled` is not model-specific. The active model's token settings apply to manual compaction, automatic threshold checks (including between assistant turns), and overflow recovery. Switching models takes effect on the next check or compaction. Configure overrides in JSON; `/settings` retains the ordinary auto-compaction toggle.
See [compaction.md](compaction.md) for trigger and summarization behavior.
### Branch Summary
| Setting | Type | Default | Description |

View file

@ -537,7 +537,7 @@ export class AgentSession {
private async _compactBeforeNextAssistantResponse(context: AgentContext): Promise<AgentContext> {
const model = this.model;
const settings = this.settingsManager.getCompactionSettings();
const settings = this.settingsManager.getCompactionSettings(model);
if (
!model ||
@ -1971,14 +1971,15 @@ export class AgentSession {
let fromExtension = false;
try {
if (!this.model) {
const model = this.model;
if (!model) {
throw new Error(formatNoModelSelectedMessage());
}
const { model: requestModel, apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model);
const settings = this.settingsManager.getCompactionSettings(model);
const { model: requestModel, apiKey, headers, env } = await this._getSummarizationRequestAuth(model);
const pathEntries = this.sessionManager.getBranch();
const settings = this.settingsManager.getCompactionSettings();
const preparation = prepareCompaction(pathEntries, settings);
if (!preparation) {
@ -2151,7 +2152,7 @@ export class AgentSession {
* @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages
*/
private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean> {
const settings = this.settingsManager.getCompactionSettings();
const settings = this.settingsManager.getCompactionSettings(this.model);
if (!settings.enabled) return false;
// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
@ -2267,16 +2268,17 @@ export class AgentSession {
* @returns Whether the post-run loop should call `agent.continue()`
*/
private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean> {
const settings = this.settingsManager.getCompactionSettings();
const model = this.model;
const settings = this.settingsManager.getCompactionSettings(model);
let started = false;
let fromExtension = false;
try {
if (!this.model) {
if (!model) {
return false;
}
const { model: requestModel, apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model);
const { model: requestModel, apiKey, headers, env } = await this._getSummarizationRequestAuth(model);
const pathEntries = this.sessionManager.getBranch();

View file

@ -1,5 +1,5 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { DEFAULT_MAX_AGENT_RETRY_DELAY_MS, type Transport } from "@earendil-works/pi-ai";
import { DEFAULT_MAX_AGENT_RETRY_DELAY_MS, type Model, type Transport } from "@earendil-works/pi-ai";
import type { TuiMode as RendererTuiMode, ScrollViewScrollbar, TerminalCapabilities } from "@earendil-works/pi-tui";
import { randomUUID } from "crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@ -10,10 +10,21 @@ import { normalizePath, resolvePath } from "../utils/paths.ts";
import { stripBom } from "../utils/text.ts";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts";
export interface CompactionModelOverride {
reserveTokens?: number;
keepRecentTokens?: number;
}
const DEFAULT_COMPACTION_TOKEN_SETTINGS: Required<CompactionModelOverride> = {
reserveTokens: 16384,
keepRecentTokens: 20000,
};
export interface CompactionSettings {
enabled?: boolean; // default: true
reserveTokens?: number; // default: 16384
keepRecentTokens?: number; // default: 20000
modelOverrides?: Record<string, CompactionModelOverride>; // exact "provider/modelId" keys
}
export interface BranchSummarySettings {
@ -840,19 +851,52 @@ export class SettingsManager {
this.save();
}
getCompactionReserveTokens(): number {
return this.settings.compaction?.reserveTokens ?? 16384;
private getCompactionTokenSetting(
field: keyof CompactionModelOverride,
model?: Pick<Model<string>, "provider" | "id">,
): number {
const compaction = this.settings.compaction;
const ordinary = compaction?.[field];
if (ordinary !== undefined && (typeof ordinary !== "number" || !Number.isSafeInteger(ordinary) || ordinary < 0)) {
throw new Error(
`Invalid compaction.${field} setting: ${String(ordinary)}. Expected a non-negative safe integer.`,
);
}
const modelKey = model ? `${model.provider}/${model.id}` : undefined;
const entry = modelKey !== undefined ? compaction?.modelOverrides?.[modelKey] : undefined;
if (entry !== undefined && !isMergeableObject(entry)) {
throw new Error(
`Invalid compaction.modelOverrides["${modelKey}"] setting: ${String(entry)}. Expected an object.`,
);
}
const override = entry?.[field];
if (override !== undefined && (typeof override !== "number" || !Number.isSafeInteger(override) || override < 0)) {
throw new Error(
`Invalid compaction.modelOverrides["${modelKey}"].${field} setting: ${String(override)}. Expected a non-negative safe integer.`,
);
}
return override ?? ordinary ?? DEFAULT_COMPACTION_TOKEN_SETTINGS[field];
}
getCompactionKeepRecentTokens(): number {
return this.settings.compaction?.keepRecentTokens ?? 20000;
getCompactionReserveTokens(model?: Pick<Model<string>, "provider" | "id">): number {
return this.getCompactionTokenSetting("reserveTokens", model);
}
getCompactionSettings(): { enabled: boolean; reserveTokens: number; keepRecentTokens: number } {
getCompactionKeepRecentTokens(model?: Pick<Model<string>, "provider" | "id">): number {
return this.getCompactionTokenSetting("keepRecentTokens", model);
}
/** Resolve each token setting through model override, ordinary setting, then built-in default. */
getCompactionSettings(model?: Pick<Model<string>, "provider" | "id">): {
enabled: boolean;
reserveTokens: number;
keepRecentTokens: number;
} {
return {
enabled: this.getCompactionEnabled(),
reserveTokens: this.getCompactionReserveTokens(),
keepRecentTokens: this.getCompactionKeepRecentTokens(),
reserveTokens: this.getCompactionReserveTokens(model),
keepRecentTokens: this.getCompactionKeepRecentTokens(model),
};
}

View file

@ -256,6 +256,7 @@ export {
type ThinkingLevelChangeEntry,
} from "./core/session-manager.ts";
export {
type CompactionModelOverride,
type CompactionSettings,
type DefaultProjectTrust,
type FullscreenExitOutput,

View file

@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts";
const model = { provider: "provider", id: "family/model" };
const modelKey = "provider/family/model";
const defaults = { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 };
// Regression coverage for #8133.
describe("compaction model overrides", () => {
it("uses defaults without compaction settings", () => {
const manager = SettingsManager.inMemory();
expect(manager.getCompactionSettings()).toEqual(defaults);
expect(manager.getCompactionSettings(model)).toEqual(defaults);
});
it("resolves each field independently and keeps individual getters consistent", () => {
const manager = SettingsManager.inMemory({
compaction: {
reserveTokens: 8192,
keepRecentTokens: 10000,
modelOverrides: { [modelKey]: { reserveTokens: 400000 } },
},
});
expect(manager.getCompactionSettings(model)).toEqual({
enabled: true,
reserveTokens: 400000,
keepRecentTokens: 10000,
});
expect(manager.getCompactionReserveTokens(model)).toBe(400000);
expect(manager.getCompactionKeepRecentTokens(model)).toBe(10000);
expect(manager.getCompactionSettings()).toEqual({ enabled: true, reserveTokens: 8192, keepRecentTokens: 10000 });
manager.applyOverrides({ compaction: { modelOverrides: { [modelKey]: { keepRecentTokens: 30000 } } } });
expect(manager.getCompactionKeepRecentTokens(model)).toBe(30000);
expect(manager.getCompactionReserveTokens(model)).toBe(400000);
});
it("falls back to built-in defaults for missing fields", () => {
const manager = SettingsManager.inMemory({
compaction: { modelOverrides: { [modelKey]: { keepRecentTokens: 1024 } } },
});
expect(manager.getCompactionSettings(model)).toEqual({ ...defaults, keepRecentTokens: 1024 });
});
it("matches exact provider/model IDs, including IDs containing slashes", () => {
const manager = SettingsManager.inMemory({
compaction: {
modelOverrides: {
[modelKey]: { reserveTokens: 400000 },
"provider/*": { reserveTokens: 1 },
"family/model": { reserveTokens: 2 },
},
},
});
expect(manager.getCompactionReserveTokens(model)).toBe(400000);
for (const other of [
{ provider: "other", id: model.id },
{ provider: model.provider, id: "other" },
{ provider: model.provider, id: "family/Model" },
]) {
expect(manager.getCompactionSettings(other)).toEqual(defaults);
}
});
it("merges project model overrides per field before resolving fallbacks", async () => {
const storage = new InMemorySettingsStorage();
storage.withLock("global", () =>
JSON.stringify({
compaction: {
reserveTokens: 8192,
modelOverrides: {
[modelKey]: { reserveTokens: 400000, keepRecentTokens: 30000 },
"provider/other": { keepRecentTokens: 4096 },
},
},
}),
);
storage.withLock("project", () =>
JSON.stringify({
compaction: { reserveTokens: 1024, modelOverrides: { [modelKey]: { keepRecentTokens: 2000 } } },
}),
);
const manager = SettingsManager.fromStorage(storage);
expect(manager.getCompactionSettings(model)).toEqual({
enabled: true,
reserveTokens: 400000,
keepRecentTokens: 2000,
});
expect(manager.getCompactionSettings({ provider: "provider", id: "other" })).toEqual({
enabled: true,
reserveTokens: 1024,
keepRecentTokens: 4096,
});
await manager.reload();
expect(manager.getCompactionKeepRecentTokens(model)).toBe(2000);
manager.setProjectTrusted(false);
expect(manager.getCompactionKeepRecentTokens(model)).toBe(30000);
});
it("keeps enabled global and preserves overrides when saving the toggle", async () => {
const storage = new InMemorySettingsStorage();
storage.withLock("global", () =>
JSON.stringify({
compaction: { modelOverrides: { [modelKey]: { enabled: false, reserveTokens: 400000 } } },
}),
);
const manager = SettingsManager.fromStorage(storage);
expect(manager.getCompactionSettings(model).enabled).toBe(true);
manager.setCompactionEnabled(false);
await manager.flush();
await manager.reload();
expect(manager.getCompactionSettings(model)).toEqual({ ...defaults, enabled: false, reserveTokens: 400000 });
});
describe.each(["reserveTokens", "keepRecentTokens"] as const)("model override %s", (field) => {
it.each([null, -1, 1.5, "400000", true, {}, [], Number.MAX_SAFE_INTEGER + 1])(
"reports invalid token values: %j",
(value) => {
const storage = new InMemorySettingsStorage();
storage.withLock("global", () =>
JSON.stringify({
compaction: { modelOverrides: { [modelKey]: { [field]: value } } },
}),
);
const manager = SettingsManager.fromStorage(storage);
expect(() => manager.getCompactionSettings(model)).toThrow(
`Invalid compaction.modelOverrides["${modelKey}"].${field} setting: ${String(value)}. Expected a non-negative safe integer.`,
);
expect(manager.getCompactionSettings()).toEqual(defaults);
expect(manager.getCompactionSettings({ provider: "other", id: model.id })).toEqual(defaults);
},
);
it.each([Number.NaN, Infinity, -Infinity])("reports non-finite runtime values: %s", (value) => {
const manager = SettingsManager.inMemory();
manager.applyOverrides({ compaction: { modelOverrides: { [modelKey]: { [field]: value } } } });
expect(() => manager.getCompactionSettings(model)).toThrow(
`Invalid compaction.modelOverrides["${modelKey}"].${field} setting: ${String(value)}`,
);
});
});
describe.each(["reserveTokens", "keepRecentTokens"] as const)("ordinary compaction.%s", (field) => {
it.each([null, -1, 1.5, "400000", true, {}, [], Number.MAX_SAFE_INTEGER + 1])(
"reports invalid values even when a valid model override exists: %j",
(value) => {
const storage = new InMemorySettingsStorage();
storage.withLock("global", () =>
JSON.stringify({
compaction: {
[field]: value,
modelOverrides: { [modelKey]: { [field]: 4096 } },
},
}),
);
const manager = SettingsManager.fromStorage(storage);
const error = `Invalid compaction.${field} setting: ${String(value)}. Expected a non-negative safe integer.`;
expect(() => manager.getCompactionSettings()).toThrow(error);
expect(() => manager.getCompactionSettings(model)).toThrow(error);
},
);
it.each([Number.NaN, Infinity, -Infinity])("reports non-finite runtime values: %s", (value) => {
const manager = SettingsManager.inMemory();
manager.applyOverrides({ compaction: { [field]: value } });
expect(() => manager.getCompactionSettings()).toThrow(`Invalid compaction.${field} setting: ${String(value)}`);
});
});
it.each([null, false, 42, "invalid", []])("reports malformed model entries: %j", (entry) => {
const storage = new InMemorySettingsStorage();
storage.withLock("global", () => JSON.stringify({ compaction: { modelOverrides: { [modelKey]: entry } } }));
expect(() => SettingsManager.fromStorage(storage).getCompactionSettings(model)).toThrow(
`Invalid compaction.modelOverrides["${modelKey}"] setting: ${String(entry)}. Expected an object.`,
);
});
it("accepts zero in ordinary settings and model overrides", () => {
const manager = SettingsManager.inMemory({
compaction: { reserveTokens: 0, keepRecentTokens: 0 },
});
expect(manager.getCompactionSettings(model)).toEqual({ enabled: true, reserveTokens: 0, keepRecentTokens: 0 });
manager.applyOverrides({
compaction: {
reserveTokens: 1000,
keepRecentTokens: 1000,
modelOverrides: { [modelKey]: { reserveTokens: 0, keepRecentTokens: 0 } },
},
});
expect(manager.getCompactionSettings(model)).toEqual({ enabled: true, reserveTokens: 0, keepRecentTokens: 0 });
});
});

View file

@ -0,0 +1,206 @@
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SessionBeforeCompactEvent } from "../../src/core/extensions/index.ts";
import { createHarness, type Harness } from "./harness.ts";
function seedHistory(harness: Harness, totalTokens = 650): string {
const model = harness.session.model!;
let recentUserId = "";
for (const label of ["old", "recent"]) {
recentUserId = harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: label.padEnd(400, "x") }],
timestamp: Date.now() - 2000,
});
const assistant = fauxAssistantMessage(label.padEnd(400, "y"), { timestamp: Date.now() - 1000 });
harness.sessionManager.appendMessage({
...assistant,
api: model.api,
provider: model.provider,
model: model.id,
usage: { ...assistant.usage, input: totalTokens, totalTokens },
});
}
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
return recentUserId;
}
// Regression coverage for #8133.
describe("AgentSession compaction model overrides", () => {
const harnesses: Harness[] = [];
afterEach(() => {
vi.restoreAllMocks();
while (harnesses.length) harnesses.pop()?.cleanup();
});
it.each(["manual", "pre-prompt", "post-run", "overflow"] as const)(
"uses model token settings for %s compaction and extension preparation",
async (path) => {
const preparations: SessionBeforeCompactEvent[] = [];
const harness = await createHarness({
models: [{ id: "faux-1", contextWindow: 4000 }],
tools: [],
settings: {
compaction: {
enabled: path !== "manual",
reserveTokens: 10,
keepRecentTokens: 20000,
modelOverrides: { "faux/faux-1": { reserveTokens: 2000, keepRecentTokens: 150 } },
},
},
extensionFactories: [
(pi) => {
pi.on("session_before_compact", (event) => {
preparations.push(event);
return {
compaction: {
summary: "compacted history",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
},
};
});
},
],
});
harnesses.push(harness);
const recentUserId = seedHistory(harness, path === "pre-prompt" ? 2500 : 650);
if (path === "manual") {
await harness.session.compact();
} else {
harness.setResponses(
path === "overflow"
? [
fauxAssistantMessage("", { stopReason: "error", errorMessage: "prompt is too long" }),
fauxAssistantMessage("recovered"),
]
: [fauxAssistantMessage(path === "post-run" ? "z".repeat(8000) : "done")],
);
await harness.session.prompt("continue");
}
expect(preparations).toHaveLength(1);
expect(preparations[0]?.preparation.settings).toEqual({
enabled: path !== "manual",
reserveTokens: 2000,
keepRecentTokens: 150,
});
expect(preparations[0]?.reason).toBe(
path === "manual" ? "manual" : path === "overflow" ? "overflow" : "threshold",
);
if (path === "manual" || path === "pre-prompt") {
expect(preparations[0]?.preparation.firstKeptEntryId).toBe(recentUserId);
}
expect(harness.eventsOfType("compaction_end")).toHaveLength(1);
expect(harness.eventsOfType("compaction_end")[0]).toMatchObject({
aborted: false,
willRetry: path === "overflow",
result: { summary: "compacted history" },
});
expect(harness.getPendingResponseCount()).toBe(0);
},
);
it.each(["manual", "automatic"] as const)("passes resolved budgets to built-in %s summarization", async (path) => {
const harness = await createHarness({
models: [{ id: "faux-1", contextWindow: 4000, maxTokens: 3000 }],
tools: [],
settings: {
compaction: {
reserveTokens: 10,
modelOverrides: { "faux/faux-1": { reserveTokens: 2000, keepRecentTokens: 150 } },
},
},
});
harnesses.push(harness);
const recentUserId = seedHistory(harness, 2500);
const budgets: Array<number | undefined> = [];
harness.setResponses([
(_context, options) => {
budgets.push(options?.maxTokens);
return fauxAssistantMessage("built-in summary");
},
...(path === "automatic" ? [fauxAssistantMessage("done")] : []),
]);
if (path === "manual") await harness.session.compact();
else await harness.session.prompt("continue");
expect(budgets).toEqual([1600]);
expect(harness.sessionManager.getEntries().find((entry) => entry.type === "compaction")).toMatchObject({
firstKeptEntryId: recentUserId,
summary: "built-in summary",
});
expect(harness.getPendingResponseCount()).toBe(0);
});
it("uses the newly selected model without changing ordinary settings", async () => {
const harness = await createHarness({
models: [
{ id: "small", contextWindow: 4000 },
{ id: "big", contextWindow: 10000 },
],
tools: [],
settings: {
compaction: {
reserveTokens: 10,
modelOverrides: { "faux/big": { reserveTokens: 8000, keepRecentTokens: 150 } },
},
},
extensionFactories: [
(pi) => {
pi.on("session_before_compact", (event) => ({
compaction: {
summary: "big model summary",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
},
}));
},
],
});
harnesses.push(harness);
seedHistory(harness, 2500);
harness.setResponses([fauxAssistantMessage("small response"), fauxAssistantMessage("big response")]);
await harness.session.prompt("continue on small");
expect(harness.eventsOfType("compaction_start")).toHaveLength(0);
// Retain usage from the small model: the next check must use the active big model's policy.
seedHistory(harness, 2500);
await harness.session.setModel(harness.getModel("big")!);
await harness.session.prompt("continue on big");
expect(harness.eventsOfType("compaction_end")).toHaveLength(1);
expect(harness.eventsOfType("compaction_end")[0]?.result?.summary).toBe("big model summary");
expect(harness.settingsManager.getCompactionReserveTokens()).toBe(10);
await harness.session.setModel(harness.getModel("small")!);
expect(harness.settingsManager.getCompactionReserveTokens(harness.session.model)).toBe(10);
});
it("captures model identity before awaiting summarization auth", async () => {
const harness = await createHarness({
models: [{ id: "first" }, { id: "second" }],
settings: {
compaction: {
modelOverrides: {
"faux/first": { reserveTokens: 2000, keepRecentTokens: 150 },
"faux/second": { reserveTokens: 4000, keepRecentTokens: 20000 },
},
},
},
});
harnesses.push(harness);
seedHistory(harness);
const getAuth = harness.session.modelRuntime.getAuth.bind(harness.session.modelRuntime);
vi.spyOn(harness.session.modelRuntime, "getAuth").mockImplementation(async (model, ...args) => {
harness.session.agent.state.model = harness.getModel("second")!;
return getAuth(model, ...args);
});
const requests: Array<{ id: string; maxTokens: number | undefined }> = [];
harness.setResponses([
(_context, options, _state, model) => {
requests.push({ id: model.id, maxTokens: options?.maxTokens });
return fauxAssistantMessage("summary");
},
]);
await harness.session.compact();
expect(requests).toEqual([{ id: "first", maxTokens: 1600 }]);
});
});

View file

@ -419,64 +419,80 @@ describe("AgentSession compaction characterization", () => {
expect(harness.session.getLastAssistantText()).toBe("completed response");
});
it("compacts after a tool result before the next assistant request in the same run", async () => {
const toolResult = `large-tool-result:${"x".repeat(6800)}`;
const largeTool: AgentTool = {
name: "large_result",
label: "Large result",
description: "Returns enough content to cross the compaction threshold",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: toolResult }], details: {} }),
};
const order: string[] = [];
const harness = await createHarness({
models: [{ id: "faux-1", contextWindow: 2600, maxTokens: 100 }],
settings: { compaction: { enabled: true, reserveTokens: 400, keepRecentTokens: 1750 } },
tools: [largeTool],
extensionFactories: [
(pi) => {
pi.on("session_before_compact", (event) => {
order.push("compaction");
return {
compaction: {
summary: "compacted history",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
};
});
// Regression coverage for #8133: model overrides must also apply between assistant turns.
it.each([false, true])(
"compacts after a tool result in the same run (model override: %s)",
async (modelOverride) => {
const toolResult = `large-tool-result:${"x".repeat(6800)}`;
const largeTool: AgentTool = {
name: "large_result",
label: "Large result",
description: "Returns enough content to cross the compaction threshold",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: toolResult }], details: {} }),
};
const order: string[] = [];
const observedSettings: unknown[] = [];
const harness = await createHarness({
models: [{ id: "faux-1", contextWindow: 2600, maxTokens: 100 }],
settings: {
compaction: modelOverride
? {
enabled: true,
reserveTokens: 0,
keepRecentTokens: 20000,
modelOverrides: { "faux/faux-1": { reserveTokens: 400, keepRecentTokens: 1750 } },
}
: { enabled: true, reserveTokens: 400, keepRecentTokens: 1750 },
},
],
});
harnesses.push(harness);
let resumedRequest = "";
harness.setResponses([
fauxAssistantMessage(`old-history:${"a".repeat(800)}`),
fauxAssistantMessage(`recent-history:${"b".repeat(800)}`),
fauxAssistantMessage(fauxToolCall("large_result", {}), { stopReason: "toolUse" }),
(context) => {
order.push("provider");
resumedRequest = JSON.stringify(context.messages);
return fauxAssistantMessage("finished after compaction");
},
]);
tools: [largeTool],
extensionFactories: [
(pi) => {
pi.on("session_before_compact", (event) => {
order.push("compaction");
observedSettings.push(event.preparation.settings);
return {
compaction: {
summary: "compacted history",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
};
});
},
],
});
harnesses.push(harness);
let resumedRequest = "";
harness.setResponses([
fauxAssistantMessage(`old-history:${"a".repeat(800)}`),
fauxAssistantMessage(`recent-history:${"b".repeat(800)}`),
fauxAssistantMessage(fauxToolCall("large_result", {}), { stopReason: "toolUse" }),
(context) => {
order.push("provider");
resumedRequest = JSON.stringify(context.messages);
return fauxAssistantMessage("finished after compaction");
},
]);
await harness.session.prompt("seed old history");
await harness.session.prompt("seed recent history");
const agentStartsBefore = harness.eventsOfType("agent_start").length;
await harness.session.prompt("run the large tool");
await harness.session.prompt("seed old history");
await harness.session.prompt("seed recent history");
const agentStartsBefore = harness.eventsOfType("agent_start").length;
await harness.session.prompt("run the large tool");
expect(order).toEqual(["compaction", "provider"]);
expect(harness.eventsOfType("agent_start")).toHaveLength(agentStartsBefore + 1);
expect(harness.eventsOfType("compaction_start").at(-1)).toEqual({
type: "compaction_start",
reason: "threshold",
});
expect(resumedRequest).toContain("compacted history");
expect(resumedRequest).toContain("large-tool-result");
expect(harness.session.getLastAssistantText()).toBe("finished after compaction");
});
expect(order).toEqual(["compaction", "provider"]);
expect(observedSettings).toEqual([{ enabled: true, reserveTokens: 400, keepRecentTokens: 1750 }]);
expect(harness.eventsOfType("agent_start")).toHaveLength(agentStartsBefore + 1);
expect(harness.eventsOfType("compaction_start").at(-1)).toEqual({
type: "compaction_start",
reason: "threshold",
});
expect(resumedRequest).toContain("compacted history");
expect(resumedRequest).toContain("large-tool-result");
expect(harness.session.getLastAssistantText()).toBe("finished after compaction");
},
);
it("includes steering queued during compaction in the resumed assistant request", async () => {
const largeTool: AgentTool = {