Skip to main content

atd_runtime/
error.rs

1//! Errors a tool may return.
2//!
3//! Axes chosen to map cleanly to the wire protocol:
4//! - InvalidArgs / InternalError → wire `error` response
5//! - ExecutionFailed → wire `tool_result { success: false }` response
6//!
7//! Named `ToolCallError` (not reusing `atd-protocol::AtdError`) because
8//! client-side and server-side errors classify different concerns.
9//!
10//! `#[non_exhaustive]` on the enum is load-bearing since this crate is now
11//! separate from `atd-ref-server` (post-SP-refactor-v1): downstream
12//! match sites must include a wildcard arm so new variants added here
13//! don't break their compile without an explicit update.
14
15use thiserror::Error;
16
17#[derive(Debug, Error)]
18#[non_exhaustive]
19pub enum ToolCallError {
20    /// Schema validation failed or args couldn't be coerced to the expected
21    /// shape. The tool's own logic did not execute.
22    #[error("invalid arguments: {0}")]
23    InvalidArgs(String),
24
25    /// Tool ran to completion but reports a failure outcome. This is the
26    /// domain-level "the operation didn't succeed" case, not a server error.
27    #[error("execution failed ({code}): {message}")]
28    ExecutionFailed {
29        code: String,
30        message: String,
31        retryable: bool,
32    },
33
34    /// Server-side bug or unexpected condition during tool invocation.
35    #[error("internal error: {0}")]
36    InternalError(String),
37
38    /// Dispatch refused the call because the tool's `max_concurrent`
39    /// semaphore is saturated. Emitted fail-fast — the tool never runs.
40    /// SP-operability-v1 C2.
41    #[error("rate limited ({tool_id}): max_concurrent={limit} in-flight")]
42    RateLimited {
43        tool_id: String,
44        limit: u32,
45        retry_after_ms: Option<u64>,
46    },
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    #[test]
54    fn invalid_args_display_format() {
55        let e = ToolCallError::InvalidArgs("missing field `path`".into());
56        assert_eq!(format!("{e}"), "invalid arguments: missing field `path`");
57    }
58
59    #[test]
60    fn execution_failed_display_includes_code_and_message() {
61        let e = ToolCallError::ExecutionFailed {
62            code: "EPERM".into(),
63            message: "denied".into(),
64            retryable: false,
65        };
66        let s = format!("{e}");
67        assert!(s.contains("EPERM"));
68        assert!(s.contains("denied"));
69    }
70
71    #[test]
72    fn internal_error_display_format() {
73        let e = ToolCallError::InternalError("logic bug".into());
74        assert_eq!(format!("{e}"), "internal error: logic bug");
75    }
76
77    #[test]
78    fn enum_is_non_exhaustive_at_api_boundary() {
79        let e = ToolCallError::InvalidArgs("x".into());
80        match e {
81            ToolCallError::InvalidArgs(_) => {}
82            ToolCallError::ExecutionFailed { .. } => {}
83            ToolCallError::InternalError(_) => {}
84            ToolCallError::RateLimited { .. } => {}
85        }
86    }
87}