What happened
On a long single-Turn coding task the agent stopped after a handful of steps and reported that it was "at the end of my budget", even though the request carries no step budget at all. Underneath, proactive context compaction never armed for this connection. Its context grew to ~95% of the window (950,898 / 1,000,000 input tokens) while contextBudget.droppedTurns stayed 0 — nothing folded until the user ran /compact by hand. Right after that manual compaction the next Turn ran 219 steps with no "budget" talk at all, which is what points at context size rather than a cap.
Primary defect: no connection ever receives a default compactionThreshold — not one added after #5225, and not one migrated by it. capacity therefore stays undefined and the mid-turn capacity fold is dead code for those connections.
How to reproduce
- Have a connection whose model list was fetched and whose Compaction threshold was never filled in (the default).
- Run a long task inside one Turn — many tool calls, no new user message — until input tokens reach a large fraction of the window.
- Observe:
contextBudget.droppedTurns stays 0, no context_compacted system note is written, and no fold happens unless /compact is invoked. The model starts answering in short bursts and eventually reports being out of budget.
Verifiable straight from the session store (replace <session-id>):
-- every root Turn: no maxSteps anywhere
sqlite3 runtime.sqlite "select json_extract(record_json,'$.execution') \
from core_root_turn_admissions where session_id='<session-id>'"
-- -> {"kind":"external_message","inputDigest":"sha256:..."}
-- the session's budget diagnostic: no drops until the manual compaction
sqlite3 runtime.sqlite "select json_extract(payload_json,'$.actions.tokenUsage.contextBudget') \
from runtime_events where session_id='<session-id>' and event_kind='runtime_fact'"
-- -> {"enabled":true,"droppedTurns":0,"droppedEvents":0,"keptTurns":39, ...}
Why no connection has one
packages/core/src/model-thinking.ts:273 — declaredContextWindow(connection, modelId) returns modelOverride(connection, modelId)?.compactionThreshold.
packages/runtime/src/context-budget-policy.ts:65 — resolveDeclaredContextWindow wraps it; the module doc states the only proactive threshold is the window the user declared, "or undefined when nothing is declared — and then no proactive compaction runs".
packages/runtime/src/ai-sdk-compaction.ts:797 — capacity is that value; ai-sdk-compaction.ts:908 — the fold only runs when state.baselineTokens + state.replyReserveTokens >= state.capacity.
There are only two producers of compactionThreshold, and neither reaches an existing connection:
- The user typing a value in the provider capability editor (
apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx). The field is opt-in and empty by default: "达到此 token 数时压缩上下文。留空则不主动压缩。" / "Compact at this token count. Leave empty to disable proactive compaction." (settings-provider-copy.ts:63,126). The input takes an absolute token count only — parseContextWindowInput (context-window-input.ts:21) accepts digits with an optional k/m suffix and no percentage, and the codec requires an integer ≥ 1 (model-catalog-entry-codec.ts:77). So "80% of the window" has to be computed by hand.
- The schemaVersion 1 → 2 migration (
packages/storage/src/runtime-policy/connection-catalog-document.ts:161,198), which derives Math.min(facts.contextWindow, facts.inputLimit ?? facts.contextWindow) — but only from a legacy record that actually carries a window.
Both SCHEMA_VERSION = 2 (connection-catalog-document.ts:76) and that derive were introduced by the same commit, #5225 (de134f9b1, 2026-09-13, "refactor(models): unify connection-scoped model configuration"). So the migration is a single, one-shot opportunity: a document created after it is never migrated, and a document migrated by it gets a threshold only if the legacy relayModelProfiles[id].contextWindow or model-facts.json entry happens to exist. When it does not, the branch emits nothing and says nothing — the connection simply comes out with no modelOverrides at all.
Nothing backfills afterwards. Every later write path carries modelOverrides forward verbatim:
connection-catalog-document.ts:498 (model discovery / refresh) — const modelOverrides = previous.modelOverrides;
connection-catalog-document.ts prepareOnboardingUpsert (~line 606), i.e. adding a connection by API key — const modelOverrides = previous && !endpointChanged ? previous.modelOverrides : undefined;
Reporter's state (legacy connection data, threshold never set)
At the time of the incident, connection-catalog.json was schemaVersion: 2 (so the 1 → 2 migration had already run), no legacy model-facts.json remained in the profile, and no connection carried a modelOverrides key at all — the derive had emitted nothing for either connection, silently. The affected connection was still at revision: 1, i.e. unchanged since it was created, with its model list fetched 2026-09-16; the reporter had never typed a threshold. That is the "legacy data + no threshold" combination this issue is about: the migration had already spent its one chance before the field existed in a form that could be derived.
Workaround applied since, and it works: setting modelOverrides["deepseek-flash"].compactionThreshold = 500000 by hand (catalog revision 12 → 13, connection revision 1 → 2) restores the fold. The defect is that nothing does this by default — including for the migrated case that the migration is supposed to cover.
Net effect: for these connections all three mitigation paths are unavailable — the proactive fold needs a threshold, the reactive fold waits for a provider rejection, and /compact needs a human.
Secondary: the trigger fires too late to matter
Even with a threshold equal to the declared window the fold lands at ~99%: replyReserveTokens is capped at MAX_REPLY_RESERVE_TOKENS = 8_000 (ai-sdk-compaction.ts:1443), so the condition is baseline + <=8k >= window. In the session above the model began reporting a missing budget at roughly 56% of the window, so a threshold at (or near) the window would not have prevented the symptom. A default should be a fraction of the window.
No model-visible usage signal
For completeness: the model cannot see how full its context is. In that session every runtime-injected non-user/agent event carries modelVisibility: "hidden" (1 system_note, 51 invocation_opened, 23,241 runtime_fact including all tokenUsage). The prompt templates under packages/runtime/src/system-prompt/* contain no token or budget text, and the "上下文窗口:已用 95%(1M / 1M token)" string lives in packages/ui/src/conversation-copy.ts and never enters a request. So "at the end of my budget" was inferred from the sheer size of the model's own history, not read from anything we send.
Related (arguably its own issue)
Root interactive Turns also carry no step budget: core_root_turn_admissions.execution is only {kind, inputDigest}, packages/runtime-host/src/server/root-turn-coordinator.ts:3554 forwards maxSteps only when present, and the only budget-shaped prompt — CHILD_STEP_BUDGET_FINALIZATION_PROMPT, injected at packages/runtime/src/ai-sdk-turn.ts:1451 — is gated on maxSteps !== undefined. A root Turn therefore can be told neither "you have N steps left" nor "keep going". If root Turns are meant to be unbounded, the model should be told that explicitly rather than left to invent a budget and stop early.
Environment
- Maka: 0.2.0-dev.44.20260920 (packaged), Electron 43.4.1, Node 24.18.1, macOS (darwin arm64)
- Surface: Desktop
- Repo HEAD:
cea20b2a5 (main 1a89a8434)
- Connection: added by API key, model list fetched, no
modelOverrides at the time of the incident; model metadata declares contextWindow: 1_000_000, maxOutputTokens: 384_000 (packages/core/src/model-metadata.generated.ts)
- Session shape: 51 Turns, all
stopReason: end_turn; 0 failed, 0 aborted, 0 step_limit; 758/758 provider attempts completed; no finishReason: length, no recorded context-overflow diagnostic, no context_provider_dropping
- Input-token trajectory: monotonic growth to 950,898 (95.1% of the window); first
context_compacted note appears only at the manual /compact
Suggested fix
- Seed a default
compactionThreshold when a connection is added, refreshed or migrated, through one shared helper — e.g. a fraction (0.6–0.7) of min(contextWindow, inputLimit) rather than the window itself.
- Make the migration's derive visible when it produces nothing, instead of silently leaving the connection unconfigured.
- Allow the field to be expressed as a percentage of the declared window, so users do not have to recompute absolute token counts by hand.
- Make the fold trigger proportionate to the window instead of
window - min(2 × last reply, 8k).
- Decide and document root-Turn budget semantics; if Turns are unbounded, send an explicit continuation signal.
What happened
On a long single-Turn coding task the agent stopped after a handful of steps and reported that it was "at the end of my budget", even though the request carries no step budget at all. Underneath, proactive context compaction never armed for this connection. Its context grew to ~95% of the window (950,898 / 1,000,000 input tokens) while
contextBudget.droppedTurnsstayed0— nothing folded until the user ran/compactby hand. Right after that manual compaction the next Turn ran 219 steps with no "budget" talk at all, which is what points at context size rather than a cap.Primary defect: no connection ever receives a default
compactionThreshold— not one added after #5225, and not one migrated by it.capacitytherefore staysundefinedand the mid-turn capacity fold is dead code for those connections.How to reproduce
contextBudget.droppedTurnsstays0, nocontext_compactedsystem note is written, and no fold happens unless/compactis invoked. The model starts answering in short bursts and eventually reports being out of budget.Verifiable straight from the session store (replace
<session-id>):Why no connection has one
packages/core/src/model-thinking.ts:273—declaredContextWindow(connection, modelId)returnsmodelOverride(connection, modelId)?.compactionThreshold.packages/runtime/src/context-budget-policy.ts:65—resolveDeclaredContextWindowwraps it; the module doc states the only proactive threshold is the window the user declared, "or undefined when nothing is declared — and then no proactive compaction runs".packages/runtime/src/ai-sdk-compaction.ts:797—capacityis that value;ai-sdk-compaction.ts:908— the fold only runs whenstate.baselineTokens + state.replyReserveTokens >= state.capacity.There are only two producers of
compactionThreshold, and neither reaches an existing connection:apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx). The field is opt-in and empty by default: "达到此 token 数时压缩上下文。留空则不主动压缩。" / "Compact at this token count. Leave empty to disable proactive compaction." (settings-provider-copy.ts:63,126). The input takes an absolute token count only —parseContextWindowInput(context-window-input.ts:21) accepts digits with an optionalk/msuffix and no percentage, and the codec requires an integer ≥ 1 (model-catalog-entry-codec.ts:77). So "80% of the window" has to be computed by hand.packages/storage/src/runtime-policy/connection-catalog-document.ts:161,198), which derivesMath.min(facts.contextWindow, facts.inputLimit ?? facts.contextWindow)— but only from a legacy record that actually carries a window.Both
SCHEMA_VERSION = 2(connection-catalog-document.ts:76) and that derive were introduced by the same commit, #5225 (de134f9b1, 2026-09-13, "refactor(models): unify connection-scoped model configuration"). So the migration is a single, one-shot opportunity: a document created after it is never migrated, and a document migrated by it gets a threshold only if the legacyrelayModelProfiles[id].contextWindowormodel-facts.jsonentry happens to exist. When it does not, the branch emits nothing and says nothing — the connection simply comes out with nomodelOverridesat all.Nothing backfills afterwards. Every later write path carries
modelOverridesforward verbatim:connection-catalog-document.ts:498(model discovery / refresh) —const modelOverrides = previous.modelOverrides;connection-catalog-document.tsprepareOnboardingUpsert(~line 606), i.e. adding a connection by API key —const modelOverrides = previous && !endpointChanged ? previous.modelOverrides : undefined;Reporter's state (legacy connection data, threshold never set)
At the time of the incident,
connection-catalog.jsonwasschemaVersion: 2(so the 1 → 2 migration had already run), no legacymodel-facts.jsonremained in the profile, and no connection carried amodelOverrideskey at all — the derive had emitted nothing for either connection, silently. The affected connection was still atrevision: 1, i.e. unchanged since it was created, with its model list fetched 2026-09-16; the reporter had never typed a threshold. That is the "legacy data + no threshold" combination this issue is about: the migration had already spent its one chance before the field existed in a form that could be derived.Workaround applied since, and it works: setting
modelOverrides["deepseek-flash"].compactionThreshold = 500000by hand (catalog revision 12 → 13, connection revision 1 → 2) restores the fold. The defect is that nothing does this by default — including for the migrated case that the migration is supposed to cover.Net effect: for these connections all three mitigation paths are unavailable — the proactive fold needs a threshold, the reactive fold waits for a provider rejection, and
/compactneeds a human.Secondary: the trigger fires too late to matter
Even with a threshold equal to the declared window the fold lands at ~99%:
replyReserveTokensis capped atMAX_REPLY_RESERVE_TOKENS = 8_000(ai-sdk-compaction.ts:1443), so the condition isbaseline + <=8k >= window. In the session above the model began reporting a missing budget at roughly 56% of the window, so a threshold at (or near) the window would not have prevented the symptom. A default should be a fraction of the window.No model-visible usage signal
For completeness: the model cannot see how full its context is. In that session every runtime-injected non-user/agent event carries
modelVisibility: "hidden"(1system_note, 51invocation_opened, 23,241runtime_factincluding alltokenUsage). The prompt templates underpackages/runtime/src/system-prompt/*contain no token or budget text, and the "上下文窗口:已用 95%(1M / 1M token)" string lives inpackages/ui/src/conversation-copy.tsand never enters a request. So "at the end of my budget" was inferred from the sheer size of the model's own history, not read from anything we send.Related (arguably its own issue)
Root interactive Turns also carry no step budget:
core_root_turn_admissions.executionis only{kind, inputDigest},packages/runtime-host/src/server/root-turn-coordinator.ts:3554forwardsmaxStepsonly when present, and the only budget-shaped prompt —CHILD_STEP_BUDGET_FINALIZATION_PROMPT, injected atpackages/runtime/src/ai-sdk-turn.ts:1451— is gated onmaxSteps !== undefined. A root Turn therefore can be told neither "you have N steps left" nor "keep going". If root Turns are meant to be unbounded, the model should be told that explicitly rather than left to invent a budget and stop early.Environment
cea20b2a5(main1a89a8434)modelOverridesat the time of the incident; model metadata declarescontextWindow: 1_000_000,maxOutputTokens: 384_000(packages/core/src/model-metadata.generated.ts)stopReason: end_turn; 0 failed, 0 aborted, 0step_limit; 758/758 provider attemptscompleted; nofinishReason: length, no recorded context-overflow diagnostic, nocontext_provider_droppingcontext_compactednote appears only at the manual/compactSuggested fix
compactionThresholdwhen a connection is added, refreshed or migrated, through one shared helper — e.g. a fraction (0.6–0.7) ofmin(contextWindow, inputLimit)rather than the window itself.window - min(2 × last reply, 8k).