#8961·jan

perf(agent): trigger compaction on a context ratio, not on a provider error

Author: thinhlpgCreated Sep 16, 2026Updated Sep 17, 2026

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

rust
// 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.

mermaid
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
    end

Three 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.

diff
 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
+    retry

Design 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_error path 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.