From 7c486f7a1a6513495ee99701104b409add54d3ec Mon Sep 17 00:00:00 2001 From: Yogthos Date: Wed, 2 Sep 2026 05:04:38 -0400 Subject: [PATCH] Fold an autonomous stretch with no user turn (dirge-qobx.4) compute_compress_window snapped both cuts to a user message (dirge-89fm) so no tool_use/tool_result pair could be split. But one prompt and a hundred tool iterations has no user turn: snap_forward_to_user walked the head cut to messages.len(), the window collapsed to (0, 0), the summarizer never ran, and every fold in the stretch degraded to prune-only. apply_checkpoint_summary refused for the same reason, so the background-checkpoint fast path could not fire there either. A user turn is sufficient, not necessary. Results follow their call immediately (heal::fix_tool_call_pairing repairs a transcript where they do not), so any index whose message is not itself a tool result is a safe cut: the kept tail cannot begin with an orphan, and the message before the cut cannot hold calls whose results were dropped, because those results would be at the cut. The user boundary stays the preferred cut, so every transcript that folded before folds the same way; the fallback only runs where the old code returned nothing at all. --- CHANGELOG.md | 12 +++ src/agent/compression.rs | 178 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 183 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a7c96d..14336212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Fixed +- Compaction can now fold an autonomous stretch. Both cuts of the compress + window snapped to a *user* message, which guarantees no tool_use↔tool_result + pair is split — but one prompt followed by a hundred tool iterations, the + normal shape of agentic work, contains no user turn at all. The head cut + then walked to the end of the transcript, the window collapsed to nothing, + the summarizer never ran, and every fold in that stretch degraded to + pruning; the same collapse silently disabled the background-checkpoint fast + path. A user turn is a sufficient cut point, not a necessary one: what the + invariant needs is that the kept tail does not begin with an orphaned tool + result, so when no user boundary is available the cut now falls back to the + nearest message that is not itself a tool result. A transcript that folded + before folds identically — the user boundary is still preferred. (dirge-qobx.4) - 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 diff --git a/src/agent/compression.rs b/src/agent/compression.rs index 9134ed60..82b49553 100644 --- a/src/agent/compression.rs +++ b/src/agent/compression.rs @@ -1661,10 +1661,13 @@ pub fn apply_summary( /// Returns `Some((new_messages, first_kept_index))` on success, or `None` /// when reuse wouldn't be safe or useful: /// - `boundary` is 0 or past the end (nothing to fold, or stale), -/// - the user-boundary snap collapses the cut to 0 (no whole turn to fold). +/// - no safe cut exists at or before the boundary (no whole turn to fold). /// -/// The cut snaps BACKWARD to a user-message boundary so the kept tail never -/// begins with an orphaned tool_result (which would 400 the next request). +/// The cut snaps BACKWARD so the kept tail never begins with an orphaned +/// tool_result (which would 400 the next request): to a user message when one +/// is available, else — dirge-qobx.4 — to the nearest message that is not +/// itself a tool result, since an autonomous stretch has no user turn to snap +/// to and this fast path would otherwise never fire during one. /// Snapping backward only ever keeps MORE verbatim than the summary covers /// — it never drops an un-summarized message — so it can't lose data; the /// few messages in `[cut..boundary]` are simply carried both in the summary @@ -1678,7 +1681,10 @@ pub fn apply_checkpoint_summary( if boundary == 0 || boundary > n { return None; } - let cut = snap_backward_to_user(messages, boundary); + let cut = match snap_backward_to_user(messages, boundary) { + 0 => snap_backward_to_safe_cut(messages, boundary), + at_user => at_user, + }; if cut == 0 { return None; } @@ -1719,12 +1725,74 @@ pub fn compute_compress_window( // (protects ≥ protect_tail); both only ever protect more, never less. let start = snap_forward_to_user(messages, raw_start); let end = snap_backward_to_user(messages, raw_end); + if start < end { + return (start, end); + } + // dirge-qobx.4: a user turn is a SUFFICIENT cut point, not a necessary + // one, and an autonomous stretch has none. + // + // One prompt and a hundred tool iterations is the normal shape of agentic + // work, and every message in it is `assistant` or `toolResult`. The snap + // above then walks the head cut to `messages.len()`, the window collapses + // to (0, 0), the summarizer never runs, and every fold in that stretch + // degrades to prune-only — which, before dirge-qobx.3, ended the run. + // + // What the invariant actually needs is that no cut splits a + // tool_use↔tool_result pair, and results follow their call immediately + // (`heal::fix_tool_call_pairing` repairs the transcript if they ever do + // not). So any index whose message is not itself a tool result is a safe + // cut: the kept tail cannot begin with an orphan, and the message before + // it cannot be an assistant turn holding calls whose results were dropped + // — those results would be at this index. + let start = snap_forward_to_safe_cut(messages, raw_start); + let end = snap_backward_to_safe_cut(messages, raw_end); if start >= end { return (0, 0); } (start, end) } +/// True when `msg` is a tool result in either transcript shape — the loop's +/// `toolResult` or the heal-on-load `tool`. +fn is_tool_result_msg(msg: &Value) -> bool { + matches!( + msg.get("role").and_then(|r| r.as_str()), + Some("toolResult" | "tool") + ) +} + +/// True when the transcript can be cut at `idx` — keeping `messages[idx..]` +/// on one side — without splitting a tool_use↔tool_result pair +/// (dirge-qobx.4). See `compute_compress_window` for why "not a tool result" +/// is the whole condition. An index past the end is vacuously safe. +fn is_safe_cut(messages: &[Value], idx: usize) -> bool { + messages.get(idx).is_none_or(|m| !is_tool_result_msg(m)) +} + +/// Smallest index `>= idx` that [`is_safe_cut`], clamped to `messages.len()`. +fn snap_forward_to_safe_cut(messages: &[Value], idx: usize) -> usize { + let n = messages.len(); + let mut i = idx.min(n); + while i < n && !is_safe_cut(messages, i) { + i += 1; + } + i +} + +/// Largest index `<= idx` that [`is_safe_cut`], or 0 when none is. +fn snap_backward_to_safe_cut(messages: &[Value], idx: usize) -> usize { + let mut i = idx.min(messages.len().saturating_sub(1)); + loop { + if is_safe_cut(messages, i) { + return i; + } + if i == 0 { + return 0; + } + i -= 1; + } +} + /// True when `msg` is a user-role turn. fn is_user_msg(msg: &Value) -> bool { msg.get("role").and_then(|r| r.as_str()) == Some("user") @@ -3360,15 +3428,37 @@ mod tests { assert!(apply_checkpoint_summary(&msgs, "## Goal\nx", 99).is_none()); } + /// dirge-qobx.4: no user turn at or before the boundary is no longer a + /// refusal — an autonomous stretch has none, and this fast path would + /// never fire during one. The cut falls back to the nearest message that + /// is not itself a tool result, which is all the pairing invariant needs. #[test] - fn checkpoint_reuse_rejects_when_snap_collapses_to_zero() { - // No user turn at/before the boundary → snap_backward returns 0 → - // None (nothing whole to fold). + fn checkpoint_reuse_falls_back_to_a_safe_cut_without_a_user_turn() { let msgs = vec![ serde_json::json!({"role": "assistant", "content": "a0"}), serde_json::json!({"role": "assistant", "content": "a1"}), serde_json::json!({"role": "assistant", "content": "a2"}), ]; + let (out, cut) = apply_checkpoint_summary(&msgs, "## Goal\nx", 2) + .expect("an all-assistant stretch is foldable"); + assert_eq!(cut, 2, "the boundary itself is a safe cut"); + assert_eq!( + out.len(), + 2, + "the summary replaces messages[..2] and the tail is kept: {out:?}" + ); + } + + /// ...but a cut that would orphan a tool result is still refused. Every + /// candidate here is a tool result, so there is no safe place to put the + /// summary and nothing whole to fold. + #[test] + fn checkpoint_reuse_still_rejects_when_every_cut_orphans_a_result() { + let msgs = vec![ + serde_json::json!({"role": "toolResult", "toolCallId": "c0", "content": "t0"}), + serde_json::json!({"role": "toolResult", "toolCallId": "c1", "content": "t1"}), + serde_json::json!({"role": "toolResult", "toolCallId": "c2", "content": "t2"}), + ]; assert!(apply_checkpoint_summary(&msgs, "## Goal\nx", 2).is_none()); } @@ -3434,6 +3524,80 @@ mod tests { assert!(out[summary_idx - 1]["tool_calls"].is_null()); } + /// dirge-qobx.4: one prompt, sixty tool iterations, no user turn in + /// sight — the normal shape of agentic work, and the shape that produced + /// an empty compress window. + /// + /// `snap_forward_to_user` walked the head cut to `messages.len()`, the + /// window collapsed to (0, 0), the summarizer never ran, and every fold + /// in the stretch degraded to prune-only. The fallback cuts on + /// tool-group boundaries instead, which is what the pairing invariant + /// actually requires. + #[test] + fn compute_window_folds_an_autonomous_stretch_with_no_user_turn() { + let mut msgs = vec![ + serde_json::json!({"role": "system", "content": "s"}), + serde_json::json!({"role": "user", "content": "one prompt"}), + ]; + for i in 0..60 { + msgs.push(serde_json::json!({ + "role": "assistant", + "content": [{ + "type": "toolCall", + "id": format!("c{i}"), + "name": "bash", + "arguments": {"command": "ls"}, + }], + })); + msgs.push(serde_json::json!({ + "role": "toolResult", + "toolCallId": format!("c{i}"), + "toolName": "bash", + "content": [{"type": "text", "text": "output"}], + })); + } + + let (start, end) = + compute_compress_window(&msgs, PROTECT_HEAD_DEFAULT, PROTECT_TAIL_DEFAULT); + assert!( + start < end, + "an autonomous stretch must be foldable; got ({start}, {end})" + ); + // Neither cut may land on a tool result, or the fold splits a pair. + assert_ne!(msgs[start]["role"].as_str(), Some("toolResult")); + assert_ne!(msgs[end]["role"].as_str(), Some("toolResult")); + + // And the applied fold leaves every tool result behind its call. + let out = apply_summary(&msgs, "## Goal\nx", start, end); + for (i, m) in out.iter().enumerate() { + if m["role"].as_str() == Some("toolResult") { + assert_eq!( + out[i - 1]["role"].as_str(), + Some("assistant"), + "toolResult at {i} must follow an assistant" + ); + } + } + } + + /// The fallback never fires when a user turn is available: the preferred + /// cut is unchanged, so every transcript that folded before folds the + /// same way. + #[test] + fn compute_window_still_prefers_a_user_boundary() { + let mut msgs: Vec = vec![ + serde_json::json!({"role": "system", "content": "s"}), + serde_json::json!({"role": "assistant", "content": "a"}), + ]; + for i in 0..8 { + msgs.push(serde_json::json!({"role": "user", "content": format!("u{i}")})); + msgs.push(serde_json::json!({"role": "assistant", "content": format!("a{i}")})); + } + let (start, end) = compute_compress_window(&msgs, 2, 2); + assert_eq!(msgs[start]["role"].as_str(), Some("user")); + assert_eq!(msgs[end]["role"].as_str(), Some("user")); + } + #[test] fn compute_window_short_list_returns_zero() { let msgs: Vec = (0..3)