use std::sync::{Arc, Mutex};
use super::convergence::{ConvergenceConfig, ConvergenceDetector, ConvergenceStatus};
use super::loop_detector::{LoopDetector, LoopDetectorConfig, Operation, ToolSignature};
pub use super::convergence::ConvergenceAction;
pub use super::convergence::ConvergenceConfigError;
pub use super::loop_detector::LoopStatus;
#[derive(Debug, Clone)]
pub enum DetectedPattern {
LoopDetected {
repetitions: usize,
pattern_description: String,
},
ConvergenceDetected {
similarity: f32,
consecutive_count: usize,
},
NoPattern,
}
#[derive(Debug, Clone)]
pub struct DetectionConfig {
pub loop_threshold: usize,
pub stop_threshold: usize,
pub enable_loop_detection: bool,
pub max_history: usize,
pub convergence_threshold: f32,
pub convergence_count: usize,
pub enable_convergence_detection: bool,
pub on_converge: ConvergenceAction,
}
impl Default for DetectionConfig {
fn default() -> Self {
Self {
loop_threshold: 3,
stop_threshold: 10,
enable_loop_detection: true,
max_history: 100,
convergence_threshold: 0.95,
convergence_count: 3,
enable_convergence_detection: true,
on_converge: ConvergenceAction::default(),
}
}
}
impl DetectionConfig {
#[must_use]
pub fn to_convergence_config(&self) -> ConvergenceConfig {
ConvergenceConfig {
enabled: self.enable_convergence_detection,
window_size: self.convergence_count,
similarity_threshold: self.convergence_threshold,
on_converge: self.on_converge,
}
}
#[must_use]
pub fn to_loop_detector_config(&self) -> LoopDetectorConfig {
LoopDetectorConfig {
window_size: self.max_history,
repetition_threshold: self.loop_threshold,
stop_threshold: self.stop_threshold,
..LoopDetectorConfig::default()
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DetectionStats {
pub turns_analyzed: usize,
pub loops_detected: usize,
pub convergences_detected: usize,
pub current_streak: usize,
}
pub struct DetectionManager {
config: DetectionConfig,
loop_detector: Arc<LoopDetector>,
convergence_detector: Mutex<ConvergenceDetector>,
stats: Mutex<DetectionStats>,
}
impl DetectionManager {
pub fn new() -> Result<Self, ConvergenceConfigError> {
Self::new_with_config(DetectionConfig::default())
}
pub fn new_with_config(config: DetectionConfig) -> Result<Self, ConvergenceConfigError> {
let loop_detector = Arc::new(LoopDetector::new(
config.to_loop_detector_config(),
Arc::new(super::loop_detector::NoOpToolSignature),
));
let convergence_detector =
Mutex::new(ConvergenceDetector::new(config.to_convergence_config())?);
Ok(Self {
config,
loop_detector,
convergence_detector,
stats: Mutex::new(DetectionStats::default()),
})
}
pub fn new_with_loop_detector(
config: DetectionConfig,
loop_detector: LoopDetector,
) -> Result<Self, ConvergenceConfigError> {
let convergence_detector =
Mutex::new(ConvergenceDetector::new(config.to_convergence_config())?);
Ok(Self {
config,
loop_detector: Arc::new(loop_detector),
convergence_detector,
stats: Mutex::new(DetectionStats::default()),
})
}
pub fn new_with_signature(
signature: Arc<dyn ToolSignature>,
) -> Result<Self, ConvergenceConfigError> {
let config = DetectionConfig::default();
let mut ldc = config.to_loop_detector_config();
ldc.tool_thresholds = signature.tool_thresholds();
let loop_detector = Arc::new(LoopDetector::new(ldc, signature));
let convergence_detector =
Mutex::new(ConvergenceDetector::new(config.to_convergence_config())?);
let stats = Mutex::new(DetectionStats::default());
Ok(Self {
config,
loop_detector,
convergence_detector,
stats,
})
}
pub fn record_operation(
&self,
operation: Operation,
) -> Result<DetectedPattern, crate::error::LoopError> {
if !self.config.enable_loop_detection {
return Ok(DetectedPattern::NoPattern);
}
self.loop_detector.record(operation);
let status = self.loop_detector.check_loop();
if status.should_stop {
self.loop_detector.mark_warned(&status.repeated_operations);
}
if status.is_looping {
let mut guard = self
.stats
.lock()
.map_err(crate::error::from_poison("detection_stats"))?;
guard.turns_analyzed = guard.turns_analyzed.saturating_add(1);
guard.loops_detected = guard.loops_detected.saturating_add(1);
guard.current_streak = status.repetition_count;
Ok(DetectedPattern::LoopDetected {
repetitions: status.repetition_count,
pattern_description: status
.repeated_operations
.first()
.map(|op| format!("{}({})", op.tool, op.primary_param))
.unwrap_or_default(),
})
} else {
let mut guard = self
.stats
.lock()
.map_err(crate::error::from_poison("detection_stats"))?;
guard.turns_analyzed = guard.turns_analyzed.saturating_add(1);
guard.current_streak = self.loop_detector.max_operation_count();
Ok(DetectedPattern::NoPattern)
}
}
pub fn acknowledge_loop_warning(&self, repeated_operations: &[Operation]) {
self.loop_detector.mark_warned(repeated_operations);
}
pub fn signature(&self) -> &dyn ToolSignature {
self.loop_detector.signature()
}
pub fn record_tool_call(
&self,
tool: &str,
input_hash: u64,
) -> Result<DetectedPattern, crate::error::LoopError> {
let operation = Operation::new(tool, format!("hash:{input_hash}"));
self.record_operation(operation)
}
pub fn record_tool_call_with_result(
&self,
tool: &str,
input_hash: u64,
result_hash: Option<u64>,
) -> Result<DetectedPattern, crate::error::LoopError> {
let operation =
Operation::new(tool, format!("hash:{input_hash}")).with_result_hash(result_hash);
self.record_operation(operation)
}
#[must_use]
pub fn check_loop(&self) -> LoopStatus {
self.loop_detector.check_loop()
}
pub fn check_convergence_pattern(&self) -> Result<DetectedPattern, crate::error::LoopError> {
let convergence = self.check_convergence()?;
if convergence.detected {
return Ok(DetectedPattern::ConvergenceDetected {
similarity: convergence.similarity_score,
consecutive_count: convergence.consecutive_count,
});
}
Ok(DetectedPattern::NoPattern)
}
#[must_use]
pub fn check_loop_pattern(&self) -> DetectedPattern {
if !self.config.enable_loop_detection {
return DetectedPattern::NoPattern;
}
let status = self.loop_detector.check_loop();
if status.is_looping {
return DetectedPattern::LoopDetected {
repetitions: status.repetition_count,
pattern_description: status
.repeated_operations
.first()
.map(|op| format!("{}({})", op.tool, op.primary_param))
.unwrap_or_default(),
};
}
DetectedPattern::NoPattern
}
pub fn consume_pending_loop_stop(&self) {
if !self.config.enable_loop_detection {
return;
}
if self.loop_detector.check_loop().should_stop {
self.loop_detector.clear();
}
}
#[must_use]
pub fn loop_detector(&self) -> &LoopDetector {
&self.loop_detector
}
#[must_use]
pub fn convergence_detector(&self) -> &Mutex<ConvergenceDetector> {
&self.convergence_detector
}
pub fn record_response(
&self,
response: &str,
) -> Result<DetectedPattern, crate::error::LoopError> {
if !self.config.enable_convergence_detection {
return Ok(DetectedPattern::NoPattern);
}
let status = self
.convergence_detector
.lock()
.map_err(crate::error::from_poison("convergence_detector"))?
.add_response(response);
if status.detected {
let mut guard = self
.stats
.lock()
.map_err(crate::error::from_poison("detection_stats"))?;
guard.convergences_detected = guard.convergences_detected.saturating_add(1);
return Ok(DetectedPattern::ConvergenceDetected {
similarity: status.similarity_score,
consecutive_count: status.consecutive_count,
});
}
Ok(DetectedPattern::NoPattern)
}
pub fn check_convergence(&self) -> Result<ConvergenceStatus, crate::error::LoopError> {
Ok(self
.convergence_detector
.lock()
.map_err(crate::error::from_poison("convergence_detector"))?
.check_convergence())
}
pub fn check_current_pattern(&self) -> Result<DetectedPattern, crate::error::LoopError> {
if !self.config.enable_loop_detection {
return self.check_convergence_pattern();
}
let loop_status = self.loop_detector.check_loop();
if loop_status.is_looping {
return Ok(DetectedPattern::LoopDetected {
repetitions: loop_status.repetition_count,
pattern_description: loop_status
.repeated_operations
.first()
.map(|op| format!("{}({})", op.tool, op.primary_param))
.unwrap_or_default(),
});
}
self.check_convergence_pattern()
}
pub fn stats(&self) -> Result<DetectionStats, crate::error::LoopError> {
Ok(self
.stats
.lock()
.map_err(crate::error::from_poison("detection_stats"))?
.clone())
}
#[must_use]
pub fn config(&self) -> &DetectionConfig {
&self.config
}
pub fn reset(&self) -> Result<(), crate::error::LoopError> {
self.loop_detector.reset();
self.convergence_detector
.lock()
.map_err(crate::error::from_poison("convergence_detector"))?
.clear();
*self
.stats
.lock()
.map_err(crate::error::from_poison("detection_stats"))? = DetectionStats::default();
Ok(())
}
}
impl Default for DetectionManager {
fn default() -> Self {
let config = DetectionConfig::default();
let loop_config = config.to_loop_detector_config();
let loop_detector = Arc::new(LoopDetector::new(
loop_config,
Arc::new(super::loop_detector::NoOpToolSignature),
));
let convergence_detector = Mutex::new(ConvergenceDetector::default());
Self {
config,
loop_detector,
convergence_detector,
stats: Mutex::new(DetectionStats::default()),
}
}
}
#[cfg(test)]
mod tests {
fn poison_convergence(mgr: &std::sync::Arc<DetectionManager>) {
let mgr = std::sync::Arc::clone(mgr);
assert!(
std::thread::spawn(move || {
let _guard = mgr
.convergence_detector()
.lock()
.expect("lock before poison");
panic!("poison the convergence detector");
})
.join()
.is_err(),
"the poisoning thread must panic"
);
}
fn poison_stats(mgr: &std::sync::Arc<DetectionManager>) {
let mgr = std::sync::Arc::clone(mgr);
assert!(
std::thread::spawn(move || {
let _guard = mgr.stats.lock().expect("lock before poison");
panic!("poison the detection stats");
})
.join()
.is_err(),
"the poisoning thread must panic"
);
}
#[test]
fn record_response_propagates_convergence_poison() {
let mgr = std::sync::Arc::new(DetectionManager::default());
poison_convergence(&mgr);
match mgr.record_response("hello") {
Err(crate::error::LoopError::LockPoisoned { what }) => {
assert_eq!(what, "convergence_detector");
}
other => panic!("convergence poison must propagate: {other:?}"),
}
}
#[test]
fn record_operation_propagates_stats_poison() {
let mgr = std::sync::Arc::new(DetectionManager::default());
poison_stats(&mgr);
match mgr.record_operation(Operation::new("Read", "/f")) {
Err(crate::error::LoopError::LockPoisoned { what }) => {
assert_eq!(what, "detection_stats");
}
other => panic!("stats poison must propagate: {other:?}"),
}
}
#[test]
fn check_convergence_propagates_poison() {
let mgr = std::sync::Arc::new(DetectionManager::default());
poison_convergence(&mgr);
assert!(matches!(
mgr.check_convergence(),
Err(crate::error::LoopError::LockPoisoned { .. })
));
}
#[test]
fn stats_propagates_poison() {
let mgr = std::sync::Arc::new(DetectionManager::default());
poison_stats(&mgr);
assert!(matches!(
mgr.stats(),
Err(crate::error::LoopError::LockPoisoned { .. })
));
}
use super::*;
#[test]
fn test_no_pattern_initially() {
let dm = DetectionManager::new().unwrap();
assert!(matches!(
dm.check_current_pattern().unwrap(),
DetectedPattern::NoPattern
));
}
#[test]
fn test_loop_detection() {
let dm = DetectionManager::new().unwrap();
for _ in 0..5 {
let result = dm.record_tool_call("read_file", 42).unwrap();
if matches!(result, DetectedPattern::LoopDetected { .. }) {
return; }
}
panic!("Expected loop detection after 5 identical calls");
}
#[test]
fn test_convergence_detection() {
let dm = DetectionManager::new().unwrap();
let response = "I am working on the task and making progress";
for _ in 0..5 {
let result = dm.record_response(response).unwrap();
if matches!(result, DetectedPattern::ConvergenceDetected { .. }) {
return;
}
}
panic!("Expected convergence detection after 5 identical responses");
}
#[test]
fn test_reset() {
let dm = DetectionManager::new().unwrap();
dm.record_tool_call("read_file", 42).unwrap();
dm.record_response("hello").unwrap();
dm.reset().unwrap();
assert!(matches!(
dm.check_current_pattern().unwrap(),
DetectedPattern::NoPattern
));
let stats = dm.stats().unwrap();
assert_eq!(stats.turns_analyzed, 0);
}
#[test]
fn test_no_detection_when_disabled() {
let config = DetectionConfig {
enable_loop_detection: false,
enable_convergence_detection: false,
..Default::default()
};
let dm = DetectionManager::new_with_config(config).unwrap();
for _ in 0..10 {
let result = dm.record_tool_call("read_file", 42).unwrap();
assert!(matches!(result, DetectedPattern::NoPattern));
}
}
#[test]
fn test_loop_status_should_stop() {
let config = DetectionConfig {
loop_threshold: 3,
stop_threshold: 5,
..Default::default()
};
let dm = DetectionManager::new_with_config(config).unwrap();
for _ in 0..5 {
dm.record_tool_call("read_file", 42).unwrap();
}
let status = dm.check_loop();
assert!(status.is_looping);
assert!(status.should_stop);
assert!(status.warning.is_some());
assert!(status.warning.unwrap().contains("STOPPING"));
}
#[test]
fn test_record_operation_with_operation_struct() {
let dm = DetectionManager::new().unwrap();
for _ in 0..5 {
dm.record_operation(Operation::new("Read", "/test/file.txt"))
.unwrap();
}
let status = dm.check_loop();
assert!(status.is_looping);
assert!(status.repetition_count >= 3);
}
#[test]
fn test_record_operation_from_input() {
use super::super::loop_detector::NoOpToolSignature;
let dm = DetectionManager::new().unwrap();
let input = serde_json::json!({"file_path": "/test/file.txt"});
for _ in 0..5 {
dm.record_operation(Operation::from_input_with_signature(
"Read",
&input,
&NoOpToolSignature,
))
.unwrap();
}
let status = dm.check_loop();
assert!(status.is_looping);
}
#[test]
fn test_result_aware_detection() {
let dm = DetectionManager::new().unwrap();
for i in 0..5 {
let hash = super::super::loop_detector::hash_result(&format!("output {i}"));
dm.record_tool_call_with_result("Bash", 42, hash).unwrap();
}
let status = dm.check_loop();
assert!(!status.is_looping, "Different results should not be a loop");
}
#[test]
fn test_result_aware_same_result_is_loop() {
let dm = DetectionManager::new().unwrap();
let hash = super::super::loop_detector::hash_result("same output");
for _ in 0..5 {
dm.record_tool_call_with_result("Bash", 42, hash).unwrap();
}
let status = dm.check_loop();
assert!(status.is_looping, "Same results should be detected as loop");
}
#[test]
fn test_access_loop_detector() {
let dm = DetectionManager::new().unwrap();
assert_eq!(dm.loop_detector().turn_count(), 0);
}
#[test]
fn test_config_to_loop_detector_config() {
let config = DetectionConfig {
loop_threshold: 5,
stop_threshold: 15,
max_history: 200,
..Default::default()
};
let ldc = config.to_loop_detector_config();
assert_eq!(ldc.repetition_threshold, 5);
assert_eq!(ldc.stop_threshold, 15);
assert_eq!(ldc.window_size, 200);
}
#[test]
fn test_detection_config_default_has_no_max_response_history() {
let config = DetectionConfig {
loop_threshold: 3,
stop_threshold: 5,
..Default::default()
};
assert_eq!(config.loop_threshold, 3);
assert_eq!(config.stop_threshold, 5);
let default = DetectionConfig::default();
assert_eq!(default.loop_threshold, 3);
assert_eq!(default.stop_threshold, 10);
assert!(default.enable_loop_detection);
assert_eq!(default.max_history, 100);
assert!((default.convergence_threshold - 0.95).abs() < f32::EPSILON);
assert_eq!(default.convergence_count, 3);
assert!(default.enable_convergence_detection);
}
#[test]
fn check_loop_pattern_respects_disabled_loop_detection() {
let config = DetectionConfig {
enable_loop_detection: false,
..DetectionConfig::default()
};
let manager = DetectionManager::new_with_config(config).unwrap();
for _ in 0..12 {
manager
.record_operation(Operation::new("Read", "/f.txt"))
.unwrap();
}
assert!(
matches!(manager.check_loop_pattern(), DetectedPattern::NoPattern),
"with loop detection disabled the pure query must never report a loop"
);
}
#[test]
fn check_loop_does_not_consume_the_warning() {
let manager = DetectionManager::new().unwrap();
for _ in 0..3 {
manager
.record_operation(Operation::new("Read", "/f.txt"))
.unwrap();
}
let first = manager.check_loop();
let second = manager.check_loop();
assert!(first.warning.is_some(), "first poll must warn");
assert!(
second.warning.is_some(),
"doc: any observability layer may poll without side effects"
);
}
#[test]
fn acknowledge_loop_warning_suppresses_rebuilds() {
let manager = DetectionManager::new().unwrap();
for _ in 0..3 {
manager
.record_operation(Operation::new("Read", "/f.txt"))
.unwrap();
}
let delivered = manager.check_loop();
let repeated = delivered.repeated_operations.clone();
assert!(
delivered.warning.is_some(),
"precondition: a warning is pending"
);
manager.acknowledge_loop_warning(&repeated);
let after = manager.check_loop();
assert!(
after.warning.is_none(),
"an acknowledged warning must not rebuild while the pattern is unchanged"
);
assert!(
after.is_looping,
"the acknowledgement suppresses the warning only — the pattern itself is still live"
);
}
#[test]
fn current_streak_tracks_below_the_threshold_and_after_recovery() {
let manager = DetectionManager::new_with_config(DetectionConfig {
loop_threshold: 4,
stop_threshold: 10,
..DetectionConfig::default()
})
.unwrap();
manager
.record_operation(Operation::new("Read", "/f.txt"))
.unwrap();
assert_eq!(
manager.stats().unwrap().current_streak,
1,
"the streak must be live in the build-up phase, not frozen at zero"
);
manager
.record_operation(Operation::new("Read", "/f.txt"))
.unwrap();
assert_eq!(manager.stats().unwrap().current_streak, 2);
manager
.record_operation(Operation::new("Read", "/other.txt"))
.unwrap();
assert_eq!(
manager.stats().unwrap().current_streak,
2,
"an unrelated operation leaves the most-repeated count alone — \
the streak is the window's live maximum"
);
}
#[test]
fn consume_pending_loop_stop_clears_only_at_the_stop_threshold() {
let manager = DetectionManager::new_with_config(DetectionConfig {
loop_threshold: 2,
stop_threshold: 5,
..DetectionConfig::default()
})
.unwrap();
for _ in 0..3 {
manager
.record_operation(Operation::new("Read", "/f.txt"))
.unwrap();
}
manager.consume_pending_loop_stop();
let below = manager.check_loop_pattern();
assert!(
matches!(&below, DetectedPattern::LoopDetected { repetitions: 3, .. }),
"below the stop threshold the window must survive — it may still warn: {below:?}"
);
for _ in 0..2 {
manager
.record_operation(Operation::new("Read", "/f.txt"))
.unwrap();
}
manager.consume_pending_loop_stop();
assert!(
matches!(manager.check_loop_pattern(), DetectedPattern::NoPattern),
"at the stop threshold the never-fired stop state must be consumed"
);
}
}