diff --git a/crates/symphony/src/engine.rs b/crates/symphony/src/engine.rs
index bf25b4a8c..7b555d6b0 100644
--- a/crates/symphony/src/engine.rs
+++ b/crates/symphony/src/engine.rs
@@ -100,6 +100,7 @@ enum Call {
Json(json::Assembler),
Tagged(tagged::Assembler),
Dsml(tagged::dsml::Assembler),
+ Keyed(tagged::keyed::Assembler),
}
impl Call {
@@ -111,6 +112,9 @@ impl Call {
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)),
+ Some(CallSyntax::Keyed(tags)) => {
+ Self::Keyed(tagged::keyed::Assembler::new(index, id, tags))
+ }
}
}
@@ -122,6 +126,10 @@ impl Call {
assembler.feed(text, out);
text.len()
}
+ Self::Keyed(assembler) => {
+ assembler.feed(text, declared, out);
+ text.len()
+ }
}
}
@@ -130,6 +138,7 @@ impl Call {
Self::Json(assembler) => assembler.started(),
Self::Tagged(assembler) => assembler.started(),
Self::Dsml(assembler) => assembler.started(),
+ Self::Keyed(assembler) => assembler.started(),
}
}
@@ -137,7 +146,8 @@ 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, and only that terminal, as `ToolCallEnd`'s bytes.
+ /// closing tag, and only that terminal, as `ToolCallEnd`'s bytes; the keyed one takes the
+ /// call's closing marker.
fn end(self, closed: Closed, terminal: &str, out: &mut Events) -> bool {
match (self, closed) {
(Self::Json(assembler), _) => assembler.finish(out),
@@ -155,6 +165,11 @@ impl Call {
}
(Self::Dsml(assembler), Closed::ByMarker) => assembler.close("", out),
(Self::Dsml(assembler), Closed::ByEnd) => assembler.finish(out),
+ (Self::Keyed(assembler), Closed::ByMarker) => {
+ assembler.close(terminal, out);
+ return true;
+ }
+ (Self::Keyed(assembler), Closed::ByEnd) => assembler.finish(out),
}
false
}
@@ -319,12 +334,18 @@ impl Engine {
let Some(call) = self.call.take() else {
return false;
};
- if call.started() {
- self.calls += 1;
- }
+ let started_before = call.started();
let mut finished = Events::new();
let taken = call.end(closed, terminal, &mut finished);
- for event in finished.drain() {
+ let finished = finished.drain();
+ // A keyed call with no arguments is named only at its end, so the end's events count too.
+ let started_at_the_end = finished
+ .iter()
+ .any(|event| matches!(event, Event::ToolCallStart { .. }));
+ if started_before || started_at_the_end {
+ self.calls += 1;
+ }
+ for event in finished {
let event = match event {
Event::Malformed {
text,
diff --git a/crates/symphony/src/format.rs b/crates/symphony/src/format.rs
index a081f36d4..c352b94d5 100644
--- a/crates/symphony/src/format.rs
+++ b/crates/symphony/src/format.rs
@@ -31,6 +31,8 @@
//! [`Engine`]: crate::Engine
//! [`formats`]: crate::formats
+use crate::tagged::keyed;
+
/// How the model writes a call between the call markers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CallSyntax {
@@ -44,6 +46,11 @@ pub enum CallSyntax {
/// 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,
+ /// The call's name as text, then `` and `` pairs, in the family's spelling
+ /// of the four tags, typed by the request's tools: GLM, Hy4, Ling, IQuest. The terminals that
+ /// enter and leave the state are the call's own markers, and the call's end carries the
+ /// closing one.
+ Keyed(keyed::Tags),
}
/// What the text inside a state is.
diff --git a/crates/symphony/src/formats/hy4.rs b/crates/symphony/src/formats/hy4.rs
new file mode 100644
index 000000000..fad601361
--- /dev/null
+++ b/crates/symphony/src/formats/hy4.rs
@@ -0,0 +1,241 @@
+//! Hy4: reasoning between `` and ``, the turn's calls between
+//! `` and ``, each call between
+//! `` and `` as its name and keyed arguments in Hy4's
+//! spelling of the tags ([`keyed::Tags::HY4`]), typed by the request's tools. Everything else is
+//! content. The prompt opens the thought, and an empty thought writes `` at
+//! once. A turn opens with `<|hy_start:opensource|>assistant<|hy_middle:opensource|>`, so the
+//! prompt's replay starts there. Recorded as `hy4-preview` (tencent/Hy4-preview).
+
+use crate::{
+ format::{CallSyntax, Emits, Format},
+ tagged::keyed,
+};
+
+/// The Hy4 table.
+pub fn hy4() -> Format {
+ Format::new("hy4")
+ .terminal("think_open", "")
+ .terminal("think_close", "")
+ .terminal("calls_open", "")
+ .terminal("calls_close", "")
+ .terminal("call_open", "")
+ .terminal("call_close", "")
+ .state("content", Emits::Content)
+ .state("reasoning", Emits::Reasoning)
+ .state("calls", Emits::Wrapper)
+ .state("call", Emits::Arguments)
+ .transition("content", "think_open", "reasoning")
+ .transition("reasoning", "think_close", "content")
+ .transition("content", "calls_open", "calls")
+ .transition("calls", "call_open", "call")
+ .transition("call", "call_close", "calls")
+ .transition("calls", "calls_close", "content")
+ .calls(CallSyntax::Keyed(keyed::Tags::HY4))
+ .opens_turn("<|hy_start:opensource|>assistant<|hy_middle:opensource|>")
+}
+
+#[cfg(test)]
+mod tests {
+ use openai_protocol::common::{Function, Tool};
+ use serde_json::json as value;
+
+ use super::*;
+ use crate::{
+ engine::Engine,
+ event::{Event, Events},
+ input::{EngineFinish, Input},
+ parser::Parser,
+ tagged::Declared,
+ };
+
+ fn declared() -> Declared {
+ Declared::of(&[Tool {
+ tool_type: "function".to_string(),
+ function: Function {
+ name: "get_weather".to_string(),
+ description: None,
+ parameters: value!({"type": "object", "properties": {
+ "city": {"type": "string"},
+ "days": {"type": "integer"},
+ }}),
+ strict: None,
+ },
+ }])
+ }
+
+ const PROMPT: &str = "\n";
+ const OUTPUT: &str = concat!(
+ "The user asks.",
+ "get_weathercity",
+ "Parisdays",
+ "3",
+ "",
+ "get_weather"
+ );
+
+ fn run(prompt: &str, output: &str) -> Vec {
+ let mut parser = Engine::new(hy4(), declared());
+ let mut out = Events::new();
+ parser
+ .feed(
+ Input::Prompt {
+ token_ids: &[],
+ text: prompt,
+ },
+ &mut out,
+ )
+ .expect("prompt");
+ parser
+ .feed(
+ Input::Delta {
+ token_ids: &[],
+ text: output,
+ 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(events: &[Event]) -> Vec<(String, String)> {
+ let mut calls: Vec<(String, String)> = Vec::new();
+ for event in events {
+ match event {
+ Event::ToolCallStart { name, .. } => calls.push((name.clone(), String::new())),
+ Event::ToolCallArguments { json, .. } => {
+ if let Some(last) = calls.last_mut() {
+ last.1.push_str(json);
+ }
+ }
+ _ => {}
+ }
+ }
+ calls
+ }
+
+ #[test]
+ fn the_prompt_is_replayed_from_hy4s_own_turn_opener() {
+ // A call marker quoted in the user's turn, then the generation prompt opening the thought:
+ // the replay starts at Hy4's own turn opener, so the output is the thought and then
+ // content (smg #2842, Alex's probe with the rendered prompt).
+ let prompt = "<|hy_start:opensource|>user<|hy_middle:opensource|>Why did you print \
+ there?<|hy_start:opensource|>assistant\
+ <|hy_middle:opensource|>\n";
+ let output = "The user asks about the tag.It opens a call.";
+ let events = run(prompt, output);
+ assert_eq!(events[0], Event::ReasoningStart);
+ assert_eq!(bytes(&events), output);
+ assert!(arguments(&events).is_empty());
+ let reasoning: String = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::Reasoning(text) => Some(text.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ let content: String = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::Content(text) => Some(text.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(reasoning, "The user asks about the tag.");
+ assert_eq!(content, "It opens a call.");
+ }
+
+ #[test]
+ fn a_recorded_output_gives_its_calls_typed_by_the_tools_and_every_byte() {
+ let events = run(PROMPT, OUTPUT);
+ assert_eq!(bytes(&events), OUTPUT);
+ assert_eq!(
+ arguments(&events),
+ [
+ (
+ "get_weather".to_string(),
+ r#"{"city": "Paris", "days": 3}"#.to_string()
+ ),
+ ("get_weather".to_string(), "{}".to_string()),
+ ]
+ );
+ assert!(matches!(
+ events.last(),
+ Some(Event::Finish { tool_calls: 2, .. })
+ ));
+ let reasoning: String = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::Reasoning(text) => Some(text.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(reasoning, "The user asks.");
+ }
+
+ #[test]
+ fn every_chunking_says_the_same_and_accounts_for_every_byte() {
+ let whole = arguments(&run(PROMPT, OUTPUT));
+ for cut in 1..OUTPUT.len() {
+ if !OUTPUT.is_char_boundary(cut) {
+ continue;
+ }
+ let mut parser = Engine::new(hy4(), declared());
+ let mut out = Events::new();
+ parser
+ .feed(
+ Input::Prompt {
+ token_ids: &[],
+ text: PROMPT,
+ },
+ &mut out,
+ )
+ .expect("prompt");
+ for piece in [&OUTPUT[..cut], &OUTPUT[cut..]] {
+ parser
+ .feed(
+ Input::Delta {
+ token_ids: &[],
+ text: piece,
+ spans: &[],
+ },
+ &mut out,
+ )
+ .expect("delta");
+ }
+ parser
+ .feed(
+ Input::End {
+ finish: EngineFinish::Stop,
+ },
+ &mut out,
+ )
+ .expect("end");
+ let events = out.drain();
+ assert_eq!(bytes(&events), OUTPUT, "cut at {cut}");
+ assert_eq!(arguments(&events), whole, "cut at {cut}");
+ }
+ }
+}
diff --git a/crates/symphony/src/formats/iquest.rs b/crates/symphony/src/formats/iquest.rs
new file mode 100644
index 000000000..3586cce12
--- /dev/null
+++ b/crates/symphony/src/formats/iquest.rs
@@ -0,0 +1,231 @@
+//! IQuest: reasoning between `` and ``, each call between `` and
+//! `` as its name and keyed arguments (``, ``), typed by
+//! the request's tools, with no newline anywhere. Everything else is content. The prompt opens
+//! the thought, and a turn opens with `<|iquest_assistant|>`, so the prompt's replay starts there.
+//! Recorded as `iquest-q1` (IQuestLab/IQuest-Q1).
+
+use crate::{
+ format::{CallSyntax, Emits, Format},
+ tagged::keyed,
+};
+
+/// The IQuest table.
+pub fn iquest() -> Format {
+ Format::new("iquest")
+ .terminal("think_open", "")
+ .terminal("think_close", "")
+ .terminal("call_open", "")
+ .terminal("call_close", "")
+ .state("content", Emits::Content)
+ .state("reasoning", Emits::Reasoning)
+ .state("call", Emits::Arguments)
+ .transition("content", "think_open", "reasoning")
+ .transition("reasoning", "think_close", "content")
+ .transition("content", "call_open", "call")
+ .transition("call", "call_close", "content")
+ .transition("call", "call_open", "call")
+ .calls(CallSyntax::Keyed(keyed::Tags::PLAIN))
+ .opens_turn("<|iquest_assistant|>")
+}
+
+#[cfg(test)]
+mod tests {
+ use openai_protocol::common::{Function, Tool};
+ use serde_json::json as value;
+
+ use super::*;
+ use crate::{
+ engine::Engine,
+ event::{Event, Events},
+ input::{EngineFinish, Input},
+ parser::Parser,
+ tagged::Declared,
+ };
+
+ fn declared() -> Declared {
+ Declared::of(&[Tool {
+ tool_type: "function".to_string(),
+ function: Function {
+ name: "get_weather".to_string(),
+ description: None,
+ parameters: value!({"type": "object", "properties": {
+ "city": {"type": "string"},
+ "days": {"type": "integer"},
+ }}),
+ strict: None,
+ },
+ }])
+ }
+
+ const PROMPT: &str = "\n";
+ const OUTPUT: &str = concat!(
+ "The user asks.get_weathercity",
+ "Parisdays3",
+ "get_weather"
+ );
+
+ fn run(prompt: &str, output: &str) -> Vec {
+ let mut parser = Engine::new(iquest(), declared());
+ let mut out = Events::new();
+ parser
+ .feed(
+ Input::Prompt {
+ token_ids: &[],
+ text: prompt,
+ },
+ &mut out,
+ )
+ .expect("prompt");
+ parser
+ .feed(
+ Input::Delta {
+ token_ids: &[],
+ text: output,
+ 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(events: &[Event]) -> Vec<(String, String)> {
+ let mut calls: Vec<(String, String)> = Vec::new();
+ for event in events {
+ match event {
+ Event::ToolCallStart { name, .. } => calls.push((name.clone(), String::new())),
+ Event::ToolCallArguments { json, .. } => {
+ if let Some(last) = calls.last_mut() {
+ last.1.push_str(json);
+ }
+ }
+ _ => {}
+ }
+ }
+ calls
+ }
+
+ #[test]
+ fn the_prompt_is_replayed_from_iquests_own_turn_opener() {
+ // A call marker quoted in the user's turn, then the generation prompt opening the thought:
+ // the replay starts at IQuest's own turn opener, so the output is the thought and then
+ // content (smg #2842, Alex's probe with the rendered prompt).
+ let prompt =
+ "user: Why did you print there?\n<|iquest_assistant|>\n";
+ let output = "The user asks about the tag.It opens a call.";
+ let events = run(prompt, output);
+ assert_eq!(events[0], Event::ReasoningStart);
+ assert_eq!(bytes(&events), output);
+ assert!(arguments(&events).is_empty());
+ let reasoning: String = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::Reasoning(text) => Some(text.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ let content: String = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::Content(text) => Some(text.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(reasoning, "The user asks about the tag.");
+ assert_eq!(content, "It opens a call.");
+ }
+
+ #[test]
+ fn a_recorded_output_gives_its_calls_typed_by_the_tools_and_every_byte() {
+ let events = run(PROMPT, OUTPUT);
+ assert_eq!(bytes(&events), OUTPUT);
+ assert_eq!(
+ arguments(&events),
+ [
+ (
+ "get_weather".to_string(),
+ r#"{"city": "Paris", "days": 3}"#.to_string()
+ ),
+ ("get_weather".to_string(), "{}".to_string()),
+ ]
+ );
+ assert!(matches!(
+ events.last(),
+ Some(Event::Finish { tool_calls: 2, .. })
+ ));
+ let reasoning: String = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::Reasoning(text) => Some(text.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(reasoning, "The user asks.");
+ }
+
+ #[test]
+ fn every_chunking_says_the_same_and_accounts_for_every_byte() {
+ let whole = arguments(&run(PROMPT, OUTPUT));
+ for cut in 1..OUTPUT.len() {
+ if !OUTPUT.is_char_boundary(cut) {
+ continue;
+ }
+ let mut parser = Engine::new(iquest(), declared());
+ let mut out = Events::new();
+ parser
+ .feed(
+ Input::Prompt {
+ token_ids: &[],
+ text: PROMPT,
+ },
+ &mut out,
+ )
+ .expect("prompt");
+ for piece in [&OUTPUT[..cut], &OUTPUT[cut..]] {
+ parser
+ .feed(
+ Input::Delta {
+ token_ids: &[],
+ text: piece,
+ spans: &[],
+ },
+ &mut out,
+ )
+ .expect("delta");
+ }
+ parser
+ .feed(
+ Input::End {
+ finish: EngineFinish::Stop,
+ },
+ &mut out,
+ )
+ .expect("end");
+ let events = out.drain();
+ assert_eq!(bytes(&events), OUTPUT, "cut at {cut}");
+ assert_eq!(arguments(&events), whole, "cut at {cut}");
+ }
+ }
+}
diff --git a/crates/symphony/src/formats/ling.rs b/crates/symphony/src/formats/ling.rs
new file mode 100644
index 000000000..ef0c043c9
--- /dev/null
+++ b/crates/symphony/src/formats/ling.rs
@@ -0,0 +1,231 @@
+//! Ling: reasoning between `` and ``, each call between `` and
+//! `` as its name and keyed arguments (``, ``), typed by the
+//! request's tools, with the template's newline after the name and after ``. Everything
+//! else is content. The prompt opens the thought, and a turn opens with `ASSISTANT`,
+//! so the prompt's replay starts there. Recorded as `ling-3.0-flash` (inclusionAI/Ling-3.0-flash);
+//! GLM 4.5 and later write the same syntax.
+
+use crate::{
+ format::{CallSyntax, Emits, Format},
+ tagged::keyed,
+};
+
+/// The Ling table.
+pub fn ling() -> Format {
+ Format::new("ling")
+ .terminal("think_open", "")
+ .terminal("think_close", "")
+ .terminal("call_open", "")
+ .terminal("call_close", "")
+ .state("content", Emits::Content)
+ .state("reasoning", Emits::Reasoning)
+ .state("call", Emits::Arguments)
+ .transition("content", "think_open", "reasoning")
+ .transition("reasoning", "think_close", "content")
+ .transition("content", "call_open", "call")
+ .transition("call", "call_close", "content")
+ .transition("call", "call_open", "call")
+ .calls(CallSyntax::Keyed(keyed::Tags::PLAIN))
+ .opens_turn("ASSISTANT")
+}
+
+#[cfg(test)]
+mod tests {
+ use openai_protocol::common::{Function, Tool};
+ use serde_json::json as value;
+
+ use super::*;
+ use crate::{
+ engine::Engine,
+ event::{Event, Events},
+ input::{EngineFinish, Input},
+ parser::Parser,
+ tagged::Declared,
+ };
+
+ fn declared() -> Declared {
+ Declared::of(&[Tool {
+ tool_type: "function".to_string(),
+ function: Function {
+ name: "get_weather".to_string(),
+ description: None,
+ parameters: value!({"type": "object", "properties": {
+ "city": {"type": "string"},
+ "days": {"type": "integer"},
+ }}),
+ strict: None,
+ },
+ }])
+ }
+
+ const PROMPT: &str = "\n";
+ const OUTPUT: &str = concat!(
+ "The user asks.get_weather\ncity\n",
+ "Parisdays\n3\n",
+ "\nget_weather\n"
+ );
+
+ fn run(prompt: &str, output: &str) -> Vec {
+ let mut parser = Engine::new(ling(), declared());
+ let mut out = Events::new();
+ parser
+ .feed(
+ Input::Prompt {
+ token_ids: &[],
+ text: prompt,
+ },
+ &mut out,
+ )
+ .expect("prompt");
+ parser
+ .feed(
+ Input::Delta {
+ token_ids: &[],
+ text: output,
+ 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(events: &[Event]) -> Vec<(String, String)> {
+ let mut calls: Vec<(String, String)> = Vec::new();
+ for event in events {
+ match event {
+ Event::ToolCallStart { name, .. } => calls.push((name.clone(), String::new())),
+ Event::ToolCallArguments { json, .. } => {
+ if let Some(last) = calls.last_mut() {
+ last.1.push_str(json);
+ }
+ }
+ _ => {}
+ }
+ }
+ calls
+ }
+
+ #[test]
+ fn the_prompt_is_replayed_from_lings_own_turn_opener() {
+ // A call marker quoted in the user's turn, then the generation prompt opening the thought:
+ // the replay starts at Ling's own turn opener, so the output is the thought and then
+ // content (smg #2842, Alex's probe with the rendered prompt).
+ let prompt = "user: Why did you print there?\nASSISTANT\n";
+ let output = "The user asks about the tag.It opens a call.";
+ let events = run(prompt, output);
+ assert_eq!(events[0], Event::ReasoningStart);
+ assert_eq!(bytes(&events), output);
+ assert!(arguments(&events).is_empty());
+ let reasoning: String = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::Reasoning(text) => Some(text.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ let content: String = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::Content(text) => Some(text.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(reasoning, "The user asks about the tag.");
+ assert_eq!(content, "It opens a call.");
+ }
+
+ #[test]
+ fn a_recorded_output_gives_its_calls_typed_by_the_tools_and_every_byte() {
+ let events = run(PROMPT, OUTPUT);
+ assert_eq!(bytes(&events), OUTPUT);
+ assert_eq!(
+ arguments(&events),
+ [
+ (
+ "get_weather".to_string(),
+ r#"{"city": "Paris", "days": 3}"#.to_string()
+ ),
+ ("get_weather".to_string(), "{}".to_string()),
+ ]
+ );
+ assert!(matches!(
+ events.last(),
+ Some(Event::Finish { tool_calls: 2, .. })
+ ));
+ let reasoning: String = events
+ .iter()
+ .filter_map(|event| match event {
+ Event::Reasoning(text) => Some(text.text.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(reasoning, "The user asks.");
+ }
+
+ #[test]
+ fn every_chunking_says_the_same_and_accounts_for_every_byte() {
+ let whole = arguments(&run(PROMPT, OUTPUT));
+ for cut in 1..OUTPUT.len() {
+ if !OUTPUT.is_char_boundary(cut) {
+ continue;
+ }
+ let mut parser = Engine::new(ling(), declared());
+ let mut out = Events::new();
+ parser
+ .feed(
+ Input::Prompt {
+ token_ids: &[],
+ text: PROMPT,
+ },
+ &mut out,
+ )
+ .expect("prompt");
+ for piece in [&OUTPUT[..cut], &OUTPUT[cut..]] {
+ parser
+ .feed(
+ Input::Delta {
+ token_ids: &[],
+ text: piece,
+ spans: &[],
+ },
+ &mut out,
+ )
+ .expect("delta");
+ }
+ parser
+ .feed(
+ Input::End {
+ finish: EngineFinish::Stop,
+ },
+ &mut out,
+ )
+ .expect("end");
+ let events = out.drain();
+ assert_eq!(bytes(&events), OUTPUT, "cut at {cut}");
+ assert_eq!(arguments(&events), whole, "cut at {cut}");
+ }
+ }
+}
diff --git a/crates/symphony/src/formats/mod.rs b/crates/symphony/src/formats/mod.rs
index cdc115a84..a1c80357c 100644
--- a/crates/symphony/src/formats/mod.rs
+++ b/crates/symphony/src/formats/mod.rs
@@ -6,17 +6,24 @@
//! [`qwen2_5()`] is the same family before thinking: `` blocks alone, and `` is
//! 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. [`seed_oss()`] is the Qwen
-//! tagged syntax under Seed-OSS's own markers.
+//! tagged syntax under Seed-OSS's own markers. [`hy4()`], [`ling()`] and [`iquest()`] write a
+//! call as its name and keyed arguments, each under its own markers.
//!
//! [`Format`]: crate::Format
//! [`Engine`]: crate::Engine
pub mod deepseek_v4_1;
+pub mod hy4;
+pub mod iquest;
+pub mod ling;
pub mod qwen2_5;
pub mod qwen3;
pub mod seed_oss;
pub use deepseek_v4_1::deepseek_v4_1;
+pub use hy4::hy4;
+pub use iquest::iquest;
+pub use ling::ling;
pub use qwen2_5::qwen2_5;
pub use qwen3::qwen3;
pub use seed_oss::seed_oss;
diff --git a/crates/symphony/src/lib.rs b/crates/symphony/src/lib.rs
index b3796aa7a..1156b5ff0 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::{deepseek_v4_1, qwen2_5, qwen3, seed_oss};
+pub use formats::{deepseek_v4_1, hy4, iquest, ling, qwen2_5, qwen3, seed_oss};
pub use input::{EngineFinish, Input, TokenSpan};
pub use markers::{Piece, Scanner};
pub use parser::{ParseError, Parser};
diff --git a/crates/symphony/src/tagged/keyed.rs b/crates/symphony/src/tagged/keyed.rs
new file mode 100644
index 000000000..7791d2ddd
--- /dev/null
+++ b/crates/symphony/src/tagged/keyed.rs
@@ -0,0 +1,849 @@
+//! Keyed arguments: the events of one call written as its name and then `` and
+//! `` pairs, from its bytes.
+//!
+//! GLM, Hy4, Ling and IQuest write a call as
+//!
+//! ```text
+//! get_weather
+//! city
+//! Parisdays
+//! 3
+//!
+//! ```
+//!
+//! each with its own spellings of the four tags ([`Tags`]) and its own newlines between them (Ling
+//! writes one after the name and one after ``, Hy4 and IQuest none). The engine's table
+//! owns the call markers: it enters this assembler after the opening marker and leaves it at the
+//! closing one, which it hands over as the call's end. Between the two the assembler reads the
+//! name and the tags itself.
+//!
+//! What the syntax says, and what the assembler does with it:
+//!
+//! - **The name is the text before the first ``**, or before the call's end when the call
+//! has no arguments, less the whitespace around it, which is the template's and goes into the
+//! next event's source. The name is known only when that tag arrives, so `ToolCallStart` waits
+//! for it; names are short. A name holds no whitespace, quote, brace or angle bracket: any
+//! other text there (the JSON of a `` block quoted inside a code fence, prose) is
+//! reported, and no call starts, so a block that names no call writes no arguments either.
+//! - **A value's type comes from the request's tools**, as in the Qwen assembler: a declared string
+//! is its text, streamed as it arrives; every other value is written whole at ``
+//! through [`json`], which reads the JSON the templates write for a number, a boolean, a list,
+//! an object or null, and infers the rest. Nothing is taken from a value: the templates write it
+//! directly between its tags.
+//! - **Inside a value only `` is a tag**; the other three there are the value's text.
+//! - **Two endings.** [`Assembler::close`] is the call's closing marker, or the block's end before
+//! it: after the last value it names the call if it has not, closes the object and pushes
+//! `ToolCallEnd` with the marker's bytes; inside a value it closes an open string and the object
+//! and reports what was held. [`Assembler::finish`] is a stream that was cut: nothing is closed.
+//! - **Every byte of the call lands in exactly one event**, with the Qwen assembler's accounting:
+//! 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 third tagged assembler, beside the Qwen one and DSML's; the three share the value
+//! module and the escaping, and the seams for one core are now visible: the tag spellings, how a
+//! name ends, where the template's newlines are, and what types a value.
+
+use crate::{
+ event::{DropReason, Event, Events, MalformedReason, Text},
+ markers::{Piece, Scanner},
+ tagged::{
+ assembler::{push_escaped, push_quoted},
+ value::{json, Declared, Kind, NULL_WORDS},
+ },
+};
+
+const KEY_OPEN: usize = 0;
+const KEY_CLOSE: usize = 1;
+const VALUE_OPEN: usize = 2;
+const VALUE_CLOSE: usize = 3;
+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_KEY: &str = "a key with no text";
+const WITHOUT_A_NAME: &str = "a call block without a name";
+const NOT_A_NAME: &str = "text where the call's name should be";
+
+/// Whether `text` can be a function's name: no whitespace, quote, brace or angle bracket.
+fn is_name(text: &str) -> bool {
+ !text
+ .chars()
+ .any(|c| c.is_whitespace() || matches!(c, '"' | '\'' | '{' | '}' | '<' | '>'))
+}
+
+/// The four tags a family spells its keyed arguments with.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Tags {
+ pub key_open: &'static str,
+ pub key_close: &'static str,
+ pub value_open: &'static str,
+ pub value_close: &'static str,
+}
+
+impl Tags {
+ /// GLM's, Ling's and most families' spelling.
+ pub const PLAIN: Self = Self {
+ key_open: "",
+ key_close: "",
+ value_open: "",
+ value_close: "",
+ };
+
+ /// Hy4's spelling, every tag suffixed `:opensource`.
+ pub const HY4: Self = Self {
+ key_open: "",
+ key_close: "",
+ value_open: "",
+ value_close: "",
+ };
+
+ fn text(&self, tag: usize) -> &'static str {
+ [
+ self.key_open,
+ self.key_close,
+ self.value_open,
+ self.value_close,
+ ][tag]
+ }
+}
+
+/// The events of one keyed call, from the bytes after the call's opening marker.
+#[derive(Clone, Debug)]
+pub struct Assembler {
+ index: u32,
+ id: String,
+ tags: Tags,
+ 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 `ToolCallStart` was pushed.
+ function: Option,
+ /// Members written to the arguments object so far.
+ written: u32,
+ done: bool,
+}
+
+#[derive(Clone, Debug)]
+enum Stage {
+ /// Before the first tag: the name is the carried text, less the whitespace around it.
+ Name,
+ /// After a value, before the next key.
+ Between,
+ /// Inside `…`: the key is `carried[start..]`.
+ Key { start: usize },
+ /// After ``, before ``.
+ Keyed { key: String },
+ /// Inside a value.
+ Value(ValueState),
+}
+
+#[derive(Clone, Debug)]
+struct ValueState {
+ key: String,
+ kind: Option,
+ /// Where the value's text starts in `carried`, while it is held.
+ start: usize,
+ mode: Mode,
+}
+
+#[derive(Clone, Copy, Debug)]
+enum Mode {
+ /// A declared string, pushed piece by piece once its opening fragment is out.
+ Streaming { opened: bool },
+ /// Written at the close with [`json`].
+ Whole,
+ /// A string that may be null: held until its text rules the null words out.
+ Undecided,
+}
+
+impl Assembler {
+ /// An assembler for the call at `index` with the id the format minted for it, positioned
+ /// just after the call's opening marker.
+ pub fn new(index: u32, id: impl Into, tags: Tags) -> Self {
+ Self {
+ index,
+ id: id.into(),
+ scanner: Scanner::new([
+ tags.key_open,
+ tags.key_close,
+ tags.value_open,
+ tags.value_close,
+ ]),
+ tags,
+ carried: String::new(),
+ stage: Stage::Name,
+ 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 call and push the events they complete. Every byte is the
+ /// call's until the engine says otherwise, so this takes them all.
+ pub fn feed(&mut self, bytes: &str, declared: &Declared, out: &mut Events) {
+ if self.done {
+ return;
+ }
+ for piece in self.scanner.feed(bytes) {
+ self.take(piece, declared, out);
+ }
+ }
+
+ /// The call's end: `terminal` is the closing marker the engine read (its bytes go into
+ /// `ToolCallEnd`), or nothing when the block ended another way. A call that has not been
+ /// named yet is named from the text so far; after the last value the object is closed; inside
+ /// a value an open string is closed, the object is closed, and what was held comes back as
+ /// `Malformed`; a block with no name at all 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 matches!(self.stage, Stage::Name) {
+ self.name_call(out);
+ }
+ if !self.started() {
+ self.carried.push_str(terminal);
+ self.report(WITHOUT_A_NAME, out);
+ return;
+ }
+ match std::mem::replace(&mut self.stage, Stage::Between) {
+ Stage::Between | Stage::Name => {}
+ Stage::Value(value) => self.cut_value(&value, out),
+ // A key or a tag the end cut short: its bytes are reported, and the object closes.
+ _ => self.report(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; everything held comes 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, declared: &Declared, out: &mut Events) {
+ match piece {
+ Piece::Text(text) => self.text(&text, out),
+ Piece::Marker(tag) => self.tag(tag, declared, out),
+ }
+ }
+
+ fn tag(&mut self, tag: usize, declared: &Declared, out: &mut Events) {
+ let bytes = self.tags.text(tag);
+ match &self.stage {
+ Stage::Name => {
+ // The first tag ends the name; the call starts here, and the tag is read as
+ // between keys.
+ self.name_call(out);
+ if self.started() {
+ self.tag_between(tag, out);
+ } else {
+ self.drop_carried(out);
+ out.push(Event::Malformed {
+ text: Text::uncounted(bytes),
+ why: MalformedReason::Other(TAG_OUT_OF_PLACE.to_string()),
+ });
+ }
+ }
+ Stage::Between => self.tag_between(tag, out),
+ Stage::Key { start } if tag == KEY_CLOSE => {
+ let key = self.carried[*start..].to_string();
+ self.carried.push_str(bytes);
+ if key.is_empty() {
+ self.report(EMPTY_KEY, out);
+ self.stage = Stage::Between;
+ } else {
+ self.stage = Stage::Keyed { key };
+ }
+ }
+ Stage::Keyed { key } if tag == VALUE_OPEN => {
+ let key = key.clone();
+ self.carried.push_str(bytes);
+ self.open_value(key, declared);
+ }
+ Stage::Value(_) if tag == VALUE_CLOSE => self.close_value(bytes, out),
+ // Inside a value, the other tags are the value's text.
+ Stage::Value(_) => self.text(bytes, out),
+ // A tag cuts a key short, or stands where the syntax has another: the bytes so far
+ // are reported, and the tag is read as between keys.
+ _ => {
+ self.report(TAG_CUT_SHORT, out);
+ self.stage = Stage::Between;
+ self.tag_between(tag, out);
+ }
+ }
+ }
+
+ /// A tag between keys: `` opens one, once the call has started; the other three, and
+ /// any tag in a block that named no call, have no place here.
+ fn tag_between(&mut self, tag: usize, out: &mut Events) {
+ let bytes = self.tags.text(tag);
+ if tag == KEY_OPEN && self.started() {
+ self.carried.push_str(bytes);
+ self.stage = Stage::Key {
+ 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::Name | Stage::Key { .. } => self.carried.push_str(text),
+ Stage::Between | Stage::Keyed { .. } => self.text_between(text, out),
+ 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 text so far is the call's name, less the whitespace around it: the call starts, and
+ /// the whitespace after the name is carried into the next source. A name with no text is
+ /// reported, and no call starts.
+ fn name_call(&mut self, out: &mut Events) {
+ let name = self.carried.trim().to_string();
+ if name.is_empty() {
+ self.stage = Stage::Between;
+ return;
+ }
+ if !is_name(&name) {
+ // Text where the name should be, a JSON call inside a code fence or prose: reported,
+ // and no call starts.
+ self.report(NOT_A_NAME, out);
+ self.stage = Stage::Between;
+ return;
+ }
+ let end = self.carried.trim_end().len();
+ let after = self.carried.split_off(end);
+ out.push(Event::ToolCallStart {
+ index: self.index,
+ id: self.id.clone(),
+ name: name.clone(),
+ source: Text::uncounted(std::mem::take(&mut self.carried)),
+ });
+ self.carried = after;
+ self.function = Some(name);
+ self.stage = Stage::Between;
+ }
+
+ /// `` after a key: the value begins, typed by what the tool declares for it.
+ fn open_value(&mut self, key: String, declared: &Declared) {
+ let function = self.function.as_deref().unwrap_or_default();
+ let kind = declared.kind(function, &key);
+ let mode = match kind {
+ Some(Kind::String) => Mode::Streaming { opened: false },
+ Some(Kind::NullableString) => Mode::Undecided,
+ Some(Kind::Integer) | None => Mode::Whole,
+ };
+ self.stage = Stage::Value(ValueState {
+ key,
+ kind,
+ start: self.carried.len(),
+ mode,
+ });
+ }
+
+ fn value_text(&mut self, text: &str, out: &mut Events) {
+ let Stage::Value(value) = &mut self.stage else {
+ return;
+ };
+ if text.is_empty() {
+ return;
+ }
+ match value.mode {
+ Mode::Whole => self.carried.push_str(text),
+ Mode::Undecided => {
+ self.carried.push_str(text);
+ let so_far = &self.carried[value.start..];
+ if !NULL_WORDS.iter().any(|word| word.starts_with(so_far)) {
+ // Not null after all: open the string and stream what arrived.
+ let arrived = self.carried.split_off(value.start);
+ value.mode = Mode::Streaming { opened: false };
+ self.stream(&arrived, out);
+ }
+ }
+ Mode::Streaming { .. } => self.stream(text, out),
+ }
+ }
+
+ /// A piece of a streamed string, after its opening fragment.
+ fn stream(&mut self, text: &str, out: &mut Events) {
+ let Stage::Value(ValueState {
+ key,
+ mode: Mode::Streaming { opened },
+ ..
+ }) = &mut self.stage
+ else {
+ return;
+ };
+ let key = key.clone();
+ if !*opened {
+ *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;
+ }
+ if text.is_empty() {
+ return;
+ }
+ let mut json = String::with_capacity(text.len());
+ push_escaped(&mut json, text);
+ self.push_fragment(json, Text::uncounted(text), out);
+ }
+
+ /// ``: a streamed string gets its closing quote, any other value is written whole.
+ fn close_value(&mut self, tag: &str, out: &mut Events) {
+ let Stage::Value(value) = &self.stage else {
+ return;
+ };
+ let fragment = match value.mode {
+ Mode::Streaming { opened: true } => "\"".to_string(),
+ Mode::Streaming { opened: false } => {
+ // 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
+ }
+ Mode::Whole | Mode::Undecided => {
+ let written = json(&self.carried[value.start..], value.kind);
+ 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 call ended inside a value: an open string is closed, and a value never written comes
+ /// back as `Malformed`, since its member was never written.
+ fn cut_value(&mut self, value: &ValueState, out: &mut Events) {
+ if matches!(value.mode, Mode::Streaming { opened: true }) {
+ // 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.report(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()),
+ });
+ }
+
+ /// 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,
+ });
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use openai_protocol::common::{Function, Tool};
+ use serde_json::json as value;
+
+ use super::*;
+
+ const CLOSE: &str = "";
+
+ fn declared() -> Declared {
+ Declared::of(&[Tool {
+ tool_type: "function".to_string(),
+ function: Function {
+ name: "f".to_string(),
+ description: None,
+ parameters: value!({"type": "object", "properties": {
+ "s": {"type": "string"},
+ "n": {"type": "integer"},
+ "ns": {"type": ["string", "null"]},
+ }}),
+ strict: None,
+ },
+ }])
+ }
+
+ /// The events of one block fed in `pieces`, closed by the call's marker or cut.
+ fn run(pieces: &[&str], cut: bool) -> Vec {
+ let mut assembler = Assembler::new(0, "call_0", Tags::PLAIN);
+ let mut out = Events::new();
+ let declared = declared();
+ for piece in pieces {
+ assembler.feed(piece, &declared, &mut out);
+ }
+ if cut {
+ assembler.finish(&mut out);
+ } else {
+ assembler.close(CLOSE, &mut out);
+ }
+ 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 fragments(events: &[Event]) -> Vec<&str> {
+ events
+ .iter()
+ .filter_map(|event| match event {
+ Event::ToolCallArguments { json, .. } => Some(json.as_str()),
+ _ => None,
+ })
+ .collect()
+ }
+
+ fn arguments(events: &[Event]) -> String {
+ fragments(events).concat()
+ }
+
+ fn malformed(events: &[Event]) -> Vec<(&str, &str)> {
+ events
+ .iter()
+ .filter_map(|event| match event {
+ Event::Malformed {
+ text,
+ why: MalformedReason::Other(why),
+ } => Some((text.text.as_str(), why.as_str())),
+ Event::Malformed { text, .. } => Some((text.text.as_str(), "unterminated")),
+ _ => None,
+ })
+ .collect()
+ }
+
+ fn starts(events: &[Event]) -> usize {
+ events
+ .iter()
+ .filter(|e| matches!(e, Event::ToolCallStart { .. }))
+ .count()
+ }
+
+ fn ends(events: &[Event]) -> Vec<&str> {
+ events
+ .iter()
+ .filter_map(|event| match event {
+ Event::ToolCallEnd { source, .. } => Some(source.text.as_str()),
+ _ => None,
+ })
+ .collect()
+ }
+
+ #[test]
+ fn a_declared_string_streams_and_every_other_value_is_written_at_its_close() {
+ let events = run(
+ &[
+ "f\ns\nPa",
+ "risn3",
+ ],
+ false,
+ );
+ assert_eq!(starts(&events), 1);
+ assert_eq!(
+ fragments(&events),
+ [r#"{"s": ""#, "Pa", "ris", "\"", r#", "n": 3"#, "}"]
+ );
+ assert_eq!(ends(&events), [CLOSE]);
+ assert_eq!(
+ bytes(&events),
+ concat!(
+ "f\ns\nParis",
+ "n3"
+ )
+ );
+ }
+
+ #[test]
+ fn a_block_that_names_no_call_starts_none_and_has_every_byte_reported() {
+ // Two pairs: the second once reached `Stage::Key` and wrote arguments for a call that
+ // never started (smg #2842, claude[bot]).
+ let output = concat!(
+ "a1",
+ "b2"
+ );
+ let events = run(&[output], false);
+ assert_eq!(starts(&events), 0);
+ assert!(fragments(&events).is_empty());
+ assert!(ends(&events).is_empty());
+ assert_eq!(bytes(&events), format!("{output}{CLOSE}"));
+ assert!(malformed(&events)
+ .iter()
+ .any(|(text, why)| *text == "" && *why == TAG_OUT_OF_PLACE));
+ }
+
+ #[test]
+ fn text_that_is_not_a_name_starts_no_call() {
+ // A JSON call quoted in a code fence (bellwether's probe), and prose.
+ for text in [
+ "\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}\n",
+ "get weathern1",
+ ] {
+ let events = run(&[text], false);
+ assert_eq!(starts(&events), 0, "{text:?}");
+ assert!(fragments(&events).is_empty(), "{text:?}");
+ assert!(ends(&events).is_empty(), "{text:?}");
+ assert!(
+ malformed(&events).iter().any(|(_, why)| *why == NOT_A_NAME),
+ "{text:?}"
+ );
+ assert_eq!(bytes(&events), format!("{text}{CLOSE}"));
+ }
+ }
+
+ #[test]
+ fn an_empty_key_is_reported_and_the_next_pair_is_read() {
+ let events = run(
+ &[concat!(
+ "fx",
+ "n1"
+ )],
+ false,
+ );
+ assert_eq!(arguments(&events), r#"{"n": 1}"#);
+ assert!(malformed(&events).iter().any(|(_, why)| *why == EMPTY_KEY));
+ }
+
+ #[test]
+ fn a_tag_inside_a_key_cuts_it_short_and_is_read_between_keys() {
+ let events = run(
+ &["fnan1"],
+ false,
+ );
+ assert_eq!(arguments(&events), r#"{"n": 1}"#);
+ assert!(malformed(&events)
+ .iter()
+ .any(|(text, why)| *text == "na" && *why == TAG_CUT_SHORT));
+ }
+
+ #[test]
+ fn a_nullable_string_is_null_when_its_text_says_so_and_streams_otherwise() {
+ let events = run(
+ &["fnsnu", "ll"],
+ false,
+ );
+ assert_eq!(arguments(&events), r#"{"ns": null}"#);
+ let events = run(
+ &[
+ "fnsnu",
+ "mber one",
+ ],
+ false,
+ );
+ // The held `nu` and the piece that ruled null out go out together.
+ assert_eq!(fragments(&events), [r#"{"ns": ""#, "number one", "\"", "}"]);
+ let events = run(&["fns"], false);
+ assert_eq!(arguments(&events), r#"{"ns": ""}"#);
+ }
+
+ #[test]
+ fn an_empty_declared_string_is_an_empty_string() {
+ let events = run(&["fs"], false);
+ assert_eq!(arguments(&events), r#"{"s": ""}"#);
+ }
+
+ #[test]
+ fn a_cut_stream_ends_a_started_call_and_closes_nothing() {
+ let events = run(&["fsPa"], true);
+ assert_eq!(starts(&events), 1);
+ assert_eq!(fragments(&events), [r#"{"s": ""#, "Pa"]);
+ assert_eq!(ends(&events), [""]);
+ let events = run(&["fn12"], true);
+ assert_eq!(fragments(&events), Vec::<&str>::new());
+ assert!(malformed(&events)
+ .iter()
+ .any(|(text, why)| *text == "n12"
+ && *why == "unterminated"));
+ assert_eq!(ends(&events), [""]);
+ // A block with no name at all, cut: its bytes are unterminated, and no call started.
+ let events = run(&["a"], true);
+ assert_eq!(starts(&events), 0);
+ assert!(ends(&events).is_empty());
+ }
+
+ #[test]
+ fn the_calls_end_inside_a_streamed_string_reports_the_held_bytes_and_closes_the_string() {
+ // `<` could begin ``, so it is held; the call's end reports it and closes the
+ // string with no bytes of its own.
+ let events = run(&["fsa <"], false);
+ assert_eq!(fragments(&events), [r#"{"s": ""#, "a ", "\"", "}"]);
+ assert!(malformed(&events)
+ .iter()
+ .any(|(text, why)| *text == "<" && *why == TAG_CUT_SHORT));
+ assert_eq!(ends(&events), [CLOSE]);
+ assert_eq!(
+ bytes(&events),
+ format!("fsa <{CLOSE}")
+ );
+ }
+
+ #[test]
+ fn text_between_tags_is_reported_with_its_whitespace_dropped() {
+ let events = run(
+ &["f\nn\n junk \n1\n"],
+ false,
+ );
+ assert_eq!(arguments(&events), r#"{"n": 1}"#);
+ assert!(malformed(&events)
+ .iter()
+ .any(|(text, why)| *text == "junk" && *why == TEXT_BETWEEN_TAGS));
+ // The whitespace before the text is the template's; the key's tags carried before it go
+ // out with it, since nothing before the text can take them.
+ assert!(events.iter().any(|e| matches!(
+ e,
+ Event::Dropped { text, why: DropReason::Wrapper } if text.text.ends_with("\n ")
+ )));
+ }
+
+ #[test]
+ fn keys_and_values_are_escaped() {
+ let events = run(
+ &["fq\"ksay \"hi\" \\ then\nnew"],
+ false,
+ );
+ assert_eq!(arguments(&events), r#"{"q\"k": "say \"hi\" \\ then\nnew"}"#);
+ let read: serde_json::Value = serde_json::from_str(&arguments(&events)).expect("json");
+ assert_eq!(read["q\"k"], "say \"hi\" \\ then\nnew");
+ }
+
+ #[test]
+ fn every_chunking_says_the_same_and_accounts_for_every_byte() {
+ let output = concat!(
+ "f\ns\nParis",
+ "n3\n"
+ );
+ let whole = run(&[output], false);
+ let said = (
+ arguments(&whole),
+ malformed(&whole).len(),
+ ends(&whole).len(),
+ );
+ for cut in 1..output.len() {
+ let events = run(&[&output[..cut], &output[cut..]], false);
+ assert_eq!(bytes(&events), format!("{output}{CLOSE}"), "cut at {cut}");
+ assert_eq!(
+ (
+ arguments(&events),
+ malformed(&events).len(),
+ ends(&events).len()
+ ),
+ said,
+ "cut at {cut}"
+ );
+ }
+ }
+}
diff --git a/crates/symphony/src/tagged/mod.rs b/crates/symphony/src/tagged/mod.rs
index 634c7de9d..adff7f285 100644
--- a/crates/symphony/src/tagged/mod.rs
+++ b/crates/symphony/src/tagged/mod.rs
@@ -9,10 +9,13 @@
//!
//! [`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.
+//! does the same for one DeepSeek DSML invoke, whose `string` attribute types each value; and
+//! [`keyed`] for one call written as its name and ``/`` pairs (GLM, Hy4,
+//! Ling, IQuest), typed by the request's tools again.
pub mod assembler;
pub mod dsml;
+pub mod keyed;
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 81f815c6f..85be33c38 100644
--- a/crates/symphony/tests/bellwether_parse_fixtures.rs
+++ b/crates/symphony/tests/bellwether_parse_fixtures.rs
@@ -47,7 +47,7 @@ use openai_protocol::common::Tool;
use serde::Deserialize;
use symphony::{
adapt,
- formats::{deepseek_v4_1, qwen2_5, qwen3, seed_oss},
+ formats::{deepseek_v4_1, hy4, iquest, ling, qwen2_5, qwen3, seed_oss},
CallSyntax, Declared, DropReason, Engine, EngineFinish, Event, Events, Input, ParseError,
Parser, TokenSpan,
};
@@ -299,6 +299,23 @@ const MODELS: &[(&str, Family, GenerationPrompt)] = &[
Family::SeedOss,
GenerationPrompt::ModelWritesTheThought,
),
+ // Keyed arguments, each family under its own markers; the prompt opens the thought, Hy4's
+ // whatever the request says.
+ (
+ "hy4-preview",
+ Family::Hy4,
+ GenerationPrompt::AlwaysOpensTheThought,
+ ),
+ (
+ "ling-3.0-flash",
+ Family::Ling,
+ GenerationPrompt::OpensTheThought,
+ ),
+ (
+ "iquest-q1",
+ Family::IQuest,
+ GenerationPrompt::OpensTheThought,
+ ),
];
/// The turn opener a model's own chat template writes, for a model that reads another family's
@@ -329,12 +346,23 @@ enum Family {
DeepSeekV4_1,
/// [`seed_oss`]: the tagged syntax under Seed-OSS's markers, typed by the request tools.
SeedOss,
+ /// [`hy4`], [`ling`], [`iquest`]: keyed arguments, typed by the request tools.
+ Hy4,
+ Ling,
+ IQuest,
}
-/// How the Qwen tables spell the thought's markers, for the reasoning allowance's guard.
-const THINK_MARKERS: (&str, &str) = ("", "");
-
impl Family {
+ /// How the family spells the thought's markers, for the prompt's tail and the reasoning
+ /// allowance's guard.
+ fn think_markers(self) -> (&'static str, &'static str) {
+ match self {
+ Self::SeedOss => ("", ""),
+ Self::Hy4 => ("", ""),
+ _ => ("", ""),
+ }
+ }
+
/// The engine for one of the model's cases: the family's table, with the model's own turn
/// opener when its template is not the table's ([`OPENERS`]), and the case's tools.
fn engine(self, slug: &str, fixture: &Fixture) -> Engine {
@@ -352,27 +380,33 @@ impl Family {
Self::Qwen2_5 => qwen2_5(),
Self::DeepSeekV4_1 => deepseek_v4_1(),
Self::SeedOss => seed_oss(),
+ Self::Hy4 => hy4(),
+ Self::Ling => ling(),
+ Self::IQuest => iquest(),
}
}
/// The probe cases known to differ under this syntax: the code fence holds the JSON syntax,
/// which the JSON assembler reads as a call and the tagged one reports as text between a
/// call's tags.
- fn known_differences(self, prompt: GenerationPrompt) -> &'static [KnownDifference] {
+ fn known_differences(self, prompt: GenerationPrompt) -> Vec {
let list = match self {
Self::Qwen3 | Self::Qwen2_5 => KNOWN_DIFFERENCES,
Self::Qwen3Tagged => KNOWN_TAGGED_DIFFERENCES,
- // Seed-OSS reads neither of the probes' Qwen markers: `` stays reasoning text
- // and the fenced `` block stays content, as the reference says.
- Self::SeedOss => &[],
- Self::DeepSeekV4_1 => KNOWN_DSML_DIFFERENCES,
+ // Seed-OSS and Hy4 read neither of the probes' Qwen markers: `` stays reasoning
+ // text and the fenced `` block stays content, as the reference says.
+ Self::SeedOss | Self::Hy4 => &[],
+ // Ling reads `` and ``, so its two probes are the tagged ones' (no
+ // call comes of the fence); IQuest and DSML read `` but not ``.
+ Self::Ling => KNOWN_TAGGED_DIFFERENCES,
+ Self::IQuest | Self::DeepSeekV4_1 => KNOWN_REASONING_PROBE,
};
// 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.
- match prompt {
- GenerationPrompt::Plain => list.get(1..).unwrap_or(&[]),
- _ => list,
- }
+ list.iter()
+ .copied()
+ .filter(|known| prompt != GenerationPrompt::Plain || known.id != REASONING_PROBE.id)
+ .collect()
}
/// The corpus classes this syntax meets in sets recorded before bellwether refused them,
@@ -384,7 +418,10 @@ impl Family {
return Vec::new();
}
let mut allowed = Vec::new();
- if matches!(self, Self::Qwen3Tagged | Self::SeedOss) {
+ if matches!(
+ self,
+ Self::Qwen3Tagged | Self::SeedOss | Self::Hy4 | Self::Ling | Self::IQuest
+ ) {
allowed.push(Allowance::DeclaredTypeConflict);
}
if prompt == GenerationPrompt::Plain {
@@ -416,14 +453,18 @@ enum GenerationPrompt {
}
impl GenerationPrompt {
- /// The bytes after `<|im_start|>assistant\n` for the case's request.
- fn tail(self, fixture: &Fixture) -> &'static str {
+ /// The bytes after the assistant header for the case's request, in the family's spelling of
+ /// the thought's markers.
+ fn tail(self, fixture: &Fixture, family: Family) -> String {
+ let (open, close) = family.think_markers();
match (self, fixture.request.thinking_off()) {
- (Self::Plain, _) | (Self::ModelWritesTheThought, false) => "",
+ (Self::Plain, _) | (Self::ModelWritesTheThought, false) => String::new(),
(Self::OpensTheThought | Self::ModelWritesTheThought, true) => {
- "\n\n\n\n"
+ format!("{open}\n\n{close}\n\n")
+ }
+ (Self::OpensTheThought, false) | (Self::AlwaysOpensTheThought, _) => {
+ format!("{open}\n")
}
- (Self::OpensTheThought, false) | (Self::AlwaysOpensTheThought, _) => "\n",
}
}
}
@@ -471,6 +512,7 @@ enum Allowance {
/// A case known to differ from the reference beyond the separator bytes: why, and what the parser
/// says instead, its call count and finish reason, so that the list allows that difference and no
/// other. `id` is the case's id after the slug, so one list serves every slug it names.
+#[derive(Clone, Copy)]
struct KnownDifference {
id: &'static str,
reason: &'static str,
@@ -479,13 +521,7 @@ struct KnownDifference {
}
const KNOWN_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",
- },
+ REASONING_PROBE,
KnownDifference {
id: "parse/content-with-marker-in-code-fence",
reason: "the content holds a complete `` block inside a code fence; the parser makes \
@@ -496,38 +532,35 @@ const KNOWN_DIFFERENCES: &[KnownDifference] = &[
},
];
-/// The same two probe cases under the tagged syntax. The code fence holds the JSON syntax, which
-/// the tagged assembler reports as text between a call's tags, so no call comes of it; the marker
-/// in the reasoning ends the thought where it stands, for the models that write one.
-const KNOWN_TAGGED_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",
- },
- KnownDifference {
- id: "parse/content-with-marker-in-code-fence",
- reason: "the content holds a complete `` block inside a code fence; the parser \
- reads the block, as every marker parser does, and the reference keeps it as \
- content (bellwether #16)",
- calls: 0,
- finish: "stop",
- },
-];
-
-/// 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 {
+/// The reasoning probe: its reasoning holds a ``, which every table with that marker
+/// reads as the thought's end.
+const REASONING_PROBE: 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 code-fence probe: its content holds a complete `` block inside a code fence,
+/// which every table with that marker reads as a block.
+const FENCE_PROBE: KnownDifference = KnownDifference {
+ id: "parse/content-with-marker-in-code-fence",
+ reason: "the content holds a complete `` block inside a code fence; the parser \
+ reads the block, as every marker parser does, and the reference keeps it as \
+ content (bellwether #16)",
+ calls: 0,
+ finish: "stop",
+};
+
+/// Under the tagged syntax both probes differ.
+const KNOWN_TAGGED_DIFFERENCES: &[KnownDifference] = &[REASONING_PROBE, FENCE_PROBE];
+
+/// Under a table that reads `` but not `` (DSML, IQuest), only the reasoning
+/// probe differs: the fence holds Qwen's syntax, which the table never reads as a call, so it is
+/// content, as the reference says.
+const KNOWN_REASONING_PROBE: &[KnownDifference] = &[REASONING_PROBE];
/// The case's id after its slug: what [`KnownDifference::id`] names.
fn after_slug(id: &str) -> &str {
@@ -686,10 +719,10 @@ fn qwen3_parse_fixtures_match_the_reference() {
let failures = parity(
&fixtures,
&|fixture| Family::Qwen3.engine(SLUG, fixture),
- &|_| "",
+ &|_| String::new(),
KNOWN_DIFFERENCES,
&[],
- THINK_MARKERS,
+ Family::Qwen3.think_markers(),
);
assert!(failures.is_empty(), "\n{}", failures.join("\n"));
}
@@ -721,10 +754,10 @@ fn every_recorded_qwen_model_parses_like_its_reference() {
failures.extend(parity(
&fixtures,
&|fixture| family.engine(slug, fixture),
- &|fixture| prompt.tail(fixture),
- family.known_differences(prompt),
+ &|fixture| prompt.tail(fixture, family),
+ &family.known_differences(prompt),
&family.allowances(slug, prompt),
- THINK_MARKERS,
+ family.think_markers(),
));
recorded += 1;
}
@@ -749,7 +782,7 @@ fn every_recorded_qwen_model_parses_like_its_reference() {
fn parity(
fixtures: &[Fixture],
new_parser: &dyn Fn(&Fixture) -> Engine,
- prompt_tail: &dyn Fn(&Fixture) -> &'static str,
+ prompt_tail: &dyn Fn(&Fixture) -> String,
known_differences: &[KnownDifference],
allowed: &[Allowance],
think_markers: (&str, &str),
@@ -769,7 +802,7 @@ fn parity(
for plan in chunkings(text) {
let events = replay_after(
&mut new_parser(fixture),
- prompt_tail(fixture),
+ &prompt_tail(fixture),
text,
&plan,
&finish,
diff --git a/crates/symphony/tests/contract.rs b/crates/symphony/tests/contract.rs
index eec00d66c..ce8ff8121 100644
--- a/crates/symphony/tests/contract.rs
+++ b/crates/symphony/tests/contract.rs
@@ -94,6 +94,34 @@ fn deepseek_v4_1() -> Box {
Box::new(Engine::new(formats::deepseek_v4_1(), Declared::default()))
}
+fn keyed_tools() -> Declared {
+ Declared::of(&[Tool {
+ tool_type: "function".to_string(),
+ function: Function {
+ name: "get_weather".to_string(),
+ description: None,
+ parameters: value!({"type": "object", "properties": {
+ "city": {"type": "string"},
+ "days": {"type": "integer"},
+ "note": {"type": ["string", "null"]},
+ }}),
+ strict: None,
+ },
+ }])
+}
+
+fn hy4() -> Box {
+ Box::new(Engine::new(formats::hy4(), keyed_tools()))
+}
+
+fn ling() -> Box {
+ Box::new(Engine::new(formats::ling(), keyed_tools()))
+}
+
+fn iquest() -> Box {
+ Box::new(Engine::new(formats::iquest(), keyed_tools()))
+}
+
const FORMATS: &[Subject] = &[
Subject {
name: "qwen3",
@@ -120,6 +148,21 @@ const FORMATS: &[Subject] = &[
new: seed_oss,
outputs: SEED_OUTPUTS,
},
+ Subject {
+ name: "hy4",
+ new: hy4,
+ outputs: HY4_OUTPUTS,
+ },
+ Subject {
+ name: "ling",
+ new: ling,
+ outputs: LING_OUTPUTS,
+ },
+ Subject {
+ name: "iquest",
+ new: iquest,
+ outputs: IQUEST_OUTPUTS,
+ },
];
/// Seed-OSS: the tagged syntax under its own markers, a value as Python's repr, and Qwen's
@@ -141,6 +184,53 @@ const SEED_OUTPUTS: &[&str] = &[
"",
];
+/// Keyed arguments in Hy4's spelling: the recorded shapes, a cut stream, a call without
+/// arguments, prose where a call should be.
+const HY4_OUTPUTS: &[&str] = &[
+ "The user asks.get_weather\
+ cityParis\
+ days\
+ 3\
+ get_weather\
+ ",
+ "get_weather\
+ cityPar",
+ "get_weather\
+ notenull\
+ extra[1, 2]\
+ \
+ ",
+ "prose\
+ ",
+ "Hello!",
+ "get_weather\
+ citya <\
+ ",
+];
+
+/// Keyed arguments in Ling's spelling, with its newlines.
+const LING_OUTPUTS: &[&str] = &[
+ "get_weather\ncity\nParis\
+ days\n3\n\nget_weather\n\
+ ",
+ "get_weather\ncity\nPar",
+ "get_weather\nq\n計画 🌍 \"q\" \\ \n\
+ \n",
+ "planSure.\n",
+ "The syntax is:\n\n```\n\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \
+ \"Paris\"}}\n\n```\n\nThat is all.",
+];
+
+/// Keyed arguments in IQuest's spelling, with no whitespace.
+const IQUEST_OUTPUTS: &[&str] = &[
+ "get_weathercityParis\
+ days3\
+ get_weather",
+ "get_weathercityPar",
+ "cityParis\
+ days3",
+];
+
/// Outputs in DeepSeek's DSML: the recorded shapes, and the cuts and faults the assembler and the
/// table name.
const DSML_OUTPUTS: &[&str] = &[