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(""", "\"").replace("&", "&")
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
}