Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/tui-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,31 @@ Override the bounds in milliseconds with `MCODE_LLM_FIRST_EVENT_TIMEOUT_MS` and
also set `firstEventTimeoutMs` and `streamIdleTimeoutMs` per model. The timeout error names
the setting that fired. The 20-minute overall request limit still applies.

## Automatic context compaction trigger

Automatic compaction starts before a model request whose context exceeds the
built-in trigger. That trigger reserves the configured output limit (capped at a
quarter of the window) and then takes 95% of the remainder, so it lands between
about 70% and 95% of the context window depending on the model. Large tool
results and the serialized request size can also start compaction on their own;
tool-result reduction is configured separately under `toolResultCompaction`.

To compact earlier, for example to keep requests smaller, set an upper bound in
the data directory's `config.yaml`:

```yaml
contextManagement:
compactionTriggerAt: 0.8 # ratio of the context window, or e.g. 300000 tokens
```

A value in `(0, 1]` is a ratio of the current model's context window; an integer
above 1 is an absolute token count. The effective trigger is the lower of this
value and the built-in trigger, so the setting can start compaction earlier but
never later, and has no lower bound: a value below what remains after compaction
compacts before every request. Other values, such as `1.5`, are ignored with a
warning in the runtime log. Automatic compaction cannot be disabled; `/compact`
remains available at any time. Apply changes by restarting MCode.

## Local Bash execution

When the current turn includes native `task_output`, foreground Bash waits up to
Expand Down
7 changes: 6 additions & 1 deletion packages/agent-modules/context-manager/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
export { ContextManager } from './manager.js';
export { computeCompactionTriggerAt, DEFAULT_CONTEXT_MANAGER_SETTINGS } from './settings.js';
export { resolveCompactionTokenBudget, resolveDynamicMaxTokens } from './provider-budget.js';
export {
isValidCompactionTriggerSetting,
resolveCompactionTokenBudget,
resolveConfiguredCompactionTrigger,
resolveDynamicMaxTokens,
} from './provider-budget.js';
export {
BpeTokenEstimator,
createDefaultContextTokenEstimator,
Expand Down
33 changes: 32 additions & 1 deletion packages/agent-modules/context-manager/src/provider-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export function resolveDynamicMaxTokens(input: {
export function resolveCompactionTokenBudget(input: {
readonly contextWindow: number;
readonly configuredMaxOutputTokens: number;
/**
* User `contextManagement.compactionTriggerAt`. It can only lower the
* automatic trigger; invalid values are ignored like an unset setting.
*/
readonly compactionTriggerAt?: number | undefined;
}): { readonly providerInputLimit: number; readonly automaticTriggerAt: number } {
const configuredOutput = positive(input.configuredMaxOutputTokens);
const { reserveTokens, safetyMarginTokens } = DEFAULT_CONTEXT_MANAGER_SETTINGS;
Expand Down Expand Up @@ -46,12 +51,38 @@ export function resolveCompactionTokenBudget(input: {
const outputReservedTrigger = Math.floor(
((input.contextWindow - outputReserve) * AUTOMATIC_TRIGGER_PERCENT) / 100,
);
const builtInTrigger = Math.min(providerInputLimit, Math.max(1, outputReservedTrigger));
const configuredTrigger = resolveConfiguredCompactionTrigger(
input.compactionTriggerAt,
input.contextWindow,
);
return {
providerInputLimit,
automaticTriggerAt: Math.min(providerInputLimit, Math.max(1, outputReservedTrigger)),
automaticTriggerAt:
configuredTrigger === undefined ? builtInTrigger : Math.min(builtInTrigger, configuredTrigger),
};
}

/**
* `compactionTriggerAt` accepts a window ratio in (0, 1] or an absolute token
* count as an integer above 1. Anything else, such as 1.5, is invalid.
*/
export function isValidCompactionTriggerSetting(value: number): boolean {
if (!Number.isFinite(value) || value <= 0) return false;
return value <= 1 || Number.isSafeInteger(value);
}

/** Resolves the user setting to tokens, or undefined when it should be ignored. */
export function resolveConfiguredCompactionTrigger(
value: number | undefined,
contextWindow: number,
): number | undefined {
if (value === undefined || !isValidCompactionTriggerSetting(value)) return undefined;
if (value > 1) return value;
if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
return Math.max(1, Math.floor(contextWindow * value));
}

function outputFloor(configuredMaxTokens: number): number {
return Math.min(configuredMaxTokens, DEFAULT_CONTEXT_MANAGER_SETTINGS.reserveTokens);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';

import {
isValidCompactionTriggerSetting,
resolveCompactionTokenBudget,
resolveConfiguredCompactionTrigger,
resolveDynamicMaxTokens,
} from '../src/provider-budget.js';

Expand Down Expand Up @@ -102,4 +104,85 @@ describe('resolveCompactionTokenBudget', () => {
}),
).toEqual({ providerInputLimit: 1, automaticTriggerAt: 1 });
});

it.each([
['a window ratio', 0.5, 100_000],
['an absolute token count', 120_000, 120_000],
['a tiny absolute count without a floor', 5_000, 5_000],
] as const)('lowers the automatic trigger to %s', (_case, compactionTriggerAt, expected) => {
const budget = resolveCompactionTokenBudget({
contextWindow: 200_000,
configuredMaxOutputTokens: 32_000,
compactionTriggerAt,
});
expect(budget).toEqual({ providerInputLimit: 181_568, automaticTriggerAt: expected });
});

it.each([
['a ratio above the built-in line', 0.95],
['the whole window', 1],
['an absolute count above the built-in line', 190_000],
] as const)('keeps the built-in trigger for %s', (_case, compactionTriggerAt) => {
expect(
resolveCompactionTokenBudget({
contextWindow: 200_000,
configuredMaxOutputTokens: 32_000,
compactionTriggerAt,
}).automaticTriggerAt,
).toBe(159_600);
});

it.each([1.5, 0, -1, Number.NaN, Number.POSITIVE_INFINITY, undefined])(
'ignores an invalid compaction trigger: %s',
(compactionTriggerAt) => {
expect(
resolveCompactionTokenBudget({
contextWindow: 200_000,
configuredMaxOutputTokens: 32_000,
compactionTriggerAt,
}).automaticTriggerAt,
).toBe(159_600);
},
);
});

describe('resolveConfiguredCompactionTrigger', () => {
it.each([
[0.8, 200_000, 160_000],
[1, 200_000, 200_000],
[0.000001, 200_000, 1],
[300_000, 200_000, 300_000],
[2, 200_000, 2],
] as const)('resolves %s against a %i-token window to %i', (value, window, expected) => {
expect(resolveConfiguredCompactionTrigger(value, window)).toBe(expected);
});

it.each([
[1.5, 200_000],
[0, 200_000],
[-0.5, 200_000],
[Number.NaN, 200_000],
[Number.POSITIVE_INFINITY, 200_000],
[0.8, 0],
[0.8, Number.NaN],
] as const)('treats %s with a %s-token window as unset', (value, window) => {
expect(resolveConfiguredCompactionTrigger(value, window)).toBeUndefined();
});

it('accepts an absolute count even when the window is unknown', () => {
expect(resolveConfiguredCompactionTrigger(50_000, Number.NaN)).toBe(50_000);
});
});

describe('isValidCompactionTriggerSetting', () => {
it.each([0.5, 1, 2, 300_000])('accepts %s', (value) => {
expect(isValidCompactionTriggerSetting(value)).toBe(true);
});

it.each([1.5, 0, -1, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_VALUE])(
'rejects %s',
(value) => {
expect(isValidCompactionTriggerSetting(value)).toBe(false);
},
);
});
24 changes: 23 additions & 1 deletion packages/config/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,17 @@ export interface ContextManagementConfig {
* Default: 5.
*/
cuMaxScreenshotsInContext: number;

/**
* Upper bound for the automatic context compaction trigger.
*
* A value in `(0, 1]` is a ratio of the current model's context window;
* an integer above 1 is an absolute token count. The effective trigger is
* the lower of this value and the built-in trigger, so it can only make
* compaction start earlier. The runtime ignores invalid numbers (such as
* `1.5`) with a warning. Unset keeps the built-in behavior.
*/
compactionTriggerAt?: number;
}

/**
Expand Down Expand Up @@ -2493,7 +2504,18 @@ function parseContextManagementConfig(
? Math.floor(rawCuMax)
: defaults.cuMaxScreenshotsInContext;

return { disableSystemReminderModels, cuMaxScreenshotsInContext };
// Semantic validation (ratio vs. token count) lives with the trigger in
// @mavis/context-manager; keep numbers so the runtime can warn on bad ones.
const compactionTriggerAt =
typeof obj.compactionTriggerAt === "number"
? obj.compactionTriggerAt
: undefined;

return {
disableSystemReminderModels,
cuMaxScreenshotsInContext,
...(compactionTriggerAt === undefined ? {} : { compactionTriggerAt }),
};
}

// ── Agent Runtime config parsing — see ./agent-runtime-config.ts for the
Expand Down
65 changes: 65 additions & 0 deletions packages/config/test/context-management-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import fs from "node:fs";
import os from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveConfigFromRaw } from "../src/config.js";

let dataDir: string;

beforeEach(() => {
dataDir = fs.mkdtempSync(join(os.tmpdir(), "mcode-context-management-"));
vi.stubEnv("MINIMAX_DATA_DIR", dataDir);
vi.stubEnv("__MAVIS_RUNTIME_DATA_DIR", dataDir);
vi.stubEnv("__MAVIS_RUNTIME_DISABLE_GIT_AUTO_CONFIG", "1");
});

afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(dataDir, { recursive: true, force: true });
});

function contextManagement(raw: Record<string, unknown>) {
return resolveConfigFromRaw(raw, dataDir).contextManagement;
}

describe("contextManagement.compactionTriggerAt", () => {
it("is absent by default", () => {
expect(contextManagement({})).not.toHaveProperty("compactionTriggerAt");
expect(contextManagement({ contextManagement: {} })).not.toHaveProperty(
"compactionTriggerAt",
);
});

it.each([0.8, 300_000, 1.5, -1])(
"keeps the number %s for runtime validation",
(value) => {
expect(
contextManagement({ contextManagement: { compactionTriggerAt: value } }),
).toMatchObject({ compactionTriggerAt: value });
},
);

it.each(["0.8", null, true, { ratio: 0.8 }])(
"drops the non-number %j",
(value) => {
expect(
contextManagement({ contextManagement: { compactionTriggerAt: value } }),
).not.toHaveProperty("compactionTriggerAt");
},
);

it("does not disturb the sibling keys", () => {
expect(
contextManagement({
contextManagement: {
compactionTriggerAt: 0.5,
cuMaxScreenshotsInContext: 3,
},
}),
).toEqual({
disableSystemReminderModels: [],
cuMaxScreenshotsInContext: 3,
compactionTriggerAt: 0.5,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export interface LocalConversationRuntimeConfig extends LocalRuntimeConfig {
readonly runawayGuard?: Config['runawayGuard'];
/** ToolResult compaction values resolved from config.yaml. */
readonly toolResultCompaction?: Config['toolResultCompaction'];
/** Context management values resolved from config.yaml. */
readonly contextManagement?: Partial<Config['contextManagement']>;
/**
* Widened past `Config['agents']`: the v2 Agent cutover also threads
* already-resolved capabilities through this read model, and `default` is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,52 @@ describe('AutomaticContextCompactor request boundaries', () => {
).rejects.toThrow(/streamFn/i);
});

it('lowers the trigger from the configured compactionTriggerAt only', () => {
const messages: AgentMessage[] = [
{ role: 'user', content: [{ type: 'text', text: 'small context' }], timestamp: 1 },
];
const model: Model<'anthropic-messages'> = {
id: 'model-1',
name: 'Model 1',
api: 'anthropic-messages',
provider: 'test',
baseUrl: 'https://example.invalid',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 32_000,
};
const rawTokens = createLocalContextFootprintMeasurer({ model }).measure(messages).inputTokens;
const input = {
sessionId: 'session-1',
phase: 'initial' as const,
messages,
model,
thinkingLevel: 'off' as const,
};
const probe = (getCompactionTriggerAt?: () => number | undefined) =>
automaticCompactor(undefined, undefined, undefined, {
...(getCompactionTriggerAt ? { getCompactionTriggerAt } : {}),
}).probeBeforeLlm(input).shouldStart;

expect(probe()).toBe(false);
// Absolute token count: strict greater-than, like the built-in trigger.
expect(probe(() => rawTokens)).toBe(false);
expect(probe(() => rawTokens - 1)).toBe(true);
// Window ratio.
expect(probe(() => (rawTokens - 1) / 200_000)).toBe(true);
// Above the built-in line, invalid, or unreadable: built-in trigger stays.
expect(probe(() => 0.99)).toBe(false);
expect(probe(() => 1.5)).toBe(false);
expect(probe(() => undefined)).toBe(false);
expect(
probe(() => {
throw new Error('config unavailable');
}),
).toBe(false);
});

it('uses the requested Messages API maxTokens at the trigger boundary', async () => {
const messages: AgentMessage[] = [
{ role: 'user', content: [{ type: 'text', text: 'small context' }], timestamp: 1 },
Expand Down
Loading
Loading