1use regex::Regex;
25use serde_json::{Value, json};
26use std::sync::OnceLock;
27
28#[derive(Debug, Clone)]
30pub struct ParsedToolCall {
31 pub name: String,
33 pub args: Value,
35 pub id: String,
37}
38
39#[derive(Debug)]
41pub struct ParseResult {
42 pub clean_text: String,
44 pub tool_calls: Vec<ParsedToolCall>,
46}
47
48static TOOL_CALL_REGEX: OnceLock<Regex> = OnceLock::new();
49static XML_TOOL_CALL_REGEX: OnceLock<Regex> = OnceLock::new();
50static INVOKE_REGEX: OnceLock<Regex> = OnceLock::new();
51static THINKING_REGEX: OnceLock<Regex> = OnceLock::new();
52static FAKE_TOOL_WRAPPER_REGEX: OnceLock<Regex> = OnceLock::new();
53
54const FAKE_TOOL_CALL_MARKERS: &[&str] = &[
55 "<function_calls>",
56 "<|DSML|tool_calls>",
57 "<|DSML|invoke ",
58 "<|DSML|tool_calls>",
59 "<|DSML|invoke ",
60 "<|dsml|tool_calls>",
61 "<|dsml|invoke ",
62 "<|tool_calls>",
63 "<|tool▁calls▁begin|>",
68 "<|tool▁call▁begin|>",
69 "<|tool▁calls▁begin|>",
70 "<|tool▁call▁begin|>",
71 "<|tool_calls_begin|>",
72 "<|tool_call_begin|>",
73 "<|tool_calls_begin|>",
74 "<|tool_call_begin|>",
75];
76
77const LITERAL_FAKE_WRAPPER_PAIRS: &[(&str, &str)] = &[
82 ("<|tool▁calls▁begin|>", "<|tool▁calls▁end|>"),
83 ("<|tool▁call▁begin|>", "<|tool▁call▁end|>"),
84 ("<|tool▁outputs▁begin|>", "<|tool▁outputs▁end|>"),
85 ("<|tool▁output▁begin|>", "<|tool▁output▁end|>"),
86 ("<|tool▁calls▁begin|>", "<|tool▁calls▁end|>"),
87 ("<|tool▁call▁begin|>", "<|tool▁call▁end|>"),
88 ("<|tool▁outputs▁begin|>", "<|tool▁outputs▁end|>"),
89 ("<|tool▁output▁begin|>", "<|tool▁output▁end|>"),
90 ("<|tool_calls_begin|>", "<|tool_calls_end|>"),
91 ("<|tool_call_begin|>", "<|tool_call_end|>"),
92 ("<|tool_outputs_begin|>", "<|tool_outputs_end|>"),
93 ("<|tool_output_begin|>", "<|tool_output_end|>"),
94 ("<|tool_calls_begin|>", "<|tool_calls_end|>"),
95 ("<|tool_call_begin|>", "<|tool_call_end|>"),
96 ("<|tool_outputs_begin|>", "<|tool_outputs_end|>"),
97 ("<|tool_output_begin|>", "<|tool_output_end|>"),
98];
99
100fn get_tool_call_regex() -> &'static Regex {
101 TOOL_CALL_REGEX.get_or_init(|| {
102 Regex::new(r"(?s)\[TOOL_CALL\]\s*(.*?)\s*\[/TOOL_CALL\]")
104 .expect("TOOL_CALL regex pattern is valid")
105 })
106}
107
108fn get_xml_tool_call_regex() -> &'static Regex {
109 XML_TOOL_CALL_REGEX.get_or_init(|| {
110 Regex::new(r"(?s)<(?:codewhale:)?tool_call[^>]*>\s*(.*?)\s*</(?:codewhale:)?tool_call>")
112 .expect("XML tool_call regex pattern is valid")
113 })
114}
115
116fn get_invoke_regex() -> &'static Regex {
117 INVOKE_REGEX.get_or_init(|| {
118 Regex::new(r#"(?s)<invoke\s+name\s*=\s*"([^"]+)"[^>]*>(.*?)</invoke>"#)
120 .expect("invoke regex pattern is valid")
121 })
122}
123
124fn get_thinking_regex() -> &'static Regex {
125 THINKING_REGEX.get_or_init(|| {
126 Regex::new(r"(?s)</?(?:think|thinking)[^>]*>").expect("thinking regex pattern is valid")
128 })
129}
130
131fn get_fake_tool_wrapper_regex() -> &'static Regex {
132 FAKE_TOOL_WRAPPER_REGEX.get_or_init(|| {
133 let mut alternatives = vec![
134 r"<function_calls>.*?</function_calls>".to_string(),
135 r"<|DSML|tool_calls>.*?</|DSML|tool_calls>".to_string(),
136 r"<|DSML|invoke\b[^>]*>.*?</|DSML|invoke>".to_string(),
137 r"<\|DSML\|tool_calls>.*?</\|DSML\|tool_calls>".to_string(),
138 r"<\|DSML\|invoke\b[^>]*>.*?</\|DSML\|invoke>".to_string(),
139 r"<\|dsml\|tool_calls>.*?</\|dsml\|tool_calls>".to_string(),
140 r"<\|dsml\|invoke\b[^>]*>.*?</\|dsml\|invoke>".to_string(),
141 r"<\|tool_calls>.*?</\|tool_calls>".to_string(),
142 ];
143 alternatives.extend(
144 LITERAL_FAKE_WRAPPER_PAIRS
145 .iter()
146 .map(|(start, end)| format!("{}.*?{}", regex::escape(start), regex::escape(end))),
147 );
148 Regex::new(&format!("(?s){}", alternatives.join("|")))
149 .expect("fake tool wrapper regex pattern is valid")
150 })
151}
152
153pub fn parse_tool_calls(text: &str) -> ParseResult {
156 let mut tool_calls = Vec::new();
157 let mut clean_text = text.to_string();
158 let mut id_counter = 0;
159
160 let thinking_regex = get_thinking_regex();
162 clean_text = thinking_regex.replace_all(&clean_text, "").to_string();
163
164 let regex = get_tool_call_regex();
166 for cap in regex.captures_iter(text) {
167 let (Some(full_match), Some(inner)) = (cap.get(0), cap.get(1)) else {
168 continue;
169 };
170 let full_match = full_match.as_str();
171 let inner = inner.as_str().trim();
172
173 if let Some(parsed) = parse_tool_call_inner(inner, &mut id_counter) {
174 tool_calls.push(parsed);
175 }
176
177 clean_text = clean_text.replace(full_match, "");
178 }
179
180 let xml_regex = get_xml_tool_call_regex();
182 for cap in xml_regex.captures_iter(text) {
183 let (Some(full_match), Some(inner)) = (cap.get(0), cap.get(1)) else {
184 continue;
185 };
186 let full_match = full_match.as_str();
187 let inner = inner.as_str().trim();
188
189 if let Some(parsed) = parse_invoke_block(inner, &mut id_counter) {
191 tool_calls.push(parsed);
192 } else if let Some(parsed) = parse_tool_call_inner(inner, &mut id_counter) {
193 tool_calls.push(parsed);
194 }
195
196 clean_text = clean_text.replace(full_match, "");
197 }
198
199 let invoke_regex = get_invoke_regex();
201 for cap in invoke_regex.captures_iter(&clean_text.clone()) {
202 let (Some(full_match), Some(tool_name), Some(inner)) = (cap.get(0), cap.get(1), cap.get(2))
203 else {
204 continue;
205 };
206 let full_match = full_match.as_str();
207 let tool_name = tool_name.as_str();
208 let inner = inner.as_str();
209
210 let args = parse_xml_parameters(inner);
211 id_counter += 1;
212 tool_calls.push(ParsedToolCall {
213 name: tool_name.to_string(),
214 args,
215 id: format!("xml_tool_{id_counter}"),
216 });
217
218 clean_text = clean_text.replace(full_match, "");
219 }
220
221 clean_text = get_fake_tool_wrapper_regex()
222 .replace_all(&clean_text, "")
223 .to_string();
224
225 clean_text = clean_text
227 .lines()
228 .filter(|line| !line.trim().is_empty())
229 .collect::<Vec<_>>()
230 .join("\n")
231 .trim()
232 .to_string();
233
234 ParseResult {
235 clean_text,
236 tool_calls,
237 }
238}
239
240fn parse_invoke_block(content: &str, id_counter: &mut u32) -> Option<ParsedToolCall> {
242 let invoke_regex = get_invoke_regex();
243 let cap = invoke_regex.captures(content)?;
244
245 let tool_name = cap.get(1)?.as_str();
246 let inner = cap.get(2)?.as_str();
247
248 let args = parse_xml_parameters(inner);
249
250 *id_counter += 1;
251 Some(ParsedToolCall {
252 name: tool_name.to_string(),
253 args,
254 id: format!("xml_tool_{id_counter}"),
255 })
256}
257
258fn parse_xml_parameters(content: &str) -> Value {
260 let param_regex = Regex::new(
261 "<(?:parameter|param)\\s+name\\s*=\\s*\"([^\"]+)\"[^>]*>(.*?)</(?:parameter|param)>",
262 )
263 .ok();
264 let simple_tag_regex =
265 Regex::new("<([a-zA-Z_][a-zA-Z0-9_]*)>(.*?)</([a-zA-Z_][a-zA-Z0-9_]*)>").ok();
266
267 let mut map = serde_json::Map::new();
268
269 if let Some(regex) = param_regex {
271 for cap in regex.captures_iter(content) {
272 if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) {
273 let name_str = name.as_str();
274 let value_str = value.as_str().trim();
275
276 let json_value = serde_json::from_str(value_str)
278 .unwrap_or_else(|_| Value::String(value_str.to_string()));
279 map.insert(name_str.to_string(), json_value);
280 }
281 }
282 }
283
284 if let Some(regex) = simple_tag_regex {
286 for cap in regex.captures_iter(content) {
287 if let (Some(name), Some(value), Some(close)) = (cap.get(1), cap.get(2), cap.get(3)) {
288 if name.as_str() != close.as_str() {
289 continue;
290 }
291 let name_str = name.as_str();
292 if ["invoke", "tool_call", "parameter", "param"].contains(&name_str) {
294 continue;
295 }
296 let value_str = value.as_str().trim();
297 if !map.contains_key(name_str) {
298 let json_value = serde_json::from_str(value_str)
299 .unwrap_or_else(|_| Value::String(value_str.to_string()));
300 map.insert(name_str.to_string(), json_value);
301 }
302 }
303 }
304 }
305
306 Value::Object(map)
307}
308
309fn parse_tool_call_inner(inner: &str, id_counter: &mut u32) -> Option<ParsedToolCall> {
311 if let Ok(json) = serde_json::from_str::<Value>(inner) {
313 return parse_from_json(&json, id_counter);
314 }
315
316 if let Some(parsed) = parse_arrow_syntax(inner, id_counter) {
318 return Some(parsed);
319 }
320
321 parse_flexible_format(inner, id_counter)
323}
324
325fn parse_from_json(json: &Value, id_counter: &mut u32) -> Option<ParsedToolCall> {
327 let obj = json.as_object()?;
328
329 let name = obj
331 .get("tool")
332 .or_else(|| obj.get("name"))
333 .or_else(|| obj.get("function"))
334 .and_then(|v| v.as_str())?
335 .to_string();
336
337 let args = obj
339 .get("args")
340 .or_else(|| obj.get("arguments"))
341 .or_else(|| obj.get("input"))
342 .or_else(|| obj.get("parameters"))
343 .cloned()
344 .unwrap_or(json!({}));
345
346 *id_counter += 1;
347 Some(ParsedToolCall {
348 name,
349 args,
350 id: format!("text_tool_{id_counter}"),
351 })
352}
353
354fn parse_arrow_syntax(inner: &str, id_counter: &mut u32) -> Option<ParsedToolCall> {
356 let tool_regex = Regex::new(r#"tool\s*=>\s*"([^"]+)""#).ok()?;
358 let name = tool_regex.captures(inner)?.get(1)?.as_str().to_string();
359
360 let args = if let Some(args_start) = inner.find("args =>") {
362 let args_str = inner[args_start + 7..].trim();
363 if let Ok(args_json) = serde_json::from_str::<Value>(args_str) {
365 args_json
366 } else if let Some(brace_start) = args_str.find('{') {
367 let mut brace_count = 0;
369 let mut end_idx = brace_start;
370 for (i, c) in args_str[brace_start..].chars().enumerate() {
371 match c {
372 '{' => brace_count += 1,
373 '}' => {
374 brace_count -= 1;
375 if brace_count == 0 {
376 end_idx = brace_start + i + 1;
377 break;
378 }
379 }
380 _ => {}
381 }
382 }
383 let content = &args_str[brace_start + 1..end_idx - 1];
384
385 if let Ok(json) = serde_json::from_str::<Value>(&format!("{{{content}}}")) {
387 json
388 } else {
389 parse_cli_style_args(content)
391 }
392 } else {
393 json!({})
394 }
395 } else {
396 json!({})
397 };
398
399 *id_counter += 1;
400 Some(ParsedToolCall {
401 name,
402 args,
403 id: format!("text_tool_{id_counter}"),
404 })
405}
406
407fn parse_cli_style_args(content: &str) -> Value {
409 let mut map = serde_json::Map::new();
410
411 let arg_regex =
413 Regex::new(r#"--([a-zA-Z_][a-zA-Z0-9_]*)\s+(?:"([^"]*)"|'([^']*)'|(\S+))"#).ok();
414
415 if let Some(regex) = arg_regex {
416 for cap in regex.captures_iter(content) {
417 if let Some(arg_name) = cap.get(1) {
418 let arg_name = arg_name.as_str();
419 let value = cap
421 .get(2)
422 .or_else(|| cap.get(3))
423 .or_else(|| cap.get(4))
424 .map_or("", |m| m.as_str());
425
426 let json_value = serde_json::from_str(value)
428 .unwrap_or_else(|_| Value::String(value.to_string()));
429 map.insert(arg_name.to_string(), json_value);
430 }
431 }
432 }
433
434 let kv_regex =
436 Regex::new(r#"([a-zA-Z_][a-zA-Z0-9_]*)\s*[:=]\s*(?:"([^"]*)"|'([^']*)'|(\S+))"#).ok();
437 if let Some(regex) = kv_regex {
438 for cap in regex.captures_iter(content) {
439 if let Some(key) = cap.get(1) {
440 let key = key.as_str();
441 if !map.contains_key(key) {
442 let value = cap
443 .get(2)
444 .or_else(|| cap.get(3))
445 .or_else(|| cap.get(4))
446 .map_or("", |m| m.as_str());
447 let json_value = serde_json::from_str(value)
448 .unwrap_or_else(|_| Value::String(value.to_string()));
449 map.insert(key.to_string(), json_value);
450 }
451 }
452 }
453 }
454
455 Value::Object(map)
456}
457
458fn parse_flexible_format(inner: &str, id_counter: &mut u32) -> Option<ParsedToolCall> {
460 let patterns = [(
466 r#"(?:tool|name|function)\s*[:=]\s*"?([a-zA-Z_][a-zA-Z0-9_]*)"?"#,
467 1,
468 )];
469
470 for (pattern, group) in patterns {
471 if let Ok(regex) = Regex::new(pattern)
472 && let Some(cap) = regex.captures(inner)
473 && let Some(name_match) = cap.get(group)
474 {
475 let name = name_match.as_str().to_string();
476
477 let args = extract_json_object(inner).unwrap_or(json!({}));
479
480 *id_counter += 1;
481 return Some(ParsedToolCall {
482 name,
483 args,
484 id: format!("text_tool_{id_counter}"),
485 });
486 }
487 }
488
489 None
490}
491
492fn extract_json_object(text: &str) -> Option<Value> {
494 let start = text.find('{')?;
495 let mut brace_count = 0;
496 let mut end_idx = start;
497
498 for (i, c) in text[start..].chars().enumerate() {
499 match c {
500 '{' => brace_count += 1,
501 '}' => {
502 brace_count -= 1;
503 if brace_count == 0 {
504 end_idx = start + i + 1;
505 break;
506 }
507 }
508 _ => {}
509 }
510 }
511
512 let json_str = &text[start..end_idx];
513 serde_json::from_str(json_str).ok()
514}
515
516pub fn has_tool_call_markers(text: &str) -> bool {
518 text.contains("[TOOL_CALL]")
519 || text.contains("<codewhale:tool_call")
520 || text.contains("<tool_call")
521 || text.contains("<invoke ")
522 || FAKE_TOOL_CALL_MARKERS
523 .iter()
524 .any(|marker| text.contains(marker))
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn test_parse_arrow_syntax() {
533 let text = r#"I'll list the directory.
534[TOOL_CALL]
535{tool => "list_dir", args => {}}
536[/TOOL_CALL]"#;
537
538 let result = parse_tool_calls(text);
539 assert_eq!(result.tool_calls.len(), 1);
540 assert_eq!(result.tool_calls[0].name, "list_dir");
541 assert_eq!(result.clean_text, "I'll list the directory.");
542 }
543
544 #[test]
545 fn test_parse_json_syntax() {
546 let text = r#"Let me check.
547[TOOL_CALL]
548{"tool": "read_file", "args": {"path": "test.txt"}}
549[/TOOL_CALL]"#;
550
551 let result = parse_tool_calls(text);
552 assert_eq!(result.tool_calls.len(), 1);
553 assert_eq!(result.tool_calls[0].name, "read_file");
554 assert_eq!(result.tool_calls[0].args["path"], "test.txt");
555 }
556
557 #[test]
558 fn test_parse_multiple_tool_calls() {
559 let text = r#"First I'll list, then read.
560[TOOL_CALL]
561{tool => "list_dir", args => {}}
562[/TOOL_CALL]
563[TOOL_CALL]
564{tool => "read_file", args => {"path": "file.txt"}}
565[/TOOL_CALL]"#;
566
567 let result = parse_tool_calls(text);
568 assert_eq!(result.tool_calls.len(), 2);
569 assert_eq!(result.tool_calls[0].name, "list_dir");
570 assert_eq!(result.tool_calls[1].name, "read_file");
571 }
572
573 #[test]
574 fn test_no_tool_calls() {
575 let text = "Just some regular text without any tool calls.";
576 let result = parse_tool_calls(text);
577 assert!(result.tool_calls.is_empty());
578 assert_eq!(result.clean_text, text);
579 }
580
581 #[test]
582 fn test_dsml_wrappers_are_stripped_without_execution() {
583 let text = "before\n<|DSML|tool_calls>\n<|DSML|invoke name=\"read_file\">\n<|DSML|parameter name=\"path\" string=\"true\">secret.txt</|DSML|parameter>\n</|DSML|invoke>\n</|DSML|tool_calls>\nafter";
584
585 assert!(has_tool_call_markers(text));
586 let result = parse_tool_calls(text);
587
588 assert!(result.tool_calls.is_empty());
589 assert!(result.clean_text.contains("before"));
590 assert!(result.clean_text.contains("after"));
591 assert!(!result.clean_text.contains("DSML"));
592 assert!(!result.clean_text.contains("read_file"));
593 assert!(!result.clean_text.contains("secret.txt"));
594 }
595
596 #[test]
597 fn test_ascii_dsml_wrappers_are_stripped_without_execution() {
598 let text = "before <|DSML|invoke name=\"grep_files\"><|DSML|parameter name=\"pattern\">SECRET</|DSML|parameter></|DSML|invoke> after";
599
600 assert!(has_tool_call_markers(text));
601 let result = parse_tool_calls(text);
602
603 assert!(result.tool_calls.is_empty());
604 assert!(result.clean_text.contains("before"));
605 assert!(result.clean_text.contains("after"));
606 assert!(!result.clean_text.contains("DSML"));
607 assert!(!result.clean_text.contains("grep_files"));
608 assert!(!result.clean_text.contains("SECRET"));
609 }
610
611 #[test]
612 fn test_deepseek_native_tool_tokens_are_stripped_without_execution() {
613 for (start, end) in [
617 ("<|tool▁calls▁begin|>", "<|tool▁calls▁end|>"),
618 ("<|tool▁call▁begin|>", "<|tool▁call▁end|>"),
619 ("<|tool▁calls▁begin|>", "<|tool▁calls▁end|>"),
620 ("<|tool_calls_begin|>", "<|tool_calls_end|>"),
621 ("<|tool_call_begin|>", "<|tool_call_end|>"),
622 ] {
623 let text = format!(
624 "before {start}function<|tool▁sep|>grep_files\n```json\n{{\"pattern\":\"SECRET\"}}\n```{end} after"
625 );
626
627 assert!(has_tool_call_markers(&text), "not detected: {start}");
628 let result = parse_tool_calls(&text);
629
630 assert!(result.tool_calls.is_empty(), "{start} was executed");
633 assert!(
634 result.clean_text.contains("before"),
635 "{:?}",
636 result.clean_text
637 );
638 assert!(
639 result.clean_text.contains("after"),
640 "{:?}",
641 result.clean_text
642 );
643 assert!(
644 !result.clean_text.contains("grep_files")
645 && !result.clean_text.contains("SECRET")
646 && !result.clean_text.contains("tool▁")
647 && !result.clean_text.contains("tool_calls"),
648 "leaked {start}: {:?}",
649 result.clean_text
650 );
651 }
652 }
653
654 #[test]
655 fn test_has_markers() {
656 assert!(has_tool_call_markers("[TOOL_CALL]test[/TOOL_CALL]"));
657 assert!(has_tool_call_markers(
658 "<|DSML|tool_calls>...</|DSML|tool_calls>"
659 ));
660 assert!(!has_tool_call_markers("no markers here"));
661 }
662}