Skip to main content

dejadb_core/format/tool_schema/
parse.rs

1//! Parse-back helper — Phase 3 of action-grain unification (2026-04-20).
2//!
3//! Inverse of the Phase 2 adapters: takes raw LLM output in one of 5
4//! text/JSON formats and extracts structured tool-call records.
5//!
6//! Provider coverage:
7//! - `hermes`: `<tool_call>{...}</tool_call>`
8//! - `llama31`: `<|python_tag|>{...}<|eom_id|>`
9//! - `anthropic-tools`: `<function_calls>…<invoke name="…"><parameter name="…">…</parameter>…</invoke></function_calls>`
10//! - `openai-tools`: assistant message JSON with `tool_calls: [...]`
11//! - `markdown-tools`: ```` ```json {...} ``` ````
12//!
13//! All regexes are compiled via `regex::RegexBuilder::size_limit(1 MB)`
14//! to defend against ReDoS / oversized-pattern attacks, following the
15//! Phase-1 validator precedent.
16
17use std::sync::OnceLock;
18
19use serde_json::Value;
20
21use super::ProviderKind;
22
23/// A successfully parsed tool call.
24#[derive(Debug, Clone, PartialEq, serde::Serialize)]
25#[cfg_attr(feature = "http", derive(utoipa::ToSchema))]
26pub struct ParsedToolCall {
27    /// Call identifier — populated from provider output when available,
28    /// otherwise generated as `call_{index}`.
29    pub id: String,
30    /// Tool name (as emitted by the provider; caller may re-normalize).
31    pub name: String,
32    /// Parsed arguments object — never a JSON-encoded string, even for
33    /// OpenAI (which emits arguments as a JSON string on the wire).
34    #[cfg_attr(feature = "http", schema(value_type = serde_json::Value))]
35    pub arguments: Value,
36}
37
38/// A non-fatal parse error — a malformed tool call that was skipped.
39/// Multiple may appear alongside successfully-parsed calls.
40#[derive(Debug, Clone, PartialEq, serde::Serialize)]
41#[cfg_attr(feature = "http", derive(utoipa::ToSchema))]
42pub struct ParseError {
43    pub reason: String,
44    /// Byte offset of the start of the malformed region when known.
45    pub position: Option<usize>,
46}
47
48/// Parse the LLM's raw output for the given provider.
49///
50/// Returns the parsed calls plus any non-fatal errors encountered. A
51/// fully-malformed input yields `Ok((vec![], errors))` rather than an
52/// `Err` — the caller (typically the harness loop or external agent)
53/// decides whether to abort or retry.
54pub fn parse(provider: ProviderKind, raw_output: &str) -> (Vec<ParsedToolCall>, Vec<ParseError>) {
55    match provider {
56        ProviderKind::Hermes => parse_hermes(raw_output),
57        ProviderKind::Llama31 => parse_llama31(raw_output),
58        ProviderKind::AnthropicTools => parse_anthropic(raw_output),
59        ProviderKind::OpenAiTools | ProviderKind::OpenAiResponses => parse_openai(raw_output),
60        ProviderKind::MarkdownTools => parse_markdown(raw_output),
61        ProviderKind::GeminiTools | ProviderKind::McpTools | ProviderKind::SmlTools => (
62            vec![],
63            vec![ParseError {
64                reason: format!("parse is not supported for provider {}", provider.as_str()),
65                position: None,
66            }],
67        ),
68    }
69}
70
71static HERMES_RE: OnceLock<regex::Regex> = OnceLock::new();
72static LLAMA31_RE: OnceLock<regex::Regex> = OnceLock::new();
73static MARKDOWN_RE: OnceLock<regex::Regex> = OnceLock::new();
74
75fn bounded(pat: &'static str) -> regex::Regex {
76    regex::RegexBuilder::new(pat)
77        .size_limit(1 << 20)
78        .dot_matches_new_line(true)
79        .build()
80        .expect("compile-time-valid pattern")
81}
82
83fn parse_hermes(raw: &str) -> (Vec<ParsedToolCall>, Vec<ParseError>) {
84    let re = HERMES_RE.get_or_init(|| bounded(r"<tool_call>\s*(\{.*?\})\s*</tool_call>"));
85    let mut calls = Vec::new();
86    let mut errors = Vec::new();
87    for (idx, cap) in re.captures_iter(raw).enumerate() {
88        let m = cap.get(1).unwrap();
89        match serde_json::from_str::<Value>(m.as_str()) {
90            Ok(v) => {
91                if let Some(c) = normalize_call(idx, &v) {
92                    calls.push(c);
93                } else {
94                    errors.push(ParseError {
95                        reason: "hermes tool_call missing name/arguments".into(),
96                        position: Some(m.start()),
97                    });
98                }
99            }
100            Err(e) => errors.push(ParseError {
101                reason: format!("hermes tool_call JSON: {e}"),
102                position: Some(m.start()),
103            }),
104        }
105    }
106    (calls, errors)
107}
108
109fn parse_llama31(raw: &str) -> (Vec<ParsedToolCall>, Vec<ParseError>) {
110    // Llama 3.1 emits <|python_tag|>{...}<|eom_id|> OR bare {...}<|eot_id|>.
111    let re = LLAMA31_RE
112        .get_or_init(|| bounded(r"(?:<\|python_tag\|>)?\s*(\{.*?\})\s*<\|e(?:om|ot)_id\|>"));
113    let mut calls = Vec::new();
114    let mut errors = Vec::new();
115    for (idx, cap) in re.captures_iter(raw).enumerate() {
116        let m = cap.get(1).unwrap();
117        match serde_json::from_str::<Value>(m.as_str()) {
118            Ok(v) => {
119                if let Some(c) = normalize_call(idx, &v) {
120                    calls.push(c);
121                } else {
122                    errors.push(ParseError {
123                        reason: "llama31 call missing name".into(),
124                        position: Some(m.start()),
125                    });
126                }
127            }
128            Err(e) => errors.push(ParseError {
129                reason: format!("llama31 call JSON: {e}"),
130                position: Some(m.start()),
131            }),
132        }
133    }
134    (calls, errors)
135}
136
137fn parse_anthropic(raw: &str) -> (Vec<ParsedToolCall>, Vec<ParseError>) {
138    // Native Anthropic API returns tool_use blocks in the message content.
139    // This parser handles both the native `{"type":"tool_use","id":...,"name":...,"input":{...}}`
140    // array shape AND the legacy pre-native XML envelope.
141    let mut calls = Vec::new();
142    let mut errors = Vec::new();
143
144    // Try JSON content block first.
145    if let Ok(Value::Array(blocks)) = serde_json::from_str::<Value>(raw) {
146        for (idx, block) in blocks.iter().enumerate() {
147            if block.get("type").and_then(|v| v.as_str()) == Some("tool_use") {
148                let id = block
149                    .get("id")
150                    .and_then(|v| v.as_str())
151                    .map(str::to_string)
152                    .unwrap_or_else(|| format!("call_{idx}"));
153                let name = match block.get("name").and_then(|v| v.as_str()) {
154                    Some(n) => n.to_string(),
155                    None => {
156                        errors.push(ParseError {
157                            reason: "anthropic tool_use missing name".into(),
158                            position: None,
159                        });
160                        continue;
161                    }
162                };
163                let arguments = block
164                    .get("input")
165                    .cloned()
166                    .unwrap_or_else(|| serde_json::json!({}));
167                calls.push(ParsedToolCall {
168                    id,
169                    name,
170                    arguments,
171                });
172            }
173        }
174        if !calls.is_empty() || !errors.is_empty() {
175            return (calls, errors);
176        }
177    }
178
179    // Fall through to XML envelope parsing.
180    static ANTHROPIC_INVOKE_RE: OnceLock<regex::Regex> = OnceLock::new();
181    let invoke_re = ANTHROPIC_INVOKE_RE
182        .get_or_init(|| bounded(r#"<invoke\s+name="([^"]+)"\s*>(.*?)</invoke>"#));
183    static PARAM_RE: OnceLock<regex::Regex> = OnceLock::new();
184    let param_re =
185        PARAM_RE.get_or_init(|| bounded(r#"<parameter\s+name="([^"]+)"\s*>(.*?)</parameter>"#));
186
187    for (idx, cap) in invoke_re.captures_iter(raw).enumerate() {
188        let name = cap.get(1).unwrap().as_str().to_string();
189        let inner = cap.get(2).unwrap().as_str();
190        let mut args = serde_json::Map::new();
191        for pcap in param_re.captures_iter(inner) {
192            let k = pcap.get(1).unwrap().as_str().to_string();
193            let v = pcap.get(2).unwrap().as_str();
194            // Parameter values are free-form; try JSON first, else string.
195            let value = serde_json::from_str::<Value>(v.trim())
196                .unwrap_or_else(|_| Value::String(v.to_string()));
197            args.insert(k, value);
198        }
199        calls.push(ParsedToolCall {
200            id: format!("call_{idx}"),
201            name,
202            arguments: Value::Object(args),
203        });
204    }
205    (calls, errors)
206}
207
208fn parse_openai(raw: &str) -> (Vec<ParsedToolCall>, Vec<ParseError>) {
209    // OpenAI tool_calls: either the full assistant message or just the
210    // tool_calls array. Handle both shapes.
211    let mut calls = Vec::new();
212    let mut errors = Vec::new();
213    let parsed: Value = match serde_json::from_str(raw) {
214        Ok(v) => v,
215        Err(e) => {
216            return (
217                calls,
218                vec![ParseError {
219                    reason: format!("openai JSON parse: {e}"),
220                    position: None,
221                }],
222            )
223        }
224    };
225    let tool_calls = parsed
226        .get("tool_calls")
227        .or_else(|| {
228            parsed
229                .get("choices")
230                .and_then(|c| c.get(0))
231                .and_then(|c| c.get("message"))
232                .and_then(|m| m.get("tool_calls"))
233        })
234        .or(Some(&parsed)) // caller already isolated the array
235        .and_then(|v| v.as_array());
236    let Some(arr) = tool_calls else {
237        return (
238            calls,
239            vec![ParseError {
240                reason: "openai response has no tool_calls".into(),
241                position: None,
242            }],
243        );
244    };
245    for (idx, tc) in arr.iter().enumerate() {
246        // Responses API: {type:"function_call", call_id, name, arguments}
247        // Chat API:      {id, type, function:{name, arguments}}
248        let id = tc
249            .get("id")
250            .or_else(|| tc.get("call_id"))
251            .and_then(|v| v.as_str())
252            .map(str::to_string)
253            .unwrap_or_else(|| format!("call_{idx}"));
254        let (name, args_raw) = match tc.get("function") {
255            Some(f) => (
256                f.get("name").and_then(|v| v.as_str()).map(str::to_string),
257                f.get("arguments").cloned(),
258            ),
259            None => (
260                tc.get("name").and_then(|v| v.as_str()).map(str::to_string),
261                tc.get("arguments").cloned(),
262            ),
263        };
264        let Some(name) = name else {
265            errors.push(ParseError {
266                reason: "openai tool_call missing name".into(),
267                position: None,
268            });
269            continue;
270        };
271        // OpenAI always emits arguments as a JSON-encoded string; parse.
272        let arguments = match args_raw {
273            Some(Value::String(s)) => serde_json::from_str::<Value>(&s).unwrap_or(Value::String(s)),
274            Some(other) => other,
275            None => Value::Object(Default::default()),
276        };
277        calls.push(ParsedToolCall {
278            id,
279            name,
280            arguments,
281        });
282    }
283    (calls, errors)
284}
285
286fn parse_markdown(raw: &str) -> (Vec<ParsedToolCall>, Vec<ParseError>) {
287    // Markdown convention: fenced JSON with `tool_call`/`name`+`arguments`.
288    let re = MARKDOWN_RE.get_or_init(|| bounded(r"```(?:json)?\s*(\{.*?\})\s*```"));
289    let mut calls = Vec::new();
290    let mut errors = Vec::new();
291    for (idx, cap) in re.captures_iter(raw).enumerate() {
292        let m = cap.get(1).unwrap();
293        match serde_json::from_str::<Value>(m.as_str()) {
294            Ok(v) => {
295                if let Some(c) = normalize_call(idx, &v) {
296                    calls.push(c);
297                } else {
298                    errors.push(ParseError {
299                        reason: "markdown fenced JSON has no name".into(),
300                        position: Some(m.start()),
301                    });
302                }
303            }
304            Err(e) => errors.push(ParseError {
305                reason: format!("markdown fenced JSON: {e}"),
306                position: Some(m.start()),
307            }),
308        }
309    }
310    (calls, errors)
311}
312
313/// Extract name + arguments from a single {name, arguments?} JSON object.
314fn normalize_call(idx: usize, v: &Value) -> Option<ParsedToolCall> {
315    let name = v.get("name").and_then(|x| x.as_str())?.to_string();
316    let id = v
317        .get("id")
318        .and_then(|x| x.as_str())
319        .map(str::to_string)
320        .unwrap_or_else(|| format!("call_{idx}"));
321    let arguments = v
322        .get("arguments")
323        .or_else(|| v.get("parameters"))
324        .or_else(|| v.get("input"))
325        .cloned()
326        .unwrap_or_else(|| Value::Object(Default::default()));
327    let arguments = match arguments {
328        // Hermes sometimes quotes its arguments as a JSON string; unquote.
329        Value::String(s) => serde_json::from_str::<Value>(&s).unwrap_or(Value::String(s)),
330        other => other,
331    };
332    Some(ParsedToolCall {
333        id,
334        name,
335        arguments,
336    })
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use serde_json::json;
343
344    #[test]
345    fn hermes_single_call() {
346        let raw = r##"<tool_call>{"name":"slack_post_message","arguments":{"channel":"#ops","text":"hi"}}</tool_call>"##;
347        let (calls, errs) = parse(ProviderKind::Hermes, raw);
348        assert!(errs.is_empty());
349        assert_eq!(calls.len(), 1);
350        assert_eq!(calls[0].name, "slack_post_message");
351        assert_eq!(calls[0].arguments, json!({"channel":"#ops","text":"hi"}));
352    }
353
354    #[test]
355    fn hermes_multiple_calls() {
356        let raw = r##"chat<tool_call>{"name":"a","arguments":{}}</tool_call><tool_call>{"name":"b","arguments":{}}</tool_call>end"##;
357        let (calls, _) = parse(ProviderKind::Hermes, raw);
358        assert_eq!(calls.len(), 2);
359        assert_eq!(calls[0].name, "a");
360        assert_eq!(calls[1].name, "b");
361    }
362
363    #[test]
364    fn hermes_malformed_emits_error_not_panic() {
365        let raw = r##"<tool_call>{"name":}</tool_call>"##;
366        let (calls, errs) = parse(ProviderKind::Hermes, raw);
367        assert!(calls.is_empty());
368        assert_eq!(errs.len(), 1);
369    }
370
371    #[test]
372    fn llama31_python_tag() {
373        let raw = r##"<|python_tag|>{"name":"calc","arguments":{"x":2}}<|eom_id|>"##;
374        let (calls, _) = parse(ProviderKind::Llama31, raw);
375        assert_eq!(calls.len(), 1);
376        assert_eq!(calls[0].name, "calc");
377    }
378
379    #[test]
380    fn openai_chat_completions_shape() {
381        let raw = r##"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"f","arguments":"{\"x\":1}"}}]}"##;
382        let (calls, _) = parse(ProviderKind::OpenAiTools, raw);
383        assert_eq!(calls.len(), 1);
384        assert_eq!(calls[0].id, "call_1");
385        assert_eq!(calls[0].arguments, json!({"x":1}));
386    }
387
388    #[test]
389    fn anthropic_native_tool_use_block() {
390        let raw = r##"[{"type":"tool_use","id":"toolu_1","name":"f","input":{"x":1}}]"##;
391        let (calls, _) = parse(ProviderKind::AnthropicTools, raw);
392        assert_eq!(calls.len(), 1);
393        assert_eq!(calls[0].id, "toolu_1");
394        assert_eq!(calls[0].name, "f");
395    }
396
397    #[test]
398    fn markdown_fenced_json() {
399        let raw = "Here:\n```json\n{\"name\":\"f\",\"arguments\":{\"x\":1}}\n```";
400        let (calls, _) = parse(ProviderKind::MarkdownTools, raw);
401        assert_eq!(calls.len(), 1);
402        assert_eq!(calls[0].arguments, json!({"x":1}));
403    }
404
405    #[test]
406    fn unsupported_provider_returns_error() {
407        let (calls, errs) = parse(ProviderKind::GeminiTools, "whatever");
408        assert!(calls.is_empty());
409        assert_eq!(errs.len(), 1);
410    }
411}