sparrow/provider/
tool_markup.rs1use regex::Regex;
23use serde_json::{Map, Value};
24
25const 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
35pub 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 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 if t.contains('.') {
64 return Value::from(f);
65 }
66 }
67 Value::String(t.to_string())
68}
69
70pub 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("just a normal answer about config.py"));
109 }
110
111 #[test]
112 fn parses_dsml_single_tool() {
113 let calls = extract_tool_calls(SAMPLE);
114 assert_eq!(calls.len(), 1);
115 assert_eq!(calls[0].name, "read");
116 assert_eq!(calls[0].args["file_path"], "config.py");
117 }
118
119 #[test]
120 fn parses_anthropic_style_without_dsml() {
121 let text = r#"<invoke name="fs_write">
122<parameter name="path">reverse.py</parameter>
123<parameter name="content">def f(): pass</parameter>
124</invoke>"#;
125 let calls = extract_tool_calls(text);
126 assert_eq!(calls.len(), 1);
127 assert_eq!(calls[0].name, "fs_write");
128 assert_eq!(calls[0].args["path"], "reverse.py");
129 assert_eq!(calls[0].args["content"], "def f(): pass");
130 }
131
132 #[test]
133 fn parses_multiple_invokes() {
134 let text = r#"<invoke name="a"><parameter name="x">1</parameter></invoke>
135<invoke name="b"><parameter name="y">two</parameter></invoke>"#;
136 let calls = extract_tool_calls(text);
137 assert_eq!(calls.len(), 2);
138 assert_eq!(calls[0].name, "a");
139 assert_eq!(calls[0].args["x"], 1);
140 assert_eq!(calls[1].name, "b");
141 assert_eq!(calls[1].args["y"], "two");
142 }
143
144 #[test]
145 fn ignores_plain_text() {
146 assert!(extract_tool_calls("no tools here, just prose").is_empty());
147 }
148}