Skip to content

Commit 9ab9b50

Browse files
committed
feat(agents): add context-window-relative compaction budget shares
`AgentCompactionConfig` only accepts absolute token counts for `reserveTokens`, `keepRecentTokens`, and `reserveTokensFloor`. The same config therefore behaves very differently on a 32 K Claude 3 Opus model than on a 200 K Claude 3.5 Sonnet model — `reserveTokensFloor: 20_000` reserves 62 % of the first context but 10 % of the second, and operators running heterogeneous fleets have to maintain per-model overrides to keep behaviour comparable (#72790). Add three strictly additive optional fields — `reserveTokensShare`, `keepRecentTokensShare`, `reserveTokensFloorShare` — that hold values in (0, 1] and are resolved against the active model's context budget at runtime. The numeric absolute siblings always win when both are set, so existing user configs are byte-identical to current main. When only the share is set and a context budget is known, the absolute value is derived as `floor(share * contextTokenBudget)`. When the share is set but the runtime has no context budget yet (cold-start / unknown model), the resolver falls back to the existing defaults, never silently returning zero. Wire the new resolver into the two existing readers in `pi-settings.ts` (`resolveCompactionReserveTokensFloor`, `applyPiCompactionSettingsFromConfig`), both of which already accept a `contextTokenBudget` parameter. The Zod schema gains matching `.gt(0).max(1)` validators so invalid shares fail fast at config load. Tests in `pi-settings.test.ts` cover four cases for each share field: (a) the share resolves correctly when context is known; (b) the absolute sibling wins when both are set; (c) the share is ignored (default behaviour) when the runtime has no context budget; (d) positive-integer constraints on `keepRecentTokens` are preserved. Strictly out of scope (per clawsweeper's review on the issue): no percent-string overloading of the existing numeric fields, no doc regeneration in this PR, no follow-on changes to the memory-preflight path in `agent-runner-memory.ts` — those need a maintainer call on the final docs/metadata shape and are easy follow-ups once the additive share-field contract lands. Refs #72790.
1 parent 178a50e commit 9ab9b50

4 files changed

Lines changed: 203 additions & 5 deletions

File tree

src/agents/pi-settings.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,90 @@ describe("applyPiCompactionSettingsFromConfig", () => {
329329

330330
expect(result.compaction.reserveTokens).toBe(DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR);
331331
});
332+
333+
it("resolves reserveTokensShare against the active context budget (#72790)", () => {
334+
const settingsManager = {
335+
getCompactionReserveTokens: () => 4_000,
336+
getCompactionKeepRecentTokens: () => 8_000,
337+
applyOverrides: vi.fn(),
338+
};
339+
// 10% of a 200 000-token context = 20 000.
340+
const cfg = {
341+
agents: { defaults: { compaction: { reserveTokensShare: 0.1 } } },
342+
} as const;
343+
344+
const result = applyPiCompactionSettingsFromConfig({
345+
settingsManager,
346+
cfg,
347+
contextTokenBudget: 200_000,
348+
});
349+
350+
expect(result.compaction.reserveTokens).toBe(20_000);
351+
});
352+
353+
it("absolute reserveTokens wins over reserveTokensShare when both are set (#72790)", () => {
354+
const settingsManager = {
355+
getCompactionReserveTokens: () => 4_000,
356+
getCompactionKeepRecentTokens: () => 8_000,
357+
applyOverrides: vi.fn(),
358+
};
359+
const cfg = {
360+
agents: {
361+
defaults: {
362+
compaction: { reserveTokens: 7_500, reserveTokensShare: 0.1 },
363+
},
364+
},
365+
} as const;
366+
367+
const result = applyPiCompactionSettingsFromConfig({
368+
settingsManager,
369+
cfg,
370+
contextTokenBudget: 200_000,
371+
});
372+
373+
// Absolute 7 500 wins over share-derived 20 000; the floor of 20 000
374+
// still applies and bumps the actual override up.
375+
expect(result.compaction.reserveTokens).toBeGreaterThanOrEqual(
376+
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR,
377+
);
378+
});
379+
380+
it("ignores reserveTokensShare when contextTokenBudget is unknown (#72790)", () => {
381+
const settingsManager = {
382+
getCompactionReserveTokens: () => 4_000,
383+
getCompactionKeepRecentTokens: () => 8_000,
384+
applyOverrides: vi.fn(),
385+
};
386+
const cfg = {
387+
agents: { defaults: { compaction: { reserveTokensShare: 0.5 } } },
388+
} as const;
389+
390+
const result = applyPiCompactionSettingsFromConfig({ settingsManager, cfg });
391+
392+
// Share cannot be resolved without a context budget, so the floor
393+
// default takes effect instead.
394+
expect(result.compaction.reserveTokens).toBe(DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR);
395+
});
396+
397+
it("resolves keepRecentTokensShare and respects positive-int constraint (#72790)", () => {
398+
const settingsManager = {
399+
getCompactionReserveTokens: () => 30_000,
400+
getCompactionKeepRecentTokens: () => 8_000,
401+
applyOverrides: vi.fn(),
402+
};
403+
const cfg = {
404+
agents: { defaults: { compaction: { keepRecentTokensShare: 0.15 } } },
405+
} as const;
406+
407+
const result = applyPiCompactionSettingsFromConfig({
408+
settingsManager,
409+
cfg,
410+
contextTokenBudget: 200_000,
411+
});
412+
413+
// 15% of 200 000 = 30 000 keepRecentTokens.
414+
expect(result.compaction.keepRecentTokens).toBe(30_000);
415+
});
332416
});
333417

334418
describe("resolveCompactionReserveTokensFloor", () => {
@@ -348,6 +432,40 @@ describe("resolveCompactionReserveTokensFloor", () => {
348432
}),
349433
).toBe(0);
350434
});
435+
436+
it("resolves reserveTokensFloorShare against the active context budget (#72790)", () => {
437+
expect(
438+
resolveCompactionReserveTokensFloor(
439+
{
440+
agents: { defaults: { compaction: { reserveTokensFloorShare: 0.1 } } },
441+
},
442+
200_000,
443+
),
444+
).toBe(20_000);
445+
});
446+
447+
it("absolute reserveTokensFloor wins over reserveTokensFloorShare (#72790)", () => {
448+
expect(
449+
resolveCompactionReserveTokensFloor(
450+
{
451+
agents: {
452+
defaults: {
453+
compaction: { reserveTokensFloor: 15_000, reserveTokensFloorShare: 0.1 },
454+
},
455+
},
456+
},
457+
200_000,
458+
),
459+
).toBe(15_000);
460+
});
461+
462+
it("falls back to the default when reserveTokensFloorShare is set but context budget is unknown (#72790)", () => {
463+
expect(
464+
resolveCompactionReserveTokensFloor({
465+
agents: { defaults: { compaction: { reserveTokensFloorShare: 0.1 } } },
466+
}),
467+
).toBe(DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR);
468+
});
351469
});
352470
describe("resolveEffectiveCompactionMode", () => {
353471
it("defaults to default compaction mode", () => {

src/agents/pi-settings.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,48 @@ export function ensurePiCompactionReserveTokens(params: {
4343
return { didOverride: true, reserveTokens: minReserveTokens };
4444
}
4545

46-
export function resolveCompactionReserveTokensFloor(cfg?: OpenClawConfig): number {
47-
const raw = cfg?.agents?.defaults?.compaction?.reserveTokensFloor;
46+
/**
47+
* Resolve a context-window-relative share (in (0, 1]) into an absolute token
48+
* count. Returns undefined when the share is not configured or when the
49+
* caller did not supply a context budget. Always rounds down so the resolved
50+
* value never exceeds `floor(share * contextTokenBudget)`. See #72790.
51+
*/
52+
export function resolveCompactionShareTokens(
53+
share: unknown,
54+
contextTokenBudget: number | undefined,
55+
): number | undefined {
56+
if (
57+
typeof share !== "number" ||
58+
!Number.isFinite(share) ||
59+
share <= 0 ||
60+
share > 1 ||
61+
typeof contextTokenBudget !== "number" ||
62+
!Number.isFinite(contextTokenBudget) ||
63+
contextTokenBudget <= 0
64+
) {
65+
return undefined;
66+
}
67+
return Math.max(0, Math.floor(share * contextTokenBudget));
68+
}
69+
70+
export function resolveCompactionReserveTokensFloor(
71+
cfg?: OpenClawConfig,
72+
contextTokenBudget?: number,
73+
): number {
74+
const compactionCfg = cfg?.agents?.defaults?.compaction;
75+
const raw = compactionCfg?.reserveTokensFloor;
4876
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) {
4977
return Math.floor(raw);
5078
}
79+
// Fall through to the share-based variant only when the absolute is
80+
// unset, so the existing absolute-wins precedence is preserved.
81+
const fromShare = resolveCompactionShareTokens(
82+
compactionCfg?.reserveTokensFloorShare,
83+
contextTokenBudget,
84+
);
85+
if (fromShare !== undefined) {
86+
return fromShare;
87+
}
5188
return DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR;
5289
}
5390

@@ -78,9 +115,25 @@ export function applyPiCompactionSettingsFromConfig(params: {
78115
const currentKeepRecentTokens = params.settingsManager.getCompactionKeepRecentTokens();
79116
const compactionCfg = params.cfg?.agents?.defaults?.compaction;
80117

81-
const configuredReserveTokens = toNonNegativeInt(compactionCfg?.reserveTokens);
82-
const configuredKeepRecentTokens = toPositiveInt(compactionCfg?.keepRecentTokens);
83-
let reserveTokensFloor = resolveCompactionReserveTokensFloor(params.cfg);
118+
// Absolute config wins; share-based is consulted only when the absolute
119+
// sibling field is unset. Both happen *before* the floor cap so the share
120+
// resolves against the user's intended context budget. See #72790.
121+
const reserveTokensFromShare = resolveCompactionShareTokens(
122+
compactionCfg?.reserveTokensShare,
123+
params.contextTokenBudget,
124+
);
125+
const keepRecentTokensFromShare = resolveCompactionShareTokens(
126+
compactionCfg?.keepRecentTokensShare,
127+
params.contextTokenBudget,
128+
);
129+
const configuredReserveTokens =
130+
toNonNegativeInt(compactionCfg?.reserveTokens) ?? reserveTokensFromShare;
131+
const configuredKeepRecentTokens =
132+
toPositiveInt(compactionCfg?.keepRecentTokens) ??
133+
(keepRecentTokensFromShare && keepRecentTokensFromShare > 0
134+
? keepRecentTokensFromShare
135+
: undefined);
136+
let reserveTokensFloor = resolveCompactionReserveTokensFloor(params.cfg, params.contextTokenBudget);
84137

85138
// Cap the floor to a safe fraction of the context window so that
86139
// small-context models (e.g. Ollama with 16 K tokens) are not starved of

src/config/types.agent-defaults.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,27 @@ export type AgentCompactionConfig = {
490490
keepRecentTokens?: number;
491491
/** Minimum reserve tokens enforced for Pi compaction (0 disables the floor). */
492492
reserveTokensFloor?: number;
493+
/**
494+
* Context-window-relative variant of `reserveTokens`. Resolved at runtime
495+
* against the active model's context budget when known, and ignored when
496+
* the absolute `reserveTokens` is also set. Value must be in (0, 1].
497+
* See #72790.
498+
*/
499+
reserveTokensShare?: number;
500+
/**
501+
* Context-window-relative variant of `keepRecentTokens`. Resolved at
502+
* runtime against the active model's context budget when known, and
503+
* ignored when the absolute `keepRecentTokens` is also set. Value must
504+
* be in (0, 1]. See #72790.
505+
*/
506+
keepRecentTokensShare?: number;
507+
/**
508+
* Context-window-relative variant of `reserveTokensFloor`. Resolved at
509+
* runtime against the active model's context budget when known, and
510+
* ignored when the absolute `reserveTokensFloor` is also set. Value
511+
* must be in (0, 1]. See #72790.
512+
*/
513+
reserveTokensFloorShare?: number;
493514
/** Max share of context window for history during safeguard pruning (0.1–0.9, default 0.5). */
494515
maxHistoryShare?: number;
495516
/** Additional compaction-summary instructions that can preserve language or persona continuity. */

src/config/zod-schema.agent-defaults.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@ export const AgentDefaultsSchema = z
178178
reserveTokens: z.number().int().nonnegative().optional(),
179179
keepRecentTokens: z.number().int().positive().optional(),
180180
reserveTokensFloor: z.number().int().nonnegative().optional(),
181+
// Context-window-relative variants — value in (0, 1]. Resolved
182+
// against the active model's context budget at runtime; ignored
183+
// when the absolute sibling field is also set. See #72790.
184+
reserveTokensShare: z.number().gt(0).max(1).optional(),
185+
keepRecentTokensShare: z.number().gt(0).max(1).optional(),
186+
reserveTokensFloorShare: z.number().gt(0).max(1).optional(),
181187
maxHistoryShare: z.number().min(0.1).max(0.9).optional(),
182188
customInstructions: z.string().optional(),
183189
identifierPolicy: z

0 commit comments

Comments
 (0)