From 9d0b878ed7dc9d25ea406251541fb7db8123e7cc Mon Sep 17 00:00:00 2001
From: Simo Lin <25425177+slin1237@users.noreply.github.com>
Date: Tue, 6 Oct 2026 22:15:48 -0700
Subject: [PATCH 1/7] feat(symphony): the DSML assembler, and a wrapper state
for the engine
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
DeepSeek V4 and V4.1 write a call as <|DSML| invoke name="NAME"> and
one <|DSML| parameter name="KEY" string="true|false">VALUE|DSML|
parameter> per argument, inside a <|DSML| calls> block that holds one
invoke or several. tagged::dsml is the assembler for one invoke: the
engine enters it at the invoke tag's opening, so it starts inside the
function's name; the string attribute types each value, a string
streamed as it arrives and anything else written whole at its close
through the value module's inference; no template newline is taken from
a value; the invoke's closing tag, read by the engine, is ToolCallEnd's
source. It shares the value module and the escaping with the Qwen tagged
assembler and will share one core when a third dialect shows the seams.
The engine gains Emits::Wrapper, the template's wrapping between a
block's calls (whitespace dropped, anything else malformed, run by run),
and CallSyntax::Dsml; a call syntax may take the terminal that closed its
region as the call's end, in which case the engine does not drop it.
Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com>
---
crates/symphony/src/engine.rs | 74 ++--
crates/symphony/src/format.rs | 15 +-
crates/symphony/src/tagged/assembler.rs | 4 +-
crates/symphony/src/tagged/dsml.rs | 522 ++++++++++++++++++++++++
crates/symphony/src/tagged/mod.rs | 6 +-
5 files changed, 589 insertions(+), 32 deletions(-)
create mode 100644 crates/symphony/src/tagged/dsml.rs
diff --git a/crates/symphony/src/engine.rs b/crates/symphony/src/engine.rs
index c12c4ffac..36f88a1fb 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,27 @@ 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 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),
+ (Self::Dsml(assembler), Closed::ByMarker) => {
+ assembler.close(terminal, out);
+ return true;
+ }
+ (Self::Dsml(assembler), Closed::ByEnd) => assembler.finish(out),
}
+ false
}
}
@@ -211,15 +227,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 +254,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 +270,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 +304,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 +330,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 +370,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/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..c1f091a6d
--- /dev/null
+++ b/crates/symphony/src/tagged/dsml.rs
@@ -0,0 +1,522 @@
+//! 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 read as `string="true"` and its tail reported as `Malformed`.
+//! - **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**; a parameter tag or an invoke tag
+//! there is the value's text, as for Qwen.
+//! - **Two endings.** [`Assembler::close`] is the invoke's closing tag, or the block's end before
+//! it: after the last parameter it closes the object and pushes `ToolCallEnd` with the tag's
+//! bytes; inside a value it closes an open string and the object and reports what was held, 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 }`.
+//!
+//! 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,
+ },
+};
+
+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 }`.
+ 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,
+ });
+ }
+ }
+
+ 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, the closing tag has no place.
+ fn tag_between(&mut self, tag: usize, out: &mut Events) {
+ let bytes = TAGS[tag];
+ if tag == PARAMETER_OPEN {
+ 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.
+ fn open_value(&mut self, key: String, tail: &str, out: &mut Events) {
+ let string = match tail {
+ STRING_TRUE => true,
+ STRING_FALSE => false,
+ other => {
+ let tag_end = self.carried.len() - other.len() - 1;
+ let mut after = self.carried.split_off(tag_end);
+ let close = after.pop();
+ if !after.is_empty() {
+ self.drop_carried(out);
+ out.push(Event::Malformed {
+ text: Text::uncounted(after),
+ why: MalformedReason::Other(TAG_TAIL.to_string()),
+ });
+ }
+ self.carried.extend(close);
+ true
+ }
+ };
+ 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 {
+ let source = Text::uncounted(std::mem::take(&mut self.carried));
+ self.push_fragment("\"".to_string(), source, 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;
From 8046bc87067b72e5483a610f410849577b053c6f Mon Sep 17 00:00:00 2001
From: Simo Lin <25425177+slin1237@users.noreply.github.com>
Date: Tue, 6 Oct 2026 22:15:48 -0700
Subject: [PATCH 2/7] feat(symphony): the DeepSeek V4.1 table, replayed over
its recorded cases
The design's section 4 example as a table: six terminals, four states
(content, reasoning, the calls block's wrapping, one invoke's arguments)
and six rows; the prompt opens the thought, and the recorded outputs
close it before the calls block. deepseek-v4.1-flash joins the parity
table: over bellwether's scale-run fixtures of 2026-10-06, 2,436 cases,
2,433 bitwise, 2 separators only, 1 listed, 0 differences.
Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com>
---
crates/symphony/src/formats/deepseek_v4_1.rs | 262 ++++++++++++++++++
crates/symphony/src/formats/mod.rs | 5 +-
crates/symphony/src/lib.rs | 2 +-
.../tests/bellwether_parse_fixtures.rs | 27 +-
4 files changed, 287 insertions(+), 9 deletions(-)
create mode 100644 crates/symphony/src/formats/deepseek_v4_1.rs
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..ca43c283d
--- /dev/null
+++ b/crates/symphony/src/formats/deepseek_v4_1.rs
@@ -0,0 +1,262 @@
+//! 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`.
+//!
+//! Three things are this table's own:
+//!
+//! - **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.
+//! - **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.
+//!
+//! No row leaves reasoning on `<|DSML| calls>`: the recorded outputs close the thought before a
+//! call, and whether a call inside the thought should end it is a question for the fixtures to
+//! raise, not a row to add in advance.
+
+use crate::format::{CallSyntax, Emits, Format};
+
+/// 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>")
+ .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("calls", "invoke_open", "invoke")
+ .transition("invoke", "invoke_close", "calls")
+ .transition("calls", "calls_close", "content")
+ .calls(CallSyntax::Dsml)
+}
+
+#[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_leaves_the_call_open_and_the_rest_malformed() {
+ 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,
+ ..
+ }
+ )));
+ assert!(!events
+ .iter()
+ .any(|event| matches!(event, Event::ToolCallEnd { .. })));
+ assert!(matches!(
+ events.last(),
+ Some(Event::Finish { tool_calls: 1, .. })
+ ));
+ }
+
+ #[test]
+ fn a_calls_block_in_the_prompt_is_not_entered_and_thinking_off_starts_in_content() {
+ let events = run("\n\n\n\n", &["Hello"]);
+ assert_eq!(events[0], Event::Content(Text::uncounted("Hello")));
+ }
+}
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..876f8e24f 100644
--- a/crates/symphony/src/lib.rs
+++ b/crates/symphony/src/lib.rs
@@ -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/tests/bellwether_parse_fixtures.rs b/crates/symphony/tests/bellwether_parse_fixtures.rs
index f37e1f6e4..5087579a0 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,9 @@ impl Family {
let list = match self {
Self::Qwen3 | Self::Qwen2_5 => KNOWN_DIFFERENCES,
Self::Qwen3Tagged => KNOWN_TAGGED_DIFFERENCES,
+ // DSML's code-fence probe holds Qwen's syntax, which the DSML table never reads as a
+ // call: the fence is content, as the reference says.
+ Self::DeepSeekV4_1 => &KNOWN_TAGGED_DIFFERENCES[..1],
};
// 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.
@@ -635,7 +648,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()
);
}
From 69e14c700ae63e8e0970344b923880cff194a85d Mon Sep 17 00:00:00 2001
From: Simo Lin <25425177+slin1237@users.noreply.github.com>
Date: Wed, 7 Oct 2026 00:11:19 -0700
Subject: [PATCH 3/7] fix(symphony): DSML's turn opener, three rows, no
fragment before a start, and the contract subject
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
From Alex's probes on #2840. The DSML table names `<|Assistant|>` as its
turn opener, so a `<|DSML| calls>` quoted in the user's turn moves nothing
(the prompt replay gave the whole thought as content). No parameter opens
before the function is named, so a missing quote on the name pushes no
fragment before any ToolCallStart, and the next invoke takes index 0 as
the old parser gave it. Three rows: `reasoning + calls_open = calls`
(design §4 and SMG's V4.1 reasoning parser), `invoke + invoke_open =
invoke` (a second invoke before the first closed ends the first and
starts the second instead of losing it), and `invoke + calls_close =
content` (the block's close ends an invoke left open, so the call closes
into an object and the prose after the block stays prose). The DSML
assembler's finish ends a started call when the stream is cut, with no
bytes of its own, as the Qwen and JSON assemblers do. deepseek_v4_1() is
a contract subject over nine DSML outputs, the cut stream among them.
Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com>
---
crates/symphony/src/formats/deepseek_v4_1.rs | 123 +++++++++++++++++--
crates/symphony/src/tagged/dsml.rs | 15 ++-
crates/symphony/tests/contract.rs | 34 +++++
3 files changed, 158 insertions(+), 14 deletions(-)
diff --git a/crates/symphony/src/formats/deepseek_v4_1.rs b/crates/symphony/src/formats/deepseek_v4_1.rs
index ca43c283d..b32c4f1e8 100644
--- a/crates/symphony/src/formats/deepseek_v4_1.rs
+++ b/crates/symphony/src/formats/deepseek_v4_1.rs
@@ -3,7 +3,7 @@
//! `|DSML| invoke>`, everything else content. The design's section 4 example, as recorded for
//! `deepseek-ai/DeepSeek-V4.1-Flash`.
//!
-//! Three things are this table's own:
+//! 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
@@ -11,14 +11,16 @@
//! 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.
+//! 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.
-//!
-//! No row leaves reasoning on `<|DSML| calls>`: the recorded outputs close the thought before a
-//! call, and whether a call inside the thought should end it is a question for the fixtures to
-//! raise, not a row to add in advance.
+//! `\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};
@@ -38,10 +40,14 @@ pub fn deepseek_v4_1() -> Format {
.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)]
@@ -230,7 +236,7 @@ mod tests {
}
#[test]
- fn a_stream_cut_inside_an_invoke_leaves_the_call_open_and_the_rest_malformed() {
+ fn a_stream_cut_inside_an_invoke_ends_the_call_with_its_arguments_cut() {
let events = run(
"",
&[concat!(
@@ -245,15 +251,110 @@ mod tests {
..
}
)));
- assert!(!events
- .iter()
- .any(|event| matches!(event, Event::ToolCallEnd { .. })));
+ // 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() {
+ let output = concat!(
+ "\n<|DSML| calls>\n<|DSML| invoke name=\"f>\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 a_calls_block_in_the_prompt_is_not_entered_and_thinking_off_starts_in_content() {
let events = run("\n\n\n\n", &["Hello"]);
diff --git a/crates/symphony/src/tagged/dsml.rs b/crates/symphony/src/tagged/dsml.rs
index c1f091a6d..9693bd2c2 100644
--- a/crates/symphony/src/tagged/dsml.rs
+++ b/crates/symphony/src/tagged/dsml.rs
@@ -184,7 +184,8 @@ impl Assembler {
/// 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 }`.
+ /// 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;
@@ -197,6 +198,12 @@ impl Assembler {
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) {
@@ -230,10 +237,12 @@ impl Assembler {
}
}
- /// A tag between parameters: the parameter tag opens a key, the closing tag has no place.
+ /// 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 {
+ if tag == PARAMETER_OPEN && self.started() {
self.carried.push_str(bytes);
self.stage = Stage::ParameterName {
start: self.carried.len(),
diff --git a/crates/symphony/tests/contract.rs b/crates/symphony/tests/contract.rs
index 31650d940..2cb4bd9b2 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,36 @@ 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=\"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=\"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>\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
From 88fedf88de65919118e0d58cb3a7f2d32dded36f Mon Sep 17 00:00:00 2001
From: Simo Lin <25425177+slin1237@users.noreply.github.com>
Date: Wed, 7 Oct 2026 00:40:59 -0700
Subject: [PATCH 4/7] fix(symphony): a DSML parameter tag with another
attribute opens no parameter
The tag comes back whole as Malformed and the assembler stays between tags, instead of opening a string value under a tag the syntax does not have. The guard test for a parameter arriving before the name holds two parameters, so the second arrives with no call started; the prompt test holds an earlier turn's calls block, closed and cut; the DSML known differences are a named list; the module docs say the invoke tags and the block's close end a call wherever they stand.
Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com>
---
crates/symphony/src/formats/deepseek_v4_1.rs | 53 ++++++++++++++++++-
crates/symphony/src/tagged/dsml.rs | 37 ++++++-------
.../tests/bellwether_parse_fixtures.rs | 15 ++++--
crates/symphony/tests/contract.rs | 8 ++-
4 files changed, 89 insertions(+), 24 deletions(-)
diff --git a/crates/symphony/src/formats/deepseek_v4_1.rs b/crates/symphony/src/formats/deepseek_v4_1.rs
index b32c4f1e8..060658fb4 100644
--- a/crates/symphony/src/formats/deepseek_v4_1.rs
+++ b/crates/symphony/src/formats/deepseek_v4_1.rs
@@ -312,8 +312,11 @@ mod tests {
#[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>"
);
@@ -356,8 +359,56 @@ mod tests {
}
#[test]
- fn a_calls_block_in_the_prompt_is_not_entered_and_thinking_off_starts_in_content() {
+ 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/tagged/dsml.rs b/crates/symphony/src/tagged/dsml.rs
index 9693bd2c2..49704c2b6 100644
--- a/crates/symphony/src/tagged/dsml.rs
+++ b/crates/symphony/src/tagged/dsml.rs
@@ -23,14 +23,18 @@
//! [`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 read as `string="true"` and its tail reported as `Malformed`.
+//! 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**; a parameter tag or an invoke tag
-//! there is the value's text, as for Qwen.
+//! - **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, or the block's end before
//! it: after the last parameter it closes the object and pushes `ToolCallEnd` with the tag's
//! bytes; inside a value it closes an open string and the object and reports what was held, as
@@ -38,7 +42,10 @@
//! - **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 }`.
+//! 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
@@ -381,24 +388,18 @@ impl Assembler {
self.stage = Stage::Between;
}
- /// The parameter tag is whole: the value begins, typed by its `string` attribute.
+ /// 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,
- other => {
- let tag_end = self.carried.len() - other.len() - 1;
- let mut after = self.carried.split_off(tag_end);
- let close = after.pop();
- if !after.is_empty() {
- self.drop_carried(out);
- out.push(Event::Malformed {
- text: Text::uncounted(after),
- why: MalformedReason::Other(TAG_TAIL.to_string()),
- });
- }
- self.carried.extend(close);
- true
+ _ => {
+ self.report(TAG_TAIL, out);
+ self.stage = Stage::Between;
+ return;
}
};
self.stage = Stage::Value(ValueState {
diff --git a/crates/symphony/tests/bellwether_parse_fixtures.rs b/crates/symphony/tests/bellwether_parse_fixtures.rs
index 5087579a0..b6e38f6c3 100644
--- a/crates/symphony/tests/bellwether_parse_fixtures.rs
+++ b/crates/symphony/tests/bellwether_parse_fixtures.rs
@@ -292,9 +292,7 @@ impl Family {
let list = match self {
Self::Qwen3 | Self::Qwen2_5 => KNOWN_DIFFERENCES,
Self::Qwen3Tagged => KNOWN_TAGGED_DIFFERENCES,
- // DSML's code-fence probe holds Qwen's syntax, which the DSML table never reads as a
- // call: the fence is content, as the reference says.
- Self::DeepSeekV4_1 => &KNOWN_TAGGED_DIFFERENCES[..1],
+ 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.
@@ -447,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)
diff --git a/crates/symphony/tests/contract.rs b/crates/symphony/tests/contract.rs
index 2cb4bd9b2..4c9982353 100644
--- a/crates/symphony/tests/contract.rs
+++ b/crates/symphony/tests/contract.rs
@@ -111,8 +111,12 @@ const DSML_OUTPUTS: &[&str] = &[
|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=\"b\" \
- string=\"true\">y|DSML| parameter>\n|DSML| invoke>\n<|DSML| invoke name=\"g\">\n\
+ "\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 \
From c9234c9ec0cf53acbe73922cb6be3c06c1d37f72 Mon Sep 17 00:00:00 2001
From: Simo Lin <25425177+slin1237@users.noreply.github.com>
Date: Wed, 7 Oct 2026 05:46:25 -0700
Subject: [PATCH 5/7] fix(symphony): only the invoke's closing tag is a DSML
call's end, and held bytes are reported before a string closes
The next invoke's opening and the block's close end an invoke too, but they belong to the region: the engine drops them as the template's wrapping and the call's end carries no bytes of its own, so no call's end holds the next call's opening and no structural marker is reported as malformed when no call had started (dsml::INVOKE_CLOSE names the one terminal the end carries, and the table uses it). When the invoke's close cuts a tag short inside a streamed string, the held bytes come back as Malformed, as the module doc says, and the quote closes with no bytes of its own. The crate's status lines no longer name the families in.
Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com>
---
crates/symphony/src/tagged/dsml.rs | 20 ++++++++++++++------
1 file changed, 14 insertions(+), 6 deletions(-)
diff --git a/crates/symphony/src/tagged/dsml.rs b/crates/symphony/src/tagged/dsml.rs
index 49704c2b6..2d5465b28 100644
--- a/crates/symphony/src/tagged/dsml.rs
+++ b/crates/symphony/src/tagged/dsml.rs
@@ -35,10 +35,12 @@
//! 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, or the block's end before
-//! it: after the last parameter it closes the object and pushes `ToolCallEnd` with the tag's
-//! bytes; inside a value it closes an open string and the object and reports what was held, as
-//! the Qwen assembler does. [`Assembler::finish`] is a stream that was cut: nothing is closed.
+//! - **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
@@ -62,6 +64,10 @@ use crate::{
},
};
+/// 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>"];
@@ -474,8 +480,10 @@ impl Assembler {
/// back as `Malformed`, since its member was never written.
fn cut_value(&mut self, value: &ValueState, out: &mut Events) {
if value.string && value.opened {
- let source = Text::uncounted(std::mem::take(&mut self.carried));
- self.push_fragment("\"".to_string(), source, out);
+ // 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);
}
From 3c548ebccb7408b24e432ba59ec9f11228cffc8a Mon Sep 17 00:00:00 2001
From: Simo Lin <25425177+slin1237@users.noreply.github.com>
Date: Wed, 7 Oct 2026 05:54:06 -0700
Subject: [PATCH 6/7] fix(symphony): the engine's half of the DSML call's end
Call::end gives the DSML assembler the invoke's closing tag, dsml::INVOKE_CLOSE, as the call's end and nothing else: for the next invoke's opening and the block's close it closes the call with no bytes and returns false, so the engine drops the terminal as the region's. The table names the terminal through the constant, two tests hold the three endings and a tag cut short inside a string, the contract corpus has that output, and the crate's status lines no longer name the families in. The previous commit carried the assembler's half alone.
Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com>
---
crates/symphony/README.md | 4 +-
crates/symphony/src/engine.rs | 7 ++-
crates/symphony/src/formats/deepseek_v4_1.rs | 50 +++++++++++++++++++-
crates/symphony/src/lib.rs | 2 +-
crates/symphony/tests/contract.rs | 3 ++
5 files changed, 59 insertions(+), 7 deletions(-)
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 36f88a1fb..6f6c03514 100644
--- a/crates/symphony/src/engine.rs
+++ b/crates/symphony/src/engine.rs
@@ -137,16 +137,19 @@ impl Call {
/// 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 as `ToolCallEnd`'s bytes.
+ /// 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),
- (Self::Dsml(assembler), Closed::ByMarker) => {
+ // 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.
+ (Self::Dsml(assembler), Closed::ByMarker) if terminal == tagged::dsml::INVOKE_CLOSE => {
assembler.close(terminal, out);
return true;
}
+ (Self::Dsml(assembler), Closed::ByMarker) => assembler.close("", out),
(Self::Dsml(assembler), Closed::ByEnd) => assembler.finish(out),
}
false
diff --git a/crates/symphony/src/formats/deepseek_v4_1.rs b/crates/symphony/src/formats/deepseek_v4_1.rs
index 060658fb4..4a6540ffc 100644
--- a/crates/symphony/src/formats/deepseek_v4_1.rs
+++ b/crates/symphony/src/formats/deepseek_v4_1.rs
@@ -22,7 +22,10 @@
//! `\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};
+use crate::{
+ format::{CallSyntax, Emits, Format},
+ tagged::dsml,
+};
/// The DeepSeek V4.1 table.
pub fn deepseek_v4_1() -> Format {
@@ -32,7 +35,7 @@ pub fn deepseek_v4_1() -> Format {
.terminal("calls_open", "<|DSML| calls>")
.terminal("calls_close", "|DSML| calls>")
.terminal("invoke_open", "<|DSML| invoke name=\"")
- .terminal("invoke_close", "|DSML| invoke>")
+ .terminal("invoke_close", dsml::INVOKE_CLOSE)
.state("content", Emits::Content)
.state("reasoning", Emits::Reasoning)
.state("calls", Emits::Wrapper)
@@ -358,6 +361,49 @@ mod tests {
);
}
+ #[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>", "", ""]);
+ assert!(events.contains(&dropped("<|DSML| invoke name=\"")));
+ assert!(events.contains(&dropped("|DSML| calls>")));
+ assert!(matches!(
+ events.last(),
+ Some(Event::Finish { tool_calls: 3, .. })
+ ));
+ }
+
+ #[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"]);
diff --git a/crates/symphony/src/lib.rs b/crates/symphony/src/lib.rs
index 876f8e24f..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)]
diff --git a/crates/symphony/tests/contract.rs b/crates/symphony/tests/contract.rs
index 4c9982353..7bd2979d2 100644
--- a/crates/symphony/tests/contract.rs
+++ b/crates/symphony/tests/contract.rs
@@ -123,6 +123,9 @@ const DSML_OUTPUTS: &[&str] = &[
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>",
];
From 073f962af4e883b2139d2eeddcf0c90ca9a4a5fc Mon Sep 17 00:00:00 2001
From: Simo Lin <25425177+slin1237@users.noreply.github.com>
Date: Wed, 7 Oct 2026 06:01:18 -0700
Subject: [PATCH 7/7] fix(symphony): an invoke that named no call is reported
however it ended
Call::end gives the DSML assembler the terminal that ended an invoke whose name never closed, whichever it is, so the invoke is reported as Malformed with those bytes by the block's close and the next invoke's opening as it is by the invoke's own closing tag; a started call still carries only the invoke's closing tag as its end. The three-endings test counts the dropped openers, so it fails if the next invoke's opening were taken as the end, and a test holds the three endings of an invoke that named nothing.
Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com>
---
crates/symphony/src/engine.rs | 8 +++-
crates/symphony/src/formats/deepseek_v4_1.rs | 50 +++++++++++++++++++-
2 files changed, 55 insertions(+), 3 deletions(-)
diff --git a/crates/symphony/src/engine.rs b/crates/symphony/src/engine.rs
index 6f6c03514..2d48df1ea 100644
--- a/crates/symphony/src/engine.rs
+++ b/crates/symphony/src/engine.rs
@@ -144,8 +144,12 @@ impl Call {
(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.
- (Self::Dsml(assembler), Closed::ByMarker) if terminal == tagged::dsml::INVOKE_CLOSE => {
+ // 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;
}
diff --git a/crates/symphony/src/formats/deepseek_v4_1.rs b/crates/symphony/src/formats/deepseek_v4_1.rs
index 4a6540ffc..c3bdb0feb 100644
--- a/crates/symphony/src/formats/deepseek_v4_1.rs
+++ b/crates/symphony/src/formats/deepseek_v4_1.rs
@@ -380,7 +380,15 @@ mod tests {
})
.collect();
assert_eq!(ends, ["|DSML| invoke>", "", ""]);
- assert!(events.contains(&dropped("<|DSML| invoke name=\"")));
+ // 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(),
@@ -388,6 +396,46 @@ mod tests {
));
}
+ #[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!(