Skip to main content

harn_vm/value/
error.rs

1use harn_lexer::Span;
2
3use super::VmValue;
4
5/// Bound expressing how many arguments a callable accepts. Used in
6/// [`VmError::ArityMismatch`] so error messages can render the exact
7/// signature contract the caller violated.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ArityExpect {
10    /// Exactly N parameters, no defaults, no rest.
11    Exact(usize),
12    /// `min..=max`: some params have defaults but the upper bound is fixed.
13    Range { min: usize, max: usize },
14    /// At least N parameters; further args land in a rest list. Used for
15    /// `print` / `log` / variadics.
16    AtLeast(usize),
17}
18
19impl std::fmt::Display for ArityExpect {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            ArityExpect::Exact(n) => write!(f, "{n}"),
23            ArityExpect::Range { min, max } => write!(f, "{min}..={max}"),
24            ArityExpect::AtLeast(n) => write!(f, "at least {n}"),
25        }
26    }
27}
28
29#[derive(Debug, Clone)]
30pub struct ArityMismatchError {
31    pub callee: String,
32    pub expected: ArityExpect,
33    pub got: usize,
34    pub span: Option<Span>,
35}
36
37#[derive(Debug, Clone)]
38pub struct ArgTypeMismatchError {
39    pub callee: String,
40    pub param: String,
41    pub expected: String,
42    pub got: &'static str,
43    pub span: Option<Span>,
44}
45
46#[derive(Debug, Clone)]
47pub enum VmError {
48    StackUnderflow,
49    StackOverflow,
50    UndefinedVariable(String),
51    UndefinedBuiltin(String),
52    ImmutableAssignment(String),
53    TypeError(String),
54    Runtime(String),
55    DivisionByZero,
56    Thrown(VmValue),
57    /// Thrown with error category for structured error handling.
58    CategorizedError {
59        message: String,
60        category: ErrorCategory,
61    },
62    DaemonQueueFull {
63        daemon_id: String,
64        capacity: usize,
65    },
66    Return(VmValue),
67    InvalidInstruction(u8),
68    /// Wrong number of arguments at a call site. Distinct from
69    /// [`VmError::TypeError`] so the runtime can match-and-recover (and
70    /// so error UX renders `expected 2..=3 got 1` consistently).
71    ArityMismatch(Box<ArityMismatchError>),
72    /// Argument value did not satisfy the declared parameter type.
73    /// `expected` is a pretty-printed type expression; `got` is the value's
74    /// runtime type name (`VmValue::type_name`). Used for both
75    /// user-defined function parameters (with declared types) and
76    /// registry-known builtin parameters.
77    ArgTypeMismatch(Box<ArgTypeMismatchError>),
78}
79
80/// Error categories for structured error handling in agent orchestration.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum ErrorCategory {
83    /// Network/connection timeout
84    Timeout,
85    /// Authentication/authorization failure
86    Auth,
87    /// Rate limit exceeded (HTTP 429 / quota)
88    RateLimit,
89    /// Upstream provider is overloaded (HTTP 503 / 529).
90    /// Distinct from RateLimit: the client hasn't exceeded a quota — the
91    /// provider is shedding load and will recover on its own.
92    Overloaded,
93    /// Provider-side 5xx error (500, 502) that isn't specifically overload.
94    ServerError,
95    /// Network-level transient failure (connection reset, DNS hiccup,
96    /// partial stream) — retryable but not provider-status-coded.
97    TransientNetwork,
98    /// LLM output failed schema validation. Retryable via `schema_retries`.
99    SchemaValidation,
100    /// Tool execution failure
101    ToolError,
102    /// Tool was rejected by the host (not permitted / not in allowlist)
103    ToolRejected,
104    /// Outbound network egress was blocked by policy.
105    EgressBlocked,
106    /// Operation was cancelled
107    Cancelled,
108    /// Resource not found
109    NotFound,
110    /// Circuit breaker is open
111    CircuitOpen,
112    /// LLM cost or token budget would be exceeded
113    BudgetExceeded,
114    /// Generic/unclassified error
115    Generic,
116}
117
118impl ErrorCategory {
119    pub fn as_str(&self) -> &'static str {
120        match self {
121            ErrorCategory::Timeout => "timeout",
122            ErrorCategory::Auth => "auth",
123            ErrorCategory::RateLimit => "rate_limit",
124            ErrorCategory::Overloaded => "overloaded",
125            ErrorCategory::ServerError => "server_error",
126            ErrorCategory::TransientNetwork => "transient_network",
127            ErrorCategory::SchemaValidation => "schema_validation",
128            ErrorCategory::ToolError => "tool_error",
129            ErrorCategory::ToolRejected => "tool_rejected",
130            ErrorCategory::EgressBlocked => "egress_blocked",
131            ErrorCategory::Cancelled => "cancelled",
132            ErrorCategory::NotFound => "not_found",
133            ErrorCategory::CircuitOpen => "circuit_open",
134            ErrorCategory::BudgetExceeded => "budget_exceeded",
135            ErrorCategory::Generic => "generic",
136        }
137    }
138
139    pub fn parse(s: &str) -> Self {
140        match s {
141            "timeout" => ErrorCategory::Timeout,
142            "auth" => ErrorCategory::Auth,
143            "rate_limit" => ErrorCategory::RateLimit,
144            "overloaded" => ErrorCategory::Overloaded,
145            "server_error" => ErrorCategory::ServerError,
146            "transient_network" => ErrorCategory::TransientNetwork,
147            "schema_validation" => ErrorCategory::SchemaValidation,
148            "tool_error" => ErrorCategory::ToolError,
149            "tool_rejected" => ErrorCategory::ToolRejected,
150            "egress_blocked" => ErrorCategory::EgressBlocked,
151            "cancelled" => ErrorCategory::Cancelled,
152            "not_found" => ErrorCategory::NotFound,
153            "circuit_open" => ErrorCategory::CircuitOpen,
154            "budget_exceeded" => ErrorCategory::BudgetExceeded,
155            _ => ErrorCategory::Generic,
156        }
157    }
158
159    /// Whether an error of this category is worth retrying for a transient
160    /// provider-side reason. Agent loops consult this to decide whether to
161    /// back off and retry vs surface the error to the user.
162    pub fn is_transient(&self) -> bool {
163        matches!(
164            self,
165            ErrorCategory::Timeout
166                | ErrorCategory::RateLimit
167                | ErrorCategory::Overloaded
168                | ErrorCategory::ServerError
169                | ErrorCategory::TransientNetwork
170        )
171    }
172}
173
174/// Create a categorized error conveniently.
175pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
176    VmError::CategorizedError {
177        message: message.into(),
178        category,
179    }
180}
181
182/// Extract error category from a VmError.
183///
184/// Classification priority:
185/// 1. Explicit CategorizedError variant (set by throw_error or internal code)
186/// 2. Thrown dict with a "category" field (user-created structured errors)
187/// 3. HTTP status code extraction (standard, unambiguous)
188/// 4. Deadline exceeded (VM-internal)
189/// 5. Fallback to Generic
190pub fn error_to_category(err: &VmError) -> ErrorCategory {
191    match err {
192        VmError::CategorizedError { category, .. } => category.clone(),
193        VmError::Thrown(VmValue::Dict(d)) => d
194            .get("category")
195            .map(|v| ErrorCategory::parse(&v.display()))
196            .unwrap_or(ErrorCategory::Generic),
197        VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
198        VmError::Runtime(msg) => classify_error_message(msg),
199        _ => ErrorCategory::Generic,
200    }
201}
202
203/// Classify an error message using HTTP status codes and well-known patterns.
204/// Prefers unambiguous signals (status codes) over substring heuristics.
205pub fn classify_error_message(msg: &str) -> ErrorCategory {
206    // 1. HTTP status codes — most reliable signal
207    if let Some(cat) = classify_by_http_status(msg) {
208        return cat;
209    }
210    // 2. Well-known error identifiers from major APIs
211    //    (Anthropic, OpenAI, and standard HTTP patterns)
212    if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
213        return ErrorCategory::Timeout;
214    }
215    if msg.contains("overloaded_error") {
216        // Anthropic overloaded_error surfaces as HTTP 529.
217        return ErrorCategory::Overloaded;
218    }
219    if msg.contains("api_error") {
220        // Anthropic catch-all server-side error.
221        return ErrorCategory::ServerError;
222    }
223    if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
224        // OpenAI-specific quota error types.
225        return ErrorCategory::RateLimit;
226    }
227    if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
228        return ErrorCategory::Auth;
229    }
230    if msg.contains("not_found_error") || msg.contains("model_not_found") {
231        return ErrorCategory::NotFound;
232    }
233    if msg.contains("circuit_open") {
234        return ErrorCategory::CircuitOpen;
235    }
236    // Network-level transient patterns (pre-HTTP-status, pre-provider-framing).
237    let lower = msg.to_lowercase();
238    if lower.contains("connection reset")
239        || lower.contains("connection refused")
240        || lower.contains("connection closed")
241        || lower.contains("broken pipe")
242        || lower.contains("dns error")
243        || lower.contains("stream error")
244        || lower.contains("unexpected eof")
245    {
246        return ErrorCategory::TransientNetwork;
247    }
248    ErrorCategory::Generic
249}
250
251/// Classify errors by HTTP status code if one appears in the message.
252/// This is the most reliable classification method since status codes
253/// are standardized (RFC 9110) and unambiguous.
254fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
255    // Extract 3-digit HTTP status codes from common patterns:
256    // "HTTP 429", "status 429", "429 Too Many", "error: 401"
257    for code in extract_http_status_codes(msg) {
258        return Some(match code {
259            401 | 403 => ErrorCategory::Auth,
260            404 | 410 => ErrorCategory::NotFound,
261            408 | 504 | 522 | 524 => ErrorCategory::Timeout,
262            429 => ErrorCategory::RateLimit,
263            503 | 529 => ErrorCategory::Overloaded,
264            500 | 502 => ErrorCategory::ServerError,
265            _ => continue,
266        });
267    }
268    None
269}
270
271/// Extract plausible HTTP status codes from an error message.
272fn extract_http_status_codes(msg: &str) -> Vec<u16> {
273    let mut codes = Vec::new();
274    let bytes = msg.as_bytes();
275    for i in 0..bytes.len().saturating_sub(2) {
276        // Look for 3-digit sequences in the 100-599 range
277        if bytes[i].is_ascii_digit()
278            && bytes[i + 1].is_ascii_digit()
279            && bytes[i + 2].is_ascii_digit()
280        {
281            // Ensure it's not part of a longer number
282            let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
283            let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
284            if before_ok && after_ok {
285                if let Ok(code) = msg[i..i + 3].parse::<u16>() {
286                    if (400..=599).contains(&code) {
287                        codes.push(code);
288                    }
289                }
290            }
291        }
292    }
293    codes
294}
295
296impl std::fmt::Display for VmError {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        match self {
299            VmError::StackUnderflow => write!(f, "Stack underflow"),
300            VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
301            VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
302            VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
303            VmError::ImmutableAssignment(n) => {
304                write!(f, "Cannot assign to immutable binding: {n}")
305            }
306            VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
307            VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
308            VmError::DivisionByZero => write!(f, "Division by zero"),
309            VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
310            VmError::CategorizedError { message, category } => {
311                write!(f, "Error [{}]: {}", category.as_str(), message)
312            }
313            VmError::DaemonQueueFull {
314                daemon_id,
315                capacity,
316            } => write!(
317                f,
318                "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
319            ),
320            VmError::Return(_) => write!(f, "Return from function"),
321            VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
322            VmError::ArityMismatch(err) => {
323                write!(
324                    f,
325                    "Arity mismatch: '{}' expects {} argument(s), got {}{}",
326                    err.callee,
327                    err.expected,
328                    err.got,
329                    fmt_span_suffix(&err.span)
330                )
331            }
332            VmError::ArgTypeMismatch(err) => {
333                write!(
334                    f,
335                    "Type error: '{}' parameter `{}` expects {}, got {}{}",
336                    err.callee,
337                    err.param,
338                    err.expected,
339                    err.got,
340                    fmt_span_suffix(&err.span)
341                )
342            }
343        }
344    }
345}
346
347fn fmt_span_suffix(span: &Option<Span>) -> String {
348    match span {
349        Some(s) => format!(" (at byte {}..{})", s.start, s.end),
350        None => String::new(),
351    }
352}
353
354impl std::error::Error for VmError {}