Skip to content

bug(runtime): no connection ever gets a default compactionThreshold — proactive compaction never arms for new or migrated connections #5574

Description

@MoonOld

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

  1. Have a connection whose model list was fetched and whose Compaction threshold was never filled in (the default).
  2. Run a long task inside one Turn — many tool calls, no new user message — until input tokens reach a large fraction of the window.
  3. 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:

  1. 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.
  2. 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

  1. 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.
  2. Make the migration's derive visible when it produces nothing, instead of silently leaving the connection unconfigured.
  3. 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.
  4. Make the fold trigger proportionate to the window instead of window - min(2 × last reply, 8k).
  5. Decide and document root-Turn budget semantics; if Turns are unbounded, send an explicit continuation signal.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions