diff --git a/deepseek-recipe/src/stream/output_parser.rs b/deepseek-recipe/src/stream/output_parser.rs index 99a3491..ed0fe43 100644 --- a/deepseek-recipe/src/stream/output_parser.rs +++ b/deepseek-recipe/src/stream/output_parser.rs @@ -161,11 +161,11 @@ pub(super) enum InvalidTokenKind { /// two distinct actions to produce both of its quotes. #[derive(Debug, Clone, Copy, PartialEq)] pub(super) enum ArgumentsLabel { - /// `{` before the first argument. + /// `{"` before the first argument name. ObjectOpen, - /// `, ` before each later argument. + /// `, "` before each later argument name. ArgumentSeparator, - /// `: ` between an argument name and its value. + /// `": ` between an argument name and its value. KeyValueSeparator, /// `"` before a string value. StringOpen, @@ -176,9 +176,9 @@ pub(super) enum ArgumentsLabel { impl ArgumentsLabel { pub(super) fn as_str(self) -> &'static str { match self { - Self::ObjectOpen => "{", - Self::ArgumentSeparator => ", ", - Self::KeyValueSeparator => ": ", + Self::ObjectOpen => "{\"", + Self::ArgumentSeparator => ", \"", + Self::KeyValueSeparator => "\": ", Self::StringOpen | Self::StringClose => "\"", } } @@ -247,7 +247,7 @@ enum Stage { /// Inside an `invoke` tag, before a `parameter` tag or the closing tag. /// `is_first_argument` holds until the first argument is written. ToolCallArguments { is_first_argument: bool }, - /// The quoted argument name of a `parameter` tag. + /// The argument name of a `parameter` tag, up to its closing quote. ToolCallArgumentName, /// The `string="true|false">` attribute of a `parameter` tag. ToolCallArgumentStringAttribute, @@ -504,7 +504,7 @@ impl State { } Stage::ToolCallArguments { is_first_argument } => { match_branches.add_literal_branch( - "parameter name=", + "parameter name=\"", Stage::ToolCallArgumentName, if is_first_argument { ActionOnMatched::LabelToolCallArguments(ArgumentsLabel::ObjectOpen) @@ -525,7 +525,7 @@ impl State { } Stage::ToolCallArgumentName => { match_branches.add_literal_branch( - " ", + "\"", Stage::ToolCallArgumentStringAttribute, ActionOnMatched::LabelToolCallArguments(ArgumentsLabel::KeyValueSeparator), ); diff --git a/deepseek-recipe/src/stream/processor.rs b/deepseek-recipe/src/stream/processor.rs index 870a419..4b3aa9c 100644 --- a/deepseek-recipe/src/stream/processor.rs +++ b/deepseek-recipe/src/stream/processor.rs @@ -748,6 +748,44 @@ mod tests { assert_eq!(finish_chunk(&chunks).0, FinishReason::ToolCalls); } + #[tokio::test] + async fn argument_names_keep_their_spaces() { + // Names with and without spaces, as first and later arguments, with + // string and raw values; an empty name ends the second call. + let block = r#"<|DSML|tool_calls> +<|DSML|invoke name="book_trip"> +<|DSML|parameter name="start date" string="true">2026-10-22 +<|DSML|parameter name="city" string="true">Tokyo +<|DSML|parameter name="party size" string="false">2 +<|DSML|parameter name="nights" string="false">3 + +<|DSML|invoke name="book_trip"> +<|DSML|parameter name="room count" string="false">1 +<|DSML|parameter name="guest name" string="true">Ada Lovelace +<|DSML|parameter name="" string="true">empty + +"#; + let expected = [ + ( + "book_trip", + r#"{"start date": "2026-10-22", "city": "Tokyo", "party size": 2, "nights": 3}"#, + ), + ( + "book_trip", + r#"{"room count": 1, "guest name": "Ada Lovelace", "": "empty"}"#, + ), + ]; + let output = format!("{PREAMBLE}\n\n{block}"); + for chunks in [ + parse_characters(&output, ParsingOptions::default()).await, + parse_texts(&[&output], ParsingOptions::default()).await, + ] { + assert_eq!(content(&chunks), PREAMBLE); + assert_tool_calls(&chunks, &expected); + assert_eq!(finish_chunk(&chunks).0, FinishReason::ToolCalls); + } + } + #[tokio::test] async fn json_text_in_a_string_value_is_escaped() { let output = format!("{PREAMBLE}\n\n{TOOL_CALLS}");