pub struct ErrorHint {
pub original: String,
pub hint: &'static str,
pub category: ErrorCategory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCategory {
EditMismatch,
AmbiguousEdit,
Timeout,
PatchContext,
CommandFailed,
NotFound,
Permission,
BadArguments,
Generic,
}
pub fn classify(tool: &str, error: &str) -> ErrorHint {
let lower = error.to_lowercase();
let (hint, category) = match tool {
"file_edit" => {
if lower.contains("not found in") {
(
"The file content may have changed since you last read it. \
Use file_read to get the current content, then retry file_edit \
with the exact text from the file.",
ErrorCategory::EditMismatch,
)
} else if lower.contains("times") && lower.contains("found") {
(
"The old_string matches multiple locations. Include more surrounding \
context lines in old_string to make it unique, or use a larger \
code block that only appears once.",
ErrorCategory::AmbiguousEdit,
)
} else {
(generic_hint(tool), ErrorCategory::Generic)
}
}
"git_patch" => {
if lower.contains("context")
|| lower.contains("does not apply")
|| lower.contains("patch failed")
{
(
"The patch context lines don't match the current file. Line numbers \
may have shifted from earlier edits. Re-read the file with file_read \
to get current line numbers, then regenerate the patch.",
ErrorCategory::PatchContext,
)
} else {
(generic_hint(tool), ErrorCategory::Generic)
}
}
"bash" => {
if lower.contains("timed out") || lower.contains("timeout") {
(
"The command timed out. Try a more targeted command, reduce scope, \
or break it into smaller steps.",
ErrorCategory::Timeout,
)
} else if lower.contains("not found") || lower.contains("no such file") {
(
"File or command not found. Verify the path exists with file_read \
or check available commands with `which`.",
ErrorCategory::NotFound,
)
} else if lower.contains("permission denied") {
(
"Permission denied. Check file permissions or try a different approach \
that doesn't require elevated access.",
ErrorCategory::Permission,
)
} else if lower.contains("exit code") || lower.contains("exit status") {
(
"Command failed. Review the error output carefully and adjust the \
command arguments or approach.",
ErrorCategory::CommandFailed,
)
} else {
(generic_hint(tool), ErrorCategory::Generic)
}
}
"file_write" | "file_read" => {
if lower.contains("not found") || lower.contains("no such file") {
(
"File not found. Verify the path is correct. Use search to find \
the right file path.",
ErrorCategory::NotFound,
)
} else if lower.contains("permission") {
(
"Permission denied. Check file permissions.",
ErrorCategory::Permission,
)
} else {
(generic_hint(tool), ErrorCategory::Generic)
}
}
t if t.starts_with("mcp__") => {
if lower.contains("cannot be")
|| lower.contains("must not be")
|| lower.contains("is required")
|| lower.contains("empty")
|| lower.contains("-400")
{
(
"The MCP tool received an invalid argument value (e.g. empty string \
or null). Ensure every required argument is non-empty and valid. \
Use tool_search to review the parameter constraints before retrying.",
ErrorCategory::BadArguments,
)
} else if lower.contains("invalid")
|| lower.contains("parse")
|| lower.contains("deserialize")
|| lower.contains("missing field")
|| lower.contains("unknown field")
|| lower.contains("parameter")
{
(
"The MCP tool arguments are malformed or missing required fields. \
Use tool_search to fetch the full schema for this tool and verify \
the exact parameter names and types before retrying.",
ErrorCategory::BadArguments,
)
} else if lower.contains("not found") || lower.contains("no such") {
(
"The requested resource was not found. Use the corresponding \
discovery tool (e.g. list_projects, get_project_docs_overview, \
get_repo_structure) to confirm available paths before retrying.",
ErrorCategory::NotFound,
)
} else {
(generic_hint(tool), ErrorCategory::Generic)
}
}
_ => {
if lower.contains("invalid") || lower.contains("parse") || lower.contains("deserialize")
{
(
"The tool arguments appear malformed. Check the JSON structure \
matches the tool's expected parameters.",
ErrorCategory::BadArguments,
)
} else {
(generic_hint(tool), ErrorCategory::Generic)
}
}
};
ErrorHint {
original: error.to_string(),
hint,
category,
}
}
pub fn format_with_hint(tool: &str, error: &str) -> String {
let hint = classify(tool, error);
if hint.category == ErrorCategory::Generic {
return error.to_string();
}
format!("{}\n\n[Recovery hint]: {}", hint.original, hint.hint)
}
fn generic_hint(_tool: &str) -> &'static str {
"Analyze the error message and try a different approach."
}
pub fn is_empty_result(result: &str) -> bool {
let trimmed = result.trim();
if trimmed.is_empty() || trimmed == "[]" || trimmed == "null" {
return true;
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
if let Some(arr) = v.as_array() {
return arr.is_empty();
}
for key in &["results", "data", "items", "hits", "matches"] {
if let Some(arr) = v.get(key).and_then(|a| a.as_array())
&& arr.is_empty()
{
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_edit_mismatch() {
let hint = classify("file_edit", "old_string not found in src/main.rs");
assert_eq!(hint.category, ErrorCategory::EditMismatch);
assert!(hint.hint.contains("file_read"));
}
#[test]
fn test_ambiguous_edit() {
let hint = classify(
"file_edit",
"old_string found 3 times in src/main.rs - must be unique",
);
assert_eq!(hint.category, ErrorCategory::AmbiguousEdit);
assert!(hint.hint.contains("context"));
}
#[test]
fn test_bash_timeout() {
let hint = classify("bash", "Error: Operation timed out after 120s");
assert_eq!(hint.category, ErrorCategory::Timeout);
}
#[test]
fn test_patch_context() {
let hint = classify(
"git_patch",
"Patch failed.\ngit apply: patch does not apply",
);
assert_eq!(hint.category, ErrorCategory::PatchContext);
assert!(hint.hint.contains("Re-read"));
}
#[test]
fn test_generic_fallback() {
let hint = classify("search", "some random error");
assert_eq!(hint.category, ErrorCategory::Generic);
}
#[test]
fn test_format_with_hint() {
let formatted = format_with_hint("file_edit", "old_string not found in src/main.rs");
assert!(formatted.contains("[Recovery hint]"));
assert!(formatted.contains("file_read"));
}
#[test]
fn test_format_generic_no_hint() {
let formatted = format_with_hint("search", "some error");
assert!(!formatted.contains("[Recovery hint]"));
}
#[test]
fn test_bad_arguments() {
let hint = classify("subagent", "Error: failed to deserialize arguments");
assert_eq!(hint.category, ErrorCategory::BadArguments);
}
#[test]
fn test_mcp_value_validation_cannot_be_empty() {
let hint = classify(
"mcp__web-search-prime__web_search_prime",
"Error: MCP call failed: MCP tools/call error for 'web_search_prime': -400 — search_query cannot be empty",
);
assert_eq!(hint.category, ErrorCategory::BadArguments);
assert!(hint.hint.contains("non-empty"));
}
#[test]
fn test_mcp_value_validation_is_required() {
let hint = classify(
"mcp__alcove__get_doc_file",
"Error: MCP call failed: MCP tools/call error for 'get_doc_file': -400 — project_id is required",
);
assert_eq!(hint.category, ErrorCategory::BadArguments);
assert!(hint.hint.contains("non-empty"));
}
#[test]
fn test_mcp_value_validation_must_not_be() {
let hint = classify(
"mcp__context7__resolve-library-id",
"Error: MCP call failed: -400 — libraryId must not be null",
);
assert_eq!(hint.category, ErrorCategory::BadArguments);
assert!(hint.hint.contains("non-empty"));
}
#[test]
fn test_mcp_schema_error_still_works() {
let hint = classify(
"mcp__zai-mcp-server__search",
"Error: MCP call failed: missing field `query`",
);
assert_eq!(hint.category, ErrorCategory::BadArguments);
assert!(hint.hint.contains("parameter names"));
}
#[test]
fn test_bash_not_found() {
let hint = classify("bash", "Error: No such file or directory");
assert_eq!(hint.category, ErrorCategory::NotFound);
}
#[test]
fn test_permission_denied() {
let hint = classify("bash", "Error: Permission denied");
assert_eq!(hint.category, ErrorCategory::Permission);
}
}