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] = &[