返回 CodeWhale
tool_parser.rs
根目录 / crates / core / src / tool_parser.rs
1 //! Legacy parser for text-based tool calls from DeepSeek models.
2 //!
3 //! The engine prefers structured tool-call items and uses this fallback when
4 //! a response has tool-call markers but no structured calls. Unbalanced argument
5 //! objects are rejected; they must not become calls with invented empty args.
6 //!
7 //! Some DeepSeek outputs tool calls as text in various formats:
8 //! ```text
9 //! [TOOL_CALL]
10 //! {tool => "tool_name", args => {...}}
11 //! [/TOOL_CALL]
12 //! ```
13 //!
14 //! Or XML-style format:
15 //! ```text
16 //! <codewhale:tool_call>
17 //! <invoke name="tool_name">
18 //! <parameter name="arg">value</parameter>
19 //! </invoke>
20 //! </codewhale:tool_call>
21 //! ```
22 //!
23 //! This module parses these text patterns into structured tool calls.
24
25 use regex::Regex;
26 use serde_json::{Value, json};
27 use std::sync::OnceLock;
28
29 /// A parsed tool call from text content.
30 #[derive(Debug, Clone)]
31 pub struct ParsedToolCall {
32 /// Tool name
33 pub name: String,
34 /// Tool arguments as JSON
35 pub args: Value,
36 /// Generated ID for the tool call
37 pub id: String,
38 }
39
40 /// Result of parsing text for tool calls.
41 #[derive(Debug)]
42 pub struct ParseResult {
43 /// The text with tool call markers removed (for display)
44 pub clean_text: String,
45 /// Parsed tool calls found in the text
46 pub tool_calls: Vec<ParsedToolCall>,
47 }
48
49 static TOOL_CALL_REGEX: OnceLock<Regex> = OnceLock::new();
50 static XML_TOOL_CALL_REGEX: OnceLock<Regex> = OnceLock::new();
51 static INVOKE_REGEX: OnceLock<Regex> = OnceLock::new();
52 static THINKING_REGEX: OnceLock<Regex> = OnceLock::new();
53 static FAKE_TOOL_WRAPPER_REGEX: OnceLock<Regex> = OnceLock::new();
54
55 const FAKE_TOOL_CALL_MARKERS: &[&str] = &[
56 "<function_calls>",
57 "<|DSML|tool_calls>",
58 "<|DSML|invoke ",
59 "<|DSML|tool_calls>",
60 "<|DSML|invoke ",
61 "<|dsml|tool_calls>",
62 "<|dsml|invoke ",
63 "<|tool_calls>",
64 // DeepSeek native tool-call tokens (#3880). See
65 // `engine::streaming::TOOL_CALL_MARKER_PAIRS` for why the `▁` (U+2581)
66 // separator matters: these match no DSML entry, so they used to reach the
67 // user as visible text.
68 "<|tool▁calls▁begin|>",
69 "<|tool▁call▁begin|>",
70 "<|tool▁calls▁begin|>",
71 "<|tool▁call▁begin|>",
72 "<|tool_calls_begin|>",
73 "<|tool_call_begin|>",
74 "<|tool_calls_begin|>",
75 "<|tool_call_begin|>",
76 ];
77
78 /// Tool-call wrapper pairs whose start and end markers are plain literals, so
79 /// their regex alternative is built by escaping rather than hand-written. The
80 /// DSML entries stay hand-written above because they carry attributes
81 /// (`invoke name="…"`) and need `\b[^>]*>` rather than a literal match.
82 const LITERAL_FAKE_WRAPPER_PAIRS: &[(&str, &str)] = &[
83 ("<|tool▁calls▁begin|>", "<|tool▁calls▁end|>"),
84 ("<|tool▁call▁begin|>", "<|tool▁call▁end|>"),
85 ("<|tool▁outputs▁begin|>", "<|tool▁outputs▁end|>"),
86 ("<|tool▁output▁begin|>", "<|tool▁output▁end|>"),
87 ("<|tool▁calls▁begin|>", "<|tool▁calls▁end|>"),
88 ("<|tool▁call▁begin|>", "<|tool▁call▁end|>"),
89 ("<|tool▁outputs▁begin|>", "<|tool▁outputs▁end|>"),
90 ("<|tool▁output▁begin|>", "<|tool▁output▁end|>"),
91 ("<|tool_calls_begin|>", "<|tool_calls_end|>"),
92 ("<|tool_call_begin|>", "<|tool_call_end|>"),
93 ("<|tool_outputs_begin|>", "<|tool_outputs_end|>"),
94 ("<|tool_output_begin|>", "<|tool_output_end|>"),
95 ("<|tool_calls_begin|>", "<|tool_calls_end|>"),
96 ("<|tool_call_begin|>", "<|tool_call_end|>"),
97 ("<|tool_outputs_begin|>", "<|tool_outputs_end|>"),
98 ("<|tool_output_begin|>", "<|tool_output_end|>"),
99 ];
100
101 fn get_tool_call_regex() -> &'static Regex {
102 TOOL_CALL_REGEX.get_or_init(|| {
103 // Match [TOOL_CALL] ... [/TOOL_CALL] blocks
104 Regex::new(r"(?s)\[TOOL_CALL\]\s*(.*?)\s*\[/TOOL_CALL\]")
105 .expect("TOOL_CALL regex pattern is valid")
106 })
107 }
108
109 fn get_xml_tool_call_regex() -> &'static Regex {
110 XML_TOOL_CALL_REGEX.get_or_init(|| {
111 // Match <codewhale:tool_call>...</codewhale:tool_call> or similar XML patterns
112 Regex::new(r"(?s)<(?:codewhale:)?tool_call[^>]*>\s*(.*?)\s*</(?:codewhale:)?tool_call>")
113 .expect("XML tool_call regex pattern is valid")
114 })
115 }
116
117 fn get_invoke_regex() -> &'static Regex {
118 INVOKE_REGEX.get_or_init(|| {
119 // Match <invoke name="tool_name">...</invoke> patterns
120 Regex::new(r#"(?s)<invoke\s+name\s*=\s*"([^"]+)"[^>]*>(.*?)</invoke>"#)
121 .expect("invoke regex pattern is valid")
122 })
123 }
124
125 fn get_thinking_regex() -> &'static Regex {
126 THINKING_REGEX.get_or_init(|| {
127 // Match thinking blocks including partial closing tags
128 Regex::new(r"(?s)</?(?:think|thinking)[^>]*>").expect("thinking regex pattern is valid")
129 })
130 }
131
132 fn get_fake_tool_wrapper_regex() -> &'static Regex {
133 FAKE_TOOL_WRAPPER_REGEX.get_or_init(|| {
134 let mut alternatives = vec![
135 r"<function_calls>.*?</function_calls>".to_string(),
136 r"<|DSML|tool_calls>.*?</|DSML|tool_calls>".to_string(),
137 r"<|DSML|invoke\b[^>]*>.*?</|DSML|invoke>".to_string(),
138 r"<\|DSML\|tool_calls>.*?</\|DSML\|tool_calls>".to_string(),
139 r"<\|DSML\|invoke\b[^>]*>.*?</\|DSML\|invoke>".to_string(),
140 r"<\|dsml\|tool_calls>.*?</\|dsml\|tool_calls>".to_string(),
141 r"<\|dsml\|invoke\b[^>]*>.*?</\|dsml\|invoke>".to_string(),
142 r"<\|tool_calls>.*?</\|tool_calls>".to_string(),
143 ];
144 alternatives.extend(
145 LITERAL_FAKE_WRAPPER_PAIRS
146 .iter()
147 .map(|(start, end)| format!("{}.*?{}", regex::escape(start), regex::escape(end))),
148 );
149 Regex::new(&format!("(?s){}", alternatives.join("|")))
150 .expect("fake tool wrapper regex pattern is valid")
151 })
152 }
153
154 /// Parse tool calls from text content.
155 /// Returns the clean text (with markers removed) and any parsed tool calls.
156 pub fn parse_tool_calls(text: &str) -> ParseResult {
157 let mut tool_calls = Vec::new();
158 let mut clean_text = text.to_string();
159 let mut id_counter = 0;
160
161 // First, remove thinking tags
162 let thinking_regex = get_thinking_regex();
163 clean_text = thinking_regex.replace_all(&clean_text, "").to_string();
164
165 // Parse [TOOL_CALL] format
166 let regex = get_tool_call_regex();
167 for cap in regex.captures_iter(text) {
168 let (Some(full_match), Some(inner)) = (cap.get(0), cap.get(1)) else {
169 continue;
170 };
171 let full_match = full_match.as_str();
172 let inner = inner.as_str().trim();
173
174 if let Some(parsed) = parse_tool_call_inner(inner, &mut id_counter) {
175 tool_calls.push(parsed);
176 }
177
178 clean_text = clean_text.replace(full_match, "");
179 }
180
181 // Parse XML-style <codewhale:tool_call> or <tool_call> format
182 let xml_regex = get_xml_tool_call_regex();
183 for cap in xml_regex.captures_iter(text) {
184 let (Some(full_match), Some(inner)) = (cap.get(0), cap.get(1)) else {
185 continue;
186 };
187 let full_match = full_match.as_str();
188 let inner = inner.as_str().trim();
189
190 // Parse invoke blocks inside
191 if let Some(parsed) = parse_invoke_block(inner, &mut id_counter) {
192 tool_calls.push(parsed);
193 } else if let Some(parsed) = parse_tool_call_inner(inner, &mut id_counter) {
194 tool_calls.push(parsed);
195 }
196
197 clean_text = clean_text.replace(full_match, "");
198 }
199
200 // Also parse standalone <invoke> blocks that might not be wrapped
201 let invoke_regex = get_invoke_regex();
202 for cap in invoke_regex.captures_iter(&clean_text.clone()) {
203 let (Some(full_match), Some(tool_name), Some(inner)) = (cap.get(0), cap.get(1), cap.get(2))
204 else {
205 continue;
206 };
207 let full_match = full_match.as_str();
208 let tool_name = tool_name.as_str();
209 let inner = inner.as_str();
210
211 let args = parse_xml_parameters(inner);
212 id_counter += 1;
213 tool_calls.push(ParsedToolCall {
214 name: tool_name.to_string(),
215 args,
216 id: format!("xml_tool_{id_counter}"),
217 });
218
219 clean_text = clean_text.replace(full_match, "");
220 }
221
222 clean_text = get_fake_tool_wrapper_regex()
223 .replace_all(&clean_text, "")
224 .to_string();
225
226 // Clean up extra whitespace and empty lines
227 clean_text = clean_text
228 .lines()
229 .filter(|line| !line.trim().is_empty())
230 .collect::<Vec<_>>()
231 .join("\n")
232 .trim()
233 .to_string();
234
235 ParseResult {
236 clean_text,
237 tool_calls,
238 }
239 }
240
241 /// Parse an `<invoke>` block into a tool call.
242 fn parse_invoke_block(content: &str, id_counter: &mut u32) -> Option<ParsedToolCall> {
243 let invoke_regex = get_invoke_regex();
244 let cap = invoke_regex.captures(content)?;
245
246 let tool_name = cap.get(1)?.as_str();
247 let inner = cap.get(2)?.as_str();
248
249 let args = parse_xml_parameters(inner);
250
251 *id_counter += 1;
252 Some(ParsedToolCall {
253 name: tool_name.to_string(),
254 args,
255 id: format!("xml_tool_{id_counter}"),
256 })
257 }
258
259 /// Parse XML-style parameters like <parameter name="foo">value</parameter>
260 fn parse_xml_parameters(content: &str) -> Value {
261 let param_regex = Regex::new(
262 "<(?:parameter|param)\\s+name\\s*=\\s*\"([^\"]+)\"[^>]*>(.*?)</(?:parameter|param)>",
263 )
264 .ok();
265 let simple_tag_regex =
266 Regex::new("<([a-zA-Z_][a-zA-Z0-9_]*)>(.*?)</([a-zA-Z_][a-zA-Z0-9_]*)>").ok();
267
268 let mut map = serde_json::Map::new();
269
270 // Try parsing <parameter name="...">value</parameter>
271 if let Some(regex) = param_regex {
272 for cap in regex.captures_iter(content) {
273 if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) {
274 let name_str = name.as_str();
275 let value_str = value.as_str().trim();
276
277 // Try to parse as JSON, otherwise use as string
278 let json_value = serde_json::from_str(value_str)
279 .unwrap_or_else(|_| Value::String(value_str.to_string()));
280 map.insert(name_str.to_string(), json_value);
281 }
282 }
283 }
284
285 // Also try parsing <tagname>value</tagname> format
286 if let Some(regex) = simple_tag_regex {
287 for cap in regex.captures_iter(content) {
288 if let (Some(name), Some(value), Some(close)) = (cap.get(1), cap.get(2), cap.get(3)) {
289 if name.as_str() != close.as_str() {
290 continue;
291 }
292 let name_str = name.as_str();
293 // Skip known wrapper tags
294 if ["invoke", "tool_call", "parameter", "param"].contains(&name_str) {
295 continue;
296 }
297 let value_str = value.as_str().trim();
298 if !map.contains_key(name_str) {
299 let json_value = serde_json::from_str(value_str)
300 .unwrap_or_else(|_| Value::String(value_str.to_string()));
301 map.insert(name_str.to_string(), json_value);
302 }
303 }
304 }
305 }
306
307 Value::Object(map)
308 }
309
310 /// Parse the inner content of a `TOOL_CALL` block.
311 fn parse_tool_call_inner(inner: &str, id_counter: &mut u32) -> Option<ParsedToolCall> {
312 // Try to parse as JSON first
313 if let Ok(json) = serde_json::from_str::<Value>(inner) {
314 return parse_from_json(&json, id_counter);
315 }
316
317 // Once the arrow format is recognized, invalid arguments must not fall
318 // through to the looser name search inside the argument text.
319 let tool_regex = Regex::new(r#"tool\s*=>\s*"([^"]+)""#).ok()?;
320 if let Some(cap) = tool_regex.captures(inner) {
321 let name = cap.get(1)?.as_str().to_string();
322 return parse_arrow_syntax(inner, name, id_counter);
323 }
324
325 if inner.starts_with('{') && extract_braced_object(inner, ArgumentSyntax::Json)? != inner {
326 return None;
327 }
328
329 // Try to extract tool name and args from any format
330 parse_flexible_format(inner, id_counter)
331 }
332
333 /// Parse from JSON object.
334 fn parse_from_json(json: &Value, id_counter: &mut u32) -> Option<ParsedToolCall> {
335 let obj = json.as_object()?;
336
337 // Try different field names for the tool name
338 let name = obj
339 .get("tool")
340 .or_else(|| obj.get("name"))
341 .or_else(|| obj.get("function"))
342 .and_then(|v| v.as_str())?
343 .to_string();
344
345 // Try different field names for the arguments
346 let args = obj
347 .get("args")
348 .or_else(|| obj.get("arguments"))
349 .or_else(|| obj.get("input"))
350 .or_else(|| obj.get("parameters"))
351 .cloned()
352 .unwrap_or(json!({}));
353
354 *id_counter += 1;
355 Some(ParsedToolCall {
356 name,
357 args,
358 id: format!("text_tool_{id_counter}"),
359 })
360 }
361
362 /// Parse the arrow syntax: {tool => "name", args => {...}}
363 fn parse_arrow_syntax(inner: &str, name: String, id_counter: &mut u32) -> Option<ParsedToolCall> {
364 // Match the same whitespace-tolerant arrow form as the tool name.
365 let args_regex = Regex::new(r"\bargs\s*=>").ok()?;
366 let args = if let Some(args_start) = args_regex.find(inner) {
367 let args_str = inner[args_start.end()..].trim();
368 let syntax = if args_str.strip_prefix('{').is_some_and(|content| {
369 let content = content.trim_start();
370 !content.starts_with('"') && !content.starts_with('}')
371 }) {
372 ArgumentSyntax::Cli
373 } else {
374 ArgumentSyntax::Json
375 };
376 // The enclosing arrow object must close too. Its arguments decide
377 // the quote rules: CLI backslashes are literal, unlike JSON escapes.
378 if inner.starts_with('{') && extract_braced_object(inner, syntax)? != inner {
379 return None;
380 }
381 // Try to parse as JSON first
382 if let Ok(args_json) = serde_json::from_str::<Value>(args_str) {
383 args_json
384 } else {
385 let object = extract_braced_object(args_str, syntax)?;
386 if let Ok(json) = serde_json::from_str::<Value>(object) {
387 json
388 } else {
389 let content = &object[1..object.len() - 1];
390 // A broken JSON object is not a CLI argument list, even if a
391 // string inside it happens to contain `key=value` text.
392 if matches!(syntax, ArgumentSyntax::Json) {
393 return None;
394 }
395 // Try CLI-style args: --arg_name "value" or --arg_name value
396 let args = parse_cli_style_args(content);
397 if args.as_object()?.is_empty() {
398 return None;
399 }
400 args
401 }
402 }
403 } else {
404 if inner.starts_with('{') && extract_braced_object(inner, ArgumentSyntax::Json)? != inner {
405 return None;
406 }
407 json!({})
408 };
409
410 *id_counter += 1;
411 Some(ParsedToolCall {
412 name,
413 args,
414 id: format!("text_tool_{id_counter}"),
415 })
416 }
417
418 /// Parse CLI-style arguments: --`arg_name` "value" or --`arg_name` value
419 fn parse_cli_style_args(content: &str) -> Value {
420 let mut map = serde_json::Map::new();
421
422 // Pattern: --arg_name "value" or --arg_name 'value' or --arg_name value
423 let arg_regex =
424 Regex::new(r#"--([a-zA-Z_][a-zA-Z0-9_]*)\s+(?:"([^"]*)"|'([^']*)'|(\S+))"#).ok();
425
426 if let Some(regex) = arg_regex {
427 for cap in regex.captures_iter(content) {
428 if let Some(arg_name) = cap.get(1) {
429 let arg_name = arg_name.as_str();
430 // Get the value from whichever capture group matched
431 let value = cap
432 .get(2)
433 .or_else(|| cap.get(3))
434 .or_else(|| cap.get(4))
435 .map_or("", |m| m.as_str());
436
437 // Try to parse as JSON value, otherwise use as string
438 let json_value = serde_json::from_str(value)
439 .unwrap_or_else(|_| Value::String(value.to_string()));
440 map.insert(arg_name.to_string(), json_value);
441 }
442 }
443 }
444
445 // Also try simple key=value format
446 let kv_regex =
447 Regex::new(r#"([a-zA-Z_][a-zA-Z0-9_]*)\s*[:=]\s*(?:"([^"]*)"|'([^']*)'|(\S+))"#).ok();
448 if let Some(regex) = kv_regex {
449 for cap in regex.captures_iter(content) {
450 if let Some(key) = cap.get(1) {
451 let key = key.as_str();
452 if !map.contains_key(key) {
453 let value = cap
454 .get(2)
455 .or_else(|| cap.get(3))
456 .or_else(|| cap.get(4))
457 .map_or("", |m| m.as_str());
458 let json_value = serde_json::from_str(value)
459 .unwrap_or_else(|_| Value::String(value.to_string()));
460 map.insert(key.to_string(), json_value);
461 }
462 }
463 }
464 }
465
466 Value::Object(map)
467 }
468
469 /// Try to parse a flexible format.
470 fn parse_flexible_format(inner: &str, id_counter: &mut u32) -> Option<ParsedToolCall> {
471 // Look for common patterns like:
472 // tool: list_dir
473 // name: "list_dir"
474 // function: list_dir
475
476 let patterns = [(
477 r#"(?:tool|name|function)\s*[:=]\s*"?([a-zA-Z_][a-zA-Z0-9_]*)"?"#,
478 1,
479 )];
480
481 for (pattern, group) in patterns {
482 if let Ok(regex) = Regex::new(pattern)
483 && let Some(cap) = regex.captures(inner)
484 && let Some(name_match) = cap.get(group)
485 {
486 let name = name_match.as_str().to_string();
487
488 // Missing arguments may be empty; a present but malformed object
489 // must not silently turn into an empty-argument tool call.
490 let args = if inner.contains('{') {
491 extract_json_object(inner)?
492 } else {
493 json!({})
494 };
495
496 *id_counter += 1;
497 return Some(ParsedToolCall {
498 name,
499 args,
500 id: format!("text_tool_{id_counter}"),
501 });
502 }
503 }
504
505 None
506 }
507
508 /// Extract the first JSON object from a string.
509 fn extract_json_object(text: &str) -> Option<Value> {
510 serde_json::from_str(extract_braced_object(text, ArgumentSyntax::Json)?).ok()
511 }
512
513 #[derive(Clone, Copy)]
514 enum ArgumentSyntax {
515 Json,
516 Cli,
517 }
518
519 /// Extract one balanced object without treating quoted braces as delimiters.
520 /// Both JSON and the legacy CLI argument form use this byte-safe boundary scan.
521 fn extract_braced_object(text: &str, syntax: ArgumentSyntax) -> Option<&str> {
522 let start = text.find('{')?;
523 let mut depth = 0usize;
524 let mut quote = None;
525 let mut escaped = false;
526 let mut at_value_start = true;
527
528 for (offset, ch) in text[start..].char_indices() {
529 if let Some(delimiter) = quote {
530 if escaped {
531 escaped = false;
532 } else if matches!(syntax, ArgumentSyntax::Json) && ch == '\\' {
533 escaped = true;
534 } else if ch == delimiter {
535 quote = None;
536 at_value_start = false;
537 }
538 continue;
539 }
540 match ch {
541 '"' if matches!(syntax, ArgumentSyntax::Json) || at_value_start => quote = Some(ch),
542 '\'' if matches!(syntax, ArgumentSyntax::Cli) && at_value_start => quote = Some(ch),
543 '{' => depth += 1,
544 '}' => {
545 depth -= 1;
546 if depth == 0 {
547 return Some(&text[start..start + offset + ch.len_utf8()]);
548 }
549 }
550 _ => {}
551 }
552 // The existing CLI regex recognizes quotes only at the start of a
553 // value; an apostrophe inside O'Brien.txt is ordinary filename data.
554 at_value_start = ch.is_whitespace() || matches!(ch, '{' | ':' | '=' | '>');
555 }
556 None
557 }
558
559 /// Check if text contains tool call markers (either format).
560 pub fn has_tool_call_markers(text: &str) -> bool {
561 text.contains("[TOOL_CALL]")
562 || text.contains("<codewhale:tool_call")
563 || text.contains("<tool_call")
564 || text.contains("<invoke ")
565 || FAKE_TOOL_CALL_MARKERS
566 .iter()
567 .any(|marker| text.contains(marker))
568 }
569
570 #[cfg(test)]
571 mod tests {
572 use super::*;
573
574 #[test]
575 fn test_parse_arrow_syntax() {
576 let text = r#"I'll list the directory.
577 [TOOL_CALL]
578 {tool => "list_dir", args => {}}
579 [/TOOL_CALL]"#;
580
581 let result = parse_tool_calls(text);
582 assert_eq!(result.tool_calls.len(), 1);
583 assert_eq!(result.tool_calls[0].name, "list_dir");
584 assert_eq!(result.clean_text, "I'll list the directory.");
585 }
586
587 #[test]
588 fn test_parse_json_syntax() {
589 let text = r#"Let me check.
590 [TOOL_CALL]
591 {"tool": "read_file", "args": {"path": "test.txt"}}
592 [/TOOL_CALL]"#;
593
594 let result = parse_tool_calls(text);
595 assert_eq!(result.tool_calls.len(), 1);
596 assert_eq!(result.tool_calls[0].name, "read_file");
597 assert_eq!(result.tool_calls[0].args["path"], "test.txt");
598 }
599
600 #[test]
601 fn unicode_and_quoted_braces_preserve_json_arguments() {
602 for args in [
603 json!({"content": "日本語"}),
604 json!({"content": "你好世界,这是一个测试"}),
605 json!({"content": "café 🐋"}),
606 json!({
607 "content": "literal } then {, escaped \"quote }\", and \\ slash",
608 "nested": {"items": [{"text": "{深い}"}]},
609 }),
610 ] {
611 for body in [
612 format!(r#"{{tool => "write_file", args => {args}}}"#),
613 format!(r#"{{tool=>"write_file", args=>{args}}}"#),
614 format!("{{tool\t=>\"write_file\", args\t\n=>{args}}}"),
615 format!("tool: write_file {args}"),
616 ] {
617 let result = parse_tool_calls(&format!("[TOOL_CALL]{body}[/TOOL_CALL]"));
618 assert_eq!(result.tool_calls.len(), 1, "{body}");
619 assert_eq!(result.tool_calls[0].name, "write_file");
620 assert_eq!(result.tool_calls[0].args, args, "{body}");
621 }
622 }
623 }
624
625 #[test]
626 fn unicode_and_quoted_braces_preserve_cli_arguments() {
627 let result = parse_tool_calls(
628 r#"[TOOL_CALL]
629 {tool => "write_file", args => {
630 --path "鲸鱼.md"
631 --content '你好 } { 世界'
632 }}
633 [/TOOL_CALL]"#,
634 );
635 assert_eq!(result.tool_calls.len(), 1);
636 assert_eq!(
637 result.tool_calls[0].args,
638 json!({"path": "鲸鱼.md", "content": "你好 } { 世界"})
639 );
640
641 // The legacy CLI grammar treats quoted backslashes literally,
642 // including one immediately before the closing quote.
643 let result = parse_tool_calls(
644 r#"[TOOL_CALL]{tool => "write_file", args => {--path '目录\' --content 'literal } {\'}}[/TOOL_CALL]"#,
645 );
646 assert_eq!(result.tool_calls.len(), 1);
647 assert_eq!(
648 result.tool_calls[0].args,
649 json!({"path": "目录\\", "content": "literal } {\\"})
650 );
651
652 for (arguments, path) in [
653 (r#"--path "C:\""#, "C:\\"),
654 (r#"--path 'C:\'"#, "C:\\"),
655 ("--path O'Brien.txt", "O'Brien.txt"),
656 (r#"path="C:\""#, "C:\\"),
657 ("path=O'Brien.txt", "O'Brien.txt"),
658 (r#"--path "O'Brien {界}.txt""#, "O'Brien {界}.txt"),
659 ] {
660 let text =
661 format!(r#"[TOOL_CALL]{{tool=>"read_file", args=>{{{arguments}}}}}[/TOOL_CALL]"#);
662 let result = parse_tool_calls(&text);
663 assert_eq!(result.tool_calls.len(), 1, "{text}");
664 assert_eq!(result.tool_calls[0].name, "read_file");
665 assert_eq!(result.tool_calls[0].args["path"], path, "{text}");
666 }
667 }
668
669 #[test]
670 fn malformed_objects_do_not_become_empty_or_nested_tool_calls() {
671 for body in [
672 r#"{tool => "write_file", args => {"#,
673 // The argument object closes, but the enclosing arrow object does not.
674 r#"{tool => "write_file", args => {"content":"日本語"}"#,
675 r#"{tool => "write_file", args => {"content":"日本語",}}"#,
676 r#"{tool => "write_file", args=> {"content":"x",}}"#,
677 r#"{tool => "write_file", args => {"content":"x",}}"#,
678 "{tool => \"write_file\", args\t\n=> {\"content\":\"x\",}}",
679 // Neither the tool name nor CLI-looking text in a broken JSON
680 // string may be reinterpreted as a different argument format.
681 r#"{tool => "write_file", args => {"content":"tool: exec_shell key=value",}}"#,
682 r#"{tool => "write_file", args => {--content "unterminated}}"#,
683 r#"{tool => "read_file", args => {--path "C:\"}"#,
684 r#"{tool => "read_file", args => {--path O'Brien.txt}"#,
685 r#"tool: write_file {"#,
686 r#"tool: write_file {"content":"日本語""#,
687 r#"tool: write_file {"content":"日本語",}"#,
688 r#"tool: write_file {"content":"escaped quote \"}"#,
689 ] {
690 let result = parse_tool_calls(&format!("[TOOL_CALL]{body}[/TOOL_CALL]"));
691 assert!(result.tool_calls.is_empty(), "{body}: {result:?}");
692 }
693 }
694
695 #[test]
696 fn flexible_call_without_an_argument_object_still_parses() {
697 for body in ["tool: list_dir", r#"{tool => "list_dir"}"#] {
698 let result = parse_tool_calls(&format!("[TOOL_CALL]{body}[/TOOL_CALL]"));
699 assert_eq!(result.tool_calls.len(), 1);
700 assert_eq!(result.tool_calls[0].name, "list_dir");
701 assert_eq!(result.tool_calls[0].args, json!({}));
702 }
703 }
704
705 #[test]
706 fn test_parse_multiple_tool_calls() {
707 let text = r#"First I'll list, then read.
708 [TOOL_CALL]
709 {tool => "list_dir", args => {}}
710 [/TOOL_CALL]
711 [TOOL_CALL]
712 {tool => "read_file", args => {"path": "file.txt"}}
713 [/TOOL_CALL]"#;
714
715 let result = parse_tool_calls(text);
716 assert_eq!(result.tool_calls.len(), 2);
717 assert_eq!(result.tool_calls[0].name, "list_dir");
718 assert_eq!(result.tool_calls[1].name, "read_file");
719 }
720
721 #[test]
722 fn test_no_tool_calls() {
723 let text = "Just some regular text without any tool calls.";
724 let result = parse_tool_calls(text);
725 assert!(result.tool_calls.is_empty());
726 assert_eq!(result.clean_text, text);
727 }
728
729 #[test]
730 fn test_dsml_wrappers_are_stripped_without_execution() {
731 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";
732
733 assert!(has_tool_call_markers(text));
734 let result = parse_tool_calls(text);
735
736 assert!(result.tool_calls.is_empty());
737 assert!(result.clean_text.contains("before"));
738 assert!(result.clean_text.contains("after"));
739 assert!(!result.clean_text.contains("DSML"));
740 assert!(!result.clean_text.contains("read_file"));
741 assert!(!result.clean_text.contains("secret.txt"));
742 }
743
744 #[test]
745 fn test_ascii_dsml_wrappers_are_stripped_without_execution() {
746 let text = "before <|DSML|invoke name=\"grep_files\"><|DSML|parameter name=\"pattern\">SECRET</|DSML|parameter></|DSML|invoke> after";
747
748 assert!(has_tool_call_markers(text));
749 let result = parse_tool_calls(text);
750
751 assert!(result.tool_calls.is_empty());
752 assert!(result.clean_text.contains("before"));
753 assert!(result.clean_text.contains("after"));
754 assert!(!result.clean_text.contains("DSML"));
755 assert!(!result.clean_text.contains("grep_files"));
756 assert!(!result.clean_text.contains("SECRET"));
757 }
758
759 #[test]
760 fn test_deepseek_native_tool_tokens_are_stripped_without_execution() {
761 // #3880: DeepSeek's own tool-call tokens use `▁` (U+2581) as the word
762 // separator, so they matched none of the DSML shapes and survived into
763 // the text shown to the user.
764 for (start, end) in [
765 ("<|tool▁calls▁begin|>", "<|tool▁calls▁end|>"),
766 ("<|tool▁call▁begin|>", "<|tool▁call▁end|>"),
767 ("<|tool▁calls▁begin|>", "<|tool▁calls▁end|>"),
768 ("<|tool_calls_begin|>", "<|tool_calls_end|>"),
769 ("<|tool_call_begin|>", "<|tool_call_end|>"),
770 ] {
771 let text = format!(
772 "before {start}function<|tool▁sep|>grep_files\n```json\n{{\"pattern\":\"SECRET\"}}\n```{end} after"
773 );
774
775 assert!(has_tool_call_markers(&text), "not detected: {start}");
776 let result = parse_tool_calls(&text);
777
778 // The wrapper is scrubbed, never executed: a model forging a tool
779 // call in plain text must not become a real invocation.
780 assert!(result.tool_calls.is_empty(), "{start} was executed");
781 assert!(
782 result.clean_text.contains("before"),
783 "{:?}",
784 result.clean_text
785 );
786 assert!(
787 result.clean_text.contains("after"),
788 "{:?}",
789 result.clean_text
790 );
791 assert!(
792 !result.clean_text.contains("grep_files")
793 && !result.clean_text.contains("SECRET")
794 && !result.clean_text.contains("tool▁")
795 && !result.clean_text.contains("tool_calls"),
796 "leaked {start}: {:?}",
797 result.clean_text
798 );
799 }
800 }
801
802 #[test]
803 fn test_has_markers() {
804 assert!(has_tool_call_markers("[TOOL_CALL]test[/TOOL_CALL]"));
805 assert!(has_tool_call_markers(
806 "<|DSML|tool_calls>...</|DSML|tool_calls>"
807 ));
808 assert!(!has_tool_call_markers("no markers here"));
809 }
810 }
811
811 lines RUST