use std::collections::HashMap;
use metrale_grammar::CompiledGrammar;
use crate::tool_parser::ToolDefinition;
use super::engine::{GrammarEngine, GrammarError};
use super::schema::{enforce_min_length_on_required_strings, sanitize_schema_for_grammar};
mod native_tags;
mod xml_param;
fn ebnf_class_escape(c: char) -> String {
match c {
']' | '\\' | '^' | '-' => format!("\\{c}"),
_ => c.to_string(),
}
}
fn ebnf_literal_escape(c: char) -> String {
match c {
'"' | '\\' => format!("\\{c}"),
_ => c.to_string(),
}
}
fn ebnf_until_close_ladder_opts(close: &str, force_close: bool) -> String {
let chars: Vec<char> = close.chars().collect();
debug_assert!(!chars.is_empty(), "close delimiter must be non-empty");
let mut alts: Vec<String> = Vec::with_capacity(chars.len().max(1));
let depth = if force_close && chars.len() > 1 {
chars.len() - 1
} else {
chars.len()
};
for k in 0..depth {
let neg = ebnf_class_escape(chars[k]);
if k == 0 {
alts.push(format!("[^{neg}]"));
} else {
let prefix: String = chars[..k]
.iter()
.copied()
.map(ebnf_literal_escape)
.collect();
alts.push(format!("\"{prefix}\" [^{neg}]"));
}
}
if alts.is_empty() {
return "[^\\x00]".to_string();
}
alts.join(" | ")
}
fn ebnf_until_close_ladder(close: &str) -> String {
ebnf_until_close_ladder_opts(close, false)
}
fn ebnf_until_close_ladder_no_leading_ws(close: &str) -> String {
let chars: Vec<char> = close.chars().collect();
debug_assert!(!chars.is_empty(), "close delimiter must be non-empty");
let mut alts: Vec<String> = Vec::with_capacity(chars.len().max(1));
for (k, &ch) in chars.iter().enumerate() {
let neg = ebnf_class_escape(ch);
if k == 0 {
alts.push(format!("[^ \\t\\r\\n{neg}]"));
} else {
let prefix: String = chars[..k]
.iter()
.copied()
.map(ebnf_literal_escape)
.collect();
alts.push(format!("\"{prefix}\" [^{neg}]"));
}
}
if alts.is_empty() {
return "[^ \\t\\r\\n\\x00]".to_string();
}
alts.join(" | ")
}
fn grammar_allow_empty_value() -> bool {
std::env::var("METRALE_GRAMMAR_ALLOW_EMPTY_VALUE").as_deref() == Ok("1")
}
fn grammar_force_close() -> bool {
std::env::var("METRALE_GRAMMAR_FORCE_CLOSE").as_deref() == Ok("1")
}
const VALUE_REST_MAX_REPEAT: u32 = 6000;
fn value_harden_enabled() -> bool {
std::env::var("METRALE_GRAMMAR_VALUE_HARDEN").as_deref() == Ok("1")
}
fn short_tool_trigger_enabled() -> bool {
std::env::var("METRALE_TOOL_SHORT_TRIGGER").as_deref() == Ok("1")
}
pub(crate) fn xml_param_value_body_ebnf(
value_close: &str,
param_names: Option<&[String]>,
) -> String {
xml_param_value_body_ebnf_opts(
value_close,
param_names,
grammar_allow_empty_value(),
grammar_force_close(),
)
}
pub(crate) fn xml_param_value_body_ebnf_opts(
value_close: &str,
param_names: Option<&[String]>,
allow_empty_value: bool,
force_close: bool,
) -> String {
let ladder = ebnf_until_close_ladder_opts(value_close, force_close);
let rest_rule = if value_harden_enabled() {
format!("rest ::= rest_part{{0,{VALUE_REST_MAX_REPEAT}}}")
} else {
"rest ::= rest_part*".to_string()
};
let paramname_rule = match param_names {
Some(names) if !names.is_empty() => {
let alts: Vec<String> = names
.iter()
.map(|n| serde_json::to_string(n).unwrap_or_else(|_| "\"\"".into()))
.collect();
format!("paramname ::= {}", alts.join(" | "))
}
_ => "paramname ::= [a-zA-Z_] [a-zA-Z_0-9]*".to_string(),
};
let value_rule = if allow_empty_value {
"value ::= leading_ws nonempty_value?"
} else {
"value ::= leading_ws nonempty_value"
};
format!(
r#"root ::= param ("\n" param)*
param ::= "<parameter=" paramname ">" value "{value_close}"
{paramname_rule}
{value_rule}
nonempty_value ::= first_content rest
leading_ws ::= [ \t\r\n]*
first_content ::= [^ \t\r\n<=>] | {first_lt_arms}
{rest_rule}
rest_part ::= {ladder}
"#,
first_lt_arms = ladder_lt_arms(&ladder),
)
}
fn ladder_lt_arms(ladder: &str) -> String {
let arms: Vec<&str> = ladder
.split(" | ")
.filter(|arm| arm.starts_with('"'))
.collect();
if arms.is_empty() {
return "[^ \\t\\r\\n<=>]".to_string();
}
arms.join(" | ")
}
pub(crate) fn schema_param_names(schema: &serde_json::Value) -> Option<Vec<String>> {
let props = schema.get("properties")?.as_object()?;
if props.is_empty() {
return None;
}
match schema.get("additionalProperties") {
None | Some(serde_json::Value::Bool(false)) => {}
Some(_) => return None,
}
Some(props.keys().cloned().collect())
}
impl GrammarEngine {
pub fn compile_hermes_tool_grammar(
&mut self,
tools: &[ToolDefinition],
use_triggers: bool,
) -> Result<CompiledGrammar, GrammarError> {
if tools.is_empty() {
return Err(GrammarError::NoTools);
}
let mut tag_entries = Vec::with_capacity(tools.len());
let mut triggers = Vec::new();
let mut seen_triggers = HashMap::<String, bool>::new();
for tool in tools {
let name = &tool.function.name;
let raw_schema = tool
.function
.parameters
.as_ref()
.cloned()
.unwrap_or_else(|| serde_json::json!({"type":"object","properties":{}}));
let raw_schema = match sanitize_schema_for_grammar(&raw_schema) {
Some(s) => s,
None => {
tracing::warn!("Skipping tool '{name}' in grammar — schema unsanitizable");
continue;
}
};
if raw_schema.get("properties").is_none() && raw_schema.get("type").is_none() {
tracing::warn!(
"Skipping tool '{name}' in grammar — schema has no properties or type"
);
continue;
}
let schema = enforce_min_length_on_required_strings(&raw_schema);
let begin = format!("<tool_call>\n{{\"name\": \"{name}\", \"arguments\": ");
tag_entries.push(serde_json::json!({
"type": "tag",
"begin": begin,
"content": {"type": "json_schema", "json_schema": schema},
"end": "}\n</tool_call>",
}));
let trigger = "<tool_call>".to_string();
if !seen_triggers.contains_key(&trigger) {
seen_triggers.insert(trigger.clone(), true);
triggers.push(trigger);
}
}
if tag_entries.is_empty() {
return Err(GrammarError::NoTools);
}
let at_least_one = !use_triggers;
let stop_after_first = !use_triggers;
self.compile_structural_tag_raw(&triggers, &tag_entries, at_least_one, stop_after_first)
}
pub fn compile_bare_json_tool_grammar(
&mut self,
tools: &[ToolDefinition],
use_triggers: bool,
) -> Result<CompiledGrammar, GrammarError> {
if tools.is_empty() {
return Err(GrammarError::NoTools);
}
let mut tag_entries = Vec::with_capacity(tools.len());
let mut triggers = Vec::new();
let mut seen_triggers = HashMap::<String, bool>::new();
for tool in tools {
let name = &tool.function.name;
let raw_schema = tool
.function
.parameters
.as_ref()
.cloned()
.unwrap_or_else(|| serde_json::json!({"type":"object","properties":{}}));
let raw_schema = match sanitize_schema_for_grammar(&raw_schema) {
Some(s) => s,
None => {
tracing::warn!("Skipping tool '{name}' in grammar — schema unsanitizable");
continue;
}
};
if raw_schema.get("properties").is_none() && raw_schema.get("type").is_none() {
tracing::warn!(
"Skipping tool '{name}' in grammar — schema has no properties or type"
);
continue;
}
let schema = enforce_min_length_on_required_strings(&raw_schema);
let begin = format!(r#"{{"name":"{name}","arguments":"#);
tag_entries.push(serde_json::json!({
"type": "tag",
"begin": begin,
"content": {"type": "json_schema", "json_schema": schema},
"end": "}",
}));
}
let trigger = r#"{"name":""#.to_string();
if !seen_triggers.contains_key(&trigger) {
seen_triggers.insert(trigger.clone(), true);
triggers.push(trigger);
}
if tag_entries.is_empty() {
return Err(GrammarError::NoTools);
}
let at_least_one = !use_triggers;
let stop_after_first = !use_triggers;
self.compile_structural_tag_raw(&triggers, &tag_entries, at_least_one, stop_after_first)
}
}