Skip to main content

enact_runner/
parser.rs

1//! Multi-format tool call parser
2//!
3//! Parses tool calls from LLM output across multiple formats:
4//! JSON → XML → Markdown (priority order).
5//!
6//! Ported from zeroclaw's `parse_tool_calls` which handles JSON, XML,
7//! Markdown, and GLM-style formats for maximum resilience.
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12/// A parsed tool call extracted from LLM output.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ParsedToolCall {
15    /// Tool name to invoke
16    pub name: String,
17    /// Arguments as a JSON value
18    pub arguments: Value,
19    /// Which format was used to detect this call
20    pub format: ToolCallFormat,
21}
22
23/// The format in which the tool call was detected.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum ToolCallFormat {
26    /// Standard JSON: `{"tool_call": {"name": "...", "arguments": {...}}}`
27    JsonObject,
28    /// JSON array: `[{"name": "...", "arguments": {...}}]`
29    JsonArray,
30    /// XML: `<tool_call><name>...</name><arguments>...</arguments></tool_call>`
31    Xml,
32    /// Markdown code block: `` ```tool_call\n{"name": "...", ...}\n``` ``
33    Markdown,
34}
35
36/// Result of parsing: extracted text (non-tool-call content) and tool calls.
37pub struct ParseResult {
38    /// Text content that is NOT a tool call
39    pub text: String,
40    /// Parsed tool calls found in the response
41    pub tool_calls: Vec<ParsedToolCall>,
42}
43
44/// Parse tool calls from an LLM response string.
45///
46/// Tries formats in priority order: JSON → XML → Markdown.
47/// Returns all detected tool calls and the remaining text.
48pub fn parse(response: &str) -> ParseResult {
49    // 1. Try JSON object format
50    if let Some(result) = try_parse_json_object(response) {
51        return result;
52    }
53
54    // 2. Try JSON array format
55    if let Some(result) = try_parse_json_array(response) {
56        return result;
57    }
58
59    // 3. Try XML format
60    if let Some(result) = try_parse_xml(response) {
61        return result;
62    }
63
64    // 4. Try Markdown code block format
65    if let Some(result) = try_parse_markdown(response) {
66        return result;
67    }
68
69    // No tool calls found — entire response is text
70    ParseResult {
71        text: response.to_string(),
72        tool_calls: vec![],
73    }
74}
75
76/// Try parsing as a JSON object: `{"tool_call": {"name": "...", "arguments": {...}}}`
77/// Also handles: `{"tool_calls": [...]}`
78fn try_parse_json_object(response: &str) -> Option<ParseResult> {
79    let trimmed = response.trim();
80
81    // Must start with '{' to be a JSON object
82    if !trimmed.starts_with('{') {
83        return None;
84    }
85
86    let parsed: Value = serde_json::from_str(trimmed).ok()?;
87
88    // Format 1: {"tool_call": {"name": "...", "arguments": {...}}}
89    if let Some(tc) = parsed.get("tool_call") {
90        let name = tc.get("name")?.as_str()?.to_string();
91        let arguments = tc
92            .get("arguments")
93            .cloned()
94            .unwrap_or(Value::Object(Default::default()));
95        return Some(ParseResult {
96            text: String::new(),
97            tool_calls: vec![ParsedToolCall {
98                name,
99                arguments,
100                format: ToolCallFormat::JsonObject,
101            }],
102        });
103    }
104
105    // Format 2: {"tool_calls": [{"name": "...", "arguments": {...}}, ...]}
106    if let Some(tcs) = parsed.get("tool_calls").and_then(|v| v.as_array()) {
107        let calls: Vec<ParsedToolCall> = tcs
108            .iter()
109            .filter_map(|tc| {
110                let name = tc.get("name")?.as_str()?.to_string();
111                let arguments = tc
112                    .get("arguments")
113                    .cloned()
114                    .unwrap_or(Value::Object(Default::default()));
115                Some(ParsedToolCall {
116                    name,
117                    arguments,
118                    format: ToolCallFormat::JsonObject,
119                })
120            })
121            .collect();
122
123        if !calls.is_empty() {
124            return Some(ParseResult {
125                text: String::new(),
126                tool_calls: calls,
127            });
128        }
129    }
130
131    // Format 3: {"name": "...", "arguments": {...}} (bare tool call)
132    if let (Some(name), Some(_)) = (
133        parsed.get("name").and_then(|v| v.as_str()),
134        parsed.get("arguments"),
135    ) {
136        return Some(ParseResult {
137            text: String::new(),
138            tool_calls: vec![ParsedToolCall {
139                name: name.to_string(),
140                arguments: parsed
141                    .get("arguments")
142                    .cloned()
143                    .unwrap_or(Value::Object(Default::default())),
144                format: ToolCallFormat::JsonObject,
145            }],
146        });
147    }
148
149    None
150}
151
152/// Try parsing as a JSON array: `[{"name": "...", "arguments": {...}}, ...]`
153fn try_parse_json_array(response: &str) -> Option<ParseResult> {
154    let trimmed = response.trim();
155
156    if !trimmed.starts_with('[') {
157        return None;
158    }
159
160    let parsed: Vec<Value> = serde_json::from_str(trimmed).ok()?;
161
162    let calls: Vec<ParsedToolCall> = parsed
163        .iter()
164        .filter_map(|tc| {
165            let name = tc.get("name")?.as_str()?.to_string();
166            let arguments = tc
167                .get("arguments")
168                .cloned()
169                .unwrap_or(Value::Object(Default::default()));
170            Some(ParsedToolCall {
171                name,
172                arguments,
173                format: ToolCallFormat::JsonArray,
174            })
175        })
176        .collect();
177
178    if calls.is_empty() {
179        return None;
180    }
181
182    Some(ParseResult {
183        text: String::new(),
184        tool_calls: calls,
185    })
186}
187
188/// Try parsing XML format:
189/// `<tool_call><name>tool_name</name><arguments>{"key": "value"}</arguments></tool_call>`
190fn try_parse_xml(response: &str) -> Option<ParseResult> {
191    let mut calls = Vec::new();
192    let mut text_parts = Vec::new();
193    let mut remaining = response;
194
195    while let Some(start) = remaining.find("<tool_call>") {
196        // Collect text before the tool call
197        let before = &remaining[..start];
198        if !before.trim().is_empty() {
199            text_parts.push(before.trim().to_string());
200        }
201
202        let after_start = &remaining[start + "<tool_call>".len()..];
203        let end = after_start.find("</tool_call>")?;
204        let inner = &after_start[..end];
205
206        // Extract name
207        let name_start = inner.find("<name>")? + "<name>".len();
208        let name_end = inner.find("</name>")?;
209        let name = inner[name_start..name_end].trim().to_string();
210
211        // Extract arguments
212        let args = if let Some(args_start_pos) = inner.find("<arguments>") {
213            let args_content_start = args_start_pos + "<arguments>".len();
214            if let Some(args_end_pos) = inner.find("</arguments>") {
215                let args_str = inner[args_content_start..args_end_pos].trim();
216                serde_json::from_str(args_str).unwrap_or(Value::Object(Default::default()))
217            } else {
218                Value::Object(Default::default())
219            }
220        } else {
221            Value::Object(Default::default())
222        };
223
224        calls.push(ParsedToolCall {
225            name,
226            arguments: args,
227            format: ToolCallFormat::Xml,
228        });
229
230        remaining = &after_start[end + "</tool_call>".len()..];
231    }
232
233    // Collect remaining text
234    if !remaining.trim().is_empty() {
235        text_parts.push(remaining.trim().to_string());
236    }
237
238    if calls.is_empty() {
239        return None;
240    }
241
242    Some(ParseResult {
243        text: text_parts.join("\n"),
244        tool_calls: calls,
245    })
246}
247
248/// Try parsing Markdown code block format:
249/// ````tool_call
250/// {"name": "tool_name", "arguments": {"key": "value"}}
251/// ````
252fn try_parse_markdown(response: &str) -> Option<ParseResult> {
253    let mut calls = Vec::new();
254    let mut text_parts = Vec::new();
255    let mut remaining = response;
256
257    let fence_patterns = ["```tool_call\n", "```tool_call\r\n"];
258
259    loop {
260        let mut found = false;
261        for pattern in &fence_patterns {
262            if let Some(start) = remaining.find(pattern) {
263                // Collect text before the code block
264                let before = &remaining[..start];
265                if !before.trim().is_empty() {
266                    text_parts.push(before.trim().to_string());
267                }
268
269                let content_start = start + pattern.len();
270                let after_content = &remaining[content_start..];
271
272                if let Some(end) = after_content.find("```") {
273                    let block_content = after_content[..end].trim();
274
275                    // Parse the JSON inside the code block
276                    if let Ok(parsed) = serde_json::from_str::<Value>(block_content) {
277                        if let Some(name) = parsed.get("name").and_then(|v| v.as_str()) {
278                            let arguments = parsed
279                                .get("arguments")
280                                .cloned()
281                                .unwrap_or(Value::Object(Default::default()));
282                            calls.push(ParsedToolCall {
283                                name: name.to_string(),
284                                arguments,
285                                format: ToolCallFormat::Markdown,
286                            });
287                        }
288                    }
289
290                    remaining = &after_content[end + "```".len()..];
291                    found = true;
292                    break;
293                }
294            }
295        }
296
297        if !found {
298            break;
299        }
300    }
301
302    // Collect remaining text
303    if !remaining.trim().is_empty() {
304        text_parts.push(remaining.trim().to_string());
305    }
306
307    if calls.is_empty() {
308        return None;
309    }
310
311    Some(ParseResult {
312        text: text_parts.join("\n"),
313        tool_calls: calls,
314    })
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    // ============ JSON Object Tests ============
322
323    #[test]
324    fn test_parse_json_tool_call() {
325        let input = r#"{"tool_call": {"name": "search", "arguments": {"query": "rust"}}}"#;
326        let result = parse(input);
327        assert_eq!(result.tool_calls.len(), 1);
328        assert_eq!(result.tool_calls[0].name, "search");
329        assert_eq!(result.tool_calls[0].format, ToolCallFormat::JsonObject);
330        assert_eq!(result.tool_calls[0].arguments["query"], "rust");
331    }
332
333    #[test]
334    fn test_parse_json_tool_calls_array() {
335        let input = r#"{"tool_calls": [
336            {"name": "search", "arguments": {"query": "rust"}},
337            {"name": "read_file", "arguments": {"path": "/tmp/test"}}
338        ]}"#;
339        let result = parse(input);
340        assert_eq!(result.tool_calls.len(), 2);
341        assert_eq!(result.tool_calls[0].name, "search");
342        assert_eq!(result.tool_calls[1].name, "read_file");
343    }
344
345    #[test]
346    fn test_parse_bare_json_tool_call() {
347        let input = r#"{"name": "search", "arguments": {"query": "rust"}}"#;
348        let result = parse(input);
349        assert_eq!(result.tool_calls.len(), 1);
350        assert_eq!(result.tool_calls[0].name, "search");
351    }
352
353    // ============ JSON Array Tests ============
354
355    #[test]
356    fn test_parse_json_array() {
357        let input = r#"[{"name": "search", "arguments": {"query": "rust"}}]"#;
358        let result = parse(input);
359        assert_eq!(result.tool_calls.len(), 1);
360        assert_eq!(result.tool_calls[0].format, ToolCallFormat::JsonArray);
361    }
362
363    // ============ XML Tests ============
364
365    #[test]
366    fn test_parse_xml() {
367        let input = r#"Let me search for that.
368<tool_call><name>search</name><arguments>{"query": "rust"}</arguments></tool_call>"#;
369        let result = parse(input);
370        assert_eq!(result.tool_calls.len(), 1);
371        assert_eq!(result.tool_calls[0].name, "search");
372        assert_eq!(result.tool_calls[0].format, ToolCallFormat::Xml);
373        assert!(result.text.contains("Let me search"));
374    }
375
376    #[test]
377    fn test_parse_multiple_xml() {
378        let input = r#"<tool_call><name>search</name><arguments>{"q": "a"}</arguments></tool_call>
379<tool_call><name>read</name><arguments>{"path": "b"}</arguments></tool_call>"#;
380        let result = parse(input);
381        assert_eq!(result.tool_calls.len(), 2);
382    }
383
384    // ============ Markdown Tests ============
385
386    #[test]
387    fn test_parse_markdown() {
388        let input = "Here's what I'll do:\n```tool_call\n{\"name\": \"search\", \"arguments\": {\"query\": \"rust\"}}\n```\n";
389        let result = parse(input);
390        assert_eq!(result.tool_calls.len(), 1);
391        assert_eq!(result.tool_calls[0].name, "search");
392        assert_eq!(result.tool_calls[0].format, ToolCallFormat::Markdown);
393        assert!(result.text.contains("Here's what I'll do"));
394    }
395
396    // ============ No Tool Call Tests ============
397
398    #[test]
399    fn test_parse_plain_text() {
400        let input = "This is just a normal response with no tool calls.";
401        let result = parse(input);
402        assert!(result.tool_calls.is_empty());
403        assert_eq!(result.text, input);
404    }
405
406    #[test]
407    fn test_parse_empty() {
408        let result = parse("");
409        assert!(result.tool_calls.is_empty());
410    }
411
412    // ============ Priority Tests ============
413
414    #[test]
415    fn test_json_takes_priority_over_xml() {
416        // If the response is valid JSON with tool_call, JSON wins
417        let input = r#"{"tool_call": {"name": "search", "arguments": {}}}"#;
418        let result = parse(input);
419        assert_eq!(result.tool_calls[0].format, ToolCallFormat::JsonObject);
420    }
421}