roboticus_agent/
normalization.rs1use regex::Regex;
7use serde::Serialize;
8use std::sync::LazyLock;
9
10pub const MAX_NORMALIZATION_RETRIES: u8 = 2;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15pub enum NormalizationPattern {
16 MalformedToolCall,
18 NarratedToolUse,
20 EmptyAction,
22}
23
24static RE_ACTION_OR_TOOL_CALL: LazyLock<Regex> =
30 LazyLock::new(|| Regex::new(r"(?i)(Action\s*:|tool_call)").unwrap());
31
32static RE_NARRATED: LazyLock<Regex> = LazyLock::new(|| {
35 Regex::new(r"(?i)(I would use|I'll run|let me use the|I should call|I need to invoke)\s+\w+")
36 .unwrap()
37});
38
39static RE_EMPTY_ACTION: LazyLock<Regex> =
41 LazyLock::new(|| Regex::new(r"(?i)Action\s*:\s*$").unwrap());
42
43pub fn detect_normalization_failure(content: &str) -> Option<NormalizationPattern> {
53 if content.trim().is_empty() {
55 return Some(NormalizationPattern::EmptyAction);
56 }
57
58 if RE_EMPTY_ACTION.is_match(content) {
60 return Some(NormalizationPattern::EmptyAction);
61 }
62
63 if RE_ACTION_OR_TOOL_CALL.is_match(content) {
65 if has_broken_json(content) {
66 return Some(NormalizationPattern::MalformedToolCall);
67 }
68 return None;
70 }
71
72 if RE_NARRATED.is_match(content) {
74 return Some(NormalizationPattern::NarratedToolUse);
75 }
76
77 None
78}
79
80pub fn build_normalization_retry_prompt(
85 pattern: &NormalizationPattern,
86 tool_count: usize,
87) -> String {
88 match pattern {
89 NormalizationPattern::MalformedToolCall => {
90 "Your previous response contained a malformed tool call. \
91 Please retry using the correct JSON format:\n\
92 Action: tool_name\n\
93 Action Input: {\"param\": \"value\"}"
94 .to_string()
95 }
96 NormalizationPattern::NarratedToolUse => {
97 format!(
98 "You described what you would do instead of doing it. \
99 Use the Action/Action Input format to actually invoke the tool. \
100 You have {tool_count} tools available."
101 )
102 }
103 NormalizationPattern::EmptyAction => "Your previous response was empty. \
104 Please provide either a direct answer or use a tool via \
105 Action/Action Input format."
106 .to_string(),
107 }
108}
109
110fn has_broken_json(content: &str) -> bool {
117 let open: i32 = content.chars().filter(|&c| c == '{').count() as i32;
118 let close: i32 = content.chars().filter(|&c| c == '}').count() as i32;
119
120 if open == 0 {
121 return true;
123 }
124
125 open != close
127}
128
129#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
137 fn detects_malformed_tool_call_unbalanced_braces() {
138 let content = "Action: web_search\nAction Input: {\"query\": \"rust async\"";
139 assert_eq!(
140 detect_normalization_failure(content),
141 Some(NormalizationPattern::MalformedToolCall)
142 );
143 }
144
145 #[test]
147 fn detects_malformed_tool_call_no_json() {
148 let content = "Action: web_search\nAction Input: query rust async";
149 assert_eq!(
150 detect_normalization_failure(content),
151 Some(NormalizationPattern::MalformedToolCall)
152 );
153 }
154
155 #[test]
157 fn detects_narrated_tool_use() {
158 let content = "I would use web_search to find recent articles on the topic.";
159 assert_eq!(
160 detect_normalization_failure(content),
161 Some(NormalizationPattern::NarratedToolUse)
162 );
163 }
164
165 #[test]
167 fn detects_empty_action_whitespace_only() {
168 let content = " \n\t ";
169 assert_eq!(
170 detect_normalization_failure(content),
171 Some(NormalizationPattern::EmptyAction)
172 );
173 }
174
175 #[test]
177 fn normal_tool_call_not_detected_as_failure() {
178 let content = "Action: web_search\nAction Input: {\"query\": \"rust async\"}";
179 assert_eq!(detect_normalization_failure(content), None);
180 }
181
182 #[test]
184 fn normal_text_response_not_detected_as_failure() {
185 let content = "The answer is 42. Rust is a systems programming language.";
186 assert_eq!(detect_normalization_failure(content), None);
187 }
188
189 #[test]
191 fn retry_prompt_includes_tool_count() {
192 let prompt = build_normalization_retry_prompt(&NormalizationPattern::NarratedToolUse, 7);
193 assert!(prompt.contains("7 tools available"));
194 }
195
196 #[test]
198 fn detects_empty_action_line() {
199 let content = "Thought: I should search for this.\nAction: ";
200 assert_eq!(
201 detect_normalization_failure(content),
202 Some(NormalizationPattern::EmptyAction)
203 );
204 }
205}