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(
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}