Download crates/forge_app/src/hooks/doom_loop.rs from SaylorTwift/forgecode: direct link, hf CLI and curl.
- Browser
- Download file 32.2 kB
-
https://huggingface.co/SaylorTwift/forgecode/resolve/main/crates/forge_app/src/hooks/doom_loop.rs
- Command line
-
hf download hf://SaylorTwift/forgecode/crates/forge_app/src/hooks/doom_loop.rs
-
curl -L -o doom_loop.rs https://huggingface.co/SaylorTwift/forgecode/resolve/main/crates/forge_app/src/hooks/doom_loop.rs
32.2 kB
| use async_trait::async_trait; | |
| use derive_setters::Setters; | |
| use forge_domain::{ | |
| ContextMessage, Conversation, EventData, EventHandle, RequestPayload, Role, TextMessage, | |
| ToolCallArguments, ToolName, | |
| }; | |
| use forge_template::Element; | |
| use tracing::warn; | |
| use crate::TemplateEngine; | |
| /// Detector for identifying doom loops - when tool calls form repetitive | |
| /// patterns | |
| /// | |
| /// This detector analyzes conversation history to identify two types of loops: | |
| /// 1. Consecutive identical calls: [A,A,A,A] - same tool with same arguments | |
| /// 2. Repeating patterns: [A,B,C][A,B,C][A,B,C] - sequence of calls repeating | |
| /// | |
| /// Both patterns indicate the agent is stuck in a loop, wasting tokens without | |
| /// making progress. | |
| /// | |
| /// Can be used as a hook on `on_request` events to detect doom loops after | |
| /// tool call records from prior turns are already persisted in context. | |
| pub struct DoomLoopDetector { | |
| /// Threshold for consecutive identical tool calls before triggering | |
| /// detection | |
| threshold: usize, | |
| } | |
| impl Default for DoomLoopDetector { | |
| fn default() -> Self { | |
| Self::new() | |
| } | |
| } | |
| impl DoomLoopDetector { | |
| const DEFAULT_THRESHOLD: usize = 3; | |
| /// Creates a new doom loop detector with the default threshold | |
| pub fn new() -> Self { | |
| Self { threshold: Self::DEFAULT_THRESHOLD } | |
| } | |
| /// Checks conversation history for doom loops using already-recorded tool | |
| /// call history in context. | |
| /// | |
| /// This variant is intended for request-phase hooks, where tool call | |
| /// results from the previous turn have already been appended to context. | |
| /// | |
| /// Returns Some(count) if a doom loop is detected | |
| pub fn detect_from_conversation(&self, conversation: &Conversation) -> Option<usize> { | |
| let all_signatures = self.extract_tool_signatures(conversation); | |
| let (_, count) = self.check_repeating_pattern(&all_signatures)?; | |
| Some(count) | |
| } | |
| fn extract_tool_signatures( | |
| &self, | |
| conversation: &Conversation, | |
| ) -> Vec<(ToolName, ToolCallArguments)> { | |
| let assistant_messages = conversation | |
| .context | |
| .as_ref() | |
| .map(|ctx| { | |
| Self::extract_assistant_messages(ctx.messages.iter().map(|entry| &entry.message)) | |
| }) | |
| .unwrap_or_default(); | |
| assistant_messages | |
| .iter() | |
| .filter_map(|msg| msg.tool_calls.as_ref()) | |
| .flat_map(|calls| calls.iter()) | |
| .map(|call| (call.name.clone(), call.arguments.clone())) | |
| .collect() | |
| } | |
| /// Checks for repeating patterns at the end of the sequence. | |
| fn check_repeating_pattern<T>(&self, sequence: &[T]) -> Option<(usize, usize)> | |
| where | |
| T: Eq, | |
| { | |
| if sequence.is_empty() { | |
| return None; | |
| } | |
| if sequence.len() < self.threshold { | |
| return None; | |
| } | |
| for pattern_length in 1..sequence.len() { | |
| let complete_repetitions = | |
| self.count_recent_pattern_repetitions(sequence, pattern_length); | |
| if complete_repetitions >= self.threshold { | |
| let pattern_offset = complete_repetitions.checked_mul(pattern_length)?; | |
| let pattern_start_idx = sequence.len().checked_sub(pattern_offset)?; | |
| if sequence.get(pattern_start_idx).is_some() { | |
| return Some((pattern_start_idx, complete_repetitions)); | |
| } | |
| } | |
| } | |
| None | |
| } | |
| /// Counts how many times a pattern of given length repeats at the END of | |
| /// the sequence | |
| /// | |
| /// This works backwards from the most recent calls to find repeating | |
| /// patterns, which allows detecting new patterns even if earlier | |
| /// patterns existed. For example, in [1,2,3,1,2,3,4,5,4,5,4,5], this | |
| /// will detect [4,5] repeating 3 times. | |
| fn count_recent_pattern_repetitions<T>(&self, sequence: &[T], pattern_length: usize) -> usize | |
| where | |
| T: Eq, | |
| { | |
| if pattern_length == 0 || sequence.len() < pattern_length { | |
| return 0; | |
| } | |
| // Start from the end and work backwards | |
| let total_len = sequence.len(); | |
| let mut repetitions = 0; | |
| // The pattern is defined by the last pattern_length elements | |
| // For a partial match, we consider it as the start of a new repetition | |
| let mut check_len = total_len; | |
| // Special case: if total length is not evenly divisible by pattern_length, | |
| // we have a partial match at the end | |
| if !total_len.is_multiple_of(pattern_length) { | |
| let partial_len = total_len % pattern_length; | |
| // Check if the partial segment matches the start of what would be the pattern | |
| // We need to look back to find what the pattern would be | |
| if total_len < pattern_length + partial_len { | |
| return 0; | |
| } | |
| let pattern_start = total_len - partial_len - pattern_length; | |
| let pattern_end = pattern_start + pattern_length; | |
| let partial_start = total_len - partial_len; | |
| let Some(pattern) = sequence.get(pattern_start..pattern_end) else { | |
| return 0; | |
| }; | |
| let Some(partial) = sequence.get(partial_start..total_len) else { | |
| return 0; | |
| }; | |
| let Some(pattern_prefix) = pattern.get(..partial_len) else { | |
| return 0; | |
| }; | |
| if partial == pattern_prefix { | |
| repetitions += 1; | |
| check_len = total_len - partial_len; | |
| } else { | |
| // Partial doesn't match, no pattern | |
| return 0; | |
| } | |
| } | |
| // Now check complete repetitions working backwards | |
| if check_len < pattern_length { | |
| return repetitions; | |
| } | |
| // The pattern is the last complete chunk | |
| let pattern_start = check_len - pattern_length; | |
| let Some(pattern) = sequence.get(pattern_start..check_len) else { | |
| return repetitions; | |
| }; | |
| repetitions += 1; // Count the pattern itself | |
| // Check backwards for more repetitions | |
| let mut pos = pattern_start; | |
| while pos >= pattern_length { | |
| pos -= pattern_length; | |
| let Some(chunk) = sequence.get(pos..pos + pattern_length) else { | |
| break; | |
| }; | |
| if chunk == pattern { | |
| repetitions += 1; | |
| } else { | |
| // Pattern broken, stop counting | |
| break; | |
| } | |
| } | |
| repetitions | |
| } | |
| /// Extracts assistant messages from context messages | |
| /// | |
| /// Helper method to filter assistant messages from a conversation context | |
| pub fn extract_assistant_messages<'a>( | |
| messages: impl Iterator<Item = &'a ContextMessage> + 'a, | |
| ) -> Vec<&'a TextMessage> { | |
| messages | |
| .filter_map(|msg| { | |
| if let ContextMessage::Text(text_msg) = msg | |
| && text_msg.role == Role::Assistant | |
| { | |
| return Some(text_msg); | |
| } | |
| None | |
| }) | |
| .collect() | |
| } | |
| } | |
| /// Implementation of EventHandle for DoomLoopDetector | |
| /// | |
| /// This allows the detector to run on request events so the previous turn's | |
| /// tool calls and results are already appended in context before reminders are | |
| /// inserted. | |
| impl EventHandle<EventData<RequestPayload>> for DoomLoopDetector { | |
| async fn handle( | |
| &self, | |
| event: &EventData<RequestPayload>, | |
| conversation: &mut Conversation, | |
| ) -> anyhow::Result<()> { | |
| if let Some(consecutive_calls) = self.detect_from_conversation(conversation) { | |
| warn!( | |
| agent_id = %event.agent.id, | |
| request_count = event.payload.request_count, | |
| consecutive_calls, | |
| "Doom loop detected from conversation context before next request" | |
| ); | |
| if let Some(context) = conversation.context.as_mut() { | |
| let reminder = TemplateEngine::default().render( | |
| "forge-doom-loop-reminder.md", | |
| &serde_json::json!({"consecutive_calls": consecutive_calls}), | |
| )?; | |
| let content = Element::new("system_reminder").cdata(reminder); | |
| context | |
| .messages | |
| .push(ContextMessage::user(content, None).into()); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| } | |
| mod tests { | |
| use forge_domain::{ | |
| Context, ContextMessage, ConversationId, MessageEntry, ToolCallArguments, ToolCallFull, | |
| }; | |
| use pretty_assertions::assert_eq; | |
| use super::*; | |
| fn create_assistant_message(tool_call: &ToolCallFull) -> TextMessage { | |
| TextMessage { | |
| role: Role::Assistant, | |
| content: String::new(), | |
| raw_content: None, | |
| tool_calls: Some(vec![tool_call.clone()]), | |
| thought_signature: None, | |
| model: None, | |
| reasoning_details: None, | |
| droppable: false, | |
| phase: None, | |
| } | |
| } | |
| fn create_conversation_with_messages(messages: Vec<TextMessage>) -> Conversation { | |
| let context_messages: Vec<MessageEntry> = messages | |
| .into_iter() | |
| .map(|msg| MessageEntry::from(ContextMessage::Text(msg))) | |
| .collect(); | |
| let context = Context::default().messages(context_messages); | |
| Conversation { | |
| id: ConversationId::generate(), | |
| title: None, | |
| context: Some(context), | |
| metrics: Default::default(), | |
| metadata: forge_domain::MetaData::new(chrono::Utc::now()), | |
| } | |
| } | |
| fn test_doom_loop_detector_detects_identical_calls() { | |
| let detector = DoomLoopDetector::new(); | |
| let tool_call = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file.txt"}"#)); | |
| // Build history with repeated calls | |
| let msg1 = create_assistant_message(&tool_call); | |
| let msg2 = create_assistant_message(&tool_call); | |
| let msg3 = create_assistant_message(&tool_call); | |
| let conversation = create_conversation_with_messages(vec![msg1, msg2, msg3]); | |
| // Third call - doom loop detected! | |
| let actual = detector.detect_from_conversation(&conversation); | |
| let expected = Some(3); | |
| assert_eq!(actual, expected); | |
| } | |
| fn test_doom_loop_detector_no_loop_with_two_calls() { | |
| let detector = DoomLoopDetector::new(); | |
| let tool_call = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file.txt"}"#)); | |
| // Build history with one call | |
| let msg1 = create_assistant_message(&tool_call); | |
| let conversation = create_conversation_with_messages(vec![msg1]); | |
| // Second call - no loop yet (need 3 for default threshold) | |
| let actual = detector.detect_from_conversation(&conversation); | |
| assert_eq!(actual, None); | |
| } | |
| fn test_doom_loop_detector_resets_on_different_arguments() { | |
| let detector = DoomLoopDetector::new(); | |
| let tool_call_1 = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file1.txt"}"#)); | |
| let tool_call_2 = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file2.txt"}"#)); | |
| // Build history with two calls of first arguments, then different | |
| let msg1 = create_assistant_message(&tool_call_1); | |
| let msg2 = create_assistant_message(&tool_call_1); | |
| let msg3 = create_assistant_message(&tool_call_2); | |
| let conversation = create_conversation_with_messages(vec![msg1, msg2, msg3]); | |
| // Call with first arguments again - should not detect loop | |
| let actual = detector.detect_from_conversation(&conversation); | |
| assert_eq!(actual, None); | |
| } | |
| fn test_doom_loop_detector_resets_on_different_tool() { | |
| let detector = DoomLoopDetector::new(); | |
| let tool_call_1 = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file.txt"}"#)); | |
| let tool_call_2 = ToolCallFull::new("write") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file.txt"}"#)); | |
| // Build history with two same tool calls, then different tool | |
| let msg1 = create_assistant_message(&tool_call_1); | |
| let msg2 = create_assistant_message(&tool_call_1); | |
| let msg3 = create_assistant_message(&tool_call_2); | |
| let conversation = create_conversation_with_messages(vec![msg1, msg2, msg3]); | |
| // Call different tool - should not detect loop | |
| let actual = detector.detect_from_conversation(&conversation); | |
| assert_eq!(actual, None); | |
| } | |
| fn test_doom_loop_detector_custom_threshold() { | |
| let detector = DoomLoopDetector::new().threshold(2); | |
| let tool_call = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file.txt"}"#)); | |
| // Build history with one call | |
| let msg1 = create_assistant_message(&tool_call); | |
| let msg2 = create_assistant_message(&tool_call); | |
| let conversation = create_conversation_with_messages(vec![msg1, msg2]); | |
| // Second call - doom loop detected with threshold of 2! | |
| let actual = detector.detect_from_conversation(&conversation); | |
| let expected = Some(2); | |
| assert_eq!(actual, expected); | |
| } | |
| fn test_doom_loop_detector_empty_history() { | |
| let detector = DoomLoopDetector::new(); | |
| // Empty history - first call, no loop | |
| let conversation = create_conversation_with_messages(vec![]); | |
| let actual = detector.detect_from_conversation(&conversation); | |
| assert_eq!(actual, None); | |
| } | |
| fn test_extract_assistant_messages() { | |
| let assistant_msg_1 = TextMessage { | |
| role: Role::Assistant, | |
| content: "Response 1".to_string(), | |
| raw_content: None, | |
| tool_calls: None, | |
| thought_signature: None, | |
| model: None, | |
| reasoning_details: None, | |
| droppable: false, | |
| phase: None, | |
| }; | |
| let user_msg = TextMessage { | |
| role: Role::User, | |
| content: "Question".to_string(), | |
| raw_content: None, | |
| tool_calls: None, | |
| thought_signature: None, | |
| model: None, | |
| reasoning_details: None, | |
| droppable: false, | |
| phase: None, | |
| }; | |
| let assistant_msg_2 = TextMessage { | |
| role: Role::Assistant, | |
| content: "Response 2".to_string(), | |
| raw_content: None, | |
| tool_calls: None, | |
| thought_signature: None, | |
| model: None, | |
| reasoning_details: None, | |
| droppable: false, | |
| phase: None, | |
| }; | |
| let messages = [ | |
| ContextMessage::Text(assistant_msg_1.clone()), | |
| ContextMessage::Text(user_msg), | |
| ContextMessage::Text(assistant_msg_2.clone()), | |
| ]; | |
| let result = DoomLoopDetector::extract_assistant_messages(messages.iter()); | |
| assert_eq!(result.len(), 2); | |
| assert_eq!(result[0].content, "Response 1"); | |
| assert_eq!(result[1].content, "Response 2"); | |
| } | |
| fn test_detect_pattern_start_with_integers_for_123_123_123() { | |
| let detector = DoomLoopDetector::new(); | |
| let fixture = vec![1, 2, 3, 1, 2, 3, 1, 2, 3]; | |
| let actual = detector.check_repeating_pattern(&fixture); | |
| let expected = Some((0, 3)); | |
| assert_eq!(actual, expected); | |
| } | |
| fn test_detect_pattern_start_with_integers_detects_recent_suffix_pattern() { | |
| let detector = DoomLoopDetector::new(); | |
| let fixture = vec![1, 2, 3, 1, 2, 3, 4, 5, 4, 5, 4, 5]; | |
| let actual = detector.check_repeating_pattern(&fixture); | |
| let expected = Some((6, 3)); | |
| assert_eq!(actual, expected); | |
| } | |
| fn test_detect_pattern_start_with_integers_detects_consecutive_identical() { | |
| let detector = DoomLoopDetector::new(); | |
| let fixture = vec![1, 2, 3, 3, 3]; | |
| let actual = detector.check_repeating_pattern(&fixture); | |
| let expected = Some((2, 3)); | |
| assert_eq!(actual, expected); | |
| } | |
| fn test_doom_loop_detector_detects_repeating_pattern_123_123_123() { | |
| let detector = DoomLoopDetector::new(); | |
| let tool_call_1 = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file1.txt"}"#)); | |
| let tool_call_2 = ToolCallFull::new("write") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file2.txt"}"#)); | |
| let tool_call_3 = ToolCallFull::new("patch") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file3.txt"}"#)); | |
| // Build history with pattern [1,2,3][1,2,3][1,2,3] | |
| let msg1 = create_assistant_message(&tool_call_1); | |
| let msg2 = create_assistant_message(&tool_call_2); | |
| let msg3 = create_assistant_message(&tool_call_3); | |
| let msg4 = create_assistant_message(&tool_call_1); | |
| let msg5 = create_assistant_message(&tool_call_2); | |
| let msg6 = create_assistant_message(&tool_call_3); | |
| let msg7 = create_assistant_message(&tool_call_1); | |
| let msg8 = create_assistant_message(&tool_call_2); | |
| let msg9 = create_assistant_message(&tool_call_3); | |
| let conversation = create_conversation_with_messages(vec![ | |
| msg1, msg2, msg3, msg4, msg5, msg6, msg7, msg8, msg9, | |
| ]); | |
| let actual = detector.detect_from_conversation(&conversation); | |
| // Should detect pattern repetition (3 times) | |
| assert_eq!(actual, Some(3)); | |
| } | |
| fn test_doom_loop_detector_detects_repeating_pattern_12_12_12() { | |
| let detector = DoomLoopDetector::new(); | |
| let tool_call_1 = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file1.txt"}"#)); | |
| let tool_call_2 = ToolCallFull::new("write") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file2.txt"}"#)); | |
| // Build history with pattern [1,2][1,2][1,2] | |
| let msg1 = create_assistant_message(&tool_call_1); | |
| let msg2 = create_assistant_message(&tool_call_2); | |
| let msg3 = create_assistant_message(&tool_call_1); | |
| let msg4 = create_assistant_message(&tool_call_2); | |
| let msg5 = create_assistant_message(&tool_call_1); | |
| let msg6 = create_assistant_message(&tool_call_2); | |
| let conversation = | |
| create_conversation_with_messages(vec![msg1, msg2, msg3, msg4, msg5, msg6]); | |
| let actual = detector.detect_from_conversation(&conversation); | |
| // Should detect pattern repetition (3 times) | |
| assert_eq!(actual, Some(3)); | |
| } | |
| fn test_doom_loop_detector_no_pattern_with_partial_repetition() { | |
| let detector = DoomLoopDetector::new(); | |
| let tool_call_1 = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file1.txt"}"#)); | |
| let tool_call_2 = ToolCallFull::new("write") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file2.txt"}"#)); | |
| let tool_call_3 = ToolCallFull::new("patch") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file3.txt"}"#)); | |
| // Build history with pattern [1,2,3][1,2] - incomplete repetition | |
| let msg1 = create_assistant_message(&tool_call_1); | |
| let msg2 = create_assistant_message(&tool_call_2); | |
| let msg3 = create_assistant_message(&tool_call_3); | |
| let msg4 = create_assistant_message(&tool_call_1); | |
| let msg5 = create_assistant_message(&tool_call_2); | |
| let conversation = create_conversation_with_messages(vec![msg1, msg2, msg3, msg4, msg5]); | |
| // Current call would not complete a full third repetition | |
| let actual = detector.detect_from_conversation(&conversation); | |
| // Should not detect pattern (incomplete) | |
| assert_eq!(actual, None); | |
| } | |
| fn test_doom_loop_detector_pattern_with_custom_threshold() { | |
| let detector = DoomLoopDetector::new().threshold(2); | |
| let tool_call_1 = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file1.txt"}"#)); | |
| let tool_call_2 = ToolCallFull::new("write") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file2.txt"}"#)); | |
| // Build history with pattern [1,2][1,2] | |
| let msg1 = create_assistant_message(&tool_call_1); | |
| let msg2 = create_assistant_message(&tool_call_2); | |
| let msg3 = create_assistant_message(&tool_call_1); | |
| let msg4 = create_assistant_message(&tool_call_2); | |
| let conversation = create_conversation_with_messages(vec![msg1, msg2, msg3, msg4]); | |
| let actual = detector.detect_from_conversation(&conversation); | |
| // Should detect pattern with threshold of 2 | |
| assert_eq!(actual, Some(2)); | |
| } | |
| fn test_doom_loop_detector_consecutive_identical_takes_precedence() { | |
| let detector = DoomLoopDetector::new(); | |
| let tool_call_1 = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file1.txt"}"#)); | |
| // Build history with three consecutive identical calls | |
| let msg1 = create_assistant_message(&tool_call_1); | |
| let msg2 = create_assistant_message(&tool_call_1); | |
| let msg3 = create_assistant_message(&tool_call_1); | |
| let conversation = create_conversation_with_messages(vec![msg1, msg2, msg3]); | |
| // Third consecutive identical call - should be caught by consecutive check | |
| let actual = detector.detect_from_conversation(&conversation); | |
| assert_eq!(actual, Some(3)); | |
| } | |
| fn test_doom_loop_detector_complex_pattern_1234_1234_1234() { | |
| let detector = DoomLoopDetector::new(); | |
| let tool_call_1 = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file1.txt"}"#)); | |
| let tool_call_2 = ToolCallFull::new("write") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file2.txt"}"#)); | |
| let tool_call_3 = ToolCallFull::new("patch") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file3.txt"}"#)); | |
| let tool_call_4 = ToolCallFull::new("shell") | |
| .arguments(ToolCallArguments::from_json(r#"{"command": "ls"}"#)); | |
| // Build history with pattern [1,2,3,4][1,2,3,4][1,2,3,4] | |
| let msg1 = create_assistant_message(&tool_call_1); | |
| let msg2 = create_assistant_message(&tool_call_2); | |
| let msg3 = create_assistant_message(&tool_call_3); | |
| let msg4 = create_assistant_message(&tool_call_4); | |
| let msg5 = create_assistant_message(&tool_call_1); | |
| let msg6 = create_assistant_message(&tool_call_2); | |
| let msg7 = create_assistant_message(&tool_call_3); | |
| let msg8 = create_assistant_message(&tool_call_4); | |
| let msg9 = create_assistant_message(&tool_call_1); | |
| let msg10 = create_assistant_message(&tool_call_2); | |
| let msg11 = create_assistant_message(&tool_call_3); | |
| let msg12 = create_assistant_message(&tool_call_4); | |
| let conversation = create_conversation_with_messages(vec![ | |
| msg1, msg2, msg3, msg4, msg5, msg6, msg7, msg8, msg9, msg10, msg11, msg12, | |
| ]); | |
| let actual = detector.detect_from_conversation(&conversation); | |
| // Should detect pattern repetition (3 times) | |
| assert_eq!(actual, Some(3)); | |
| } | |
| fn test_doom_loop_detector_real_world_scenario() { | |
| let detector = DoomLoopDetector::new(); | |
| // Simulate a real-world loop: read file, check diagnostics, patch file | |
| let read_call = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "src/main.rs"}"#)); | |
| let diagnostics_call = ToolCallFull::new("mcp_forge_extension_tool_get_diagnostics") | |
| .arguments(ToolCallArguments::from_json(r#"{"severity": "error"}"#)); | |
| let patch_call = ToolCallFull::new("patch").arguments(ToolCallArguments::from_json( | |
| r#"{"path": "src/main.rs", "old": "foo", "new": "bar"}"#, | |
| )); | |
| // Create pattern [read, diagnostics, patch] repeated three times | |
| let msg1 = create_assistant_message(&read_call); | |
| let msg2 = create_assistant_message(&diagnostics_call); | |
| let msg3 = create_assistant_message(&patch_call); | |
| let msg4 = create_assistant_message(&read_call); | |
| let msg5 = create_assistant_message(&diagnostics_call); | |
| let msg6 = create_assistant_message(&patch_call); | |
| let msg7 = create_assistant_message(&read_call); | |
| let msg8 = create_assistant_message(&diagnostics_call); | |
| let msg9 = create_assistant_message(&patch_call); | |
| let conversation = create_conversation_with_messages(vec![ | |
| msg1, msg2, msg3, msg4, msg5, msg6, msg7, msg8, msg9, | |
| ]); | |
| let actual = detector.detect_from_conversation(&conversation); | |
| // Should detect the pattern loop | |
| assert_eq!(actual, Some(3)); | |
| } | |
| fn test_doom_loop_detector_pattern_changes_midway_123123454545() { | |
| let detector = DoomLoopDetector::new(); | |
| let tool_call_1 = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file1.txt"}"#)); | |
| let tool_call_2 = ToolCallFull::new("write") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file2.txt"}"#)); | |
| let tool_call_3 = ToolCallFull::new("patch") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file3.txt"}"#)); | |
| let tool_call_4 = ToolCallFull::new("shell") | |
| .arguments(ToolCallArguments::from_json(r#"{"command": "ls"}"#)); | |
| let tool_call_5 = ToolCallFull::new("fs_search") | |
| .arguments(ToolCallArguments::from_json(r#"{"pattern": "test"}"#)); | |
| // Build history with pattern [1,2,3][1,2,3] then [4,5][4,5][4,5] | |
| // Pattern: 123123454545 | |
| let msg1 = create_assistant_message(&tool_call_1); | |
| let msg2 = create_assistant_message(&tool_call_2); | |
| let msg3 = create_assistant_message(&tool_call_3); | |
| let msg4 = create_assistant_message(&tool_call_1); | |
| let msg5 = create_assistant_message(&tool_call_2); | |
| let msg6 = create_assistant_message(&tool_call_3); | |
| let msg7 = create_assistant_message(&tool_call_4); | |
| let msg8 = create_assistant_message(&tool_call_5); | |
| let msg9 = create_assistant_message(&tool_call_4); | |
| let msg10 = create_assistant_message(&tool_call_5); | |
| let msg11 = create_assistant_message(&tool_call_4); | |
| let msg12 = create_assistant_message(&tool_call_5); | |
| let conversation = create_conversation_with_messages(vec![ | |
| msg1, msg2, msg3, msg4, msg5, msg6, msg7, msg8, msg9, msg10, msg11, msg12, | |
| ]); | |
| // [4,5][4,5][4,5] now fully exists at the end | |
| let actual = detector.detect_from_conversation(&conversation); | |
| // Should detect the [4,5][4,5][4,5] pattern at the end | |
| // The detector looks for the longest repeating pattern, starting from the most | |
| // recent calls | |
| // The pattern [4,5] repeats 3 times at the end | |
| assert_eq!(actual, Some(3)); | |
| } | |
| fn test_doom_loop_detector_sequence_1234546454545_step_by_step() { | |
| let detector = DoomLoopDetector::new(); | |
| // Define the 6 unique tool calls | |
| let tool_1 = ToolCallFull::new("read") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file1.txt"}"#)); | |
| let tool_2 = ToolCallFull::new("write") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file2.txt"}"#)); | |
| let tool_3 = ToolCallFull::new("patch") | |
| .arguments(ToolCallArguments::from_json(r#"{"path": "file3.txt"}"#)); | |
| let tool_4 = ToolCallFull::new("shell") | |
| .arguments(ToolCallArguments::from_json(r#"{"command": "ls"}"#)); | |
| let tool_5 = ToolCallFull::new("fs_search") | |
| .arguments(ToolCallArguments::from_json(r#"{"pattern": "test"}"#)); | |
| let tool_6 = ToolCallFull::new("sem_search") | |
| .arguments(ToolCallArguments::from_json(r#"{"queries": []}"#)); | |
| // Sequence: 1234546454545 | |
| // Let's build it step by step and check at each step | |
| let mut messages = vec![]; | |
| // Step 1: [1] - no loop | |
| messages.push(create_assistant_message(&tool_1)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| assert_eq!(detector.detect_from_conversation(&conv), None); | |
| // Step 2: [1,2] - no loop | |
| messages.push(create_assistant_message(&tool_2)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| assert_eq!(detector.detect_from_conversation(&conv), None); | |
| // Step 3: [1,2,3] - no loop | |
| messages.push(create_assistant_message(&tool_3)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| assert_eq!(detector.detect_from_conversation(&conv), None); | |
| // Step 4: [1,2,3,4] - no loop | |
| messages.push(create_assistant_message(&tool_4)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| assert_eq!(detector.detect_from_conversation(&conv), None); | |
| // Step 5: [1,2,3,4,5] - no loop | |
| messages.push(create_assistant_message(&tool_5)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| assert_eq!(detector.detect_from_conversation(&conv), None); | |
| // Step 6: [1,2,3,4,5,4] - no loop yet | |
| messages.push(create_assistant_message(&tool_4)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| assert_eq!(detector.detect_from_conversation(&conv), None); | |
| // Step 7: [1,2,3,4,5,4,6] - no loop | |
| messages.push(create_assistant_message(&tool_6)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| assert_eq!(detector.detect_from_conversation(&conv), None); | |
| // Step 8: [1,2,3,4,5,4,6,4] - no loop yet | |
| messages.push(create_assistant_message(&tool_4)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| assert_eq!(detector.detect_from_conversation(&conv), None); | |
| // Step 9: [1,2,3,4,5,4,6,4,5] - no loop yet (only 1.5 repetitions of [4,5]) | |
| messages.push(create_assistant_message(&tool_5)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| assert_eq!(detector.detect_from_conversation(&conv), None); | |
| // Step 10: [1,2,3,4,5,4,6,4,5,4] - no loop yet (2 repetitions of [4,5]) | |
| messages.push(create_assistant_message(&tool_4)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| assert_eq!(detector.detect_from_conversation(&conv), None); | |
| // Step 11: [1,2,3,4,5,4,6,4,5,4,5] - still no loop (2.5 repetitions) | |
| messages.push(create_assistant_message(&tool_5)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| assert_eq!(detector.detect_from_conversation(&conv), None); | |
| // Step 12: [1,2,3,4,5,4,6,4,5,4,5,4] - still no loop (almost 3) | |
| messages.push(create_assistant_message(&tool_4)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| assert_eq!(detector.detect_from_conversation(&conv), None); | |
| // Step 13: [1,2,3,4,5,4,6,4,5,4,5,4,5] - [4,5] pattern now repeats 3 times at | |
| // end | |
| messages.push(create_assistant_message(&tool_5)); | |
| let conv = create_conversation_with_messages(messages.clone()); | |
| let result = detector.detect_from_conversation(&conv); | |
| // Should detect pattern [4,5] repeating 3 times | |
| assert_eq!(result, Some(3)); | |
| } | |
| } | |