Skip to main content

harness_core/
errors.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4/// Stable set of machine-readable error codes emitted by any harness tool.
5///
6/// Mirrors `ToolErrorCode` in `@agent-sh/harness-core`. The string form on
7/// the wire uses the snake_case-ish shape the TS side has been shipping
8/// (`NOT_FOUND`, `INVALID_PARAM`, ...) so a TS consumer parsing the
9/// JSON-RPC result of a Rust tool sees the same codes it's already
10/// pattern-matching on.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
13pub enum ToolErrorCode {
14    NotFound,
15    Binary,
16    TooLarge,
17    OutsideWorkspace,
18    Sensitive,
19    PermissionDenied,
20    InvalidParam,
21    IoError,
22    NotReadThisSession,
23    StaleRead,
24    OldStringNotFound,
25    OldStringNotUnique,
26    EmptyFile,
27    NoOpEdit,
28    BinaryNotEditable,
29    NotebookUnsupported,
30    DeniedByHook,
31    ValidateFailed,
32    InvalidRegex,
33    Timeout,
34    Killed,
35    InvalidUrl,
36    SsrfBlocked,
37    DnsError,
38    TlsError,
39    ConnectionReset,
40    Oversize,
41    UnsupportedContentType,
42    RedirectLoop,
43    InteractiveDetected,
44    ServerNotAvailable,
45    ServerCrashed,
46    PositionInvalid,
47    InvalidFrontmatter,
48    NameMismatch,
49    Disabled,
50    NotTrusted,
51}
52
53impl ToolErrorCode {
54    /// Canonical wire form: `"INVALID_PARAM"`, `"NOT_FOUND"`, etc.
55    pub fn as_str(&self) -> &'static str {
56        // serde's SCREAMING_SNAKE_CASE produces the right strings, but
57        // we hand-roll this for `Display` so error formatting doesn't
58        // need to go through a serializer.
59        match self {
60            Self::NotFound => "NOT_FOUND",
61            Self::Binary => "BINARY",
62            Self::TooLarge => "TOO_LARGE",
63            Self::OutsideWorkspace => "OUTSIDE_WORKSPACE",
64            Self::Sensitive => "SENSITIVE",
65            Self::PermissionDenied => "PERMISSION_DENIED",
66            Self::InvalidParam => "INVALID_PARAM",
67            Self::IoError => "IO_ERROR",
68            Self::NotReadThisSession => "NOT_READ_THIS_SESSION",
69            Self::StaleRead => "STALE_READ",
70            Self::OldStringNotFound => "OLD_STRING_NOT_FOUND",
71            Self::OldStringNotUnique => "OLD_STRING_NOT_UNIQUE",
72            Self::EmptyFile => "EMPTY_FILE",
73            Self::NoOpEdit => "NO_OP_EDIT",
74            Self::BinaryNotEditable => "BINARY_NOT_EDITABLE",
75            Self::NotebookUnsupported => "NOTEBOOK_UNSUPPORTED",
76            Self::DeniedByHook => "DENIED_BY_HOOK",
77            Self::ValidateFailed => "VALIDATE_FAILED",
78            Self::InvalidRegex => "INVALID_REGEX",
79            Self::Timeout => "TIMEOUT",
80            Self::Killed => "KILLED",
81            Self::InvalidUrl => "INVALID_URL",
82            Self::SsrfBlocked => "SSRF_BLOCKED",
83            Self::DnsError => "DNS_ERROR",
84            Self::TlsError => "TLS_ERROR",
85            Self::ConnectionReset => "CONNECTION_RESET",
86            Self::Oversize => "OVERSIZE",
87            Self::UnsupportedContentType => "UNSUPPORTED_CONTENT_TYPE",
88            Self::RedirectLoop => "REDIRECT_LOOP",
89            Self::InteractiveDetected => "INTERACTIVE_DETECTED",
90            Self::ServerNotAvailable => "SERVER_NOT_AVAILABLE",
91            Self::ServerCrashed => "SERVER_CRASHED",
92            Self::PositionInvalid => "POSITION_INVALID",
93            Self::InvalidFrontmatter => "INVALID_FRONTMATTER",
94            Self::NameMismatch => "NAME_MISMATCH",
95            Self::Disabled => "DISABLED",
96            Self::NotTrusted => "NOT_TRUSTED",
97        }
98    }
99}
100
101/// Structured tool error, shape-compatible with the TS `ToolError`.
102///
103/// `meta` stores arbitrary JSON payload fields (e.g. `{"path": "...",
104/// "siblings": [...]}`) that callers can inspect without parsing the
105/// message text. `cause` is an opaque JSON blob preserving the
106/// underlying error chain when available.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ToolError {
109    pub code: ToolErrorCode,
110    pub message: String,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub cause: Option<Value>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub meta: Option<Value>,
115}
116
117impl ToolError {
118    pub fn new(code: ToolErrorCode, message: impl Into<String>) -> Self {
119        Self {
120            code,
121            message: message.into(),
122            cause: None,
123            meta: None,
124        }
125    }
126
127    pub fn with_meta(mut self, meta: Value) -> Self {
128        self.meta = Some(meta);
129        self
130    }
131
132    pub fn with_cause(mut self, cause: Value) -> Self {
133        self.cause = Some(cause);
134        self
135    }
136}
137
138impl std::fmt::Display for ToolError {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        write!(f, "{}", format_tool_error(self))
141    }
142}
143
144impl std::error::Error for ToolError {}
145
146/// Canonical model-facing rendering: `"Error [CODE]: message"`. This is
147/// the string the Node e2e harness reads from the tool_result block.
148pub fn format_tool_error(err: &ToolError) -> String {
149    format!("Error [{}]: {}", err.code.as_str(), err.message)
150}