From e9c88e3f81ff8c795674d97ad5fd20a91243864d Mon Sep 17 00:00:00 2001 From: xlx1212 Date: Wed, 5 Aug 2026 11:50:18 +0800 Subject: [PATCH] fix: clear loop detection after compression and detect text-only loops After context compression succeeds, recent_tool_signatures and recent_failed_tool_signatures were not cleared. The compressed context is effectively a fresh start, so stale pre-compression tool signatures must not bias post-compression loop detection. Additionally, when the model produces text-only responses (no tool calls), recent_tool_signatures.clear() reset all loop detection, allowing the model to loop indefinitely producing identical text. This is the exact symptom described in issue #1179: after compression, the response content keeps looping. Two fixes: 1. Clear loop detection state (recent_tool_signatures, recent_failed_tool_signatures, failed_tool_recovery_attempts) at both compression success sites (primary path and overflow recovery). 2. Add text-only loop detection via recent_text_fingerprints: a normalized fingerprint of assistant text content is tracked for text-only rounds. When max_consec consecutive identical fingerprints are detected, the turn is finalized with reason "repeated_text_responses". Closes #1179 --- .gitignore | 1 + .../src/agentic/execution/execution_engine.rs | 87 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/.gitignore b/.gitignore index aaeef78ec..2ecaf5aca 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ dist-ssr # Build outputs - Rust/Tauri target/ **/target/ +target-isolated/ # The deployable Rust services use the workspace lockfile for reproducible # container builds. !Cargo.lock diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 9e60a91ea..6ac9ed3d3 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -2319,6 +2319,32 @@ impl ExecutionEngine { .filter(|text| !text.is_empty()) } + /// A normalized fingerprint of assistant text content for text-loop detection. + /// + /// After context compression, the model may regenerate the same text + /// response repeatedly because the compressed summary triggers the same + /// output. This fingerprint strips whitespace and lowercases the text so + /// that trivial formatting differences do not mask a genuine loop. + fn assistant_text_fingerprint(message: &Message) -> Option { + let text = Self::assistant_message_text(message)?; + let normalized: String = text + .chars() + .filter(|c| !c.is_whitespace()) + .map(|c| c.to_ascii_lowercase()) + .collect(); + if normalized.is_empty() { + return None; + } + // Truncate to bound memory usage; 500 chars is enough to distinguish + // genuinely different responses while ignoring long trailing content. + let truncated = if normalized.len() > 500 { + &normalized[..500] + } else { + &normalized[..] + }; + Some(truncated.to_string()) + } + /// Native hook session facts for a compaction or turn-lifecycle dispatch. fn native_hook_facts<'a>( session_id: &'a str, @@ -3275,6 +3301,12 @@ impl ExecutionEngine { let mut recent_tool_signatures: Vec = Vec::new(); let mut recent_failed_tool_signatures: Vec = Vec::new(); let mut failed_tool_recovery_attempts: usize = 0; + // Track text-only response fingerprints for text-loop detection. + // After context compression, the model may regenerate the same text + // response repeatedly because it lost the full conversation context. + // The tool-based loop detectors only check tool-call signatures, so + // they cannot detect this pattern. + let mut recent_text_fingerprints: Vec = Vec::new(); const MAX_FAILED_TOOL_RECOVERY_ATTEMPTS: usize = 3; const MAX_PARTIAL_CONTINUATION_ATTEMPTS: usize = 3; let mut full_compression_count = 0usize; @@ -3533,6 +3565,14 @@ impl ExecutionEngine { full_compression_count += 1; consecutive_compression_failures = 0; send_pressure_reusable = false; + // Clear loop-detection state after compression: the + // compressed context is effectively a fresh start, so + // stale pre-compression tool signatures must not bias + // post-compression loop detection. + recent_tool_signatures.clear(); + recent_failed_tool_signatures.clear(); + failed_tool_recovery_attempts = 0; + recent_text_fingerprints.clear(); } Ok(None) => { debug!("No eligible multi-turn context available for compression"); @@ -3767,6 +3807,13 @@ impl ExecutionEngine { .await; full_compression_count += 1; consecutive_compression_failures = 0; + // Clear loop-detection state after overflow + // recovery compression, same rationale as the + // primary compression path above. + recent_tool_signatures.clear(); + recent_failed_tool_signatures.clear(); + failed_tool_recovery_attempts = 0; + recent_text_fingerprints.clear(); continue; } Ok(None) => { @@ -3880,6 +3927,8 @@ impl ExecutionEngine { if let Some(round_signature) = Self::tool_call_signature(&round_result.tool_calls) { recent_tool_signatures.push(round_signature.clone()); + // The model made tool calls, so it is not in a text-only loop. + recent_text_fingerprints.clear(); if Self::failed_tool_round_signature( &round_result.tool_calls, &round_result.tool_result_messages, @@ -3892,9 +3941,25 @@ impl ExecutionEngine { failed_tool_recovery_attempts = 0; } } else { + // No tool calls in this round. Clear tool-signature tracking + // since the model switched away from tool usage, but track + // text-only responses for text-loop detection. recent_tool_signatures.clear(); recent_failed_tool_signatures.clear(); failed_tool_recovery_attempts = 0; + + // Track text-only response fingerprint for loop detection. + // After context compression, the model may regenerate the + // same text response repeatedly because it lost the full + // conversation context and the compressed summary triggers + // the same output. + if let Some(fingerprint) = + Self::assistant_text_fingerprint(&round_result.assistant_message) + { + recent_text_fingerprints.push(fingerprint); + } else { + recent_text_fingerprints.clear(); + } } let after_round_pressure = Self::estimate_auto_compression_pressure( @@ -4025,6 +4090,28 @@ impl ExecutionEngine { } } + // Text-only loop detection. + // + // After context compression, the model may enter a text-only loop: + // it produces the same assistant text response round after round + // without making any tool calls. The tool-based loop detectors + // above only check tool-call signatures, so they cannot detect + // this pattern. We track fingerprints of text-only responses and + // detect when the same content repeats consecutively, then + // finalize the turn to avoid wasting compute. + if recent_text_fingerprints.len() >= max_consec { + let tail = &recent_text_fingerprints + [recent_text_fingerprints.len() - max_consec..]; + if tail.windows(2).all(|w| w[0] == w[1]) { + warn!( + "Repeated text-only response detected: {} consecutive rounds with identical assistant text, finalizing turn", + max_consec + ); + finalization_reason = Some("repeated_text_responses"); + break; + } + } + // User-steering messages submitted while this turn is running: drain and inject // them as user messages into the working history before starting the next round // (Codex-style mid-turn injection). This does NOT end the current turn: if the