#![allow(unused_imports, dead_code)]
use super::fuzzy_match::fuzzy_match_tool_name;
use super::*;
mod backfill;
mod paths;
pub use backfill::backfill_required_params;
pub use paths::normalize_paths;
fn resolve_schema_type(schema: &serde_json::Value) -> Option<&str> {
if let Some(t) = schema.get("type").and_then(|t| t.as_str()) {
return Some(t);
}
for key in ["anyOf", "oneOf"] {
if let Some(variants) = schema.get(key).and_then(|v| v.as_array()) {
for variant in variants {
if let Some(t) = variant.get("type").and_then(|t| t.as_str())
&& t != "null"
{
return Some(t);
}
}
}
}
None
}
pub(crate) fn normalize_param_name(tools: &[ToolDefinition], call_name: &str, key: &str) -> String {
let Some(tool_def) = tools.iter().find(|t| t.function.name == call_name) else {
return key.to_string();
};
let Some(props) = tool_def
.function
.parameters
.as_ref()
.and_then(|p| p.get("properties"))
.and_then(|p| p.as_object())
else {
return key.to_string();
};
if props.contains_key(key) {
return key.to_string();
}
let schema_normalized: std::collections::HashMap<String, &str> = props
.keys()
.map(|k| (k.to_lowercase().replace('_', ""), k.as_str()))
.collect();
let norm = key.to_lowercase().replace('_', "");
schema_normalized
.get(&norm)
.map(|schema_key| schema_key.to_string())
.unwrap_or_else(|| key.to_string())
}
pub(crate) fn salvage_echoed_param(
tools: &[ToolDefinition],
call_name: &str,
key: &str,
value: &str,
) -> Option<(String, String)> {
let props = tools
.iter()
.find(|t| t.function.name == call_name)?
.function
.parameters
.as_ref()?
.get("properties")?
.as_object()?;
if props.is_empty() || props.contains_key(key) {
return None;
}
let mut names: Vec<&String> = props.keys().collect();
names.sort_by_key(|n| std::cmp::Reverse(n.len()));
for prop in names {
if let Some(rest) = value
.strip_prefix(prop.as_str())
.and_then(|r| r.strip_prefix('>'))
{
return Some((prop.clone(), rest.trim().to_string()));
}
}
None
}
pub fn find_empty_required_params(call: &ToolCall, tools: &[ToolDefinition]) -> Vec<String> {
let Some(tool_def) = tools.iter().find(|t| t.function.name == call.function.name) else {
return Vec::new();
};
let Some(ref params_schema) = tool_def.function.parameters else {
return Vec::new();
};
let required = params_schema
.get("required")
.and_then(|r| r.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let args: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&call.function.arguments).unwrap_or_default();
let mut empty = Vec::new();
for key in &required {
match args.get(key.as_str()) {
None => empty.push(key.clone()),
Some(serde_json::Value::String(s)) if s.trim().is_empty() => empty.push(key.clone()),
Some(serde_json::Value::Null) => empty.push(key.clone()),
_ => {}
}
}
empty
}
pub struct ValidatedToolCalls {
pub valid: Vec<ToolCall>,
pub errors: Vec<String>,
}
pub fn validate_tool_calls(
mut calls: Vec<ToolCall>,
tools: &[ToolDefinition],
) -> ValidatedToolCalls {
let mut valid = Vec::new();
let mut errors = Vec::new();
for call in &mut calls {
if tools.iter().all(|t| t.function.name != call.function.name)
&& let Some(best) = fuzzy_match_tool_name(&call.function.name, tools)
{
tracing::info!(
"Fuzzy tool name repair: '{}' -> '{}'",
call.function.name,
best
);
call.function.name = best;
}
match assess_tool_call(call, tools) {
Ok(()) => valid.push(call.clone()),
Err(ToolCallIssue::MissingParam(msg)) => {
valid.push(call.clone());
errors.push(msg);
}
Err(issue) => errors.push(issue.into_message()),
}
}
ValidatedToolCalls { valid, errors }
}
#[derive(Debug)]
pub enum ToolCallIssue {
MissingParam(String),
EmptyRequired(String),
Hard(String),
}
impl ToolCallIssue {
pub fn message(&self) -> &str {
match self {
Self::MissingParam(m) | Self::EmptyRequired(m) | Self::Hard(m) => m,
}
}
pub fn into_message(self) -> String {
match self {
Self::MissingParam(m) | Self::EmptyRequired(m) | Self::Hard(m) => m,
}
}
}
pub fn validate_single_tool_call(call: &ToolCall, tools: &[ToolDefinition]) -> Result<(), String> {
assess_tool_call(call, tools).map_err(ToolCallIssue::into_message)
}
pub fn assess_tool_call(call: &ToolCall, tools: &[ToolDefinition]) -> Result<(), ToolCallIssue> {
let name = &call.function.name;
let tool_def = tools.iter().find(|t| t.function.name == *name);
if tool_def.is_none() {
let available: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect();
return Err(ToolCallIssue::Hard(format!(
"Error: Unknown tool '{}'. Available tools: {}",
name,
available.join(", ")
)));
}
let tool_def = tool_def.unwrap();
let args: serde_json::Map<String, serde_json::Value> =
match serde_json::from_str(&call.function.arguments) {
Ok(a) => a,
Err(_) => {
let preview: String = call.function.arguments.chars().take(100).collect();
return Err(ToolCallIssue::Hard(format!(
"Error: {name} arguments must be valid JSON. Got: {preview}"
)));
}
};
if let Some(ref params_schema) = tool_def.function.parameters {
let required: Vec<&str> = params_schema
.get("required")
.and_then(|r| r.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
for key in &required {
if args.get(*key).is_none() {
return Err(ToolCallIssue::MissingParam(format!(
"Error: {} requires parameter '{}' but it was not provided.",
name, key
)));
}
}
}
const FILE_TOOLS: &[&str] = &["Write", "write", "Edit", "edit", "Read", "read"];
const PATH_KEYS: &[&str] = &["file_path", "filePath", "path"];
const WRITE_FAMILY: &[&str] = &[
"Write",
"write",
"Edit",
"edit",
"MultiEdit",
"multiEdit",
"multi_edit",
"write_file",
"writeFile",
];
if WRITE_FAMILY.contains(&name.as_str()) {
for key in PATH_KEYS {
if let Some(serde_json::Value::String(path)) = args.get(*key) {
let trimmed = path.trim();
if trimmed.is_empty() {
if std::env::var("METRALE_TOOLCALL_DEBUG").as_deref() == Ok("1") {
let shape: Vec<String> = args
.iter()
.map(|(k, v)| match v {
serde_json::Value::String(s) => {
format!("{k}=str(len={})", s.len())
}
other => format!("{k}={}", other),
})
.collect();
tracing::warn!(
tool = %name, empty_key = %key,
"METRALE_TOOLCALL_DEBUG empty-path arg shape: [{}]",
shape.join(", ")
);
}
return Err(ToolCallIssue::EmptyRequired(format!(
"Error: {name} requires a non-empty '{key}'. \
Got empty string — provide an absolute path \
like '/tmp/calc-test75/Cargo.toml'."
)));
}
const SHELL_META: &[char] = &[
' ', '\t', '\n', '\r', '&', '|', ';', '`', '$', '<', '>', '(', ')', '*', '?',
];
let looks_like_command = trimmed.contains(SHELL_META);
if looks_like_command || trimmed.len() < 3 {
return Err(ToolCallIssue::Hard(format!(
"Error: {name} '{key}' must be a filesystem path (absolute or relative \
to the working directory), at least 3 chars, with no shell \
metacharacters or whitespace. Got {path:?}."
)));
}
}
}
}
const SHELL_FAMILY: &[&str] = &[
"bash", "Bash", "shell", "Shell", "exec", "Exec", "run", "Run", "execute", "Execute",
"terminal", "Terminal",
];
const CMD_KEYS: &[&str] = &["command", "cmd", "script", "code"];
if SHELL_FAMILY.contains(&name.as_str()) {
for key in CMD_KEYS {
if let Some(serde_json::Value::String(cmd)) = args.get(*key)
&& (cmd.trim().is_empty() || cmd.trim().len() < 2)
{
return Err(ToolCallIssue::EmptyRequired(format!(
"Error: {name} requires a non-empty '{key}'. \
Got empty string — provide the shell command \
to execute, e.g. 'ls /tmp'."
)));
}
}
}
if FILE_TOOLS.contains(&name.as_str()) {
for key in PATH_KEYS {
if let Some(serde_json::Value::String(path)) = args.get(*key) {
if path.ends_with('/') {
return Err(ToolCallIssue::Hard(format!(
"Error: {} file_path must be a FILE, not a directory. Got '{}'. Use e.g. '{}/index.ts'",
name,
path,
path.trim_end_matches('/')
)));
}
if !path.is_empty()
&& !path.contains('.')
&& !path.contains('/')
&& path
.chars()
.all(|c| c.is_lowercase() || c == '-' || c == '_')
{
return Err(ToolCallIssue::Hard(format!(
"Error: {} file_path '{}' looks like a directory. Add a filename, e.g. '{}/index.ts'",
name, path, path
)));
}
}
}
}
Ok(())
}
#[cfg(test)]
mod path_sanitizer_tests {
use crate::tool_parser::{FunctionCall, ToolCall};
fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
ToolCall {
id: "x".into(),
call_type: "function".into(),
function: FunctionCall {
name: name.into(),
arguments: arguments.to_string(),
},
}
}
#[test]
fn malformed_quoted_comma_filepath_sanitized() {
let mut calls = vec![call(
"write",
serde_json::json!({
"filePath": "\"/tmp/proj/Cargo.toml\",",
"content": "[package]\nname = \"x\"\n"
}),
)];
super::normalize_paths(&mut calls, "/tmp/proj");
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["filePath"], "Cargo.toml");
}
#[test]
fn punctuation_drifted_workdir_is_repaired_to_cwd() {
let cwd = "/tmp/harness-laguna_s21-carddefaults-r1";
let mut calls = vec![call(
"bash",
serde_json::json!({
"command": "cargo test",
"workdir": "/tmp/harness-laguna-s21-carddefaults-r1"
}),
)];
super::normalize_paths(&mut calls, cwd);
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["workdir"], cwd);
}
#[test]
fn punctuation_drifted_cwd_inside_bash_command_is_repaired() {
let cwd = "/tmp/harness-laguna_s21-agentfix-r1";
let mut calls = vec![call(
"bash",
serde_json::json!({
"command": "ls /tmp/harness-laguna-s21-agentfix-r1/opencode && pwd",
"workdir": cwd
}),
)];
super::normalize_paths(&mut calls, cwd);
let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(
args["command"],
"ls /tmp/harness-laguna_s21-agentfix-r1/opencode && pwd"
);
}
}