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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
- 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.
- A compaction rejected as too large for the summarizer's own window now retreats to the span the last accepted request's input covered, instead of halving the covered range. That span is the newest reply this route produced, found through the run headers, so it was accepted by this model on this connection and is provably within capacity; halving can overshoot (discarding verbatim history for nothing) or undershoot (paying another round trip), and a span another route accepted proves nothing at all. One retreat, then the fold fails open and the provider decides.
- `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.
- 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.
- 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.
- 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.
- Moved Read image snapshots into the durable context-offload store with Runtime-owned
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/pi-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1345,8 +1345,17 @@ function systemNoteText(message: SystemNoteMessage): string | undefined {
return 'Context compacted to keep this task within the model window.';
case 'context_compaction_failed_open':
return 'Context summary failed; the session continued without a new summary.';
case 'context_provider_dropping':
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.';
case 'context_provider_dropping': {
const data = message.data as
| { inputTokens?: unknown; priorInputTokens?: unknown }
| undefined;
const used = typeof data?.inputTokens === 'number' ? data.inputTokens : undefined;
const prior = typeof data?.priorInputTokens === 'number' ? data.priorInputTokens : undefined;
if (used === undefined || prior === undefined) {
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.';
}
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.`;
}
case 'context_overflow_after_compaction':
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.';
case 'context_reported_window_exceeded': {
Expand Down
84 changes: 84 additions & 0 deletions packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ interface MidTurnFixtureOptions {
firstResult?: string;
/** The model finishes on the second request instead of running three steps. */
finalAtSecondCall?: boolean;
/** One request and no tool call, so only the step-0 comparison can fire. */
singleRequest?: boolean;
/** Add a third tool step whose result outgrows even a rolled-forward fold (finding A). */
rollingOverflow?: boolean;
/** Tool-search availability with a huge deferred schema (finding D). */
Expand Down Expand Up @@ -263,6 +265,7 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture {
if (options.bigToolGroup) {
return call === 1 ? toolCallChunks('tool-1', 'tool_search', { query: 'Big' }) : doneChunks();
}
if (options.singleRequest) return doneChunks();
if (call === 1) {
const first = toolCallChunks('tool-1', 'Read', { path: 'one.md' });
if (!options.assistantTextInFirstStep) return first;
Expand Down Expand Up @@ -1292,6 +1295,87 @@ function defineMidTurnSuite(consumer: ConsumerMode): void {
);
});

test('reports provider context dropping across the send boundary', async () => {
// The Ollama shape: the provider truncates to its own window, so the input
// it counts is the SAME on every later request while the user keeps adding
// turns. A send of one or two steps never sees that from the inside
// (#4623). One request here, so only the step-0 comparison can write it.
const fixture = buildFixture({
withoutContextWindow: true,
singleRequest: true,
finalStepUsage: { input: 3_716, output: 10 },
extraPriorEvents: [priorUsageEvent({ inputTokens: 3_716, outputTokens: 12 })],
priorRunHeaders: [priorRunHeader()],
});
await runFixtureTurn(fixture, consumer);

const note = fixture.messages.find(
(message): message is { type: 'system_note'; kind: string; data?: unknown } =>
(message as { kind?: string }).kind === 'context_provider_dropping',
);
assert.deepEqual(note?.data, { inputTokens: 3_716, priorInputTokens: 3_716 });
});

test('does not report dropping across the boundary when the input grew', async () => {
const fixture = buildFixture({
withoutContextWindow: true,
singleRequest: true,
finalStepUsage: { input: 4_000, output: 10 },
extraPriorEvents: [priorUsageEvent({ inputTokens: 3_716, outputTokens: 12 })],
priorRunHeaders: [priorRunHeader()],
});
await runFixtureTurn(fixture, consumer);

assert.equal(
fixture.messages.some(
(message) => (message as { kind?: string }).kind === 'context_provider_dropping',
),
false,
);
});

test('does not report dropping across the boundary when the input merely shrank', async () => {
// A manual compaction leaves the pre-compaction anchor behind, a turn can
// carry a smaller tool set, and a user can edit or branch history. All
// three shrink the input legitimately, and none lands on exactly the same
// count, so equality is what separates them from a truncating provider.
const fixture = buildFixture({
withoutContextWindow: true,
singleRequest: true,
finalStepUsage: { input: 900, output: 10 },
extraPriorEvents: [priorUsageEvent({ inputTokens: 3_716, outputTokens: 12 })],
priorRunHeaders: [priorRunHeader()],
});
await runFixtureTurn(fixture, consumer);

assert.equal(
fixture.messages.some(
(message) => (message as { kind?: string }).kind === 'context_provider_dropping',
),
false,
);
});

test('does not report dropping across the boundary when this send folded first', async () => {
// A fold before the first request explains a smaller input by itself.
const fixture = buildFixture({
contextWindow: 3_000,
singleRequest: true,
finalStepUsage: { input: 3_716, output: 10 },
extraPriorEvents: [priorUsageEvent({ inputTokens: 3_716, outputTokens: 12 })],
priorRunHeaders: [priorRunHeader()],
});
await runFixtureTurn(fixture, consumer);

assert.equal(fixture.summarizerCalls, 1);
assert.equal(
fixture.messages.some(
(message) => (message as { kind?: string }).kind === 'context_provider_dropping',
),
false,
);
});

test('records provider context dropping when an append-only step reports the same usage', async () => {
// The Ollama shape: the provider truncates to its own window, so input
// stops growing rather than dropping while Maka keeps appending. A
Expand Down
48 changes: 42 additions & 6 deletions packages/runtime/src/ai-sdk-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,15 @@ export class AiSdkBackend implements AgentBackend {
*/
private readonly activeTurns = new Set<TurnScope>();
private readonly compaction: AiSdkCompaction;
/**
* The provider has been reported dropping context, for this backend.
*
* Not 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. The scope is this backend's lifetime rather than the Session's, so a
* backend that is disposed and rebuilt may say it once more.
*/
private contextProviderDroppingReported = false;
/** Session-scoped running total, deliberately accumulated across turns. */
private cumulativeUsageCheckpoint: NormalizedAiSdkUsage | undefined;
private readonly memoryReplayMessageEvents = new WeakMap<ModelMessage, readonly string[]>();
Expand Down Expand Up @@ -1565,7 +1574,6 @@ export class AiSdkBackend implements AgentBackend {
let contextBudgetForTelemetry: ContextBudgetDiagnostic | undefined;
let contextCompactedNoteWritten = false;
let contextCompactionFailedOpenNoteWritten = false;
let contextProviderDroppingNoteWritten = false;
let contextWindowOverrunNoteWritten = false;
let contextReportedWindowNoteWritten = false;
let contextOverflowAfterCompactionNoteWritten = false;
Expand Down Expand Up @@ -2188,27 +2196,55 @@ export class AiSdkBackend implements AgentBackend {
const toolSchemaShrank =
lastStepActiveToolCount !== undefined &&
activeToolsForRequest.length < lastStepActiveToolCount;
// Across the send boundary the comparison is the same one,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 toolSchemaShrank reads lastStepActiveToolCount, which is declared inside the send at :1565 and is therefore always undefined at step 0, so this exclusion is dead across the boundary. Your own comment says it exists because Maka shaped the request and the provider dropped nothing, and that reasoning holds across turns just as well: switching permission mode, disconnecting an MCP server, or a subagent with a different tool set all shrink the schema legitimately, by thousands of tokens.

The anchor does not record a tool count, so there is no cheap check here; widening it would change the token_usage shape and cost an epoch, which is not worth it. Equality on the cross-boundary comparison sidesteps it entirely.

// against the last request a provider accepted before this
// send. 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 never sees
// that from the inside: the live evidence plateaus at 3,716
// input tokens across eight turns with nothing reported
// (#4623). The first request of a send therefore compares
// against 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.
const acrossSends = completedRequestIndex === 0;
const priorInput = acrossSends
? midTurnState?.compactionAppliedThisSend === true
? undefined
: midTurnState?.priorAcceptedInputTokens
: lastStepInputTokens;
if (
!contextProviderDroppingNoteWritten &&
!this.contextProviderDroppingReported &&
!toolSchemaShrank &&
midTurnState &&
completedRequestIndex >= 1 &&
lastStepInputTokens !== undefined &&
priorInput !== undefined &&
midTurnState.replacedStepNumber !== completedRequestIndex &&
pruneAppliedAtStep !== completedRequestIndex &&
midTurnState.omittedImageToolResults.size === 0 &&
stepUsage !== undefined &&
Number.isFinite(stepUsage.inputTokens) &&
stepUsage.inputTokens > 0 &&
stepUsage.inputTokens <= lastStepInputTokens
// Across sends the test is equality, not "did not grow".
// Inside a send Maka knows it only appended, so any
// shortfall is the provider's. Across the boundary it does
// not: a manual compaction leaves the pre-compaction anchor
// behind, a turn can carry a smaller tool set, and a user
// can edit or branch history. All three shrink the input
// legitimately, and none of them lands on exactly the same
// count. A provider truncating to a fixed window does, on
// every later request.
(acrossSends
? stepUsage.inputTokens === priorInput
: stepUsage.inputTokens <= priorInput)
) {
contextProviderDroppingNoteWritten = true;
this.contextProviderDroppingReported = true;
const note: SystemNoteMessage = {
type: 'system_note',
id: this.newId(),
turnId,
ts: this.now(),
kind: 'context_provider_dropping',
data: { inputTokens: stepUsage.inputTokens, priorInputTokens: priorInput },
};
await this.input.appendMessage(note).catch(() => {});
}
Expand Down
9 changes: 9 additions & 0 deletions packages/runtime/src/ai-sdk-compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,7 @@ export class AiSdkCompaction {
if (persisted) {
state.baselineTokens = persisted.inputTokens + (persisted.outputTokens ?? 0);
state.lastAcceptedTotalTokens = state.baselineTokens;
state.priorAcceptedInputTokens = persisted.inputTokens;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Related to the equality change: this is where the pre-compaction anchor gets carried into the next send. If you would rather not touch the comparison itself, the alternative fix lives here, in persistedRequestAnchor's reverse scan: treat an anchorless usage record as a barrier rather than skipping it, at least for this field, so a manual /compact produces a cold start instead of a stale boundary. Same one-boolean cost. I prefer the equality change because it also covers the tool-schema and edit-or-branch cases.

Separately, P3 on the note itself: it carries no numbers while its four siblings do, so a claim the user cannot dismiss is also one they cannot check. Putting inputTokens and priorInput in data is a few lines and makes it falsifiable, which matters here because a wrong note is persisted and silences the real one for the rest of the session.

}
if (persisted) state.replyReserveTokens = replyReserveTokens(persisted.outputTokens);
return state;
Expand Down Expand Up @@ -1464,6 +1465,14 @@ export class MidTurnCapacityCompactState {
* rejection about what remains in it.
*/
compactionAppliedThisSend = false;
/**
* Input tokens of the last request a provider accepted before this send.
*
* Input against input, across the send boundary: the first request of a send
* has no earlier step to compare with, and `baselineTokens` counts the reply
* too, which the next request does not always carry.
*/
priorAcceptedInputTokens: number | undefined;

constructor(
readonly headAnchor: RuntimeEvent,
Expand Down
8 changes: 5 additions & 3 deletions packages/ui/src/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ export interface ConversationCopy {
systemNotes: {
contextCompacted: string;
contextCompactionFailedOpen: string;
contextProviderDropping: string;
contextProviderDropping: (used: number, prior: number) => string;
contextWindowSuggestion: (tokens: number, declared: number | undefined) => string;
contextWindowOverrun: (used: number, declared: number) => string;
contextReportedWindowExceeded: (used: number, reported: number) => string;
Expand Down Expand Up @@ -541,7 +541,8 @@ const CONVERSATION_COPY = {
systemNotes: {
contextCompacted: '已压缩较早的对话内容,以适应模型上下文窗口。',
contextCompactionFailedOpen: '上下文摘要失败;本轮已在未生成新摘要的情况下继续。',
contextProviderDropping: '供应商在丢弃或改写上下文(追加了内容但用量未增长)。在连接设置里为该模型声明上下文窗口,让 Maka 先行压缩。',
contextProviderDropping: (used, prior) =>
`供应商在丢弃或改写上下文:追加了内容,它报告的输入却是 ${used.toLocaleString('zh-CN')} tokens,与之前的 ${prior.toLocaleString('zh-CN')} 相比没有增长。在连接设置里为该模型声明上下文窗口,让 Maka 先行压缩。`,
contextWindowSuggestion: (tokens, declared) =>
declared === undefined
? `供应商拒绝了这次请求。该模型未声明上下文窗口;上次成功的用量约 ${tokens} tokens,可将窗口设为该值让 Maka 先行压缩。`
Expand Down Expand Up @@ -714,7 +715,8 @@ const CONVERSATION_COPY = {
systemNotes: {
contextCompacted: 'Context compacted to keep this session within the model window.',
contextCompactionFailedOpen: 'Context summary failed; the session continued without a new summary.',
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.',
contextProviderDropping: (used, prior) =>
`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.`,
contextWindowSuggestion: (tokens, declared) =>
declared === undefined
? `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.`
Expand Down
Loading