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