use serde_json::{Map, Value};
use super::super::ToolDefinition;
use super::args::{ARG_KEY, coerce_to_schema, convert_args, tool_properties};
use super::recover::recover_intended_call;
pub const REFUSAL_KEY: &str = "_rejected_by_server";
const UNKNOWN_TOOL: &str = "unknown_tool";
const MAX_NAME_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq)]
pub enum Verdict {
Accept { name: String, arguments: String },
Refuse {
name: String,
arguments: String,
reason: String,
},
}
impl Verdict {
pub fn name(&self) -> &str {
match self {
Self::Accept { name, .. } | Self::Refuse { name, .. } => name,
}
}
pub fn arguments(&self) -> &str {
match self {
Self::Accept { arguments, .. } | Self::Refuse { arguments, .. } => arguments,
}
}
}
pub(super) fn is_name_shaped(s: &str) -> bool {
let mut chars = s.chars();
s.len() <= MAX_NAME_LEN
&& chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(is_name_char)
}
pub(super) fn is_name_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')
}
pub(crate) fn split_body(body: &str) -> (&str, &str) {
let name_end = body.find(ARG_KEY).unwrap_or(body.len());
(body[..name_end].trim(), &body[name_end..])
}
pub fn resolve_name(emitted: &str, tools: &[ToolDefinition]) -> Option<String> {
let offered = || tools.iter().map(|t| t.function.name.as_str());
if offered().any(|n| n == emitted) {
return Some(emitted.to_string());
}
let best = offered()
.filter(|n| !n.is_empty() && emitted.starts_with(n))
.max_by_key(|n| n.len())?;
let continues_name = emitted[best.len()..]
.chars()
.next()
.is_some_and(is_name_char);
(!continues_name).then(|| best.to_string())
}
pub(super) fn reject_reason(
name: &str,
args: &Map<String, Value>,
tools: &[ToolDefinition],
) -> Option<String> {
if !is_name_shaped(name) {
return Some(format!("tool name {name:?} is not a valid tool name"));
}
if !tools.iter().any(|t| t.function.name == name) {
return Some(format!(
"tool name {name:?} is not one of the tools offered in this request"
));
}
let properties = tool_properties(tools, name).filter(|p| !p.is_empty());
for key in args.keys() {
if !is_name_shaped(key) {
return Some(format!("argument key {key:?} is not a valid argument name"));
}
if let Some(properties) = properties
&& !properties.contains_key(key)
{
let mut valid: Vec<&str> = properties.keys().map(String::as_str).collect();
valid.sort_unstable();
return Some(format!(
"argument {key:?} is not a parameter of {name}; its parameters are: {}",
valid.join(", ")
));
}
}
None
}
pub(super) fn typed_arguments(raw_args: &str, tools: &[ToolDefinition], name: &str) -> String {
let mut args = convert_args(raw_args);
coerce_to_schema(&mut args, tools, name);
Value::Object(args).to_string()
}
pub fn judge(body: &str, tools: &[ToolDefinition]) -> Verdict {
let (emitted, raw_args) = split_body(body);
if let Some(reason) = recover_intended_call(emitted, raw_args, tools) {
return refuse(emitted, tools, reason);
}
let resolved = resolve_name(emitted, tools);
let name = resolved.as_deref().unwrap_or(emitted);
match reject_reason(name, &convert_args(raw_args), tools) {
None => Verdict::Accept {
name: name.to_string(),
arguments: typed_arguments(raw_args, tools, name),
},
Some(reason) => refuse(emitted, tools, reason),
}
}
fn refuse(emitted: &str, tools: &[ToolDefinition], reason: String) -> Verdict {
let name = resolve_name(emitted, tools).unwrap_or_else(|| {
if is_name_shaped(emitted) {
emitted.to_string()
} else {
UNKNOWN_TOOL.to_string()
}
});
tracing::warn!(tool = %name, "glm47 tool call refused: {reason}");
let mut args = Map::new();
args.insert(REFUSAL_KEY.to_string(), Value::String(reason.clone()));
Verdict::Refuse {
name,
arguments: Value::Object(args).to_string(),
reason,
}
}