use serde_json::Value;
use ferrox_models::grammar::json_schema::GrammarBuilder;
use ferrox_models::grammar::LazyTriggers;
use super::{block, check_key, object_expected, parameters, trigger, untyped};
use crate::policy::parser::tool_call::gemma;
use crate::policy::parser::ToolCallFormat;
use crate::tool_grammar::exclude::text_excluding;
use crate::tool_grammar::{escape, schema_refused, ToolSpec};
use crate::ApiError;
pub(super) fn pairs_root(
builder: &mut GrammarBuilder,
format: ToolCallFormat,
tools: &[ToolSpec<'_>],
) -> Result<(String, LazyTriggers), ApiError> {
let markers = format.markers();
let text = text_excluding(builder, "arg-text", &[gemma::QUOTE, markers.close])?;
let mut alternatives = Vec::with_capacity(tools.len());
for tool in tools {
let args = arg_list(builder, tool, &text)?;
let body = format!(
r#""{key}{name}{open}" {args} "{close}""#,
key = escape(gemma::CALL_KEY),
name = escape(tool.name),
open = escape(gemma::ARGS_OPEN),
close = escape(gemma::ARGS_CLOSE),
);
alternatives.push(builder.add_rule(&format!("tool-{}-call", tool.name), &body));
}
let call = builder.add_rule("tool-call", &alternatives.join(" | "));
Ok((
block(markers.open, &call, markers.close),
trigger(markers.open)?,
))
}
fn arg_list(
builder: &mut GrammarBuilder,
tool: &ToolSpec<'_>,
text: &str,
) -> Result<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(builder.add_rule(&format!("tool-{}-args", tool.name), r#""""#)),
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 pairs = Vec::with_capacity(properties.len());
for (key, property) in properties {
pairs.push((
pair_rule(builder, tool, key, property, text)?,
required.contains(&key.as_str()),
));
}
for name in &required {
if !properties.contains_key(*name) {
return Err(crate::tool_grammar::invalid(
format!(
"tool {:?} cannot be forced: it requires the argument {name:?}, which its \
\"parameters\" schema does not declare",
tool.name
),
"tools",
));
}
}
let mut rest = String::from(r#""""#);
let mut first = String::from(r#""""#);
for (index, (rule, is_required)) in pairs.iter().enumerate().rev() {
let separator = escape(gemma::PAIR_SEPARATOR);
let with_separator = format!(r#""{separator}" {rule} {rest}"#);
let without = format!("{rule} {rest}");
let (rest_body, first_body) = if *is_required {
(with_separator, without)
} else {
(
format!("{with_separator} | {rest}"),
format!("{without} | {first}"),
)
};
rest = builder.add_rule(&format!("tool-{}-rest-{index}", tool.name), &rest_body);
first = builder.add_rule(&format!("tool-{}-from-{index}", tool.name), &first_body);
}
Ok(first)
}
fn pair_rule(
builder: &mut GrammarBuilder,
tool: &ToolSpec<'_>,
key: &str,
property: &Value,
text: &str,
) -> Result<String, ApiError> {
check_key(tool.name, key)?;
let value = value_rule(builder, tool, key, property, text)?;
let body = format!(
r#""{key}{separator}" {value}"#,
key = escape(key),
separator = escape(gemma::KEY_SEPARATOR),
);
Ok(builder.add_rule(&format!("tool-{}-pair-{key}", tool.name), &body))
}
fn value_rule(
builder: &mut GrammarBuilder,
tool: &ToolSpec<'_>,
key: &str,
property: &Value,
text: &str,
) -> Result<String, ApiError> {
let Some(object) = property.as_object() else {
return Err(untyped(tool.name, key, "it is not a schema object"));
};
let Some(declared) = object.get("type").and_then(Value::as_str) else {
return Err(untyped(
tool.name,
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",
));
};
let quote = escape(gemma::QUOTE);
match declared {
"string" => {
let inner = match object.get("enum").or_else(|| object.get("const")) {
None => text.to_string(),
Some(members) => {
let members = match members {
Value::Array(members) => members.clone(),
single => vec![single.clone()],
};
if members.is_empty() {
return Err(untyped(tool.name, 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.name,
key,
"it is a string whose \"enum\" holds a member that is not a string",
));
};
if member.contains(gemma::QUOTE) || member.contains(gemma::BLOCK_CLOSE) {
return Err(untyped(
tool.name,
key,
"one of its \"enum\" members contains the markup that ends an \
argument, so writing it would end the argument early",
));
}
alternatives.push(format!("\"{}\"", escape(member)));
}
builder.add_rule(
&format!("tool-{}-enum-{key}", tool.name),
&alternatives.join(" | "),
)
}
};
Ok(builder.add_rule(
&format!("tool-{}-arg-{key}", tool.name),
&format!(r#""{quote}" {inner} "{quote}""#),
))
}
"integer" | "number" | "boolean" | "null" => builder
.add_schema_value(&format!("tool-{}-arg-{key}", tool.name), property)
.map_err(|e| schema_refused(tool.name, &e)),
"object" | "array" => Err(untyped(
tool.name,
key,
&format!(
"it is declared {declared:?}, and gemma writes a composite value in its own DSL -- \
bare keys, and strings wrapped in gemma's quote -- while this server reads a \
value back with `serde_json`. So the spelling the checkpoint was trained to \
write is read back as a string, and the spelling that reads back correctly is \
not one this family emits"
),
)),
other => Err(untyped(
tool.name,
key,
&format!("this server does not map the declared type {other:?} onto gemma's syntax"),
)),
}
}