sigit-code 1.6.3__tar.gz → 1.6.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. sigit_code-1.6.4/.sigit/code-cloud-agent/config.toml +6 -0
  2. sigit_code-1.6.4/.sigit/code-review/config.toml +3 -0
  3. {sigit_code-1.6.3 → sigit_code-1.6.4}/CHANGELOG.md +20 -0
  4. {sigit_code-1.6.3 → sigit_code-1.6.4}/Cargo.lock +1 -1
  5. {sigit_code-1.6.3 → sigit_code-1.6.4}/Cargo.toml +1 -1
  6. {sigit_code-1.6.3 → sigit_code-1.6.4}/PKG-INFO +1 -1
  7. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/inline_tool_calls.rs +262 -1
  8. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/main.rs +2 -1
  9. {sigit_code-1.6.3 → sigit_code-1.6.4}/.agents/AGENTS.md +0 -0
  10. {sigit_code-1.6.3 → sigit_code-1.6.4}/.agents/skills/agent-client-protocol/SKILL.md +0 -0
  11. {sigit_code-1.6.3 → sigit_code-1.6.4}/.agents/skills/ai-assisted-coding/SKILL.md +0 -0
  12. {sigit_code-1.6.3 → sigit_code-1.6.4}/.agents/skills/branding/SKILL.md +0 -0
  13. {sigit_code-1.6.3 → sigit_code-1.6.4}/.agents/skills/run-sigit/SKILL.md +0 -0
  14. {sigit_code-1.6.3 → sigit_code-1.6.4}/.agents/skills/run-sigit/driver.mjs +0 -0
  15. {sigit_code-1.6.3 → sigit_code-1.6.4}/.agents/skills/run-sigit/tui-smoke.sh +0 -0
  16. {sigit_code-1.6.3 → sigit_code-1.6.4}/.agents/skills/sigit-code-release/SKILL.md +0 -0
  17. {sigit_code-1.6.3 → sigit_code-1.6.4}/.agents/skills/tool-calling/SKILL.md +0 -0
  18. {sigit_code-1.6.3 → sigit_code-1.6.4}/.cargo/config.toml +0 -0
  19. {sigit_code-1.6.3 → sigit_code-1.6.4}/.claude/skills/agent-client-protocol/SKILL.md +0 -0
  20. {sigit_code-1.6.3 → sigit_code-1.6.4}/.claude/skills/ai-assisted-coding/SKILL.md +0 -0
  21. {sigit_code-1.6.3 → sigit_code-1.6.4}/.claude/skills/branding/SKILL.md +0 -0
  22. {sigit_code-1.6.3 → sigit_code-1.6.4}/.claude/skills/run-sigit/SKILL.md +0 -0
  23. {sigit_code-1.6.3 → sigit_code-1.6.4}/.claude/skills/run-sigit/driver.mjs +0 -0
  24. {sigit_code-1.6.3 → sigit_code-1.6.4}/.claude/skills/run-sigit/tui-smoke.sh +0 -0
  25. {sigit_code-1.6.3 → sigit_code-1.6.4}/.claude/skills/sigit-code-release/SKILL.md +0 -0
  26. {sigit_code-1.6.3 → sigit_code-1.6.4}/.claude/skills/tool-calling/SKILL.md +0 -0
  27. {sigit_code-1.6.3 → sigit_code-1.6.4}/.github/workflows/ci.yml +0 -0
  28. {sigit_code-1.6.3 → sigit_code-1.6.4}/.github/workflows/release-aur.yml +0 -0
  29. {sigit_code-1.6.3 → sigit_code-1.6.4}/.github/workflows/release-crates.yml +0 -0
  30. {sigit_code-1.6.3 → sigit_code-1.6.4}/.github/workflows/release-github.yml +0 -0
  31. {sigit_code-1.6.3 → sigit_code-1.6.4}/.github/workflows/release-homebrew.yml +0 -0
  32. {sigit_code-1.6.3 → sigit_code-1.6.4}/.github/workflows/release-npm.yml +0 -0
  33. {sigit_code-1.6.3 → sigit_code-1.6.4}/.github/workflows/release-nuget.yml +0 -0
  34. {sigit_code-1.6.3 → sigit_code-1.6.4}/.github/workflows/release-pypi.yml +0 -0
  35. {sigit_code-1.6.3 → sigit_code-1.6.4}/.github/workflows/release-scoop.yml +0 -0
  36. {sigit_code-1.6.3 → sigit_code-1.6.4}/.github/workflows/release-winget.yml +0 -0
  37. {sigit_code-1.6.3 → sigit_code-1.6.4}/.gitignore +0 -0
  38. {sigit_code-1.6.3 → sigit_code-1.6.4}/.nvmrc +0 -0
  39. {sigit_code-1.6.3 → sigit_code-1.6.4}/.sigit/factory/config.toml +0 -0
  40. {sigit_code-1.6.3 → sigit_code-1.6.4}/AGENTS.md +0 -0
  41. {sigit_code-1.6.3 → sigit_code-1.6.4}/CLAUDE.md +0 -0
  42. {sigit_code-1.6.3 → sigit_code-1.6.4}/LICENSE +0 -0
  43. {sigit_code-1.6.3 → sigit_code-1.6.4}/README.md +0 -0
  44. {sigit_code-1.6.3 → sigit_code-1.6.4}/docs/headless.md +0 -0
  45. {sigit_code-1.6.3 → sigit_code-1.6.4}/docs/hooks.md +0 -0
  46. {sigit_code-1.6.3 → sigit_code-1.6.4}/docs/mcp.md +0 -0
  47. {sigit_code-1.6.3 → sigit_code-1.6.4}/examples/settings-with-hooks.toml +0 -0
  48. {sigit_code-1.6.3 → sigit_code-1.6.4}/examples/skills/README.md +0 -0
  49. {sigit_code-1.6.3 → sigit_code-1.6.4}/examples/skills/commit-message/SKILL.md +0 -0
  50. {sigit_code-1.6.3 → sigit_code-1.6.4}/npm/README.md.tmpl +0 -0
  51. {sigit_code-1.6.3 → sigit_code-1.6.4}/npm/package-compat.json.tmpl +0 -0
  52. {sigit_code-1.6.3 → sigit_code-1.6.4}/npm/package-main.json.tmpl +0 -0
  53. {sigit_code-1.6.3 → sigit_code-1.6.4}/npm/package.json.tmpl +0 -0
  54. {sigit_code-1.6.3 → sigit_code-1.6.4}/npm/scripts/render-main-package.cjs +0 -0
  55. {sigit_code-1.6.3 → sigit_code-1.6.4}/npm/scripts/render-platform-package.cjs +0 -0
  56. {sigit_code-1.6.3 → sigit_code-1.6.4}/npm/sigit/.gitignore +0 -0
  57. {sigit_code-1.6.3 → sigit_code-1.6.4}/npm/sigit/README.md +0 -0
  58. {sigit_code-1.6.3 → sigit_code-1.6.4}/npm/sigit/package.json +0 -0
  59. {sigit_code-1.6.3 → sigit_code-1.6.4}/npm/sigit/src/index.ts +0 -0
  60. {sigit_code-1.6.3 → sigit_code-1.6.4}/npm/sigit/tsconfig.json +0 -0
  61. {sigit_code-1.6.3 → sigit_code-1.6.4}/nuget/.gitignore +0 -0
  62. {sigit_code-1.6.3 → sigit_code-1.6.4}/nuget/sigit/Program.cs +0 -0
  63. {sigit_code-1.6.3 → sigit_code-1.6.4}/nuget/sigit/README.md +0 -0
  64. {sigit_code-1.6.3 → sigit_code-1.6.4}/nuget/sigit/SiGit.Code.csproj +0 -0
  65. {sigit_code-1.6.3 → sigit_code-1.6.4}/packaging/aur/PKGBUILD.in +0 -0
  66. {sigit_code-1.6.3 → sigit_code-1.6.4}/packaging/nfpm.yaml +0 -0
  67. {sigit_code-1.6.3 → sigit_code-1.6.4}/packaging/windows/check-static-crt.ps1 +0 -0
  68. {sigit_code-1.6.3 → sigit_code-1.6.4}/packaging/winget/getSigit.siGitCode.installer.yaml.in +0 -0
  69. {sigit_code-1.6.3 → sigit_code-1.6.4}/packaging/winget/getSigit.siGitCode.locale.en-US.yaml.in +0 -0
  70. {sigit_code-1.6.3 → sigit_code-1.6.4}/packaging/winget/getSigit.siGitCode.yaml.in +0 -0
  71. {sigit_code-1.6.3 → sigit_code-1.6.4}/pypi/README.md +0 -0
  72. {sigit_code-1.6.3 → sigit_code-1.6.4}/pypi/pyproject.toml +0 -0
  73. {sigit_code-1.6.3 → sigit_code-1.6.4}/pyproject.toml +0 -0
  74. {sigit_code-1.6.3 → sigit_code-1.6.4}/rust-toolchain.toml +0 -0
  75. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/account.rs +0 -0
  76. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/backend.rs +0 -0
  77. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/browser_auth.rs +0 -0
  78. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/chat.rs +0 -0
  79. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/client_fs.rs +0 -0
  80. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/commands.rs +0 -0
  81. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/credentials.rs +0 -0
  82. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/frontmatter.rs +0 -0
  83. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/headless.rs +0 -0
  84. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/hooks.rs +0 -0
  85. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/instructions.rs +0 -0
  86. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/mcp.rs +0 -0
  87. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/models.rs +0 -0
  88. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/permissions.rs +0 -0
  89. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/provider.rs +0 -0
  90. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/session_store.rs +0 -0
  91. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/settings.rs +0 -0
  92. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/setup.rs +0 -0
  93. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/skills.rs +0 -0
  94. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/subagents.rs +0 -0
  95. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/tools.rs +0 -0
  96. {sigit_code-1.6.3 → sigit_code-1.6.4}/src/workspace.rs +0 -0
  97. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/acp_client_fs.rs +0 -0
  98. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/acp_concurrent_sessions.rs +0 -0
  99. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/acp_endpoint_errors.rs +0 -0
  100. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/acp_images.rs +0 -0
  101. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/acp_logout.rs +0 -0
  102. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/acp_multi_root.rs +0 -0
  103. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/acp_permissions.rs +0 -0
  104. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/acp_session_list.rs +0 -0
  105. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/acp_session_load.rs +0 -0
  106. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/acp_stop_reasons.rs +0 -0
  107. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/acp_tool_stdin.rs +0 -0
  108. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/headless_mode.rs +0 -0
  109. {sigit_code-1.6.3 → sigit_code-1.6.4}/tests/version_flag.rs +0 -0
@@ -0,0 +1,6 @@
1
+ [pull_request]
2
+ base = "development"
3
+ branch_prefix = "feature"
4
+
5
+ [agent]
6
+ max_tool_rounds = 60
@@ -0,0 +1,3 @@
1
+ [review.reviewer_skill]
2
+ name = "sigit-code-review-inner-loop"
3
+ version = "1.0.0"
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.4
4
+
5
+ ### Added
6
+
7
+ - **DeepSeek DSML tool calls written into the reply text are recovered.** A
8
+ DeepSeek-family endpoint can render a tool call as literal
9
+ `<|DSML|tool_calls>` text instead of the structured `tool_calls` field,
10
+ which streamed the tag into the editor and ended the turn with nothing run.
11
+ The inline tool-call scanner that already recovered the GLM XML and Kimi K3
12
+ shapes now recovers the DSML shape too, including a marker split across
13
+ stream chunks. A block that fails to parse is still kept out of the reply
14
+ and the history, and the model is told the call did not run (#181).
15
+
16
+ ### Fixed
17
+
18
+ - **The round-cap stop message no longer carries a stray run of spaces.**
19
+ When the tool-round cap ended a turn, the closing message could collapse a
20
+ space run into the wrong spot, which the editor then rendered verbatim
21
+ (#177).
22
+
3
23
  ## 1.6.3
4
24
 
5
25
  ### Added
@@ -5778,7 +5778,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
5778
5778
 
5779
5779
  [[package]]
5780
5780
  name = "sigit"
5781
- version = "1.6.3"
5781
+ version = "1.6.4"
5782
5782
  dependencies = [
5783
5783
  "agent-client-protocol",
5784
5784
  "anyhow",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sigit"
3
- version = "1.6.3"
3
+ version = "1.6.4"
4
4
  edition = "2024"
5
5
  description = "siGit Code — ACP-compatible AI coding agent. Sí, git."
6
6
  documentation = "https://github.com/getsigit/sigit"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sigit-code
3
- Version: 1.6.3
3
+ Version: 1.6.4
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -53,7 +53,20 @@ const K3_THINK_OPEN: &str = "<|open|>think<|sep|>";
53
53
  const K3_THINK_CLOSE: &str = "<|close|>think<|sep|>";
54
54
  const K3_SEP: &str = "<|sep|>";
55
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
+ ];
57
70
 
58
71
  /// One tool call recovered from inline text.
59
72
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -275,6 +288,78 @@ fn decode_k3_attribute(value: &str) -> String {
275
288
  value.replace("&quot;", "\"").replace("&amp;", "&")
276
289
  }
277
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
+
278
363
  fn split_once<'a>(s: &'a str, delim: &str) -> Option<(&'a str, &'a str)> {
279
364
  let idx = s.find(delim)?;
280
365
  Some((&s[..idx], &s[idx + delim.len()..]))
@@ -382,6 +467,9 @@ impl<'a> StreamScanner<'a> {
382
467
  || rest
383
468
  .strip_prefix(XML_OPEN_TAG)
384
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)
385
473
  {
386
474
  ScanEvent::Malformed(rest)
387
475
  } else {
@@ -421,6 +509,10 @@ impl<'a> StreamScanner<'a> {
421
509
  Some(mut recovered) => events.append(&mut recovered),
422
510
  None => break,
423
511
  },
512
+ DSML_TOOL_CALLS_OPEN => match self.drain_dsml() {
513
+ Some(mut recovered) => events.append(&mut recovered),
514
+ None => break,
515
+ },
424
516
  K3_RESPONSE_OPEN => {
425
517
  let Some(mut unwrapped) =
426
518
  self.drain_wrapped_text(K3_RESPONSE_OPEN, K3_RESPONSE_CLOSE, true)
@@ -474,6 +566,22 @@ impl<'a> StreamScanner<'a> {
474
566
  })
475
567
  }
476
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
+
477
585
  fn drain_wrapped_text(
478
586
  &mut self,
479
587
  open: &str,
@@ -535,6 +643,14 @@ fn looks_like_k3_call(after_marker: &str) -> bool {
535
643
  rest.is_empty() || rest.starts_with(K3_CALL_OPEN)
536
644
  }
537
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
+
538
654
  fn scan_complete_text(text: &str, tools: &[ToolSpec]) -> Vec<ScanEvent> {
539
655
  let mut scanner = StreamScanner::new(tools);
540
656
  let mut events = scanner.push(text);
@@ -1231,4 +1347,149 @@ mod tests {
1231
1347
  assert_eq!(calls.len(), 1);
1232
1348
  assert_eq!(calls[0].name, "run_command");
1233
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
+ }
1234
1495
  }
@@ -308,7 +308,8 @@ fn stop_reason_for(round_cap_reached: bool, finish: backend::FinishReason) -> St
308
308
  /// when nothing follows it (issue #120).
309
309
  fn round_cap_stop_message(max_tool_rounds: usize) -> String {
310
310
  format!(
311
- "I stopped here because this turn used all {max_tool_rounds} of its tool rounds, so the task may not be finished. Reply \"continue\" to pick it back up."
311
+ "I stopped here because this turn used all {max_tool_rounds} of its tool rounds, \
312
+ so the task may not be finished. Reply \"continue\" to pick it back up."
312
313
  )
313
314
  }
314
315
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes