Add DeepSeek DSML inline tool-call recovery (#181)

DeepSeek-family endpoints can emit tool calls as literal <|DSML|tool_calls> text instead of structured tool_calls fields. This adds a recovery path for that format, matching the existing GLM XML and Kimi K3 paths: a scanner gate for prose mentions, block parsing for invoke/parameter elements, and streaming support including markers split across chunk boundaries. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 7, 2026 at 21:30 UTC fb7d28e10dc07fb3eced068befd84849eb93e29e
1 file changed +262 -1
src/inline_tool_calls.rs
+262 -1
@@ -53,7 +53,20 @@ const K3_THINK_OPEN: &str = "<|open|>think<|sep|>";
53 const K3_THINK_CLOSE: &str = "<|close|>think<|sep|>";
54 const K3_SEP: &str = "<|sep|>";
55
56 -const START_MARKERS: &[&str] = &[XML_OPEN_TAG, K3_TOOLS_OPEN, K3_RESPONSE_OPEN, K3_THINK_OPEN];
56 +const DSML_TOOL_CALLS_OPEN: &str = "<|DSML|tool_calls>";
57 +const DSML_TOOL_CALLS_CLOSE: &str = "<|DSML|/tool_calls>";
58 +const DSML_INVOKE_OPEN: &str = "<|DSML|invoke";
59 +const DSML_INVOKE_CLOSE: &str = "<|DSML|/invoke>";
60 +const DSML_PARAMETER_OPEN: &str = "<|DSML|parameter";
61 +const DSML_PARAMETER_CLOSE: &str = "<|DSML|/parameter>";
62 +
63 +const START_MARKERS: &[&str] = &[
64 + XML_OPEN_TAG,
65 + K3_TOOLS_OPEN,
66 + K3_RESPONSE_OPEN,
67 + K3_THINK_OPEN,
68 + DSML_TOOL_CALLS_OPEN,
69 +];
70
71 /// One tool call recovered from inline text.
72 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -275,6 +288,78 @@ fn decode_k3_attribute(value: &str) -> String {
288 value.replace("&quot;", "\"").replace("&amp;", "&")
289 }
290
291 +/// Parse a DeepSeek DSML `<|DSML|tool_calls>` envelope's inner text: zero or
292 +/// more `<|DSML|invoke name="$TOOL">...</|DSML|invoke>` blocks, each
293 +/// holding `<|DSML|parameter name="$KEY" string="true|false">...` entries.
294 +fn parse_dsml_block(inner: &str, tools: &[ToolSpec]) -> Option<Vec<Recovered>> {
295 + let mut calls = Vec::new();
296 + let mut rest = inner;
297 +
298 + while !rest.trim().is_empty() {
299 + rest = rest.trim_start();
300 + let header = rest.strip_prefix(DSML_INVOKE_OPEN)?;
301 + let (attr_text, after_header) = split_once(header, ">")?;
302 + let attrs = parse_k3_attributes(attr_text)?;
303 + let name = attrs.get("name")?;
304 + if name.is_empty() || !offered_tool(tools, name) {
305 + return None;
306 + }
307 +
308 + let (call_inner, after_call) = split_once(after_header, DSML_INVOKE_CLOSE)?;
309 + let arguments = parse_dsml_parameters(call_inner, tools, name)?;
310 + calls.push(Recovered {
311 + name: name.to_string(),
312 + arguments: serde_json::Value::Object(arguments).to_string(),
313 + });
314 + rest = after_call;
315 + }
316 +
317 + Some(calls)
318 +}
319 +
320 +fn parse_dsml_parameters(
321 + mut rest: &str,
322 + tools: &[ToolSpec],
323 + tool_name: &str,
324 +) -> Option<serde_json::Map<String, serde_json::Value>> {
325 + let mut args = serde_json::Map::new();
326 +
327 + while !rest.trim().is_empty() {
328 + rest = rest.trim_start();
329 + let header = rest.strip_prefix(DSML_PARAMETER_OPEN)?;
330 + let (attr_text, after_header) = split_once(header, ">")?;
331 + let attrs = parse_k3_attributes(attr_text)?;
332 + let key = attrs.get("name")?.trim();
333 + if key.is_empty() {
334 + return None;
335 + }
336 + // DSML marks each parameter's encoding with `string="true|false"`. If
337 + // an endpoint omits it, fall back to the schema-based typing the
338 + // other recovery paths use instead of mistyping the value.
339 + let is_string = match attrs.get("string").map(String::as_str) {
340 + Some("true") => true,
341 + Some("false") => false,
342 + _ => {
343 + declared_type(tools, tool_name, key)
344 + .as_deref()
345 + .unwrap_or("string")
346 + == "string"
347 + }
348 + };
349 +
350 + let (raw_value, after_param) = split_once(after_header, DSML_PARAMETER_CLOSE)?;
351 + let value = if is_string {
352 + serde_json::Value::String(raw_value.to_string())
353 + } else {
354 + serde_json::from_str(raw_value).ok()?
355 + };
356 + args.insert(key.to_string(), value);
357 + rest = after_param;
358 + }
359 +
360 + Some(args)
361 +}
362 +
363 fn split_once<'a>(s: &'a str, delim: &str) -> Option<(&'a str, &'a str)> {
364 let idx = s.find(delim)?;
365 Some((&s[..idx], &s[idx + delim.len()..]))
@@ -382,6 +467,9 @@ impl<'a> StreamScanner<'a> {
467 || rest
468 .strip_prefix(XML_OPEN_TAG)
469 .is_some_and(looks_like_xml_call)
470 + || rest
471 + .strip_prefix(DSML_TOOL_CALLS_OPEN)
472 + .is_some_and(looks_like_dsml_call)
473 {
474 ScanEvent::Malformed(rest)
475 } else {
@@ -421,6 +509,10 @@ impl<'a> StreamScanner<'a> {
509 Some(mut recovered) => events.append(&mut recovered),
510 None => break,
511 },
512 + DSML_TOOL_CALLS_OPEN => match self.drain_dsml() {
513 + Some(mut recovered) => events.append(&mut recovered),
514 + None => break,
515 + },
516 K3_RESPONSE_OPEN => {
517 let Some(mut unwrapped) =
518 self.drain_wrapped_text(K3_RESPONSE_OPEN, K3_RESPONSE_CLOSE, true)
@@ -474,6 +566,22 @@ impl<'a> StreamScanner<'a> {
566 })
567 }
568
569 + fn drain_dsml(&mut self) -> Option<Vec<ScanEvent>> {
570 + let close_rel = self.pending[DSML_TOOL_CALLS_OPEN.len()..].find(DSML_TOOL_CALLS_CLOSE)?;
571 + let close_idx = DSML_TOOL_CALLS_OPEN.len() + close_rel;
572 + let block: String = self
573 + .pending
574 + .drain(..close_idx + DSML_TOOL_CALLS_CLOSE.len())
575 + .collect();
576 + let inner = &block[DSML_TOOL_CALLS_OPEN.len()..block.len() - DSML_TOOL_CALLS_CLOSE.len()];
577 +
578 + Some(match parse_dsml_block(inner, self.tools) {
579 + Some(calls) => calls.into_iter().map(ScanEvent::ToolCall).collect(),
580 + None if looks_like_dsml_call(inner) => vec![ScanEvent::Malformed(block)],
581 + None => vec![ScanEvent::Text(block)],
582 + })
583 + }
584 +
585 fn drain_wrapped_text(
586 &mut self,
587 open: &str,
@@ -535,6 +643,14 @@ fn looks_like_k3_call(after_marker: &str) -> bool {
643 rest.is_empty() || rest.starts_with(K3_CALL_OPEN)
644 }
645
646 +/// Whether the text after a DeepSeek `<|DSML|tool_calls>` marker reads as
647 +/// the start of a call: a `<|DSML|invoke` marker after any whitespace, or
648 +/// nothing at all. A quoted marker, like the constant in this file, fails.
649 +fn looks_like_dsml_call(after_marker: &str) -> bool {
650 + let rest = after_marker.trim_start();
651 + rest.is_empty() || rest.starts_with(DSML_INVOKE_OPEN)
652 +}
653 +
654 fn scan_complete_text(text: &str, tools: &[ToolSpec]) -> Vec<ScanEvent> {
655 let mut scanner = StreamScanner::new(tools);
656 let mut events = scanner.push(text);
@@ -1231,4 +1347,149 @@ mod tests {
1347 assert_eq!(calls.len(), 1);
1348 assert_eq!(calls[0].name, "run_command");
1349 }
1350 +
1351 + #[test]
1352 + fn dsml_multiple_invokes_are_recovered_in_order() {
1353 + let tools = vec![command_output_spec()];
1354 + let Extracted { text, calls, .. } = extract(
1355 + "Working <|DSML|tool_calls><|DSML|invoke name=\"command_output\"><|DSML|parameter name=\"task_id\" string=\"false\">1<|DSML|/parameter><|DSML|/invoke><|DSML|invoke name=\"command_output\"><|DSML|parameter name=\"task_id\" string=\"false\">2<|DSML|/parameter><|DSML|/invoke><|DSML|/tool_calls>",
1356 + &tools,
1357 + );
1358 + assert_eq!(text, "Working ");
1359 + assert_eq!(calls.len(), 2);
1360 + let first: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1361 + let second: serde_json::Value = serde_json::from_str(&calls[1].arguments).unwrap();
1362 + assert_eq!(first["task_id"], 1);
1363 + assert_eq!(second["task_id"], 2);
1364 + }
1365 +
1366 + #[test]
1367 + fn dsml_string_true_is_kept_verbatim_and_string_false_is_json_typed() {
1368 + let tools = vec![run_command_spec()];
1369 + let Extracted { calls, .. } = extract(
1370 + "<|DSML|tool_calls><|DSML|invoke name=\"run_command\"><|DSML|parameter name=\"command\" string=\"true\">echo {\"a\":1}<|DSML|/parameter><|DSML|parameter name=\"run_in_background\" string=\"false\">true<|DSML|/parameter><|DSML|/invoke><|DSML|/tool_calls>",
1371 + &tools,
1372 + );
1373 + let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1374 + assert_eq!(args["command"], "echo {\"a\":1}");
1375 + assert_eq!(args["run_in_background"], true);
1376 + }
1377 +
1378 + #[test]
1379 + fn dsml_missing_string_attribute_uses_the_tool_schema() {
1380 + let tools = vec![command_output_spec()];
1381 + let Extracted { calls, .. } = extract(
1382 + "<|DSML|tool_calls><|DSML|invoke name=\"command_output\"><|DSML|parameter name=\"task_id\">2<|DSML|/parameter><|DSML|/invoke><|DSML|/tool_calls>",
1383 + &tools,
1384 + );
1385 + let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1386 + assert_eq!(args["task_id"], 2);
1387 + assert!(args["task_id"].is_number());
1388 + }
1389 +
1390 + #[test]
1391 + fn unterminated_dsml_tool_calls_block_is_dropped() {
1392 + let text = "<|DSML|tool_calls><|DSML|invoke name=\"run_command\"><|DSML|parameter name=\"command\" string=\"true\">echo hi<|DSML|/parameter>";
1393 + let Extracted {
1394 + text: out,
1395 + calls,
1396 + malformed,
1397 + } = extract(text, &[run_command_spec()]);
1398 + assert_eq!(out, "");
1399 + assert_eq!(malformed, 1);
1400 + assert!(calls.is_empty());
1401 + }
1402 +
1403 + #[test]
1404 + fn unknown_dsml_tool_is_dropped() {
1405 + let text = "<|DSML|tool_calls><|DSML|invoke name=\"delete_everything\"><|DSML|/invoke><|DSML|/tool_calls>";
1406 + let Extracted {
1407 + text: out,
1408 + calls,
1409 + malformed,
1410 + } = extract(text, &[run_command_spec()]);
1411 + assert_eq!(out, "");
1412 + assert_eq!(malformed, 1);
1413 + assert!(calls.is_empty());
1414 + }
1415 +
1416 + #[test]
1417 + fn invalid_dsml_json_typed_parameter_is_dropped() {
1418 + let text = "<|DSML|tool_calls><|DSML|invoke name=\"command_output\"><|DSML|parameter name=\"task_id\" string=\"false\">not-json<|DSML|/parameter><|DSML|/invoke><|DSML|/tool_calls>";
1419 + let Extracted {
1420 + text: out,
1421 + calls,
1422 + malformed,
1423 + } = extract(text, &[command_output_spec()]);
1424 + assert_eq!(out, "");
1425 + assert_eq!(malformed, 1);
1426 + assert!(calls.is_empty());
1427 + }
1428 +
1429 + #[test]
1430 + fn a_dsml_marker_mentioned_in_prose_stays_text() {
1431 + let tools = vec![run_command_spec()];
1432 + for text in [
1433 + "The marker is `<|DSML|tool_calls>` and it never closes here.",
1434 + "Quote `<|DSML|tool_calls>` then `<|DSML|/tool_calls>` in docs.",
1435 + ] {
1436 + assert_eq!(
1437 + extract(text, &tools),
1438 + Extracted {
1439 + text: text.to_string(),
1440 + ..Extracted::default()
1441 + },
1442 + "{text}"
1443 + );
1444 + }
1445 + }
1446 +
1447 + #[test]
1448 + fn scanner_recovers_dsml_tool_calls_split_across_chunk_boundaries_including_inside_the_marker()
1449 + {
1450 + let tools = vec![run_command_spec()];
1451 + let mut scanner = StreamScanner::new(&tools);
1452 + let mut text = String::new();
1453 + let mut calls = Vec::new();
1454 +
1455 + for chunk in [
1456 + "pre ",
1457 + "<|DSML",
1458 + "|tool_calls><|DSML|invoke name=\"run_command\"><|DSML|paramet",
1459 + "er name=\"command\" string=\"true\">echo hi<|DSML|/parameter><|DSML|/invoke><|DSML|/tool_calls>",
1460 + " post",
1461 + ] {
1462 + for event in scanner.push(chunk) {
1463 + match event {
1464 + ScanEvent::Text(t) => text.push_str(&t),
1465 + ScanEvent::ToolCall(c) => calls.push(c),
1466 + ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
1467 + }
1468 + }
1469 + }
1470 + if let Some(ScanEvent::Text(rest)) = scanner.finish() {
1471 + text.push_str(&rest);
1472 + }
1473 +
1474 + assert_eq!(text, "pre post");
1475 + assert_eq!(calls.len(), 1);
1476 + assert_eq!(calls[0].name, "run_command");
1477 + let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1478 + assert_eq!(args["command"], "echo hi");
1479 + }
1480 +
1481 + #[test]
1482 + fn scanner_reports_an_unclosed_dsml_tag_at_stream_end_as_malformed() {
1483 + let tools = vec![run_command_spec()];
1484 + let mut scanner = StreamScanner::new(&tools);
1485 + let events =
1486 + scanner.push("partial <|DSML|tool_calls><|DSML|invoke name=\"run_command\">");
1487 + assert_eq!(events, vec![ScanEvent::Text("partial ".to_string())]);
1488 + assert_eq!(
1489 + scanner.finish(),
1490 + Some(ScanEvent::Malformed(
1491 + "<|DSML|tool_calls><|DSML|invoke name=\"run_command\">".to_string()
1492 + ))
1493 + );
1494 + }
1495 }