use ferrox_models::grammar::Grammar;
use super::{build, Forced, ToolSpec};
use crate::output::{parse_output, OutputPosture};
use crate::policy::parser::tool_call::{ToolCallParser, ToolSchema};
use crate::policy::parser::ToolCallFormat;
use crate::{ToolDef, ToolFunctionDef};
pub(super) fn feed(grammar: &Grammar, pieces: &[&str]) -> Result<bool, String> {
let mut g = grammar.clone();
for (i, piece) in pieces.iter().enumerate() {
g.accept_token(i as u32, piece.as_bytes())
.map_err(|e| format!("piece {piece:?}: {e}"))?;
}
Ok(g.allows_eog())
}
fn weather_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"city": {"type": "string"},
"days": {"type": "integer"},
},
"required": ["city"],
"additionalProperties": false,
})
}
const EXPECTED_ARGUMENTS: &str = r#"{"city":"Rome","days":3}"#;
struct Sample {
model: &'static str,
prose: &'static str,
call: &'static str,
near_miss: &'static str,
}
fn sample(format: ToolCallFormat) -> Option<Sample> {
match format {
ToolCallFormat::Qwen25 => Some(Sample {
model: "qwen2.5-7b-instruct",
prose: "Let me look that up. ",
call: r#"<tool_call>{"name": "get_weather", "arguments": {"city": "Rome", "days": 3}}</tool_call>"#,
near_miss: r#"<tool_call>{"name": "get_weather", "arguments": {"days""#,
}),
ToolCallFormat::Llama3 => Some(Sample {
model: "llama-3.1-8b-instruct",
prose: "Let me look that up. ",
call: r#"<|python_tag|>{"name": "get_weather", "arguments": {"city": "Rome", "days": 3}}"#,
near_miss: r#"<|python_tag|>{"name": "get_weather", "arguments": {"days""#,
}),
ToolCallFormat::Mistral => Some(Sample {
model: "mistral-7b-instruct",
prose: "Let me look that up. ",
call: r#"[TOOL_CALLS] [{"name": "get_weather", "arguments": {"city": "Rome", "days": 3}}]"#,
near_miss: r#"[TOOL_CALLS] {"name""#,
}),
ToolCallFormat::Qwen3Coder => Some(Sample {
model: "qwen3-coder-30b",
prose: "Let me look that up. ",
call: "<tool_call>\n<function=get_weather>\n\
<parameter=city>\nRome\n</parameter>\n\
<parameter=days>\n3\n</parameter>\n\
</function>\n</tool_call>",
near_miss: "<tool_call>\n<function=get_weather>\n<parameter=days>",
}),
ToolCallFormat::Glm47 => Some(Sample {
model: "glm-4.7",
prose: "Let me look that up. ",
call: "<tool_call>get_weather\n\
<arg_key>city</arg_key><arg_value>Rome</arg_value>\n\
<arg_key>days</arg_key><arg_value>3</arg_value>\n\
</tool_call>",
near_miss: "<tool_call>get_weather\n<arg_key>days</arg_key>",
}),
ToolCallFormat::MiniMax => Some(Sample {
model: "minimax-m2",
prose: "Let me look that up.</think>",
call: "<minimax:tool_call><invoke name=\"get_weather\">\
<parameter name=\"city\">Rome</parameter>\
<parameter name=\"days\">3</parameter>\
</invoke></minimax:tool_call>",
near_miss: "<minimax:tool_call><invoke name=\"get_weather\">\
<parameter name=\"days\"",
}),
ToolCallFormat::DeepSeekV32 => Some(Sample {
model: "deepseek-v3.2",
prose: "Let me look that up. ",
call: "<|DSML|function_calls><|DSML|invoke name=\"get_weather\">\
<|DSML|parameter name=\"city\">Rome</|DSML|parameter>\
<|DSML|parameter name=\"days\">3</|DSML|parameter>\
</|DSML|invoke></|DSML|function_calls>",
near_miss: "<|DSML|function_calls><|DSML|invoke name=\"get_weather\">\
<|DSML|parameter name=\"days\"",
}),
ToolCallFormat::GptOss => Some(Sample {
model: "gpt-oss-20b",
prose: "Let me look that up. ",
call: "<|channel|>commentary to=functions.get_weather <|constrain|>json\
<|message|>{\"city\": \"Rome\", \"days\": 3}<|call|>",
near_miss: "<|channel|>commentary to=functions.get_weather<|message|>{\"days\"",
}),
ToolCallFormat::Gemma4 | ToolCallFormat::MiniMaxM3 | ToolCallFormat::MuseGlimmer => None,
}
}
const EVERY_FORMAT: [ToolCallFormat; 11] = [
ToolCallFormat::Qwen25,
ToolCallFormat::Llama3,
ToolCallFormat::Mistral,
ToolCallFormat::Qwen3Coder,
ToolCallFormat::Glm47,
ToolCallFormat::DeepSeekV32,
ToolCallFormat::MiniMax,
ToolCallFormat::MiniMaxM3,
ToolCallFormat::GptOss,
ToolCallFormat::Gemma4,
ToolCallFormat::MuseGlimmer,
];
fn tool_defs() -> Vec<ToolDef> {
["get_weather", "send_mail"]
.iter()
.map(|name| ToolDef {
kind: "function".to_string(),
function: ToolFunctionDef {
name: (*name).to_string(),
description: None,
parameters: Some(weather_schema()),
},
})
.collect()
}
#[test]
fn every_format_either_forces_a_call_this_server_reads_back_or_refuses_by_name() {
let schema = weather_schema();
let offered = [
ToolSpec {
name: "get_weather",
parameters: Some(&schema),
},
ToolSpec {
name: "send_mail",
parameters: Some(&schema),
},
];
let defs = tool_defs();
for format in EVERY_FORMAT {
let Some(sample) = sample(format) else {
let (status, axum::Json(body)) = build(Forced::Any, &offered, format)
.expect_err("this format has no root rule and must refuse");
assert_eq!(
status,
axum::http::StatusCode::NOT_IMPLEMENTED,
"{format:?}"
);
let message = body["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains(format.as_str()),
"a refusal must name the format: {message}"
);
continue;
};
assert_eq!(
ToolCallFormat::infer(sample.model),
format,
"the sample's model name must resolve to its own format"
);
let grammar = build(Forced::Any, &offered, format)
.unwrap_or_else(|e| panic!("{format:?} should build a grammar: {e:?}"));
assert!(
feed(&grammar, &[sample.prose, sample.call])
.unwrap_or_else(|e| panic!("{format:?} should accept its own call: {e}")),
"{format:?}: the parse must be complete once the call is written"
);
let native = ToolCallParser::new(
format,
vec![ToolSchema::with_parameters("get_weather", schema.clone())],
);
let (_, calls) = native.parse_complete(sample.call);
assert_eq!(calls.len(), 1, "{format:?} should read back one call");
assert_eq!(calls[0].name, "get_weather", "{format:?}");
assert_eq!(calls[0].arguments, EXPECTED_ARGUMENTS, "{format:?}");
let parsed = parse_output(
&format!("{}{}", sample.prose, sample.call),
&defs,
OutputPosture::for_model(sample.model),
);
assert_eq!(parsed.calls.len(), 1, "{format:?} through parse_output");
assert_eq!(parsed.calls[0].name, "get_weather", "{format:?}");
assert_eq!(parsed.calls[0].arguments, EXPECTED_ARGUMENTS, "{format:?}");
assert!(
feed(&grammar, &[sample.near_miss]).is_err(),
"{format:?}: {:?} must not be a legal forced call",
sample.near_miss
);
}
}
#[test]
fn a_forced_choice_constrains_every_format_it_is_served_for() {
let schema = weather_schema();
let offered = [
ToolSpec {
name: "get_weather",
parameters: Some(&schema),
},
ToolSpec {
name: "send_mail",
parameters: Some(&schema),
},
];
for format in EVERY_FORMAT {
let Some(sample) = sample(format) else {
continue;
};
let any = build(Forced::Any, &offered, format).expect("a grammar");
assert!(any.is_awaiting_trigger(), "{format:?} must be lazy");
assert!(!any.allows_eog(), "{format:?}: nothing has been called yet");
let mut prose = (*any).clone();
prose
.accept_token(0, b"I do not think a tool is needed here.")
.expect("prose before the trigger is free");
assert!(
!prose.allows_eog(),
"{format:?}: prose must not be allowed to finish a forced turn"
);
let named = build(Forced::Named("send_mail"), &offered, format).expect("a grammar");
assert!(
feed(&named, &[sample.call]).is_err(),
"{format:?}: a named tool_choice must make every other tool unreachable"
);
}
}
#[test]
fn an_element_format_refuses_an_argument_it_cannot_type() {
let untyped = serde_json::json!({
"type": "object",
"properties": {"city": {"description": "where"}},
"required": ["city"],
});
let offered = [ToolSpec {
name: "get_weather",
parameters: Some(&untyped),
}];
for format in [ToolCallFormat::Qwen3Coder, ToolCallFormat::MiniMax] {
let (status, axum::Json(body)) = build(Forced::Any, &offered, format)
.expect_err("an untyped argument has no value rule");
assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{format:?}");
assert!(
body["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("city"),
"the refusal must name the argument: {body}"
);
}
}
#[test]
fn an_enum_argument_is_written_as_its_member_and_not_as_json() {
let schema = serde_json::json!({
"type": "object",
"properties": {"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}},
"required": ["unit"],
});
let offered = [ToolSpec {
name: "get_weather",
parameters: Some(&schema),
}];
let grammar = build(Forced::Any, &offered, ToolCallFormat::MiniMax).expect("a grammar");
let call = "<minimax:tool_call><invoke name=\"get_weather\">\
<parameter name=\"unit\">celsius</parameter>\
</invoke></minimax:tool_call>";
assert!(feed(&grammar, &[call]).expect("the member is legal"));
assert!(
feed(
&grammar,
&["<minimax:tool_call><invoke name=\"get_weather\">\
<parameter name=\"unit\">kelvin"]
)
.is_err(),
"a member the enum does not list must be unreachable"
);
let parser = ToolCallParser::new(
ToolCallFormat::MiniMax,
vec![ToolSchema::with_parameters("get_weather", schema)],
);
let (_, calls) = parser.parse_complete(call);
assert_eq!(calls[0].arguments, r#"{"unit":"celsius"}"#);
}
#[test]
fn an_argument_may_hold_markup_that_is_not_its_closing_tag() {
let schema = serde_json::json!({
"type": "object",
"properties": {"patch": {"type": "string"}},
"required": ["patch"],
});
let offered = [ToolSpec {
name: "write_file",
parameters: Some(&schema),
}];
let grammar = build(Forced::Any, &offered, ToolCallFormat::Qwen3Coder).expect("a grammar");
let call = "<tool_call>\n<function=write_file>\n<parameter=patch>\n\
<html><body>a < b && c > d</body></html>\n\
</parameter>\n</function>\n</tool_call>";
assert!(feed(&grammar, &[call]).expect("markup is legal in a value"));
let parser = ToolCallParser::new(
ToolCallFormat::Qwen3Coder,
vec![ToolSchema::with_parameters("write_file", schema)],
);
let (_, calls) = parser.parse_complete(call);
assert_eq!(
calls[0].arguments,
r#"{"patch":"<html><body>a < b && c > d</body></html>"}"#
);
}
#[test]
fn a_json_argument_may_sit_on_its_own_line_and_still_arrive_typed() {
let schema = weather_schema();
let offered = [ToolSpec {
name: "get_weather",
parameters: Some(&schema),
}];
let grammar = build(Forced::Any, &offered, ToolCallFormat::DeepSeekV32).expect("a grammar");
let call = "<|DSML|function_calls><|DSML|invoke name=\"get_weather\">\
<|DSML|parameter name=\"city\">Rome</|DSML|parameter>\
<|DSML|parameter name=\"days\">\n3\n</|DSML|parameter>\
</|DSML|invoke></|DSML|function_calls>";
assert!(feed(&grammar, &[call]).expect("a newline around a JSON value is legal"));
let parser = ToolCallParser::new(
ToolCallFormat::DeepSeekV32,
vec![ToolSchema::with_parameters("get_weather", schema)],
);
let (_, calls) = parser.parse_complete(call);
assert_eq!(calls[0].arguments, EXPECTED_ARGUMENTS);
}
#[test]
fn the_harmony_trigger_does_not_fire_on_a_reasoning_channel() {
let schema = weather_schema();
let offered = [ToolSpec {
name: "get_weather",
parameters: Some(&schema),
}];
let grammar = build(Forced::Any, &offered, ToolCallFormat::GptOss).expect("a grammar");
let mut g = (*grammar).clone();
g.accept_token(
0,
"<|channel|>analysis<|message|>The user wants weather.<|end|>".as_bytes(),
)
.expect("an analysis channel must stay unconstrained");
assert!(
g.is_awaiting_trigger(),
"a reasoning channel must not switch the grammar on"
);
assert!(
!g.allows_eog(),
"and the turn still may not end without a call"
);
g.accept_token(
1,
"<|start|>assistant<|channel|>commentary to=functions.get_weather\
<|message|>{\"city\": \"Rome\"}<|call|>"
.as_bytes(),
)
.expect("the call that follows is legal");
assert!(g.allows_eog(), "the call completes the turn");
}