@@ -151,6 +151,14 @@ fn estimate_context_tokens(context: &[crate::utils::ChatMessage]) -> usize {
151151 context. iter ( ) . map ( estimate_message_tokens) . sum ( )
152152}
153153
154+ /// Best available context-size estimate for the compaction trigger: the larger of the bytes/4
155+ /// heuristic and the last LLM call's exact `usage.prompt_tokens`. The heuristic under-counts the
156+ /// code/JSON/non-English payloads this agent produces, while `prompt_tokens` is the provider's
157+ /// ground-truth input count — so the max guards against silently overflowing the context window.
158+ fn effective_context_tokens ( estimate : usize , last_prompt_tokens : Option < u32 > ) -> usize {
159+ estimate. max ( last_prompt_tokens. unwrap_or ( 0 ) as usize )
160+ }
161+
154162/// Trim context from the front (oldest messages) to stay within a token budget.
155163/// Preserves the system message at index 0 and never splits tool_call/tool pairs.
156164/// A marker message is inserted only when messages were actually removed.
@@ -2382,6 +2390,11 @@ impl AgentLogic {
23822390 let mut consecutive_doom_detections: usize = 0 ;
23832391 // Set when the doom loop persists past the nudge budget; branches the terminal message.
23842392 let mut doom_loop_stuck = false ;
2393+ // Ground-truth input size from the most recent LLM call's `usage.prompt_tokens` (exact,
2394+ // server-counted). The bytes/4 heuristic under-counts code/JSON/non-English — exactly what
2395+ // this agent generates — so the compaction trigger uses `max(estimate, last_prompt_tokens)`
2396+ // to avoid silently overflowing the context window. Updated after each provider response.
2397+ let mut last_prompt_tokens: Option < u32 > = None ;
23852398
23862399 while iterations < max_iterations {
23872400 if cancel_token. is_cancelled ( ) {
@@ -2682,6 +2695,12 @@ impl AgentLogic {
26822695 )
26832696 . with_chat_id ( & inbound. chat_id ) ,
26842697 ) ) ;
2698+ // The context just shrank, so the pre-compaction `prompt_tokens` is
2699+ // now stale. Clear it; otherwise, if the retried call returns no
2700+ // usage stats (mock/local provider, transient gap), the end-of-turn
2701+ // check would read the old huge value via `effective_context_tokens`
2702+ // and immediately fire a redundant compaction right after this one.
2703+ last_prompt_tokens = None ;
26852704 // Next iteration refetches context (now smaller due
26862705 // to AddSummary + UpdateThreadMetadata) and re-runs
26872706 // the chat call. No iteration counter refund — the
@@ -2793,6 +2812,10 @@ impl AgentLogic {
27932812
27942813 // Log USAGE telemetry
27952814 if let Some ( usage) = & response. usage {
2815+ // Remember the exact server-counted input size for the compaction trigger.
2816+ if usage. prompt_tokens > 0 {
2817+ last_prompt_tokens = Some ( usage. prompt_tokens ) ;
2818+ }
27962819 let usage_evt = TelemetryEvent :: AgentUsage {
27972820 chat_id : inbound. chat_id . clone ( ) ,
27982821 model : "llm_provider" . to_string ( ) ,
@@ -3167,7 +3190,15 @@ impl AgentLogic {
31673190 // Auto-compaction check
31683191 let current_context = mem. get_context_since_reflection ( ) . await ?;
31693192 let user_turns = current_context. iter ( ) . filter ( |m| m. role == "user" ) . count ( ) ;
3170- let approx_tokens: usize = estimate_context_tokens ( & current_context) ;
3193+ // Prefer the ground truth: `last_prompt_tokens` is the exact input size the provider
3194+ // counted for the most recent request, which (at this end-of-turn point) covers
3195+ // nearly the entire current context — only the just-produced final assistant message
3196+ // is newer. Taking the max with the bytes/4 estimate corrects the heuristic's
3197+ // under-count on code/JSON-heavy contexts so a real overflow triggers compaction.
3198+ let approx_tokens: usize = effective_context_tokens (
3199+ estimate_context_tokens ( & current_context) ,
3200+ last_prompt_tokens,
3201+ ) ;
31713202
31723203 // PR-3: pull the model's context window from the provider; if known,
31733204 // tighten the absolute token threshold to whichever is smaller of:
@@ -4249,6 +4280,19 @@ mod tests {
42494280 ) ;
42504281 }
42514282
4283+ #[ test]
4284+ fn effective_context_tokens_prefers_ground_truth ( ) {
4285+ // No usage yet -> fall back to the estimate.
4286+ assert_eq ! ( super :: effective_context_tokens( 1000 , None ) , 1000 ) ;
4287+ // Provider's exact count exceeds the bytes/4 under-estimate -> use the ground truth so a
4288+ // real overflow still triggers compaction.
4289+ assert_eq ! ( super :: effective_context_tokens( 1000 , Some ( 9000 ) ) , 9000 ) ;
4290+ // Estimate larger (e.g. messages added since the last call) -> keep the estimate.
4291+ assert_eq ! ( super :: effective_context_tokens( 9000 , Some ( 1000 ) ) , 9000 ) ;
4292+ // A zero ground-truth never lowers the estimate.
4293+ assert_eq ! ( super :: effective_context_tokens( 1000 , Some ( 0 ) ) , 1000 ) ;
4294+ }
4295+
42524296 #[ tokio:: test]
42534297 async fn run_reasoning_loop_persists_terminal_message_on_cancel ( ) {
42544298 let ( result, context) =
0 commit comments