use anyhow::{Result, bail, ensure};
use serde_json::{Map, Value};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ToolDef {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default = "empty_params")]
pub parameters: Value,
}
fn empty_params() -> Value {
serde_json::json!({ "type": "object", "properties": {} })
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ToolCall {
pub name: String,
pub arguments: Value,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ToolChatMessage {
pub role: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
impl ToolChatMessage {
pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
ToolChatMessage {
role: role.into(),
content: content.into(),
tool_calls: None,
tool_call_id: None,
}
}
pub fn assistant_calls(content: impl Into<String>, calls: Vec<ToolCall>) -> Self {
ToolChatMessage {
role: "assistant".into(),
content: content.into(),
tool_calls: Some(calls),
tool_call_id: None,
}
}
pub fn tool_result(content: impl Into<String>, tool_call_id: Option<String>) -> Self {
ToolChatMessage {
role: "tool".into(),
content: content.into(),
tool_calls: None,
tool_call_id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolFormat {
Lfm2Pythonic,
Hermes,
}
impl ToolFormat {
pub fn detect(architecture: &str) -> Option<ToolFormat> {
match architecture {
"lfm2" | "lfm2moe" => Some(ToolFormat::Lfm2Pythonic),
"qwen2" | "qwen2.5" | "qwen3" | "qwen3moe" => Some(ToolFormat::Hermes),
_ => None,
}
}
pub fn call_start_marker(self) -> &'static str {
match self {
ToolFormat::Lfm2Pythonic => "<|tool_call_start|>",
ToolFormat::Hermes => "<tool_call>",
}
}
pub fn call_end_marker(self) -> &'static str {
match self {
ToolFormat::Lfm2Pythonic => "<|tool_call_end|>",
ToolFormat::Hermes => "</tool_call>",
}
}
}
pub fn parse_tool_calls(text: &str, format: ToolFormat) -> Result<Vec<ToolCall>> {
match format {
ToolFormat::Lfm2Pythonic => parse_lfm2_pythonic(text),
ToolFormat::Hermes => parse_hermes(text),
}
}
pub fn tool_grammar(tools: &[ToolDef], format: ToolFormat) -> Result<String> {
if tools.is_empty() {
bail!("tool_grammar: no tools provided");
}
match format {
ToolFormat::Lfm2Pythonic => {
for tool in tools {
ensure!(
is_py_ident(&tool.name),
"tool name `{}` is not a valid Python identifier; the LFM2 \
Pythonic format requires [A-Za-z_][A-Za-z0-9_]* names",
tool.name
);
for (name, _) in properties(tool) {
ensure!(
is_py_ident(&name),
"argument name `{name}` of tool `{}` is not a valid Python \
identifier; the LFM2 Pythonic format requires \
[A-Za-z_][A-Za-z0-9_]* names",
tool.name
);
}
}
Ok(lfm2_grammar(tools))
}
ToolFormat::Hermes => Ok(hermes_grammar(tools)),
}
}
fn is_py_ident(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn value_rules(pythonic: bool) -> String {
let (t, f, n) = if pythonic {
("\"True\"", "\"False\"", "\"None\"")
} else {
("\"true\"", "\"false\"", "\"null\"")
};
format!(
r#"tc-value ::= tc-str | tc-num | tc-bool | tc-null | tc-array | tc-object
tc-str ::= "\"" tc-char* "\""
tc-char ::= [^"\\\x00-\x1F] | "\\" tc-esc
tc-esc ::= ["\\/bfnrt] | "u" tc-hex tc-hex tc-hex tc-hex
tc-hex ::= [0-9a-fA-F]
tc-int ::= "-"? ("0" | [1-9] [0-9]*)
tc-num ::= tc-int ("." [0-9]+)? ([eE] [-+]? [0-9]+)?
tc-bool ::= {t} | {f}
tc-null ::= {n}
tc-array ::= "[" tc-ws ( tc-value ( tc-ws "," tc-ws tc-value )* )? tc-ws "]"
tc-object ::= "{{" tc-ws ( tc-str tc-ws ":" tc-ws tc-value ( tc-ws "," tc-ws tc-str tc-ws ":" tc-ws tc-value )* )? tc-ws "}}"
tc-ws ::= [ \t\n\r]*
"#,
)
}
fn json_str_gbnf(s: &str) -> String {
let json = serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""));
gbnf_lit(&json)
}
fn gbnf_lit(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\r' => out.push_str("\\r"),
c => out.push(c),
}
}
out.push('"');
out
}
fn enum_literal(v: &Value, pythonic: bool) -> Option<String> {
match v {
Value::String(s) => Some(json_str_gbnf(s)),
Value::Bool(b) => Some(gbnf_lit(match (b, pythonic) {
(true, true) => "True",
(false, true) => "False",
(true, false) => "true",
(false, false) => "false",
})),
Value::Number(n) => Some(gbnf_lit(&n.to_string())),
Value::Null => Some(gbnf_lit(if pythonic { "None" } else { "null" })),
_ => None,
}
}
fn value_for_schema(schema: &Value, pythonic: bool) -> String {
if let Some(Value::Array(variants)) = schema.get("enum")
&& !variants.is_empty()
{
let lits: Vec<Option<String>> =
variants.iter().map(|v| enum_literal(v, pythonic)).collect();
if lits.iter().all(Option::is_some) {
let joined = lits.into_iter().flatten().collect::<Vec<_>>().join(" | ");
return format!("( {joined} )");
}
}
let ty = schema.get("type").and_then(|t| t.as_str());
match ty {
Some("string") => "tc-str".into(),
Some("integer") => "tc-int".into(),
Some("number") => "tc-num".into(),
Some("boolean") => "tc-bool".into(),
Some("array") => "tc-array".into(),
Some("object") => "tc-object".into(),
Some("null") => "tc-null".into(),
_ => "tc-value".into(),
}
}
fn properties(tool: &ToolDef) -> Vec<(String, Value)> {
tool.parameters
.get("properties")
.and_then(|p| p.as_object())
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default()
}
fn lfm2_grammar(tools: &[ToolDef]) -> String {
let mut out = String::new();
out.push_str(
"root ::= tc-ws \"[\" tc-ws tc-call ( tc-ws \",\" tc-ws tc-call )* tc-ws \"]\" tc-ws\n",
);
let call_alts: Vec<String> = (0..tools.len()).map(|i| format!("tc-call-{i}")).collect();
out.push_str(&format!("tc-call ::= {}\n", call_alts.join(" | ")));
for (i, tool) in tools.iter().enumerate() {
let props = properties(tool);
out.push_str(&format!(
"tc-call-{i} ::= {} tc-ws \"(\" tc-ws tc-args-{i} tc-ws \")\"\n",
gbnf_lit(&tool.name)
));
if props.is_empty() {
out.push_str(&format!("tc-args-{i} ::= \"\"\n"));
continue;
}
out.push_str(&format!(
"tc-args-{i} ::= ( tc-pair-{i} ( tc-ws \",\" tc-ws tc-pair-{i} )* )?\n"
));
let pair_alts: Vec<String> = (0..props.len())
.map(|j| format!("tc-pair-{i}-{j}"))
.collect();
out.push_str(&format!("tc-pair-{i} ::= {}\n", pair_alts.join(" | ")));
for (j, (name, schema)) in props.iter().enumerate() {
out.push_str(&format!(
"tc-pair-{i}-{j} ::= {} tc-ws \"=\" tc-ws {}\n",
gbnf_lit(name),
value_for_schema(schema, true)
));
}
}
out.push_str(&value_rules(true));
out
}
fn hermes_grammar(tools: &[ToolDef]) -> String {
let mut out = String::new();
out.push_str("root ::= tc-ws tc-call tc-ws\n");
let call_alts: Vec<String> = (0..tools.len()).map(|i| format!("tc-call-{i}")).collect();
out.push_str(&format!("tc-call ::= {}\n", call_alts.join(" | ")));
for (i, tool) in tools.iter().enumerate() {
let props = properties(tool);
out.push_str(&format!(
"tc-call-{i} ::= \"{{\" tc-ws \"\\\"name\\\"\" tc-ws \":\" tc-ws {} tc-ws \",\" tc-ws \"\\\"arguments\\\"\" tc-ws \":\" tc-ws tc-args-{i} tc-ws \"}}\"\n",
json_str_gbnf(&tool.name)
));
if props.is_empty() {
out.push_str(&format!("tc-args-{i} ::= \"{{\" tc-ws \"}}\"\n"));
continue;
}
out.push_str(&format!(
"tc-args-{i} ::= \"{{\" tc-ws ( tc-pair-{i} ( tc-ws \",\" tc-ws tc-pair-{i} )* )? tc-ws \"}}\"\n"
));
let pair_alts: Vec<String> = (0..props.len())
.map(|j| format!("tc-pair-{i}-{j}"))
.collect();
out.push_str(&format!("tc-pair-{i} ::= {}\n", pair_alts.join(" | ")));
for (j, (name, schema)) in props.iter().enumerate() {
out.push_str(&format!(
"tc-pair-{i}-{j} ::= {} tc-ws \":\" tc-ws {}\n",
json_str_gbnf(name),
value_for_schema(schema, false)
));
}
}
out.push_str(&value_rules(false));
out
}
fn parse_lfm2_pythonic(text: &str) -> Result<Vec<ToolCall>> {
let mut calls = Vec::new();
let mut rest = text;
let mut saw_marker = false;
while let Some(start) = rest.find("<|tool_call_start|>") {
saw_marker = true;
let after = &rest[start + "<|tool_call_start|>".len()..];
let (inner, next) = match after.find("<|tool_call_end|>") {
Some(end) => (&after[..end], &after[end + "<|tool_call_end|>".len()..]),
None => (after, ""),
};
parse_pythonic_section(inner.trim(), &mut calls)?;
rest = next;
}
if !saw_marker {
let trimmed = text.trim();
if trimmed.starts_with('[') {
let mut bare = Vec::new();
if parse_pythonic_section(trimmed, &mut bare).is_ok() {
calls.append(&mut bare);
}
}
}
Ok(calls)
}
fn parse_pythonic_section(inner: &str, calls: &mut Vec<ToolCall>) -> Result<()> {
let body = inner
.strip_prefix('[')
.map(|s| s.strip_suffix(']').unwrap_or(s))
.unwrap_or(inner)
.trim();
if body.is_empty() {
return Ok(());
}
let mut p = PyParser::new(body);
loop {
p.skip_ws();
if p.eof() {
break;
}
calls.push(p.parse_call()?);
p.skip_ws();
if p.peek() == Some(',') {
p.bump();
}
}
Ok(())
}
struct PyParser<'a> {
s: &'a [u8],
i: usize,
}
impl<'a> PyParser<'a> {
fn new(s: &'a str) -> Self {
PyParser {
s: s.as_bytes(),
i: 0,
}
}
fn eof(&self) -> bool {
self.i >= self.s.len()
}
fn peek(&self) -> Option<char> {
self.s.get(self.i).map(|&b| b as char)
}
fn bump(&mut self) -> Option<char> {
let c = self.peek();
if c.is_some() {
self.i += 1;
}
c
}
fn skip_ws(&mut self) {
while let Some(c) = self.peek() {
if c.is_ascii_whitespace() {
self.i += 1;
} else {
break;
}
}
}
fn parse_call(&mut self) -> Result<ToolCall> {
self.skip_ws();
let name = self.parse_ident();
if name.is_empty() {
bail!("expected function name in tool call");
}
self.skip_ws();
if self.bump() != Some('(') {
bail!("expected '(' after tool name '{name}'");
}
let mut args = Map::new();
loop {
self.skip_ws();
if self.peek() == Some(')') {
self.bump();
break;
}
let key = self.parse_ident();
if key.is_empty() {
bail!("expected argument name in call to '{name}'");
}
self.skip_ws();
if self.bump() != Some('=') {
bail!("expected '=' after argument '{key}' in call to '{name}'");
}
self.skip_ws();
let val = self.parse_value()?;
args.insert(key, val);
self.skip_ws();
match self.peek() {
Some(',') => {
self.bump();
}
Some(')') => {
self.bump();
break;
}
_ => bail!("expected ',' or ')' in argument list for '{name}'"),
}
}
Ok(ToolCall {
name,
arguments: Value::Object(args),
})
}
fn parse_ident(&mut self) -> String {
let start = self.i;
match self.peek() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => self.i += 1,
_ => return String::new(),
}
while let Some(c) = self.peek() {
if c.is_ascii_alphanumeric() || c == '_' {
self.i += 1;
} else {
break;
}
}
String::from_utf8_lossy(&self.s[start..self.i]).into_owned()
}
fn parse_value(&mut self) -> Result<Value> {
self.skip_ws();
match self.peek() {
Some('"') | Some('\'') => self.parse_string(),
Some('[') => self.parse_list(),
Some('{') => self.parse_dict(),
Some(c) if c == '-' || c.is_ascii_digit() => self.parse_number(),
Some(_) => self.parse_keyword(),
None => bail!("unexpected end of input while parsing value"),
}
}
fn parse_string(&mut self) -> Result<Value> {
let quote = self.s[self.i];
self.i += 1;
let mut out: Vec<u8> = Vec::new();
while self.i < self.s.len() {
let b = self.s[self.i];
self.i += 1;
match b {
b'\\' => {
let Some(&e) = self.s.get(self.i) else {
bail!("unterminated escape in string literal");
};
self.i += 1;
match e {
b'n' => out.push(b'\n'),
b't' => out.push(b'\t'),
b'r' => out.push(b'\r'),
b'b' => out.push(0x08),
b'f' => out.push(0x0C),
b'/' => out.push(b'/'),
b'\\' => out.push(b'\\'),
b'\'' => out.push(b'\''),
b'"' => out.push(b'"'),
b'u' => {
let cp = self.parse_u_codepoint()?;
let ch = char::from_u32(cp).unwrap_or('\u{FFFD}');
let mut buf = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
}
other => {
out.push(b'\\');
out.push(other);
}
}
}
b if b == quote => {
return Ok(Value::String(String::from_utf8_lossy(&out).into_owned()));
}
b => out.push(b),
}
}
bail!("unterminated string literal")
}
fn parse_u_codepoint(&mut self) -> Result<u32> {
let hi = self.parse_hex4()?;
if (0xD800..=0xDBFF).contains(&hi)
&& self.s.get(self.i) == Some(&b'\\')
&& self.s.get(self.i + 1) == Some(&b'u')
{
let before_second = self.i; self.i += 2; let lo = self.parse_hex4()?;
if (0xDC00..=0xDFFF).contains(&lo) {
return Ok(0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00));
}
self.i = before_second;
}
Ok(hi)
}
fn parse_hex4(&mut self) -> Result<u32> {
let mut cp = 0u32;
for _ in 0..4 {
let Some(&b) = self.s.get(self.i) else {
bail!("truncated \\u escape in string literal");
};
let d = (b as char)
.to_digit(16)
.ok_or_else(|| anyhow::anyhow!("bad hex digit in \\u escape"))?;
cp = cp * 16 + d;
self.i += 1;
}
Ok(cp)
}
fn parse_number(&mut self) -> Result<Value> {
let start = self.i;
if self.peek() == Some('-') {
self.bump();
}
let mut is_float = false;
while let Some(c) = self.peek() {
if c.is_ascii_digit() {
self.bump();
} else if c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-' {
is_float = true;
self.bump();
} else {
break;
}
}
let tok = std::str::from_utf8(&self.s[start..self.i]).unwrap_or("");
if is_float {
let f: f64 = tok
.parse()
.map_err(|_| anyhow::anyhow!("bad float '{tok}'"))?;
ensure!(f.is_finite(), "float literal '{tok}' out of range");
Ok(serde_json::json!(f))
} else {
match tok.parse::<i64>() {
Ok(n) => Ok(serde_json::json!(n)),
Err(_) => {
let f: f64 = tok
.parse()
.map_err(|_| anyhow::anyhow!("bad integer '{tok}'"))?;
ensure!(f.is_finite(), "integer literal '{tok}' out of range");
Ok(serde_json::json!(f))
}
}
}
}
fn parse_keyword(&mut self) -> Result<Value> {
let start = self.i;
while let Some(c) = self.peek() {
if c.is_ascii_alphabetic() {
self.bump();
} else {
break;
}
}
let kw = std::str::from_utf8(&self.s[start..self.i]).unwrap_or("");
match kw {
"True" | "true" => Ok(Value::Bool(true)),
"False" | "false" => Ok(Value::Bool(false)),
"None" | "null" => Ok(Value::Null),
other => bail!("unexpected literal '{other}' in tool call"),
}
}
fn parse_list(&mut self) -> Result<Value> {
self.bump(); let mut items = Vec::new();
loop {
self.skip_ws();
if self.peek() == Some(']') {
self.bump();
break;
}
items.push(self.parse_value()?);
self.skip_ws();
match self.peek() {
Some(',') => {
self.bump();
}
Some(']') => {
self.bump();
break;
}
_ => bail!("expected ',' or ']' in list literal"),
}
}
Ok(Value::Array(items))
}
fn parse_dict(&mut self) -> Result<Value> {
self.bump(); let mut map = Map::new();
loop {
self.skip_ws();
if self.peek() == Some('}') {
self.bump();
break;
}
let key = match self.parse_value()? {
Value::String(s) => s,
other => bail!("dict keys must be strings, got {other}"),
};
self.skip_ws();
if self.bump() != Some(':') {
bail!("expected ':' in dict literal");
}
let val = self.parse_value()?;
map.insert(key, val);
self.skip_ws();
match self.peek() {
Some(',') => {
self.bump();
}
Some('}') => {
self.bump();
break;
}
_ => bail!("expected ',' or '}}' in dict literal"),
}
}
Ok(Value::Object(map))
}
}
fn parse_hermes(text: &str) -> Result<Vec<ToolCall>> {
let mut calls = Vec::new();
let mut rest = text;
while let Some(start) = rest.find("<tool_call>") {
let after = &rest[start + "<tool_call>".len()..];
let (json_str, next) = match after.find("</tool_call>") {
Some(end) => (&after[..end], &after[end + "</tool_call>".len()..]),
None => (after, ""),
};
let v: Value = serde_json::from_str(json_str.trim())
.map_err(|e| anyhow::anyhow!("invalid <tool_call> JSON: {e}"))?;
let name = v
.get("name")
.and_then(|n| n.as_str())
.ok_or_else(|| anyhow::anyhow!("<tool_call> missing string 'name'"))?
.to_string();
let arguments = v
.get("arguments")
.or_else(|| v.get("parameters"))
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
calls.push(ToolCall { name, arguments });
rest = next;
}
Ok(calls)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_arch() {
assert_eq!(ToolFormat::detect("lfm2"), Some(ToolFormat::Lfm2Pythonic));
assert_eq!(ToolFormat::detect("qwen2"), Some(ToolFormat::Hermes));
assert_eq!(ToolFormat::detect("qwen2.5"), Some(ToolFormat::Hermes));
assert_eq!(ToolFormat::detect("qwen3"), Some(ToolFormat::Hermes));
assert_eq!(ToolFormat::detect("gpt2"), None);
}
#[test]
fn lfm2_marker_less_non_call_is_empty_not_error() {
for text in ["[1, 2, 3]", "[just some prose]", "[\"a\", \"b\"]"] {
let calls = parse_tool_calls(text, ToolFormat::Lfm2Pythonic).unwrap();
assert!(calls.is_empty(), "expected no calls for {text:?}");
}
assert!(
parse_tool_calls(
"<|tool_call_start|>[1, 2, 3]<|tool_call_end|>",
ToolFormat::Lfm2Pythonic
)
.is_err()
);
let calls =
parse_tool_calls("[get_weather(city=\"Paris\")]", ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "get_weather");
}
#[test]
fn lfm2_grammar_rejects_non_identifier_names() {
let bad_tool = ToolDef {
name: "get-weather".into(),
description: None,
parameters: empty_params(),
};
assert!(tool_grammar(&[bad_tool], ToolFormat::Lfm2Pythonic).is_err());
let bad_arg = ToolDef {
name: "get_weather".into(),
description: None,
parameters: serde_json::json!({
"type": "object",
"properties": { "2fa": { "type": "string" } }
}),
};
assert!(tool_grammar(std::slice::from_ref(&bad_arg), ToolFormat::Lfm2Pythonic).is_err());
assert!(tool_grammar(&[bad_arg], ToolFormat::Hermes).is_ok());
}
#[test]
fn lfm2_single_call() {
let text = "<|tool_call_start|>[get_weather(city=\"Paris\")]<|tool_call_end|>";
let calls = parse_tool_calls(text, ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "get_weather");
assert_eq!(calls[0].arguments, serde_json::json!({"city": "Paris"}));
}
#[test]
fn lfm2_multi_arg_types() {
let text = "<|tool_call_start|>[f(a=1, b=2.5, c=True, d=None, e=\"hi\")]<|tool_call_end|>";
let calls = parse_tool_calls(text, ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(
calls[0].arguments,
serde_json::json!({"a": 1, "b": 2.5, "c": true, "d": null, "e": "hi"})
);
}
#[test]
fn lfm2_nested_and_multiple_calls() {
let text = "<|tool_call_start|>[a(x=[1, 2, 3]), b(y={\"k\": \"v\"})]<|tool_call_end|>";
let calls = parse_tool_calls(text, ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].arguments, serde_json::json!({"x": [1, 2, 3]}));
assert_eq!(calls[1].arguments, serde_json::json!({"y": {"k": "v"}}));
}
#[test]
fn lfm2_non_ascii_string_arg() {
let text = "<|tool_call_start|>[f(city=\"Zürich\", note=\"日本語 👍\")]<|tool_call_end|>";
let calls = parse_tool_calls(text, ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls[0].arguments["city"], "Zürich");
assert_eq!(calls[0].arguments["note"], "日本語 👍");
}
#[test]
fn lfm2_unicode_and_slash_escapes() {
let text = "<|tool_call_start|>[open(url=\"http:\\/\\/x.com\\u002Fp\")]<|tool_call_end|>";
let calls = parse_tool_calls(text, ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls[0].arguments["url"], "http://x.com/p");
}
#[test]
fn lfm2_big_integer_falls_back_to_float() {
let text = "<|tool_call_start|>[wire(amount=10000000000000000000)]<|tool_call_end|>";
let calls = parse_tool_calls(text, ToolFormat::Lfm2Pythonic).unwrap();
assert!(calls[0].arguments["amount"].is_number());
let huge = "9".repeat(400);
let text = format!("<|tool_call_start|>[wire(amount={huge})]<|tool_call_end|>");
assert!(parse_tool_calls(&text, ToolFormat::Lfm2Pythonic).is_err());
}
#[test]
fn lfm2_surrogate_pair_escape() {
let text = "<|tool_call_start|>[react(emoji=\"\\uD83D\\uDE00\")]<|tool_call_end|>";
let calls = parse_tool_calls(text, ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls[0].arguments["emoji"], "\u{1F600}");
}
#[test]
fn lfm2_out_of_range_float_is_error_not_null() {
let text = "<|tool_call_start|>[f(x=1e9999)]<|tool_call_end|>";
assert!(parse_tool_calls(text, ToolFormat::Lfm2Pythonic).is_err());
let ok = "<|tool_call_start|>[f(x=1.5)]<|tool_call_end|>";
let calls = parse_tool_calls(ok, ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls[0].arguments["x"], 1.5);
}
#[test]
fn lfm2_unpaired_high_surrogate_keeps_following_escape() {
let text = "<|tool_call_start|>[f(s=\"\\uD83D\\u0041\")]<|tool_call_end|>";
let calls = parse_tool_calls(text, ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls[0].arguments["s"], "\u{FFFD}A");
}
#[test]
fn lfm2_multiple_sections() {
let text = "<|tool_call_start|>[a(x=1)]<|tool_call_end|> then \
<|tool_call_start|>[b(y=2)]<|tool_call_end|>";
let calls = parse_tool_calls(text, ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "a");
assert_eq!(calls[1].name, "b");
}
#[test]
fn enum_with_backslash_value_is_emittable() {
let tools = vec![ToolDef {
name: "open".into(),
description: None,
parameters: serde_json::json!({
"type": "object",
"properties": {"p": {"type": "string", "enum": ["C:\\path"]}}
}),
}];
let g = compile(&tool_grammar(&tools, ToolFormat::Hermes).unwrap());
assert!(accepts_complete(
&g,
br#"{"name": "open", "arguments": {"p": "C:\\path"}}"#
));
}
#[test]
fn non_scalar_enum_falls_back_to_type() {
let tools = vec![ToolDef {
name: "f".into(),
description: None,
parameters: serde_json::json!({
"type": "object",
"properties": {"x": {"type": "array", "enum": [["a"], ["b"]]}}
}),
}];
let g = compile(&tool_grammar(&tools, ToolFormat::Lfm2Pythonic).unwrap());
assert!(accepts_complete(&g, b"[f(x=[\"a\"])]"));
}
#[test]
fn lfm2_missing_end_marker_is_tolerated() {
let text = "<|tool_call_start|>[ping()]";
let calls = parse_tool_calls(text, ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "ping");
assert_eq!(calls[0].arguments, serde_json::json!({}));
}
#[test]
fn lfm2_single_quotes() {
let text = "<|tool_call_start|>[echo(msg='it\\'s ok')]<|tool_call_end|>";
let calls = parse_tool_calls(text, ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls[0].arguments, serde_json::json!({"msg": "it's ok"}));
}
#[test]
fn lfm2_bare_list_without_markers() {
let calls =
parse_tool_calls("[get_weather(city=\"Paris\")]", ToolFormat::Lfm2Pythonic).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "get_weather");
let calls = parse_tool_calls("no tools here", ToolFormat::Lfm2Pythonic).unwrap();
assert!(calls.is_empty());
}
#[test]
fn no_call_is_empty_not_error() {
let calls = parse_tool_calls("The weather is sunny.", ToolFormat::Lfm2Pythonic).unwrap();
assert!(calls.is_empty());
let calls = parse_tool_calls("Just prose.", ToolFormat::Hermes).unwrap();
assert!(calls.is_empty());
}
#[test]
fn hermes_single_and_parameters_alias() {
let text = "<tool_call>{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}</tool_call>";
let calls = parse_tool_calls(text, ToolFormat::Hermes).unwrap();
assert_eq!(calls[0].name, "get_weather");
assert_eq!(calls[0].arguments, serde_json::json!({"city": "Paris"}));
let alias = "<tool_call>{\"name\": \"f\", \"parameters\": {\"a\": 1}}</tool_call>";
let calls = parse_tool_calls(alias, ToolFormat::Hermes).unwrap();
assert_eq!(calls[0].arguments, serde_json::json!({"a": 1}));
}
#[test]
fn hermes_multiple_blocks() {
let text = "<tool_call>{\"name\": \"a\", \"arguments\": {}}</tool_call>\n\
<tool_call>{\"name\": \"b\", \"arguments\": {\"x\": true}}</tool_call>";
let calls = parse_tool_calls(text, ToolFormat::Hermes).unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "a");
assert_eq!(calls[1].name, "b");
}
fn compile(src: &str) -> std::sync::Arc<crate::grammar::Grammar> {
std::sync::Arc::new(
crate::grammar::Grammar::parse(src)
.unwrap_or_else(|e| panic!("grammar failed to compile: {e}\n---\n{src}")),
)
}
fn accepts_complete(g: &std::sync::Arc<crate::grammar::Grammar>, bytes: &[u8]) -> bool {
let mut st = crate::grammar::GrammarState::new(g.clone());
if !st.accepts(bytes) {
return false;
}
st.accept(bytes);
st.is_complete()
}
fn weather() -> ToolDef {
ToolDef {
name: "get_weather".into(),
description: None,
parameters: serde_json::json!({
"type": "object",
"properties": {
"city": {"type": "string"},
"days": {"type": "integer"},
"units": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["city"]
}),
}
}
#[test]
fn lfm2_grammar_accepts_valid_calls() {
let g = compile(&tool_grammar(&[weather()], ToolFormat::Lfm2Pythonic).unwrap());
assert!(accepts_complete(&g, b"[get_weather(city=\"Paris\")]"));
assert!(accepts_complete(
&g,
b"[get_weather(city=\"Paris\", days=3)]"
));
assert!(accepts_complete(
&g,
b"[get_weather(units=\"celsius\", city=\"Rome\")]"
));
assert!(accepts_complete(&g, b"[get_weather()]"));
}
#[test]
fn lfm2_grammar_rejects_invalid_calls() {
let g = compile(&tool_grammar(&[weather()], ToolFormat::Lfm2Pythonic).unwrap());
let st = crate::grammar::GrammarState::new(g.clone());
assert!(!st.accepts(b"[get_stocks("));
let st = crate::grammar::GrammarState::new(g.clone());
assert!(!st.accepts(b"[get_weather(country="));
let st = crate::grammar::GrammarState::new(g.clone());
assert!(!st.accepts(b"[get_weather(days=\""));
let st = crate::grammar::GrammarState::new(g.clone());
assert!(!st.accepts(b"[get_weather(units=\"kelvin\")"));
}
#[test]
fn hermes_grammar_accepts_json_call() {
let g = compile(&tool_grammar(&[weather()], ToolFormat::Hermes).unwrap());
assert!(accepts_complete(
&g,
br#"{"name": "get_weather", "arguments": {"city": "Paris"}}"#
));
assert!(accepts_complete(
&g,
br#"{"name": "get_weather", "arguments": {"city": "Rome", "days": 5}}"#
));
}
#[test]
fn hermes_grammar_rejects_bad_name() {
let g = compile(&tool_grammar(&[weather()], ToolFormat::Hermes).unwrap());
let st = crate::grammar::GrammarState::new(g.clone());
assert!(!st.accepts(br#"{"name": "get_stocks"#));
}
#[test]
fn multi_tool_grammar_alternates() {
let tools = vec![
weather(),
ToolDef {
name: "add".into(),
description: None,
parameters: serde_json::json!({
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}
}),
},
];
let g = compile(&tool_grammar(&tools, ToolFormat::Lfm2Pythonic).unwrap());
assert!(accepts_complete(&g, b"[get_weather(city=\"Paris\")]"));
assert!(accepts_complete(&g, b"[add(a=1, b=2)]"));
assert!(accepts_complete(
&g,
b"[add(a=1, b=2), get_weather(city=\"X\")]"
));
}
#[test]
fn empty_tools_is_error() {
assert!(tool_grammar(&[], ToolFormat::Lfm2Pythonic).is_err());
}
#[test]
fn tool_def_serializes_to_function_shape() {
let tool = ToolDef {
name: "get_weather".into(),
description: Some("Get weather".into()),
parameters: serde_json::json!({
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}),
};
let v = serde_json::to_value(&tool).unwrap();
assert_eq!(v["name"], "get_weather");
assert_eq!(v["description"], "Get weather");
assert_eq!(v["parameters"]["properties"]["city"]["type"], "string");
}
}