Skip to content

Commit 7fda8ca

Browse files
committed
fix(runtime): stop estimating context fit; the provider decides
The runtime no longer estimates whether a request fits a context window. Every "does it fit" question is answered by a provider: the conversation model's own context-length rejection is recovered by one compact-and-retry, and the summarizer's provider answers for compaction input (input_too_large retreats the fold by half). The chars/4 payload ruler, the signed delta estimate, the 32,000-token fallback history budget, the quarter-window reserve, the replacement-not-smaller and prefix-over-budget replay gates, and the final-request rescue re-entry are removed. Proactive compaction keeps one trigger: the previous accepted request's real input plus output tokens, as the provider counted them, compared with the context window the user declared for the model (a model-facts pin or a relay profile). A provider's /models report and generated metadata are no longer a threshold on their own. With no declaration there is no proactive fold; the provider decides. A reply the provider cut at its output limit (finishReason length) folds once before the next request. The persisted last-request anchor becomes { inputTokens, outputTokens }; the retired payloadChars key still decodes so 0.2.0 sessions keep loading. Summaries are capped at 8,000 output tokens with one shorter retry, and the too-small-for-fold floor reads the summarizer call's real usage instead of a chars/4 estimate. Two user-visible notes explain provider-side context changes: context_provider_dropping (an append-only step whose usage did not grow) and context_window_suggestion (a rejection at a proven-fit total, with the number the user can declare). Closes apache#4559 Refs apache#4458, apache#4486 Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
1 parent 4019fc5 commit 7fda8ca

35 files changed

Lines changed: 878 additions & 2302 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch,
4949
and SessionEvent-to-RuntimeEvent conversion remains a pure mapper.
5050
- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance.
51-
- Let the provider decide whether a request fits, and anchored the estimate that decides when to compact on the last request the provider actually counted. `token_usage` records now persist that anchor under a new `lastRequestAnchor` key. **Sessions this build writes do not open in earlier releases:** those decode `token_usage` against a closed allowlist, so the unknown key fails the record and, with it, the Session that contains it. Downgrading therefore needs a copy of the workspace's `runtime.sqlite` taken before the upgrade. Retired with the local verdict: nothing produces the `context_budget_exhausted` stop reason any more — a request that really is too large is compacted and retried once, then reported as a `context_overflow` provider error — though sessions that already recorded it still decode and present. The Runtime Host compatibility epoch moves to 94.
51+
- Let the provider decide whether a request fits. Proactive compaction now uses only a user-declared Maka window and the previous accepted request's provider-reported `inputTokens + outputTokens`; no declaration means no proactive capacity threshold. `/models` and generated model metadata are display hints, not limits. `token_usage` records persist the last-request anchor under `lastRequestAnchor`; its new `{ inputTokens, outputTokens }` shape still decodes the retired `payloadChars` key from older sessions. Requests that are too large are compacted and retried once after a real provider rejection, then reported as a `context_overflow` provider error. New provider-dropping and context-window suggestion system notes explain provider-side context changes.
5252
- Unified context management under one Runtime-owned policy. `MAKA_CONTEXT_*` environment overrides no longer tune or disable compaction and Tool Result pruning; model-visible archive placeholders are read on demand through bounded `ArchiveRead` calls instead of eager hydration. Previously supported overrides are ignored on upgrade: if Tool Result pruning was set to `off`, pruning is re-enabled, and there is currently no supported replacement opt-out.
5353
- Moved Read image snapshots into the durable context-offload store with Runtime-owned
5454
lifecycle identity, exact branch and revision copying, recovery-safe cleanup, and bounded

docs/architecture/llm-compaction-events-log-projection-draft.md

Lines changed: 38 additions & 38 deletions
Large diffs are not rendered by default.

docs/architecture/llm-compaction-events-log-projection-draft.zh-CN.md

Lines changed: 35 additions & 35 deletions
Large diffs are not rendered by default.

packages/core/src/__tests__/usage-record-last-request-anchor.test.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,28 +24,33 @@ import { decodeCanonicalMessage } from '../session.js';
2424

2525
const usage = { input: 370, output: 60 };
2626

27-
test('a last-request anchor is only valid as a complete positive pair', () => {
27+
test('a last-request anchor accepts the new usage shape and retired payload key', () => {
28+
assert.equal(isLastRequestAnchor({ inputTokens: 120, outputTokens: 30 }), true);
29+
assert.equal(isLastRequestAnchor({ inputTokens: 120 }), true);
2830
assert.equal(isLastRequestAnchor({ inputTokens: 120, payloadChars: 4_000 }), true);
29-
assert.equal(isLastRequestAnchor({ inputTokens: 120 }), false);
3031
assert.equal(isLastRequestAnchor({ payloadChars: 4_000 }), false);
3132
assert.equal(isLastRequestAnchor({ inputTokens: 0, payloadChars: 4_000 }), false);
32-
assert.equal(isLastRequestAnchor({ inputTokens: 120, payloadChars: 0 }), false);
33-
assert.equal(
34-
isLastRequestAnchor({ inputTokens: 120, payloadChars: 4_000, stepNumber: 2 }),
35-
false,
36-
);
33+
assert.equal(isLastRequestAnchor({ inputTokens: 120, outputTokens: -1 }), false);
34+
assert.equal(isLastRequestAnchor({ inputTokens: 120, foo: 1 }), false);
3735
});
3836

3937
test('token-usage fields carry the anchor and reject a broken one', () => {
4038
assert.equal(
41-
isTokenUsageFields({ ...usage, lastRequestAnchor: { inputTokens: 120, payloadChars: 4_000 } }),
39+
isTokenUsageFields({ ...usage, lastRequestAnchor: { inputTokens: 120, outputTokens: 30 } }),
4240
true,
4341
);
4442
assert.equal(isTokenUsageFields(usage), true);
45-
assert.equal(isTokenUsageFields({ ...usage, lastRequestAnchor: { inputTokens: 120 } }), false);
43+
assert.equal(
44+
isTokenUsageFields({ ...usage, lastRequestAnchor: { inputTokens: 120, payloadChars: 4_000 } }),
45+
true,
46+
);
47+
assert.equal(
48+
isTokenUsageFields({ ...usage, lastRequestAnchor: { inputTokens: 120, foo: 1 } }),
49+
false,
50+
);
4651
});
4752

48-
test('a half-written anchor fails the whole token_usage message decode', () => {
53+
test('an invalid anchor fails the whole token_usage message decode', () => {
4954
const message = {
5055
type: 'token_usage',
5156
id: 'usage-1',
@@ -56,11 +61,11 @@ test('a half-written anchor fails the whole token_usage message decode', () => {
5661
assert.deepEqual(
5762
decodeCanonicalMessage({
5863
...message,
59-
lastRequestAnchor: { inputTokens: 120, payloadChars: 4_000 },
64+
lastRequestAnchor: { inputTokens: 120, outputTokens: 30 },
6065
}),
61-
{ ...message, lastRequestAnchor: { inputTokens: 120, payloadChars: 4_000 } },
66+
{ ...message, lastRequestAnchor: { inputTokens: 120, outputTokens: 30 } },
6267
);
6368
assert.throws(() =>
64-
decodeCanonicalMessage({ ...message, lastRequestAnchor: { payloadChars: 4_000 } }),
69+
decodeCanonicalMessage({ ...message, lastRequestAnchor: { inputTokens: 0 } }),
6570
);
6671
});

packages/core/src/model-thinking.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,39 @@ export function relayModelProfile(
268268
return normalizeRelayModelProfile(connection.relayModelProfiles?.[modelId]);
269269
}
270270

271+
/** The connection fields the declared-window rule reads; structural so runtime and UI projections both fit. */
272+
export interface DeclaredContextWindowContext extends ConnectionThinkingContext {
273+
readonly models?: readonly {
274+
readonly id: string;
275+
readonly contextWindow?: number;
276+
readonly inputLimit?: number;
277+
readonly factOverriddenFields?: readonly string[];
278+
}[];
279+
}
280+
281+
/**
282+
* The context window the USER declared for a model — the Maka window: the
283+
* proactive compaction target, and nothing else. Exactly two sources count as
284+
* a declaration: a model-facts pin (`factOverriddenFields` includes
285+
* `contextWindow`, narrowest of window/input limit) and a relay model profile.
286+
* A provider's `/models` report and generated metadata describe the model and
287+
* are shown as a hint; they never become a threshold on their own. This is the
288+
* single owner of that rule for runtime and UI (#4559).
289+
*/
290+
export function declaredContextWindow(
291+
connection: DeclaredContextWindowContext,
292+
modelId: string,
293+
): number | undefined {
294+
const model = connection.models?.find((candidate) => candidate.id === modelId);
295+
if (model?.factOverriddenFields?.includes('contextWindow')) {
296+
const values = [model.contextWindow, model.inputLimit].filter(
297+
(value): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0,
298+
);
299+
return values.length > 0 ? Math.min(...values) : undefined;
300+
}
301+
return relayModelProfile(connection, modelId)?.contextWindow;
302+
}
303+
271304
/**
272305
* Mirrors @ai-sdk/openai@4.0.42 priority-processing detection. The UI and
273306
* runtime share this gate so a saved Fast declaration always reaches the wire.

packages/core/src/session.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,8 @@ export function userFacingText(message: Pick<UserMessage, 'text' | 'displayText'
792792
const USER_VISIBLE_SESSION_SYSTEM_NOTES = new Set([
793793
'context_compacted',
794794
'context_compaction_failed_open',
795+
'context_provider_dropping',
796+
'context_window_suggestion',
795797
'step_limit',
796798
]);
797799

@@ -1072,6 +1074,8 @@ export interface SystemNoteMessage {
10721074
| 'model_change'
10731075
| 'context_compacted'
10741076
| 'context_compaction_failed_open'
1077+
| 'context_provider_dropping'
1078+
| 'context_window_suggestion'
10751079
| 'step_limit'
10761080
| 'error'
10771081
| 'abort';
@@ -1276,6 +1280,8 @@ const SYSTEM_NOTE_KINDS = new Set([
12761280
'model_change',
12771281
'context_compacted',
12781282
'context_compaction_failed_open',
1283+
'context_provider_dropping',
1284+
'context_window_suggestion',
12791285
'step_limit',
12801286
'error',
12811287
'abort',

packages/core/src/usage-record-schema.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ const CURRENT_CONTEXT_BUDGET_SHAPE = defineObjectShape<ContextBudgetDiagnostic>(
8282
],
8383
[
8484
'policyName',
85-
'maxHistoryEstimatedTokens',
8685
'prunedToolResults',
8786
'prunedToolResultEstimatedTokensBefore',
8887
'prunedToolResultEstimatedTokensAfter',
@@ -105,6 +104,7 @@ const CURRENT_CONTEXT_BUDGET_SHAPE = defineObjectShape<ContextBudgetDiagnostic>(
105104
* produce them through ContextBudgetDiagnostic.
106105
*/
107106
const RETIRED_CONTEXT_BUDGET_KEYS = [
107+
'maxHistoryEstimatedTokens',
108108
'maxHistoryTurns',
109109
'semanticCompactEnabled',
110110
'semanticCompactMode',
@@ -285,38 +285,49 @@ export interface TokenUsageFields {
285285
providerRequestTraceId?: string;
286286
/**
287287
* The send's LAST provider request, as a pair: the input tokens the provider
288-
* reported for it, and the wire payload chars the runtime measured for that
289-
* same request. `input` above is the send's sum across steps and cannot
290-
* anchor anything; this pair can, so the next turn estimates its first
291-
* request from real usage instead of guessing the whole payload at char/4.
292-
*
293-
* Only the pair means anything — an anchor taken from one request and a
294-
* baseline from another is off by a whole step's growth — so the two numbers
295-
* live in one object that is written and read together. Absent means no
296-
* anchor, and the estimate falls back to the cold start.
288+
* reported for it, and its output tokens. `input` above is the send's sum
289+
* across steps and cannot anchor anything; the last step's real input and
290+
* output can, so the next turn judges its first request from real usage.
291+
* Absent means no anchor, and the next turn has no proactive fold until its
292+
* first accepted request.
297293
*/
298294
lastRequestAnchor?: LastRequestAnchor;
299295
}
300296

301-
/** Real input tokens of one provider request, paired with its measured payload chars. */
297+
/**
298+
* The last provider request of a send, as the provider counted it: its real
299+
* input tokens and its real output tokens. Together they are the baseline the
300+
* next request is judged from — everything the model produced is re-sent as
301+
* input — with no local measure involved (#4559).
302+
*
303+
* `payloadChars` is a retired key from the 0.2.0 anchor, which paired the input
304+
* count with a locally measured payload size. It is still accepted on decode so
305+
* sessions written by that build keep loading, and ignored.
306+
*/
302307
export interface LastRequestAnchor {
303308
inputTokens: number;
304-
payloadChars: number;
309+
outputTokens?: number;
305310
}
306311

307312
const LAST_REQUEST_ANCHOR_SHAPE = defineObjectShape<LastRequestAnchor>()(
308-
['inputTokens', 'payloadChars'],
309-
[],
313+
['inputTokens'],
314+
['outputTokens'],
310315
);
316+
const RETIRED_LAST_REQUEST_ANCHOR_KEYS = ['payloadChars'] as const;
317+
const LAST_REQUEST_ANCHOR_DECODE_SHAPE = {
318+
required: LAST_REQUEST_ANCHOR_SHAPE.required,
319+
allowed: new Set([...LAST_REQUEST_ANCHOR_SHAPE.allowed, ...RETIRED_LAST_REQUEST_ANCHOR_KEYS]),
320+
};
311321

312322
export function isLastRequestAnchor(value: unknown): value is LastRequestAnchor {
313323
return (
314324
isRecord(value) &&
315-
hasExactShape(value, LAST_REQUEST_ANCHOR_SHAPE) &&
325+
hasExactShape(value, LAST_REQUEST_ANCHOR_DECODE_SHAPE) &&
316326
isFiniteNumber(value.inputTokens) &&
317-
isFiniteNumber(value.payloadChars) &&
318327
value.inputTokens > 0 &&
319-
value.payloadChars > 0
328+
(value.outputTokens === undefined ||
329+
(isFiniteNumber(value.outputTokens) && value.outputTokens >= 0)) &&
330+
(value.payloadChars === undefined || isFiniteNumber(value.payloadChars))
320331
);
321332
}
322333

packages/core/src/usage-stats/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,6 @@ export interface CompactionDecisionDiagnostic {
284284
export interface ContextBudgetDiagnostic {
285285
enabled: boolean;
286286
policyName?: string;
287-
maxHistoryEstimatedTokens?: number;
288287
estimatedTokensBefore: number;
289288
estimatedTokensAfter: number;
290289
keptTurns: number;

packages/runtime-host/src/__tests__/execution-model-composition.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,16 @@ test('production Host executes a canonical ai-sdk Session against a real provide
17791779
});
17801780
assert.equal(configured.kind, 'committed');
17811781
await publishConnectionModel(policy, connection.connectionId, MODEL_ID);
1782+
// The fetched /models value is metadata only. This explicit model-facts
1783+
// declaration is the Maka compaction target used by the long-session flow.
1784+
await writeFile(
1785+
join(root, 'model-facts.json'),
1786+
JSON.stringify({
1787+
schemaVersion: 1,
1788+
overrides: { [`moonshot:${MODEL_ID}`]: { contextWindow: 3_072 } },
1789+
}),
1790+
'utf8',
1791+
);
17821792
let policySnapshot = await policy.runtimePolicy.getSnapshot();
17831793
const personalized = await policy.runtimePolicy.mutate({
17841794
expectedRevision: policySnapshot.revision,
@@ -1860,8 +1870,8 @@ test('production Host executes a canonical ai-sdk Session against a real provide
18601870
assert.equal(remembered.result.kind, 'committed');
18611871

18621872
const turnIds: string[] = [];
1863-
// Cross the history high-water without making the text-only compact input
1864-
// exceed this fixture's 2,304-token summarizer budget.
1873+
// Cross the explicitly declared Maka window without making the text-only
1874+
// compact input exceed this fixture's 2,304-token summarizer budget.
18651875
for (let index = 0; index < 5; index += 1) {
18661876
const turnId = randomUUID();
18671877
turnIds.push(turnId);

packages/runtime-host/src/server/execution-model-composition.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ async function buildHostAiSdkBackend(
392392
: {}),
393393
...(!input.context.tools && input.childAgents ? input.childAgents : {}),
394394
providerOptions,
395-
contextBudget: buildDefaultContextBudgetPolicy(target.connection, {
395+
contextBudget: buildDefaultContextBudgetPolicy({
396396
name: 'runtime-host-default-history-budget',
397397
modelId: target.model,
398398
}),

0 commit comments

Comments
 (0)