use serde_json::Value;
use ferrox_models::grammar::json_schema::GrammarBuilder;
use ferrox_models::grammar::LazyTriggers;
use super::exclude::text_excluding;
use super::{escape, internal, invalid, schema_refused, unsupported, ToolSpec};
use crate::policy::parser::tool_call::{harmony, Markers, NameStyle, TagGrammar};
use crate::policy::parser::ToolCallFormat;
use crate::ApiError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Shape {
Json { array: bool },
Elements,
Harmony,
}
fn shape(format: ToolCallFormat) -> Result<Shape, ApiError> {
match format {
ToolCallFormat::Qwen25 | ToolCallFormat::Llama3 => Ok(Shape::Json { array: false }),
ToolCallFormat::Mistral => Ok(Shape::Json { array: true }),
ToolCallFormat::Qwen3Coder
| ToolCallFormat::Glm47
| ToolCallFormat::MiniMax
| ToolCallFormat::DeepSeekV32 => Ok(Shape::Elements),
ToolCallFormat::GptOss => Ok(Shape::Harmony),
ToolCallFormat::Gemma4 => Err(refused(
format,
"a gemma4 call's arguments are a comma-separated list in gemma's own quoting rather \
than a JSON object, so which of them are required cannot be expressed by the object \
rule every other format here shares; writing a second one beside the JSON Schema \
converter is the drift this refusal exists to avoid",
)),
ToolCallFormat::MiniMaxM3 => Err(refused(
format,
"a minimax_m3 call names each argument with an ELEMENT of its own, and what a \
repeated element means -- an array rather than a value -- depends on siblings that \
have not been written yet, so no root rule can force a call whose arguments this \
server would read back the way the schema declares them",
)),
ToolCallFormat::MuseGlimmer => Err(refused(
format,
"a muse_glimmer call's boundary is not syntactic: the same <atem:function_calls> \
block is a call inside a channel addressed to a tool and prose inside one addressed \
to the user, so a grammar over the block alone would force text this server reads \
back as content",
)),
}
}
pub(super) fn build_root(
builder: &mut GrammarBuilder,
format: ToolCallFormat,
tools: &[ToolSpec<'_>],
) -> Result<(String, LazyTriggers), ApiError> {
match shape(format)? {
Shape::Json { array } => json_root(builder, format, tools, array),
Shape::Elements => elements_root(builder, format, tools),
Shape::Harmony => harmony_root(builder, tools),
}
}
fn json_root(
builder: &mut GrammarBuilder,
format: ToolCallFormat,
tools: &[ToolSpec<'_>],
array: bool,
) -> Result<(String, LazyTriggers), ApiError> {
let markers = format.markers();
let mut alternatives = Vec::with_capacity(tools.len());
for tool in tools {
let args = builder
.add_schema_value(&format!("tool-{}-args", tool.name), parameters(tool))
.map_err(|e| schema_refused(tool.name, &e))?;
let body = format!(
r#""{{" space "\"name\"" space ":" space "\"{name}\"" space "," space "\"arguments\"" space ":" space {args} space "}}""#,
name = tool.name,
);
alternatives.push(builder.add_rule(&format!("tool-{}-call", tool.name), &body));
}
let call = builder.add_rule("tool-call", &alternatives.join(" | "));
let payload = if array {
builder.add_rule("tool-call-list", &format!(r#""[" space {call} space "]""#))
} else {
call
};
Ok((
block(
markers.open,
&format!("space {payload} space"),
markers.close,
),
trigger(markers.open)?,
))
}
fn elements_root(
builder: &mut GrammarBuilder,
format: ToolCallFormat,
tools: &[ToolSpec<'_>],
) -> Result<(String, LazyTriggers), ApiError> {
let Markers {
open,
close,
invoke,
param,
trim_newlines: _,
undeclared: _,
} = format.markers();
let Some(param) = param else {
return Err(internal(format!(
"{} was given the element shape but its framing declares no parameter tag",
format.as_str()
)));
};
let text = text_excluding(builder, "arg-text", param.close)?;
let mut alternatives = Vec::with_capacity(tools.len());
for tool in tools {
let mut body = invoke_open(invoke, tool.name)?;
for arg in element_args(builder, param, tool, &text)? {
body.push_str(" space ");
body.push_str(&arg);
}
if let Some(tag) = invoke {
body.push_str(&format!(r#" space "{}""#, escape(tag.close)));
}
alternatives.push(builder.add_rule(&format!("tool-{}-call", tool.name), &body));
}
let call = builder.add_rule("tool-call", &alternatives.join(" | "));
let lead = if invoke.is_some() { "space " } else { "" };
Ok((
block(open, &format!("{lead}{call} space"), close),
trigger(open)?,
))
}
fn element_args(
builder: &mut GrammarBuilder,
param: TagGrammar,
tool: &ToolSpec<'_>,
text: &str,
) -> Result<Vec<String>, ApiError> {
let schema = parameters(tool);
let Some(object) = schema.as_object() else {
return Err(object_expected(tool.name));
};
match object.get("type").and_then(Value::as_str) {
Some("object") | None => {}
Some(_) => return Err(object_expected(tool.name)),
}
let properties = match object.get("properties") {
None => return Ok(Vec::new()),
Some(Value::Object(map)) => map,
Some(_) => return Err(object_expected(tool.name)),
};
let required: Vec<&str> = object
.get("required")
.and_then(Value::as_array)
.map(|names| names.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let mut args = Vec::new();
for key in required.iter().copied() {
let Some(property) = properties.get(key) else {
return Err(invalid(
format!(
"tool {:?} cannot be forced: it requires the argument {key:?}, which its \
\"parameters\" schema does not declare",
tool.name
),
"tools",
));
};
args.push(param_rule(builder, param, tool, key, property, text)?);
}
for (key, property) in properties {
if required.contains(&key.as_str()) {
continue;
}
let rule = param_rule(builder, param, tool, key, property, text)?;
args.push(format!("{rule}?"));
}
Ok(args)
}
fn param_rule(
builder: &mut GrammarBuilder,
param: TagGrammar,
tool: &ToolSpec<'_>,
key: &str,
property: &Value,
text: &str,
) -> Result<String, ApiError> {
check_key(tool.name, key)?;
let value = match value_shape(tool.name, key, property, param.close)? {
ValueShape::Text => text.to_string(),
ValueShape::Literals(body) => {
builder.add_rule(&format!("tool-{}-enum-{key}", tool.name), &body)
}
ValueShape::Json => format!(
"space {} space",
builder
.add_schema_value(&format!("tool-{}-arg-{key}", tool.name), property)
.map_err(|e| schema_refused(tool.name, &e))?
),
};
let head = match param.name {
NameStyle::Bare => format!(r#""{}{}>""#, escape(param.open), escape(key)),
NameStyle::Attribute => format!(r#""{} name=\"{}\">""#, escape(param.open), escape(key)),
NameStyle::Paired {
key_close,
value_open,
} => format!(
r#""{}{}{}{}""#,
escape(param.open),
escape(key),
escape(key_close),
escape(value_open)
),
};
let body = format!(r#"{head} {value} "{}""#, escape(param.close));
Ok(builder.add_rule(&format!("tool-{}-param-{key}", tool.name), &body))
}
enum ValueShape {
Text,
Literals(String),
Json,
}
const ANNOTATIONS: [&str; 10] = [
"title",
"description",
"default",
"examples",
"$schema",
"$id",
"$comment",
"deprecated",
"readOnly",
"writeOnly",
];
fn value_shape(
tool: &str,
key: &str,
property: &Value,
param_close: &str,
) -> Result<ValueShape, ApiError> {
let Some(object) = property.as_object() else {
return Err(untyped(tool, key, "it is not a schema object"));
};
let declared = object.get("type").and_then(Value::as_str);
let Some(declared) = declared else {
return Err(untyped(
tool,
key,
"it declares no \"type\", and this server would have to GUESS whether the text the \
model writes there is a string, a number or JSON",
));
};
if declared != "string" {
return Ok(ValueShape::Json);
}
if let Some(members) = object.get("enum").or_else(|| object.get("const")) {
let members = match members {
Value::Array(members) => members.clone(),
single => vec![single.clone()],
};
if members.is_empty() {
return Err(untyped(tool, key, "its \"enum\" lists no members"));
}
let mut alternatives = Vec::with_capacity(members.len());
for member in &members {
let Some(member) = member.as_str() else {
return Err(untyped(
tool,
key,
"it is a string whose \"enum\" holds a member that is not a string",
));
};
if member.contains(param_close) {
return Err(untyped(
tool,
key,
"one of its \"enum\" members contains the tag that ends an argument, so \
writing it would end the argument early",
));
}
alternatives.push(format!("\"{}\"", escape(member)));
}
return Ok(ValueShape::Literals(alternatives.join(" | ")));
}
for keyword in object.keys() {
if keyword == "type" || ANNOTATIONS.contains(&keyword.as_str()) {
continue;
}
return Err(untyped(
tool,
key,
&format!(
"it is a string carrying {keyword:?}, which this server cannot honour in a value \
that is written as bare text rather than as JSON"
),
));
}
Ok(ValueShape::Text)
}
fn invoke_open(invoke: Option<TagGrammar>, name: &str) -> Result<String, ApiError> {
match invoke {
Some(tag) => match tag.name {
NameStyle::Bare => Ok(format!(r#""{}{}>""#, escape(tag.open), escape(name))),
NameStyle::Attribute => Ok(format!(
r#""{} name=\"{}\">""#,
escape(tag.open),
escape(name)
)),
NameStyle::Paired { .. } => Err(internal(format!(
"the invoke tag {:?} is named the way a parameter is, which has no reader",
tag.open
))),
},
None => Ok(format!(r#""{}\n""#, escape(name))),
}
}
fn harmony_root(
builder: &mut GrammarBuilder,
tools: &[ToolSpec<'_>],
) -> Result<(String, LazyTriggers), ApiError> {
let constrain = builder.add_rule(
"harmony-constrain",
&format!(r#"| " {}json""#, escape(harmony::CONSTRAIN)),
);
let channel = builder.add_rule(
"harmony-channel",
&harmony::CHANNELS
.iter()
.map(|name| format!("\"{}\"", escape(name)))
.collect::<Vec<_>>()
.join(" | "),
);
let mut alternatives = Vec::with_capacity(tools.len());
for tool in tools {
let schema = parameters(tool);
match schema.get("type").and_then(Value::as_str) {
Some("object") | None => {}
Some(_) => return Err(object_expected(tool.name)),
}
let args = builder
.add_schema_value(&format!("tool-{}-args", tool.name), schema)
.map_err(|e| schema_refused(tool.name, &e))?;
let body = format!(
r#""{name}" {constrain} "{message}" {args} "{call}""#,
name = escape(tool.name),
message = escape(harmony::MESSAGE_OPEN),
call = escape(harmony::CALL_CLOSE),
);
alternatives.push(builder.add_rule(&format!("tool-{}-call", tool.name), &body));
}
let call = builder.add_rule("tool-call", &alternatives.join(" | "));
let root = format!(
r#""{open}" {channel} " {key}{namespace}" {call}"#,
open = escape(harmony::CHANNEL_OPEN),
key = escape(harmony::RECIPIENT_KEY),
namespace = escape(harmony::FUNCTION_NAMESPACE),
);
let mut triggers = LazyTriggers::new().mandatory();
for name in harmony::CHANNELS {
triggers = triggers
.with_word(&format!(
"{}{name} {}",
harmony::CHANNEL_OPEN,
harmony::RECIPIENT_KEY
))
.map_err(|e| internal(format!("tool-call trigger does not compile: {e}")))?;
}
Ok((root, triggers))
}
fn block(open: &str, body: &str, close: &str) -> String {
let mut root = format!(r#""{}" {body}"#, escape(open));
if !close.is_empty() {
root.push_str(&format!(r#" "{}""#, escape(close)));
}
root
}
fn trigger(open: &str) -> Result<LazyTriggers, ApiError> {
LazyTriggers::new()
.with_word(open)
.map_err(|e| internal(format!("tool-call trigger does not compile: {e}")))
.map(LazyTriggers::mandatory)
}
fn parameters<'a>(tool: &ToolSpec<'a>) -> &'a Value {
static EMPTY: std::sync::OnceLock<Value> = std::sync::OnceLock::new();
tool.parameters.unwrap_or_else(|| {
EMPTY.get_or_init(|| {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false,
})
})
})
}
fn check_key(tool: &str, key: &str) -> Result<(), ApiError> {
let ok = !key.is_empty()
&& key.len() <= 64
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.');
if ok {
return Ok(());
}
Err(invalid(
format!(
"tool {tool:?} cannot be forced: its argument {key:?} is written into the wire format \
as a bare name, and this server accepts only names of 1..=64 characters from \
[A-Za-z0-9_.-] there"
),
"tools",
))
}
fn object_expected(tool: &str) -> ApiError {
invalid(
format!(
"tool {tool:?} cannot be forced: this checkpoint's wire format writes a call's \
arguments as named members, so its \"parameters\" must be an object schema"
),
"tools",
)
}
fn untyped(tool: &str, key: &str, why: &str) -> ApiError {
invalid(
format!(
"tool {tool:?} cannot be forced: its argument {key:?} cannot be given a value \
rule, because {why}"
),
"tools",
)
}
fn refused(format: ToolCallFormat, why: &str) -> ApiError {
unsupported(format!(
"tool_choice cannot be enforced for a {} checkpoint: {why}. Use tool_choice \"auto\", \
which asks for a call in the prompt instead of forcing one.",
format.as_str()
))
}