From 8a47b039b3ba849e9eb587e66e00c56cdeb32ab8 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Wed, 2 Sep 2026 05:02:09 -0400 Subject: [PATCH] Stop ending the run on a policy threshold (dirge-qobx.3, dirge-qobx.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The force-summary tier asked the fold for room and read the answer as matches!(outcome, Succeeded(_)) — did the summarizer replace a slice — so a prune-only pass that freed real tokens and brought the next request back under the threshold reported no room and ended the RUN, mid-task, with the budget no longer full. made_room is now a question about the next request: the estimator's account of the messages the fold left, plus the fixed overhead that is not in them, against the same threshold the decision used. And when the overhead alone clears that threshold, no fold can ever help — it rewrites messages and the overhead is not in messages. That state now warns once (naming the tool surface and context_target), skips further summarizer calls, and lets the run continue as long as requests still fit the window. Stopping is reserved for the case where the unfoldable part fills the window, because then nothing fits. Also dirge-qobx.5: the stop notice said 'over the model's window', false in exactly the case it fired, and guessed at a cause that is now measured; it says which of the two states it is. An empty compress window — the usual reason a fold degrades to prune-only — logged nothing at all, leaving a healthy-looking ContextCompacted with a tiny delta as the only evidence. --- CHANGELOG.md | 19 ++ src/agent/agent_loop/context_manager.rs | 13 +- src/agent/agent_loop/run.rs | 201 ++++++++++++++---- src/agent/agent_loop/run_tests.rs | 268 +++++++++++++++++++++++- 4 files changed, 455 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de852121..37f99e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Fixed +- A run is no longer ended by a compaction that had nothing left to summarize. + The force-summary tier asked the fold for room and read the answer as "did + the summarizer replace a slice" — so a pass that pruned thousands of tokens + of stale tool output and brought the next request comfortably back under the + threshold reported no room, and the run stopped mid-task with the budget no + longer full. It now projects what the next request will actually cost (the + messages the fold left, plus the fixed overhead that is not in them) and + carries on when that fits. And when the fixed overhead ALONE clears the + threshold — a large MCP tool surface will do it — no fold can ever help, so + the run says that once, names the two knobs that can (trim the tool surface, + raise `context_target`), stops spending summarizer calls on it, and keeps + working as long as requests still fit the window (the free tool-output prune + still runs each turn, so the history half stays bounded). Only when the unfoldable + part fills the whole window does the run stop, because then nothing fits. + The stop notice no longer claims the model's window was exceeded when what + was exceeded was dirge's own 80% budget threshold, and a fold that skips + summarization for want of a compress window now says so in the log instead + of leaving a healthy-looking `ContextCompacted` as the only trace. + (dirge-qobx.3, dirge-qobx.5) - Auto-compaction now measures what it actually sends. The fold trigger reads the provider's prompt count — system prompt, every tool schema, replayed reasoning, images, the lot — while the fold's own accounting was `chars / 4` diff --git a/src/agent/agent_loop/context_manager.rs b/src/agent/agent_loop/context_manager.rs index 408f7b3e..872cc4b0 100644 --- a/src/agent/agent_loop/context_manager.rs +++ b/src/agent/agent_loop/context_manager.rs @@ -32,8 +32,17 @@ //! ratio back under it, and without this guard the loop re-folds every turn //! forever (dirge-kq3a). //! -//! Note this is a *no-progress* guard, not the minimum-savings guard this doc -//! previously described: there was a `HISTORY_FOLD_MIN_SAVINGS_FRACTION = 0.30` +//! As of dirge-qobx.3 the loop does more than decline to report that fold: at +//! the force-summary tier it *measures* the fixed overhead (see the pre-send +//! accounting below), and when the overhead alone clears the threshold it says +//! so once, skips the summarizer for the rest of the run, and keeps going on +//! the strength of whether requests still FIT the window — which is a +//! different question from whether a fold can help. The run is only stopped +//! when the unfoldable part fills the whole window, because then nothing fits. +//! +//! Note this no-rotation rule is a *no-progress* guard, not the +//! minimum-savings guard this doc previously described: there was a +//! `HISTORY_FOLD_MIN_SAVINGS_FRACTION = 0.30` //! constant, but it was `#[cfg(test)]` and referenced only by a compile-time //! assert, so no release build ever consulted it and no fold was ever skipped //! for saving too little. Skipping a *partially* useful fold is a real diff --git a/src/agent/agent_loop/run.rs b/src/agent/agent_loop/run.rs index b4186f70..e26a1e81 100644 --- a/src/agent/agent_loop/run.rs +++ b/src/agent/agent_loop/run.rs @@ -1862,6 +1862,22 @@ async fn run_compaction_pass_with_focus( outcome = SummaryOutcome::Failed; } } + } else if !reused { + // dirge-qobx.5: a skipped summarization used to be completely + // silent. `compute_compress_window` returns (0, 0) when the + // window collapses — most often because the stretch to fold + // contains no user turn to cut on (dirge-qobx.4) — and the + // outcome is then the same `Skipped` a healthy prune-only pass + // returns. The only surviving evidence was a ContextCompacted + // with a tiny delta, which reads as a successful compaction. + tracing::debug!( + target: "dirge::agent_loop", + messages = current_context.messages.len(), + protect_head = compression::PROTECT_HEAD_DEFAULT, + protect_tail = protect_tail.max(compression::PROTECT_TAIL_DEFAULT), + tokens = after_prune, + "compaction: no compress window — nothing between the protected head and tail to summarize", + ); } } @@ -2696,6 +2712,15 @@ pub async fn run_loop( // gauge uses. let mut fixed_overhead: u64 = 0; + // dirge-qobx.3: whether this run has already said that its fixed overhead + // alone clears the force-summary threshold. Once it does, no fold can + // bring the ratio back under it — the fold rewrites `messages` and the + // overhead is not in `messages` — so the honest move is to say so once, + // stop spending summarizer calls on it, and let the run continue against + // a window that is not actually full. Latched, because the alternative is + // the same warning every turn for the rest of the run. + let mut overhead_is_unfoldable = false; + // dirge-5mtx.1: per-run gate/capability tally. Declared before the // initial steering poll so both steering-poll sites can record into it. let mut tally = GateTally::new(); @@ -4030,35 +4055,114 @@ pub async fn run_loop( // still over the threshold and the next request could // overflow or 400. Honoured below, after the // checkpoint-schedule reset. - // When context is critically over the threshold, - // prune aggressively then run the structured-summary - // pass if a summarizer is wired. - let outcome = run_compaction_pass( - &mut current_context, - &summarize_fn, - 3, // protect only last 3 - compaction_failures, - &memory_provider, - config.compaction_hooks.as_ref(), - emit, - &checkpoint_slot, - &mut checkpoint_generation, - (ctx_max as f64 * context_manager::HISTORY_FOLD_THRESHOLD) as u64, - ) - .await; - // dirge-8s2v: `Succeeded` is the only outcome that - // moved anything — `Failed` and `Skipped` both leave - // the context exactly as it was. - force_turn_end = Some(( - matches!(outcome, SummaryOutcome::Succeeded(_)), - decision.prompt_tokens, - )); - if let SummaryOutcome::Succeeded(idx) = outcome { - restore_working_files(&config, &mut current_context, idx, ctx_max) - .await; - } - if !compaction_recorded_this_iter { - record_compaction_outcome(&mut compaction_failures, outcome); + // dirge-qobx.3: is there anything a fold could even + // reach? The ratio that got us here counts the system + // prompt and every tool schema; a fold rewrites + // `messages`. When the overhead ALONE clears the + // threshold, folding the history to nothing would leave + // the ratio where it is — so say so once, stop spending + // summarizer calls on it, and decide whether to go on + // from whether requests still FIT, not from whether the + // fold helped. + let force_threshold = + (ctx_max as f64 * context_manager::FORCE_SUMMARY_THRESHOLD) as u64; + if fixed_overhead > force_threshold { + if !overhead_is_unfoldable { + overhead_is_unfoldable = true; + let pct = fixed_overhead + .saturating_mul(100) + .checked_div(ctx_max) + .unwrap_or(0); + tracing::warn!( + target: "dirge::agent_loop", + fixed_overhead, + ctx_max, + "context: fixed overhead alone is over the force-summary threshold — no fold can help", + ); + let _ = emit + .send(LoopEvent::SystemNotice { + content: format!( + "The system prompt and tool schemas alone are \ + {fixed_overhead} tokens — {pct}% of the {ctx_max}-token \ + context budget. No compaction can reduce that, so the \ + context manager will stop trying to. Trim the tool \ + surface (fewer MCP servers) or raise `context_target` \ + in config.json." + ), + }) + .await; + } + // Pruning is free, so it still runs: it keeps the + // history from growing into what little room the + // overhead leaves, and a run in this state has no + // other brake on it. Only the summarizer is + // skipped — multi-second, and unable to move a + // ratio the overhead dominates, which is the + // every-turn runaway dirge-kq3a is about. + current_context.messages = + crate::agent::compression::prune_tool_outputs( + ¤t_context.messages, + 3, + ); + // Whether the RUN can go on is a different question + // from whether a fold can help. If the unfoldable + // part alone fills the window, every later request + // is refused and stopping is the only honest + // outcome. Under the window it still fits — the + // budget is merely tighter than the policy wants, + // and ending a task over that is the bug here. + let requests_still_fit = fixed_overhead < ctx_max; + force_turn_end = Some((requests_still_fit, decision.prompt_tokens)); + } else { + // When context is critically over the threshold, + // prune aggressively then run the structured-summary + // pass if a summarizer is wired. + let outcome = run_compaction_pass( + &mut current_context, + &summarize_fn, + 3, // protect only last 3 + compaction_failures, + &memory_provider, + config.compaction_hooks.as_ref(), + emit, + &checkpoint_slot, + &mut checkpoint_generation, + (ctx_max as f64 * context_manager::HISTORY_FOLD_THRESHOLD) as u64, + ) + .await; + // dirge-qobx.3: "made room" is a question about the + // NEXT REQUEST, not about which code path the fold + // took. It used to be `matches!(outcome, + // Succeeded(_))` — so a prune-only pass that freed + // real tokens and brought the request back under the + // threshold ended the RUN, mid-task, with the budget + // no longer full. Project what the next request will + // cost instead: what the estimator makes of the + // messages the fold left, plus the overhead that is + // not in them. + let projected = fixed_overhead.saturating_add( + crate::agent::compression::estimate_messages_tokens( + ¤t_context.messages, + ), + ); + let made_room = projected <= force_threshold; + if !made_room { + tracing::warn!( + target: "dirge::agent_loop", + outcome = ?outcome, + projected, + force_threshold, + "context: the fold left the next request over the threshold", + ); + } + force_turn_end = Some((made_room, decision.prompt_tokens)); + if let SummaryOutcome::Succeeded(idx) = outcome { + restore_working_files(&config, &mut current_context, idx, ctx_max) + .await; + } + if !compaction_recorded_this_iter { + record_compaction_outcome(&mut compaction_failures, outcome); + } } } _ => {} @@ -4124,19 +4228,40 @@ pub async fn run_loop( continue 'outer; } if !made_room { - let notice = format!( - "Run stopped: the context is over the model's window \ - ({} of {} tokens) and compaction could not reduce it. \ - The task is unfinished. This usually means the system \ - prompt and tool schemas alone exceed the window — \ - check the model's context_window in config.", - prompt_tokens, ctx_max, - ); + // dirge-qobx.3 / dirge-qobx.5: name what was measured, and + // which of the two states this is. + // + // One message used to cover both, and it said "over the + // model's window" — false whenever the run was merely over + // dirge's own force-summary threshold (80% of + // `min(window, context_target)`) with the rest of the + // window unused. It then guessed at the cause; that guess + // is now a measurement. + let notice = if overhead_is_unfoldable { + format!( + "Run stopped: the system prompt and tool schemas alone are \ + {fixed_overhead} tokens, at or over the whole {ctx_max}-token \ + context budget, so no request fits and no compaction can change \ + that. The task is unfinished. Trim the tool surface (fewer MCP \ + servers), or raise `context_target` / `context_window` in \ + config.json if the model's real window is larger." + ) + } else { + format!( + "Run stopped: the context is at {prompt_tokens} of the \ + {ctx_max}-token context budget and compaction could not reduce it \ + further. The task is unfinished. Raise `context_target` in \ + config.json if the model's window is larger than the budget, or \ + start a fresh session to carry on." + ) + }; tracing::warn!( target: "dirge::agent_loop", prompt_tokens = prompt_tokens, ctx_max = ctx_max, - "run truncated: context over window and nothing left to fold", + fixed_overhead = fixed_overhead, + unfoldable_overhead = overhead_is_unfoldable, + "run truncated: nothing left to fold and the next request stays over the budget", ); let _ = emit .send(LoopEvent::SystemNotice { diff --git a/src/agent/agent_loop/run_tests.rs b/src/agent/agent_loop/run_tests.rs index dddd886c..49ca9cbc 100644 --- a/src/agent/agent_loop/run_tests.rs +++ b/src/agent/agent_loop/run_tests.rs @@ -1696,12 +1696,29 @@ async fn drain(rx: &mut mpsc::Receiver) -> Vec { /// No summarizer is wired on purpose: the tier must end the turn even when /// the summarizer is absent/fails, which is the state the commit message /// describes as the dangerous one. +/// +/// dirge-qobx.3 re-fixtured the context. The prompt has to be mostly HISTORY +/// for this to be a test about the fold: against a near-empty transcript, all +/// 110k of the reported prompt is fixed overhead, which no fold can reach — +/// that case now warns and keeps working (the run is not out of window), and +/// this factory hands out the same tool call forever, so the assertion below +/// would never be reached. 20 messages of 4 KB each leave ~90k of overhead +/// and ~20k the fold can see, which still cannot get under the 102,400 +/// threshold with no summarizer wired: the tier ends the turn, the run stops, +/// and one stream call is what the model gets. #[tokio::test] async fn exit_with_summary_ends_the_turn() { use crate::agent::agent_loop::message::TokenUsage; let echo = std::sync::Arc::new(EchoTool::new()); let mut ctx = empty_context(); + for i in 0..20 { + let role = if i % 2 == 0 { "assistant" } else { "user" }; + ctx.messages.push(serde_json::json!({ + "role": role, + "content": format!("turn {i}: {}", "x".repeat(4_000)), + })); + } ctx.tools.push(echo.clone()); let calls = std::sync::Arc::new(AtomicUsize::new(0)); @@ -9033,9 +9050,23 @@ fn over_budget_factory(responses: Vec) -> (StreamFn, Arc, + prompt_tokens: Vec, +) -> (StreamFn, Arc) { + assert!(!prompt_tokens.is_empty(), "need at least one usage figure"); + let calls = Arc::new(AtomicUsize::new(0)); + let responses = Arc::new(responses); + let prompt_tokens = Arc::new(prompt_tokens); + let factory: StreamFn = { + let calls = calls.clone(); + Arc::new(move |_ctx, _opts| { + let n = calls.fetch_add(1, Ordering::SeqCst); + let msg = responses + .get(n) + .cloned() + .unwrap_or_else(|| text_response("done")); + let input_tokens = *prompt_tokens + .get(n) + .unwrap_or_else(|| prompt_tokens.last().expect("non-empty")); + let reason = msg.stop_reason; + Box::pin(futures::stream::iter(vec![StreamEvent::Done { + reason, + message: msg, + usage: Some(crate::agent::agent_loop::message::TokenUsage { + input_tokens, + ..Default::default() + }), + }])) + }) + }; + (factory, calls) +} + +/// A context whose bulk is in prunable tool results, each small enough that +/// the pre-send per-result cap (3000 tokens) leaves it alone and large enough +/// (>500 chars) that `prune_tool_outputs` collapses it to one line. +/// +/// ~40 KB of text, so ~10k estimated tokens before the fold and a few hundred +/// after — a prune-only pass with real savings, which is precisely the shape +/// that used to end the run. +fn ctx_with_prunable_results(n: usize) -> super::Context { + let mut ctx = empty_context(); + ctx.messages + .push(serde_json::json!({"role": "user", "content": "initial task"})); + for i in 0..n { + ctx.messages.push(serde_json::json!({ + "role": "assistant", + "content": [{ + "type": "toolCall", + "id": format!("call-{i}"), + "name": "bash", + "arguments": {"command": "ls"}, + }], + })); + ctx.messages.push(serde_json::json!({ + "role": "toolResult", + "toolCallId": format!("call-{i}"), + "toolName": "bash", + "content": [{"type": "text", "text": "x".repeat(2_000)}], + "isError": false, + })); + } + ctx +} + +/// A prune-only fold that clears the threshold continues the run. +/// +/// `made_room` was `matches!(outcome, Succeeded(_))` — "did the summarizer +/// replace a slice", not "will the next request fit". So a pass that pruned +/// 9k tokens of stale tool output and brought the request back under the +/// threshold reported no room, and the run ENDED mid-task with the budget no +/// longer full. Observed live as a run that stopped one turn after printing +/// `context compacted: 63800 -> 63479 tokens`. +/// +/// No summarizer here on purpose: prune-only is the outcome under test. +#[tokio::test] +async fn a_prune_only_fold_that_clears_the_threshold_continues_the_run() { + let mut ctx = ctx_with_prunable_results(20); + ctx.tools.push(Arc::new(RecBashTool::new())); + let mut cfg = build_config(); + // "qwen" is a 32k window: the force-summary threshold is 25,600. + cfg.model_name = Some("qwen".to_string()); + cfg.max_turns = Some(4); + + // 26k of prompt against ~10k of estimated messages: over the threshold, + // and ~16k of it is fixed overhead the fold cannot touch. The second + // request costs 12k, which is what pruning ~9k of stale tool output + // actually does to the next prompt. + let (factory, calls) = usage_factory( + vec![tool_use_response( + "call-1", + "bash", + serde_json::json!({"command": "ls"}), + )], + vec![26_000, 12_000], + ); + + let (tx, mut rx) = mpsc::channel::(512); + let _ = run_agent_loop( + vec![user("task")], + ctx, + cfg, + AbortSignal::new(), + &tx, + &factory, + None, // no summarizer: the pass can only prune + None, + ) + .await; + drop(tx); + + assert!( + calls.load(Ordering::SeqCst) >= 2, + "the prune freed ~9k tokens and the next request fits; the run must \ + go on, but the model was called {} time(s)", + calls.load(Ordering::SeqCst) + ); + let mut notices = Vec::new(); + while let Ok(evt) = rx.try_recv() { + if let LoopEvent::SystemNotice { content } = evt { + notices.push(content); + } + } + assert!( + !notices.iter().any(|n| n.contains("Run stopped")), + "nothing was cut short here; notices were {notices:?}" + ); +} + +/// Fixed overhead over the threshold but under the window: say so once, and +/// keep working. +/// +/// This is the state a large tool surface puts every run in — the system +/// prompt and schemas alone clear 80% of the budget, and no fold reaches +/// either, because a fold rewrites `messages` and neither is in `messages`. +/// The old code asked the fold for room, got none, and ended the run with +/// most of the window unused. The requests still fit; what has run out is the +/// fold's ability to help, and that is a warning, not a stop. +#[tokio::test] +async fn unfoldable_overhead_under_the_window_warns_once_and_carries_on() { + let mut ctx = empty_context(); + ctx.messages + .push(serde_json::json!({"role": "user", "content": "task"})); + ctx.tools.push(Arc::new(RecBashTool::new())); + let mut cfg = build_config(); + cfg.model_name = Some("qwen".to_string()); // 32k window, 25,600 threshold + cfg.max_turns = Some(4); + + // 27k of prompt against a near-empty transcript: the overhead alone is + // over the threshold, and still 5k short of the window. Constant on + // purpose — this is the case where nothing the loop does can move the + // number, which is exactly what the latch is for. + let (factory, calls) = usage_factory( + vec![ + tool_use_response("call-1", "bash", serde_json::json!({"command": "ls"})), + tool_use_response("call-2", "bash", serde_json::json!({"command": "ls"})), + ], + vec![27_000], + ); + let summarizer_called = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let summarize_fn = recording_summarizer(summarizer_called.clone()); + + let (tx, mut rx) = mpsc::channel::(512); + let _ = run_agent_loop( + vec![user("task")], + ctx, + cfg, + AbortSignal::new(), + &tx, + &factory, + summarize_fn, + None, + ) + .await; + drop(tx); + + assert!( + calls.load(Ordering::SeqCst) >= 2, + "requests still fit — the run must continue; the model was called {} \ + time(s)", + calls.load(Ordering::SeqCst) + ); + assert!( + !summarizer_called.load(std::sync::atomic::Ordering::SeqCst), + "no fold can reduce fixed overhead, so no summarizer call should be \ + spent trying" + ); + + let mut notices = Vec::new(); + while let Ok(evt) = rx.try_recv() { + if let LoopEvent::SystemNotice { content } = evt { + notices.push(content); + } + } + let overhead_notices = notices + .iter() + .filter(|n| n.contains("tool schemas")) + .count(); + assert_eq!( + overhead_notices, 1, + "the unfoldable-overhead warning is latched — once per run, not once \ + per turn; notices were {notices:?}" + ); + assert!( + !notices.iter().any(|n| n.contains("Run stopped")), + "the run was not out of window; notices were {notices:?}" + ); +} + // ── dirge-4afz: tail-injected notes are not duplicated ────────────── /// A tail context note persists in the conversation, unlike a system-prompt