From 68314eee64de812302a68c47a06bade71f451636 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 30 Aug 2026 17:47:11 +0800 Subject: [PATCH 1/3] fix(execution): detect successful repeated tool loops The tool-call loop only had a max_rounds gate, which is disabled when max_rounds=0 (the upstream default for unlimited), so a long-running round could keep calling the same tool with identical arguments many times. Successful repeats were only logged at debug level (log_policy_thresholds's has_repeated_tool_loop), never surfaced as a signal that the model is stuck. Mirror the failed-tool recovery detection onto the successful path: track a consecutive count of identical successful tool signatures (reusing tool_call_signature + repeated_tool_signature_count), exempt rounds that are legitimate read/poll tools (is_legitimate_poll_tool), and inject a LoopRecovery internal_reminder once the effective loop threshold is crossed so the model changes strategy. The deterministic finalize backstop after the recovery cap is added in the follow-up commit. max_rounds=0 stays untouched; this adds a convergence signal, not a new round cap, so long tasks with varied signatures keep running. Test: cargo test -p bitfun-core --features agent-runtime --jobs 4 AI: lightly tested --- .../src/agentic/execution/execution_engine.rs | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) 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 033b552ef8..e268a9cc9b 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -882,6 +882,27 @@ impl ExecutionEngine { Self::tool_call_signature(tool_calls) } + /// Tools that legitimately poll or read and may repeat without indicating a + /// no-progress loop. A round whose calls are all read/poll tools is exempted + /// from successful-repeat convergence detection, but stays subject to the + /// deterministic max-round/exhaustion backstops. A round containing a + /// non-poll (write/execute/mutating) tool with an identical signature is the + /// signal the convergence detector looks for. + fn is_legitimate_poll_tool(tool_calls: &[crate::agentic::core::ToolCall]) -> bool { + tool_calls.iter().all(|tool_call| { + matches!( + tool_call.tool_name.as_str(), + "Read" + | "Grep" + | "Glob" + | "LS" + | "WebSearch" + | "WebFetch" + | "ListModels" + ) + }) + } + /// Whether a partial stream recovery should trigger a continuation round /// instead of treating truncated assistant text as the final answer. /// @@ -3708,6 +3729,9 @@ impl ExecutionEngine { let mut recent_failed_tool_signatures: Vec = Vec::new(); let mut failed_tool_recovery_attempts: usize = 0; const MAX_FAILED_TOOL_RECOVERY_ATTEMPTS: usize = 3; + let mut successful_tool_signature_count: usize = 0; + let mut successful_recovery_attempts: usize = 0; + const MAX_SUCCESSFUL_LOOP_RECOVERY_ATTEMPTS: usize = 3; const MAX_PARTIAL_CONTINUATION_ATTEMPTS: usize = 3; let mut full_compression_count = 0usize; let mut compression_failure_count = 0u32; @@ -4348,14 +4372,18 @@ impl ExecutionEngine { .is_some() { recent_failed_tool_signatures.push(round_signature); + successful_tool_signature_count = 0; } else { recent_failed_tool_signatures.clear(); failed_tool_recovery_attempts = 0; + successful_tool_signature_count = + ContextHealthSnapshot::repeated_tool_signature_count(&recent_tool_signatures); } } else { recent_tool_signatures.clear(); recent_failed_tool_signatures.clear(); failed_tool_recovery_attempts = 0; + successful_tool_signature_count = 0; } let after_round_pressure = Self::estimate_auto_compression_pressure( @@ -4436,6 +4464,48 @@ impl ExecutionEngine { } } + // Successful-tool convergence detection (mirror of the failed-path recovery + // above). A model that keeps calling the same non-read-only tool with identical + // arguments is not making progress even though each call succeeds: inject a + // convergence reminder so it changes strategy. The deterministic backstop that + // finalizes after the cap is enforced in the follow-up branch. + if !Self::is_legitimate_poll_tool(&round_result.tool_calls) + && successful_tool_signature_count >= max_consec + && successful_recovery_attempts < MAX_SUCCESSFUL_LOOP_RECOVERY_ATTEMPTS + { + successful_recovery_attempts += 1; + warn!( + "Repeated successful tool call detected: {} consecutive rounds with identical tool signatures, injecting convergence reminder #{}", + successful_tool_signature_count, successful_recovery_attempts + ); + let reminder = format!( + "Repeated successful tool calls detected: the same tool call with identical arguments has succeeded {} times in a row. \ + This looks like a loop without progress. You MUST now change your strategy: try a different approach, break the task into smaller steps, \ + or stop if the goal is already met.", + successful_tool_signature_count + ); + let user_msg = Message::internal_reminder( + InternalReminderKind::LoopRecovery, + reminder, + ) + .with_turn_id(context.dialog_turn_id.clone()); + messages.push(user_msg.clone()); + self.remember_generation_message( + &context.session_id, + &context.dialog_turn_id, + &user_msg, + ); + if let Err(e) = self + .session_manager + .add_message(&context.session_id, user_msg) + .await + { + warn!("Failed to persist successful-tool recovery reminder: {}", e); + } + recent_tool_signatures.clear(); + successful_tool_signature_count = 0; + } + // Periodic-pattern loop detection. // // The strict consecutive check above only fires on `A-A-A` patterns. @@ -5183,6 +5253,60 @@ mod tests { assert!(reached_fixed_model_round_limit(200, 201)); } + #[test] + fn legitimate_poll_tools_are_exempt_from_successful_loop_detection() { + let read = vec![ToolCall { + tool_id: "call-1".to_string(), + tool_name: "Read".to_string(), + arguments: json!({ "path": "src/main.rs" }), + raw_arguments: None, + is_error: false, + parse_error: None, + recovered_from_truncation: false, + repair_kind: Default::default(), + }]; + assert!(ExecutionEngine::is_legitimate_poll_tool(&read)); + + let mixed_poll = vec![ + ToolCall { + tool_id: "call-1".to_string(), + tool_name: "Grep".to_string(), + arguments: json!({ "pattern": "fn main" }), + raw_arguments: None, + is_error: false, + parse_error: None, + recovered_from_truncation: false, + repair_kind: Default::default(), + }, + ToolCall { + tool_id: "call-2".to_string(), + tool_name: "Glob".to_string(), + arguments: json!({ "pattern": "**/*.rs" }), + raw_arguments: None, + is_error: false, + parse_error: None, + recovered_from_truncation: false, + repair_kind: Default::default(), + }, + ]; + assert!(ExecutionEngine::is_legitimate_poll_tool(&mixed_poll)); + } + + #[test] + fn mutating_tools_are_not_exempt_from_successful_loop_detection() { + let edit = vec![ToolCall { + tool_id: "call-1".to_string(), + tool_name: "Edit".to_string(), + arguments: json!({ "filePath": "src/main.rs" }), + raw_arguments: None, + is_error: false, + parse_error: None, + recovered_from_truncation: false, + repair_kind: Default::default(), + }]; + assert!(!ExecutionEngine::is_legitimate_poll_tool(&edit)); + } + #[test] fn max_rounds_execution_config_projects_the_global_ai_limit() { let mut ai_config = AIConfig::default(); From 646e3ea74009e8658fe3eb4bb3ef4691e03d1d23 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 30 Aug 2026 17:47:21 +0800 Subject: [PATCH 2/3] fix(execution): finalize on successful tool loop cap The convergence reminder is a soft signal; the model may ignore it and keep repeating the same successful tool signature. Add a deterministic backstop for the loop: once successful_recovery_attempts reaches MAX_SUCCESSFUL_LOOP_RECOVERY_ATTEMPTS and the round is still repeating the same non-poll tool signature, finalize without further tool calls. This reuses the existing finalize path via finalization_reason, so the round ends with a response instead of looping indefinitely. Test: cargo test -p bitfun-core --features agent-runtime --jobs 4 AI: lightly tested --- .../src/agentic/execution/execution_engine.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 e268a9cc9b..338c7575bf 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -4506,6 +4506,21 @@ impl ExecutionEngine { successful_tool_signature_count = 0; } + // Deterministic backstop: if the model ignored the convergence reminders and keeps + // repeating the same successful tool signature past the cap, finalize without tools + // instead of looping forever. + if !Self::is_legitimate_poll_tool(&round_result.tool_calls) + && successful_tool_signature_count >= max_consec + && successful_recovery_attempts >= MAX_SUCCESSFUL_LOOP_RECOVERY_ATTEMPTS + { + warn!( + "Repeated successful tool calls exceeded max convergence attempts ({}), finalizing without tools", + MAX_SUCCESSFUL_LOOP_RECOVERY_ATTEMPTS + ); + finalization_reason = Some("repeated_successful_tool_calls"); + break; + } + // Periodic-pattern loop detection. // // The strict consecutive check above only fires on `A-A-A` patterns. From 400bc03551838f232beaf92845f403ceffdf1ffb Mon Sep 17 00:00:00 2001 From: user Date: Sun, 30 Aug 2026 18:23:56 +0800 Subject: [PATCH 3/3] fix(execution): drop dead init in successful loop counter `successful_tool_signature_count` was initialized to 0 at the top of the execution loop, but that initial value is never read: every read is reached through the tool_call_signature if-let chain that re-assigns it on all three branches. rustc flags the initializer as an unused_assignments warning. Drop the dead `= 0` initializer (deferred initialization) and reflow the three rustfmt deviations in the same convergence-detection block so the crate is warning-clean and formatted. The convergence/finalization behavior is unchanged: the counter is still re-assigned before each read on every path. Test: cargo check -p bitfun-core --features agent-runtime (no new warnings; the base `fork_session_for_plugin` dead-code warning remains); 5 execution_engine loop-detection tests pass (successful_loop x2, zero_max_rounds, failed_tool_round_signature x2). AI: light-tested (cargo check + targeted unit tests) --- .../src/agentic/execution/execution_engine.rs | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) 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 338c7575bf..47843828d5 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -892,13 +892,7 @@ impl ExecutionEngine { tool_calls.iter().all(|tool_call| { matches!( tool_call.tool_name.as_str(), - "Read" - | "Grep" - | "Glob" - | "LS" - | "WebSearch" - | "WebFetch" - | "ListModels" + "Read" | "Grep" | "Glob" | "LS" | "WebSearch" | "WebFetch" | "ListModels" ) }) } @@ -3729,7 +3723,7 @@ impl ExecutionEngine { let mut recent_failed_tool_signatures: Vec = Vec::new(); let mut failed_tool_recovery_attempts: usize = 0; const MAX_FAILED_TOOL_RECOVERY_ATTEMPTS: usize = 3; - let mut successful_tool_signature_count: usize = 0; + let mut successful_tool_signature_count: usize; let mut successful_recovery_attempts: usize = 0; const MAX_SUCCESSFUL_LOOP_RECOVERY_ATTEMPTS: usize = 3; const MAX_PARTIAL_CONTINUATION_ATTEMPTS: usize = 3; @@ -4377,7 +4371,9 @@ impl ExecutionEngine { recent_failed_tool_signatures.clear(); failed_tool_recovery_attempts = 0; successful_tool_signature_count = - ContextHealthSnapshot::repeated_tool_signature_count(&recent_tool_signatures); + ContextHealthSnapshot::repeated_tool_signature_count( + &recent_tool_signatures, + ); } } else { recent_tool_signatures.clear(); @@ -4484,11 +4480,9 @@ impl ExecutionEngine { or stop if the goal is already met.", successful_tool_signature_count ); - let user_msg = Message::internal_reminder( - InternalReminderKind::LoopRecovery, - reminder, - ) - .with_turn_id(context.dialog_turn_id.clone()); + let user_msg = + Message::internal_reminder(InternalReminderKind::LoopRecovery, reminder) + .with_turn_id(context.dialog_turn_id.clone()); messages.push(user_msg.clone()); self.remember_generation_message( &context.session_id,