| 1 | //! Legacy parser for text-based tool calls from DeepSeek models. |
| 2 | //! |
| 3 | //! Structured tool-call items are preferred, so the engine no longer invokes |
| 4 | //! this parser. It is kept for reference/debugging. |
| 5 | //! |
| 6 | //! Some DeepSeek outputs tool calls as text in various formats: |
| 7 | //! ```text |
| 8 | //! [TOOL_CALL] |
| 9 | //! {tool => "tool_name", args => {...}} |
| 10 | //! [/TOOL_CALL] |
| 11 | //! ``` |
| 12 | //! |
| 13 | //! Or XML-style format: |
| 14 | //! ```text |
| 15 | //! <codewhale:tool_call> |
| 16 | //! <invoke name="tool_name"> |
| 17 | //! <parameter name="arg">value</parameter> |
| 18 | //! </invoke> |
| 19 | //! </codewhale:tool_call> |
| 20 | //! ``` |
| 21 | //! |
| 22 | //! This module parses these text patterns into structured tool calls. |
| 23 | |
| 24 | use regex::Regex; |
| 25 | use serde_json::{Value, json}; |
| 26 | use std::sync::OnceLock; |
| 27 | |
| 28 | /// A parsed tool call from text content. |
| 29 | #[derive(Debug, Clone)] |
| 30 | pub struct ParsedToolCall { |
| 31 | /// Tool name |
| 32 | pub name: String, |
| 33 | /// Tool arguments as JSON |
| 34 | pub args: Value, |
| 35 | /// Generated ID for the tool call |
| 36 | pub id: String, |
| 37 | } |
| 38 | |
| 39 | /// Result of parsing text for tool calls. |
| 40 | #[derive(Debug)] |
| 41 | pub struct ParseResult { |
| 42 | /// The text with tool call markers removed (for display) |
| 43 | pub clean_text: String, |
| 44 | /// Parsed tool calls found in the text |
| 45 | pub tool_calls: Vec<ParsedToolCall>, |
| 46 | } |
| 47 | |
| 48 | static TOOL_CALL_REGEX: OnceLock<Regex> = OnceLock::new(); |
| 49 | static XML_TOOL_CALL_REGEX: OnceLock<Regex> = OnceLock::new(); |
| 50 | static INVOKE_REGEX: OnceLock<Regex> = OnceLock::new(); |
| 51 | static THINKING_REGEX: OnceLock<Regex> = OnceLock::new(); |
| 52 | static FAKE_TOOL_WRAPPER_REGEX: OnceLock<Regex> = OnceLock::new(); |
| 53 | |
| 54 | const 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 | // DeepSeek native tool-call tokens (#3880). See |
| 64 | // `engine::streaming::TOOL_CALL_MARKER_PAIRS` for why the `▁` (U+2581) |
| 65 | // separator matters: these match no DSML entry, so they used to reach the |
| 66 | // user as visible text. |
| 67 | "<|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 | |
| 77 | /// Tool-call wrapper pairs whose start and end markers are plain literals, so |
| 78 | /// their regex alternative is built by escaping rather than hand-written. The |
| 79 | /// DSML entries stay hand-written above because they carry attributes |
| 80 | /// (`invoke name="…"`) and need `\b[^>]*>` rather than a literal match. |
| 81 | const 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 | |
| 100 | fn get_tool_call_regex() -> &'static Regex { |
| 101 | TOOL_CALL_REGEX.get_or_init(|| { |
| 102 | // Match [TOOL_CALL] ... [/TOOL_CALL] blocks |
| 103 | Regex::new(r"(?s)\[TOOL_CALL\]\s*(.*?)\s*\[/TOOL_CALL\]") |
| 104 | .expect("TOOL_CALL regex pattern is valid") |
| 105 | }) |
| 106 | } |
| 107 | |
| 108 | fn get_xml_tool_call_regex() -> &'static Regex { |
| 109 | XML_TOOL_CALL_REGEX.get_or_init(|| { |
| 110 | // Match <codewhale:tool_call>...</codewhale:tool_call> or similar XML patterns |
| 111 | Regex::new(r"(?s)<(?:codewhale:)?tool_call[^>]*>\s*(.*?)\s*</(?:codewhale:)?tool_call>") |
| 112 | .expect("XML tool_call regex pattern is valid") |
| 113 | }) |
| 114 | } |
| 115 | |
| 116 | fn get_invoke_regex() -> &'static Regex { |
| 117 | INVOKE_REGEX.get_or_init(|| { |
| 118 | // Match <invoke name="tool_name">...</invoke> patterns |
| 119 | Regex::new(r#"(?s)<invoke\s+name\s*=\s*"([^"]+)"[^>]*>(.*?)</invoke>"#) |
| 120 | .expect("invoke regex pattern is valid") |
| 121 | }) |
| 122 | } |
| 123 | |
| 124 | fn get_thinking_regex() -> &'static Regex { |
| 125 | THINKING_REGEX.get_or_init(|| { |
| 126 | // Match thinking blocks including partial closing tags |
| 127 | Regex::new(r"(?s)</?(?:think|thinking)[^>]*>").expect("thinking regex pattern is valid") |
| 128 | }) |
| 129 | } |
| 130 | |
| 131 | fn 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 | |
| 153 | /// Parse tool calls from text content. |
| 154 | /// Returns the clean text (with markers removed) and any parsed tool calls. |
| 155 | pub 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 | // First, remove thinking tags |
| 161 | let thinking_regex = get_thinking_regex(); |
| 162 | clean_text = thinking_regex.replace_all(&clean_text, "").to_string(); |
| 163 | |
| 164 | // Parse [TOOL_CALL] format |
| 165 | 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 | // Parse XML-style <codewhale:tool_call> or <tool_call> format |
| 181 | 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 | // Parse invoke blocks inside |
| 190 | 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 | // Also parse standalone <invoke> blocks that might not be wrapped |
| 200 | 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 up extra whitespace and empty lines |
| 226 | 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 | |
| 240 | /// Parse an `<invoke>` block into a tool call. |
| 241 | fn 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 | |
| 258 | /// Parse XML-style parameters like <parameter name="foo">value</parameter> |
| 259 | fn 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 | // Try parsing <parameter name="...">value</parameter> |
| 270 | 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 | // Try to parse as JSON, otherwise use as string |
| 277 | 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 | // Also try parsing <tagname>value</tagname> format |
| 285 | 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 | // Skip known wrapper tags |
| 293 | 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 | |
| 309 | /// Parse the inner content of a `TOOL_CALL` block. |
| 310 | fn parse_tool_call_inner(inner: &str, id_counter: &mut u32) -> Option<ParsedToolCall> { |
| 311 | // Try to parse as JSON first |
| 312 | if let Ok(json) = serde_json::from_str::<Value>(inner) { |
| 313 | return parse_from_json(&json, id_counter); |
| 314 | } |
| 315 | |
| 316 | // Try the arrow syntax: {tool => "name", args => {...}} |
| 317 | if let Some(parsed) = parse_arrow_syntax(inner, id_counter) { |
| 318 | return Some(parsed); |
| 319 | } |
| 320 | |
| 321 | // Try to extract tool name and args from any format |
| 322 | parse_flexible_format(inner, id_counter) |
| 323 | } |
| 324 | |
| 325 | /// Parse from JSON object. |
| 326 | fn parse_from_json(json: &Value, id_counter: &mut u32) -> Option<ParsedToolCall> { |
| 327 | let obj = json.as_object()?; |
| 328 | |
| 329 | // Try different field names for the tool name |
| 330 | 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 | // Try different field names for the arguments |
| 338 | 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 | |
| 354 | /// Parse the arrow syntax: {tool => "name", args => {...}} |
| 355 | fn parse_arrow_syntax(inner: &str, id_counter: &mut u32) -> Option<ParsedToolCall> { |
| 356 | // Extract tool name |
| 357 | 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 | // Extract args - try to find the JSON object after "args =>" |
| 361 | let args = if let Some(args_start) = inner.find("args =>") { |
| 362 | let args_str = inner[args_start + 7..].trim(); |
| 363 | // Try to parse as JSON first |
| 364 | 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 | // Try to extract the content between braces |
| 368 | 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 | // Try to parse as JSON |
| 386 | if let Ok(json) = serde_json::from_str::<Value>(&format!("{{{content}}}")) { |
| 387 | json |
| 388 | } else { |
| 389 | // Try CLI-style args: --arg_name "value" or --arg_name value |
| 390 | 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 | |
| 407 | /// Parse CLI-style arguments: --`arg_name` "value" or --`arg_name` value |
| 408 | fn parse_cli_style_args(content: &str) -> Value { |
| 409 | let mut map = serde_json::Map::new(); |
| 410 | |
| 411 | // Pattern: --arg_name "value" or --arg_name 'value' or --arg_name value |
| 412 | 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 | // Get the value from whichever capture group matched |
| 420 | 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 | // Try to parse as JSON value, otherwise use as string |
| 427 | 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 | // Also try simple key=value format |
| 435 | 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 | |
| 458 | /// Try to parse a flexible format. |
| 459 | fn parse_flexible_format(inner: &str, id_counter: &mut u32) -> Option<ParsedToolCall> { |
| 460 | // Look for common patterns like: |
| 461 | // tool: list_dir |
| 462 | // name: "list_dir" |
| 463 | // function: list_dir |
| 464 | |
| 465 | 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 | // Try to extract args/input as JSON |
| 478 | 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 | |
| 492 | /// Extract the first JSON object from a string. |
| 493 | fn 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 | |
| 516 | /// Check if text contains tool call markers (either format). |
| 517 | pub 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)] |
| 528 | mod 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 | // #3880: DeepSeek's own tool-call tokens use `▁` (U+2581) as the word |
| 614 | // separator, so they matched none of the DSML shapes and survived into |
| 615 | // the text shown to the user. |
| 616 | 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 | // The wrapper is scrubbed, never executed: a model forging a tool |
| 631 | // call in plain text must not become a real invocation. |
| 632 | 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 | } |
| 663 |