Auto-compaction never triggers

Open 💬 6 comments Opened Mar 27, 2026 by brandomagnani
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI is running?

codex-cli 0.117.0

What subscription do you have?

Pro

Which model were you using?

gpt-5.3-codex

What platform is your computer?

macOS Darwin 25.3.0 (Apple Silicon)

What terminal emulator and version are you using (if applicable)?

Headless mode via codex exec (spawned programmatically)

What issue are you seeing?

Auto-compaction never fires when running Codex in headless exec mode. The session accumulates tool outputs until it hits the context window limit, then crashes with "Codex ran out of room in the model's context window." The watcher retries, each retry adds to the session, and it enters a permanent crash loop.

Config:

model_context_window = 400000
model_auto_compact_token_limit = 350000

Evidence from Codex session file:

  • 86 shell commands executed, producing ~242K tokens of tool output
  • token_count events in session file all show empty input_tokens_used — token tracking appears broken in exec mode
  • Zero compact events in the session — compaction never triggered
  • Session crashes at context limit without any compaction attempt

Evidence from traces:

  • turn_context events appear (which I believe are compaction triggers) but they are followed immediately by task_complete with last_agent_message: null — suggesting compaction was attempted but failed silently
  • After first crash, every retry fails within 4-5 seconds with the same context overflow error

Codex session ID: 019d30b8-c263-7190-9072-14650024a1ae

This is blocking automated workflows where agents need to read large codebases. The agent reads files incrementally (200-300 line chunks via sed), but after ~86 commands the accumulated context exceeds the window. In interactive mode, compaction would presumably trigger and summarize earlier context. In exec mode, it never fires.

What steps can reproduce the bug?

  1. Set model_auto_compact_token_limit = 350000 and model_context_window = 400000 in config.toml
  2. Run codex in headless exec mode: codex exec --resume <session-id> "Read a large codebase by running sed on many files"
  3. The agent will read files until it accumulates ~350K+ tokens
  4. Expected: auto-compact triggers at 350K, summarizes context, continues
  5. Actual: no compaction, crashes at 400K, permanent crash loop on retry

What is the expected behavior?

model_auto_compact_token_limit should trigger auto-compaction in exec mode the same way it does in interactive mode. Token counting should work (currently reports empty values in exec mode).

Additional information

Related issues: #14860, #14913, #13279 (compaction death spiral)

This may be the same underlying issue as #14860 (TCP timeout on compaction endpoint), but the symptom is different — in our case compaction never even attempts to fire, rather than timing out mid-compact.

View original on GitHub ↗

6 Comments

github-actions[bot] contributor · 5 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #14860
  • #14913
  • #14559
  • #14310

Powered by Codex Action

brandomagnani · 5 months ago

Update: confirmed this persists after completely nuking ~/.codex/, fresh installing 0.117.0, and re-authenticating (Pro subscription). No old threads or sessions carried over.

Error on every retry:

{"type":"error","message":"Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying."}

Running via codex exec with --resume. Config: model_context_window = 400000, model_auto_compact_token_limit = 300000.

brandomagnani · 5 months ago

Root cause found in source code (codex-rs/core/src/codex.rs):

Auto-compaction only checks token count between turns, not between tool calls within a turn.

Pre-sampling compact check (line 6080) runs once at turn start. But within a single turn, the agent can make 80+ tool calls that accumulate 240K+ tokens of output. No compaction check runs between tool calls — the token counter only updates from API response headers after the model responds.

So the sequence is:

  1. Turn starts, pre-sampling check: get_total_token_usage() returns 0 (fresh session, no prior API response) → skip compact
  2. Agent makes 83 shell commands reading files, each returning 2-20K tokens
  3. Context fills from 0 → 400K within a single turn
  4. Model finally tries to respond → API rejects: ContextWindowExceeded
  5. Turn fails — compaction never had a chance

There is a TODO in the code acknowledging this (line 5584-5586):

// TODO(ccunningham): Pre-turn compaction runs before context updates and the
// new user message are recorded. Estimate pending incoming items (context
// diffs/full reinjection + user input) and trigger compaction preemptively
// when they would push the thread over the compaction threshold.

The fix would be checking accumulated token count after each tool output (or periodically mid-turn), not just between turns.

brandomagnani · 5 months ago

Additional finding: compaction also fails to trigger in interactive mode (not just exec), suggesting this is not exec-specific.

Also noting that #16042 confirms a regression in >=0.115 where compaction fires but the model stops afterward with a "Handoff" summary instead of continuing. That reporter confirms 0.114.0 works correctly with no issues in multi-day runs. We will test downgrading to 0.114.0.

Our session traces show token_count events with real values climbing from 17K → 302K → 1.3M with model_context_window=380000 and auto_compact_token_limit derived as 342K (90% of 380K), yet zero compaction events fire. The compaction condition at codex.rs:5895 (token_limit_reached && needs_follow_up) is never satisfied despite both conditions appearing to be independently true at different points in the turn.

brandomagnani · 5 months ago

Important update: the issue does not reproduce when model_context_window and model_auto_compact_token_limit are NOT set in config.toml. With defaults (256K context window), compaction works correctly and agents run for 10+ minutes with zero failures on the same workload.

The bug only triggers when these variables are explicitly set in config.toml (e.g. model_context_window = 400000, model_auto_compact_token_limit = 300000). Removing both lines and letting Codex use the default 256K window resolves the issue completely.

EvolveAegis · 26 days ago

Looking into this, I found an accounting mutation on the ContextWindowExceeded recovery path that looks relevant. It overwrites the cumulative token usage in a way that conflicts with how the same value is read back for the limit-reached check.

Where. codex-rs/protocol/src/protocol.rs, TokenUsageInfo::fill_to_context_window (L2100 at f57467275cb0; unchanged on current main, now ~L2115):

pub fn fill_to_context_window(&mut self, context_window: i64) {
    let previous_total = self.total_token_usage.total_tokens;
    let delta = (context_window - previous_total).max(0);

    self.model_context_window = Some(context_window);
    self.total_token_usage = TokenUsage {
        total_tokens: context_window,
        ..TokenUsage::default()
    };
    self.last_token_usage = TokenUsage {
        total_tokens: delta,
        ..TokenUsage::default()
    };
}

Two things happen here:

  1. total_token_usage is replaced wholesale — the accumulated input/output/cache breakdown is zeroed and total_tokens is set to the full window.
  2. last_token_usage.total_tokens becomes max(0, context_window - previous_total). previous_total is cumulative across turns, so once it crosses the window this is 0.

The recovery path reaches this on overflow: core/src/session/turn.rs:1185 calls set_total_tokens_full(&turn_context) on Err(ContextWindowExceeded), which routes into fill_to_context_window (core/src/compact.rs:295 does the same).

Downstream, core/src/context_manager/history.rs:328 (get_total_token_usage) reads info.last_token_usage.total_tokens as the active context size, and core/src/session/context_window.rs:23 (context_window_token_status) uses that return value to compute token_limit_reached / full_context_window_limit_reached (L75-79). In the common case where cumulative usage already exceeds the window, the fill leaves last_token_usage.total_tokens at 0, so the next reached-check would read as not-reached.

Observed behavior (synthetic turn sizes, in-crate test). I drove the real function with fabricated usage:

  • 3 turns × (10_000 input / 2_000 cached / 500 output), cumulative 31_500. After fill_to_context_window(200_000): total.{input,output,cached} all 0, total.total = 200_000, last.total = 168_500 — a turn nobody made.
  • Cumulative 217_000 (150_000 + 67_000) > window 200_000. After fill: last.total = 0, and the 217_000 lifetime spend gets reported down to 200_000.

Reproduce (needs only a Rust toolchain):

git clone https://github.com/openai/codex && cd codex
# tested at f57467275cb0; same code on current main

Save this as codex-rs/protocol/tests/zz_fill_usage.rs:

use codex_protocol::protocol::{TokenUsage, TokenUsageInfo};

fn usage(input: i64, cached: i64, output: i64) -> TokenUsage {
    TokenUsage {
        input_tokens: input,
        cached_input_tokens: cached,
        cache_write_input_tokens: 0,
        output_tokens: output,
        reasoning_output_tokens: 0,
        total_tokens: input + output,
    }
}

fn info() -> TokenUsageInfo {
    TokenUsageInfo {
        total_token_usage: TokenUsage::default(),
        last_token_usage: TokenUsage::default(),
        model_context_window: None,
    }
}

#[test]
fn fill_zeroes_breakdown_and_inflates_total() {
    let mut info = info();
    // synthetic turn sizes
    for _ in 0..3 {
        info.append_last_usage(&usage(10_000, 2_000, 500));
    }
    assert_eq!(info.total_token_usage.total_tokens, 31_500);

    info.fill_to_context_window(200_000);

    assert_eq!(info.total_token_usage.input_tokens, 0);
    assert_eq!(info.total_token_usage.output_tokens, 0);
    assert_eq!(info.total_token_usage.cached_input_tokens, 0);
    assert_eq!(info.total_token_usage.total_tokens, 200_000);
    // phantom turn = window - previous_total
    assert_eq!(info.last_token_usage.total_tokens, 168_500);
}

#[test]
fn fill_when_cumulative_exceeds_window_zeroes_last() {
    let mut info = info();
    info.append_last_usage(&usage(150_000, 0, 5_000));
    info.append_last_usage(&usage(60_000, 0, 2_000));
    assert_eq!(info.total_token_usage.total_tokens, 217_000);

    info.fill_to_context_window(200_000);

    // delta = max(0, 200_000 - 217_000) = 0
    assert_eq!(info.last_token_usage.total_tokens, 0);
    // 217k lifetime spend reported down to 200k
    assert_eq!(info.total_token_usage.total_tokens, 200_000);
}
cargo test -p codex-protocol --test zz_fill_usage -- --nocapture
# test result: ok. 2 passed

Scope. This is a crate-level test of the accounting function — it shows the mutation and the arithmetic, not an end-to-end run that observes the next compaction decision after an overflow. So I'd treat it as a candidate contributor to the symptoms in this issue rather than a confirmed root cause. If it checks out, the fix looks like: last_token_usage should carry the actual current request size (not window - cumulative), and the cumulative total_token_usage shouldn't be overwritten — the fabricated baseline belongs in a separate field.