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.