From 19f33a5495e51e89618bfbb13ce6f8570c393fbd Mon Sep 17 00:00:00 2001 From: ai-jz Date: Fri, 2 Oct 2026 23:53:08 -0700 Subject: [PATCH] fix(tool-parser): close MiniMax M2 streamed argument objects Signed-off-by: ai-jz --- crates/tool_parser/src/parsers/minimax_m2.rs | 21 +++--- .../tests/tool_parser_minimax_m2.rs | 73 +++++++++++++++++++ 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/crates/tool_parser/src/parsers/minimax_m2.rs b/crates/tool_parser/src/parsers/minimax_m2.rs index 8097889f1..fed31346f 100644 --- a/crates/tool_parser/src/parsers/minimax_m2.rs +++ b/crates/tool_parser/src/parsers/minimax_m2.rs @@ -475,18 +475,15 @@ impl ToolParser for MinimaxM2Parser { parameters: "{}".to_string(), }); self.streamed_args_for_tool[tool_id].push_str("{}"); - } else if !current_streamed.ends_with('}') { - // Count opening and closing braces to check if JSON is complete - let open_braces = current_streamed.matches('{').count(); - let close_braces = current_streamed.matches('}').count(); - if open_braces > close_braces { - calls.push(ToolCallItem { - tool_index: tool_id, - name: None, - parameters: "}".to_string(), - }); - self.streamed_args_for_tool[tool_id].push('}'); - } + } else { + // Parameter fragments leave the outer object open. + // Braces inside values do not close that object. + calls.push(ToolCallItem { + tool_index: tool_id, + name: None, + parameters: "}".to_string(), + }); + self.streamed_args_for_tool[tool_id].push('}'); } } diff --git a/crates/tool_parser/tests/tool_parser_minimax_m2.rs b/crates/tool_parser/tests/tool_parser_minimax_m2.rs index 7ed608795..8c3f94452 100644 --- a/crates/tool_parser/tests/tool_parser_minimax_m2.rs +++ b/crates/tool_parser/tests/tool_parser_minimax_m2.rs @@ -577,6 +577,79 @@ async fn test_minimax_streaming_empty_parameters() { } } +#[tokio::test] +async fn test_minimax_streaming_closes_outer_object_after_nested_value() { + let tools = vec![Tool { + tool_type: "function".to_string(), + function: Function { + name: "process".to_string(), + description: None, + parameters: json!({ + "type": "object", + "properties": {"data": {"type": "object"}} + }), + strict: None, + }, + }]; + let input = r#"{"host":"db.example"}"#; + let (_normal, complete) = MinimaxM2Parser::new() + .parse_complete_with_tools(input, &tools) + .await + .unwrap(); + let expected: serde_json::Value = + serde_json::from_str(&complete[0].function.arguments).unwrap(); + assert_eq!(expected, json!({"data": {"host": "db.example"}})); + + for chunk_size in [input.len(), 7] { + let mut parser = MinimaxM2Parser::new(); + let mut arguments = String::new(); + for chunk in input.as_bytes().chunks(chunk_size) { + let result = parser + .parse_incremental(std::str::from_utf8(chunk).unwrap(), &tools) + .await + .unwrap(); + for call in result.calls { + arguments.push_str(&call.parameters); + } + } + if let Some(calls) = parser.get_unstreamed_tool_args() { + for call in calls { + arguments.push_str(&call.parameters); + } + } + assert_eq!( + serde_json::from_str::(&arguments).unwrap(), + expected + ); + } +} + +#[tokio::test] +async fn test_minimax_streaming_closure_ignores_braces_in_strings_and_resets() { + let mut parser = MinimaxM2Parser::new(); + for _ in 0..2 { + let mut arguments = [String::new(), String::new()]; + for chunk in [ + "", + r#"}"#, + r#""#, + "", + ] { + let result = parser.parse_incremental(chunk, &[]).await.unwrap(); + for call in result.calls { + arguments[call.tool_index].push_str(&call.parameters); + } + } + assert_eq!( + serde_json::from_str::(&arguments[0]).unwrap(), + json!({"text": "}"}) + ); + assert_eq!(arguments[1], "{}"); + assert!(parser.get_unstreamed_tool_args().is_none()); + parser.reset(); + } +} + #[tokio::test] async fn test_minimax_multiline_parameter_values() { let parser = MinimaxM2Parser::new();