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::{Markers, NameStyle, TagGrammar};
use crate::policy::parser::ToolCallFormat;
use crate::tool_grammar::exclude::text_excluding;
use crate::tool_grammar::{escape, internal, invalid, schema_refused, ToolSpec};
use crate::ApiError;
pub(super) 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 forbidden = value_forbidden(param);
let text = text_excluding(builder, "arg-text", &forbidden)?;
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, &value_forbidden(param))? {
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, close) = param_tags(param, key);
let body = format!(r#""{}" {value} "{}""#, escape(&head), escape(&close));
Ok(builder.add_rule(&format!("tool-{}-param-{key}", tool.name), &body))
}
fn param_tags(param: TagGrammar, key: &str) -> (String, String) {
let close = param.close.to_string();
match param.name {
NameStyle::Bare => (format!("{}{key}>", param.open), close),
NameStyle::Attribute => (format!("{} name=\"{key}\">", param.open), close),
NameStyle::Paired {
key_close,
value_open,
} => (format!("{}{key}{key_close}{value_open}", param.open), close),
NameStyle::Element => (
format!("{}{key}>", param.open),
format!("{}{key}>", param.close),
),
}
}
fn value_forbidden(param: TagGrammar) -> [&'static str; 1] {
match param.name {
NameStyle::Bare | NameStyle::Attribute | NameStyle::Paired { .. } => [param.close],
NameStyle::Element => [param.open],
}
}
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,
forbidden: &[&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 forbidden.iter().any(|literal| member.contains(literal)) {
return Err(untyped(
tool,
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)));
}
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 { .. } | NameStyle::Element => 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))),
}
}