perf(agent): trigger compaction on a context ratio, not on a provider error
Problem
Compaction is reactive. It runs only after the provider has already rejected an oversized request:
src-tauri/src/core/agent/loop.rs:2585-2631
// On a context-overflow error, compact the conversation and retry.
loop {
let request_value = build_completion_request(...);
match model.invoke(&request_value, events).await {
Ok(c) => break c,
Err(e) if is_context_overflow_error(&e) && attempts < MAX_COMPACTION_ATTEMPTS => {
let compacted = compact_conversation(...).await?; // ← only after the rejection
conversation_messages = compacted;
keep_recent = (keep_recent / 2).max(2);
attempts += 1;
}The second trigger is budget exhaustion at the end of a run (loop.rs:2759-2781). Neither one looks at how full the window is before sending.
The progressive retry makes it worse than a single miss: each failed attempt halves keep_recent and re-runs a summarizer call, so a bad estimate costs several round trips and several summarizer invocations before the turn lands.
sequenceDiagram
autonumber
participant J as Jan
participant P as Provider
rect rgb(250,222,219)
Note over J,P: today — reactive
J->>P: request · 198k tokens
P-->>J: 400 context_length_exceeded
Note right of J: latency spent, nothing gained
J->>J: compact
J->>P: retry · 60k tokens
P-->>J: cold miss (prefix rewritten)
end
rect rgb(223,240,224)
Note over J,P: proposed — preflight
J->>J: estimate 162k / 200k = 0.81 ≥ 0.80
J->>J: compact before sending
J->>P: request · 60k tokens
P-->>J: one planned cold miss, then hits
endThree costs, in order of size:
| Cost | |
|---|---|
| Wasted round trip | The oversized request is transmitted and rejected. Nothing is billed for a rejected call, but the wall-clock latency is real and lands on the user mid-task. |
| Unplanned prefix break | Compaction rewrites the head, so the retry is a cold miss. That miss was going to happen eventually — the problem is it happens at a moment nobody chose. |
| User-visible stall | The failure mode is "the agent froze for several seconds", which reads as a bug. |
Compaction will always break the prefix — a summarized history is different bytes. The goal is not to avoid the break but to schedule it: one planned break at a known point, instead of an unplanned one triggered by an error.
Proposed change
Add a preflight check before dispatch.
run_turn
build messages
build tools
+ estimate prompt tokens
+ if estimate / context_window >= compact_ratio # default 0.80
+ compact now
+ log "planned compaction at 0.81 of window"
send
- on context_overflow error
- compact
- retry
+ on context_overflow error # kept as a safety net
+ compact
+ retryDesign notes:
- Keep the reactive path. Estimates are estimates; a mis-estimate must still recover rather than fail the run.
- Make the ratio configurable per route, since context windows differ by an order of magnitude across the providers Jan supports.
- Compact at a message boundary and cut only at the tail, so the prefix that survives is a true prefix of what was sent before.
- Report it. A compaction is the one legitimate cache break in a session; the user should see it happen rather than infer it from a latency spike.
Acceptance criteria
- A prompt-size estimate is computed before dispatch and compared to the route's context window.
- Crossing the configured ratio triggers compaction before the request is sent.
- The reactive
is_context_overflow_errorpath remains as a fallback and is still tested. - A test drives a conversation past the ratio and asserts zero context-overflow errors were returned by the provider.
- The ratio is configurable per provider route, with a documented default.
- A compaction emits a user-visible event.
Prior art
- DeepSeek-Reasonix runs a compaction preflight at
compact_ratio 0.80— measured before the call, never after a rejection. - OpenAI Codex #37305 shows the other half of this problem: compaction fired at ~235,000 input tokens and the compacted result could not reuse its own prefix. Scheduling the break is necessary but not sufficient — see #8962, which is what makes the post-compaction prefix reusable.
Source: janhq/jan