From 7693ddda50451335c300fcb250829bb316569724 Mon Sep 17 00:00:00 2001 From: Bohdan Khorolets Date: Fri, 10 Jul 2026 19:43:21 +0300 Subject: [PATCH 1/2] fix(llm): recover near.ai streaming tool-call args with trailing content The near.ai SSE streaming tool-call finalizer parsed accumulated arguments with a strict serde_json::from_str that rejects trailing characters. Reasoning models (DeepSeek-V4-Flash) stream a valid arguments object followed by a stray token; the strict parse errored and parse_tool_call_args_lossy collapsed the arguments to empty {}, so the tool ran with no arguments (observed as "the tool input could not be encoded" memory_read failures). Parse the first complete JSON value and ignore trailing content; empty and truncated inputs keep their existing repair behavior. Tests: tool_args unit case for trailing content + a caller-level nearai_chat streaming test asserting the recovered arguments reach the tool call. --- crates/ironclaw_llm/src/nearai_chat.rs | 57 ++++++++++++++++++++++++++ crates/ironclaw_llm/src/tool_args.rs | 37 +++++++++++++++-- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/crates/ironclaw_llm/src/nearai_chat.rs b/crates/ironclaw_llm/src/nearai_chat.rs index 6d914eb9fdc..978a303ddd6 100644 --- a/crates/ironclaw_llm/src/nearai_chat.rs +++ b/crates/ironclaw_llm/src/nearai_chat.rs @@ -2103,6 +2103,63 @@ data: [DONE] ); } + #[tokio::test] + async fn complete_with_tools_streaming_recovers_args_with_trailing_content() { + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server_task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept request"); + let (headers, body) = read_http_request_body(&mut socket).await; + assert!(headers.starts_with("POST /v1/chat/completions ")); + let request_json: serde_json::Value = + serde_json::from_str(&body).expect("request json"); + assert_eq!(request_json["stream"], true); + + socket + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\nconnection: close\r\n\r\n", + ) + .await + .expect("write sse headers"); + // A reasoning model streams a complete arguments object followed by a + // stray trailing token. The leading object must be recovered, not + // collapsed to an empty `{}` (which would call the tool with no args). + socket + .write_all( + br#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_search","type":"function","function":{"name":"search","arguments":"{\"query\":\"test\"}trailing"}}]},"finish_reason":"tool_calls"}]} + +data: [DONE] + +"#, + ) + .await + .expect("write trailing-content tool call stream"); + }); + + let provider = NearAiChatProvider::new(test_nearai_config(&base_url), test_session()) + .expect("provider"); + let response = complete_search_tool_streaming(provider) + .await + .expect("trailing content after valid args should be recovered, not fail the turn"); + + server_task.await.expect("server task"); + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].id, "call_search"); + assert_eq!(response.tool_calls[0].name, "search"); + assert_eq!( + response.tool_calls[0].arguments, + serde_json::json!({"query": "test"}), + "the leading valid object must be recovered, not dropped to empty" + ); + assert!( + response.tool_calls[0].arguments_parse_error.is_none(), + "trailing content after a valid object must not mark the args for repair" + ); + } + #[tokio::test] async fn complete_with_tools_streaming_marks_empty_tool_arguments_for_repair() { use tokio::io::AsyncWriteExt; diff --git a/crates/ironclaw_llm/src/tool_args.rs b/crates/ironclaw_llm/src/tool_args.rs index b9d1a80bdfb..8cfe481728c 100644 --- a/crates/ironclaw_llm/src/tool_args.rs +++ b/crates/ironclaw_llm/src/tool_args.rs @@ -41,9 +41,24 @@ pub(crate) struct ArgsParseError { /// accepted — `Ok` is returned. Callers decide whether a non-object shape /// should be rejected. Only a parse failure produces `Err`. pub(crate) fn parse_tool_call_args(raw: &str) -> Result { - serde_json::from_str(raw).map_err(|e| ArgsParseError { - reason: format!("failed to parse tool-call arguments JSON: {e}"), - }) + // Some tool-capable reasoning models append a stray token after a complete + // JSON arguments object (`{...}` + junk). A strict `from_str` rejects that as + // "trailing characters". On the streaming tool-call path + // (`NearAiStreamingToolCallState::into_tool_call`), `parse_tool_call_args_lossy` + // then collapses the arguments to an empty `{}` (recording an + // `arguments_parse_error`), so the tool is invoked with the model's real + // arguments silently dropped. Take the first complete JSON value and ignore + // any trailing content; only empty or genuinely malformed input errors. + let mut values = serde_json::Deserializer::from_str(raw).into_iter::(); + match values.next() { + Some(Ok(value)) => Ok(value), + Some(Err(error)) => Err(ArgsParseError { + reason: format!("failed to parse tool-call arguments JSON: {error}"), + }), + None => Err(ArgsParseError { + reason: "failed to parse tool-call arguments JSON: empty input".to_owned(), + }), + } } /// Migration helper: silent-`{}` fallback with error capture. @@ -138,6 +153,22 @@ mod tests { assert!(err.is_none()); } + #[test] + fn parse_args_ignores_trailing_content_after_first_value() { + // Regression: reasoning models stream a valid arguments object followed + // by a stray token. The leading object must be recovered, not rejected + // as "trailing characters" (which otherwise silently dropped the args + // to `{}` on the streaming path). + let result = parse_tool_call_args(r#"{"a": 2} x"#).expect("leading object should parse"); + assert_eq!(result["a"], 2); + + // And through the lossy wrapper: the real arguments survive with no + // captured parse error, rather than collapsing to `{}`. + let (val, err) = parse_tool_call_args_lossy(r#"{"query": "test"}trailing"#); + assert_eq!(val["query"], "test"); + assert!(err.is_none()); + } + #[test] fn probe_reasoning_field_first_present_wins() { let json = json!({"reasoning": "first", "reasoning_content": "second"}); From e1df89d5ab68d5550775dd2f121eafa12f9063c5 Mon Sep 17 00:00:00 2001 From: Bohdan Khorolets Date: Sat, 11 Jul 2026 09:05:39 +0300 Subject: [PATCH 2/2] refactor(llm): scope trailing-arg tolerance to a named streaming helper Per review feedback: the previous change loosened the shared, fail-loud `parse_tool_call_args` primitive for every provider to fix a NearAI streaming-specific bug, making `{"a":1} garbage` / `{}{}` silently parse to the first value at the canonical tool-call boundary. Keep `parse_tool_call_args` strict (`serde_json::from_str`) and move the "recover first value, ignore trailing junk" behavior into an explicitly-named `parse_tool_call_args_allow_trailing_lossy`, called only from `NearAiStreamingToolCallState::into_tool_call`. It delegates to `parse_tool_call_args_lossy` for the clean/malformed paths so the shared bridge stays referenced (no dead code). Add `parse_tool_call_args_stays_strict_on_trailing_content` locking that the shared primitive still rejects trailing/concatenated JSON. --- crates/ironclaw_llm/src/nearai_chat.rs | 4 +- crates/ironclaw_llm/src/tool_args.rs | 81 +++++++++++++++++--------- 2 files changed, 54 insertions(+), 31 deletions(-) diff --git a/crates/ironclaw_llm/src/nearai_chat.rs b/crates/ironclaw_llm/src/nearai_chat.rs index d1872014dee..8ea1cbbf598 100644 --- a/crates/ironclaw_llm/src/nearai_chat.rs +++ b/crates/ironclaw_llm/src/nearai_chat.rs @@ -26,7 +26,7 @@ use crate::provider::{ ChatMessage, CompletionRequest, CompletionResponse, CompletionStreamSink, FinishReason, LlmProvider, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, }; -use crate::tool_args::parse_tool_call_args_lossy; +use crate::tool_args::parse_tool_call_args_allow_trailing_lossy; #[path = "nearai_tool_message_flattening.rs"] mod nearai_tool_message_flattening; @@ -1604,7 +1604,7 @@ impl NearAiStreamingToolCallState { if raw_arguments.is_empty() && !arguments_delta_seen { (serde_json::Value::Object(Default::default()), None) } else { - parse_tool_call_args_lossy(&raw_arguments) + parse_tool_call_args_allow_trailing_lossy(&raw_arguments) }; let arguments_parse_error = arguments_parse_error.map(|parse_error| { format!( diff --git a/crates/ironclaw_llm/src/tool_args.rs b/crates/ironclaw_llm/src/tool_args.rs index 8cfe481728c..928c54baae2 100644 --- a/crates/ironclaw_llm/src/tool_args.rs +++ b/crates/ironclaw_llm/src/tool_args.rs @@ -41,24 +41,9 @@ pub(crate) struct ArgsParseError { /// accepted — `Ok` is returned. Callers decide whether a non-object shape /// should be rejected. Only a parse failure produces `Err`. pub(crate) fn parse_tool_call_args(raw: &str) -> Result { - // Some tool-capable reasoning models append a stray token after a complete - // JSON arguments object (`{...}` + junk). A strict `from_str` rejects that as - // "trailing characters". On the streaming tool-call path - // (`NearAiStreamingToolCallState::into_tool_call`), `parse_tool_call_args_lossy` - // then collapses the arguments to an empty `{}` (recording an - // `arguments_parse_error`), so the tool is invoked with the model's real - // arguments silently dropped. Take the first complete JSON value and ignore - // any trailing content; only empty or genuinely malformed input errors. - let mut values = serde_json::Deserializer::from_str(raw).into_iter::(); - match values.next() { - Some(Ok(value)) => Ok(value), - Some(Err(error)) => Err(ArgsParseError { - reason: format!("failed to parse tool-call arguments JSON: {error}"), - }), - None => Err(ArgsParseError { - reason: "failed to parse tool-call arguments JSON: empty input".to_owned(), - }), - } + serde_json::from_str(raw).map_err(|e| ArgsParseError { + reason: format!("failed to parse tool-call arguments JSON: {e}"), + }) } /// Migration helper: silent-`{}` fallback with error capture. @@ -85,6 +70,36 @@ pub(crate) fn parse_tool_call_args_lossy(raw: &str) -> (Value, Option) { } } +/// Streaming-salvage variant of [`parse_tool_call_args_lossy`]. +/// +/// Some streaming providers (e.g. NearAI reasoning models like DeepSeek) append +/// a stray token after a complete arguments object (`{...}` + junk). This parses +/// the **first** complete JSON value and ignores any trailing content, then +/// falls back to the same silent-`{}` + captured-`reason` behavior as +/// [`parse_tool_call_args_lossy`] on empty or genuinely malformed input. +/// +/// Kept as a separate, explicitly-named helper so this permissive "ignore +/// trailing junk" behavior stays scoped to the streaming path and does **not** +/// loosen the shared, fail-loud [`parse_tool_call_args`] boundary that other +/// providers rely on. +pub(crate) fn parse_tool_call_args_allow_trailing_lossy(raw: &str) -> (Value, Option) { + // A clean parse (no trailing junk) is handled by the shared lossy helper. + let lossy = parse_tool_call_args_lossy(raw); + if lossy.1.is_none() { + return lossy; + } + // The strict parse rejected the input. If it is a valid JSON value followed + // by a stray trailing token (streaming reasoning models do this), recover the + // leading value; otherwise keep the lossy `{}` + captured reason. + match serde_json::Deserializer::from_str(raw) + .into_iter::() + .next() + { + Some(Ok(value)) => (value, None), + _ => lossy, + } +} + /// Ordered probe over candidate reasoning-field names. /// /// Iterates `fields` in order; returns a borrowed reference to the first value @@ -154,19 +169,27 @@ mod tests { } #[test] - fn parse_args_ignores_trailing_content_after_first_value() { - // Regression: reasoning models stream a valid arguments object followed - // by a stray token. The leading object must be recovered, not rejected - // as "trailing characters" (which otherwise silently dropped the args - // to `{}` on the streaming path). - let result = parse_tool_call_args(r#"{"a": 2} x"#).expect("leading object should parse"); - assert_eq!(result["a"], 2); - - // And through the lossy wrapper: the real arguments survive with no - // captured parse error, rather than collapsing to `{}`. - let (val, err) = parse_tool_call_args_lossy(r#"{"query": "test"}trailing"#); + fn allow_trailing_lossy_recovers_first_value() { + // The streaming salvage helper recovers the leading object when a + // reasoning model appends a stray token after it, instead of dropping + // the arguments to `{}`. + let (val, err) = parse_tool_call_args_allow_trailing_lossy(r#"{"query": "test"}trailing"#); assert_eq!(val["query"], "test"); assert!(err.is_none()); + + // Genuinely malformed input still fails closed to `{}` + a captured reason. + let (val, err) = parse_tool_call_args_allow_trailing_lossy(r#"{not valid"#); + assert!(val.as_object().expect("object").is_empty()); + assert!(err.is_some()); + } + + #[test] + fn parse_tool_call_args_stays_strict_on_trailing_content() { + // The shared, fail-loud primitive must NOT accept concatenated or + // trailing output — only the streaming salvage helper tolerates it. + // Guards against loosening the canonical boundary for other providers. + assert!(parse_tool_call_args(r#"{"a": 2} x"#).is_err()); + assert!(parse_tool_call_args(r#"{}{}"#).is_err()); } #[test]