Skip to main content

sparrow/provider/
tool_markup.rs

1//! Fallback parser for tool calls emitted as inline markup instead of the
2//! provider's native function-calling JSON.
3//!
4//! Some models — notably DeepSeek served through certain OpenAI-compatible
5//! proxies (e.g. `opencode-go`) — emit tool calls as XML-ish markup inside the
6//! assistant content stream rather than as OpenAI `tool_calls`:
7//!
8//! ```text
9//! <||DSML||tool_calls>
10//! <||DSML||invoke name="read">
11//! <||DSML||parameter name="file_path" string="true">config.py</||DSML||parameter>
12//! </||DSML||invoke>
13//! </||DSML||tool_calls>
14//! ```
15//!
16//! Anthropic-style `<invoke name="...">` blocks use the same shape without the
17//! `||DSML||` token. When the OpenAI-compatible layer sees this in `content`
18//! (with `finish_reason: "stop"`), the tool would otherwise leak to the user as
19//! raw text and never execute. We detect and normalize both forms into real
20//! tool calls.
21
22use regex::Regex;
23use serde_json::{Map, Value};
24
25/// The DeepSeek special token that wraps each tag: `||DSML||` where `|` is
26/// U+FF5C (fullwidth vertical line).
27const DSML_TOKEN: &str = "\u{FF5C}\u{FF5C}DSML\u{FF5C}\u{FF5C}";
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct ParsedToolCall {
31    pub name: String,
32    pub args: Value,
33}
34
35/// Cheap pre-check: does this text contain inline tool-call markup we can parse?
36/// Used to (a) decide whether to suppress the raw text from the user and
37/// (b) whether to run the (more expensive) full extraction at stream end.
38pub fn looks_like_tool_markup(text: &str) -> bool {
39    (text.contains(DSML_TOKEN) || text.contains("<invoke ") || text.contains("<invoke\t"))
40        && text.contains("name=")
41}
42
43fn strip_dsml(text: &str) -> String {
44    // Remove the exact DSML token so `<||DSML||invoke ...>` becomes
45    // `<invoke ...>`. We never touch parameter *values* because the token only
46    // appears as a tag prefix.
47    text.replace(DSML_TOKEN, "")
48}
49
50fn coerce(raw: &str) -> Value {
51    let t = raw.trim();
52    if t == "true" {
53        return Value::Bool(true);
54    }
55    if t == "false" {
56        return Value::Bool(false);
57    }
58    if let Ok(i) = t.parse::<i64>() {
59        return Value::from(i);
60    }
61    if let Ok(f) = t.parse::<f64>() {
62        // Keep integers integral; only use float when it really is one.
63        if t.contains('.') {
64            return Value::from(f);
65        }
66    }
67    Value::String(t.to_string())
68}
69
70/// Extract every `<invoke name="X"> … <parameter name="Y">Z</parameter> … </invoke>`
71/// block (tolerating the `||DSML||` token prefix) into structured tool calls.
72pub fn extract_tool_calls(text: &str) -> Vec<ParsedToolCall> {
73    let cleaned = strip_dsml(text);
74    let invoke_re = Regex::new(r#"(?s)<invoke\s+name="([^"]+)"\s*>(.*?)</invoke>"#)
75        .expect("static invoke regex");
76    let param_re = Regex::new(r#"(?s)<parameter\s+name="([^"]+)"[^>]*?>(.*?)</parameter>"#)
77        .expect("static parameter regex");
78
79    let mut calls = Vec::new();
80    for inv in invoke_re.captures_iter(&cleaned) {
81        let name = inv[1].trim().to_string();
82        let body = &inv[2];
83        let mut args = Map::new();
84        for p in param_re.captures_iter(body) {
85            let pname = p[1].trim().to_string();
86            let pval = coerce(&p[2]);
87            args.insert(pname, pval);
88        }
89        if !name.is_empty() {
90            calls.push(ParsedToolCall {
91                name,
92                args: Value::Object(args),
93            });
94        }
95    }
96    calls
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    const SAMPLE: &str = "<\u{FF5C}\u{FF5C}DSML\u{FF5C}\u{FF5C}tool_calls>\n<\u{FF5C}\u{FF5C}DSML\u{FF5C}\u{FF5C}invoke name=\"read\">\n<\u{FF5C}\u{FF5C}DSML\u{FF5C}\u{FF5C}parameter name=\"file_path\" string=\"true\">config.py</\u{FF5C}\u{FF5C}DSML\u{FF5C}\u{FF5C}parameter>\n</\u{FF5C}\u{FF5C}DSML\u{FF5C}\u{FF5C}invoke>\n</\u{FF5C}\u{FF5C}DSML\u{FF5C}\u{FF5C}tool_calls>";
104
105    #[test]
106    fn detects_dsml_markup() {
107        assert!(looks_like_tool_markup(SAMPLE));
108        assert!(!looks_like_tool_markup(
109            "just a normal answer about config.py"
110        ));
111    }
112
113    #[test]
114    fn parses_dsml_single_tool() {
115        let calls = extract_tool_calls(SAMPLE);
116        assert_eq!(calls.len(), 1);
117        assert_eq!(calls[0].name, "read");
118        assert_eq!(calls[0].args["file_path"], "config.py");
119    }
120
121    #[test]
122    fn parses_anthropic_style_without_dsml() {
123        let text = r#"<invoke name="fs_write">
124<parameter name="path">reverse.py</parameter>
125<parameter name="content">def f(): pass</parameter>
126</invoke>"#;
127        let calls = extract_tool_calls(text);
128        assert_eq!(calls.len(), 1);
129        assert_eq!(calls[0].name, "fs_write");
130        assert_eq!(calls[0].args["path"], "reverse.py");
131        assert_eq!(calls[0].args["content"], "def f(): pass");
132    }
133
134    #[test]
135    fn parses_multiple_invokes() {
136        let text = r#"<invoke name="a"><parameter name="x">1</parameter></invoke>
137<invoke name="b"><parameter name="y">two</parameter></invoke>"#;
138        let calls = extract_tool_calls(text);
139        assert_eq!(calls.len(), 2);
140        assert_eq!(calls[0].name, "a");
141        assert_eq!(calls[0].args["x"], 1);
142        assert_eq!(calls[1].name, "b");
143        assert_eq!(calls[1].args["y"], "two");
144    }
145
146    #[test]
147    fn ignores_plain_text() {
148        assert!(extract_tool_calls("no tools here, just prose").is_empty());
149    }
150}