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
23 changes: 9 additions & 14 deletions packages/cli/src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ import {
estimateTokens,
type LedgerRole,
} from '../repl/context-ledger.js';
import {
resolveModelContextWindow as resolveBackendTokenWindow,
contextBudgetForWindow as defaultContextBudget,
} from '../repl/context-limits.js';
import { parseSlashCommand } from '../repl/slash.js';
import {
parseEvictSelection,
Expand Down Expand Up @@ -452,25 +456,16 @@ const LEDGER_COMPACT_CHARS = 420;
const AUTO_TRIM_KEEP_RECENT_ENTRIES = 6;
const DEFAULT_TRIM_TARGET_PCT = 70;
const CTRL_C_EXIT_WINDOW_MS = 3000;
const DEFAULT_BACKEND_TOKEN_WINDOW = 1_000_000;
// Our working context budget — deliberately smaller than the backend's raw
// window. When the transcript approaches this, we compact (summarize the
// oldest entries into a new start state) rather than letting it grow until
// turns degrade or argv/window limits bite. Override with --max-context-tokens.
const DEFAULT_MAX_CONTEXT_TOKENS = 200_000;
// Working context budget + per-model window resolution live in ../repl/
// context-limits.js (imported above as defaultContextBudget /
// resolveBackendTokenWindow). ink derives its budget from the model's REAL
// window so it always compacts before the provider would — that module owns the
// conservative per-model table and the provider-headroom math.
// Compact when transcript+identity utilization crosses this fraction of budget
const AUTO_COMPACT_THRESHOLD_PCT = 0.8;
// Entries kept verbatim after the compaction summary (the working tail)
const AUTO_COMPACT_KEEP_RECENT_ENTRIES = 12;
const HISTORY_PREVIEW_MAX = 200;
function resolveBackendTokenWindow(_backend: string, _model?: string): number {
// Current policy: claude/codex/gemini all default to 1M effective context window.
return DEFAULT_BACKEND_TOKEN_WINDOW;
}

function defaultContextBudget(backendTokenWindow: number): number {
return Math.min(backendTokenWindow, DEFAULT_MAX_CONTEXT_TOKENS);
}

function formatTokenCount(value: number): string {
return value.toLocaleString();
Expand Down
171 changes: 171 additions & 0 deletions packages/cli/src/repl/context-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest';

import {
DEFAULT_MODEL_CONTEXT_WINDOW,
INK_WORKING_BUDGET_CAP,
MODEL_CONTEXT_WINDOWS,
PROVIDER_HEADROOM_PCT,
contextBudgetForWindow,
resolveModelContextWindow,
} from './context-limits.js';

describe('resolveModelContextWindow', () => {
it('resolves Claude families to the conservative 200K window', () => {
expect(resolveModelContextWindow('claude', 'claude-opus-4-8')).toBe(200_000);
expect(resolveModelContextWindow('claude', 'claude-opus-5')).toBe(200_000);
expect(resolveModelContextWindow('claude', 'claude-sonnet-5')).toBe(200_000);
expect(resolveModelContextWindow('claude', 'claude-haiku-4-5-20251001')).toBe(200_000);
expect(resolveModelContextWindow('claude', 'claude-fable-5')).toBe(200_000);
// Unknown claude-* still matches the generic family entry.
expect(resolveModelContextWindow('claude', 'claude-something-new')).toBe(200_000);
});

it('resolves gemini to a 1M window', () => {
expect(resolveModelContextWindow('gemini', 'gemini-2.0-flash')).toBe(1_000_000);
expect(resolveModelContextWindow('gemini', 'gemini-1.5-pro')).toBe(1_000_000);
expect(resolveModelContextWindow('gemini', 'gemini-experimental')).toBe(1_000_000);
});

it('resolves codex / gpt families conservatively', () => {
// codex-mini-latest is a 200K-window model — the broad codex prefix must NOT
// overestimate it (that is the unsafe direction this table exists to avoid).
expect(resolveModelContextWindow('codex', 'codex-mini-latest')).toBe(200_000);
expect(resolveModelContextWindow('codex', 'codex-mini')).toBe(200_000);
// The larger window is reserved for the specific gpt-5-codex prefix.
expect(resolveModelContextWindow('codex', 'gpt-5-codex')).toBe(256_000);
expect(resolveModelContextWindow('codex', 'gpt-4o')).toBe(128_000);
});

it('uses LONGEST prefix match, not first match', () => {
// gpt-5 (256K) must beat the shorter gpt- (128K) entry regardless of order.
expect(resolveModelContextWindow('codex', 'gpt-5-turbo')).toBe(256_000);
// gpt-4 stays on the 128K entry.
expect(resolveModelContextWindow('codex', 'gpt-4.1')).toBe(128_000);
});

it('is case-insensitive and trims whitespace on the model id', () => {
expect(resolveModelContextWindow('claude', ' CLAUDE-SONNET-5 ')).toBe(200_000);
expect(resolveModelContextWindow('gemini', 'Gemini-2.0')).toBe(1_000_000);
});

it('falls back to a conservative per-backend default when no model id is given', () => {
expect(resolveModelContextWindow('claude')).toBe(200_000);
expect(resolveModelContextWindow('claude', '')).toBe(200_000);
expect(resolveModelContextWindow('claude', ' ')).toBe(200_000);
// codex default must match codex-mini-latest (200K), not overestimate it.
expect(resolveModelContextWindow('codex')).toBe(200_000);
expect(resolveModelContextWindow('gemini')).toBe(1_000_000);
});

it('falls back to the per-backend default for an unrecognized model id', () => {
// Unknown model on a known backend → backend default, NOT a phantom large window.
expect(resolveModelContextWindow('claude', 'mystery-model')).toBe(200_000);
expect(resolveModelContextWindow('codex', 'mystery-model')).toBe(200_000);
});

it('uses the global safe default for an unknown backend with no known model', () => {
expect(resolveModelContextWindow('mystery-backend')).toBe(DEFAULT_MODEL_CONTEXT_WINDOW);
expect(resolveModelContextWindow('mystery-backend', 'mystery-model')).toBe(
DEFAULT_MODEL_CONTEXT_WINDOW
);
});
});

describe('contextBudgetForWindow', () => {
it('applies provider headroom below the cap for a 200K window', () => {
// min(200K cap, floor(0.85 * 200K)=170K) → 170K
expect(contextBudgetForWindow(200_000)).toBe(170_000);
});

it('applies the global cap for large (1M+) windows', () => {
// floor(0.85 * 1M)=850K is above the cap → capped at 200K
expect(contextBudgetForWindow(1_000_000)).toBe(INK_WORKING_BUDGET_CAP);
expect(contextBudgetForWindow(2_000_000)).toBe(INK_WORKING_BUDGET_CAP);
});

it('applies headroom for sub-200K windows', () => {
expect(contextBudgetForWindow(128_000)).toBe(Math.floor(128_000 * PROVIDER_HEADROOM_PCT));
});

it('never returns a non-positive budget for tiny windows', () => {
expect(contextBudgetForWindow(1)).toBe(1);
expect(contextBudgetForWindow(0)).toBe(1);
});
});

describe('keystone safety invariant — ink compacts before the provider', () => {
// The in-budget compaction threshold used by chat.ts (AUTO_COMPACT_THRESHOLD_PCT).
// Kept in sync here to assert the end-to-end safety margin; it only ever lowers
// ink's compaction point further below the provider trigger.
const INK_COMPACT_THRESHOLD_PCT = 0.8;

// Representative real windows across every provider family we support.
const windows = [128_000, 200_000, 256_000, 1_000_000, 2_000_000];

it('keeps ink’s ENTIRE working budget within the provider-headroom slice', () => {
for (const w of windows) {
const budget = contextBudgetForWindow(w);
// Budget never exceeds the fraction of the window at which we assume the
// provider might begin its own auto-compaction.
expect(budget).toBeLessThanOrEqual(Math.floor(w * PROVIDER_HEADROOM_PCT));
// And of course never exceeds the raw window.
expect(budget).toBeLessThan(w);
}
});

it('places ink’s compaction point strictly below the provider trigger for every model', () => {
// For each known model, derive its window → budget → compaction point and
// assert it fires before the provider would (headroom slice of the window).
const models = [
['claude', 'claude-opus-4-8'],
['claude', 'claude-sonnet-5'],
['claude', 'claude-haiku-4-5'],
['codex', 'gpt-5-codex'],
['codex', 'gpt-4o'],
['gemini', 'gemini-2.0-flash'],
] as const;
for (const [backend, model] of models) {
const window = resolveModelContextWindow(backend, model);
const budget = contextBudgetForWindow(window);
const inkCompactAt = budget * INK_COMPACT_THRESHOLD_PCT;
const providerTriggerAt = window * PROVIDER_HEADROOM_PCT;
expect(inkCompactAt).toBeLessThan(providerTriggerAt);
}
});

// Documented REAL context windows, specified INDEPENDENTLY of the table so
// this guard catches over-estimation (the unsafe direction). If the resolver
// ever assumes a window larger than reality, the derived budget can exceed the
// real provider-headroom slice and the provider wins the compaction race —
// exactly the codex-mini-latest bug this test was added for.
const KNOWN_REAL_WINDOWS: ReadonlyArray<readonly [string, string, number]> = [
['claude', 'claude-opus-4-8', 200_000],
['claude', 'claude-sonnet-5', 200_000],
['codex', 'codex-mini-latest', 200_000],
['codex', undefined as unknown as string, 200_000], // codex backend default
['gemini', 'gemini-2.0-flash', 1_000_000],
];

it('never budgets above the REAL provider-headroom slice for documented models', () => {
for (const [backend, model, realWindow] of KNOWN_REAL_WINDOWS) {
const assumed = resolveModelContextWindow(backend, model);
// The resolver must never assume MORE context than the model really has.
expect(assumed, `${backend}/${model ?? '(default)'} assumed window`).toBeLessThanOrEqual(
realWindow
);
// And ink's whole budget must sit under the real provider trigger.
const budget = contextBudgetForWindow(assumed);
expect(
budget,
`${backend}/${model ?? '(default)'} budget vs real headroom`
).toBeLessThanOrEqual(Math.floor(realWindow * PROVIDER_HEADROOM_PCT));
}
});

it('every table window is positive and yields a positive budget', () => {
for (const [prefix, window] of MODEL_CONTEXT_WINDOWS) {
expect(window, `${prefix} window`).toBeGreaterThan(0);
expect(contextBudgetForWindow(window), `${prefix} budget`).toBeGreaterThan(0);
}
});
});
134 changes: 134 additions & 0 deletions packages/cli/src/repl/context-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Per-model context-window limits + ink's working-budget derivation.
*
* The keystone principle (task 17d212ff): **ink owns compaction, not the
* provider.** For that to hold, ink must know each model's REAL context window
* and keep its own working budget comfortably below where the provider (Claude
* Code, codex, gemini) would run its OWN auto-compaction. If we over-estimate a
* window, ink compacts too late and the provider compacts first — the exact
* failure this table exists to prevent.
*
* So every value here is deliberately CONSERVATIVE. The two directions are not
* symmetric:
* - under-estimating a window → ink compacts a little early (harmless).
* - over-estimating a window → the provider compacts first (the bug).
* When unsure, round DOWN.
*/

/**
* The provider auto-compacts as its native session approaches full. We don't
* know each provider's exact trigger and it can move between releases, so we
* assume the provider may compact once its session is this fraction of the
* model's window full, and keep ink's ENTIRE working budget at or below it.
*
* Claude Code has historically compacted around ~90–92% full; 0.85 leaves a
* safety margin under even an aggressive provider. Combined with the 0.8
* in-budget compaction threshold (AUTO_COMPACT_THRESHOLD_PCT in chat.ts), ink
* compacts by ~0.68 × window in the worst case (a small-window model whose
* budget equals the headroom slice) — well ahead of any provider auto-compact.
*/
export const PROVIDER_HEADROOM_PCT = 0.85;

/**
* ink's working budget is also hard-capped here regardless of window: a
* deliberately smaller slice than a huge (1M/2M) window so turns stay tight and
* argv size / latency don't degrade before we ever approach the provider's
* trigger. This is the historical DEFAULT_MAX_CONTEXT_TOKENS.
*/
export const INK_WORKING_BUDGET_CAP = 200_000;

/**
* Fallback window for a model we don't recognize. A SAFE default: small enough
* that an unknown model with a modest window still gets ink-first compaction.
* Any model with a genuinely smaller window MUST be added to the table below —
* do NOT rely on this fallback for sub-200K models.
*/
export const DEFAULT_MODEL_CONTEXT_WINDOW = 200_000;

/**
* Real total context windows (tokens) keyed by lowercased model-id prefix.
* Longest matching prefix wins, so `gpt-5` beats `gpt-`. Conservative on
* purpose (see file header). Add new / smaller-window models here.
*/
export const MODEL_CONTEXT_WINDOWS: ReadonlyArray<readonly [string, number]> = [
// Anthropic / Claude Code. Standard API window is 200K across Opus / Sonnet /
// Haiku / Fable. Sonnet's 1M beta is intentionally NOT assumed — Claude Code
// does not enable it by default, and assuming 200K keeps ink safely ahead.
// Kept as family entries (not one `claude-` line) so bumping a single family
// to a larger window later is a one-line change.
['claude-opus', 200_000],
['claude-sonnet', 200_000],
['claude-haiku', 200_000],
['claude-fable', 200_000],
['claude-', 200_000],

// OpenAI / codex. GPT-5 / GPT-5-Codex carry large windows; 256K is a
// conservative floor for those SPECIFIC prefixes. Older gpt-4-class is 128K.
// The broad `codex` prefix (and the codex backend default below) must stay at
// 200K: `codex-mini-latest` is a 200K-window model, and extending 256K to all
// `codex-*` would overestimate it — the unsafe direction. Larger codex windows
// belong on specific gpt-5-codex prefixes above, never on the broad entry.
['gpt-5', 256_000],
['gpt-4', 128_000],
['gpt-', 128_000],
['o3', 200_000],
['o4', 200_000],
['codex', 200_000],

// Google / gemini. 1M+ windows; assume 1M conservatively (2M variants exist).
['gemini-2', 1_000_000],
['gemini-1.5', 1_000_000],
['gemini-', 1_000_000],
];

/**
* Conservative window for a backend's *default* model (when no model id is
* given). Never larger than the smallest window that backend routinely uses.
*/
function backendDefaultWindow(backend: string): number {
switch (backend) {
case 'gemini':
return 1_000_000;
case 'codex':
// codex-mini-latest is a 200K-window model; the codex default must not
// exceed it. (Specific gpt-5-codex prefixes get their larger window via
// the table.)
return 200_000;
case 'claude':
return 200_000;
default:
return DEFAULT_MODEL_CONTEXT_WINDOW;
}
}

/**
* Resolve a model's real context window (tokens). Prefers an explicit model id
* (longest prefix match, case-insensitive); falls back to a conservative
* per-backend default; then the global safe default.
*/
export function resolveModelContextWindow(backend: string, model?: string): number {
const id = (model ?? '').trim().toLowerCase();
if (id) {
let best: number | undefined;
let bestLen = -1;
for (const [prefix, window] of MODEL_CONTEXT_WINDOWS) {
if (id.startsWith(prefix) && prefix.length > bestLen) {
best = window;
bestLen = prefix.length;
}
}
if (best !== undefined) return best;
}
return backendDefaultWindow(backend);
}

/**
* ink's working budget for a given real window: the smaller of the global cap
* and the provider-headroom slice of the window. This is what guarantees ink's
* budget — and therefore its 80%-of-budget compaction point — sits below the
* provider's own auto-compaction trigger.
*/
export function contextBudgetForWindow(window: number): number {
const headroom = Math.floor(window * PROVIDER_HEADROOM_PCT);
return Math.max(1, Math.min(INK_WORKING_BUDGET_CAP, headroom));
}
Loading
Loading