Skip to content

Commit 82e454e

Browse files
committed
feat(runtime): report a provider dropping context across the send boundary
The provider-dropping note compared each step's input against the previous step's, so it only saw a provider evicting context from inside one send. The shape it exists for is not visible there: a provider that truncates to a fixed window reports the same input on every later request while the user keeps adding turns, and a send of one or two steps has no earlier step to compare with. The live evidence in #4623 plateaus at 3,716 input tokens across eight turns with nothing reported, which is the case the note was written for. The first request of a send now compares against the last request a provider accepted before it, read from the persisted anchor, which is route-validated where it is read. A fold before that request would explain a smaller input by itself, so it disables the comparison, as prunes, image omissions and a shrinking tool set already do. The note is now reported once per session rather than once per send. The condition persists once a provider starts truncating, so a note on every later turn would repeat one fact the user has already been told. Refs #4559, #4623 Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
1 parent b9748a7 commit 82e454e

7 files changed

Lines changed: 158 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
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.
5151
- `token_usage` anchors now record the model and connection that produced them. A token count is a number in one model's tokenizer against one connection; carrying the route on the record lets any reader apply the rule the runtime already enforces, instead of pairing one model's usage with another model's window. The record decodes against a closed allowlist, so sessions written with these keys do not open in earlier releases, and the Runtime Host compatibility epoch moves to 107.
52+
- The provider-dropping note now also fires across the send boundary. A provider that truncates to a fixed window reports the same input on every later request while the user keeps adding turns, which a send of one or two steps cannot see from the inside; the first request of a send compares against the persisted anchor instead. Across the boundary the test is equality rather than "did not grow": inside a send Maka knows it only appended, while across it a manual compaction, a smaller tool set or an edited history all shrink the input legitimately, and none of them lands on exactly the same count. The note carries the two counts it compared, and is reported once per backend rather than once per send, because the condition persists once it starts.
5253
- 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. Compaction is entered at most once per send, and a request rejected after a fold was actually applied is reported as still too large after compaction. A fold that failed open makes no such claim: that request went out with its full raw history. A reply cut at `finishReason: length` no longer triggers a fold, because the provider running out of window room and the provider's own lower output cap are indistinguishable from outside. Five system notes explain the provider-side cases: dropping context, a window worth declaring, an exchange past the declared window, a request accepted past the window the model reports (once per crossing, while nothing is declared), and a request still too large after compaction. The reply reserve that arms the proactive threshold is twice the last real reply, bounded at 8,000 tokens, rather than the model's maximum output. **Sessions this build writes do not open in earlier releases:** those decode `token_usage` against a closed allowlist, so the reshaped `lastRequestAnchor` 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. 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 106.
5354
- 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.
5455
- Moved Read image snapshots into the durable context-offload store with Runtime-owned

packages/cli/src/pi-transcript.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,8 +1345,17 @@ function systemNoteText(message: SystemNoteMessage): string | undefined {
13451345
return 'Context compacted to keep this task within the model window.';
13461346
case 'context_compaction_failed_open':
13471347
return 'Context summary failed; the session continued without a new summary.';
1348-
case 'context_provider_dropping':
1349-
return 'The provider is dropping or rewriting context: content was appended but its reported usage did not grow. Declare a context window for this model so Maka compacts first.';
1348+
case 'context_provider_dropping': {
1349+
const data = message.data as
1350+
| { inputTokens?: unknown; priorInputTokens?: unknown }
1351+
| undefined;
1352+
const used = typeof data?.inputTokens === 'number' ? data.inputTokens : undefined;
1353+
const prior = typeof data?.priorInputTokens === 'number' ? data.priorInputTokens : undefined;
1354+
if (used === undefined || prior === undefined) {
1355+
return 'The provider is dropping or rewriting context: content was appended but its reported usage did not grow. Declare a context window for this model so Maka compacts first.';
1356+
}
1357+
return `The provider is dropping or rewriting context: content was appended, and it counted ${used} input tokens against ${prior} before, which is no growth. Declare a context window for this model so Maka compacts first.`;
1358+
}
13501359
case 'context_overflow_after_compaction':
13511360
return 'History was compacted and the provider still called this request too large. What remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.';
13521361
case 'context_reported_window_exceeded': {

packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ interface MidTurnFixtureOptions {
147147
firstResult?: string;
148148
/** The model finishes on the second request instead of running three steps. */
149149
finalAtSecondCall?: boolean;
150+
/** One request and no tool call, so only the step-0 comparison can fire. */
151+
singleRequest?: boolean;
150152
/** Add a third tool step whose result outgrows even a rolled-forward fold (finding A). */
151153
rollingOverflow?: boolean;
152154
/** Tool-search availability with a huge deferred schema (finding D). */
@@ -263,6 +265,7 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture {
263265
if (options.bigToolGroup) {
264266
return call === 1 ? toolCallChunks('tool-1', 'tool_search', { query: 'Big' }) : doneChunks();
265267
}
268+
if (options.singleRequest) return doneChunks();
266269
if (call === 1) {
267270
const first = toolCallChunks('tool-1', 'Read', { path: 'one.md' });
268271
if (!options.assistantTextInFirstStep) return first;
@@ -1292,6 +1295,87 @@ function defineMidTurnSuite(consumer: ConsumerMode): void {
12921295
);
12931296
});
12941297

1298+
test('reports provider context dropping across the send boundary', async () => {
1299+
// The Ollama shape: the provider truncates to its own window, so the input
1300+
// it counts is the SAME on every later request while the user keeps adding
1301+
// turns. A send of one or two steps never sees that from the inside
1302+
// (#4623). One request here, so only the step-0 comparison can write it.
1303+
const fixture = buildFixture({
1304+
withoutContextWindow: true,
1305+
singleRequest: true,
1306+
finalStepUsage: { input: 3_716, output: 10 },
1307+
extraPriorEvents: [priorUsageEvent({ inputTokens: 3_716, outputTokens: 12 })],
1308+
priorRunHeaders: [priorRunHeader()],
1309+
});
1310+
await runFixtureTurn(fixture, consumer);
1311+
1312+
const note = fixture.messages.find(
1313+
(message): message is { type: 'system_note'; kind: string; data?: unknown } =>
1314+
(message as { kind?: string }).kind === 'context_provider_dropping',
1315+
);
1316+
assert.deepEqual(note?.data, { inputTokens: 3_716, priorInputTokens: 3_716 });
1317+
});
1318+
1319+
test('does not report dropping across the boundary when the input grew', async () => {
1320+
const fixture = buildFixture({
1321+
withoutContextWindow: true,
1322+
singleRequest: true,
1323+
finalStepUsage: { input: 4_000, output: 10 },
1324+
extraPriorEvents: [priorUsageEvent({ inputTokens: 3_716, outputTokens: 12 })],
1325+
priorRunHeaders: [priorRunHeader()],
1326+
});
1327+
await runFixtureTurn(fixture, consumer);
1328+
1329+
assert.equal(
1330+
fixture.messages.some(
1331+
(message) => (message as { kind?: string }).kind === 'context_provider_dropping',
1332+
),
1333+
false,
1334+
);
1335+
});
1336+
1337+
test('does not report dropping across the boundary when the input merely shrank', async () => {
1338+
// A manual compaction leaves the pre-compaction anchor behind, a turn can
1339+
// carry a smaller tool set, and a user can edit or branch history. All
1340+
// three shrink the input legitimately, and none lands on exactly the same
1341+
// count, so equality is what separates them from a truncating provider.
1342+
const fixture = buildFixture({
1343+
withoutContextWindow: true,
1344+
singleRequest: true,
1345+
finalStepUsage: { input: 900, output: 10 },
1346+
extraPriorEvents: [priorUsageEvent({ inputTokens: 3_716, outputTokens: 12 })],
1347+
priorRunHeaders: [priorRunHeader()],
1348+
});
1349+
await runFixtureTurn(fixture, consumer);
1350+
1351+
assert.equal(
1352+
fixture.messages.some(
1353+
(message) => (message as { kind?: string }).kind === 'context_provider_dropping',
1354+
),
1355+
false,
1356+
);
1357+
});
1358+
1359+
test('does not report dropping across the boundary when this send folded first', async () => {
1360+
// A fold before the first request explains a smaller input by itself.
1361+
const fixture = buildFixture({
1362+
contextWindow: 3_000,
1363+
singleRequest: true,
1364+
finalStepUsage: { input: 3_716, output: 10 },
1365+
extraPriorEvents: [priorUsageEvent({ inputTokens: 3_716, outputTokens: 12 })],
1366+
priorRunHeaders: [priorRunHeader()],
1367+
});
1368+
await runFixtureTurn(fixture, consumer);
1369+
1370+
assert.equal(fixture.summarizerCalls, 1);
1371+
assert.equal(
1372+
fixture.messages.some(
1373+
(message) => (message as { kind?: string }).kind === 'context_provider_dropping',
1374+
),
1375+
false,
1376+
);
1377+
});
1378+
12951379
test('records provider context dropping when an append-only step reports the same usage', async () => {
12961380
// The Ollama shape: the provider truncates to its own window, so input
12971381
// stops growing rather than dropping while Maka keeps appending. A

packages/runtime/src/ai-sdk-backend.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,15 @@ export class AiSdkBackend implements AgentBackend {
10721072
*/
10731073
private readonly activeTurns = new Set<TurnScope>();
10741074
private readonly compaction: AiSdkCompaction;
1075+
/**
1076+
* The provider has been reported dropping context, for this backend.
1077+
*
1078+
* Not per send: the condition persists once a provider starts truncating, so
1079+
* a note on every later turn would repeat one fact the user has already been
1080+
* told. The scope is this backend's lifetime rather than the Session's, so a
1081+
* backend that is disposed and rebuilt may say it once more.
1082+
*/
1083+
private contextProviderDroppingReported = false;
10751084
/** Session-scoped running total, deliberately accumulated across turns. */
10761085
private cumulativeUsageCheckpoint: NormalizedAiSdkUsage | undefined;
10771086
private readonly memoryReplayMessageEvents = new WeakMap<ModelMessage, readonly string[]>();
@@ -1565,7 +1574,6 @@ export class AiSdkBackend implements AgentBackend {
15651574
let contextBudgetForTelemetry: ContextBudgetDiagnostic | undefined;
15661575
let contextCompactedNoteWritten = false;
15671576
let contextCompactionFailedOpenNoteWritten = false;
1568-
let contextProviderDroppingNoteWritten = false;
15691577
let contextWindowOverrunNoteWritten = false;
15701578
let contextReportedWindowNoteWritten = false;
15711579
let contextOverflowAfterCompactionNoteWritten = false;
@@ -2188,27 +2196,55 @@ export class AiSdkBackend implements AgentBackend {
21882196
const toolSchemaShrank =
21892197
lastStepActiveToolCount !== undefined &&
21902198
activeToolsForRequest.length < lastStepActiveToolCount;
2199+
// Across the send boundary the comparison is the same one,
2200+
// against the last request a provider accepted before this
2201+
// send. A provider that truncates to a fixed window reports
2202+
// the same input on every later request while the user keeps
2203+
// adding turns, and a send of one or two steps never sees
2204+
// that from the inside: the live evidence plateaus at 3,716
2205+
// input tokens across eight turns with nothing reported
2206+
// (#4623). The first request of a send therefore compares
2207+
// against the persisted anchor, which is route-validated
2208+
// where it is read; a fold before that request would explain
2209+
// a smaller input by itself, so it disables the comparison.
2210+
const acrossSends = completedRequestIndex === 0;
2211+
const priorInput = acrossSends
2212+
? midTurnState?.compactionAppliedThisSend === true
2213+
? undefined
2214+
: midTurnState?.priorAcceptedInputTokens
2215+
: lastStepInputTokens;
21912216
if (
2192-
!contextProviderDroppingNoteWritten &&
2217+
!this.contextProviderDroppingReported &&
21932218
!toolSchemaShrank &&
21942219
midTurnState &&
2195-
completedRequestIndex >= 1 &&
2196-
lastStepInputTokens !== undefined &&
2220+
priorInput !== undefined &&
21972221
midTurnState.replacedStepNumber !== completedRequestIndex &&
21982222
pruneAppliedAtStep !== completedRequestIndex &&
21992223
midTurnState.omittedImageToolResults.size === 0 &&
22002224
stepUsage !== undefined &&
22012225
Number.isFinite(stepUsage.inputTokens) &&
22022226
stepUsage.inputTokens > 0 &&
2203-
stepUsage.inputTokens <= lastStepInputTokens
2227+
// Across sends the test is equality, not "did not grow".
2228+
// Inside a send Maka knows it only appended, so any
2229+
// shortfall is the provider's. Across the boundary it does
2230+
// not: a manual compaction leaves the pre-compaction anchor
2231+
// behind, a turn can carry a smaller tool set, and a user
2232+
// can edit or branch history. All three shrink the input
2233+
// legitimately, and none of them lands on exactly the same
2234+
// count. A provider truncating to a fixed window does, on
2235+
// every later request.
2236+
(acrossSends
2237+
? stepUsage.inputTokens === priorInput
2238+
: stepUsage.inputTokens <= priorInput)
22042239
) {
2205-
contextProviderDroppingNoteWritten = true;
2240+
this.contextProviderDroppingReported = true;
22062241
const note: SystemNoteMessage = {
22072242
type: 'system_note',
22082243
id: this.newId(),
22092244
turnId,
22102245
ts: this.now(),
22112246
kind: 'context_provider_dropping',
2247+
data: { inputTokens: stepUsage.inputTokens, priorInputTokens: priorInput },
22122248
};
22132249
await this.input.appendMessage(note).catch(() => {});
22142250
}

packages/runtime/src/ai-sdk-compaction.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,7 @@ export class AiSdkCompaction {
814814
if (persisted) {
815815
state.baselineTokens = persisted.inputTokens + (persisted.outputTokens ?? 0);
816816
state.lastAcceptedTotalTokens = state.baselineTokens;
817+
state.priorAcceptedInputTokens = persisted.inputTokens;
817818
}
818819
if (persisted) state.replyReserveTokens = replyReserveTokens(persisted.outputTokens);
819820
return state;
@@ -1459,6 +1460,14 @@ export class MidTurnCapacityCompactState {
14591460
* rejection about what remains in it.
14601461
*/
14611462
compactionAppliedThisSend = false;
1463+
/**
1464+
* Input tokens of the last request a provider accepted before this send.
1465+
*
1466+
* Input against input, across the send boundary: the first request of a send
1467+
* has no earlier step to compare with, and `baselineTokens` counts the reply
1468+
* too, which the next request does not always carry.
1469+
*/
1470+
priorAcceptedInputTokens: number | undefined;
14621471

14631472
constructor(
14641473
readonly headAnchor: RuntimeEvent,

packages/ui/src/conversation-copy.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ export interface ConversationCopy {
320320
systemNotes: {
321321
contextCompacted: string;
322322
contextCompactionFailedOpen: string;
323-
contextProviderDropping: string;
323+
contextProviderDropping: (used: number, prior: number) => string;
324324
contextWindowSuggestion: (tokens: number, declared: number | undefined) => string;
325325
contextWindowOverrun: (used: number, declared: number) => string;
326326
contextReportedWindowExceeded: (used: number, reported: number) => string;
@@ -541,7 +541,8 @@ const CONVERSATION_COPY = {
541541
systemNotes: {
542542
contextCompacted: '已压缩较早的对话内容,以适应模型上下文窗口。',
543543
contextCompactionFailedOpen: '上下文摘要失败;本轮已在未生成新摘要的情况下继续。',
544-
contextProviderDropping: '供应商在丢弃或改写上下文(追加了内容但用量未增长)。在连接设置里为该模型声明上下文窗口,让 Maka 先行压缩。',
544+
contextProviderDropping: (used, prior) =>
545+
`供应商在丢弃或改写上下文:追加了内容,它报告的输入却是 ${used.toLocaleString('zh-CN')} tokens,与之前的 ${prior.toLocaleString('zh-CN')} 相比没有增长。在连接设置里为该模型声明上下文窗口,让 Maka 先行压缩。`,
545546
contextWindowSuggestion: (tokens, declared) =>
546547
declared === undefined
547548
? `供应商拒绝了这次请求。该模型未声明上下文窗口;上次成功的用量约 ${tokens} tokens,可将窗口设为该值让 Maka 先行压缩。`
@@ -714,7 +715,8 @@ const CONVERSATION_COPY = {
714715
systemNotes: {
715716
contextCompacted: 'Context compacted to keep this session within the model window.',
716717
contextCompactionFailedOpen: 'Context summary failed; the session continued without a new summary.',
717-
contextProviderDropping: 'The provider is dropping or rewriting context (content was appended but usage did not grow). Declare a context window for this model in the connection settings so Maka compacts first.',
718+
contextProviderDropping: (used, prior) =>
719+
`The provider is dropping or rewriting context: content was appended, and it counted ${used.toLocaleString('en-US')} input tokens against ${prior.toLocaleString('en-US')} before, which is no growth. Declare a context window for this model in the connection settings so Maka compacts first.`,
718720
contextWindowSuggestion: (tokens, declared) =>
719721
declared === undefined
720722
? `The provider rejected this request. No context window is declared for this model; the last accepted usage was about ${tokens} tokens — set the window to that value so Maka compacts first.`

0 commit comments

Comments
 (0)