diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index f54227e49..68cebe5ca 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -119,21 +119,53 @@ 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`: +To compact earlier, for example to keep requests smaller, run `/autocompact` in a +Session to set the trigger for the current model: + +```text +/autocompact # show the current model's trigger, its share of the window, and its source +/autocompact 300000 # a token count; 500k and 1.5M also work, and a bare 100-1000 means thousands (200 = 200K) +/autocompact auto # use the built-in trigger for the current model again +``` + +The setting is saved under `modelSettings` in the data directory's `config.yaml`, +in the same shape as Claude Code's `modelSettings..autoCompactWindow`. The +key is the Session's selected `provider/model`, such as `minimax/MiniMax-M3` or +`custom_provider:/`; `/autocompact` prints it: + +```yaml +modelSettings: + "minimax/MiniMax-M3": + autoCompactWindow: 300000 # an integer token count above 1, or "auto" +``` + +A global upper bound for every model is also available: ```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. +For `compactionTriggerAt`, a value in `(0, 1]` is a ratio of the current model's +context window and an integer above 1 is an absolute token count. + +Precedence is the current model's `autoCompactWindow`, then the global +`compactionTriggerAt`, then the built-in trigger. `autoCompactWindow: auto` uses +the built-in trigger for that model and ignores the global value. The effective +trigger is the lower of the selected value and the built-in trigger, so a setting +can start compaction earlier but never later. A value above the built-in trigger +is saved as given, the built-in trigger still applies, and `/autocompact` says so. +There is no lower bound and, unlike Claude Code, no 100K-1M clamp: a value below +what remains after compaction compacts before every request. Invalid values, such +as `autoCompactWindow: 0` or `compactionTriggerAt: 1.5`, are ignored with a +warning in the runtime log. + +The Auto-compact line in `/context` shows the current trigger, its share of the +window, and its source. A value saved with `/autocompact` applies from the next +model request. The save reserializes `config.yaml`, so comment formatting is not +retained. After editing `config.yaml` by hand, run `/reload` or restart MCode. +ACP clients can use `/autocompact` too. Automatic compaction cannot be disabled; +`/compact` remains available at any time. ## Local Bash execution diff --git a/packages/config/src/auto-compact-window.ts b/packages/config/src/auto-compact-window.ts new file mode 100644 index 000000000..6b5905aaf --- /dev/null +++ b/packages/config/src/auto-compact-window.ts @@ -0,0 +1,149 @@ +/** + * Per-model automatic compaction trigger, stored like Claude Code's + * `modelSettings..autoCompactWindow`: + * + * ```yaml + * modelSettings: + * "minimax/MiniMax-M3": + * autoCompactWindow: 400000 # token count, or "auto" for the built-in trigger + * ``` + * + * The key is the source-qualified `provider/model` id. Model ids may contain + * dots, so the key is never addressed through a dotted config path. + */ + +export type AutoCompactWindowSetting = number | 'auto'; + +export interface ModelSettingsEntry { + /** Positive integer token count above 1, or `auto`; other numbers are ignored at runtime. */ + readonly autoCompactWindow?: AutoCompactWindowSetting; +} + +export type ModelSettingsConfig = Readonly>; + +export type AutoCompactWindowInput = + | { readonly kind: 'auto' } + | { readonly kind: 'tokens'; readonly tokens: number } + | { readonly kind: 'invalid' }; + +/** Which setting decides a model's automatic compaction trigger. */ +export type CompactionTriggerSetting = + | { readonly source: 'model'; readonly value: number } + | { readonly source: 'model-auto' } + | { readonly source: 'global'; readonly value: number } + | { readonly source: 'built-in' }; + +interface CompactionTriggerConfig { + readonly modelSettings?: ModelSettingsConfig; + readonly contextManagement?: { readonly compactionTriggerAt?: number }; +} + +const INPUT_PATTERN = /^(\d+)(?:\.(\d+))?([km])?$/u; +const SUFFIX_EXPONENT = { k: 3, m: 6 } as const; +const THOUSANDS_SHORTHAND = { min: 100, max: 1_000 } as const; + +/** + * A token count above 1. `contextManagement.compactionTriggerAt` reads 1 as the + * whole window, so a per-model value of 1 would be ambiguous and is rejected. + */ +export function isValidAutoCompactWindowTokens(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 1; +} + +/** + * Parses `/autocompact` input with Claude Code's grammar: a token count, a + * `k`/`M` suffix (decimals allowed when the result is whole), a bare 100–1000 + * meaning thousands, or `auto`. Unlike Claude Code there is no lower bound or + * 1M clamp; the built-in trigger still caps the effective value. + */ +export function parseAutoCompactWindowInput(text: string): AutoCompactWindowInput { + const normalized = text.trim().toLowerCase(); + if (normalized === 'auto') return { kind: 'auto' }; + const match = INPUT_PATTERN.exec(normalized); + if (!match) return { kind: 'invalid' }; + const [, whole = '', fraction, suffix] = match; + let tokens: number | undefined; + if (suffix === 'k' || suffix === 'm') { + tokens = scaleDecimal(whole, fraction ?? '', SUFFIX_EXPONENT[suffix]); + } else if (fraction === undefined) { + const value = Number(whole); + tokens = + value >= THOUSANDS_SHORTHAND.min && value <= THOUSANDS_SHORTHAND.max ? value * 1_000 : value; + } + return isValidAutoCompactWindowTokens(tokens) ? { kind: 'tokens', tokens } : { kind: 'invalid' }; +} + +/** Exact decimal scaling; undefined when the result is not a whole number. */ +function scaleDecimal(whole: string, fraction: string, exponent: number): number | undefined { + // A loop instead of /0+$/, which backtracks quadratically on long zero runs. + let end = fraction.length; + while (end > 0 && fraction.endsWith('0', end)) end -= 1; + const significant = fraction.slice(0, end); + if (significant.length > exponent) return undefined; + return Number(`${whole}${significant.padEnd(exponent, '0')}`); +} + +/** + * Keeps numbers for runtime validation and only the exact string `auto`; + * entries without a recognized field are dropped. + */ +export function parseModelSettings(raw: unknown): ModelSettingsConfig | undefined { + if (!isPlainRecord(raw)) return undefined; + const entries = Object.entries(raw).flatMap(([key, entry]) => { + if (!isPlainRecord(entry)) return []; + const value = entry.autoCompactWindow; + return typeof value === 'number' || value === 'auto' + ? [[key, { autoCompactWindow: value }] as const] + : []; + }); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +/** + * Picks the trigger setting for one `provider/model` key: a valid per-model + * value (`auto` included) wins over `contextManagement.compactionTriggerAt`, + * which wins over the built-in trigger. The global value is passed through + * as-is; the compaction budget validates it. + */ +export function selectCompactionTriggerSetting( + config: CompactionTriggerConfig, + modelKey: string, +): CompactionTriggerSetting { + const value = readModelSetting(config.modelSettings, modelKey)?.autoCompactWindow; + if (value === 'auto') return { source: 'model-auto' }; + if (isValidAutoCompactWindowTokens(value)) return { source: 'model', value }; + const global = config.contextManagement?.compactionTriggerAt; + return typeof global === 'number' ? { source: 'global', value: global } : { source: 'built-in' }; +} + +/** The `compactionTriggerAt` budget input a setting stands for; undefined keeps the built-in. */ +export function compactionTriggerAtForSetting( + setting: CompactionTriggerSetting, +): number | undefined { + return setting.source === 'model' || setting.source === 'global' ? setting.value : undefined; +} + +/** Per-model numbers the runtime ignores, for a one-time configuration warning. */ +export function findInvalidAutoCompactWindows( + modelSettings: ModelSettingsConfig | undefined, +): readonly { readonly modelKey: string; readonly value: unknown }[] { + return Object.entries(modelSettings ?? {}).flatMap(([modelKey, entry]) => { + const value = entry.autoCompactWindow; + return value === undefined || value === 'auto' || isValidAutoCompactWindowTokens(value) + ? [] + : [{ modelKey, value }]; + }); +} + +function readModelSetting( + modelSettings: ModelSettingsConfig | undefined, + modelKey: string, +): ModelSettingsEntry | undefined { + return modelSettings && Object.hasOwn(modelSettings, modelKey) + ? modelSettings[modelKey] + : undefined; +} + +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts index 84660a0cb..1d5e0e22d 100644 --- a/packages/config/src/config.ts +++ b/packages/config/src/config.ts @@ -75,6 +75,7 @@ import { SANDBOX_CONFIG_DEFAULTS, type SandboxConfig, } from "./sandbox-config.js"; +import { parseModelSettings, type ModelSettingsConfig } from "./auto-compact-window.js"; export type { TuiConfig, TuiCustomStatusLineConfig } from "./tui-config.js"; @@ -894,7 +895,9 @@ export interface ContextManagementConfig { * 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. + * `1.5`) with a warning. Unset keeps the built-in behavior. A valid + * `modelSettings..autoCompactWindow` (including `auto`) + * replaces this value for that model. */ compactionTriggerAt?: number; } @@ -1008,6 +1011,11 @@ export interface Config { opencode: OpenCodeAdapterConfig; /** Per-model context management overrides (e.g. disable SR for low-context models). */ contextManagement: ContextManagementConfig; + /** + * Per-model settings keyed by the source-qualified `provider/model` id, such + * as `autoCompactWindow`; see ./auto-compact-window.ts. Undefined when absent. + */ + modelSettings?: ModelSettingsConfig; /** Local Runaway Guard policy; valid Apollo booleans override it on Desktop. */ runawayGuard: RunawayGuardSettings; /** ToolResult archive/trim thresholds shared by TUI, CLI and managed Desktop. */ @@ -2060,6 +2068,7 @@ export function resolveConfigFromRaw( telemetry: parseTelemetryConfig(raw.telemetry), opencode: parseOpenCodeAdapterConfig(raw, DEFAULTS.opencode), contextManagement: parseContextManagementConfig(raw), + modelSettings: parseModelSettings(raw.modelSettings), runawayGuard: resolveRunawayGuardConfig(raw.runawayGuard), toolResultCompaction: parseToolResultCompactionConfig( raw.toolResultCompaction, diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index b9cac3d35..4a44d49c0 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -87,9 +87,24 @@ export { compareAndSetLocalModelContext, removeLocalProviderConfig, replaceLocalManagedMinimaxProvider, + updateLocalAutoCompactWindow, updateLocalByokConfig, updateLocalModelSelection, } from './local-model-provider-write.js'; +export { + compactionTriggerAtForSetting, + findInvalidAutoCompactWindows, + isValidAutoCompactWindowTokens, + parseAutoCompactWindowInput, + selectCompactionTriggerSetting, +} from './auto-compact-window.js'; +export type { + AutoCompactWindowInput, + AutoCompactWindowSetting, + CompactionTriggerSetting, + ModelSettingsConfig, + ModelSettingsEntry, +} from './auto-compact-window.js'; export type { LocalByokConfigDraft, ManagedMinimaxProviderSnapshot, diff --git a/packages/config/src/local-model-provider-write.ts b/packages/config/src/local-model-provider-write.ts index 32e3549d2..5d844f015 100644 --- a/packages/config/src/local-model-provider-write.ts +++ b/packages/config/src/local-model-provider-write.ts @@ -7,6 +7,10 @@ import lockfile from 'proper-lockfile'; import { getConfig, getConfigPath, resetConfig, type Config } from './config.js'; import { minimaxApiModels } from './byok-config.js'; +import { + isValidAutoCompactWindowTokens, + type AutoCompactWindowSetting, +} from './auto-compact-window.js'; import { MANAGED_MINIMAX_PROVIDER_ID, MINIMAX_API_PROVIDER_ID } from './model-availability.js'; const LOCAL_CONFIG_FILE_MODE = 0o600; @@ -161,6 +165,28 @@ export async function updateLocalModelSelection( }).then(({ config }) => ({ config })); } +/** + * Persist `modelSettings[modelKey].autoCompactWindow` under the shared config + * lock. The key is set as one path segment, so dotted model ids stay intact. + */ +export async function updateLocalAutoCompactWindow(input: { + readonly modelKey: string; + readonly value: AutoCompactWindowSetting; +}): Promise<{ readonly config: Config }> { + const modelKey = input.modelKey.trim(); + const slash = modelKey.indexOf('/'); + if (slash <= 0 || slash === modelKey.length - 1) { + throw new LocalModelProviderConfigValidationError('Invalid model key'); + } + if (input.value !== 'auto' && !isValidAutoCompactWindowTokens(input.value)) { + throw new LocalModelProviderConfigValidationError('Invalid autoCompactWindow'); + } + return withLockedConfig(async (raw) => { + setNestedValue(raw, ['modelSettings', modelKey, 'autoCompactWindow'], input.value); + return { write: true, value: undefined }; + }).then(({ config }) => ({ config })); +} + /** Compare, remote-test, and persist a managed MiniMax model Context under one config lock. */ export async function compareAndSetLocalModelContext( input: { diff --git a/packages/config/test/auto-compact-window.test.ts b/packages/config/test/auto-compact-window.test.ts new file mode 100644 index 000000000..9f578ddff --- /dev/null +++ b/packages/config/test/auto-compact-window.test.ts @@ -0,0 +1,245 @@ +import fs from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os, { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import yaml from 'js-yaml'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getConfig, + getConfigPath, + parseAutoCompactWindowInput, + resetConfig, + resetGitDetect, + selectCompactionTriggerSetting, + updateLocalAutoCompactWindow, +} from '../src/index.js'; +import { resolveConfigFromRaw } from '../src/config.js'; + +describe('parseAutoCompactWindowInput', () => { + it('strips a long run of trailing zeros without regex backtracking', () => { + expect(parseAutoCompactWindowInput(`1.${'0'.repeat(100_000)}k`)).toEqual({ + kind: 'tokens', + tokens: 1_000, + }); + }); + + it.each([ + ['200000', 200_000], + ['500k', 500_000], + ['500K', 500_000], + ['1M', 1_000_000], + ['1m', 1_000_000], + ['1.5M', 1_500_000], + ['1.500k', 1_500], + ['1.1k', 1_100], + // A bare number from 100 to 1000 means thousands, like Claude Code. + ['100', 100_000], + ['200', 200_000], + ['1000', 1_000_000], + // No lower bound or upper clamp: the built-in trigger already caps it. + ['50', 50], + ['99', 99], + ['1001', 1_001], + ['5M', 5_000_000], + ])('reads %s as %d tokens', (text, tokens) => { + expect(parseAutoCompactWindowInput(text)).toEqual({ kind: 'tokens', tokens }); + }); + + it.each(['auto', 'AUTO', ' Auto '])('reads %j as auto', (text) => { + expect(parseAutoCompactWindowInput(text)).toEqual({ kind: 'auto' }); + }); + + it.each([ + '', + '0', + '0k', + '-5', + '0.8', + '80%', + '1.5', + '200.0', + '1.2345k', + '1', + '12x', + 'k', + '1e6', + 'automatic', + '99999999999999999999', + ])('rejects %j', (text) => { + expect(parseAutoCompactWindowInput(text)).toEqual({ kind: 'invalid' }); + }); +}); + +describe('modelSettings parsing', () => { + let dataDir: string; + + beforeEach(() => { + dataDir = fs.mkdtempSync(join(os.tmpdir(), 'mcode-model-settings-')); + 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 }); + }); + + it('keeps numbers for runtime validation and only the exact string auto', () => { + const config = resolveConfigFromRaw( + { + modelSettings: { + 'minimax/MiniMax-M3': { autoCompactWindow: 400_000 }, + 'openai/gpt-5.4': { autoCompactWindow: 'auto' }, + 'custom_provider:acme/model.v2': { autoCompactWindow: 1.5 }, + 'other/a': { autoCompactWindow: 'AUTO' }, + 'other/b': { autoCompactWindow: '400k' }, + 'other/c': 'not an entry', + }, + }, + dataDir, + ); + + expect(config.modelSettings).toEqual({ + 'minimax/MiniMax-M3': { autoCompactWindow: 400_000 }, + 'openai/gpt-5.4': { autoCompactWindow: 'auto' }, + 'custom_provider:acme/model.v2': { autoCompactWindow: 1.5 }, + }); + }); + + it('is absent when unset or not a mapping', () => { + expect(resolveConfigFromRaw({}, dataDir).modelSettings).toBeUndefined(); + expect(resolveConfigFromRaw({ modelSettings: ['x'] }, dataDir).modelSettings).toBeUndefined(); + }); +}); + +describe('selectCompactionTriggerSetting', () => { + const key = 'minimax/MiniMax-M3'; + + it('prefers a valid per-model value, then the global value, then the built-in trigger', () => { + expect( + selectCompactionTriggerSetting( + { + modelSettings: { [key]: { autoCompactWindow: 300_000 } }, + contextManagement: { compactionTriggerAt: 0.5 }, + }, + key, + ), + ).toEqual({ source: 'model', value: 300_000 }); + expect( + selectCompactionTriggerSetting({ contextManagement: { compactionTriggerAt: 0.5 } }, key), + ).toEqual({ source: 'global', value: 0.5 }); + expect(selectCompactionTriggerSetting({}, key)).toEqual({ source: 'built-in' }); + }); + + it('lets auto override the global value for that model only', () => { + const config = { + modelSettings: { [key]: { autoCompactWindow: 'auto' as const } }, + contextManagement: { compactionTriggerAt: 0.5 }, + }; + expect(selectCompactionTriggerSetting(config, key)).toEqual({ source: 'model-auto' }); + expect(selectCompactionTriggerSetting(config, 'minimax/MiniMax-M2.7')).toEqual({ + source: 'global', + value: 0.5, + }); + }); + + it.each([0, -1, 1, 1.5, Number.NaN])( + 'ignores the invalid per-model value %s and falls through to the global value', + (value) => { + expect( + selectCompactionTriggerSetting( + { + modelSettings: { [key]: { autoCompactWindow: value } }, + contextManagement: { compactionTriggerAt: 0.5 }, + }, + key, + ), + ).toEqual({ source: 'global', value: 0.5 }); + }, + ); + + it('does not match inherited object keys', () => { + expect(selectCompactionTriggerSetting({ modelSettings: {} }, 'constructor')).toEqual({ + source: 'built-in', + }); + }); +}); + +describe('updateLocalAutoCompactWindow', () => { + let dataDir: string; + + beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), 'auto-compact-window-write-')); + vi.spyOn(os, 'homedir').mockReturnValue(dataDir); + vi.stubEnv('MINIMAX_DATA_DIR', ''); + vi.stubEnv('MAVIS_DATA_DIR', dataDir); + vi.stubEnv('__MAVIS_RUNTIME_MANAGED', '1'); + vi.stubEnv('__MAVIS_RUNTIME_DISABLE_GIT_AUTO_CONFIG', '1'); + resetGitDetect(); + resetConfig(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + resetGitDetect(); + resetConfig(); + await rm(dataDir, { recursive: true, force: true }); + }); + + it('writes one model entry under a dotted model id, keeps siblings, and refreshes config', async () => { + fs.writeFileSync( + getConfigPath(), + [ + '# keep this comment', + 'contextManagement:', + ' compactionTriggerAt: 0.5', + 'modelSettings:', + ' minimax/MiniMax-M3:', + ' autoCompactWindow: 400000', + '', + ].join('\n'), + 'utf-8', + ); + resetConfig(); + expect(getConfig().modelSettings?.['openai/gpt-5.4']).toBeUndefined(); + + const { config } = await updateLocalAutoCompactWindow({ + modelKey: 'openai/gpt-5.4', + value: 'auto', + }); + await updateLocalAutoCompactWindow({ modelKey: 'minimax/MiniMax-M3', value: 250_000 }); + + const text = fs.readFileSync(getConfigPath(), 'utf-8'); + expect(yaml.load(text)).toEqual({ + contextManagement: { compactionTriggerAt: 0.5 }, + modelSettings: { + 'minimax/MiniMax-M3': { autoCompactWindow: 250_000 }, + 'openai/gpt-5.4': { autoCompactWindow: 'auto' }, + }, + }); + expect(config.modelSettings?.['openai/gpt-5.4']).toEqual({ autoCompactWindow: 'auto' }); + // The cached config is reset, so the next reader sees the write. + expect(getConfig().modelSettings?.['minimax/MiniMax-M3']).toEqual({ + autoCompactWindow: 250_000, + }); + }); + + it.each([ + { modelKey: 'minimax/MiniMax-M3', value: 0 }, + { modelKey: 'minimax/MiniMax-M3', value: 1.5 }, + { modelKey: 'minimax/MiniMax-M3', value: 'AUTO' }, + { modelKey: 'no-slash', value: 1_000 }, + { modelKey: '/model', value: 1_000 }, + { modelKey: '__proto__', value: 1_000 }, + ])('rejects $modelKey = $value without touching the file', async (input) => { + fs.writeFileSync(getConfigPath(), 'logLevel: info\n', 'utf-8'); + await expect( + updateLocalAutoCompactWindow(input as Parameters[0]), + ).rejects.toThrow(); + expect(fs.readFileSync(getConfigPath(), 'utf-8')).toBe('logLevel: info\n'); + }); +}); diff --git a/packages/local-runtime-v2/src/application/session/compaction-trigger-application.test.ts b/packages/local-runtime-v2/src/application/session/compaction-trigger-application.test.ts new file mode 100644 index 000000000..05e447432 --- /dev/null +++ b/packages/local-runtime-v2/src/application/session/compaction-trigger-application.test.ts @@ -0,0 +1,131 @@ +import fs from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { getConfig, getConfigPath, resetConfig, resetGitDetect } from '@mavis/config'; +import { resolveCompactionTokenBudget } from '@mavis/context-manager'; +import yaml from 'js-yaml'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createCompactionTriggerApplication } from './compaction-trigger-application.js'; + +const CONFIG_ENV_KEYS = [ + 'MAVIS_DATA_DIR', + '__MAVIS_RUNTIME_DATA_DIR', + '__MAVIS_RUNTIME_PROFILE', + '__MAVIS_RUNTIME_DISABLE_GIT_AUTO_CONFIG', +] as const; + +const BUILT_IN = resolveCompactionTokenBudget({ + contextWindow: 200_000, + configuredMaxOutputTokens: 32_000, +}).automaticTriggerAt; + +function application(options: { agentModel?: Record } = {}) { + const buildAgentConfig = vi.fn(async () => ({ + // An OAuth custom provider with a dotted model id: the Pi model uses the bare key. + model: options.agentModel ?? { provider: 'custom_provider:acme', model_id: 'gpt-5.4' }, + })); + const resolveModel = vi.fn(async () => ({ + model: { provider: 'acme', id: 'gpt-5.4', contextWindow: 200_000, maxTokens: 64_000 }, + maxTokens: 32_000, + })); + return createCompactionTriggerApplication({ + sessions: { + getExecutionSnapshot: async (sessionId: string) => + sessionId === 'session-1' + ? ({ sessionId, agentName: 'mavis', sessionKind: 'root' } as never) + : undefined, + }, + agents: { getExecutionSnapshot: async () => ({ agentName: 'mavis' }) as never }, + preparation: { buildAgentConfig, resolveModel } as never, + config: getConfig, + }); +} + +function savedModelSettings(): unknown { + return (yaml.load(fs.readFileSync(getConfigPath(), 'utf-8')) as Record) + .modelSettings; +} + +describe('compaction trigger application', () => { + let dataDir: string; + + beforeEach(async () => { + for (const key of CONFIG_ENV_KEYS) vi.stubEnv(key, undefined); + dataDir = await mkdtemp(join(tmpdir(), 'compaction-trigger-application-')); + vi.stubEnv('MAVIS_DATA_DIR', dataDir); + resetGitDetect(); + resetConfig(); + fs.writeFileSync( + getConfigPath(), + yaml.dump({ contextManagement: { compactionTriggerAt: 0.5 } }), + 'utf-8', + ); + resetConfig(); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + resetGitDetect(); + resetConfig(); + await rm(dataDir, { recursive: true, force: true }); + }); + + it('reads the effective trigger for the Session model with the Turn output budget', async () => { + await expect(application().getCompactionTrigger({ sessionId: 'session-1' })).resolves.toEqual({ + providerId: 'custom_provider:acme', + modelId: 'gpt-5.4', + contextWindow: 200_000, + builtInTriggerTokens: BUILT_IN, + effectiveTriggerTokens: 100_000, + source: 'global', + }); + }); + + it('saves the value under the selection key and returns the refreshed trigger', async () => { + const triggers = application(); + + await expect( + triggers.setCompactionTrigger({ sessionId: 'session-1', value: 'auto' }), + ).resolves.toMatchObject({ + source: 'model-auto', + saved: 'auto', + effectiveTriggerTokens: BUILT_IN, + }); + expect(savedModelSettings()).toEqual({ + 'custom_provider:acme/gpt-5.4': { autoCompactWindow: 'auto' }, + }); + + // Above the built-in trigger: saved as asked, but the built-in line still applies. + await expect( + triggers.setCompactionTrigger({ sessionId: 'session-1', value: 400_000 }), + ).resolves.toMatchObject({ source: 'model', saved: 400_000, effectiveTriggerTokens: BUILT_IN }); + await expect( + triggers.setCompactionTrigger({ sessionId: 'session-1', value: 50 }), + ).resolves.toMatchObject({ source: 'model', saved: 50, effectiveTriggerTokens: 50 }); + await expect(triggers.getCompactionTrigger({ sessionId: 'session-1' })).resolves.toMatchObject({ + source: 'model', + effectiveTriggerTokens: 50, + }); + }); + + it('rejects invalid values and Sessions without a resolved model without writing', async () => { + const before = fs.readFileSync(getConfigPath(), 'utf-8'); + + await expect( + application().setCompactionTrigger({ sessionId: 'session-1', value: 1.5 }), + ).rejects.toThrow('autoCompactWindow'); + await expect( + application().setCompactionTrigger({ sessionId: 'missing', value: 'auto' }), + ).rejects.toThrow('Session not found'); + await expect( + application({ agentModel: {} }).setCompactionTrigger({ + sessionId: 'session-1', + value: 'auto', + }), + ).rejects.toThrow('no resolved model'); + expect(fs.readFileSync(getConfigPath(), 'utf-8')).toBe(before); + }); +}); diff --git a/packages/local-runtime-v2/src/application/session/compaction-trigger-application.ts b/packages/local-runtime-v2/src/application/session/compaction-trigger-application.ts new file mode 100644 index 000000000..4ba2341bb --- /dev/null +++ b/packages/local-runtime-v2/src/application/session/compaction-trigger-application.ts @@ -0,0 +1,99 @@ +import { + compactionTriggerAtForSetting, + isValidAutoCompactWindowTokens, + updateLocalAutoCompactWindow, + type AutoCompactWindowSetting, +} from '@mavis/config'; + +import { + formatModelKey, + readCompactionTriggerSetting, + type LocalConversationRuntimeConfig, +} from '../../service/model-system/index.js'; +import type { SessionRecord } from '../../service/session-system/index.js'; +import { + describeCompactionTrigger, + readCompactionModelSelection, + readSessionAgentExecutionSnapshot, + type ProductionAgentPreparation, + type ProductionAgentProductCapabilities, +} from '../../service/turn-system/index.js'; +import type { LocalRuntimeCompactionTrigger } from './process-local-application-contract.js'; + +export interface CompactionTriggerApplicationOptions { + readonly sessions: { + getExecutionSnapshot(sessionId: string): Promise; + }; + readonly agents: ProductionAgentProductCapabilities['agents']; + readonly preparation: Pick; + readonly config: () => LocalConversationRuntimeConfig; +} + +/** + * Reads and saves a Session model's automatic compaction trigger. The model and + * its limits come from the same Agent/model preparation as the next Turn, and + * the trigger from the same config selection and budget as the compactor. + */ +export function createCompactionTriggerApplication(options: CompactionTriggerApplicationOptions) { + const describe = (model: SessionCompactionModel): LocalRuntimeCompactionTrigger => { + // The same setting reader and budget the automatic compactor probes with. + const setting = readCompactionTriggerSetting(options.config(), model.selection); + return { + ...model.selection, + contextWindow: model.llm.model.contextWindow, + ...describeCompactionTrigger({ + compactionTriggerAt: compactionTriggerAtForSetting(setting), + llm: model.llm, + }), + source: setting.source, + ...(setting.source === 'model' ? { saved: setting.value } : {}), + ...(setting.source === 'model-auto' ? { saved: 'auto' as const } : {}), + }; + }; + return { + getCompactionTrigger: async ({ sessionId }: { readonly sessionId: string }) => + describe(await resolveSessionCompactionModel(options, sessionId)), + setCompactionTrigger: async (input: { + readonly sessionId: string; + readonly value: AutoCompactWindowSetting; + }) => { + if (input.value !== 'auto' && !isValidAutoCompactWindowTokens(input.value)) { + throw new Error('autoCompactWindow must be an integer token count above 1 or "auto".'); + } + const model = await resolveSessionCompactionModel(options, input.sessionId); + await updateLocalAutoCompactWindow({ + modelKey: formatModelKey(model.selection.providerId, model.selection.modelId), + value: input.value, + }); + return describe(model); + }, + }; +} + +interface SessionCompactionModel { + readonly selection: NonNullable>; + readonly llm: Awaited>; +} + +async function resolveSessionCompactionModel( + options: CompactionTriggerApplicationOptions, + sessionId: string, +): Promise { + const session = await options.sessions.getExecutionSnapshot(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + const agent = await readSessionAgentExecutionSnapshot(options.agents, session); + if (!agent) throw new Error(`Agent not found: ${session.agentName}`); + const agentConfig = await options.preparation.buildAgentConfig({ + session, + agent, + isSessionFirstTurn: false, + }); + const selection = readCompactionModelSelection(agentConfig); + if (!selection) throw new Error(`Session ${sessionId} has no resolved model.`); + const llm = await options.preparation.resolveModel({ + sessionId, + turnId: `${sessionId}:compaction-trigger`, + agentConfig, + }); + return { selection, llm }; +} diff --git a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts index 605a45a46..39b9cfbc0 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts @@ -87,6 +87,20 @@ interface LocalRuntimeBackgroundTaskQuery { readonly limit?: number; } +/** A Session model's automatic compaction trigger, in tokens. */ +export interface LocalRuntimeCompactionTrigger { + /** Source-qualified selection that keys `modelSettings`. */ + readonly providerId: string; + readonly modelId: string; + readonly contextWindow: number; + readonly builtInTriggerTokens: number; + /** min(setting, built-in): a setting can only make compaction earlier. */ + readonly effectiveTriggerTokens: number; + readonly source: 'model' | 'model-auto' | 'global' | 'built-in'; + /** This model's own valid `autoCompactWindow`. */ + readonly saved?: number | 'auto'; +} + /** Internal peripheral Application surface consumed by process-local delivery. */ export interface LocalRuntimeApplication { readonly events: { @@ -235,6 +249,16 @@ export interface LocalRuntimeApplication { setPermissionMode(input: { mode: "default" | "acceptEdits" | "bypassPermissions" | "auto" | "off"; }): Promise; + /** Automatic compaction trigger the Session's current model would use on its next request. */ + getCompactionTrigger?(input: { sessionId: string }): Promise; + /** + * Persists `modelSettings..autoCompactWindow` for the + * Session's current model and returns the refreshed trigger. + */ + setCompactionTrigger?(input: { + sessionId: string; + value: number | 'auto'; + }): Promise; }; readonly models?: { list(input?: { sessionId?: string }): Promise; diff --git a/packages/local-runtime-v2/src/application/session/process-local-application.ts b/packages/local-runtime-v2/src/application/session/process-local-application.ts index b5da0eee9..5b14e1b09 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-application.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-application.ts @@ -33,6 +33,11 @@ export interface ProcessLocalApplicationOptions { readonly plan: NonNullable; readonly sessionReports?: SessionReportCapability; readonly instructions?: LocalRuntimeApplication['instructions']; + /** Session-aware compaction trigger read/write, merged into `configuration`. */ + readonly compactionTrigger?: Pick< + NonNullable, + 'getCompactionTrigger' | 'setCompactionTrigger' + >; readonly modelProvider: { readonly application: Pick; readonly providers: LocalModelProviderService; @@ -127,7 +132,7 @@ export function createProcessLocalApplication( : {}), }, ...(options.instructions ? { instructions: options.instructions } : {}), - configuration: options.peripherals.configuration, + configuration: { ...options.peripherals.configuration, ...options.compactionTrigger }, models: createModelsApplication(options.modelProvider), modelProviders: { listProviderPresets: () => options.modelProvider.listProviderPresets(), diff --git a/packages/local-runtime-v2/src/application/session/process-local-composition.ts b/packages/local-runtime-v2/src/application/session/process-local-composition.ts index b83c27bc3..b32966f33 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-composition.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-composition.ts @@ -20,6 +20,7 @@ export function composeProcessLocalApplication(input: { readonly planEntryEnabled: () => boolean; readonly sessionReports?: ProcessLocalApplicationOptions['sessionReports']; readonly instructions?: ProcessLocalApplicationOptions['instructions']; + readonly compactionTrigger?: ProcessLocalApplicationOptions['compactionTrigger']; readonly mcp: ProcessLocalApplicationOptions['peripherals']['mcp']; readonly modelProvider: ProcessLocalApplicationOptions['modelProvider']; }): ReturnType { @@ -36,6 +37,7 @@ export function composeProcessLocalApplication(input: { plan: { isEntryEnabled: input.planEntryEnabled }, ...(input.sessionReports ? { sessionReports: input.sessionReports } : {}), instructions: input.instructions, + ...(input.compactionTrigger ? { compactionTrigger: input.compactionTrigger } : {}), peripherals: { ...input.compatibility.peripherals, mcp: input.mcp }, modelProvider: input.modelProvider, }); diff --git a/packages/local-runtime-v2/src/local/cli-service.ts b/packages/local-runtime-v2/src/local/cli-service.ts index 64bed1d55..a9735b513 100644 --- a/packages/local-runtime-v2/src/local/cli-service.ts +++ b/packages/local-runtime-v2/src/local/cli-service.ts @@ -641,6 +641,20 @@ export class CliService { ).setPermissionMode(input); } + /** Undefined when this host cannot read a Session model's automatic compaction trigger. */ + getCompactionTrigger( + input: Parameters[0], + ): ReturnType | undefined { + return this.options.application.configuration?.getCompactionTrigger?.(input); + } + + /** Undefined when this host cannot persist a model's automatic compaction trigger. */ + setCompactionTrigger( + input: Parameters[0], + ): ReturnType | undefined { + return this.options.application.configuration?.setCompactionTrigger?.(input); + } + listModels( input?: Parameters< NonNullable["list"] @@ -905,6 +919,13 @@ function toPermissionDecision( } } +type CompactionTriggerConfiguration = Required< + Pick< + NonNullable, + "getCompactionTrigger" | "setCompactionTrigger" + > +>; + type QuestionnaireApplicationRequest = NonNullable< Awaited< ReturnType< diff --git a/packages/local-runtime-v2/src/service/model-system/compaction-trigger-settings.ts b/packages/local-runtime-v2/src/service/model-system/compaction-trigger-settings.ts new file mode 100644 index 000000000..1ec9ba53b --- /dev/null +++ b/packages/local-runtime-v2/src/service/model-system/compaction-trigger-settings.ts @@ -0,0 +1,60 @@ +import { + compactionTriggerAtForSetting, + findInvalidAutoCompactWindows, + selectCompactionTriggerSetting, + type CompactionTriggerSetting, +} from '@mavis/config'; +import { isValidCompactionTriggerSetting } from '@mavis/context-manager'; + +import type { LocalConversationRuntimeConfig } from './contracts.js'; +import { formatModelKey } from './resolution/model-key.js'; + +type CompactionTriggerConfig = Pick< + LocalConversationRuntimeConfig, + 'contextManagement' | 'modelSettings' +>; + +interface CompactionTriggerModel { + readonly providerId: string; + readonly modelId: string; +} + +/** + * The setting that decides a model's automatic compaction trigger, keyed by its + * `provider/model` selection. An invalid global value counts as unset. + */ +export function readCompactionTriggerSetting( + config: CompactionTriggerConfig, + model: CompactionTriggerModel, +): CompactionTriggerSetting { + const setting = selectCompactionTriggerSetting( + config, + formatModelKey(model.providerId, model.modelId), + ); + return setting.source === 'global' && !isValidCompactionTriggerSetting(setting.value) + ? { source: 'built-in' } + : setting; +} + +/** Trigger settings for the automatic compactor; every call reads the current config. */ +export function readCompactionTriggerSettings(readConfig: () => CompactionTriggerConfig) { + return { + compactionTriggerAt: (model: CompactionTriggerModel) => + compactionTriggerAtForSetting(readCompactionTriggerSetting(readConfig(), model)), + ignored: () => findIgnoredCompactionTriggerSettings(readConfig()), + }; +} + +function findIgnoredCompactionTriggerSettings(config: CompactionTriggerConfig) { + const global = config.contextManagement?.compactionTriggerAt; + return [ + ...(global !== undefined && !isValidCompactionTriggerSetting(global) + ? [{ setting: 'contextManagement.compactionTriggerAt', value: global }] + : []), + ...findInvalidAutoCompactWindows(config.modelSettings).map(({ modelKey, value }) => ({ + setting: 'modelSettings.autoCompactWindow', + model: modelKey, + value, + })), + ]; +} 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 fc4698212..a9d7aa762 100644 --- a/packages/local-runtime-v2/src/service/model-system/contracts.ts +++ b/packages/local-runtime-v2/src/service/model-system/contracts.ts @@ -136,6 +136,8 @@ export interface LocalConversationRuntimeConfig extends LocalRuntimeConfig { readonly toolResultCompaction?: Config['toolResultCompaction']; /** Context management values resolved from config.yaml. */ readonly contextManagement?: Partial; + /** Per-model settings keyed by `provider/model`, such as `autoCompactWindow`. */ + readonly modelSettings?: Config['modelSettings']; /** * 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/model-system/index.ts b/packages/local-runtime-v2/src/service/model-system/index.ts index f8d9112f0..059b96a65 100644 --- a/packages/local-runtime-v2/src/service/model-system/index.ts +++ b/packages/local-runtime-v2/src/service/model-system/index.ts @@ -93,5 +93,9 @@ export { parseSourceQualifiedModelKey, parseProviderId, } from './resolution/model-key.js'; +export { + readCompactionTriggerSetting, + readCompactionTriggerSettings, +} from './compaction-trigger-settings.js'; export type { ManagedModelParameterSnapshot } from './resolution/model-ref.js'; diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts index 22370bec8..d635cc92b 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts @@ -1233,6 +1233,9 @@ describe('Codex OAuth discovered models', () => { thinkingRequestPatch: { reasoning: { effort } }, model: { id: 'gpt-6-astra', + // The Pi provider is the OAuth key, not the `custom_provider:` selection + // id; per-model settings such as autoCompactWindow use the selection id. + provider: 'openai-codex', api: 'openai-codex-responses', contextWindow: 800_000, maxTokens: 60_000, diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.test.ts index 42b58c610..99ce8ca20 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.test.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.test.ts @@ -17,6 +17,7 @@ import { logCompactionFailureBestEffort, readCompactionCommitMetadata, readCompactionLifecycleMetadata, + readCompactionModelSelection, readCompactionTokenUsage, } from './context-compaction.js'; import { createManualCompactionChange } from './compaction-history.js'; @@ -274,6 +275,46 @@ describe('automatic ContextCompactor trigger probe', () => { error: root, }); }); + + it('hands the prepared Session model selection to the compactor beside the Pi model', async () => { + // Per-model settings follow the selection; the Pi model keeps its own provider id. + const modelSelection = readCompactionModelSelection({ + model: { provider: ' custom_provider:acme ', model_id: 'model' }, + }); + expect(readCompactionModelSelection({})).toBeUndefined(); + const probeBeforeLlm = vi.fn((_input: AutomaticContextCompactionInput) => ({ + shouldStart: true, + })); + const compactBeforeLlm = vi.fn(async (_input: AutomaticContextCompactionInput) => ({ + status: 'unchanged' as const, + reason: 'nothing-to-compact' as const, + })); + const hook = createAutomaticContextCompactionHook( + { + automatic: { probeBeforeLlm, compactBeforeLlm }, + lifecycle: { + completeCommittedHistory: vi.fn(async () => undefined), + failCommittedHistory: vi.fn(async () => undefined), + }, + }, + context, + 'lease-model-selection', + { maxSerializedInputBytes: 2_048, ...(modelSelection ? { modelSelection } : {}) }, + ); + + await expect(hook(hookInput())).resolves.toEqual({ type: 'continue' }); + const expected = expect.objectContaining({ + phase: 'initial', + maxSerializedInputBytes: 2_048, + model: expect.objectContaining({ provider: 'test' }), + modelSelection: { providerId: 'custom_provider:acme', modelId: 'model' }, + }); + expect(probeBeforeLlm).toHaveBeenCalledWith(expected); + expect(compactBeforeLlm).toHaveBeenCalledWith( + expected, + expect.objectContaining({ onStarted: expect.any(Function) }), + ); + }); }); describe('automatic ContextCompactor diagnostics', () => { diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.ts index 6ab3e3db7..f85ad1b4d 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.ts @@ -21,6 +21,7 @@ import type { CheckpointStopReason, AutomaticContextCompactionInput, ContextCompactionLifecycleMetadata, + ContextCompactionModelSelection, ContextCompactionObservation, ContextCompactionObserver, ContextCompactionPhase, @@ -82,6 +83,8 @@ export function createAutomaticContextCompactionHook( leaseId: string, options: { readonly maxSerializedInputBytes?: number; + /** The Turn's Session model selection, which keys per-model compaction settings. */ + readonly modelSelection?: ContextCompactionModelSelection; readonly attempts?: ContextCompactionAttemptFactory; readonly logger?: Pick | undefined; /** Runs only after the automatic trigger matches, immediately before compaction starts. */ @@ -100,10 +103,7 @@ export function createAutomaticContextCompactionHook( } = { started: false }; const providerTokenUsage = createCompactionTokenUsageAccumulator(); try { - const automaticInput = automaticContextCompactionInput( - input, - options.maxSerializedInputBytes, - ); + const automaticInput = automaticContextCompactionInput(input, options); const probe = dependencies.automatic.probeBeforeLlm?.(automaticInput); if (probe?.shouldStart === false) { return resolveAutomaticCompactionResult({ @@ -218,16 +218,38 @@ export function createAutomaticContextCompactionHook( }; } +/** + * The Session model selection in a prepared AgentConfig. It keys per-model + * compaction settings; the resolved Pi provider can be an alias of it. + */ +export function readCompactionModelSelection(agentConfig: { + readonly model?: unknown; +}): ContextCompactionModelSelection | undefined { + const model = agentConfig.model; + if (!model || typeof model !== 'object') return undefined; + const { provider, model_id: modelId } = model as Readonly>; + const selection = { + providerId: typeof provider === 'string' ? provider.trim() : '', + modelId: typeof modelId === 'string' ? modelId.trim() : '', + }; + return selection.providerId && selection.modelId ? selection : undefined; +} + function automaticContextCompactionInput( input: PiBeforeLlmCallHookInput, - maxSerializedInputBytes: number | undefined, + options: { + readonly maxSerializedInputBytes?: number; + readonly modelSelection?: ContextCompactionModelSelection; + }, ): AutomaticContextCompactionInput { + const { maxSerializedInputBytes, modelSelection } = options; return { sessionId: input.sessionId, phase: input.phase, messages: input.messages, model: input.model, ...(maxSerializedInputBytes === undefined ? {} : { maxSerializedInputBytes }), + ...(modelSelection ? { modelSelection } : {}), streamFn: input.streamFn, apiKey: input.apiKey, headers: input.headers, diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/contracts.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/contracts.ts index 592d20ba3..5fad19e9d 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/contracts.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/contracts.ts @@ -187,6 +187,16 @@ export interface ContextCompactionHooks { readonly getProviderTokenUsage?: () => CompactionTokenUsage | undefined; } +/** + * The Session model selection behind a request. Per-model settings are keyed by + * it: a Pi `model.provider` can be a runtime alias (an OAuth custom provider + * resolves to its bare key), so it cannot stand in for the selection id. + */ +export interface ContextCompactionModelSelection { + readonly providerId: string; + readonly modelId: string; +} + export type AutomaticContextCompactionInput = Readonly< Pick< PiBeforeLlmCallHookInput, @@ -206,6 +216,8 @@ export type AutomaticContextCompactionInput = Readonly< | 'signal' > & { readonly maxSerializedInputBytes?: number; + /** Absent when the Host has no selection; the Pi model identity is used instead. */ + readonly modelSelection?: ContextCompactionModelSelection; } >; diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/contracts.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/contracts.ts index 9ad328ceb..fae2cd2d4 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/contracts.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/contracts.ts @@ -312,6 +312,7 @@ export type { CompactionTokenUsage, CompactionOutcome, ContextCompactionHooks, + ContextCompactionModelSelection, ManualContextCompactionInput, ContextCompactionResult, ContextCompactor, diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/index.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/index.ts index e7688fe01..93a60b441 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/index.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/index.ts @@ -26,7 +26,10 @@ export { type AgentHostTurnSkillCapability, type AgentHostTurnToolMode, } from './assembly/turn-capability-lifecycle.js'; -export { ContextCompactionResultValidationError } from './compaction/context-compaction.js'; +export { + ContextCompactionResultValidationError, + readCompactionModelSelection, +} from './compaction/context-compaction.js'; export { AgentHostDependencyUnavailableError, assertAgentHostCapabilityAvailable, diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/local-agent-host.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/local-agent-host.ts index c6dd3d615..c2733a2d8 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/local-agent-host.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/local-agent-host.ts @@ -24,6 +24,7 @@ import { import { ContextCompactionAttemptFactory, createAutomaticContextCompactionHook, + readCompactionModelSelection, } from './compaction/context-compaction.js'; import { assertAgentHostCapabilityAvailable, @@ -596,6 +597,7 @@ export class LocalAgentHost< input.lease.leaseId, { maxSerializedInputBytes: preflight.preparation.llm.maxRequestBodyBytes, + ...compactionModelSelectionOption(preflight.preparation.agentConfig), attempts: this.compactionAttempts, logger: this.dependencies.logger, ...(pluginHooks.length @@ -1038,3 +1040,8 @@ function uniqueTaskIds(values: readonly string[]): string[] { function isAcceptedAtMs(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; } + +function compactionModelSelectionOption(agentConfig: { readonly model?: unknown }) { + const modelSelection = readCompactionModelSelection(agentConfig); + return modelSelection ? { modelSelection } : {}; +} 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 8e78d13a5..684d553d2 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 @@ -1034,6 +1034,50 @@ describe('AutomaticContextCompactor request boundaries', () => { ).toBe(false); }); + it('reads the setting for the Session model selection, not the Pi provider alias', () => { + const messages: AgentMessage[] = [ + { role: 'user', content: [{ type: 'text', text: 'small context' }], timestamp: 1 }, + ]; + // An OAuth custom provider resolves its Pi model under the bare provider + // key, while config.yaml and /autocompact key it by the selection id. + const model: Model<'anthropic-messages'> = { + id: 'gpt-5.4', + name: 'gpt-5.4', + api: 'anthropic-messages', + provider: 'acme', + 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 perModelTrigger: Record = { + 'custom_provider:acme/gpt-5.4': rawTokens - 1, + }; + const compactor = automaticCompactor(undefined, undefined, undefined, { + getCompactionTriggerAt: ({ providerId, modelId }) => + perModelTrigger[`${providerId}/${modelId}`], + }); + const input = { + sessionId: 'session-1', + phase: 'initial' as const, + messages, + model, + thinkingLevel: 'off' as const, + }; + + expect( + compactor.probeBeforeLlm({ + ...input, + modelSelection: { providerId: 'custom_provider:acme', modelId: 'gpt-5.4' }, + }).shouldStart, + ).toBe(true); + // Without a Host selection the Pi identity is the only key available. + expect(compactor.probeBeforeLlm(input).shouldStart).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 335dcd9fb..747ece17a 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 @@ -10,6 +10,7 @@ import type { AutomaticContextCompactionProbe, AutomaticContextCompactor as AutomaticContextCompactorPort, ContextCompactionHooks, + ContextCompactionModelSelection, ContextCompactionResult, PromptSnapshotSource, } from '../agent-host/contracts.js'; @@ -33,14 +34,19 @@ import type { SubagentCheckpointStateSource } from './contracts.js'; interface AutomaticContextCompactorDependencies { readonly toolResultArchiver?: ToolResultArchiver; readonly promptSnapshots?: PromptSnapshotSource; - /** User `contextManagement.compactionTriggerAt`; read per call. */ - readonly getCompactionTriggerAt?: () => number | undefined; + /** + * The `compactionTriggerAt` budget input for one request's model, from the + * per-model or global user setting; read per call. + */ + readonly getCompactionTriggerAt?: CompactionTriggerAtGetter; } +type CompactionTriggerAtGetter = (model: ContextCompactionModelSelection) => number | undefined; + export class AutomaticContextCompactor implements AutomaticContextCompactorPort { private readonly toolResultArchiver?: ToolResultArchiver; private readonly promptSnapshots?: PromptSnapshotSource; - private readonly getCompactionTriggerAt?: () => number | undefined; + private readonly getCompactionTriggerAt?: CompactionTriggerAtGetter; constructor( private readonly usageAnchor: ContextUsageAnchorState | undefined, @@ -60,7 +66,7 @@ export class AutomaticContextCompactor implements AutomaticContextCompactorPort input, this.usageAnchor, this.toolResultArchiver, - this.readCompactionTriggerAt(), + this.readCompactionTriggerAt(input), ).shouldStart, }; } @@ -74,7 +80,7 @@ export class AutomaticContextCompactor implements AutomaticContextCompactorPort input, this.usageAnchor, this.toolResultArchiver, - this.readCompactionTriggerAt(), + this.readCompactionTriggerAt(input), ); if (!preparation.shouldStart) { return { status: 'unchanged', reason: 'nothing-to-compact' }; @@ -110,9 +116,11 @@ export class AutomaticContextCompactor implements AutomaticContextCompactorPort } /** A failing config read degrades to the built-in trigger. */ - private readCompactionTriggerAt(): number | undefined { + private readCompactionTriggerAt(input: AutomaticContextCompactionInput): number | undefined { try { - return this.getCompactionTriggerAt?.(); + return this.getCompactionTriggerAt?.( + input.modelSelection ?? { providerId: input.model.provider, modelId: input.model.id }, + ); } catch { return undefined; } diff --git a/packages/local-runtime-v2/src/service/turn-system/compaction/execution/production-context-compactors.test.ts b/packages/local-runtime-v2/src/service/turn-system/compaction/execution/production-context-compactors.test.ts new file mode 100644 index 000000000..9599140ef --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/compaction/execution/production-context-compactors.test.ts @@ -0,0 +1,153 @@ +import fs from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { AgentMessage } from '@earendil-works/pi-agent-core'; +import type { Model } from '@earendil-works/pi-ai'; +import { + getConfig, + getConfigPath, + resetConfig, + resetGitDetect, + updateLocalAutoCompactWindow, +} from '@mavis/config'; +import yaml from 'js-yaml'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { readCompactionTriggerSettings } from '../../../model-system/index.js'; +import { ToolResultArchiver } from '../algorithm/tool-result-archiver.js'; +import { createLocalContextFootprintMeasurer } from './local-context-footprint.js'; +import { createContextCompactors, describeCompactionTrigger } from '../../production-composition.js'; +import { ContextUsageAnchorState } from './usage-anchor.js'; + +const CONFIG_ENV_KEYS = [ + 'MAVIS_DATA_DIR', + '__MAVIS_RUNTIME_DATA_DIR', + '__MAVIS_RUNTIME_PROFILE', + '__MAVIS_RUNTIME_DISABLE_GIT_AUTO_CONFIG', +] as const; + +// An OAuth custom provider: the Pi model carries the bare key, config uses the selection id. +const model: Model<'anthropic-messages'> = { + id: 'gpt-5.4', + name: 'gpt-5.4', + api: 'anthropic-messages', + provider: 'acme', + baseUrl: 'https://example.invalid', + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 32_000, +}; +const selection = { providerId: 'custom_provider:acme', modelId: 'gpt-5.4' }; +const modelKey = 'custom_provider:acme/gpt-5.4'; +const messages: AgentMessage[] = [ + { + role: 'user', + content: [{ type: 'text', text: 'a context large enough to count more than a few tokens' }], + timestamp: 1, + }, +]; +const rawTokens = createLocalContextFootprintMeasurer({ model }).measure(messages).inputTokens; + +function writeConfig(raw: Record): void { + fs.writeFileSync(getConfigPath(), yaml.dump(raw), 'utf-8'); + resetConfig(); +} + +function automaticCompactor(logger = { warn: vi.fn(), error: vi.fn() }) { + const { automatic } = createContextCompactors({ + contextUsageAnchor: new ContextUsageAnchorState(), + checkpointState: undefined, + logger, + toolResultArchiver: new ToolResultArchiver({ writeArtifact: vi.fn() }), + promptSnapshots: undefined, + trigger: readCompactionTriggerSettings(getConfig), + }); + return (modelSelection?: typeof selection) => + automatic.probeBeforeLlm({ + sessionId: 'session-1', + phase: 'initial', + messages, + model, + thinkingLevel: 'off', + ...(modelSelection ? { modelSelection } : {}), + }).shouldStart; +} + +describe('production automatic compaction trigger', () => { + let dataDir: string; + + beforeEach(async () => { + for (const key of CONFIG_ENV_KEYS) vi.stubEnv(key, undefined); + dataDir = await mkdtemp(join(tmpdir(), 'production-compaction-trigger-')); + vi.stubEnv('MAVIS_DATA_DIR', dataDir); + resetGitDetect(); + resetConfig(); + expect(rawTokens).toBeGreaterThan(2); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + resetGitDetect(); + resetConfig(); + await rm(dataDir, { recursive: true, force: true }); + }); + + it('applies a per-model value written to config.yaml from the next probe without a restart', async () => { + writeConfig({ contextManagement: { compactionTriggerAt: rawTokens - 1 } }); + const probe = automaticCompactor(); + expect(probe(selection)).toBe(true); + + // auto restores the built-in trigger for this model only. + await updateLocalAutoCompactWindow({ modelKey, value: 'auto' }); + expect(probe(selection)).toBe(false); + expect(probe({ providerId: 'minimax', modelId: 'MiniMax-M3' })).toBe(true); + + // The /autocompact read reports the exact line the probe compares against. + const effectiveTrigger = () => + describeCompactionTrigger({ + compactionTriggerAt: + readCompactionTriggerSettings(getConfig).compactionTriggerAt(selection), + llm: { model }, + }).effectiveTriggerTokens; + await updateLocalAutoCompactWindow({ modelKey, value: rawTokens }); + expect(effectiveTrigger()).toBe(rawTokens); + expect(probe(selection)).toBe(false); + await updateLocalAutoCompactWindow({ modelKey, value: rawTokens - 1 }); + expect(effectiveTrigger()).toBe(rawTokens - 1); + expect(probe(selection)).toBe(true); + }); + + it('warns once at composition and ignores invalid per-model and global values', () => { + writeConfig({ + contextManagement: { compactionTriggerAt: 1.5 }, + modelSettings: { [modelKey]: { autoCompactWindow: 0 } }, + }); + const logger = { warn: vi.fn(), error: vi.fn() }; + const probe = automaticCompactor(logger); + + expect(probe(selection)).toBe(false); + expect(probe(selection)).toBe(false); + expect(logger.warn).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + { + event: 'context_compaction_trigger_config_ignored', + setting: 'contextManagement.compactionTriggerAt', + value: 1.5, + }, + expect.any(String), + ); + expect(logger.warn).toHaveBeenCalledWith( + { + event: 'context_compaction_trigger_config_ignored', + setting: 'modelSettings.autoCompactWindow', + model: modelKey, + value: 0, + }, + expect.any(String), + ); + }); +}); diff --git a/packages/local-runtime-v2/src/service/turn-system/index.ts b/packages/local-runtime-v2/src/service/turn-system/index.ts index 4841aafa5..3ab030162 100644 --- a/packages/local-runtime-v2/src/service/turn-system/index.ts +++ b/packages/local-runtime-v2/src/service/turn-system/index.ts @@ -68,6 +68,7 @@ export { createProductionRootArchiveTitleModel, createProductionSessionTitleModel, createSessionLLMRetryEventObserver, + describeCompactionTrigger, } from './production-composition.js'; export type { CreateLocalAgentHostOptions, @@ -77,6 +78,7 @@ export type { ProductionSessionTitleProductCapabilities, } from './production-composition.js'; export type { ToolResultCompactionConfig } from './compaction/contracts.js'; +export { readSessionAgentExecutionSnapshot } from './agent-host/preparation/session-agent-execution-snapshot.js'; export type { AgentHostRuntimeLifecycle } from './agent-host/native-production-dependencies.js'; export { ModelRouteAvailabilityError, @@ -129,6 +131,9 @@ export { endLocalPluginHookSession, markNextLocalPluginHookSessionStart, } from './agent-host/assembly/local-turn-plugin-hooks.js'; -export { buildLocalTurnPayloadTransform } from './agent-host/index.js'; +export { + buildLocalTurnPayloadTransform, + readCompactionModelSelection, +} from './agent-host/index.js'; export { normalizeModelSelection, type UserModelSelection } from './agent-host/index.js'; 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 fccfe3954..f646266e9 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 @@ -10,7 +10,7 @@ import { toolOutputBudgetExtension, type ToolOutputArtifactInput, } from '@mavis/agent-extension'; -import type { LLMRetryOptions } from '@mavis/agent-core/pi-turn-runner'; +import type { LLMRetryOptions, PiTurnRunnerLogger } from '@mavis/agent-core/pi-turn-runner'; import { describeLocalBrowserToolInput } from '@mavis/agent-tools/desktop'; import type { IAgentConfig } from '@mavis/protocol'; import type { GlobalEventInput } from '@mavis/shared/global-events'; @@ -20,7 +20,7 @@ import { type ResolvedAgentCapabilities, type RunawayGuardOverride, } from '@mavis/config'; -import { isValidCompactionTriggerSetting } from '@mavis/context-manager'; +import { resolveCompactionTokenBudget } from '@mavis/context-manager'; import type { AppDb } from '../../infra/db/client.js'; import type { ContentSafetyService } from '../content-safety/index.js'; @@ -34,6 +34,7 @@ import { resolveSessionHistoryPaths, } from '../session-system/index.js'; import { isPeekSession } from '../session-system/sessions/repo/normalization.js'; +import { readCompactionTriggerSettings } from '../model-system/index.js'; import { createNativeAgentHostProductionDependencies, createNativeLocalAgentPreparation, @@ -57,7 +58,11 @@ import { type NativeAgentPreparationOptions, type NativeLocalAgentPreparationOptions, } from './agent-host/index.js'; -import type { AgentExecutionSnapshot, AgentHost } from './agent-host/contracts.js'; +import type { + AgentExecutionSnapshot, + AgentHost, + ContextCompactionModelSelection, +} from './agent-host/contracts.js'; import type { CanonicalHistoryCompactionChange } from './agent-host/history/contracts.js'; import type { DurableCanonicalHistoryProvider } from './agent-host/history/durable-canonical-history-store.js'; import { captureSemanticSnapshot } from './agent-host/history/semantic-identity.js'; @@ -469,7 +474,7 @@ export async function createLocalAgentHost< logger: options.product.runner.logger, toolResultArchiver, promptSnapshots: options.product.promptSnapshots, - getCompactionTriggerAt: createCompactionTriggerAtGetter(options), + trigger: readCompactionTriggerSettings(options.product.preparation.configBuilder.config), }), control: options.turnControl, lifecycle: options.sessions.agentProjection.compactionLifecycle, @@ -593,14 +598,28 @@ function createToolOutputArtifactRepository number | undefined; + /** Settings the trigger ignores, reported once when the host is composed. */ + readonly ignored: () => readonly { + readonly setting: string; + readonly model?: string; + readonly value: unknown; + }[]; +} + +/** Builds the automatic and manual context compactors used by the production host. */ +export function createContextCompactors(input: { readonly contextUsageAnchor: ContextUsageAnchorState; readonly checkpointState: ConstructorParameters[1]; - readonly logger: ConstructorParameters[2]; + readonly logger: Pick | undefined; readonly toolResultArchiver: ToolResultArchiver; readonly promptSnapshots: ConstructorParameters[2]; - readonly getCompactionTriggerAt: () => number | undefined; + readonly trigger: CompactionTriggerSettings; }) { + warnIgnoredTriggerSettings(input.trigger, input.logger); return { automatic: new AutomaticContextCompactor( input.contextUsageAnchor, @@ -609,7 +628,7 @@ function createContextCompactors(input: { { toolResultArchiver: input.toolResultArchiver, promptSnapshots: input.promptSnapshots, - getCompactionTriggerAt: input.getCompactionTriggerAt, + getCompactionTriggerAt: input.trigger.compactionTriggerAt, }, ), manual: new LocalContextCompactor(input.checkpointState, input.logger, input.promptSnapshots), @@ -617,26 +636,47 @@ function createContextCompactors(input: { } /** - * Reads `contextManagement.compactionTriggerAt` per call. An invalid value is - * reported once when the host is composed and then ignored by the budget. + * The trigger a request for this resolved Turn model would use, from the same + * budget as the automatic compactor's probe. */ -function createCompactionTriggerAtGetter( - options: CreateLocalAgentHostOptions, -): () => number | undefined { - const read = () => - options.product.preparation.configBuilder.config().contextManagement?.compactionTriggerAt; +export function describeCompactionTrigger(input: { + readonly compactionTriggerAt: number | undefined; + readonly llm: { + readonly model: { readonly contextWindow: number; readonly maxTokens: number }; + readonly maxTokens?: number; + }; +}): { readonly builtInTriggerTokens: number; readonly effectiveTriggerTokens: number } { + const { model, maxTokens } = input.llm; + // The Pi runner hands the probe `maxTokens` only when positive, and the probe + // then falls back to the model limit. + const limits = { + contextWindow: model.contextWindow, + configuredMaxOutputTokens: + maxTokens !== undefined && maxTokens > 0 ? maxTokens : model.maxTokens, + }; + return { + builtInTriggerTokens: resolveCompactionTokenBudget(limits).automaticTriggerAt, + effectiveTriggerTokens: resolveCompactionTokenBudget({ + ...limits, + compactionTriggerAt: input.compactionTriggerAt, + }).automaticTriggerAt, + }; +} + +function warnIgnoredTriggerSettings( + trigger: CompactionTriggerSettings, + logger: Pick | undefined, +): void { 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', + for (const ignored of trigger.ignored()) { + logger?.warn?.( + { event: 'context_compaction_trigger_config_ignored', ...ignored }, + `[local-runtime-v2] ignoring invalid ${ignored.setting}; it must be an integer token count above 1 (or "auto" per model, or a window ratio in (0, 1] globally)`, ); } } catch { // The compactor treats an unreadable config as unset. } - return read; } function createToolResultCompactionConfigGetter( diff --git a/packages/local-runtime-v2/src/services.ts b/packages/local-runtime-v2/src/services.ts index d5b604116..fe3a40d1a 100644 --- a/packages/local-runtime-v2/src/services.ts +++ b/packages/local-runtime-v2/src/services.ts @@ -52,6 +52,7 @@ import { type RuntimeSkillApplication, } from "./application/index.js"; import { createRuntimeQueueSelection } from "./application/session/runtime-queue-selection.js"; +import { createCompactionTriggerApplication } from "./application/session/compaction-trigger-application.js"; import { createForkState } from "./application/session/conversation-fork-state.js"; import { createProductionConversationMutationWorkflow, @@ -529,6 +530,12 @@ export async function createRuntimeServices( listSources: (input) => readLocalInstructionSources({ ...input, dataDir: options.dataDir }), }, + compactionTrigger: createCompactionTriggerApplication({ + sessions: sessionSystem.sessions.execution, + agents: product.agents, + preparation, + config: product.preparation.configBuilder.config, + }), mcp: owners.mcp.public, modelProvider: { application: modelProviderApplication, diff --git a/packages/tui/src/acp/commands.ts b/packages/tui/src/acp/commands.ts index e7e434e24..76cf10351 100644 --- a/packages/tui/src/acp/commands.ts +++ b/packages/tui/src/acp/commands.ts @@ -1,6 +1,12 @@ import * as acp from '@agentclientprotocol/sdk'; import { TUI_COMMAND_DESCRIPTORS } from '../application/command-descriptors.js'; +import { + AUTO_COMPACT_USAGE, + formatAutoCompactSaved, + formatAutoCompactStatus, + parseAutoCompactCommand, +} from '../application/auto-compact-command.js'; import { sanitizeTerminalText } from '../tui/rendering/terminal-text.js'; import type { TuiModel, TuiSkillList } from '../runtime/port.js'; import type { TuiAcpRuntime } from './runtime.js'; @@ -40,6 +46,10 @@ export const TUI_ACP_AVAILABLE_COMMANDS = [ ...TUI_COMMAND_DESCRIPTORS.compact, input: { hint: '[instructions]' }, }, + { + ...TUI_COMMAND_DESCRIPTORS.autocompact, + input: { hint: '[auto | ]' }, + }, ] satisfies readonly acp.AvailableCommand[]; /** Keep native commands authoritative and expose only invocable Skill names. */ @@ -81,6 +91,7 @@ export async function executeTuiAcpCommand(options: { readonly runtime: Pick< TuiAcpRuntime, | 'getAccountStatus' + | 'getCompactionTrigger' | 'getContextSnapshot' | 'getRuntimeDiagnostics' | 'getSessionUsage' @@ -89,6 +100,7 @@ export async function executeTuiAcpCommand(options: { | 'listSkills' | 'requestCompaction' | 'selectModel' + | 'setCompactionTrigger' >; readonly sessionId: string; readonly agentName?: string; @@ -183,6 +195,12 @@ export async function executeTuiAcpCommand(options: { result.error ?? result.code ?? 'Runtime rejected the compaction request.', ); } + if (command.name === 'autocompact') { + return { + handled: true, + output: await runAutoCompactCommand(options.runtime, options.sessionId, command.input), + }; + } if (command.name !== 'model') return { handled: false }; const models = await options.runtime.listModels(options.sessionId); @@ -201,6 +219,30 @@ export async function executeTuiAcpCommand(options: { return { handled: true, output: `Model selected: ${formatModelSelection(selection)}` }; } +async function runAutoCompactCommand( + runtime: Pick, + sessionId: string, + input: string, +): Promise { + const command = parseAutoCompactCommand(input); + if (command.kind === 'invalid') { + throw acp.RequestError.invalidParams(undefined, AUTO_COMPACT_USAGE); + } + const trigger = + command.kind === 'save' + ? await runtime.setCompactionTrigger?.(sessionId, command.value) + : await runtime.getCompactionTrigger?.(sessionId); + if (!trigger) { + throw acp.RequestError.internalError( + undefined, + 'Auto-compact settings are unavailable in this MiniMax Code Runtime.', + ); + } + return command.kind === 'save' + ? formatAutoCompactSaved(trigger) + : formatAutoCompactStatus(trigger); +} + function formatCompaction(result: Awaited>): string { return [ 'Compaction completed.', diff --git a/packages/tui/src/application/auto-compact-command.ts b/packages/tui/src/application/auto-compact-command.ts new file mode 100644 index 000000000..34c6c9f44 --- /dev/null +++ b/packages/tui/src/application/auto-compact-command.ts @@ -0,0 +1,75 @@ +import { parseAutoCompactWindowInput } from '@mavis/config'; + +import type { TuiCompactionTrigger } from '../runtime/port.js'; +import { sanitizeTerminalText } from '../tui/rendering/terminal-text.js'; + +/** Shared by the TUI and ACP `/autocompact` commands. */ +export const AUTO_COMPACT_USAGE = + 'Usage: /autocompact [auto | ], e.g. 300000, 500k, 1.5M, or 200 for 200K'; + +export type AutoCompactCommand = + | { readonly kind: 'show' } + | { readonly kind: 'save'; readonly value: number | 'auto' } + | { readonly kind: 'invalid' }; + +export function parseAutoCompactCommand(args: string): AutoCompactCommand { + if (!args.trim()) return { kind: 'show' }; + const parsed = parseAutoCompactWindowInput(args); + if (parsed.kind === 'invalid') return { kind: 'invalid' }; + return { kind: 'save', value: parsed.kind === 'auto' ? 'auto' : parsed.tokens }; +} + +const SOURCE_LABELS: Readonly> = { + model: 'saved for this model', + 'model-auto': 'built-in (auto saved for this model)', + global: 'global config', + 'built-in': 'built-in', +}; + +export function compactionTriggerSourceLabel(source: TuiCompactionTrigger['source']): string { + return SOURCE_LABELS[source]; +} + +/** `/autocompact` without arguments. */ +export function formatAutoCompactStatus(trigger: TuiCompactionTrigger): string { + return [ + `Auto-compact for ${modelLabel(trigger)} starts at ${startsAt(trigger)}`, + compactionTriggerSourceLabel(trigger.source), + ...aboveBuiltInNote(trigger), + ].join(' · '); +} + +/** Confirmation after `/autocompact ` saved the setting. */ +export function formatAutoCompactSaved(trigger: TuiCompactionTrigger): string { + const saved = + trigger.saved === 'auto' + ? 'auto (built-in trigger)' + : `${formatTokens(trigger.saved ?? trigger.effectiveTriggerTokens)} tokens`; + return [ + `Auto-compact for ${modelLabel(trigger)} set to ${saved}`, + `starts at ${startsAt(trigger)} from the next request`, + ...aboveBuiltInNote(trigger), + ].join(' · '); +} + +function aboveBuiltInNote(trigger: TuiCompactionTrigger): string[] { + return typeof trigger.saved === 'number' && trigger.saved > trigger.builtInTriggerTokens + ? ['above the built-in trigger, which still applies'] + : []; +} + +function startsAt(trigger: TuiCompactionTrigger): string { + const percent = + trigger.contextWindow > 0 + ? ` (${Math.round((trigger.effectiveTriggerTokens / trigger.contextWindow) * 100)}% of window)` + : ''; + return `${formatTokens(trigger.effectiveTriggerTokens)} tokens${percent}`; +} + +function modelLabel(trigger: TuiCompactionTrigger): string { + return sanitizeTerminalText(`${trigger.providerId}/${trigger.modelId}`).replace(/\s+/gu, ' '); +} + +function formatTokens(value: number): string { + return new Intl.NumberFormat('en-US').format(value); +} diff --git a/packages/tui/src/application/command-descriptors.ts b/packages/tui/src/application/command-descriptors.ts index e8410bbd8..5bb0a9d39 100644 --- a/packages/tui/src/application/command-descriptors.ts +++ b/packages/tui/src/application/command-descriptors.ts @@ -44,6 +44,10 @@ export const TUI_COMMAND_DESCRIPTORS = { name: 'compact', description: 'Shorten the active conversation', }, + autocompact: { + name: 'autocompact', + description: "Show or set when this model's conversation is compacted automatically", + }, export: { name: 'export', description: 'Export the current Session as Markdown', diff --git a/packages/tui/src/runtime/adapter.ts b/packages/tui/src/runtime/adapter.ts index 8e60b2d04..90e284b4b 100644 --- a/packages/tui/src/runtime/adapter.ts +++ b/packages/tui/src/runtime/adapter.ts @@ -29,6 +29,7 @@ import type { TuiActiveRunSnapshot, TuiBackgroundTask, TuiCompactionResult, + TuiCompactionTrigger, TuiDelegationSnapshot, TuiDelegationStopReceipt, TuiFeedbackPreview, @@ -445,6 +446,15 @@ export class TuiRuntimeAdapter implements TuiRuntime { setPermissionMode(mode: TuiPermissionMode): Promise { return this.productAccess.setPermissionMode(mode); } + getCompactionTrigger(sessionId: string): Promise { + return this.productAccess.getCompactionTrigger(sessionId); + } + setCompactionTrigger( + sessionId: string, + value: number | 'auto', + ): Promise { + return this.productAccess.setCompactionTrigger(sessionId, value); + } listModels(sessionId?: string): Promise { return this.productAccess.listModels(sessionId); } @@ -540,8 +550,8 @@ export class TuiRuntimeAdapter implements TuiRuntime { customInstructions, ); } - getContextSnapshot(sessionId: string) { - return this.productAccess.getContextSnapshot(sessionId); + getContextSnapshot(sessionId: string, options?: { readonly compactionTrigger?: boolean }) { + return this.productAccess.getContextSnapshot(sessionId, options); } getActiveRun(sessionId: string): Promise { return this.productAccess.getActiveRun(sessionId); diff --git a/packages/tui/src/runtime/adapters/product-access.ts b/packages/tui/src/runtime/adapters/product-access.ts index 32ca3fa91..d405a553e 100644 --- a/packages/tui/src/runtime/adapters/product-access.ts +++ b/packages/tui/src/runtime/adapters/product-access.ts @@ -8,6 +8,7 @@ import type { TuiAccountStatusOptions, TuiActiveRunSnapshot, TuiCompactionResult, + TuiCompactionTrigger, TuiContextSnapshotResponse, TuiInstructionSource, TuiMcpServer, @@ -90,6 +91,23 @@ export class TuiProductAccess { ); } + /** Undefined when this Runtime cannot read the trigger. */ + async getCompactionTrigger(sessionId: string): Promise { + return this.context + .service("config.compaction-trigger.read") + .getCompactionTrigger({ sessionId }); + } + + /** Undefined when this Runtime cannot persist the trigger. */ + async setCompactionTrigger( + sessionId: string, + value: number | 'auto', + ): Promise { + return this.context + .service("config.compaction-trigger.write") + .setCompactionTrigger({ sessionId, value }); + } + async listModels(sessionId?: string): Promise { const service = this.context.service("model.list"); const request = { ...(sessionId ? { sessionId } : {}) }; @@ -317,12 +335,17 @@ export class TuiProductAccess { async getContextSnapshot( sessionId: string, + options: { readonly compactionTrigger?: boolean } = {}, ): Promise { const service = this.context.service("session.context-snapshot"); - const [sessionResponse, messagesResponse, models] = await Promise.all([ + const [sessionResponse, messagesResponse, models, compactionTrigger] = await Promise.all([ service.getSession({ id: sessionId }), service.getMessages({ id: sessionId, limit: 80 }), service.listModels({ sessionId }) as Promise, + // The trigger is extra detail: a failed read keeps the rest of /context. + options.compactionTrigger + ? this.getCompactionTrigger(sessionId).catch(() => undefined) + : undefined, ]); const session = sessionResponse.session; if (!session) @@ -334,11 +357,12 @@ export class TuiProductAccess { selected.modelId !== session.model?.modelId) ? selected : (session.model ?? selected); - return projectTuiContextSnapshot({ + const snapshot = projectTuiContextSnapshot({ messages: messagesResponse.messages ?? [], active: session.status?.statusType === 1, model, }); + return compactionTrigger ? { ...snapshot, compactionTrigger } : snapshot; } async getActiveRun(sessionId: string): Promise { diff --git a/packages/tui/src/runtime/port.ts b/packages/tui/src/runtime/port.ts index 0502ba7df..a5e301c38 100644 --- a/packages/tui/src/runtime/port.ts +++ b/packages/tui/src/runtime/port.ts @@ -261,6 +261,20 @@ export interface TuiSessionForkPort { forkSession(input: ForkTuiSessionInput): Promise; } +/** A Session model's automatic compaction trigger, in tokens. */ +export interface TuiCompactionTrigger { + /** Source-qualified selection that keys `modelSettings` in config.yaml. */ + readonly providerId: string; + readonly modelId: string; + readonly contextWindow: number; + readonly builtInTriggerTokens: number; + /** The lower of the setting and the built-in trigger. */ + readonly effectiveTriggerTokens: number; + readonly source: 'model' | 'model-auto' | 'global' | 'built-in'; + /** This model's own valid `autoCompactWindow`. */ + readonly saved?: number | 'auto'; +} + export interface TuiConfigurationPort extends McodeProviderRuntimePort { getRuntimeDiagnostics(): Promise; getInstructionSources(workspaceDir: string): Promise; @@ -270,6 +284,16 @@ export interface TuiConfigurationPort extends McodeProviderRuntimePort { ): Promise; getPermissionMode(): Promise; setPermissionMode(mode: TuiPermissionMode): Promise; + /** The Session model's automatic compaction trigger; undefined when the Runtime has none. */ + getCompactionTrigger?(sessionId: string): Promise; + /** + * Saves `modelSettings..autoCompactWindow` for the Session's + * model; resolves the refreshed trigger, or undefined when the Runtime cannot. + */ + setCompactionTrigger?( + sessionId: string, + value: number | 'auto', + ): Promise; listModels(sessionId?: string): Promise; selectModel(model: TuiModelSelection, sessionId?: string): Promise; selectSessionModel(model: TuiModelSelection, sessionId: string): Promise; @@ -299,7 +323,11 @@ export interface TuiInspectionPort { listSkills(agentName?: string, keyword?: string, workspaceDir?: string): Promise; listMcpServers(keyword?: string, sessionId?: string): Promise; inspectProjectMcp(sessionId: string): Promise; - getContextSnapshot(sessionId: string): Promise; + /** `compactionTrigger` also reads the automatic compaction trigger; a failed read is omitted. */ + getContextSnapshot( + sessionId: string, + options?: { readonly compactionTrigger?: boolean }, + ): Promise; } export type TuiContextComponentKind = @@ -332,6 +360,8 @@ export interface TuiContextSnapshotResponse { readonly state: 'never' | 'running' | 'completed' | 'failed'; readonly lastCompactedAtMs?: number; }; + /** Present only when requested and readable. */ + readonly compactionTrigger?: TuiCompactionTrigger; } export interface TuiFeedbackPort { diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index 9b8964307..c0a150a49 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -619,6 +619,12 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { }, isStopped: () => stopped, }); + const readAutoCompact = options.runtime.getCompactionTrigger?.bind(options.runtime); + const writeAutoCompact = options.runtime.setCompactionTrigger?.bind(options.runtime); + const autoCompactSetting = + readAutoCompact && writeAutoCompact + ? { read: readAutoCompact, write: writeAutoCompact } + : undefined; commandFlow = new TuiCommandFlow({ bashFlow, workspaceDir: options.workspaceDir, @@ -632,6 +638,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { goalFlow, planModeFlow, permissionModeFlow, + ...(autoCompactSetting ? { autoCompactSetting } : {}), ...(options.auth ? { auth: options.auth } : {}), openExternalTarget: options.openExternalTarget, interactionFlow, diff --git a/packages/tui/src/tui/commands/catalog.ts b/packages/tui/src/tui/commands/catalog.ts index 3ff254c15..27982a624 100644 --- a/packages/tui/src/tui/commands/catalog.ts +++ b/packages/tui/src/tui/commands/catalog.ts @@ -348,6 +348,19 @@ const COMMAND_SOURCES: readonly TuiCommandSource[] = [ visibleWhen: (context) => context.hasSession, unavailableReason: 'Start or resume a Session before viewing context.', }, + { + ...TUI_COMMAND_DESCRIPTORS.autocompact, + category: 'Runtime', + discoverability: 'contextual', + argumentHint: '[auto | ]', + visibleWhen: (context) => context.hasSession, + unavailableReason: 'Start or resume a Session before configuring auto-compact.', + getArgumentCompletions: argumentCompleter([ + ['auto', 'Use the built-in trigger for this model'], + ['200k', 'Start compacting at 200K tokens'], + ['500k', 'Start compacting at 500K tokens'], + ]), + }, { name: 'steer', description: 'Steer current work without interrupting it', diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index b75a012b6..144e84186 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -4,6 +4,7 @@ import { matchTuiCommandInput, type TuiCommandCatalog, type TuiCommandContribution, + type TuiCommandExecutionDisposition, type TuiCommandHandler, type TuiCommandReadiness, } from '../../commands/catalog.js'; @@ -42,6 +43,12 @@ import { import { TuiLoginRequiredError } from '../../../application/login-gate.js'; import { TuiFailure } from '../../../failure.js'; import { formatTuiActionFailure } from '../../../user-facing-failure.js'; +import { + AUTO_COMPACT_USAGE, + formatAutoCompactSaved, + formatAutoCompactStatus, + parseAutoCompactCommand, +} from '../../../application/auto-compact-command.js'; import { createTuiSubmissionSnapshot, type TuiSubmissionSeed, @@ -50,7 +57,11 @@ import { import { rebuildSessionMutationTransport } from '../../features/session-mutation/transport.js'; import { toTuiTranscriptAttachments } from '../../features/composer/attachments.js'; import { resolveTuiRuntimeFailure } from '../runtime/runtime-error-presentation.js'; -import type { TuiEditMessageAttachment, TuiSession } from '../../../runtime/port.js'; +import type { + TuiCompactionTrigger, + TuiEditMessageAttachment, + TuiSession, +} from '../../../runtime/port.js'; import type { TuiAttachment, TuiTransportAttachment } from '../../../types/invocation.js'; import { sessionMutationText } from '../../features/session-mutation/copy.js'; import { parseTuiBashInput } from '../../commands/bash-input.js'; @@ -78,6 +89,14 @@ export interface TuiCommandFlowOptions { readonly goalFlow?: Pick; readonly planModeFlow?: TuiPlanModeFlow; readonly permissionModeFlow?: TuiPermissionModeFlow; + /** + * The Session model's automatic compaction trigger (`modelSettings` in + * config.yaml); absent, or resolving undefined, when the Runtime has none. + */ + readonly autoCompactSetting?: { + read(sessionId: string): Promise; + write(sessionId: string, value: number | 'auto'): Promise; + }; readonly auth?: McodeAuthPort; readonly openExternalTarget?: TuiExternalTargetOpener; readonly interactionFlow: TuiInteractionFlow; @@ -1267,6 +1286,7 @@ export class TuiCommandFlow { } await this.options.permissionModeFlow.set(mode); }, + autocompact: async ({ args }) => this.runAutoCompactCommand(args), login: () => { this.pendingLoginContinuation = undefined; this.showLoginRegionPicker(); @@ -1564,6 +1584,54 @@ export class TuiCommandFlow { this.showLoginRegionPicker(); } + private async runAutoCompactCommand(args: string): Promise { + const setting = this.options.autoCompactSetting; + const reportUnavailable = () => + this.options.append('Auto-compact settings are unavailable in this host.', 'warning'); + if (!setting) { + reportUnavailable(); + return 'consumed'; + } + const command = parseAutoCompactCommand(args); + if (command.kind === 'invalid') { + this.options.append(AUTO_COMPACT_USAGE, 'warning'); + return 'retained'; + } + const sessionId = this.options.controller.snapshot().session?.sessionId; + if (!sessionId) { + this.options.append('Start or resume a Session before configuring auto-compact.', 'warning'); + return 'retained'; + } + let trigger: TuiCompactionTrigger | undefined; + try { + trigger = + command.kind === 'save' + ? await setting.write(sessionId, command.value) + : await setting.read(sessionId); + } catch (error) { + this.options.append( + formatTuiActionFailure(error, { + summary: + command.kind === 'save' + ? "Couldn't save the auto-compact setting." + : "Couldn't read the auto-compact setting.", + nextStep: + 'Check that the Session has a usable model and that config.yaml is valid and writable, then retry.', + }), + 'error', + ); + return 'retained'; + } + if (!trigger) { + reportUnavailable(); + return 'consumed'; + } + this.options.append( + command.kind === 'save' ? formatAutoCompactSaved(trigger) : formatAutoCompactStatus(trigger), + ); + return 'consumed'; + } + private showPermissionModePicker(): void { const flow = this.options.permissionModeFlow; if (!flow) { diff --git a/packages/tui/src/tui/controller/run/active-run-flow.ts b/packages/tui/src/tui/controller/run/active-run-flow.ts index 333083bbb..cc57c2bc8 100644 --- a/packages/tui/src/tui/controller/run/active-run-flow.ts +++ b/packages/tui/src/tui/controller/run/active-run-flow.ts @@ -346,7 +346,9 @@ export class TuiActiveRunFlow { }); this.showContextInspection(session.sessionId, panel); try { - const response = await this.options.runtime.getContextSnapshot(session.sessionId); + const response = await this.options.runtime.getContextSnapshot(session.sessionId, { + compactionTrigger: true, + }); if (this.isStopped()) return; if (this.options.controller.snapshot().session?.sessionId !== session.sessionId) { this.closeContextInspection(panel); diff --git a/packages/tui/src/tui/features/inspection/product-inspection.ts b/packages/tui/src/tui/features/inspection/product-inspection.ts index dbdb77d23..870831627 100644 --- a/packages/tui/src/tui/features/inspection/product-inspection.ts +++ b/packages/tui/src/tui/features/inspection/product-inspection.ts @@ -659,7 +659,10 @@ export function createTuiContextInspection( basis: usage.totalCountSource === 'PROVIDER_USAGE_ANCHORED' ? 'provider-usage' : 'runtime-estimate', compaction: response.compaction?.state ?? 'never', - compactionThresholdTokens: null, + compactionThresholdTokens: response.compactionTrigger?.effectiveTriggerTokens ?? null, + ...(response.compactionTrigger + ? { compactionThresholdSource: response.compactionTrigger.source } + : {}), components: usage.components, }; return { diff --git a/packages/tui/src/tui/transcript/context-visualization.ts b/packages/tui/src/tui/transcript/context-visualization.ts index eab1edbc4..ffcf075ff 100644 --- a/packages/tui/src/tui/transcript/context-visualization.ts +++ b/packages/tui/src/tui/transcript/context-visualization.ts @@ -2,6 +2,7 @@ import type { Component } from '../rendering/component.js'; import { truncateToWidth } from '../rendering/text.js'; import { sanitizeTerminalText } from '../rendering/terminal-text.js'; import { tuiChalk as chalk, tuiColors as colors } from '../theme/runtime.js'; +import { compactionTriggerSourceLabel } from '../../application/auto-compact-command.js'; import type { TranscriptContextComponent, TranscriptContextComponentKind, @@ -166,7 +167,10 @@ export class ContextVisualization implements Component { const heading = `${chalk.bold.hex(colors.muted)('Auto-compact')}${chalk.hex(colors.dim)( ' · ', )}${paintCompaction(lifecycle, this.data.compaction)}`; - const detail = `Starts at ${formatCompact(threshold)} · ${thresholdPercent}% of window · ${distance} away`; + const source = this.data.compactionThresholdSource; + const detail = `Starts at ${formatCompact(threshold)} · ${thresholdPercent}% of window · ${distance} away${ + source ? ` · ${compactionTriggerSourceLabel(source)}` : '' + }`; return [heading, paintCompaction(detail, this.data.compaction)]; } } diff --git a/packages/tui/src/tui/transcript/model.ts b/packages/tui/src/tui/transcript/model.ts index 2a8140aaf..0060c2d9e 100644 --- a/packages/tui/src/tui/transcript/model.ts +++ b/packages/tui/src/tui/transcript/model.ts @@ -1,6 +1,7 @@ import type { TuiStructuredPreview } from '../../types/runtime-models.js'; import type { TuiSessionCacheMetrics } from '../../application/session-cache-metrics.js'; import type { TuiAgentTeamSnapshot } from '../agent-team/model.js'; +import type { TuiCompactionTrigger } from '../../runtime/port.js'; export type TranscriptCellKind = | 'user' @@ -96,6 +97,8 @@ export interface TranscriptContextVisualization { readonly basis: 'runtime-estimate' | 'provider-usage'; readonly compaction: 'never' | 'running' | 'completed' | 'failed'; readonly compactionThresholdTokens: number | null; + /** Which setting decides the threshold; absent when the threshold is unknown. */ + readonly compactionThresholdSource?: TuiCompactionTrigger['source']; readonly components: readonly TranscriptContextComponent[]; } diff --git a/packages/tui/test/unit/acp-agent.test.ts b/packages/tui/test/unit/acp-agent.test.ts index fcdba3cb0..19fbb0b92 100644 --- a/packages/tui/test/unit/acp-agent.test.ts +++ b/packages/tui/test/unit/acp-agent.test.ts @@ -493,6 +493,16 @@ describe('MiniMax Code ACP agent', () => { { providerId: 'custom', modelId: 'coder', displayName: 'Coder' }, ], }); + const setCompactionTrigger = vi.fn(async (_sessionId: string, value: number | 'auto') => ({ + providerId: 'minimax', + modelId: 'm3', + contextWindow: 1_000_000, + builtInTriggerTokens: 760_000, + effectiveTriggerTokens: typeof value === 'number' ? value : 760_000, + source: 'model' as const, + saved: value, + })); + Object.assign(runtime, { setCompactionTrigger }); const updates: acp.SessionNotification[] = []; const agent = createTuiAcpAgent({ runtime, version: '1.2.3' }); const client = acp @@ -559,6 +569,11 @@ describe('MiniMax Code ACP agent', () => { description: 'Shorten the active conversation', input: { hint: '[instructions]' }, }, + { + name: 'autocompact', + description: "Show or set when this model's conversation is compacted automatically", + input: { hint: '[auto | ]' }, + }, ], }, }), @@ -582,6 +597,24 @@ describe('MiniMax Code ACP agent', () => { prompt: [{ type: 'text', text: '/model custom/coder' }], }), ).resolves.toEqual({ stopReason: 'end_turn' }); + await expect( + connection.request(acp.methods.agent.session.prompt, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: '/autocompact 300k' }], + }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + }); + + expect(setCompactionTrigger).toHaveBeenCalledWith('session-1', 300_000); + expect(updates).toContainEqual({ + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: 'Auto-compact for minimax/m3 set to 300,000 tokens · starts at 300,000 tokens (30% of window) from the next request', + }, + }, }); expect(listModels).toHaveBeenCalledTimes(3); @@ -613,6 +646,7 @@ describe('MiniMax Code ACP agent', () => { '- /mcp [filter] — Inspect MCP capabilities and project configuration', '- /usage — Show session usage', '- /compact [instructions] — Shorten the active conversation', + "- /autocompact [auto | ] — Show or set when this model's conversation is compacted automatically", ].join('\n'), }, }, diff --git a/packages/tui/test/unit/runtime-product-access.test.ts b/packages/tui/test/unit/runtime-product-access.test.ts index 0e1b1b53d..eaf92578c 100644 --- a/packages/tui/test/unit/runtime-product-access.test.ts +++ b/packages/tui/test/unit/runtime-product-access.test.ts @@ -252,6 +252,35 @@ describe("TuiRuntimeAdapter product access", () => { expect(cliService.getActiveTurn).toHaveBeenCalledWith("session-1"); }); + it('adds the compaction trigger to a requested context snapshot and keeps /context when it fails', async () => { + const cliService = cliServiceFixture(); + const trigger = { + providerId: 'minimax', + modelId: 'MiniMax-M3', + contextWindow: 1000, + builtInTriggerTokens: 760, + effectiveTriggerTokens: 500, + source: 'global' as const, + }; + const getCompactionTrigger = vi.fn(async () => trigger); + Object.assign(cliService, { getCompactionTrigger }); + const adapter = new TuiRuntimeAdapter(cliService); + + await expect(adapter.getContextSnapshot('session-1')).resolves.not.toHaveProperty( + 'compactionTrigger', + ); + expect(getCompactionTrigger).not.toHaveBeenCalled(); + await expect( + adapter.getContextSnapshot('session-1', { compactionTrigger: true }), + ).resolves.toMatchObject({ status: 'stale', compactionTrigger: trigger }); + expect(getCompactionTrigger).toHaveBeenCalledWith({ sessionId: 'session-1' }); + + getCompactionTrigger.mockRejectedValueOnce(new Error('model credentials unavailable')); + const degraded = await adapter.getContextSnapshot('session-1', { compactionTrigger: true }); + expect(degraded).toMatchObject({ status: 'stale', contextUsage: { usedTokens: 300 } }); + expect(degraded).not.toHaveProperty('compactionTrigger'); + }); + it("derives decision-blocked active state from generated interaction contracts", async () => { const cliService = cliServiceFixture(); vi.mocked(cliService.listPendingPermissions).mockResolvedValueOnce({ diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index ba8cceb9e..125e033a4 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -5483,6 +5483,65 @@ describe("createTuiApp", () => { await app.stop(); }); + it('shows and saves the auto-compact trigger for the Session model with /autocompact', async () => { + let saved: number | 'auto' | undefined; + const trigger = () => ({ + providerId: 'minimax', + modelId: 'MiniMax-M3', + contextWindow: 1_000_000, + builtInTriggerTokens: 760_000, + effectiveTriggerTokens: typeof saved === 'number' ? Math.min(saved, 760_000) : 760_000, + source: + saved === undefined + ? ('built-in' as const) + : saved === 'auto' + ? ('model-auto' as const) + : ('model' as const), + ...(saved === undefined ? {} : { saved }), + }); + const setCompactionTrigger = vi.fn(async (_sessionId: string, value: number | 'auto') => { + saved = value; + return trigger(); + }); + const runtime = createRuntime(); + runtime.getCompactionTrigger = vi.fn(async () => trigger()); + runtime.setCompactionTrigger = setCompactionTrigger; + const terminal = new FakeTerminal(); + const app = createTuiApp({ + runtime, + terminal, + version: '0.1.0', + workspaceDir: '/workspace', + }); + const screen = () => renderTerminalViewport(app, terminal).replace(/\s+/gu, ' '); + app.start(); + await app.ready; + await app.submit('Fix the failing parser'); + + await app.submit('/autocompact'); + expect(screen()).toContain( + 'Auto-compact for minimax/MiniMax-M3 starts at 760,000 tokens (76% of window) · built-in', + ); + + await app.submit('/autocompact 400k'); + expect(setCompactionTrigger).toHaveBeenLastCalledWith(expect.any(String), 400_000); + expect(screen()).toContain( + 'set to 400,000 tokens · starts at 400,000 tokens (40% of window) from the next request', + ); + + await app.submit('/autocompact 2M'); + expect(screen()).toContain('above the built-in trigger, which still applies'); + + await app.submit('/autocompact 80%'); + expect(setCompactionTrigger).toHaveBeenCalledTimes(2); + expect(screen()).toContain('Usage: /autocompact [auto | ]'); + + await app.submit('/autocompact auto'); + expect(setCompactionTrigger).toHaveBeenLastCalledWith(expect.any(String), 'auto'); + expect(screen()).toContain('set to auto (built-in trigger)'); + await app.stop(); + }); + it("opens the latest settled Plan as a read-only full snapshot", async () => { const runtime = createRuntime(); vi.mocked(runtime.getLatestPlanReview).mockResolvedValue( diff --git a/packages/tui/test/unit/tui-capability-inspection.test.ts b/packages/tui/test/unit/tui-capability-inspection.test.ts index d6a23411c..dd96bd4b9 100644 --- a/packages/tui/test/unit/tui-capability-inspection.test.ts +++ b/packages/tui/test/unit/tui-capability-inspection.test.ts @@ -5,6 +5,8 @@ import { createTuiMcpInspectionPanel, createTuiSkillsInspectionPanel, } from "../../src/tui/features/inspection/capabilities.js"; +import { createTuiContextInspection } from "../../src/tui/features/inspection/product-inspection.js"; +import { ContextVisualization } from "../../src/tui/transcript/context-visualization.js"; describe("MCode capability inspection", () => { it("renders Skills as a grouped read-only catalog instead of transcript prose", () => { @@ -121,3 +123,35 @@ describe("MCode capability inspection", () => { expect(rendered).not.toContain("do-not-render"); }); }); + +describe("MCode context inspection", () => { + it("shows when automatic compaction starts and which setting decides it", () => { + // /context hard-coded the threshold to null, so this line never rendered. + const inspection = createTuiContextInspection({ + status: "live", + contextUsage: { + contextWindowTokens: 10_000, + usedTokens: 3_000, + totalCountSource: "PROVIDER_USAGE_ANCHORED", + components: [], + }, + compactionTrigger: { + providerId: "minimax", + modelId: "MiniMax-M3", + contextWindow: 10_000, + builtInTriggerTokens: 7_600, + effectiveTriggerTokens: 6_000, + source: "model", + saved: 6_000, + }, + }); + const visualization = inspection?.visualization; + if (visualization?.kind !== "context") throw new Error("Expected a context visualization."); + const output = new ContextVisualization(visualization) + .render(180) + .map(stripVTControlCharacters) + .join("\n"); + + expect(output).toContain("Starts at 6k · 60% of window · 3k away · saved for this model"); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index df74562df..4729a6ee3 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -484,6 +484,7 @@ "packages/config/src/agent-runtime-config.ts", "packages/config/src/ask-user-config.ts", "packages/config/src/asr.ts", + "packages/config/src/auto-compact-window.ts", "packages/config/src/browser-config.ts", "packages/config/src/byok-config.ts", "packages/config/src/config.ts", @@ -515,6 +516,7 @@ "packages/config/src/tool-result-compaction-config.ts", "packages/config/src/tui-config.ts", "packages/config/src/tui-status-line-write.ts", + "packages/config/test/auto-compact-window.test.ts", "packages/config/test/builtin-model-fallback.test.ts", "packages/config/test/config-file-permissions.test.ts", "packages/config/test/config-update-preservation.test.ts", @@ -642,6 +644,8 @@ "packages/local-runtime-v2/src/application/queue/contract.ts", "packages/local-runtime-v2/src/application/queue/queue-application.ts", "packages/local-runtime-v2/src/application/queue/wire.ts", + "packages/local-runtime-v2/src/application/session/compaction-trigger-application.test.ts", + "packages/local-runtime-v2/src/application/session/compaction-trigger-application.ts", "packages/local-runtime-v2/src/application/session/content-application.ts", "packages/local-runtime-v2/src/application/session/content-timing.test.ts", "packages/local-runtime-v2/src/application/session/conversation-fork-contracts.ts", @@ -973,6 +977,7 @@ "packages/local-runtime-v2/src/service/model-system/catalog/provider-views.ts", "packages/local-runtime-v2/src/service/model-system/codex-oauth.test.ts", "packages/local-runtime-v2/src/service/model-system/codex-oauth.ts", + "packages/local-runtime-v2/src/service/model-system/compaction-trigger-settings.ts", "packages/local-runtime-v2/src/service/model-system/connectivity/codex-model-discovery.ts", "packages/local-runtime-v2/src/service/model-system/connectivity/discover-models.test.ts", "packages/local-runtime-v2/src/service/model-system/connectivity/discover-models.ts", @@ -1440,6 +1445,7 @@ "packages/local-runtime-v2/src/service/turn-system/compaction/execution/checkpoint-provider.test.ts", "packages/local-runtime-v2/src/service/turn-system/compaction/execution/checkpoint-provider.ts", "packages/local-runtime-v2/src/service/turn-system/compaction/execution/local-context-footprint.ts", + "packages/local-runtime-v2/src/service/turn-system/compaction/execution/production-context-compactors.test.ts", "packages/local-runtime-v2/src/service/turn-system/compaction/execution/usage-anchor.ts", "packages/local-runtime-v2/src/service/turn-system/compaction/local-context-compactor.test.ts", "packages/local-runtime-v2/src/service/turn-system/compaction/local-context-compactor.ts", @@ -2901,6 +2907,7 @@ "packages/tui/src/analytics/tui-business-event-tracker.ts", "packages/tui/src/application/assistant-content.ts", "packages/tui/src/application/attachment-policy.ts", + "packages/tui/src/application/auto-compact-command.ts", "packages/tui/src/application/command-descriptors.ts", "packages/tui/src/application/context-window.ts", "packages/tui/src/application/exec-result.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 157c81564..8db1dcc24 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -147,6 +147,7 @@ "packages/agent-modules/goal/test/unit/thread-goal/final-reply.test.ts", "packages/local-runtime-v2/src/application/agent/goal-final-reply.test.ts", "packages/local-runtime-v2/src/application/session/process-local-application.test.ts", + "packages/local-runtime-v2/src/application/session/compaction-trigger-application.test.ts", "packages/local-runtime-v2/src/compat/v1/agent-host.test.ts", "packages/local-runtime-v2/src/service/turn-system/queue.dispatcher.test.ts", "packages/local-runtime-v2/test/integration/mcp/project-mcp.integration.test.ts", @@ -182,6 +183,7 @@ "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/config/test/auto-compact-window.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", @@ -211,6 +213,7 @@ "packages/local-runtime-v2/src/service/turn-system/compaction/automatic-context-compactor.test.ts", "packages/local-runtime-v2/src/service/turn-system/compaction/execution/checkpoint-prompt.test.ts", "packages/local-runtime-v2/src/service/turn-system/compaction/execution/checkpoint-provider.test.ts", + "packages/local-runtime-v2/src/service/turn-system/compaction/execution/production-context-compactors.test.ts", "packages/local-runtime-v2/src/service/turn-system/compaction/local-context-compactor.test.ts", "packages/agent-core/test/unit/event-bridge/decode-timing.test.ts", "packages/tui/test/unit/tui-stream-events.test.ts",