From 16d8e2f210a78e67867358058f3aab9f7eff2055 Mon Sep 17 00:00:00 2001 From: key4ng Date: Tue, 23 Jun 2026 17:35:42 -0700 Subject: [PATCH 1/2] fix(tool_parser): serialize tool-call arguments with Python/vLLM spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vLLM serializes tool_calls[].arguments with Python json.dumps (spaced ", "/": "), while SMG's XML-style parsers used serde_json::to_string (compact). The Qwen3 chat template emits arguments verbatim, so on every multi-turn step that feeds back a prior tool call, the SMG arm rendered different prompt tokens than pure vLLM — diverging generations that compound across turns (confirmed: a byte-for-byte template-parity test matches HF except for this arg spacing). Add helpers::args_to_json_string (spaced separators, UTF-8 escaping unchanged) and use it in the XML-derived parsers that build args from tags: qwen_xml, minimax_m2, deepseek_dsml. kimik2 already passes the model's raw arguments string through, so it's unchanged. Type coercion is preserved (only spacing changes). Signed-off-by: key4ng --- .../tool_parser/src/parsers/deepseek_dsml.rs | 4 +- crates/tool_parser/src/parsers/helpers.rs | 52 +++++++++++++++++++ crates/tool_parser/src/parsers/minimax_m2.rs | 4 +- crates/tool_parser/src/parsers/qwen_xml.rs | 5 +- .../tests/tool_parser_minimax_m2.rs | 21 ++++++++ 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/crates/tool_parser/src/parsers/deepseek_dsml.rs b/crates/tool_parser/src/parsers/deepseek_dsml.rs index d1fe28668c..43e877890f 100644 --- a/crates/tool_parser/src/parsers/deepseek_dsml.rs +++ b/crates/tool_parser/src/parsers/deepseek_dsml.rs @@ -232,7 +232,9 @@ impl DeepSeekDsmlParser { } } - serde_json::to_string(&Value::Object(params)).unwrap_or_else(|_| "{}".to_string()) + // Spaced separators (Python json.dumps / vLLM) so multi-turn fed-back + // tool_calls render to the same prompt tokens as pure vLLM. + helpers::args_to_json_string(&Value::Object(params)).unwrap_or_else(|_| "{}".to_string()) } /// Parse a single complete invoke block into a ToolCall diff --git a/crates/tool_parser/src/parsers/helpers.rs b/crates/tool_parser/src/parsers/helpers.rs index 81a4463232..82e8fb7a85 100644 --- a/crates/tool_parser/src/parsers/helpers.rs +++ b/crates/tool_parser/src/parsers/helpers.rs @@ -394,6 +394,58 @@ pub(crate) fn handle_json_tool_streaming( Ok(result) } +/// serde_json formatter matching Python `json.dumps` default item/key separators +/// (`", "` and `": "`). +struct SpacedFormatter; + +impl serde_json::ser::Formatter for SpacedFormatter { + fn begin_array_value( + &mut self, + writer: &mut W, + first: bool, + ) -> std::io::Result<()> { + if first { + Ok(()) + } else { + writer.write_all(b", ") + } + } + + fn begin_object_key( + &mut self, + writer: &mut W, + first: bool, + ) -> std::io::Result<()> { + if first { + Ok(()) + } else { + writer.write_all(b", ") + } + } + + fn begin_object_value( + &mut self, + writer: &mut W, + ) -> std::io::Result<()> { + writer.write_all(b": ") + } +} + +/// Serialize tool-call arguments like Python `json.dumps` — spaced separators +/// (`", "` / `": "`), no newlines. vLLM serializes `tool_calls[].arguments` with +/// `json.dumps`, so SMG must match the spacing: otherwise the assistant tool_calls +/// fed back across a multi-turn conversation render to different prompt tokens than +/// pure vLLM (the chat template emits `arguments` verbatim), diverging generations. +/// String escaping is left as serde's UTF-8 default (`ensure_ascii=False`). +pub fn args_to_json_string(value: &T) -> ParserResult { + let mut buf = Vec::new(); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, SpacedFormatter); + value + .serialize(&mut ser) + .map_err(|e| ParserError::ParsingFailed(e.to_string()))?; + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tool_parser/src/parsers/minimax_m2.rs b/crates/tool_parser/src/parsers/minimax_m2.rs index f219512b39..14ef732761 100644 --- a/crates/tool_parser/src/parsers/minimax_m2.rs +++ b/crates/tool_parser/src/parsers/minimax_m2.rs @@ -160,7 +160,9 @@ impl MinimaxM2Parser { let param_types = helpers::param_types_for_function(tools, func_name); let parameters = self.parse_parameters(params_text, ¶m_types); - match serde_json::to_string(¶meters) { + // Spaced separators (Python json.dumps / vLLM) so multi-turn fed-back + // tool_calls render to the same prompt tokens as pure vLLM. + match helpers::args_to_json_string(¶meters) { Ok(arguments_str) => calls.push(ToolCall { function: FunctionCall { name: func_name.to_string(), diff --git a/crates/tool_parser/src/parsers/qwen_xml.rs b/crates/tool_parser/src/parsers/qwen_xml.rs index e5e4e458d0..82242052c3 100644 --- a/crates/tool_parser/src/parsers/qwen_xml.rs +++ b/crates/tool_parser/src/parsers/qwen_xml.rs @@ -216,8 +216,9 @@ impl QwenXmlParser { } } - let arguments = serde_json::to_string(¶meters) - .map_err(|e| ParserError::ParsingFailed(e.to_string()))?; + // Spaced separators (Python json.dumps / vLLM) so multi-turn fed-back + // tool_calls render to the same prompt tokens as pure vLLM. + let arguments = helpers::args_to_json_string(¶meters)?; Ok(Some(ToolCall { function: FunctionCall { diff --git a/crates/tool_parser/tests/tool_parser_minimax_m2.rs b/crates/tool_parser/tests/tool_parser_minimax_m2.rs index ea31ad8bce..1b73b6a716 100644 --- a/crates/tool_parser/tests/tool_parser_minimax_m2.rs +++ b/crates/tool_parser/tests/tool_parser_minimax_m2.rs @@ -965,3 +965,24 @@ async fn test_minimax_invalid_json_in_parameters() { assert_eq!(normal_text, ""); } + +#[tokio::test] +async fn test_minimax_arguments_use_python_json_spacing() { + // Args must serialize with spaced separators (", " / ": "), matching vLLM's + // json.dumps — so multi-turn fed-back tool_calls produce identical prompt tokens. + let parser = MinimaxM2Parser::new(); + let input = r#" + +Tokyo +celsius + +"#; + let (_n, tools) = parser.parse_complete(input).await.unwrap(); + assert_eq!(tools.len(), 1); + let a = &tools[0].function.arguments; + assert!( + a.contains("\": \""), + "expected spaced key/value separators, got {a}" + ); + assert!(!a.contains("\":\""), "must not be compact, got {a}"); +} From 641db26b9cff23452432444c82942b436cc61b52 Mon Sep 17 00:00:00 2001 From: key4ng Date: Tue, 23 Jun 2026 17:39:50 -0700 Subject: [PATCH 2/2] docs(tool_parser): tighten arg-serialization comments Signed-off-by: key4ng --- crates/tool_parser/src/parsers/deepseek_dsml.rs | 2 -- crates/tool_parser/src/parsers/helpers.rs | 12 ++++-------- crates/tool_parser/src/parsers/minimax_m2.rs | 2 -- crates/tool_parser/src/parsers/qwen_xml.rs | 2 -- crates/tool_parser/tests/tool_parser_minimax_m2.rs | 3 +-- 5 files changed, 5 insertions(+), 16 deletions(-) diff --git a/crates/tool_parser/src/parsers/deepseek_dsml.rs b/crates/tool_parser/src/parsers/deepseek_dsml.rs index 43e877890f..1cd6b483a5 100644 --- a/crates/tool_parser/src/parsers/deepseek_dsml.rs +++ b/crates/tool_parser/src/parsers/deepseek_dsml.rs @@ -232,8 +232,6 @@ impl DeepSeekDsmlParser { } } - // Spaced separators (Python json.dumps / vLLM) so multi-turn fed-back - // tool_calls render to the same prompt tokens as pure vLLM. helpers::args_to_json_string(&Value::Object(params)).unwrap_or_else(|_| "{}".to_string()) } diff --git a/crates/tool_parser/src/parsers/helpers.rs b/crates/tool_parser/src/parsers/helpers.rs index 82e8fb7a85..7fc5155718 100644 --- a/crates/tool_parser/src/parsers/helpers.rs +++ b/crates/tool_parser/src/parsers/helpers.rs @@ -394,8 +394,7 @@ pub(crate) fn handle_json_tool_streaming( Ok(result) } -/// serde_json formatter matching Python `json.dumps` default item/key separators -/// (`", "` and `": "`). +/// serde_json formatter with Python `json.dumps` separators (`", "` / `": "`). struct SpacedFormatter; impl serde_json::ser::Formatter for SpacedFormatter { @@ -431,12 +430,9 @@ impl serde_json::ser::Formatter for SpacedFormatter { } } -/// Serialize tool-call arguments like Python `json.dumps` — spaced separators -/// (`", "` / `": "`), no newlines. vLLM serializes `tool_calls[].arguments` with -/// `json.dumps`, so SMG must match the spacing: otherwise the assistant tool_calls -/// fed back across a multi-turn conversation render to different prompt tokens than -/// pure vLLM (the chat template emits `arguments` verbatim), diverging generations. -/// String escaping is left as serde's UTF-8 default (`ensure_ascii=False`). +/// Serialize tool-call arguments like Python `json.dumps` (spaced separators), so +/// they match vLLM — chat templates emit `arguments` verbatim, so compact vs spaced +/// would diverge the prompt across multi-turn tool calls. pub fn args_to_json_string(value: &T) -> ParserResult { let mut buf = Vec::new(); let mut ser = serde_json::Serializer::with_formatter(&mut buf, SpacedFormatter); diff --git a/crates/tool_parser/src/parsers/minimax_m2.rs b/crates/tool_parser/src/parsers/minimax_m2.rs index 14ef732761..9a5e7e663a 100644 --- a/crates/tool_parser/src/parsers/minimax_m2.rs +++ b/crates/tool_parser/src/parsers/minimax_m2.rs @@ -160,8 +160,6 @@ impl MinimaxM2Parser { let param_types = helpers::param_types_for_function(tools, func_name); let parameters = self.parse_parameters(params_text, ¶m_types); - // Spaced separators (Python json.dumps / vLLM) so multi-turn fed-back - // tool_calls render to the same prompt tokens as pure vLLM. match helpers::args_to_json_string(¶meters) { Ok(arguments_str) => calls.push(ToolCall { function: FunctionCall { diff --git a/crates/tool_parser/src/parsers/qwen_xml.rs b/crates/tool_parser/src/parsers/qwen_xml.rs index 82242052c3..ce991ab7f6 100644 --- a/crates/tool_parser/src/parsers/qwen_xml.rs +++ b/crates/tool_parser/src/parsers/qwen_xml.rs @@ -216,8 +216,6 @@ impl QwenXmlParser { } } - // Spaced separators (Python json.dumps / vLLM) so multi-turn fed-back - // tool_calls render to the same prompt tokens as pure vLLM. let arguments = helpers::args_to_json_string(¶meters)?; Ok(Some(ToolCall { diff --git a/crates/tool_parser/tests/tool_parser_minimax_m2.rs b/crates/tool_parser/tests/tool_parser_minimax_m2.rs index 1b73b6a716..555f73d10f 100644 --- a/crates/tool_parser/tests/tool_parser_minimax_m2.rs +++ b/crates/tool_parser/tests/tool_parser_minimax_m2.rs @@ -968,8 +968,7 @@ async fn test_minimax_invalid_json_in_parameters() { #[tokio::test] async fn test_minimax_arguments_use_python_json_spacing() { - // Args must serialize with spaced separators (", " / ": "), matching vLLM's - // json.dumps — so multi-turn fed-back tool_calls produce identical prompt tokens. + // Spaced separators (", " / ": "), matching vLLM's json.dumps. let parser = MinimaxM2Parser::new(); let input = r#"