use std::sync::OnceLock;
use regex::Regex;
use serde_json::{Map, Value};
use uuid::Uuid;
use super::super::ToolDefinition;
use super::super::response::{CalledFunction, ToolCallResponse, ToolCallType};
pub(crate) const TOOL_CALL_START: &str = "<|tool_call>";
pub(crate) const TOOL_CALL_END: &str = "<tool_call|>";
pub(crate) const STRING_DELIM: &str = "<|\"|>";
pub(crate) const CALL_PREFIX: &str = "call:";
static TOOL_CALL_REGEX: OnceLock<Regex> = OnceLock::new();
fn tool_call_regex() -> &'static Regex {
TOOL_CALL_REGEX.get_or_init(|| {
let pattern = format!(
r"(?s){}{}(?P<name>[\w\-\.]+)\{{(?P<args>.*?)\}}{}",
regex::escape(TOOL_CALL_START),
regex::escape(CALL_PREFIX),
regex::escape(TOOL_CALL_END),
);
Regex::new(&pattern).expect("Failed to compile gemma4 tool call regex")
})
}
fn parse_gemma_call_parts(
name: &str,
args_raw: &str,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<ToolCallResponse> {
let name = name.to_string();
if let Some(tools) = tools
&& !tools.iter().any(|t| t.name == name)
{
tracing::warn!(
"Tool '{}' is not defined in the tools list (Gemma 4 parser).",
name
);
}
let args_value = match parse_args_object(args_raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
"Failed to parse Gemma 4 args for '{}': {}. Falling back to empty object.",
name,
e
);
Value::Object(Map::new())
}
};
let arguments = serde_json::to_string(&args_value)?;
Ok(ToolCallResponse {
id: format!("call-{}", Uuid::new_v4()),
tp: ToolCallType::Function,
function: CalledFunction { name, arguments },
})
}
fn find_balanced_args_end(input: &str, open_brace: usize) -> Option<usize> {
debug_assert_eq!(input.as_bytes().get(open_brace), Some(&b'{'));
let mut cursor = open_brace;
let mut depth = 0usize;
let mut in_string = false;
while cursor < input.len() {
let rest = &input[cursor..];
if rest.starts_with(STRING_DELIM) {
in_string = !in_string;
cursor += STRING_DELIM.len();
continue;
}
let ch = rest.chars().next()?;
if !in_string {
match ch {
'{' => depth += 1,
'}' => {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some(cursor);
}
}
_ => {}
}
}
cursor += ch.len_utf8();
}
None
}
fn parse_recoverable_call_at(
input: &str,
allow_missing_start: bool,
allow_missing_end: bool,
) -> Option<(&str, &str, usize)> {
let after_start_offset = if let Some(rest) = input.strip_prefix(TOOL_CALL_START) {
input.len() - rest.len()
} else if allow_missing_start && input.starts_with(CALL_PREFIX) {
0
} else {
return None;
};
let after_start = &input[after_start_offset..];
let after_prefix = after_start.strip_prefix(CALL_PREFIX)?;
let name_len = after_prefix.find('{').filter(|idx| *idx > 0)?;
let name = &after_prefix[..name_len];
if !name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
return None;
}
let open_brace = after_start_offset + CALL_PREFIX.len() + name_len;
let close_brace = find_balanced_args_end(input, open_brace)?;
let args_start = open_brace + 1;
let args_raw = &input[args_start..close_brace];
let after_args = &input[close_brace + 1..];
if after_args.starts_with(TOOL_CALL_END) {
return Some((name, args_raw, close_brace + 1 + TOOL_CALL_END.len()));
}
if allow_missing_end && after_args.trim().is_empty() {
return Some((name, args_raw, close_brace + 1));
}
None
}
fn is_call_prefix_boundary(input: &str, idx: usize) -> bool {
idx == 0
|| input[..idx]
.chars()
.next_back()
.is_none_or(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')))
}
fn find_call_prefix_at_boundary(input: &str, from: usize) -> Option<usize> {
let mut cursor = from;
while cursor < input.len() {
let rel = input[cursor..].find(CALL_PREFIX)?;
let idx = cursor + rel;
if is_call_prefix_boundary(input, idx) {
return Some(idx);
}
cursor = idx + CALL_PREFIX.len();
}
None
}
pub fn detect_tool_call_start_gemma4(chunk: &str) -> bool {
if chunk.contains(TOOL_CALL_START) {
return true;
}
let mut cursor = 0usize;
while let Some(idx) = find_call_prefix_at_boundary(chunk, cursor) {
let candidate = &chunk[idx..];
if parse_recoverable_call_at(candidate, true, true).is_some()
|| has_bare_call_body_start(candidate)
{
return true;
}
cursor = idx + CALL_PREFIX.len();
}
for i in 1..TOOL_CALL_START.len() {
if TOOL_CALL_START.is_char_boundary(i) && chunk.ends_with(&TOOL_CALL_START[..i]) {
return true;
}
}
false
}
fn has_bare_call_body_start(input: &str) -> bool {
let Some(after_prefix) = input.strip_prefix(CALL_PREFIX) else {
return false;
};
let Some(open_brace) = after_prefix.find('{') else {
return false;
};
if open_brace == 0 {
return false;
}
after_prefix[..open_brace]
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
}
pub fn split_partial_call_prefix_gemma4(chunk: &str) -> Option<(&str, &str)> {
for i in 1..=CALL_PREFIX.len() {
if !CALL_PREFIX.is_char_boundary(i) || !chunk.ends_with(&CALL_PREFIX[..i]) {
continue;
}
let partial_start = chunk.len() - i;
if is_call_prefix_boundary(chunk, partial_start) {
return Some((&chunk[..partial_start], &chunk[partial_start..]));
}
}
None
}
pub fn find_tool_call_end_position_gemma4(chunk: &str) -> Option<usize> {
let mut cursor = 0usize;
let mut last_end = None;
while cursor < chunk.len() {
let next_start = chunk[cursor..]
.find(TOOL_CALL_START)
.map(|rel| (cursor + rel, false, TOOL_CALL_START.len()));
let next_bare =
find_call_prefix_at_boundary(chunk, cursor).map(|idx| (idx, true, CALL_PREFIX.len()));
let Some((rel_start, allow_missing_start, marker_len)) = [next_start, next_bare]
.into_iter()
.flatten()
.min_by_key(|(idx, _, _)| *idx)
else {
break;
};
if let Some((_, _, consumed)) =
parse_recoverable_call_at(&chunk[rel_start..], allow_missing_start, false)
{
let mut end = rel_start + consumed;
while chunk[end..].starts_with(TOOL_CALL_END) {
end += TOOL_CALL_END.len();
}
last_end = Some(end);
cursor = end;
} else {
cursor = rel_start + marker_len;
}
}
last_end
}
fn push_recovered_call(
calls: &mut Vec<ToolCallResponse>,
first_tool_start: &mut Option<usize>,
absolute_start: usize,
recovered: (&str, &str, usize),
tools: Option<&[ToolDefinition]>,
reason: &'static str,
) -> anyhow::Result<()> {
if first_tool_start.is_none_or(|idx| absolute_start < idx) {
*first_tool_start = Some(absolute_start);
}
tracing::warn!(
why = reason,
recovered_calls = 1,
recovered_bytes = recovered.2,
"gemma4 recovery: recovered complete call body from damaged wrapper"
);
calls.push(parse_gemma_call_parts(recovered.0, recovered.1, tools)?);
Ok(())
}
fn recover_calls_in_span(
span: &str,
span_offset: usize,
allow_missing_end: bool,
tools: Option<&[ToolDefinition]>,
calls: &mut Vec<ToolCallResponse>,
first_tool_start: &mut Option<usize>,
) -> anyhow::Result<()> {
let mut cursor = 0usize;
while cursor < span.len() {
let next_start = span[cursor..]
.find(TOOL_CALL_START)
.map(|rel| (cursor + rel, false, TOOL_CALL_START.len()));
let next_bare =
find_call_prefix_at_boundary(span, cursor).map(|idx| (idx, true, CALL_PREFIX.len()));
let Some((rel_start, allow_missing_start, marker_len)) = [next_start, next_bare]
.into_iter()
.flatten()
.min_by_key(|(idx, _, _)| *idx)
else {
break;
};
let parsed = parse_recoverable_call_at(
&span[rel_start..],
allow_missing_start,
allow_missing_end && !allow_missing_start,
);
if let Some(recovered) = parsed {
let reason = if allow_missing_start {
"missing_start_recovery"
} else {
"missing_end_recovery"
};
push_recovered_call(
calls,
first_tool_start,
span_offset + rel_start,
recovered,
tools,
reason,
)?;
cursor = rel_start + recovered.2;
} else {
cursor = rel_start + marker_len;
}
}
Ok(())
}
pub fn try_tool_call_parse_gemma4(
message: &str,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<(Vec<ToolCallResponse>, Option<String>)> {
let regex = tool_call_regex();
let mut calls = Vec::new();
let mut first_tool_start = None;
let mut cursor = 0usize;
for caps in regex.captures_iter(message) {
if let Some(m) = caps.get(0) {
recover_calls_in_span(
&message[cursor..m.start()],
cursor,
false,
tools,
&mut calls,
&mut first_tool_start,
)?;
first_tool_start.get_or_insert(m.start());
cursor = m.end();
}
let name = caps.name("name").map(|m| m.as_str()).unwrap_or_default();
if name.is_empty() {
continue;
}
let args_raw = caps.name("args").map(|m| m.as_str()).unwrap_or("");
calls.push(parse_gemma_call_parts(name, args_raw, tools)?);
}
recover_calls_in_span(
&message[cursor..],
cursor,
true,
tools,
&mut calls,
&mut first_tool_start,
)?;
let has_markup = message.contains(TOOL_CALL_START)
|| message.contains(TOOL_CALL_END)
|| message.contains(STRING_DELIM);
let normal_text = if calls.is_empty() {
if has_markup {
let preview: String = message.chars().take(120).collect();
tracing::warn!(
why = "no_calls_with_markup",
stripped_bytes = message.len(),
has_start = message.contains(TOOL_CALL_START),
has_end = message.contains(TOOL_CALL_END),
has_string_delim = message.contains(STRING_DELIM),
"gemma4 strip (recovery): zero calls extracted but gemma4 markup present (<|tool_call>, <tool_call|>, <|\"|>); suppressing entire message to prevent leak into normal_text. preview={:?}",
preview
);
String::new()
} else {
message.trim().to_string()
}
} else {
match first_tool_start {
Some(idx) => {
let stripped = &message[idx..];
let preview: String = stripped.chars().take(120).collect();
tracing::debug!(
why = "prefix_only_contract",
n_calls = calls.len(),
kept_prefix_bytes = idx,
stripped_bytes = stripped.len(),
"gemma4 strip (success): kept prefix before first <|tool_call>; dropped parsed-call(s) + any inter-call / trailing narration. preview={:?}",
preview
);
message[..idx].trim().to_string()
}
None => String::new(),
}
};
Ok((calls, Some(normal_text)))
}
struct Cursor<'a> {
src: &'a str,
pos: usize,
}
impl<'a> Cursor<'a> {
fn new(src: &'a str) -> Self {
Self { src, pos: 0 }
}
fn rest(&self) -> &'a str {
&self.src[self.pos..]
}
fn eof(&self) -> bool {
self.pos >= self.src.len()
}
fn skip_whitespace(&mut self) {
let bytes = self.src.as_bytes();
while self.pos < bytes.len() && bytes[self.pos].is_ascii_whitespace() {
self.pos += 1;
}
}
fn peek_byte(&self) -> Option<u8> {
self.src.as_bytes().get(self.pos).copied()
}
fn consume_byte(&mut self, b: u8) -> bool {
if self.peek_byte() == Some(b) {
self.pos += 1;
true
} else {
false
}
}
}
pub(crate) fn parse_args_object(input: &str) -> anyhow::Result<Value> {
let mut cur = Cursor::new(input);
cur.skip_whitespace();
let val = parse_object_body(&mut cur)?;
cur.skip_whitespace();
if !cur.eof() {
anyhow::bail!(
"trailing characters after Gemma 4 args object at offset {}: {:?}",
cur.pos,
cur.rest()
);
}
Ok(val)
}
fn parse_object_body(cur: &mut Cursor) -> anyhow::Result<Value> {
let mut map = Map::new();
cur.skip_whitespace();
if cur.eof() || cur.peek_byte() == Some(b'}') {
return Ok(Value::Object(map));
}
loop {
cur.skip_whitespace();
let key = parse_key(cur)?;
cur.skip_whitespace();
if !cur.consume_byte(b':') {
anyhow::bail!("expected ':' after key '{}' at offset {}", key, cur.pos);
}
cur.skip_whitespace();
let value = match cur.peek_byte() {
None | Some(b',') | Some(b'}') => Value::String(String::new()),
_ => parse_value(cur)?,
};
map.insert(key, value);
cur.skip_whitespace();
if !cur.consume_byte(b',') {
break;
}
}
Ok(Value::Object(map))
}
fn parse_key(cur: &mut Cursor) -> anyhow::Result<String> {
let bytes = cur.src.as_bytes();
let start = cur.pos;
while cur.pos < bytes.len() {
let b = bytes[cur.pos];
if b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' {
cur.pos += 1;
} else {
break;
}
}
if cur.pos == start {
anyhow::bail!("expected bare key at offset {}", start);
}
Ok(cur.src[start..cur.pos].to_string())
}
fn try_consume_keyword(cur: &mut Cursor, keyword: &str) -> bool {
let bytes = cur.src.as_bytes();
let kw = keyword.as_bytes();
let end = cur.pos + kw.len();
if end > bytes.len() {
return false;
}
if !bytes[cur.pos..end].eq_ignore_ascii_case(kw) {
return false;
}
if let Some(&next) = bytes.get(end)
&& (next.is_ascii_alphanumeric() || next == b'_')
{
return false;
}
cur.pos = end;
true
}
fn parse_value(cur: &mut Cursor) -> anyhow::Result<Value> {
cur.skip_whitespace();
if cur.rest().starts_with(STRING_DELIM) {
cur.pos += STRING_DELIM.len();
let body_start = cur.pos;
match cur.src[body_start..].find(STRING_DELIM) {
Some(end_rel) => {
let body_end = body_start + end_rel;
let s = cur.src[body_start..body_end].to_string();
cur.pos = body_end + STRING_DELIM.len();
return Ok(Value::String(s));
}
None => {
let s = cur.src[body_start..].to_string();
cur.pos = cur.src.len();
return Ok(Value::String(s));
}
}
}
if cur.consume_byte(b'{') {
let v = parse_object_body(cur)?;
cur.skip_whitespace();
if !cur.consume_byte(b'}') {
anyhow::bail!("expected '}}' to close object at offset {}", cur.pos);
}
return Ok(v);
}
if cur.consume_byte(b'[') {
return parse_array(cur);
}
if try_consume_keyword(cur, "true") {
return Ok(Value::Bool(true));
}
if try_consume_keyword(cur, "false") {
return Ok(Value::Bool(false));
}
if try_consume_keyword(cur, "null")
|| try_consume_keyword(cur, "none")
|| try_consume_keyword(cur, "nil")
{
return Ok(Value::Null);
}
parse_number(cur)
}
fn parse_array(cur: &mut Cursor) -> anyhow::Result<Value> {
let mut items = Vec::new();
cur.skip_whitespace();
if cur.consume_byte(b']') {
return Ok(Value::Array(items));
}
loop {
cur.skip_whitespace();
items.push(parse_value(cur)?);
cur.skip_whitespace();
if cur.consume_byte(b']') {
return Ok(Value::Array(items));
}
if !cur.consume_byte(b',') {
anyhow::bail!("expected ',' or ']' in array at offset {}", cur.pos);
}
}
}
fn parse_number(cur: &mut Cursor) -> anyhow::Result<Value> {
let start = cur.pos;
let bytes = cur.src.as_bytes();
if cur.peek_byte() == Some(b'-') {
cur.pos += 1;
}
let int_start = cur.pos;
while cur.pos < bytes.len() && bytes[cur.pos].is_ascii_digit() {
cur.pos += 1;
}
if cur.pos == int_start {
anyhow::bail!(
"expected value at offset {} but got: {:?}",
start,
&cur.src[start..]
);
}
let mut is_float = false;
if cur.peek_byte() == Some(b'.') {
is_float = true;
cur.pos += 1;
while cur.pos < bytes.len() && bytes[cur.pos].is_ascii_digit() {
cur.pos += 1;
}
}
let lex = &cur.src[start..cur.pos];
if is_float {
let f: f64 = lex.parse()?;
Ok(serde_json::json!(f))
} else {
let i: i64 = lex.parse()?;
Ok(serde_json::json!(i))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn extract_first(input: &str) -> (String, Value) {
let (calls, _) = try_tool_call_parse_gemma4(input, None).unwrap();
assert_eq!(calls.len(), 1, "expected exactly one tool call");
let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
(calls[0].function.name.clone(), args)
}
#[test] fn detect_full_and_partial_start() {
assert!(detect_tool_call_start_gemma4("<|tool_call>"));
assert!(detect_tool_call_start_gemma4("blah <|tool_call>"));
assert!(detect_tool_call_start_gemma4(
"call:get_weather{location:<|\"|>NYC<|\"|>}<tool_call|>"
));
assert!(detect_tool_call_start_gemma4("call:get_weather{"));
assert!(detect_tool_call_start_gemma4("<|tool_"));
assert!(detect_tool_call_start_gemma4("<|"));
assert!(!detect_tool_call_start_gemma4("I will call: you tomorrow"));
assert!(!detect_tool_call_start_gemma4("nothing here"));
assert!(!detect_tool_call_start_gemma4("toolcall"));
}
#[test] fn find_end_returns_position_after_last_marker() {
let text = "<|tool_call>call:f{}<tool_call|>more";
let pos = find_tool_call_end_position_gemma4(text).unwrap();
assert_eq!(&text[pos..], "more");
assert_eq!(
find_tool_call_end_position_gemma4("<|tool_call>call:f{"),
None
);
}
#[test] fn parse_single_string_argument() {
let input = r#"<|tool_call>call:get_weather{location:<|"|>Tokyo<|"|>}<tool_call|>"#;
let (name, args) = extract_first(input);
assert_eq!(name, "get_weather");
assert_eq!(args["location"], "Tokyo");
}
#[test] fn parse_multiple_typed_arguments() {
let input = r#"<|tool_call>call:f{loc:<|"|>San Francisco, CA<|"|>,unit:<|"|>celsius<|"|>,count:42,flag:true,nope:null}<tool_call|>"#;
let (name, args) = extract_first(input);
assert_eq!(name, "f");
assert_eq!(args["loc"], "San Francisco, CA");
assert_eq!(args["unit"], "celsius");
assert_eq!(args["count"], 42);
assert_eq!(args["flag"], true);
assert_eq!(args["nope"], Value::Null);
}
#[test] fn parse_no_arg_call() {
let input = "<|tool_call>call:get_time{}<tool_call|>";
let (name, args) = extract_first(input);
assert_eq!(name, "get_time");
assert!(args.as_object().unwrap().is_empty());
}
#[test] fn parse_nested_object_value() {
let input = r#"<|tool_call>call:f{cfg:{ssl:true,pool:{min:5,max:20}}}<tool_call|>"#;
let (_name, args) = extract_first(input);
assert_eq!(args["cfg"]["ssl"], true);
assert_eq!(args["cfg"]["pool"]["min"], 5);
assert_eq!(args["cfg"]["pool"]["max"], 20);
}
#[test] fn parse_array_of_strings() {
let input = r#"<|tool_call>call:f{tags:[<|"|>a<|"|>,<|"|>b<|"|>,<|"|>c<|"|>]}<tool_call|>"#;
let (_name, args) = extract_first(input);
assert_eq!(args["tags"], serde_json::json!(["a", "b", "c"]));
}
#[test] fn parse_array_of_mixed_primitives() {
let input = "<|tool_call>call:f{xs:[1,2,3.5,true,false,null]}<tool_call|>";
let (_name, args) = extract_first(input);
assert_eq!(args["xs"][0], 1);
assert_eq!(args["xs"][1], 2);
assert!((args["xs"][2].as_f64().unwrap() - 3.5).abs() < 1e-9);
assert_eq!(args["xs"][3], true);
assert_eq!(args["xs"][4], false);
assert_eq!(args["xs"][5], Value::Null);
}
#[test] fn parse_multiple_parallel_calls() {
let input = concat!(
"<|tool_call>call:a{x:1}<tool_call|>",
"<|tool_call>call:b{y:<|\"|>two<|\"|>}<tool_call|>",
"<|tool_call>call:c{}<tool_call|>",
);
let (calls, normal) = try_tool_call_parse_gemma4(input, None).unwrap();
assert_eq!(calls.len(), 3);
assert_eq!(calls[0].function.name, "a");
assert_eq!(calls[1].function.name, "b");
assert_eq!(calls[2].function.name, "c");
assert_eq!(normal, Some(String::new()));
}
#[test] fn parse_with_surrounding_text() {
let input = r#"Sure thing. <|tool_call>call:f{x:1}<tool_call|> All set."#;
let (calls, normal) = try_tool_call_parse_gemma4(input, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(normal, Some("Sure thing.".to_string()));
}
#[test] fn parse_no_tool_calls() {
let (calls, normal) = try_tool_call_parse_gemma4("just plain prose here", None).unwrap();
assert_eq!(calls.len(), 0);
assert_eq!(normal, Some("just plain prose here".to_string()));
}
#[test] fn truncated_tail_dropped_complete_prior_survives() {
let input = concat!(
"<|tool_call>call:complete{x:1}<tool_call|>",
"<|tool_call>call:partial{y:<|\"|>incomp", );
let (calls, normal) = try_tool_call_parse_gemma4(input, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "complete");
assert_eq!(normal, Some(String::new()));
}
#[test] fn malformed_args_falls_back_to_empty_object() {
let input = "<|tool_call>call:f{garbage no colons here}<tool_call|>";
let (calls, _) = try_tool_call_parse_gemma4(input, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "f");
let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert!(args.as_object().unwrap().is_empty());
}
#[test] fn parse_function_names_with_special_chars() {
for (input, expected_name) in [
(
"<|tool_call>call:list-tasklists{}<tool_call|>",
"list-tasklists",
),
(
"<|tool_call>call:mcp__portal__search-doc{}<tool_call|>",
"mcp__portal__search-doc",
),
(
"<|tool_call>call:my.namespaced.fn{}<tool_call|>",
"my.namespaced.fn",
),
] {
let (calls, _) = try_tool_call_parse_gemma4(input, None).unwrap();
assert_eq!(calls.len(), 1, "input: {input}");
assert_eq!(calls[0].function.name, expected_name);
}
}
#[test] fn parse_html_in_string_value() {
let input = r#"<|tool_call>call:render{html:<|"|><div class="x"><h1>Hi</h1></div><|"|>}<tool_call|>"#;
let (_name, args) = extract_first(input);
assert_eq!(args["html"], "<div class=\"x\"><h1>Hi</h1></div>");
}
#[test] fn parse_newlines_in_string_value() {
let input = "<|tool_call>call:f{body:<|\"|>line1\nline2\nline3<|\"|>}<tool_call|>";
let (_name, args) = extract_first(input);
assert_eq!(args["body"], "line1\nline2\nline3");
}
#[test] fn parse_with_internal_whitespace() {
let input = r#"<|tool_call>call:f{ x : 1 , y : <|"|>z<|"|> }<tool_call|>"#;
let (_name, args) = extract_first(input);
assert_eq!(args["x"], 1);
assert_eq!(args["y"], "z");
}
#[test] fn parse_signed_numbers_and_floats() {
let input = "<|tool_call>call:f{a:-1,b:-2.5,c:0,d:0.0}<tool_call|>";
let (_name, args) = extract_first(input);
assert_eq!(args["a"], -1);
assert!((args["b"].as_f64().unwrap() - -2.5).abs() < 1e-9);
assert_eq!(args["c"], 0);
assert!((args["d"].as_f64().unwrap()).abs() < 1e-9);
}
#[test] fn parse_with_tool_validation() {
let input = r#"<|tool_call>call:get_weather{x:1}<tool_call|>"#;
let tools = vec![ToolDefinition {
name: "get_weather".to_string(),
parameters: None,
strict: None,
}];
let (calls, _) = try_tool_call_parse_gemma4(input, Some(&tools)).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
}
#[test] fn parse_empty_text_between_calls() {
let input = concat!(
"<|tool_call>call:a{}<tool_call|>",
"<|tool_call>call:b{}<tool_call|>",
);
let (calls, normal) = try_tool_call_parse_gemma4(input, None).unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(normal, Some(String::new()));
}
#[test] fn args_grammar_empty() {
let v = parse_args_object("").unwrap();
assert_eq!(v, serde_json::json!({}));
}
#[test] fn args_grammar_string_with_special_chars() {
let v = parse_args_object(r#"x:<|"|>has,comma:and{brace}<|"|>"#).unwrap();
assert_eq!(v["x"], "has,comma:and{brace}");
}
#[test] fn args_grammar_deeply_nested() {
let v = parse_args_object("a:{b:{c:{d:{e:1}}}}").unwrap();
assert_eq!(v["a"]["b"]["c"]["d"]["e"], 1);
}
#[test] fn args_grammar_array_of_objects() {
let v = parse_args_object(r#"items:[{n:<|"|>x<|"|>},{n:<|"|>y<|"|>}]"#).unwrap();
assert_eq!(v["items"][0]["n"], "x");
assert_eq!(v["items"][1]["n"], "y");
}
#[test] fn args_grammar_unterminated_string_takes_remainder() {
let v = parse_args_object(r#"x:<|"|>oops"#).unwrap();
assert_eq!(v["x"], "oops");
}
#[test] fn args_grammar_empty_value_yields_empty_string() {
let v = parse_args_object("x:,y:1").unwrap();
assert_eq!(v["x"], "");
assert_eq!(v["y"], 1);
}
#[test] fn args_grammar_trailing_empty_value() {
let v = parse_args_object("x:1,y:").unwrap();
assert_eq!(v["x"], 1);
assert_eq!(v["y"], "");
}
#[test] fn args_grammar_null_aliases_case_insensitive() {
for variant in [
"null", "NULL", "Null", "none", "NONE", "None", "nil", "NIL", "Nil",
] {
let body = format!("x:{variant}");
let v = parse_args_object(&body).unwrap();
assert_eq!(v["x"], Value::Null, "variant: {variant}");
}
}
#[test] fn args_grammar_keyword_prefix_not_consumed() {
let _ = parse_args_object("x:nullable").unwrap_err();
}
#[test] fn incomplete_tool_call_suppresses_markup() {
let input = "<|tool_call>call:foo{x:1";
let (calls, normal) = try_tool_call_parse_gemma4(input, None).unwrap();
assert_eq!(calls.len(), 0);
assert_eq!(normal, Some(String::new()));
}
#[test] fn embedded_tool_call_marker_in_string_value() {
let input = r#"<|tool_call>call:render{html:<|"|><tool_call|> example<|"|>}<tool_call|>"#;
let (calls, _) = try_tool_call_parse_gemma4(input, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "render");
let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["html"], "<tool_call|> example");
}
#[test] fn find_end_position_skips_embedded_marker() {
let input = r#"<|tool_call>call:render{html:<|"|><tool_call|>x<|"|>}<tool_call|> trailing"#;
let pos = find_tool_call_end_position_gemma4(input).unwrap();
assert_eq!(&input[pos..], " trailing");
}
#[test] fn paired_reasoning_and_tool_call_in_same_emission() {
let input = concat!(
"<|channel>thought\nthinking about the request<channel|>",
"<|tool_call>call:get_weather{location:<|\"|>Tokyo<|\"|>}<tool_call|>",
);
let (calls, normal) = try_tool_call_parse_gemma4(input, None).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["location"], "Tokyo");
assert!(normal.unwrap().contains("<|channel>thought"));
}
#[test] fn empty_input_yields_zero_calls_empty_content() {
let (calls, normal) = try_tool_call_parse_gemma4("", None).unwrap();
assert_eq!(calls.len(), 0);
assert_eq!(normal, Some(String::new()));
}
#[test] fn null_argument_values_preserved() {
let input = "<|tool_call>call:f{x:null,y:none,z:nil}<tool_call|>";
let (calls, _) = try_tool_call_parse_gemma4(input, None).unwrap();
assert_eq!(calls.len(), 1);
let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["x"], Value::Null);
assert_eq!(args["y"], Value::Null);
assert_eq!(args["z"], Value::Null);
}
#[test] fn duplicate_tool_call_same_name() {
let input = concat!(
"<|tool_call>call:get_weather{location:<|\"|>Tokyo<|\"|>}<tool_call|>",
"<|tool_call>call:get_weather{location:<|\"|>NYC<|\"|>}<tool_call|>",
);
let (calls, _) = try_tool_call_parse_gemma4(input, None).unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(calls[1].function.name, "get_weather");
assert_ne!(
calls[0].id, calls[1].id,
"duplicate calls need distinct IDs"
);
let args0: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
let args1: Value = serde_json::from_str(&calls[1].function.arguments).unwrap();
assert_eq!(args0["location"], "Tokyo");
assert_eq!(args1["location"], "NYC");
}
}