diff --git a/crates/symphony/README.md b/crates/symphony/README.md
index 6833353af..6b993f800 100644
--- a/crates/symphony/README.md
+++ b/crates/symphony/README.md
@@ -7,8 +7,8 @@ given, each decoded delta, the end of the stream. Every byte of output lands in
including bytes that were dropped or could not be parsed.
Status: the public types are the contract, the adapters render them for the Chat Completions,
-Responses and Messages APIs, and the engine runs a format table; the Qwen family's tables are in,
-and the other families follow one table each.
+Responses and Messages APIs, and the engine runs a format table; the tables under `formats` are
+the families recorded so far, and more follow.
Why the crate has a name: the community has spent years on many parser implementations, and none
of them is right the way we want it, SMG's own two crates included. Not right as working code;
diff --git a/crates/symphony/src/engine.rs b/crates/symphony/src/engine.rs
index c12c4ffac..2d48df1ea 100644
--- a/crates/symphony/src/engine.rs
+++ b/crates/symphony/src/engine.rs
@@ -68,6 +68,7 @@ pub(crate) const BLOCK_WITHOUT_A_COMPLETE_CALL: &str =
"a tool-call block that closed without a complete call";
pub(crate) const TEXT_AFTER_THE_OBJECT: &str =
"text between a call's object and its closing marker";
+const TEXT_BETWEEN_CALLS: &str = "text between a block's calls";
/// Why a call region closed: its closing terminal (or the next opener) arrived, or the stream
/// ended.
@@ -98,6 +99,7 @@ pub struct Engine {
enum Call {
Json(json::Assembler),
Tagged(tagged::Assembler),
+ Dsml(tagged::dsml::Assembler),
}
impl Call {
@@ -108,6 +110,7 @@ impl Call {
// with an arguments state and no call syntax. The arm keeps the match total.
Some(CallSyntax::Json) | None => Self::Json(json::Assembler::new(index, id)),
Some(CallSyntax::Tagged) => Self::Tagged(tagged::Assembler::new(index, id)),
+ Some(CallSyntax::Dsml) => Self::Dsml(tagged::dsml::Assembler::new(index, id)),
}
}
@@ -115,6 +118,10 @@ impl Call {
match self {
Self::Json(assembler) => assembler.feed(text, out),
Self::Tagged(assembler) => assembler.feed(text, declared, out),
+ Self::Dsml(assembler) => {
+ assembler.feed(text, out);
+ text.len()
+ }
}
}
@@ -122,18 +129,34 @@ impl Call {
match self {
Self::Json(assembler) => assembler.started(),
Self::Tagged(assembler) => assembler.started(),
+ Self::Dsml(assembler) => assembler.started(),
}
}
- /// Ends the call the way the region closed: the JSON assembler has one ending, and the engine
- /// names a block its terminal closed (`close_call`); the tagged one closes the call for the
- /// client when the block ended, and leaves it open when the stream was cut.
- fn end(self, closed: Closed, out: &mut Events) {
+ /// Ends the call the way the region closed, and says whether the terminal that closed it was
+ /// taken as the call's end. The JSON assembler has one ending, and the engine names a block its
+ /// terminal closed (`close_call`); the Qwen tagged one closes the call for the client when the
+ /// block ended, and leaves it open when the stream was cut; the DSML one takes the invoke's
+ /// closing tag, and only that terminal, as `ToolCallEnd`'s bytes.
+ fn end(self, closed: Closed, terminal: &str, out: &mut Events) -> bool {
match (self, closed) {
(Self::Json(assembler), _) => assembler.finish(out),
(Self::Tagged(assembler), Closed::ByMarker) => assembler.close(out),
(Self::Tagged(assembler), Closed::ByEnd) => assembler.finish(out),
+ // Only the invoke's closing tag is the call's end; the next invoke's opening and the
+ // block's close end the call too, and the engine drops them as the region's. An
+ // invoke that named no call takes whichever terminal ended it, so that it is reported
+ // the same way however it ended.
+ (Self::Dsml(assembler), Closed::ByMarker)
+ if terminal == tagged::dsml::INVOKE_CLOSE || !assembler.started() =>
+ {
+ assembler.close(terminal, out);
+ return true;
+ }
+ (Self::Dsml(assembler), Closed::ByMarker) => assembler.close("", out),
+ (Self::Dsml(assembler), Closed::ByEnd) => assembler.finish(out),
}
+ false
}
}
@@ -211,15 +234,17 @@ impl Engine {
for event in assembled.drain() {
out.push(self.tokens.relabel(event));
}
- self.surplus(&text[taken..], out);
+ self.wrapping(&text[taken..], TEXT_AFTER_THE_OBJECT, out);
}
+ Emits::Wrapper => self.wrapping(text, TEXT_BETWEEN_CALLS, out),
}
}
- /// Text after a call and before its closing terminal: each run of whitespace is the template's
- /// wrapping and is dropped, each run of anything else is malformed. Classifying run by run
- /// keeps the result the same wherever the chunks were cut.
- fn surplus(&mut self, surplus: &str, out: &mut Events) {
+ /// Text where the template only wraps: after a call and before its closing terminal, or
+ /// between a block's calls. Each run of whitespace is the template's and is dropped, each run
+ /// of anything else is malformed with `why`. Classifying run by run keeps the result the same
+ /// wherever the chunks were cut.
+ fn wrapping(&mut self, surplus: &str, why: &str, out: &mut Events) {
let mut rest = surplus;
while let Some(first) = rest.chars().next() {
let space = first.is_whitespace();
@@ -236,7 +261,7 @@ impl Engine {
} else {
Event::Malformed {
text: run,
- why: MalformedReason::Other(TEXT_AFTER_THE_OBJECT.to_string()),
+ why: MalformedReason::Other(why.to_string()),
}
});
rest = &rest[length..];
@@ -252,14 +277,16 @@ impl Engine {
};
// The call's remaining events come before the terminal that closed it, so the events'
// bytes stay in the output's order; a new call before the previous one closed finishes
- // what arrived, then starts.
- if self.emits() == Emits::Arguments {
- self.close_call(Closed::ByMarker, out);
+ // what arrived, then starts. A call syntax may take the terminal as the call's end.
+ let terminal = self.format.terminal_text(index).to_string();
+ let taken =
+ self.emits() == Emits::Arguments && self.close_call(Closed::ByMarker, &terminal, out);
+ if !taken {
+ out.push(Event::Dropped {
+ text: self.tokens.text(&terminal),
+ why: DropReason::Wrapper,
+ });
}
- out.push(Event::Dropped {
- text: self.tokens.text(self.format.terminal_text(index)),
- why: DropReason::Wrapper,
- });
self.enter(next, out);
}
@@ -284,18 +311,19 @@ impl Engine {
self.call = Some(Call::new(self.format.call_syntax().copied(), self.calls));
}
- /// Ends the call region: the assembler closes what arrived. A region its terminal closed
- /// reports leftover bytes as a block without a complete call; `UnterminatedRegion` is kept for
- /// a region the end of the stream cut.
- fn close_call(&mut self, closed: Closed, out: &mut Events) {
+ /// Ends the call region: the assembler closes what arrived, and the result says whether it took
+ /// `terminal`, the bytes that closed the region, as the call's end. A region its terminal
+ /// closed reports leftover bytes as a block without a complete call; `UnterminatedRegion` is
+ /// kept for a region the end of the stream cut.
+ fn close_call(&mut self, closed: Closed, terminal: &str, out: &mut Events) -> bool {
let Some(call) = self.call.take() else {
- return;
+ return false;
};
if call.started() {
self.calls += 1;
}
let mut finished = Events::new();
- call.end(closed, &mut finished);
+ let taken = call.end(closed, terminal, &mut finished);
for event in finished.drain() {
let event = match event {
Event::Malformed {
@@ -309,6 +337,7 @@ impl Engine {
};
out.push(self.tokens.relabel(event));
}
+ taken
}
/// Where the prompt leaves the engine: its terminals replayed over the table from the initial
@@ -348,8 +377,10 @@ impl Engine {
}
match self.emits() {
Emits::Reasoning => out.push(Event::ReasoningEnd),
- Emits::Arguments => self.close_call(Closed::ByEnd, out),
- Emits::Content => {}
+ Emits::Arguments => {
+ self.close_call(Closed::ByEnd, "", out);
+ }
+ Emits::Content | Emits::Wrapper => {}
}
self.state = 0;
self.tokens.finish(out);
diff --git a/crates/symphony/src/format.rs b/crates/symphony/src/format.rs
index c476f28e6..a081f36d4 100644
--- a/crates/symphony/src/format.rs
+++ b/crates/symphony/src/format.rs
@@ -6,9 +6,10 @@
//!
//! - A **terminal** is a spelling the model writes, `` or ``, named so that a
//! transition can speak of it. Token ids come in a later step; today a terminal is its text.
-//! - A **state** says what the text inside it is: content, reasoning, or a call's arguments. Text
-//! in a content state is `Content`; in a reasoning state `Reasoning`; in an arguments state it
-//! is fed to the call syntax's assembler and becomes the call's events.
+//! - A **state** says what the text inside it is: content, reasoning, a call's arguments, or the
+//! template's wrapping between calls. Text in a content state is `Content`; in a reasoning
+//! state `Reasoning`; in an arguments state it is fed to the call syntax's assembler and becomes
+//! the call's events; in a wrapper state whitespace is dropped and anything else is malformed.
//! - A **transition** `from + terminal = to` moves the engine between states when the terminal
//! arrives in `from`. A terminal with no transition from the current state is text, where the
//! model put it: `` in content is content. Entering a reasoning state pushes
@@ -38,6 +39,11 @@ pub enum CallSyntax {
/// `` and then `` around each value's text, typed by the
/// request's tools, which reach the engine with the request: Qwen 3.5 and later, Qwen3-Coder.
Tagged,
+ /// DeepSeek's DSML: the arguments state is one `<|DSML| invoke name="…">` block, whose
+ /// parameter tags carry a `string` attribute that types each value. The terminal that enters
+ /// the state is the invoke tag's opening, and the one that leaves it is the invoke's closing
+ /// tag, which the call's end carries.
+ Dsml,
}
/// What the text inside a state is.
@@ -47,6 +53,9 @@ pub enum Emits {
Reasoning,
/// A call's arguments, assembled by the format's call syntax.
Arguments,
+ /// The template's wrapping between calls: whitespace is `Dropped { Wrapper }`, anything else
+ /// `Malformed`.
+ Wrapper,
}
/// One format's table. Built with [`Format::new`] and the methods that add a row each; the
diff --git a/crates/symphony/src/formats/deepseek_v4_1.rs b/crates/symphony/src/formats/deepseek_v4_1.rs
new file mode 100644
index 000000000..c3bdb0feb
--- /dev/null
+++ b/crates/symphony/src/formats/deepseek_v4_1.rs
@@ -0,0 +1,508 @@
+//! DeepSeek V4.1: reasoning between `` and ``, the calls of one turn between
+//! `<|DSML| calls>` and `|DSML| calls>`, each call between `<|DSML| invoke name="…">` and
+//! `|DSML| invoke>`, everything else content. The design's section 4 example, as recorded for
+//! `deepseek-ai/DeepSeek-V4.1-Flash`.
+//!
+//! What this table says beyond the Qwen ones:
+//!
+//! - **Two states for the calls.** `calls` is the template's wrapping between invokes (a newline,
+//! dropped; anything else malformed), and `invoke` is one call's arguments, read by the DSML
+//! assembler ([`tagged::dsml`](crate::tagged::dsml)). A block with several invokes gives several
+//! calls, each with its own index, through the same two rows.
+//! - **The invoke tag's opening is the terminal** that enters the arguments state, so the
+//! assembler starts inside the function's name; the closing tag is the terminal that leaves it,
+//! and the call's end carries its bytes. A second invoke before the first closed ends the first
+//! and starts the second (`invoke + invoke_open = invoke`), and the block's close ends an invoke
+//! still open (`invoke + calls_close = content`), so the call closes into an object and the
+//! prose after the block stays prose.
+//! - **A calls block ends the thought** (`reasoning + calls_open = calls`), as the design's table
+//! and SMG's V4.1 reasoning parser have it; the recorded outputs close the thought first.
+//! - **The prompt opens the thought.** The template ends the generation prompt with ``, and
+//! an empty thought writes `` at once, so most recorded outputs start with
+//! `\n\n`; the engine's prompt replay puts the output inside the thought first. The
+//! turn opener is `<|Assistant|>`, so a marker quoted in an earlier turn moves nothing.
+
+use crate::{
+ format::{CallSyntax, Emits, Format},
+ tagged::dsml,
+};
+
+/// The DeepSeek V4.1 table.
+pub fn deepseek_v4_1() -> Format {
+ Format::new("deepseek_v4_1")
+ .terminal("think_open", "")
+ .terminal("think_close", "")
+ .terminal("calls_open", "<|DSML| calls>")
+ .terminal("calls_close", "|DSML| calls>")
+ .terminal("invoke_open", "<|DSML| invoke name=\"")
+ .terminal("invoke_close", dsml::INVOKE_CLOSE)
+ .state("content", Emits::Content)
+ .state("reasoning", Emits::Reasoning)
+ .state("calls", Emits::Wrapper)
+ .state("invoke", Emits::Arguments)
+ .transition("content", "think_open", "reasoning")
+ .transition("reasoning", "think_close", "content")
+ .transition("content", "calls_open", "calls")
+ .transition("reasoning", "calls_open", "calls")
+ .transition("calls", "invoke_open", "invoke")
+ .transition("invoke", "invoke_close", "calls")
+ .transition("invoke", "invoke_open", "invoke")
+ .transition("invoke", "calls_close", "content")
+ .transition("calls", "calls_close", "content")
+ .calls(CallSyntax::Dsml)
+ .opens_turn("<|Assistant|>")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{
+ engine::Engine,
+ event::{DropReason, Event, Events, Text},
+ input::{EngineFinish, Input},
+ parser::Parser,
+ tagged::Declared,
+ };
+
+ /// A recorded output, qwen-style spacing and all: an empty thought, two invokes, a string and
+ /// a JSON value.
+ const OUTPUT: &str = concat!(
+ "\n\n<|DSML| calls>\n",
+ "<|DSML| invoke name=\"ChaDri_change_drink\">\n",
+ "<|DSML| parameter name=\"drink_id\" string=\"true\">latte|DSML| parameter>\n",
+ "<|DSML| parameter name=\"new_preferences\" string=\"false\">",
+ "{\"size\": \"large\", \"temperature\": \"hot\"}|DSML| parameter>\n",
+ "|DSML| invoke>\n",
+ "<|DSML| invoke name=\"get_stock_price\">\n\n|DSML| invoke>\n",
+ "|DSML| calls>"
+ );
+
+ fn run(prompt: &str, pieces: &[&str]) -> Vec {
+ let mut parser = Engine::new(deepseek_v4_1(), Declared::default());
+ let mut out = Events::new();
+ parser
+ .feed(
+ Input::Prompt {
+ token_ids: &[],
+ text: prompt,
+ },
+ &mut out,
+ )
+ .expect("prompt");
+ for piece in pieces {
+ parser
+ .feed(
+ Input::Delta {
+ token_ids: &[],
+ text: piece,
+ spans: &[],
+ },
+ &mut out,
+ )
+ .expect("delta");
+ }
+ parser
+ .feed(
+ Input::End {
+ finish: EngineFinish::Stop,
+ },
+ &mut out,
+ )
+ .expect("end");
+ out.drain()
+ }
+
+ fn bytes(events: &[Event]) -> String {
+ events
+ .iter()
+ .map(|e| match e {
+ Event::Content(t) | Event::Reasoning(t) => t.text.as_str(),
+ Event::Dropped { text, .. } | Event::Malformed { text, .. } => text.text.as_str(),
+ Event::ToolCallStart { source, .. }
+ | Event::ToolCallArguments { source, .. }
+ | Event::ToolCallEnd { source, .. } => source.text.as_str(),
+ Event::ReasoningStart | Event::ReasoningEnd | Event::Finish { .. } => "",
+ })
+ .collect()
+ }
+
+ fn arguments_of(events: &[Event], call: u32) -> String {
+ events
+ .iter()
+ .filter_map(|event| match event {
+ Event::ToolCallArguments { index, json, .. } if *index == call => {
+ Some(json.as_str())
+ }
+ _ => None,
+ })
+ .collect()
+ }
+
+ fn dropped(text: &str) -> Event {
+ Event::Dropped {
+ text: Text::uncounted(text),
+ why: DropReason::Wrapper,
+ }
+ }
+
+ #[test]
+ fn a_recorded_output_gives_its_two_calls_with_their_types_and_every_byte() {
+ let events = run("<|Assistant|>", &[OUTPUT]);
+ assert_eq!(bytes(&events), OUTPUT);
+ assert_eq!(
+ events[..5],
+ [
+ Event::ReasoningStart,
+ dropped(""),
+ Event::ReasoningEnd,
+ Event::Content(Text::uncounted("\n\n")),
+ dropped("<|DSML| calls>"),
+ ]
+ );
+ assert_eq!(
+ events[5],
+ dropped("\n"),
+ "the wrapping before the first invoke"
+ );
+ assert_eq!(events[6], dropped("<|DSML| invoke name=\""));
+ assert_eq!(
+ events[7],
+ Event::ToolCallStart {
+ index: 0,
+ id: "call_0".into(),
+ name: "ChaDri_change_drink".into(),
+ source: Text::uncounted("ChaDri_change_drink\">"),
+ }
+ );
+ assert_eq!(
+ arguments_of(&events, 0),
+ r#"{"drink_id": "latte", "new_preferences": {"size": "large", "temperature": "hot"}}"#
+ );
+ assert_eq!(arguments_of(&events, 1), "{}");
+ let ends: Vec<&str> = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::ToolCallEnd { source, .. } => Some(source.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(ends, ["|DSML| invoke>", "|DSML| invoke>"]);
+ assert!(matches!(
+ events.last(),
+ Some(Event::Finish { tool_calls: 2, .. })
+ ));
+ }
+
+ #[test]
+ fn a_string_value_streams_as_it_arrives_and_a_json_value_is_written_at_its_close() {
+ let pieces = [
+ "\n\n<|DSML| calls>\n<|DSML| invoke name=\"f\">\n",
+ "<|DSML| parameter name=\"city\" string=\"true\">Par",
+ "is|DSML| parameter>\n<|DSML| parameter name=\"n\" string=\"false\">1",
+ "2|DSML| parameter>\n|DSML| invoke>\n|DSML| calls>",
+ ];
+ let events = run("", &pieces);
+ let fragments: Vec<&str> = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::ToolCallArguments { json, .. } => Some(json.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(
+ fragments,
+ ["{\"city\": \"", "Par", "is", "\"", ", \"n\": 12", "}"]
+ );
+ assert_eq!(bytes(&events), pieces.concat());
+ }
+
+ #[test]
+ fn every_chunking_says_the_same_and_accounts_for_every_byte() {
+ let whole = run("", &[OUTPUT]);
+ let whole_arguments = (arguments_of(&whole, 0), arguments_of(&whole, 1));
+ for cut in 1..OUTPUT.len() {
+ if !OUTPUT.is_char_boundary(cut) {
+ continue;
+ }
+ let events = run("", &[&OUTPUT[..cut], &OUTPUT[cut..]]);
+ assert_eq!(bytes(&events), OUTPUT, "cut at {cut}");
+ assert_eq!(
+ (arguments_of(&events, 0), arguments_of(&events, 1)),
+ whole_arguments,
+ "cut at {cut}"
+ );
+ assert!(
+ matches!(events.last(), Some(Event::Finish { tool_calls: 2, .. })),
+ "cut at {cut}"
+ );
+ }
+ }
+
+ #[test]
+ fn a_stream_cut_inside_an_invoke_ends_the_call_with_its_arguments_cut() {
+ let events = run(
+ "",
+ &[concat!(
+ "\n<|DSML| calls>\n<|DSML| invoke name=\"f\">\n",
+ "<|DSML| parameter name=\"x\" string=\"false\">1"
+ )],
+ );
+ assert!(events.iter().any(|event| matches!(
+ event,
+ Event::Malformed {
+ why: crate::event::MalformedReason::UnterminatedRegion,
+ ..
+ }
+ )));
+ // The call ends, with no bytes of its own, as every assembler ends a started call at the
+ // end of the stream; its arguments stay cut, `{` without its `}`.
+ assert!(events.iter().any(|event| matches!(
+ event,
+ Event::ToolCallEnd { index: 0, source } if source.text.is_empty()
+ )));
+ assert!(matches!(
+ events.last(),
+ Some(Event::Finish { tool_calls: 1, .. })
+ ));
+ }
+
+ #[test]
+ fn a_calls_block_inside_the_thought_ends_it_and_a_second_invoke_ends_the_first() {
+ let output = concat!(
+ "Let me call.<|DSML| calls>\n<|DSML| invoke name=\"f\">\n",
+ "<|DSML| parameter name=\"a\" string=\"true\">x|DSML| parameter>\n",
+ "<|DSML| invoke name=\"g\">\n|DSML| invoke>\n|DSML| calls>"
+ );
+ let events = run("", &[output]);
+ assert_eq!(bytes(&events), output);
+ assert_eq!(
+ events[..4],
+ [
+ Event::ReasoningStart,
+ Event::Reasoning(Text::uncounted("Let me call.")),
+ dropped("<|DSML| calls>"),
+ Event::ReasoningEnd,
+ ]
+ );
+ assert_eq!(arguments_of(&events, 0), r#"{"a": "x"}"#);
+ assert_eq!(arguments_of(&events, 1), "{}");
+ assert!(matches!(
+ events.last(),
+ Some(Event::Finish { tool_calls: 2, .. })
+ ));
+ }
+
+ #[test]
+ fn the_blocks_close_ends_an_invoke_left_open_and_the_prose_after_it_stays() {
+ let output = concat!(
+ "\n<|DSML| calls>\n<|DSML| invoke name=\"f\">\n",
+ "<|DSML| parameter name=\"a\" string=\"true\">x|DSML| parameter>\n",
+ "|DSML| calls>\nThe weather is sunny."
+ );
+ let events = run("", &[output]);
+ assert_eq!(bytes(&events), output);
+ assert_eq!(arguments_of(&events, 0), r#"{"a": "x"}"#);
+ let content: String = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::Content(text) => Some(text.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ // The newline after `` is content too.
+ assert_eq!(content, "\n\nThe weather is sunny.");
+ }
+
+ #[test]
+ fn a_missing_quote_on_the_name_starts_no_call_and_the_next_invoke_takes_index_zero() {
+ // Two parameters: the first arrives while the name is still open and cuts it short, the
+ // second arrives between tags with no call started, which is the guard this test holds.
+ let output = concat!(
+ "\n<|DSML| calls>\n<|DSML| invoke name=\"f>\n",
+ "<|DSML| parameter name=\"a\" string=\"true\">x|DSML| parameter>\n",
+ "<|DSML| parameter name=\"b\" string=\"true\">y|DSML| parameter>\n",
+ "|DSML| invoke>\n<|DSML| invoke name=\"g\">\n|DSML| invoke>\n|DSML| calls>"
+ );
+ let events = run("", &[output]);
+ assert_eq!(bytes(&events), output);
+ let starts: Vec<(u32, &str)> = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::ToolCallStart { index, name, .. } => Some((*index, name.as_str())),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(starts, [(0, "g")]);
+ // No fragment before the start: everything of the unnamed invoke is reported.
+ let first_start = events
+ .iter()
+ .position(|event| matches!(event, Event::ToolCallStart { .. }))
+ .expect("g starts");
+ assert!(!events[..first_start]
+ .iter()
+ .any(|event| matches!(event, Event::ToolCallArguments { .. })));
+ assert_eq!(arguments_of(&events, 0), "{}");
+ }
+
+ #[test]
+ fn a_marker_quoted_in_an_earlier_turn_moves_nothing() {
+ let prompt = concat!(
+ "<|User|>Why did you write <|DSML| calls> and <|DSML| invoke name=\" there?",
+ "<|Assistant|>"
+ );
+ let events = run(
+ prompt,
+ &["The user asks about the tag.\n\nIt opens a calls block."],
+ );
+ assert_eq!(events[0], Event::ReasoningStart);
+ assert_eq!(
+ events[1],
+ Event::Reasoning(Text::uncounted("The user asks about the tag."))
+ );
+ }
+
+ #[test]
+ fn only_the_invokes_closing_tag_is_a_calls_end() {
+ // Three ways an invoke ends: its own closing tag, which the end carries; the next invoke's
+ // opening and the block's close, which the engine drops as the region's, so the end
+ // carries nothing.
+ let output = concat!(
+ "<|DSML| calls>\n<|DSML| invoke name=\"f\">\n|DSML| invoke>\n",
+ "<|DSML| invoke name=\"g\">\n<|DSML| invoke name=\"h\">\n|DSML| calls>"
+ );
+ let events = run("\n\n\n\n", &[output]);
+ assert_eq!(bytes(&events), output);
+ let ends: Vec<&str> = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::ToolCallEnd { source, .. } => Some(source.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(ends, ["|DSML| invoke>", "", ""]);
+ // All three openers are dropped: `f`'s and `g`'s by the `calls + invoke_open` row, `h`'s by
+ // the `invoke + invoke_open` row, which this test pins.
+ assert_eq!(
+ events
+ .iter()
+ .filter(|event| **event == dropped("<|DSML| invoke name=\""))
+ .count(),
+ 3
+ );
+ assert!(events.contains(&dropped("|DSML| calls>")));
+ assert!(matches!(
+ events.last(),
+ Some(Event::Finish { tool_calls: 3, .. })
+ ));
+ }
+
+ #[test]
+ fn an_invoke_that_named_nothing_is_reported_however_it_ended() {
+ // The invoke tag cut short before its name, ended by the block's close, by the next
+ // invoke's opening, or by its own closing tag: each ending's bytes come back as
+ // `Malformed`, and no call is counted for it.
+ for (output, ending) in [
+ (
+ "<|DSML| calls>\n<|DSML| invoke name=\"|DSML| calls>",
+ "|DSML| calls>",
+ ),
+ (
+ "<|DSML| calls>\n<|DSML| invoke name=\"<|DSML| invoke name=\"g\">\n\
+ |DSML| invoke>\n|DSML| calls>",
+ "<|DSML| invoke name=\"",
+ ),
+ (
+ "<|DSML| calls>\n<|DSML| invoke name=\"|DSML| invoke>\n|DSML| calls>",
+ "|DSML| invoke>",
+ ),
+ ] {
+ let events = run("\n\n\n\n", &[output]);
+ assert_eq!(bytes(&events), output, "{ending}");
+ assert!(
+ events.iter().any(|event| matches!(
+ event,
+ Event::Malformed { text, .. } if text.text == ending
+ )),
+ "{ending}: {events:?}"
+ );
+ }
+ let events = run(
+ "\n\n\n\n",
+ &["<|DSML| calls>\n<|DSML| invoke name=\"|DSML| calls>"],
+ );
+ assert!(matches!(
+ events.last(),
+ Some(Event::Finish { tool_calls: 0, .. })
+ ));
+ }
+
+ #[test]
+ fn a_tag_the_invokes_close_cut_short_inside_a_string_is_reported() {
+ let output = concat!(
+ "<|DSML| calls>\n<|DSML| invoke name=\"f\">\n",
+ "<|DSML| parameter name=\"a\" string=\"true\">x|DSML| par|DSML| invoke>\n",
+ "|DSML| calls>"
+ );
+ let events = run("\n\n\n\n", &[output]);
+ assert_eq!(bytes(&events), output);
+ assert_eq!(arguments_of(&events, 0), r#"{"a": "x"}"#);
+ assert!(events.iter().any(|event| matches!(
+ event,
+ Event::Malformed { text, .. } if text.text == "|DSML| par"
+ )));
+ }
+
+ #[test]
+ fn thinking_off_starts_in_content() {
+ let events = run("\n\n\n\n", &["Hello"]);
+ assert_eq!(events[0], Event::Content(Text::uncounted("Hello")));
+ }
+
+ #[test]
+ fn a_calls_block_in_an_earlier_turn_of_the_prompt_is_not_entered() {
+ // The replay starts at the last `<|Assistant|>`, so the earlier turn's block, closed or
+ // not, moves nothing; the thought the prompt opens is where the output starts.
+ for earlier in [
+ "<|DSML| calls>\n<|DSML| invoke name=\"f\">\n|DSML| invoke>\n|DSML| calls>",
+ "<|DSML| calls>\n<|DSML| invoke name=\"f\">\n",
+ ] {
+ let prompt = format!(
+ "<|User|>q<|Assistant|>{earlier}<|end▁of▁sentence|>\
+ <|User|>r<|Assistant|>"
+ );
+ let events = run(&prompt, &["A thought.\n\nAn answer."]);
+ assert_eq!(events[0], Event::ReasoningStart, "{earlier:?}");
+ assert_eq!(
+ events[1],
+ Event::Reasoning(Text::uncounted("A thought.")),
+ "{earlier:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn a_parameter_tag_with_another_attribute_is_no_parameter() {
+ let output = concat!(
+ "\n<|DSML| calls>\n<|DSML| invoke name=\"f\">\n",
+ "<|DSML| parameter name=\"x\" kind=\"y\">v|DSML| parameter>\n",
+ "<|DSML| parameter name=\"a\" string=\"true\">1|DSML| parameter>\n",
+ "|DSML| invoke>\n|DSML| calls>"
+ );
+ let events = run("", &[output]);
+ assert_eq!(bytes(&events), output);
+ assert_eq!(arguments_of(&events, 0), r#"{"a": "1"}"#);
+ let malformed: Vec<&str> = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::Malformed { text, .. } => Some(text.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert!(
+ malformed
+ .iter()
+ .any(|text| text.ends_with("<|DSML| parameter name=\"x\" kind=\"y\">")),
+ "{malformed:?}"
+ );
+ }
+}
diff --git a/crates/symphony/src/formats/mod.rs b/crates/symphony/src/formats/mod.rs
index fd86514f0..8abfc54fb 100644
--- a/crates/symphony/src/formats/mod.rs
+++ b/crates/symphony/src/formats/mod.rs
@@ -4,13 +4,16 @@
//! [`qwen3()`] is the first: `` blocks and `` blocks, each holding one call in
//! the family's call syntax, a JSON object (Qwen3) or tags (Qwen 3.5 and later, Qwen3-Coder).
//! [`qwen2_5()`] is the same family before thinking: `` blocks alone, and `` is
-//! text.
+//! text. [`deepseek_v4_1()`] is DeepSeek's DSML: a `calls` block of one or several `invoke`
+//! blocks, each a call whose parameter tags type their own values.
//!
//! [`Format`]: crate::Format
//! [`Engine`]: crate::Engine
+pub mod deepseek_v4_1;
pub mod qwen2_5;
pub mod qwen3;
+pub use deepseek_v4_1::deepseek_v4_1;
pub use qwen2_5::qwen2_5;
pub use qwen3::qwen3;
diff --git a/crates/symphony/src/lib.rs b/crates/symphony/src/lib.rs
index 1c0f45a73..473fc7c72 100644
--- a/crates/symphony/src/lib.rs
+++ b/crates/symphony/src/lib.rs
@@ -18,7 +18,7 @@
//!
//! Status: the public types below are the contract, [`adapt`] renders them for the Chat
//! Completions, Responses and Messages APIs, streamed and whole, and [`Engine`] runs a [`Format`]
-//! table: the Qwen family's tables are in, the other families follow one table each.
+//! table: the tables in [`formats`] are the families recorded so far, and more follow.
#![forbid(unsafe_code)]
@@ -37,7 +37,7 @@ pub mod tokens;
pub use engine::Engine;
pub use event::{DropReason, Event, Events, FinishReason, MalformedReason, Text};
pub use format::{CallSyntax, Emits, Format};
-pub use formats::{qwen2_5, qwen3};
+pub use formats::{deepseek_v4_1, qwen2_5, qwen3};
pub use input::{EngineFinish, Input, TokenSpan};
pub use markers::{Piece, Scanner};
pub use parser::{ParseError, Parser};
diff --git a/crates/symphony/src/tagged/assembler.rs b/crates/symphony/src/tagged/assembler.rs
index 8924cef7f..7f9ea0b0e 100644
--- a/crates/symphony/src/tagged/assembler.rs
+++ b/crates/symphony/src/tagged/assembler.rs
@@ -608,7 +608,7 @@ impl Assembler {
}
/// Appends `text` as a JSON string, quotes included.
-fn push_quoted(out: &mut String, text: &str) {
+pub(crate) fn push_quoted(out: &mut String, text: &str) {
out.push('"');
push_escaped(out, text);
out.push('"');
@@ -616,7 +616,7 @@ fn push_quoted(out: &mut String, text: &str) {
/// Appends `text` as the inside of a JSON string, escaped as `serde_json` escapes it: the quote,
/// the backslash and the control characters, nothing else.
-fn push_escaped(out: &mut String, text: &str) {
+pub(crate) fn push_escaped(out: &mut String, text: &str) {
for c in text.chars() {
match c {
'"' => out.push_str("\\\""),
diff --git a/crates/symphony/src/tagged/dsml.rs b/crates/symphony/src/tagged/dsml.rs
new file mode 100644
index 000000000..2d5465b28
--- /dev/null
+++ b/crates/symphony/src/tagged/dsml.rs
@@ -0,0 +1,540 @@
+//! DeepSeek's DSML: the events of one `<|DSML| invoke name="NAME">` block, from its bytes.
+//!
+//! DeepSeek V4 and V4.1 write a call as
+//!
+//! ```text
+//! <|DSML| invoke name="get_weather">
+//! <|DSML| parameter name="city" string="true">Paris|DSML| parameter>
+//! <|DSML| parameter name="days" string="false">3|DSML| parameter>
+//! |DSML| invoke>
+//! ```
+//!
+//! inside a `<|DSML| calls>` block that holds one invoke or several. The engine's table owns the
+//! `calls` and `invoke` markers ([`formats::deepseek_v4_1`]): it enters this assembler at
+//! `<|DSML| invoke name="` and leaves it at `|DSML| invoke>`, so the assembler starts inside
+//! the function's name and never sees the invoke's closing tag; the engine hands it that tag's
+//! bytes as the call's end. Between those two the assembler reads the parameter tags itself.
+//!
+//! What the syntax says, and what the assembler does with it:
+//!
+//! - **The `string` attribute types the value.** `string="true"` is the text, every byte of it,
+//! streamed as it arrives like a declared string in [`assembler`](super::assembler);
+//! `string="false"` is JSON the model wrote, written whole at the value's close through
+//! [`json`]'s inference (`null`, `true`, `[1, 2]`, `{"a": 1}` as written; text that is not JSON
+//! is a string holding it, so one odd value never costs a call its other arguments). The
+//! request's tools are not consulted: the model says the type. A parameter tag with another
+//! attribute, or none, is no parameter tag: it comes back whole as `Malformed`, and what
+//! follows it is text between tags until the next tag the syntax has.
+//! - **No template newline around a value.** The template writes the value directly between the
+//! tags, so nothing is taken away from it; the newline between tags is the template's and goes
+//! into the next event's source, as in the Qwen assembler.
+//! - **A name ends at its quote.** The function name runs to `"`, the key to `"`; the rest of the
+//! tag runs to `>`.
+//! - **Inside a value only `|DSML| parameter>` is a tag for the assembler**; a parameter tag
+//! there is the value's text, as for Qwen. The invoke tags and the block's close are the
+//! engine's terminals, so they end the call wherever they stand, a value included, as
+//! `` does for Qwen (the table's `invoke + invoke_open` and `invoke + calls_close`
+//! rows).
+//! - **Two endings.** [`Assembler::close`] is the invoke's closing tag ([`INVOKE_CLOSE`], the one
+//! terminal the call's end carries), or the next invoke's opening or the block's close, which
+//! end the call with no bytes of their own: after the last parameter it closes the object and
+//! pushes `ToolCallEnd`; inside a value it reports what was held, closes an open string and the
+//! object, as the Qwen assembler does. [`Assembler::finish`] is a stream that was cut: nothing
+//! is closed.
+//! - **Every byte of the invoke lands in exactly one event**, with the same accounting as the Qwen
+//! assembler: the name's bytes in `ToolCallStart`, each tag's and value's bytes in the
+//! fragments they produce, text where the syntax has tags as `Malformed` run by run with the
+//! whitespace before it as `Dropped { Wrapper }`, as the Qwen assembler reports it. A client
+//! that shows malformed text sees such words without the spaces between them (an invoke whose
+//! name never closed shows as `name="a"string="true"`); the bytes are all accounted for, in the
+//! dropped and malformed events.
+//!
+//! This is the second tagged assembler beside the Qwen one; the two share the value module and the
+//! escaping, and will share one core when a third dialect (GLM, MiniMax, Hy4) arrives and shows
+//! where the seams are.
+//!
+//! [`formats::deepseek_v4_1`]: crate::formats::deepseek_v4_1()
+
+use crate::{
+ event::{DropReason, Event, Events, MalformedReason, Text},
+ markers::{Piece, Scanner},
+ tagged::{
+ assembler::{push_escaped, push_quoted},
+ value::json,
+ },
+};
+
+/// The invoke's closing tag: the one terminal the call's end carries. The next invoke's opening
+/// and the block's close end a call too, but they belong to the region, and the engine drops them.
+pub const INVOKE_CLOSE: &str = "|DSML| invoke>";
+
+const PARAMETER_OPEN: usize = 0;
+const PARAMETER_CLOSE: usize = 1;
+const TAGS: [&str; 2] = ["<|DSML| parameter name=\"", "|DSML| parameter>"];
+const TEXT_BETWEEN_TAGS: &str = "text between a call's tags";
+const TAG_OUT_OF_PLACE: &str = "a tag where the call's syntax has none";
+const TAG_CUT_SHORT: &str = "a tag that another tag cut short";
+const EMPTY_NAME: &str = "a tag without a name";
+const TAG_TAIL: &str = "text after a tag's name";
+const CLOSED_EARLY: &str = "an invoke that closed before its function name closed";
+const STRING_TRUE: &str = " string=\"true\"";
+const STRING_FALSE: &str = " string=\"false\"";
+
+/// The events of one DSML invoke, from the bytes after `<|DSML| invoke name="`.
+#[derive(Clone, Debug)]
+pub struct Assembler {
+ index: u32,
+ id: String,
+ scanner: Scanner,
+ /// Bytes no event has accounted for yet, in order; the next event takes them as its source.
+ carried: String,
+ stage: Stage,
+ /// The function's name once its tag is whole, which is when `ToolCallStart` was pushed.
+ function: Option,
+ /// Members written to the arguments object so far.
+ written: u32,
+ done: bool,
+}
+
+#[derive(Clone, Debug)]
+enum Stage {
+ /// Inside the invoke tag: the name is `carried[..]` until `"`, then the tail until `>`.
+ FunctionName,
+ /// After the name's quote, before the tag's `>`: the tail is `carried[start..]`.
+ FunctionTail { name: String, start: usize },
+ /// After the invoke tag or a `|DSML| parameter>`.
+ Between,
+ /// Inside a parameter tag: the key is `carried[start..]` until `"`.
+ ParameterName { start: usize },
+ /// After the key's quote, before the tag's `>`: the attribute, `carried[start..]`.
+ ParameterTail { key: String, start: usize },
+ /// Inside a value.
+ Value(ValueState),
+}
+
+#[derive(Clone, Debug)]
+struct ValueState {
+ key: String,
+ /// `string="true"`: streamed; `string="false"`: written whole at the close.
+ string: bool,
+ /// For a whole value, where its text starts in `carried`.
+ start: usize,
+ /// For a streamed value, whether its opening fragment has been pushed.
+ opened: bool,
+}
+
+impl Assembler {
+ /// An assembler for the invoke at `index` with the id the format minted for it, positioned
+ /// just after `<|DSML| invoke name="`.
+ pub fn new(index: u32, id: impl Into) -> Self {
+ Self {
+ index,
+ id: id.into(),
+ scanner: Scanner::new(TAGS),
+ carried: String::new(),
+ stage: Stage::FunctionName,
+ function: None,
+ written: 0,
+ done: false,
+ }
+ }
+
+ /// Whether a call has started, that is, whether `ToolCallStart` has been pushed.
+ pub fn started(&self) -> bool {
+ self.function.is_some()
+ }
+
+ /// Append the next bytes of the invoke and push the events they complete. Every byte is the
+ /// invoke's until the engine says otherwise, so this takes them all.
+ pub fn feed(&mut self, bytes: &str, out: &mut Events) {
+ if self.done {
+ return;
+ }
+ for piece in self.scanner.feed(bytes) {
+ self.take(piece, out);
+ }
+ }
+
+ /// The invoke's end: `terminal` is the closing tag the engine read (its bytes go into
+ /// `ToolCallEnd`), or nothing when the block ended another way. After the last parameter the
+ /// object is closed and the call ends; inside a value an open string is closed, the object is
+ /// closed, and what was held comes back as `Malformed`; an invoke that never named a function
+ /// has its bytes reported.
+ pub fn close(mut self, terminal: &str, out: &mut Events) {
+ if self.done {
+ return;
+ }
+ let held = self.scanner.held().to_string();
+ self.carried.push_str(&held);
+ if !self.started() {
+ let why = if self.carried.is_empty() && terminal.is_empty() {
+ None
+ } else if matches!(self.stage, Stage::FunctionName) && self.carried.is_empty() {
+ Some(EMPTY_NAME)
+ } else {
+ Some(CLOSED_EARLY)
+ };
+ self.carried.push_str(terminal);
+ if let Some(why) = why {
+ self.leftover(why, out);
+ }
+ return;
+ }
+ match std::mem::replace(&mut self.stage, Stage::Between) {
+ Stage::Between => {}
+ Stage::Value(value) => self.cut_value(&value, out),
+ // A name or a tag the end cut short: its bytes are reported, and the object closes.
+ _ => self.leftover(TAG_CUT_SHORT, out),
+ }
+ let source = Text::uncounted(std::mem::take(&mut self.carried));
+ self.close_object(source, out);
+ out.push(Event::ToolCallEnd {
+ index: self.index,
+ source: Text::uncounted(terminal),
+ });
+ self.done = true;
+ }
+
+ /// The stream was cut: nothing is closed, so arguments cut short never look complete to a
+ /// client. A streamed string's open fragment stays open; a whole value never written, a name
+ /// and the held bytes come back as `Malformed { UnterminatedRegion }`; a call that started
+ /// still ends, with no bytes of its own, as every assembler ends one.
+ pub fn finish(mut self, out: &mut Events) {
+ if self.done {
+ return;
+ }
+ let held = self.scanner.held().to_string();
+ self.carried.push_str(&held);
+ if !self.carried.is_empty() {
+ out.push(Event::Malformed {
+ text: Text::uncounted(std::mem::take(&mut self.carried)),
+ why: MalformedReason::UnterminatedRegion,
+ });
+ }
+ if self.started() {
+ out.push(Event::ToolCallEnd {
+ index: self.index,
+ source: Text::default(),
+ });
+ }
+ }
+
+ fn take(&mut self, piece: Piece, out: &mut Events) {
+ match piece {
+ Piece::Text(text) => self.text(&text, out),
+ Piece::Marker(tag) => self.tag(tag, out),
+ }
+ }
+
+ fn tag(&mut self, tag: usize, out: &mut Events) {
+ let bytes = TAGS[tag];
+ match &self.stage {
+ Stage::Between => self.tag_between(tag, out),
+ Stage::Value(_) if tag == PARAMETER_CLOSE => self.close_value(bytes, out),
+ // Inside a value, the other tag is the value's text.
+ Stage::Value(_) => self.text(bytes, out),
+ // A tag cuts a name or a tag's tail short: the bytes so far are reported, and the tag
+ // is read where they began.
+ _ => {
+ self.report(TAG_CUT_SHORT, out);
+ self.stage = Stage::Between;
+ if self.started() {
+ self.tag_between(tag, out);
+ } else {
+ out.push(Event::Malformed {
+ text: Text::uncounted(bytes),
+ why: MalformedReason::Other(TAG_OUT_OF_PLACE.to_string()),
+ });
+ }
+ }
+ }
+ }
+
+ /// A tag between parameters: the parameter tag opens a key, once the function is named; the
+ /// closing tag has no place, and neither does a parameter before the function's name closed
+ /// (a missing quote on the name), so no fragment comes before the call's start.
+ fn tag_between(&mut self, tag: usize, out: &mut Events) {
+ let bytes = TAGS[tag];
+ if tag == PARAMETER_OPEN && self.started() {
+ self.carried.push_str(bytes);
+ self.stage = Stage::ParameterName {
+ start: self.carried.len(),
+ };
+ } else {
+ self.drop_carried(out);
+ out.push(Event::Malformed {
+ text: Text::uncounted(bytes),
+ why: MalformedReason::Other(TAG_OUT_OF_PLACE.to_string()),
+ });
+ }
+ }
+
+ fn text(&mut self, text: &str, out: &mut Events) {
+ match &self.stage {
+ Stage::FunctionName => match text.split_once('"') {
+ Some((head, rest)) => {
+ self.carried.push_str(head);
+ // The name's bytes stay carried: they are the start's source.
+ let name = self.carried.clone();
+ self.carried.push('"');
+ if name.is_empty() {
+ self.report(EMPTY_NAME, out);
+ self.stage = Stage::Between;
+ } else {
+ self.stage = Stage::FunctionTail {
+ name,
+ start: self.carried.len(),
+ };
+ }
+ self.text(rest, out);
+ }
+ None => self.carried.push_str(text),
+ },
+ Stage::FunctionTail { name, start } => {
+ let (name, start) = (name.clone(), *start);
+ match text.split_once('>') {
+ Some((head, rest)) => {
+ self.carried.push_str(head);
+ let tail = self.carried[start..].to_string();
+ self.carried.push('>');
+ self.start_call(name, &tail, out);
+ self.text(rest, out);
+ }
+ None => self.carried.push_str(text),
+ }
+ }
+ Stage::Between => self.text_between(text, out),
+ Stage::ParameterName { start } => {
+ let start = *start;
+ match text.split_once('"') {
+ Some((head, rest)) => {
+ self.carried.push_str(head);
+ let key = self.carried[start..].to_string();
+ self.carried.push('"');
+ if key.is_empty() {
+ self.report(EMPTY_NAME, out);
+ self.stage = Stage::Between;
+ } else {
+ self.stage = Stage::ParameterTail {
+ key,
+ start: self.carried.len(),
+ };
+ }
+ self.text(rest, out);
+ }
+ None => self.carried.push_str(text),
+ }
+ }
+ Stage::ParameterTail { key, start } => {
+ let (key, start) = (key.clone(), *start);
+ match text.split_once('>') {
+ Some((head, rest)) => {
+ self.carried.push_str(head);
+ let tail = self.carried[start..].to_string();
+ self.carried.push('>');
+ self.open_value(key, &tail, out);
+ self.text(rest, out);
+ }
+ None => self.carried.push_str(text),
+ }
+ }
+ Stage::Value(_) => self.value_text(text, out),
+ }
+ }
+
+ /// Text where the syntax has tags: whitespace is the template's, anything else is reported.
+ fn text_between(&mut self, text: &str, out: &mut Events) {
+ let mut rest = text;
+ while let Some(first) = rest.chars().next() {
+ let space = first.is_whitespace();
+ let length = rest
+ .char_indices()
+ .find(|(_, c)| c.is_whitespace() != space)
+ .map_or(rest.len(), |(at, _)| at);
+ if space {
+ self.carried.push_str(&rest[..length]);
+ } else {
+ self.drop_carried(out);
+ out.push(Event::Malformed {
+ text: Text::uncounted(&rest[..length]),
+ why: MalformedReason::Other(TEXT_BETWEEN_TAGS.to_string()),
+ });
+ }
+ rest = &rest[length..];
+ }
+ }
+
+ /// The invoke tag is whole: the call starts. A tail after the name's quote is reported first.
+ fn start_call(&mut self, name: String, tail: &str, out: &mut Events) {
+ self.function = Some(name.clone());
+ if tail.is_empty() {
+ out.push(Event::ToolCallStart {
+ index: self.index,
+ id: self.id.clone(),
+ name,
+ source: Text::uncounted(std::mem::take(&mut self.carried)),
+ });
+ } else {
+ // The tag's bytes up to the tail stay as the start's source; the tail is reported.
+ let tag_end = self.carried.len() - tail.len() - 1;
+ let mut after = self.carried.split_off(tag_end);
+ let close = after.pop();
+ out.push(Event::ToolCallStart {
+ index: self.index,
+ id: self.id.clone(),
+ name,
+ source: Text::uncounted(std::mem::take(&mut self.carried)),
+ });
+ out.push(Event::Malformed {
+ text: Text::uncounted(after),
+ why: MalformedReason::Other(TAG_TAIL.to_string()),
+ });
+ self.carried.extend(close);
+ }
+ self.stage = Stage::Between;
+ }
+
+ /// The parameter tag is whole: the value begins, typed by its `string` attribute. A tag with
+ /// any other tail (`<|DSML| parameter name="x" kind="y">`) is not a parameter tag the
+ /// syntax has: the whole tag comes back as `Malformed`, and no parameter opens, so a tag the
+ /// syntax does not spell is never read as one.
+ fn open_value(&mut self, key: String, tail: &str, out: &mut Events) {
+ let string = match tail {
+ STRING_TRUE => true,
+ STRING_FALSE => false,
+ _ => {
+ self.report(TAG_TAIL, out);
+ self.stage = Stage::Between;
+ return;
+ }
+ };
+ self.stage = Stage::Value(ValueState {
+ key,
+ string,
+ start: self.carried.len(),
+ opened: false,
+ });
+ }
+
+ fn value_text(&mut self, text: &str, out: &mut Events) {
+ let Stage::Value(value) = &mut self.stage else {
+ return;
+ };
+ if text.is_empty() {
+ return;
+ }
+ if !value.string {
+ self.carried.push_str(text);
+ return;
+ }
+ let key = value.key.clone();
+ if !value.opened {
+ value.opened = true;
+ let mut opening = String::with_capacity(key.len() + 8);
+ opening.push_str(self.separator());
+ push_quoted(&mut opening, &key);
+ opening.push_str(": \"");
+ let source = Text::uncounted(std::mem::take(&mut self.carried));
+ self.push_fragment(opening, source, out);
+ self.written += 1;
+ }
+ let mut json = String::with_capacity(text.len());
+ push_escaped(&mut json, text);
+ self.push_fragment(json, Text::uncounted(text), out);
+ }
+
+ /// `|DSML| parameter>`: a streamed string gets its closing quote, a whole value is written.
+ fn close_value(&mut self, tag: &str, out: &mut Events) {
+ let Stage::Value(value) = &self.stage else {
+ return;
+ };
+ let fragment = if value.string {
+ if value.opened {
+ "\"".to_string()
+ } else {
+ // An empty string: its opening and closing quotes come together.
+ let mut member = String::with_capacity(value.key.len() + 8);
+ member.push_str(self.separator());
+ push_quoted(&mut member, &value.key);
+ member.push_str(": \"\"");
+ self.written += 1;
+ member
+ }
+ } else {
+ let written = json(&self.carried[value.start..], None);
+ let mut member = String::with_capacity(value.key.len() + written.len() + 8);
+ member.push_str(self.separator());
+ push_quoted(&mut member, &value.key);
+ member.push_str(": ");
+ member.push_str(&written);
+ self.written += 1;
+ member
+ };
+ let mut source = std::mem::take(&mut self.carried);
+ source.push_str(tag);
+ self.push_fragment(fragment, Text::uncounted(source), out);
+ self.stage = Stage::Between;
+ }
+
+ /// The invoke ended inside a value: an open string is closed, and a whole value's text comes
+ /// back as `Malformed`, since its member was never written.
+ fn cut_value(&mut self, value: &ValueState, out: &mut Events) {
+ if value.string && value.opened {
+ // Bytes the end cut short (the beginning of a tag) are reported, not hidden in the
+ // quote's source.
+ self.report(TAG_CUT_SHORT, out);
+ self.push_fragment("\"".to_string(), Text::default(), out);
+ } else {
+ self.leftover(TAG_CUT_SHORT, out);
+ }
+ }
+
+ /// Reports the carried bytes as `Malformed` with `why`, whitespace included.
+ fn report(&mut self, why: &str, out: &mut Events) {
+ if self.carried.is_empty() {
+ return;
+ }
+ out.push(Event::Malformed {
+ text: Text::uncounted(std::mem::take(&mut self.carried)),
+ why: MalformedReason::Other(why.to_string()),
+ });
+ }
+
+ fn leftover(&mut self, why: &str, out: &mut Events) {
+ self.report(why, out);
+ }
+
+ /// Whitespace carried before text that is reported: the template's, dropped so the report
+ /// holds only the text.
+ fn drop_carried(&mut self, out: &mut Events) {
+ if self.carried.is_empty() {
+ return;
+ }
+ out.push(Event::Dropped {
+ text: Text::uncounted(std::mem::take(&mut self.carried)),
+ why: DropReason::Wrapper,
+ });
+ }
+
+ fn separator(&self) -> &'static str {
+ if self.written == 0 {
+ "{"
+ } else {
+ ", "
+ }
+ }
+
+ /// `}` or `{}`, with `source` as its bytes.
+ fn close_object(&mut self, source: Text, out: &mut Events) {
+ let json = if self.written == 0 { "{}" } else { "}" }.to_string();
+ self.push_fragment(json, source, out);
+ }
+
+ fn push_fragment(&self, json: String, source: Text, out: &mut Events) {
+ out.push(Event::ToolCallArguments {
+ index: self.index,
+ json,
+ source,
+ });
+ }
+}
diff --git a/crates/symphony/src/tagged/mod.rs b/crates/symphony/src/tagged/mod.rs
index da209b86a..634c7de9d 100644
--- a/crates/symphony/src/tagged/mod.rs
+++ b/crates/symphony/src/tagged/mod.rs
@@ -7,10 +7,12 @@
//! passed through: the model writes no JSON object, so the parser writes one, and it has to decide
//! what type each value's text is.
//!
-//! [`value`] is that decision; [`assembler`] turns one call's tags into its events, streaming a
-//! declared string as it arrives and writing every other value at its close.
+//! [`value`] is that decision; [`assembler`] turns one Qwen call's tags into its events,
+//! streaming a declared string as it arrives and writing every other value at its close; [`dsml`]
+//! does the same for one DeepSeek DSML invoke, whose `string` attribute types each value.
pub mod assembler;
+pub mod dsml;
pub mod value;
pub use assembler::Assembler;
diff --git a/crates/symphony/tests/bellwether_parse_fixtures.rs b/crates/symphony/tests/bellwether_parse_fixtures.rs
index f37e1f6e4..b6e38f6c3 100644
--- a/crates/symphony/tests/bellwether_parse_fixtures.rs
+++ b/crates/symphony/tests/bellwether_parse_fixtures.rs
@@ -47,18 +47,19 @@ use openai_protocol::common::Tool;
use serde::Deserialize;
use symphony::{
adapt,
- formats::{qwen2_5, qwen3},
+ formats::{deepseek_v4_1, qwen2_5, qwen3},
CallSyntax, Declared, DropReason, Engine, EngineFinish, Event, Events, Input, ParseError,
Parser, TokenSpan,
};
const FIXTURES_ENV: &str = "BELLWETHER_FIXTURES";
const SLUG: &str = "qwen3-8b";
-/// bellwether's slugs for the Qwen checkpoints, in its manifests' spelling, each with the table
-/// that reads it and how its template ends the generation prompt. The fixtures carry the request
-/// and the output, not the rendered prompt, so the prompt's tail is stated here until bellwether
-/// records it (noted for Simo in STATE.md). A slug bellwether has not recorded is skipped with a
-/// notice; `qwen3-8b` is the one set bellwether's main always holds, and has its own test.
+/// bellwether's slugs for the checkpoints the tables read, in its manifests' spelling, each with
+/// the table that reads it and how its template ends the generation prompt. The fixtures carry
+/// the request and the output, not the rendered prompt, so the prompt's tail is stated here until
+/// bellwether records it (noted for Simo in STATE.md). A slug bellwether has not recorded is
+/// skipped with a notice; `qwen3-8b` is the one set bellwether's main always holds, and has its
+/// own test.
const MODELS: &[(&str, Family, GenerationPrompt)] = &[
// Qwen3: the model writes its own ``; thinking off closes it in the prompt.
(
@@ -249,6 +250,12 @@ const MODELS: &[(&str, Family, GenerationPrompt)] = &[
Family::Qwen3Tagged,
GenerationPrompt::Plain,
),
+ // DeepSeek V4.1 writes DSML and opens the thought in the prompt (``, no newline).
+ (
+ "deepseek-v4.1-flash",
+ Family::DeepSeekV4_1,
+ GenerationPrompt::OpensTheThought,
+ ),
];
/// The table that reads a checkpoint's output.
@@ -260,6 +267,8 @@ enum Family {
Qwen3Tagged,
/// [`qwen2_5`].
Qwen2_5,
+ /// [`deepseek_v4_1`]: DSML, whose parameter tags type their own values.
+ DeepSeekV4_1,
}
/// How the Qwen tables spell the thought's markers, for the reasoning allowance's guard.
@@ -272,6 +281,7 @@ impl Family {
Self::Qwen3 => Engine::new(qwen3(CallSyntax::Json), declared),
Self::Qwen3Tagged => Engine::new(qwen3(CallSyntax::Tagged), declared),
Self::Qwen2_5 => Engine::new(qwen2_5(), declared),
+ Self::DeepSeekV4_1 => Engine::new(deepseek_v4_1(), declared),
}
}
@@ -282,6 +292,7 @@ impl Family {
let list = match self {
Self::Qwen3 | Self::Qwen2_5 => KNOWN_DIFFERENCES,
Self::Qwen3Tagged => KNOWN_TAGGED_DIFFERENCES,
+ Self::DeepSeekV4_1 => KNOWN_DSML_DIFFERENCES,
};
// A template without a thought leaves the reasoning out, so the marker inside it is never
// read; that case falls under the reasoning allowance instead of the list.
@@ -434,6 +445,17 @@ const KNOWN_TAGGED_DIFFERENCES: &[KnownDifference] = &[
},
];
+/// Under the DSML table only the reasoning probe differs: the code fence holds Qwen's syntax,
+/// which this table never reads as a call, so the fence is content, as the reference says.
+const KNOWN_DSML_DIFFERENCES: &[KnownDifference] = &[KnownDifference {
+ id: "parse/reasoning-with-marker-text",
+ reason: "the reasoning holds a ``; the parser ends the reasoning there, as every \
+ marker parser does, and the reference keeps the marker as reasoning text \
+ (bellwether #16)",
+ calls: 0,
+ finish: "stop",
+}];
+
/// The case's id after its slug: what [`KnownDifference::id`] names.
fn after_slug(id: &str) -> &str {
id.split_once('/').map_or(id, |(_, rest)| rest)
@@ -635,7 +657,7 @@ fn every_recorded_qwen_model_parses_like_its_reference() {
}
if recorded == 0 {
eprintln!(
- "skipping: none of the Qwen slugs is recorded under {}",
+ "skipping: none of the table's slugs is recorded under {}",
root.display()
);
}
diff --git a/crates/symphony/tests/contract.rs b/crates/symphony/tests/contract.rs
index 31650d940..7bd2979d2 100644
--- a/crates/symphony/tests/contract.rs
+++ b/crates/symphony/tests/contract.rs
@@ -71,6 +71,10 @@ fn qwen2_5() -> Box {
Box::new(Engine::new(formats::qwen2_5(), Declared::default()))
}
+fn deepseek_v4_1() -> Box {
+ Box::new(Engine::new(formats::deepseek_v4_1(), Declared::default()))
+}
+
const FORMATS: &[Subject] = &[
Subject {
name: "qwen3",
@@ -87,6 +91,43 @@ const FORMATS: &[Subject] = &[
new: qwen2_5,
outputs: QWEN3_OUTPUTS,
},
+ Subject {
+ name: "deepseek v4.1",
+ new: deepseek_v4_1,
+ outputs: DSML_OUTPUTS,
+ },
+];
+
+/// Outputs in DeepSeek's DSML: the recorded shapes, and the cuts and faults the assembler and the
+/// table name.
+const DSML_OUTPUTS: &[&str] = &[
+ "\n\n<|DSML| calls>\n<|DSML| invoke name=\"get_weather\">\n<|DSML| parameter \
+ name=\"city\" string=\"true\">Paris|DSML| parameter>\n<|DSML| parameter name=\"days\" \
+ string=\"false\">3|DSML| parameter>\n|DSML| invoke>\n<|DSML| invoke \
+ name=\"get_stock_price\">\n\n|DSML| invoke>\n|DSML| calls>",
+ "<|DSML| calls>\n<|DSML| invoke name=\"f\">\n<|DSML| parameter name=\"a\" string=\"true\">par",
+ "Let me call.<|DSML| calls>\n<|DSML| invoke name=\"f\">\n<|DSML| parameter name=\"a\" \
+ string=\"true\">x|DSML| parameter>\n<|DSML| invoke name=\"g\">\n|DSML| invoke>\n\
+ |DSML| calls>",
+ "\n<|DSML| calls>\n<|DSML| invoke name=\"f\">\n<|DSML| parameter name=\"a\" \
+ string=\"true\">x|DSML| parameter>\n|DSML| calls>\nThe weather is sunny.",
+ "\n<|DSML| calls>\n<|DSML| invoke name=\"f>\n<|DSML| parameter name=\"a\" \
+ string=\"true\">x|DSML| parameter>\n<|DSML| parameter name=\"b\" string=\"true\">y\
+ |DSML| parameter>\n|DSML| invoke>\n<|DSML| invoke name=\"g\">\n|DSML| invoke>\n\
+ |DSML| calls>",
+ "<|DSML| calls>\n<|DSML| invoke name=\"f\">\n<|DSML| parameter name=\"x\" kind=\"y\">v\
+ |DSML| parameter>\n<|DSML| parameter name=\"a\" string=\"true\">1|DSML| parameter>\n\
+ |DSML| invoke>\n|DSML| calls>",
+ "<|DSML| calls>\n<|DSML| invoke name=\"f\">\n<|DSML| parameter name=\"o\" string=\"false\">\
+ {\"size\": \"large\", \"deep\": [1, 2.5, true, null]}|DSML| parameter>\n<|DSML| parameter \
+ name=\"q\" string=\"true\">計画 🌍 \"q\" \\ |DSML| parameter>\n|DSML| invoke>\n\
+ |DSML| calls>",
+ "planHello, no call.",
+ "<|DSML| calls>\n<|DSML| invoke name=\"f\">\n<|DSML| parameter name=\"a\" string=\"true\">x\
+ |DSML| par|DSML| invoke>\n<|DSML| invoke name=\"g\">\n<|DSML| invoke name=\"h\">\n\
+ |DSML| calls>",
+ "<|DSML| calls>\nprose where an invoke should be\n|DSML| calls>",
+ "<|DSML| calls>\n<|DSML| invoke name=\"\">\n|DSML| invoke>\n|DSML| calls>",
];
/// Outputs in the tagged syntax: the recorded shapes, and the cuts and faults the assembler