From 43873eb8077343b2f11eed8414d9a38318ca8f5e Mon Sep 17 00:00:00 2001 From: ai-jz Date: Fri, 2 Oct 2026 23:53:07 -0700 Subject: [PATCH] fix(tool-parser): preserve whitespace in GLM string arguments Signed-off-by: ai-jz --- crates/tool_parser/src/parsers/glm4_moe.rs | 67 +++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/tool_parser/src/parsers/glm4_moe.rs b/crates/tool_parser/src/parsers/glm4_moe.rs index 0b86168d52..7c318e7a7a 100644 --- a/crates/tool_parser/src/parsers/glm4_moe.rs +++ b/crates/tool_parser/src/parsers/glm4_moe.rs @@ -178,11 +178,11 @@ impl Glm4MoeParser { for capture in self.arg_extractor.captures_iter(args_text) { let key = capture.get(1).map_or("", |m| m.as_str()).trim(); - let value_str = capture.get(2).map_or("", |m| m.as_str()).trim(); + let value_str = capture.get(2).map_or("", |m| m.as_str()); let value = helpers::coerce_by_schema_type(value_str, param_types.get(key).map(String::as_str)) - .unwrap_or_else(|| infer_value(value_str)); + .unwrap_or_else(|| infer_value(value_str.trim())); arguments.insert(key.to_string(), value); } @@ -590,4 +590,67 @@ mod tests { assert_eq!(args["limit"], Value::String("4".to_string())); assert_eq!(args["count"], Value::Number(5.into())); } + + #[tokio::test] + async fn test_declared_strings_preserve_whitespace() { + let tools = tool_with_props(serde_json::json!({ + "code": {"type": "string"}, + "needle": {"type": "string"}, + "blank": {"type": "string"}, + "quoted": {"type": "string"}, + "count": {"type": "integer"}, + })); + let args_text = concat!( + "code return 1\n", + "needle total = 1\n", + "blank \t\n", + "quoted \" return 1\\n\" ", + "count 42\n", + "unknown legacy text\n", + ); + let expected = serde_json::json!({ + "code": " return 1\n", + "needle": " total = 1\n", + "blank": " \t\n", + "quoted": " return 1\n", + "count": 42, + "unknown": "legacy text", + }); + + for (make_parser, name_separator) in [ + (Glm4MoeParser::glm45 as fn() -> Glm4MoeParser, "\n"), + (Glm4MoeParser::glm47 as fn() -> Glm4MoeParser, ""), + ] { + let text = format!("f{name_separator}{args_text}"); + let (_, calls) = make_parser() + .parse_complete_with_tools(&text, &tools) + .await + .unwrap(); + assert_eq!(calls.len(), 1); + let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap(); + assert_eq!(args, expected); + + // Keep the model's opening special token intact; split the argument + // text into small chunks, including inside values and closing tags. + let mut parser = make_parser(); + let start = "".len(); + let result = parser + .parse_incremental(&text[..start], &tools) + .await + .unwrap(); + assert!(result.calls.is_empty()); + let mut arguments = String::new(); + for chunk in text.as_bytes()[start..].chunks(7) { + let result = parser + .parse_incremental(std::str::from_utf8(chunk).unwrap(), &tools) + .await + .unwrap(); + for call in result.calls { + arguments.push_str(&call.parameters); + } + } + let args: Value = serde_json::from_str(&arguments).unwrap(); + assert_eq!(args, expected); + } + } }