collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Error pattern recognition with guided recovery hints.
//!
//! Classifies tool errors and provides actionable recovery guidance
//! to the LLM, improving self-correction success rates.

/// Classified tool error with recovery guidance.
pub struct ErrorHint {
    /// The original error message.
    pub original: String,
    /// Human-readable recovery hint for the LLM.
    pub hint: &'static str,
    /// Error category for metrics.
    pub category: ErrorCategory,
}

/// Error categories for tracking and metrics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCategory {
    /// old_string not found in file.
    EditMismatch,
    /// old_string found multiple times.
    AmbiguousEdit,
    /// Tool execution timed out.
    Timeout,
    /// Patch context lines don't match file content.
    PatchContext,
    /// Command exited with non-zero status.
    CommandFailed,
    /// File or directory not found.
    NotFound,
    /// Permission denied.
    Permission,
    /// JSON parse error in tool arguments.
    BadArguments,
    /// Uncategorized error.
    Generic,
}

/// Classify a tool error and produce a recovery hint.
///
/// Returns `None` for generic errors where no specific guidance applies.
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,
    }
}

/// Format an error with its recovery hint for context injection.
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."
}

/// Returns true if the tool result looks like an empty/no-results response.
/// Covers common patterns: `[]`, `{"results":[]}`, `{"data":[]}`, empty string.
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) {
        // Top-level array that is empty
        if let Some(arr) = v.as_array() {
            return arr.is_empty();
        }
        // Object with a common results/data/items key that is an empty array
        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);
    }
}