From d809270050fb7f15fd04087c354359fd2aaeb96a Mon Sep 17 00:00:00 2001 From: Oscar-Williams Date: Sun, 27 Sep 2026 05:55:47 +0800 Subject: [PATCH 1/5] fix(agent): recover function_call JSON envelopes --- crates/tinytools-agent/README.md | 2 +- .../src/parse/grammar/function_call.rs | 88 +++++++++++++++++++ .../tinytools-agent/src/parse/grammar/mod.rs | 2 + .../src/parse/test/function_call.rs | 62 +++++++++++++ crates/tinytools-agent/src/parse/test/mod.rs | 1 + crates/tinytools-agent/src/stream/test.rs | 12 +++ crates/tinytools-agent/src/types.rs | 4 +- 7 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 crates/tinytools-agent/src/parse/grammar/function_call.rs create mode 100644 crates/tinytools-agent/src/parse/test/function_call.rs diff --git a/crates/tinytools-agent/README.md b/crates/tinytools-agent/README.md index b41a0b5..f558f2a 100644 --- a/crates/tinytools-agent/README.md +++ b/crates/tinytools-agent/README.md @@ -26,7 +26,7 @@ and the unknown-tool policy remain the consuming harness's or host's. | `CallSource` | Shape | Seen from | | --- | --- | --- | -| `TaggedJson` | `{json}`, ``, ``, bare ``, attribute form ``, garbled `<\|tool_call>…`, `call:` prefix, fenced ```` ```tool_call ````, Kimi `NAME{…}` bodies | Hermes / Qwen templates, OpenRouter, Composio sub-agents, Kimi K2 | +| `TaggedJson` | `{json}`, ``, ``, bare ``, attribute form ``, garbled `<\|tool_call>…`, `call:` and `function_call:` prefixes, fenced ```` ```tool_call ````, Kimi `NAME{…}` bodies | Hermes / Qwen templates, OpenRouter, Composio sub-agents, Kimi K2 | | `InvokeXml` | ``, DeepSeek DSML `<|DSML|invoke …>`, namespaced ``, ``, `` | Claude, DeepSeek V3/V4, muse-spark, Llama / Qwen / Gemma | | `Sentinel` | `<|tool▁call▁begin|>…<|tool▁call▁end|>`, `<\|tool_call_begin\|>…<\|tool_call_end\|>` | DeepSeek R1 / V3, Kimi K2 | | `Harmony` | `<\|channel\|>commentary to=NAME<\|message\|>{json}<\|call\|>` | gpt-oss | diff --git a/crates/tinytools-agent/src/parse/grammar/function_call.rs b/crates/tinytools-agent/src/parse/grammar/function_call.rs new file mode 100644 index 0000000..75b4704 --- /dev/null +++ b/crates/tinytools-agent/src/parse/grammar/function_call.rs @@ -0,0 +1,88 @@ +//! `function_call:{...}` envelopes narrated in ordinary assistant text. +//! +//! Some native-tool models put the call in the visible message as a compact +//! JSON envelope instead of filling the provider's structured field. The +//! explicit prefix is the marker: unlike the bare-JSON path, this grammar may +//! remove only the marked object from surrounding prose. + +use super::{Block, Decoded, Grammar, Probe, ScanMode, find_ci}; +use crate::parse::json_values::find_json_end; +use crate::repair::args; +use crate::types::{CallSource, ParseOptions, ParsedToolCall}; + +/// The marker used by the affected native-model response shape. +const PREFIX: &str = "function_call:"; + +/// Grammar for a marked JSON call in ordinary text. +#[derive(Debug)] +pub(crate) struct FunctionCall; + +impl Grammar for FunctionCall { + fn source(&self) -> CallSource { + // This is another explicit JSON marker, so it shares the tagged + // source classification used by the existing JSON call grammars. + CallSource::TaggedJson + } + + fn probe(&self, text: &str, from: usize, _options: &ParseOptions<'_>, mode: ScanMode) -> Probe { + let mut cursor = from; + while let Some(start) = find_ci(text, PREFIX, cursor) { + let after = &text[start + PREFIX.len()..]; + if !after.trim_start().starts_with('{') { + cursor = start + PREFIX.len(); + continue; + } + + let Some(end) = find_json_end(after) else { + return if mode == ScanMode::Stream { + Probe::Pending { start } + } else { + Probe::None + }; + }; + let block_end = start + PREFIX.len() + end; + let Ok(value) = serde_json::from_str::(&after[..end]) else { + // Skip a balanced but invalid object as one unit. This + // prevents a marker in its payload from becoming a nested + // call while still allowing a later real marker in the + // response to be considered. + cursor = block_end; + continue; + }; + let Some(call) = decode_call(&value) else { + cursor = block_end; + continue; + }; + + return Probe::Found(Block { + start, + end: block_end, + decoded: Decoded::Calls(vec![call]), + }); + } + + Probe::None + } + + fn openers(&self) -> &'static [&'static str] { + &[PREFIX] + } +} + +/// Reads the two observed name spellings without treating arbitrary JSON as a +/// call. The marker already establishes the call context, so the usual tagged +/// argument aliases remain safe here. +fn decode_call(value: &serde_json::Value) -> Option { + let object = value.as_object()?; + let name = object + .get("call") + .or_else(|| object.get("name")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty())?; + Some(ParsedToolCall::new( + name, + args::from_call_object(value), + CallSource::TaggedJson, + )) +} diff --git a/crates/tinytools-agent/src/parse/grammar/mod.rs b/crates/tinytools-agent/src/parse/grammar/mod.rs index 3b996cb..d8e2b1e 100644 --- a/crates/tinytools-agent/src/parse/grammar/mod.rs +++ b/crates/tinytools-agent/src/parse/grammar/mod.rs @@ -12,6 +12,7 @@ //! at the same byte; the engine otherwise takes the earliest opener. pub(crate) mod bare_json; +pub(crate) mod function_call; pub(crate) mod glm; pub(crate) mod harmony; pub(crate) mod invoke_xml; @@ -92,6 +93,7 @@ pub(crate) trait Grammar: Sync { /// Every scan grammar, in tie-break order. pub(crate) static GRAMMARS: &[&dyn Grammar] = &[ + &function_call::FunctionCall, &invoke_xml::InvokeXml, &sentinel::Sentinel, &harmony::Harmony, diff --git a/crates/tinytools-agent/src/parse/test/function_call.rs b/crates/tinytools-agent/src/parse/test/function_call.rs new file mode 100644 index 0000000..740565e --- /dev/null +++ b/crates/tinytools-agent/src/parse/test/function_call.rs @@ -0,0 +1,62 @@ +//! Regression tests for the explicit `function_call:` JSON envelope. + +use super::parse; +use crate::types::CallSource; + +#[test] +fn function_call_prefix_recovers_a_narrated_json_call() { + let (text, calls) = parse( + r#"Let me proceed with querying the tasks ledger. function_call:{"id":"call_3rY","call":"read_ledger","arguments":{"ledger":"tasks","query":"2026-09-01"}}"#, + ); + + assert_eq!(text, "Let me proceed with querying the tasks ledger."); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "read_ledger"); + assert_eq!(calls[0].source, CallSource::TaggedJson); + assert_eq!( + calls[0].arguments, + serde_json::json!({"ledger": "tasks", "query": "2026-09-01"}) + ); +} + +#[test] +fn function_call_prefix_accepts_the_standard_name_field() { + let (text, calls) = + parse(r#"function_call: {"name":"echo","arguments":{"value":"ok"}} trailing"#); + + assert_eq!(text, "trailing"); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "echo"); + assert_eq!(calls[0].arguments, serde_json::json!({"value": "ok"})); +} + +#[test] +fn an_unrelated_function_call_object_stays_visible() { + let input = r#"function_call:{"message":"this is not a tool call"}"#; + + let (text, calls) = parse(input); + + assert_eq!(text, input); + assert!(calls.is_empty()); +} + +#[test] +fn a_fenced_function_call_example_is_not_executed() { + let input = "```text\nfunction_call:{\"name\":\"echo\",\"arguments\":{}}\n```"; + + let (text, calls) = parse(input); + + assert_eq!(text, input); + assert!(calls.is_empty()); +} + +#[test] +fn an_invalid_marked_object_does_not_hide_a_later_call() { + let (text, calls) = parse( + r#"function_call:{"message":"not a call"} then function_call:{"call":"echo","arguments":{}}"#, + ); + + assert_eq!(text, r#"function_call:{"message":"not a call"} then"#); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "echo"); +} diff --git a/crates/tinytools-agent/src/parse/test/mod.rs b/crates/tinytools-agent/src/parse/test/mod.rs index da1751d..2a811b1 100644 --- a/crates/tinytools-agent/src/parse/test/mod.rs +++ b/crates/tinytools-agent/src/parse/test/mod.rs @@ -3,6 +3,7 @@ mod bare_json; mod engine; +mod function_call; mod glm; mod harmony_mistral; mod invoke_xml; diff --git a/crates/tinytools-agent/src/stream/test.rs b/crates/tinytools-agent/src/stream/test.rs index 4775c29..38013fe 100644 --- a/crates/tinytools-agent/src/stream/test.rs +++ b/crates/tinytools-agent/src/stream/test.rs @@ -51,6 +51,18 @@ fn markup_split_across_fragments_never_leaks() { assert_eq!(calls, 1); } +#[test] +fn function_call_prefix_split_across_fragments_is_scrubbed() { + let (out, calls) = scrub_all(&[ + "answer: ", + "function_", + "call:{\"id\":\"c1\",\"call\":\"read_ledger\",\"arguments\":", + "{\"ledger\":\"tasks\"}} done", + ]); + assert_eq!(out, "answer: done"); + assert_eq!(calls, 1); +} + #[test] fn a_partial_open_marker_is_held_not_emitted() { let mut s = StreamScrubber::new(); diff --git a/crates/tinytools-agent/src/types.rs b/crates/tinytools-agent/src/types.rs index 7d61500..ac26826 100644 --- a/crates/tinytools-agent/src/types.rs +++ b/crates/tinytools-agent/src/types.rs @@ -15,8 +15,8 @@ use crate::PFormatRegistry; pub enum CallSource { /// The provider's structured tool-call channel. Native, - /// `{json}` and its spelling variants, including - /// fenced ```` ```tool_call ```` blocks. + /// `{json}`, `function_call:{json}`, and their + /// spelling variants, including fenced ```` ```tool_call ```` blocks. TaggedJson, /// `` XML: Claude, `DeepSeek` DSML, /// namespaced variants, and `` forms. From f95fae22e9d134fd7e46c180d07267a5d682fd58 Mon Sep 17 00:00:00 2001 From: Oscar-Williams Date: Sun, 27 Sep 2026 06:03:44 +0800 Subject: [PATCH 2/5] test(agent): cover function_call error paths --- .../src/parse/test/function_call.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/tinytools-agent/src/parse/test/function_call.rs b/crates/tinytools-agent/src/parse/test/function_call.rs index 740565e..991f808 100644 --- a/crates/tinytools-agent/src/parse/test/function_call.rs +++ b/crates/tinytools-agent/src/parse/test/function_call.rs @@ -60,3 +60,23 @@ fn an_invalid_marked_object_does_not_hide_a_later_call() { assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "echo"); } + +#[test] +fn an_unterminated_marked_object_stays_visible_in_batch_mode() { + let input = r#"function_call:{"call":"echo""#; + + let (text, calls) = parse(input); + + assert_eq!(text, input); + assert!(calls.is_empty()); +} + +#[test] +fn an_invalid_marked_json_object_stays_visible() { + let input = r#"function_call:{call:"echo"}"#; + + let (text, calls) = parse(input); + + assert_eq!(text, input); + assert!(calls.is_empty()); +} From 26dd0f2123638c62ce05e210904c9b1fefbab863 Mon Sep 17 00:00:00 2001 From: Oscar-Williams Date: Sun, 27 Sep 2026 06:17:33 +0800 Subject: [PATCH 3/5] fix(agent): handle function_call stream boundaries --- .../src/parse/grammar/function_call.rs | 17 ++++++++++++----- .../src/parse/test/function_call.rs | 10 ++++++++++ crates/tinytools-agent/src/stream/test.rs | 13 +++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/tinytools-agent/src/parse/grammar/function_call.rs b/crates/tinytools-agent/src/parse/grammar/function_call.rs index 75b4704..4be3794 100644 --- a/crates/tinytools-agent/src/parse/grammar/function_call.rs +++ b/crates/tinytools-agent/src/parse/grammar/function_call.rs @@ -29,16 +29,23 @@ impl Grammar for FunctionCall { while let Some(start) = find_ci(text, PREFIX, cursor) { let after = &text[start + PREFIX.len()..]; if !after.trim_start().starts_with('{') { + if mode == ScanMode::Stream && after.trim_start().is_empty() { + return Probe::Pending { start }; + } cursor = start + PREFIX.len(); continue; } let Some(end) = find_json_end(after) else { - return if mode == ScanMode::Stream { - Probe::Pending { start } - } else { - Probe::None - }; + if mode == ScanMode::Stream { + return Probe::Pending { start }; + } + // Batch input may contain an abandoned marker before a real + // call. There is no balanced boundary to skip to, so resume + // at the marker's payload and let the next explicit marker + // make progress. + cursor = start + PREFIX.len(); + continue; }; let block_end = start + PREFIX.len() + end; let Ok(value) = serde_json::from_str::(&after[..end]) else { diff --git a/crates/tinytools-agent/src/parse/test/function_call.rs b/crates/tinytools-agent/src/parse/test/function_call.rs index 991f808..7370bfd 100644 --- a/crates/tinytools-agent/src/parse/test/function_call.rs +++ b/crates/tinytools-agent/src/parse/test/function_call.rs @@ -61,6 +61,16 @@ fn an_invalid_marked_object_does_not_hide_a_later_call() { assert_eq!(calls[0].name, "echo"); } +#[test] +fn an_unterminated_marked_object_does_not_hide_a_later_call() { + let (text, calls) = + parse(r#"function_call:{"broken" then function_call:{"name":"echo","arguments":{}}"#); + + assert_eq!(text, r#"function_call:{"broken" then"#); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "echo"); +} + #[test] fn an_unterminated_marked_object_stays_visible_in_batch_mode() { let input = r#"function_call:{"call":"echo""#; diff --git a/crates/tinytools-agent/src/stream/test.rs b/crates/tinytools-agent/src/stream/test.rs index 38013fe..69b7b4e 100644 --- a/crates/tinytools-agent/src/stream/test.rs +++ b/crates/tinytools-agent/src/stream/test.rs @@ -63,6 +63,19 @@ fn function_call_prefix_split_across_fragments_is_scrubbed() { assert_eq!(calls, 1); } +#[test] +fn a_function_call_marker_with_no_body_is_held_until_the_next_fragment() { + let mut s = StreamScrubber::new(); + let first = s.feed("answer: function_call: "); + assert_eq!(first.text, "answer: "); + assert!(first.calls.is_empty()); + + let second = s.feed(r#"{"call":"echo","arguments":{}} done"#); + assert_eq!(second.text, " done"); + assert_eq!(second.calls.len(), 1); + assert_eq!(second.calls[0].name, "echo"); +} + #[test] fn a_partial_open_marker_is_held_not_emitted() { let mut s = StreamScrubber::new(); From 247fcd8131a715834f71924db6dc1a33f38d37d0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 11:55:42 +0300 Subject: [PATCH 4/5] chore(grammar): reorder module declarations for consistency The `function_call` module declaration was moved after `element` to maintain alphabetical ordering of the module declarations in the grammar module, improving code readability and maintainability. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools-agent/src/parse/grammar/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinytools-agent/src/parse/grammar/mod.rs b/crates/tinytools-agent/src/parse/grammar/mod.rs index 1218713..b08a903 100644 --- a/crates/tinytools-agent/src/parse/grammar/mod.rs +++ b/crates/tinytools-agent/src/parse/grammar/mod.rs @@ -12,8 +12,8 @@ //! at the same byte; the engine otherwise takes the earliest opener. pub(crate) mod bare_json; -pub(crate) mod function_call; pub(crate) mod element; +pub(crate) mod function_call; pub(crate) mod glm; pub(crate) mod harmony; pub(crate) mod invoke_xml; From 75cc3d90dc7c4fffac41e8781c40b94755e4bc32 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 29 Sep 2026 12:06:56 +0300 Subject: [PATCH 5/5] fix(parse): prevent false function call detection inside identifiers and improve name fallback The function call parser now skips prefix matches that appear inside longer identifiers, preventing false positives when a word like "not_function_call" contains the marker. Additionally, the JSON decoding logic extracts the call name through a helper that handles null and empty string values, falling back to the "name" field when the "call" field is unusable. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/parse/grammar/function_call.rs | 26 ++++++++++++++--- .../src/parse/test/function_call.rs | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/tinytools-agent/src/parse/grammar/function_call.rs b/crates/tinytools-agent/src/parse/grammar/function_call.rs index 4be3794..361ffe6 100644 --- a/crates/tinytools-agent/src/parse/grammar/function_call.rs +++ b/crates/tinytools-agent/src/parse/grammar/function_call.rs @@ -27,6 +27,15 @@ impl Grammar for FunctionCall { fn probe(&self, text: &str, from: usize, _options: &ParseOptions<'_>, mode: ScanMode) -> Probe { let mut cursor = from; while let Some(start) = find_ci(text, PREFIX, cursor) { + if text[..start] + .chars() + .next_back() + .is_some_and(is_identifier_char) + { + cursor = start + PREFIX.len(); + continue; + } + let after = &text[start + PREFIX.len()..]; if !after.trim_start().starts_with('{') { if mode == ScanMode::Stream && after.trim_start().is_empty() { @@ -83,13 +92,22 @@ fn decode_call(value: &serde_json::Value) -> Option { let object = value.as_object()?; let name = object .get("call") - .or_else(|| object.get("name")) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|name| !name.is_empty())?; + .and_then(nonempty_name) + .or_else(|| object.get("name").and_then(nonempty_name))?; Some(ParsedToolCall::new( name, args::from_call_object(value), CallSource::TaggedJson, )) } + +fn nonempty_name(value: &serde_json::Value) -> Option<&str> { + value + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty()) +} + +fn is_identifier_char(character: char) -> bool { + character == '_' || character.is_alphanumeric() +} diff --git a/crates/tinytools-agent/src/parse/test/function_call.rs b/crates/tinytools-agent/src/parse/test/function_call.rs index 7370bfd..fd8d176 100644 --- a/crates/tinytools-agent/src/parse/test/function_call.rs +++ b/crates/tinytools-agent/src/parse/test/function_call.rs @@ -30,6 +30,35 @@ fn function_call_prefix_accepts_the_standard_name_field() { assert_eq!(calls[0].arguments, serde_json::json!({"value": "ok"})); } +#[test] +fn embedded_function_call_marker_in_a_longer_identifier_stays_visible() { + let input = r#"not_function_call:{"name":"echo","arguments":{}}"#; + + let (text, calls) = parse(input); + + assert_eq!(text, input); + assert!(calls.is_empty()); +} + +#[test] +fn unusable_call_fields_fall_back_to_a_valid_name() { + for call in [serde_json::Value::Null, serde_json::json!("")] { + let envelope = serde_json::json!({ + "call": call, + "name": "echo", + "arguments": {"value": "ok"} + }); + let input = format!("function_call:{envelope}"); + + let (text, calls) = parse(&input); + + assert!(text.is_empty()); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "echo"); + assert_eq!(calls[0].arguments, serde_json::json!({"value": "ok"})); + } +} + #[test] fn an_unrelated_function_call_object_stays_visible() { let input = r#"function_call:{"message":"this is not a tool call"}"#;