diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 2ac374419..f54227e49 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -110,6 +110,31 @@ Override the bounds in milliseconds with `MCODE_LLM_FIRST_EVENT_TIMEOUT_MS` and also set `firstEventTimeoutMs` and `streamIdleTimeoutMs` per model. The timeout error names the setting that fired. The 20-minute overall request limit still applies. +## Automatic context compaction trigger + +Automatic compaction starts before a model request whose context exceeds the +built-in trigger. That trigger reserves the configured output limit (capped at a +quarter of the window) and then takes 95% of the remainder, so it lands between +about 70% and 95% of the context window depending on the model. Large tool +results and the serialized request size can also start compaction on their own; +tool-result reduction is configured separately under `toolResultCompaction`. + +To compact earlier, for example to keep requests smaller, set an upper bound in +the data directory's `config.yaml`: + +```yaml +contextManagement: + compactionTriggerAt: 0.8 # ratio of the context window, or e.g. 300000 tokens +``` + +A value in `(0, 1]` is a ratio of the current model's context window; an integer +above 1 is an absolute token count. The effective trigger is the lower of this +value and the built-in trigger, so the setting can start compaction earlier but +never later, and has no lower bound: a value below what remains after compaction +compacts before every request. Other values, such as `1.5`, are ignored with a +warning in the runtime log. Automatic compaction cannot be disabled; `/compact` +remains available at any time. Apply changes by restarting MCode. + ## Local Bash execution When the current turn includes native `task_output`, foreground Bash waits up to diff --git a/packages/agent-modules/context-manager/src/index.ts b/packages/agent-modules/context-manager/src/index.ts index e7ffca6c7..9303c8436 100644 --- a/packages/agent-modules/context-manager/src/index.ts +++ b/packages/agent-modules/context-manager/src/index.ts @@ -1,6 +1,11 @@ export { ContextManager } from './manager.js'; export { computeCompactionTriggerAt, DEFAULT_CONTEXT_MANAGER_SETTINGS } from './settings.js'; -export { resolveCompactionTokenBudget, resolveDynamicMaxTokens } from './provider-budget.js'; +export { + isValidCompactionTriggerSetting, + resolveCompactionTokenBudget, + resolveConfiguredCompactionTrigger, + resolveDynamicMaxTokens, +} from './provider-budget.js'; export { BpeTokenEstimator, createDefaultContextTokenEstimator, diff --git a/packages/agent-modules/context-manager/src/provider-budget.ts b/packages/agent-modules/context-manager/src/provider-budget.ts index c8bce00c9..0b07b8287 100644 --- a/packages/agent-modules/context-manager/src/provider-budget.ts +++ b/packages/agent-modules/context-manager/src/provider-budget.ts @@ -19,6 +19,11 @@ export function resolveDynamicMaxTokens(input: { export function resolveCompactionTokenBudget(input: { readonly contextWindow: number; readonly configuredMaxOutputTokens: number; + /** + * User `contextManagement.compactionTriggerAt`. It can only lower the + * automatic trigger; invalid values are ignored like an unset setting. + */ + readonly compactionTriggerAt?: number | undefined; }): { readonly providerInputLimit: number; readonly automaticTriggerAt: number } { const configuredOutput = positive(input.configuredMaxOutputTokens); const { reserveTokens, safetyMarginTokens } = DEFAULT_CONTEXT_MANAGER_SETTINGS; @@ -46,12 +51,38 @@ export function resolveCompactionTokenBudget(input: { const outputReservedTrigger = Math.floor( ((input.contextWindow - outputReserve) * AUTOMATIC_TRIGGER_PERCENT) / 100, ); + const builtInTrigger = Math.min(providerInputLimit, Math.max(1, outputReservedTrigger)); + const configuredTrigger = resolveConfiguredCompactionTrigger( + input.compactionTriggerAt, + input.contextWindow, + ); return { providerInputLimit, - automaticTriggerAt: Math.min(providerInputLimit, Math.max(1, outputReservedTrigger)), + automaticTriggerAt: + configuredTrigger === undefined ? builtInTrigger : Math.min(builtInTrigger, configuredTrigger), }; } +/** + * `compactionTriggerAt` accepts a window ratio in (0, 1] or an absolute token + * count as an integer above 1. Anything else, such as 1.5, is invalid. + */ +export function isValidCompactionTriggerSetting(value: number): boolean { + if (!Number.isFinite(value) || value <= 0) return false; + return value <= 1 || Number.isSafeInteger(value); +} + +/** Resolves the user setting to tokens, or undefined when it should be ignored. */ +export function resolveConfiguredCompactionTrigger( + value: number | undefined, + contextWindow: number, +): number | undefined { + if (value === undefined || !isValidCompactionTriggerSetting(value)) return undefined; + if (value > 1) return value; + if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined; + return Math.max(1, Math.floor(contextWindow * value)); +} + function outputFloor(configuredMaxTokens: number): number { return Math.min(configuredMaxTokens, DEFAULT_CONTEXT_MANAGER_SETTINGS.reserveTokens); } diff --git a/packages/agent-modules/context-manager/test/provider-budget.test.ts b/packages/agent-modules/context-manager/test/provider-budget.test.ts index fa932acdd..85bad5c67 100644 --- a/packages/agent-modules/context-manager/test/provider-budget.test.ts +++ b/packages/agent-modules/context-manager/test/provider-budget.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + isValidCompactionTriggerSetting, resolveCompactionTokenBudget, + resolveConfiguredCompactionTrigger, resolveDynamicMaxTokens, } from '../src/provider-budget.js'; @@ -102,4 +104,85 @@ describe('resolveCompactionTokenBudget', () => { }), ).toEqual({ providerInputLimit: 1, automaticTriggerAt: 1 }); }); + + it.each([ + ['a window ratio', 0.5, 100_000], + ['an absolute token count', 120_000, 120_000], + ['a tiny absolute count without a floor', 5_000, 5_000], + ] as const)('lowers the automatic trigger to %s', (_case, compactionTriggerAt, expected) => { + const budget = resolveCompactionTokenBudget({ + contextWindow: 200_000, + configuredMaxOutputTokens: 32_000, + compactionTriggerAt, + }); + expect(budget).toEqual({ providerInputLimit: 181_568, automaticTriggerAt: expected }); + }); + + it.each([ + ['a ratio above the built-in line', 0.95], + ['the whole window', 1], + ['an absolute count above the built-in line', 190_000], + ] as const)('keeps the built-in trigger for %s', (_case, compactionTriggerAt) => { + expect( + resolveCompactionTokenBudget({ + contextWindow: 200_000, + configuredMaxOutputTokens: 32_000, + compactionTriggerAt, + }).automaticTriggerAt, + ).toBe(159_600); + }); + + it.each([1.5, 0, -1, Number.NaN, Number.POSITIVE_INFINITY, undefined])( + 'ignores an invalid compaction trigger: %s', + (compactionTriggerAt) => { + expect( + resolveCompactionTokenBudget({ + contextWindow: 200_000, + configuredMaxOutputTokens: 32_000, + compactionTriggerAt, + }).automaticTriggerAt, + ).toBe(159_600); + }, + ); +}); + +describe('resolveConfiguredCompactionTrigger', () => { + it.each([ + [0.8, 200_000, 160_000], + [1, 200_000, 200_000], + [0.000001, 200_000, 1], + [300_000, 200_000, 300_000], + [2, 200_000, 2], + ] as const)('resolves %s against a %i-token window to %i', (value, window, expected) => { + expect(resolveConfiguredCompactionTrigger(value, window)).toBe(expected); + }); + + it.each([ + [1.5, 200_000], + [0, 200_000], + [-0.5, 200_000], + [Number.NaN, 200_000], + [Number.POSITIVE_INFINITY, 200_000], + [0.8, 0], + [0.8, Number.NaN], + ] as const)('treats %s with a %s-token window as unset', (value, window) => { + expect(resolveConfiguredCompactionTrigger(value, window)).toBeUndefined(); + }); + + it('accepts an absolute count even when the window is unknown', () => { + expect(resolveConfiguredCompactionTrigger(50_000, Number.NaN)).toBe(50_000); + }); +}); + +describe('isValidCompactionTriggerSetting', () => { + it.each([0.5, 1, 2, 300_000])('accepts %s', (value) => { + expect(isValidCompactionTriggerSetting(value)).toBe(true); + }); + + it.each([1.5, 0, -1, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_VALUE])( + 'rejects %s', + (value) => { + expect(isValidCompactionTriggerSetting(value)).toBe(false); + }, + ); }); diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts index db95f7f81..deb73c403 100644 --- a/packages/config/src/config.ts +++ b/packages/config/src/config.ts @@ -886,6 +886,17 @@ export interface ContextManagementConfig { * Default: 5. */ cuMaxScreenshotsInContext: number; + + /** + * Upper bound for the automatic context compaction trigger. + * + * A value in `(0, 1]` is a ratio of the current model's context window; + * an integer above 1 is an absolute token count. The effective trigger is + * the lower of this value and the built-in trigger, so it can only make + * compaction start earlier. The runtime ignores invalid numbers (such as + * `1.5`) with a warning. Unset keeps the built-in behavior. + */ + compactionTriggerAt?: number; } /** @@ -2493,7 +2504,18 @@ function parseContextManagementConfig( ? Math.floor(rawCuMax) : defaults.cuMaxScreenshotsInContext; - return { disableSystemReminderModels, cuMaxScreenshotsInContext }; + // Semantic validation (ratio vs. token count) lives with the trigger in + // @mavis/context-manager; keep numbers so the runtime can warn on bad ones. + const compactionTriggerAt = + typeof obj.compactionTriggerAt === "number" + ? obj.compactionTriggerAt + : undefined; + + return { + disableSystemReminderModels, + cuMaxScreenshotsInContext, + ...(compactionTriggerAt === undefined ? {} : { compactionTriggerAt }), + }; } // ── Agent Runtime config parsing — see ./agent-runtime-config.ts for the diff --git a/packages/config/test/context-management-config.test.ts b/packages/config/test/context-management-config.test.ts new file mode 100644 index 000000000..186d1084d --- /dev/null +++ b/packages/config/test/context-management-config.test.ts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveConfigFromRaw } from "../src/config.js"; + +let dataDir: string; + +beforeEach(() => { + dataDir = fs.mkdtempSync(join(os.tmpdir(), "mcode-context-management-")); + vi.stubEnv("MINIMAX_DATA_DIR", dataDir); + vi.stubEnv("__MAVIS_RUNTIME_DATA_DIR", dataDir); + vi.stubEnv("__MAVIS_RUNTIME_DISABLE_GIT_AUTO_CONFIG", "1"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +function contextManagement(raw: Record) { + return resolveConfigFromRaw(raw, dataDir).contextManagement; +} + +describe("contextManagement.compactionTriggerAt", () => { + it("is absent by default", () => { + expect(contextManagement({})).not.toHaveProperty("compactionTriggerAt"); + expect(contextManagement({ contextManagement: {} })).not.toHaveProperty( + "compactionTriggerAt", + ); + }); + + it.each([0.8, 300_000, 1.5, -1])( + "keeps the number %s for runtime validation", + (value) => { + expect( + contextManagement({ contextManagement: { compactionTriggerAt: value } }), + ).toMatchObject({ compactionTriggerAt: value }); + }, + ); + + it.each(["0.8", null, true, { ratio: 0.8 }])( + "drops the non-number %j", + (value) => { + expect( + contextManagement({ contextManagement: { compactionTriggerAt: value } }), + ).not.toHaveProperty("compactionTriggerAt"); + }, + ); + + it("does not disturb the sibling keys", () => { + expect( + contextManagement({ + contextManagement: { + compactionTriggerAt: 0.5, + cuMaxScreenshotsInContext: 3, + }, + }), + ).toEqual({ + disableSystemReminderModels: [], + cuMaxScreenshotsInContext: 3, + compactionTriggerAt: 0.5, + }); + }); +}); diff --git a/packages/local-runtime-v2/src/service/model-system/contracts.ts b/packages/local-runtime-v2/src/service/model-system/contracts.ts index 7505cf2d1..fc4698212 100644 --- a/packages/local-runtime-v2/src/service/model-system/contracts.ts +++ b/packages/local-runtime-v2/src/service/model-system/contracts.ts @@ -134,6 +134,8 @@ export interface LocalConversationRuntimeConfig extends LocalRuntimeConfig { readonly runawayGuard?: Config['runawayGuard']; /** ToolResult compaction values resolved from config.yaml. */ readonly toolResultCompaction?: Config['toolResultCompaction']; + /** Context management values resolved from config.yaml. */ + readonly contextManagement?: Partial; /** * Widened past `Config['agents']`: the v2 Agent cutover also threads * already-resolved capabilities through this read model, and `default` is diff --git a/packages/local-runtime-v2/src/service/turn-system/compaction/automatic-context-compactor.test.ts b/packages/local-runtime-v2/src/service/turn-system/compaction/automatic-context-compactor.test.ts index 6cd430fca..8e78d13a5 100644 --- a/packages/local-runtime-v2/src/service/turn-system/compaction/automatic-context-compactor.test.ts +++ b/packages/local-runtime-v2/src/service/turn-system/compaction/automatic-context-compactor.test.ts @@ -988,6 +988,52 @@ describe('AutomaticContextCompactor request boundaries', () => { ).rejects.toThrow(/streamFn/i); }); + it('lowers the trigger from the configured compactionTriggerAt only', () => { + const messages: AgentMessage[] = [ + { role: 'user', content: [{ type: 'text', text: 'small context' }], timestamp: 1 }, + ]; + const model: Model<'anthropic-messages'> = { + id: 'model-1', + name: 'Model 1', + api: 'anthropic-messages', + provider: 'test', + baseUrl: 'https://example.invalid', + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 32_000, + }; + const rawTokens = createLocalContextFootprintMeasurer({ model }).measure(messages).inputTokens; + const input = { + sessionId: 'session-1', + phase: 'initial' as const, + messages, + model, + thinkingLevel: 'off' as const, + }; + const probe = (getCompactionTriggerAt?: () => number | undefined) => + automaticCompactor(undefined, undefined, undefined, { + ...(getCompactionTriggerAt ? { getCompactionTriggerAt } : {}), + }).probeBeforeLlm(input).shouldStart; + + expect(probe()).toBe(false); + // Absolute token count: strict greater-than, like the built-in trigger. + expect(probe(() => rawTokens)).toBe(false); + expect(probe(() => rawTokens - 1)).toBe(true); + // Window ratio. + expect(probe(() => (rawTokens - 1) / 200_000)).toBe(true); + // Above the built-in line, invalid, or unreadable: built-in trigger stays. + expect(probe(() => 0.99)).toBe(false); + expect(probe(() => 1.5)).toBe(false); + expect(probe(() => undefined)).toBe(false); + expect( + probe(() => { + throw new Error('config unavailable'); + }), + ).toBe(false); + }); + it('uses the requested Messages API maxTokens at the trigger boundary', async () => { const messages: AgentMessage[] = [ { role: 'user', content: [{ type: 'text', text: 'small context' }], timestamp: 1 }, diff --git a/packages/local-runtime-v2/src/service/turn-system/compaction/automatic-context-compactor.ts b/packages/local-runtime-v2/src/service/turn-system/compaction/automatic-context-compactor.ts index 885483207..335dcd9fb 100644 --- a/packages/local-runtime-v2/src/service/turn-system/compaction/automatic-context-compactor.ts +++ b/packages/local-runtime-v2/src/service/turn-system/compaction/automatic-context-compactor.ts @@ -33,11 +33,14 @@ import type { SubagentCheckpointStateSource } from './contracts.js'; interface AutomaticContextCompactorDependencies { readonly toolResultArchiver?: ToolResultArchiver; readonly promptSnapshots?: PromptSnapshotSource; + /** User `contextManagement.compactionTriggerAt`; read per call. */ + readonly getCompactionTriggerAt?: () => number | undefined; } export class AutomaticContextCompactor implements AutomaticContextCompactorPort { private readonly toolResultArchiver?: ToolResultArchiver; private readonly promptSnapshots?: PromptSnapshotSource; + private readonly getCompactionTriggerAt?: () => number | undefined; constructor( private readonly usageAnchor: ContextUsageAnchorState | undefined, @@ -47,13 +50,18 @@ export class AutomaticContextCompactor implements AutomaticContextCompactorPort ) { this.toolResultArchiver = dependencies.toolResultArchiver; this.promptSnapshots = dependencies.promptSnapshots; + this.getCompactionTriggerAt = dependencies.getCompactionTriggerAt; } probeBeforeLlm(input: AutomaticContextCompactionInput): AutomaticContextCompactionProbe { input.signal?.throwIfAborted(); return { - shouldStart: prepareAutomaticCompaction(input, this.usageAnchor, this.toolResultArchiver) - .shouldStart, + shouldStart: prepareAutomaticCompaction( + input, + this.usageAnchor, + this.toolResultArchiver, + this.readCompactionTriggerAt(), + ).shouldStart, }; } @@ -66,6 +74,7 @@ export class AutomaticContextCompactor implements AutomaticContextCompactorPort input, this.usageAnchor, this.toolResultArchiver, + this.readCompactionTriggerAt(), ); if (!preparation.shouldStart) { return { status: 'unchanged', reason: 'nothing-to-compact' }; @@ -99,18 +108,29 @@ export class AutomaticContextCompactor implements AutomaticContextCompactorPort this.usageAnchor?.invalidate(input.sessionId); return completedAutomaticCompaction(input.messages, decision); } + + /** A failing config read degrades to the built-in trigger. */ + private readCompactionTriggerAt(): number | undefined { + try { + return this.getCompactionTriggerAt?.(); + } catch { + return undefined; + } + } } function prepareAutomaticCompaction( input: AutomaticContextCompactionInput, usageAnchor: ContextUsageAnchorState | undefined, archiver: ToolResultArchiver | undefined, + compactionTriggerAt: number | undefined, ) { const measurer = createAutomaticFootprintMeasurer(input, usageAnchor); const footprint = measurer.measure(input.messages); const { providerInputLimit, automaticTriggerAt } = resolveCompactionTokenBudget({ contextWindow: input.model.contextWindow, configuredMaxOutputTokens: input.maxTokens ?? input.model.maxTokens, + compactionTriggerAt, }); const toolResultCompactionPlan = archiver?.plan({ sessionId: input.sessionId, diff --git a/packages/local-runtime-v2/src/service/turn-system/production-composition.ts b/packages/local-runtime-v2/src/service/turn-system/production-composition.ts index 4a418e787..fccfe3954 100644 --- a/packages/local-runtime-v2/src/service/turn-system/production-composition.ts +++ b/packages/local-runtime-v2/src/service/turn-system/production-composition.ts @@ -20,6 +20,7 @@ import { type ResolvedAgentCapabilities, type RunawayGuardOverride, } from '@mavis/config'; +import { isValidCompactionTriggerSetting } from '@mavis/context-manager'; import type { AppDb } from '../../infra/db/client.js'; import type { ContentSafetyService } from '../content-safety/index.js'; @@ -468,6 +469,7 @@ export async function createLocalAgentHost< logger: options.product.runner.logger, toolResultArchiver, promptSnapshots: options.product.promptSnapshots, + getCompactionTriggerAt: createCompactionTriggerAtGetter(options), }), control: options.turnControl, lifecycle: options.sessions.agentProjection.compactionLifecycle, @@ -597,6 +599,7 @@ function createContextCompactors(input: { readonly logger: ConstructorParameters[2]; readonly toolResultArchiver: ToolResultArchiver; readonly promptSnapshots: ConstructorParameters[2]; + readonly getCompactionTriggerAt: () => number | undefined; }) { return { automatic: new AutomaticContextCompactor( @@ -606,12 +609,36 @@ function createContextCompactors(input: { { toolResultArchiver: input.toolResultArchiver, promptSnapshots: input.promptSnapshots, + getCompactionTriggerAt: input.getCompactionTriggerAt, }, ), manual: new LocalContextCompactor(input.checkpointState, input.logger, input.promptSnapshots), }; } +/** + * Reads `contextManagement.compactionTriggerAt` per call. An invalid value is + * reported once when the host is composed and then ignored by the budget. + */ +function createCompactionTriggerAtGetter( + options: CreateLocalAgentHostOptions, +): () => number | undefined { + const read = () => + options.product.preparation.configBuilder.config().contextManagement?.compactionTriggerAt; + try { + const initial = read(); + if (initial !== undefined && !isValidCompactionTriggerSetting(initial)) { + options.product.runner.logger?.warn?.( + { event: 'context_compaction_trigger_config_ignored', value: initial }, + '[local-runtime-v2] contextManagement.compactionTriggerAt must be a ratio in (0, 1] or an integer token count above 1; using the built-in trigger', + ); + } + } catch { + // The compactor treats an unreadable config as unset. + } + return read; +} + function createToolResultCompactionConfigGetter( options: CreateLocalAgentHostOptions, ): () => ToolResultCompactionConfig { diff --git a/release/public-source.json b/release/public-source.json index 0d4938ed6..35cb481d5 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -515,6 +515,7 @@ "packages/config/test/builtin-model-fallback.test.ts", "packages/config/test/config-file-permissions.test.ts", "packages/config/test/config-update-preservation.test.ts", + "packages/config/test/context-management-config.test.ts", "packages/config/test/managed-preset-sync.test.ts", "packages/config/test/private-config-file.test.ts", "packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 16edbdedd..bfe9bd78d 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -180,6 +180,7 @@ "packages/config/test/managed-preset-sync.test.ts", "packages/config/test/builtin-model-fallback.test.ts", "packages/config/test/config-update-preservation.test.ts", + "packages/config/test/context-management-config.test.ts", "packages/agent-modules/context-manager/test/compaction-usage.test.ts", "packages/local-runtime/test/unit/session-diff-fallback.test.ts", "test/history-processing.test.ts",