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..361ffe6 --- /dev/null +++ b/crates/tinytools-agent/src/parse/grammar/function_call.rs @@ -0,0 +1,113 @@ +//! `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) { + 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() { + return Probe::Pending { start }; + } + cursor = start + PREFIX.len(); + continue; + } + + let Some(end) = find_json_end(after) else { + 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 { + // 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") + .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/grammar/mod.rs b/crates/tinytools-agent/src/parse/grammar/mod.rs index 0d37d01..b08a903 100644 --- a/crates/tinytools-agent/src/parse/grammar/mod.rs +++ b/crates/tinytools-agent/src/parse/grammar/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod bare_json; pub(crate) mod element; +pub(crate) mod function_call; pub(crate) mod glm; pub(crate) mod harmony; pub(crate) mod invoke_xml; @@ -93,6 +94,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..fd8d176 --- /dev/null +++ b/crates/tinytools-agent/src/parse/test/function_call.rs @@ -0,0 +1,121 @@ +//! 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 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"}"#; + + 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"); +} + +#[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""#; + + 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()); +} diff --git a/crates/tinytools-agent/src/parse/test/mod.rs b/crates/tinytools-agent/src/parse/test/mod.rs index 747510c..73eafbd 100644 --- a/crates/tinytools-agent/src/parse/test/mod.rs +++ b/crates/tinytools-agent/src/parse/test/mod.rs @@ -4,6 +4,7 @@ mod bare_json; mod element; 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..69b7b4e 100644 --- a/crates/tinytools-agent/src/stream/test.rs +++ b/crates/tinytools-agent/src/stream/test.rs @@ -51,6 +51,31 @@ 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_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(); diff --git a/crates/tinytools-agent/src/types.rs b/crates/tinytools-agent/src/types.rs index 124b5e4..1f56186 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.