Skip to main content

roboticus_agent/
normalization.rs

1//! # Normalization
2//!
3//! Detects malformed tool protocol and narrated next steps in ReAct loop output,
4//! and builds retry prompts to guide the model back to correct behavior.
5
6use regex::Regex;
7use serde::Serialize;
8use std::sync::LazyLock;
9
10/// Maximum number of normalization retries before giving up.
11pub const MAX_NORMALIZATION_RETRIES: u8 = 2;
12
13/// Describes the kind of protocol failure detected in model output.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15pub enum NormalizationPattern {
16    /// Tool call JSON is syntactically malformed.
17    MalformedToolCall,
18    /// Model narrated what it would do instead of calling a tool.
19    NarratedToolUse,
20    /// Model produced empty or whitespace-only action.
21    EmptyAction,
22}
23
24// ── Compiled regex patterns ──────────────────────────────────────────────────
25
26/// Matches an `Action:` or `tool_call` keyword followed by broken JSON.
27/// "Broken" means there is at least one `{` but unbalanced braces, or a
28/// bare `{` followed by content that is missing a closing `}`.
29static RE_ACTION_OR_TOOL_CALL: LazyLock<Regex> =
30    LazyLock::new(|| Regex::new(r"(?i)(Action\s*:|tool_call)").unwrap());
31
32/// Matches narration patterns where the model describes an intent to use a
33/// tool rather than actually using it, only when no `Action:` block is present.
34static 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
39/// Matches `Action:` followed by only optional whitespace (empty action).
40static RE_EMPTY_ACTION: LazyLock<Regex> =
41    LazyLock::new(|| Regex::new(r"(?i)Action\s*:\s*$").unwrap());
42
43// ── Public API ────────────────────────────────────────────────────────────────
44
45/// Inspect `content` and return the first `NormalizationPattern` detected, or
46/// `None` if the content looks well-formed.
47///
48/// Detection priority:
49/// 1. `EmptyAction`  — content is blank, or `Action:` with nothing after it.
50/// 2. `MalformedToolCall` — `Action:` / `tool_call` present but JSON is broken.
51/// 3. `NarratedToolUse` — model described intent without an `Action:` block.
52pub fn detect_normalization_failure(content: &str) -> Option<NormalizationPattern> {
53    // 1. Empty / whitespace-only content
54    if content.trim().is_empty() {
55        return Some(NormalizationPattern::EmptyAction);
56    }
57
58    // 2. `Action:` present but nothing follows it (trailing whitespace only)
59    if RE_EMPTY_ACTION.is_match(content) {
60        return Some(NormalizationPattern::EmptyAction);
61    }
62
63    // 3. `Action:` or `tool_call` present → check whether JSON is well-formed
64    if RE_ACTION_OR_TOOL_CALL.is_match(content) {
65        if has_broken_json(content) {
66            return Some(NormalizationPattern::MalformedToolCall);
67        }
68        // Keyword present and JSON looks balanced — not a failure
69        return None;
70    }
71
72    // 4. No `Action:` block at all, but the model narrated tool use
73    if RE_NARRATED.is_match(content) {
74        return Some(NormalizationPattern::NarratedToolUse);
75    }
76
77    None
78}
79
80/// Build a system-level retry instruction for the given `pattern`.
81///
82/// `tool_count` is embedded in the `NarratedToolUse` message so the model
83/// knows how many tools are available.
84pub 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
110// ── Helpers ───────────────────────────────────────────────────────────────────
111
112/// Returns `true` when the content contains a `{` character but the curly
113/// braces are unbalanced, indicating broken JSON.  Also returns `true` when
114/// there are no braces at all after an `Action:` / `tool_call` keyword (the
115/// caller has already confirmed such a keyword is present).
116fn 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        // Keyword present but no JSON object at all — malformed
122        return true;
123    }
124
125    // Unbalanced braces or a quoted field left open (simple heuristic)
126    open != close
127}
128
129// ── Tests ─────────────────────────────────────────────────────────────────────
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    // 1. Detects malformed JSON tool call (unbalanced braces)
136    #[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    // 2. Detects malformed tool call — keyword present but no JSON at all
146    #[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    // 3. Detects narrated tool use ("I would use web_search to…")
156    #[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    // 4. Detects empty action — content is whitespace only
166    #[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    // 5. Normal tool call NOT detected as failure
176    #[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    // 6. Normal text response (no tool) NOT detected as failure
183    #[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    // 7. Retry prompt includes tool count for NarratedToolUse
190    #[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    // 8. Detects empty Action: line (trailing whitespace after colon)
197    #[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}