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