Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/tinytools-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ and the unknown-tool policy remain the consuming harness's or host's.

| `CallSource` | Shape | Seen from |
| --- | --- | --- |
| `TaggedJson` | `<tool_call>{json}</tool_call>`, `<toolcall>`, `<tool-call>`, bare `<invoke>`, attribute form `<tool_call id="…">`, garbled `<\|tool_call>…<tool_call\|>`, `call:` prefix, fenced ```` ```tool_call ````, Kimi `NAME{…}` bodies | Hermes / Qwen templates, OpenRouter, Composio sub-agents, Kimi K2 |
| `TaggedJson` | `<tool_call>{json}</tool_call>`, `<toolcall>`, `<tool-call>`, bare `<invoke>`, attribute form `<tool_call id="…">`, garbled `<\|tool_call>…<tool_call\|>`, `call:` and `function_call:` prefixes, fenced ```` ```tool_call ````, Kimi `NAME{…}` bodies | Hermes / Qwen templates, OpenRouter, Composio sub-agents, Kimi K2 |
| `InvokeXml` | `<invoke name><parameter name>`, DeepSeek DSML `<|DSML|invoke …>`, namespaced `<atem:invoke>`, `<function=NAME><parameter=k>`, `<function name>` | 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 |
Expand Down
113 changes: 113 additions & 0 deletions crates/tinytools-agent/src/parse/grammar/function_call.rs
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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('{') {
Comment thread
senamakel marked this conversation as resolved.
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;
};
Comment thread
senamakel marked this conversation as resolved.
let block_end = start + PREFIX.len() + end;
let Ok(value) = serde_json::from_str::<serde_json::Value>(&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<ParsedToolCall> {
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()
}
2 changes: 2 additions & 0 deletions crates/tinytools-agent/src/parse/grammar/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
121 changes: 121 additions & 0 deletions crates/tinytools-agent/src/parse/test/function_call.rs
Original file line number Diff line number Diff line change
@@ -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());
}
1 change: 1 addition & 0 deletions crates/tinytools-agent/src/parse/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
mod bare_json;
mod element;
mod engine;
mod function_call;
mod glm;
mod harmony_mistral;
mod invoke_xml;
Expand Down
25 changes: 25 additions & 0 deletions crates/tinytools-agent/src/stream/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions crates/tinytools-agent/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ use crate::PFormatRegistry;
pub enum CallSource {
/// The provider's structured tool-call channel.
Native,
/// `<tool_call>{json}</tool_call>` and its spelling variants, including
/// fenced ```` ```tool_call ```` blocks.
/// `<tool_call>{json}</tool_call>`, `function_call:{json}`, and their
/// spelling variants, including fenced ```` ```tool_call ```` blocks.
TaggedJson,
/// `<invoke name="…"><parameter name="…">` XML: Claude, `DeepSeek` DSML,
/// namespaced variants, and `<function=…>` forms.
Expand Down
Loading