Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions parsers/v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Rules:
For a new streaming parser family, add or update these files:

- `parsers/v2/src/tool_calling/<family>.rs` for the parser implementation.
- `parsers/v2/src/tool_calling/mod.rs` for the family registry entry.
- `parsers/v2/src/tool_calling/registry.rs` for the family registry entry.
- `conformance/toolcalling/fixtures-stream-v2/<family>/TOOLCALLING.streamv2.*.yaml` for per-chunk stream captures.
- `conformance/toolcalling/fixtures-batch-on-stream-v2/<family>/TOOLCALLING.batch*.yaml` for complete batch text fed through streaming parsers.
- `conformance/toolcalling/fixtures-batch-v1/<family>/TOOLCALLING.batch*.yaml` only when the family or taxonomy cases do not already exist in the v1 batch corpus.
Expand Down Expand Up @@ -200,7 +200,7 @@ In order:
3. Inspect vLLM **Python** and **SGLang** for behavior and coverage — they are the peer references the matrix compares against.
4. Decide the parser family id and peer parser names; add a row to `conformance/utils/src/parser_families.yaml` (`vllm_python` / `vllm_rust` / `sglang_python` / `dynamo_v2` / `preferred_input`), AND declare the family's grammar tokens in the `markers:` section of the same file (`pairs` / `singletons` / `leak`; explicit `{}` for markup-less grammars). The `↯` leak detector and the popup token coloring are derived from that declaration — skipping it makes leaks render as clean cells and every token render as a red orphan, and `check.sh coverage` fails on it.
5. Implement `parsers/v2/src/tool_calling/<family>.rs`, returning `ToolParseResult` from every chunk; start from `harmony.rs` (token/channel grammar) or `dsml.rs` (text incremental state machine).
6. Register the family in `create_tool_parser_for_family` in `parsers/v2/src/tool_calling/mod.rs`; override `prefers_tokens()` if the parser is token-native.
6. Register the family in `create_tool_parser_for_family` in `parsers/v2/src/tool_calling/registry.rs`; override `prefers_tokens()` if the parser is token-native.
7. Add Rust unit tests for: one call, multiple calls, partial chunks, malformed recovery, `normal_text`, and EOF.
8. Add or update fixture files (see "Which Fixture Do I Edit?"). What "complete" means is machine-readable: `conformance/case-taxonomy.yaml` lists every case group and sub-case with requiredness. Run `conformance/utils/check.sh coverage --family <family>` — its FAIL list IS your fixture TODO list; a case your grammar cannot express gets a placeholder entry with an `explanation:` instead of silence.
9. Capture one case (`conformance/utils/capture.sh dynamo-stream --fixture … --output …`), inspect, fix the parser, then capture all peer behavior (`capture.sh stream` / `capture.sh batch-on-stream`, optionally `--family <family>`).
Expand Down
5 changes: 4 additions & 1 deletion parsers/v2/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

//! Dynamo parser v2 implementations.

pub mod structural_tag;
pub mod tool_calling;
pub mod unified;

Expand All @@ -19,7 +20,9 @@ pub use tool_calling::minimax_m3::MiniMaxM3ToolStreamParser;
pub use tool_calling::muse_glimmer::MuseGlimmerToolStreamParser;
pub use tool_calling::qwen3_coder::Qwen3CoderToolStreamParser;
pub use tool_calling::traits::{Tool, ToolCallDelta, ToolParseResult, ToolParser, ToolParserInput};
pub use tool_calling::{REGISTERED_FAMILIES, create_tool_parser_for_family};
pub use tool_calling::{
REGISTERED_FAMILIES, create_tool_parser_for_family, structural_tag_builder_for_family,
};
// Vendored batch-extraction types that surface in the public streaming API
// (e.g. `ToolStreamResult::tool_call_chunks`). v2 owns these now — see
// `tool_calling::v1core`.
Expand Down
300 changes: 300 additions & 0 deletions parsers/v2/src/structural_tag/builders/deepseek_dsml.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use super::{StructuralTagBuilder, ToolCallGrammar, triggered_calls_format};
use crate::structural_tag::policy::{
ResolvedToolCallingPolicy, ToolCallingMode, resolve_tool_schema,
};
use crate::structural_tag::wire::{
ConstStringFormat, Format, JsonSchemaFormat, JsonSchemaStyle, SequenceFormat, TagFormat,
TagsWithSeparatorFormat,
};

pub(crate) static DEEPSEEK_DSML: StructuralTagBuilder =
StructuralTagBuilder::new(&DEEPSEEK_DSML_FORMAT);

static DEEPSEEK_DSML_FORMAT: DeepSeekDsmlFormat = DeepSeekDsmlFormat;

struct DeepSeekDsmlFormat;

const TRIGGER: &str = "<|DSML|tool_calls>";
const TOOL_CALLS_PREFIX: &str = "\n\n";
const TOOL_CALLS_BEGIN: &str = "<|DSML|tool_calls>\n";
const TOOL_CALLS_END: &str = "</|DSML|tool_calls>";
const BARE_INVOKE_BEGIN: &str = "<|DSML|invoke name=";
const INVOKE_BEGIN_PREFIX: &str = "<|DSML|invoke name=\"";
const INVOKE_BEGIN_SUFFIX: &str = "\">\n";
const INVOKE_END: &str = "</|DSML|invoke>\n";
const INVOKE_SEPARATOR: &str = "";
const REASONING_BEGIN: &str = "<think>";
const REASONING_END: &str = "</think>";

impl DeepSeekDsmlFormat {
fn tool_call_block(
&self,
policy: &ResolvedToolCallingPolicy<'_>,
tool_arguments_any_order: bool,
) -> TagFormat {
let invoke_tags = policy
.tools
.iter()
.map(|tool| TagFormat {
begin: [INVOKE_BEGIN_PREFIX, &tool.name, INVOKE_BEGIN_SUFFIX].concat(),
content: Box::new(Format::JsonSchema(JsonSchemaFormat {
json_schema: resolve_tool_schema(tool, policy.schema_mode),
style: JsonSchemaStyle::DeepseekXml,
any_order: tool_arguments_any_order,
})),
end: INVOKE_END.to_string(),
})
.collect();

TagFormat {
begin: TOOL_CALLS_BEGIN.to_string(),
content: Box::new(Format::TagsWithSeparator(TagsWithSeparatorFormat {
tags: invoke_tags,
separator: INVOKE_SEPARATOR.to_string(),
at_least_one: true,
stop_after_first: policy.stop_after_first(),
})),
end: TOOL_CALLS_END.to_string(),
}
}
}

impl ToolCallGrammar for DeepSeekDsmlFormat {
fn reasoning_begin(&self) -> Option<&'static str> {
Some(REASONING_BEGIN)
}

fn reasoning_end(&self) -> Option<&'static str> {
Some(REASONING_END)
}

fn tool_call_excludes(&self) -> &'static [&'static str] {
&[TRIGGER, BARE_INVOKE_BEGIN]
}

fn build_triggered_calls(
&self,
policy: &ResolvedToolCallingPolicy<'_>,
exclude_special_tokens: bool,
tool_arguments_any_order: bool,
) -> anyhow::Result<Format> {
anyhow::ensure!(
matches!(
policy.mode,
ToolCallingMode::Auto | ToolCallingMode::Required
),
"auto or required policy expected"
);

let format = triggered_calls_format(
TRIGGER,
vec![self.tool_call_block(policy, tool_arguments_any_order)],
self.reasoning_begin(),
self.reasoning_end(),
&[BARE_INVOKE_BEGIN],
exclude_special_tokens,
policy,
);
if policy.mode != ToolCallingMode::Required {
return Ok(format);
}

Ok(Format::Sequence(SequenceFormat {
elements: vec![
Format::ConstString(ConstStringFormat {
value: TOOL_CALLS_PREFIX.to_string(),
}),
format,
],
}))
}

fn build_tool_calls_only(
&self,
policy: &ResolvedToolCallingPolicy<'_>,
tool_arguments_any_order: bool,
) -> anyhow::Result<Format> {
let mut block = self.tool_call_block(policy, tool_arguments_any_order);
block.begin.insert_str(0, TOOL_CALLS_PREFIX);
Ok(Format::Tag(block))
}
}

#[cfg(test)]
mod tests {
use serde_json::json;

use super::DEEPSEEK_DSML;
use crate::structural_tag::test_support::{build, tools};
use crate::structural_tag::{StructuralTagSchemaMode, StructuralTagToolChoice};

#[test]
fn named_builds_one_dsml_invoke() {
let tools = tools();
let actual = build(
&DEEPSEEK_DSML,
StructuralTagToolChoice::Named("get_weather"),
&tools,
Some(true),
StructuralTagSchemaMode::Auto,
false,
);

assert_eq!(
actual,
json!({
"type": "structural_tag",
"format": {
"type": "tag",
"begin": "\n\n<|DSML|tool_calls>\n",
"content": {
"type": "tags_with_separator",
"tags": [{
"type": "tag",
"begin": "<|DSML|invoke name=\"get_weather\">\n",
"content": {
"type": "json_schema",
"json_schema": tools[1].parameters,
"style": "deepseek_xml"
},
"end": "</|DSML|invoke>\n"
}],
"separator": "",
"at_least_one": true,
"stop_after_first": true
},
"end": "</|DSML|tool_calls>"
}
})
);
}

#[test]
fn required_starts_with_a_triggered_dsml_block() {
let tools = tools();
let actual = build(
&DEEPSEEK_DSML,
StructuralTagToolChoice::Required,
&tools,
Some(true),
StructuralTagSchemaMode::Auto,
false,
);

assert_eq!(actual["format"]["type"], "sequence");
assert_eq!(actual["format"]["elements"][0]["type"], "const_string");
assert_eq!(actual["format"]["elements"][0]["value"], "\n\n");
let calls = &actual["format"]["elements"][1];
assert_eq!(calls["type"], "triggered_tags");
assert_eq!(calls["triggers"], json!(["<|DSML|tool_calls>"]));
assert_eq!(calls["at_least_one"], true);
assert_eq!(calls["stop_after_first"], false);
assert_eq!(
calls["tags"][0]["content"]["tags"]
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(calls["tags"][0]["content"]["at_least_one"], true);
}

#[test]
fn auto_builds_triggered_tool_dispatch() {
let tools = tools();
let actual = build(
&DEEPSEEK_DSML,
StructuralTagToolChoice::Auto,
&tools,
None,
StructuralTagSchemaMode::Auto,
false,
);

assert_eq!(
actual,
json!({
"type": "structural_tag",
"format": {
"type": "triggered_tags",
"triggers": ["<|DSML|tool_calls>"],
"tags": [{
"type": "tag",
"begin": "<|DSML|tool_calls>\n",
"content": {
"type": "tags_with_separator",
"tags": [
{
"type": "tag",
"begin": "<|DSML|invoke name=\"add_numbers\">\n",
"content": {
"type": "json_schema",
"json_schema": true,
"style": "deepseek_xml"
},
"end": "</|DSML|invoke>\n"
},
{
"type": "tag",
"begin": "<|DSML|invoke name=\"get_weather\">\n",
"content": {
"type": "json_schema",
"json_schema": tools[1].parameters,
"style": "deepseek_xml"
},
"end": "</|DSML|invoke>\n"
}
],
"separator": "",
"at_least_one": true,
"stop_after_first": false
},
"end": "</|DSML|tool_calls>"
}],
"excludes": [
"<think>",
"</think>",
"<|DSML|invoke name="
],
"at_least_one": false,
"stop_after_first": false
}
})
);
}

#[test]
fn none_excludes_dsml_markup() {
let tools = tools();
let actual = build(
&DEEPSEEK_DSML,
StructuralTagToolChoice::None,
&tools,
None,
StructuralTagSchemaMode::Auto,
false,
);

assert_eq!(
actual,
json!({
"type": "structural_tag",
"format": {
"type": "tag",
"begin": "",
"content": {
"type": "any_text",
"excludes": [
"<|DSML|tool_calls>",
"<|DSML|invoke name="
]
},
"end": ""
}
})
);
}
}
Loading
Loading