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    /// LLM streaming response was aborted mid-stream because the partial
101    /// JSON content could not conceivably satisfy `output_schema`. Surfaced
102    /// by `llm_call` when `schema_stream_abort` is on (the default for
103    /// schema-bearing calls). Consumes one `schema_retries` budget slot;
104    /// the retry replays the prompt with a corrective nudge that cites
105    /// the abort path + reason.
106    SchemaStreamAborted,
107    /// Tool execution failure
108    ToolError,
109    /// Tool was rejected by the host (not permitted / not in allowlist)
110    ToolRejected,
111    /// Outbound network egress was blocked by policy.
112    EgressBlocked,
113    /// Operation was cancelled
114    Cancelled,
115    /// Resource not found
116    NotFound,
117    /// Circuit breaker is open
118    CircuitOpen,
119    /// LLM cost or token budget would be exceeded
120    BudgetExceeded,
121    /// Generic/unclassified error
122    Generic,
123}
124
125impl ErrorCategory {
126    pub fn as_str(&self) -> &'static str {
127        match self {
128            ErrorCategory::Timeout => "timeout",
129            ErrorCategory::Auth => "auth",
130            ErrorCategory::RateLimit => "rate_limit",
131            ErrorCategory::Overloaded => "overloaded",
132            ErrorCategory::ServerError => "server_error",
133            ErrorCategory::TransientNetwork => "transient_network",
134            ErrorCategory::SchemaValidation => "schema_validation",
135            ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
136            ErrorCategory::ToolError => "tool_error",
137            ErrorCategory::ToolRejected => "tool_rejected",
138            ErrorCategory::EgressBlocked => "egress_blocked",
139            ErrorCategory::Cancelled => "cancelled",
140            ErrorCategory::NotFound => "not_found",
141            ErrorCategory::CircuitOpen => "circuit_open",
142            ErrorCategory::BudgetExceeded => "budget_exceeded",
143            ErrorCategory::Generic => "generic",
144        }
145    }
146
147    pub fn parse(s: &str) -> Self {
148        match s {
149            "timeout" => ErrorCategory::Timeout,
150            "auth" => ErrorCategory::Auth,
151            "rate_limit" => ErrorCategory::RateLimit,
152            "overloaded" => ErrorCategory::Overloaded,
153            "server_error" => ErrorCategory::ServerError,
154            "transient_network" => ErrorCategory::TransientNetwork,
155            "schema_validation" => ErrorCategory::SchemaValidation,
156            "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
157            "tool_error" => ErrorCategory::ToolError,
158            "tool_rejected" => ErrorCategory::ToolRejected,
159            "egress_blocked" => ErrorCategory::EgressBlocked,
160            "cancelled" => ErrorCategory::Cancelled,
161            "not_found" => ErrorCategory::NotFound,
162            "circuit_open" => ErrorCategory::CircuitOpen,
163            "budget_exceeded" => ErrorCategory::BudgetExceeded,
164            _ => ErrorCategory::Generic,
165        }
166    }
167
168    /// Whether an error of this category is worth retrying for a transient
169    /// provider-side reason. Agent loops consult this to decide whether to
170    /// back off and retry vs surface the error to the user.
171    pub fn is_transient(&self) -> bool {
172        matches!(
173            self,
174            ErrorCategory::Timeout
175                | ErrorCategory::RateLimit
176                | ErrorCategory::Overloaded
177                | ErrorCategory::ServerError
178                | ErrorCategory::TransientNetwork
179        )
180    }
181}
182
183/// Create a categorized error conveniently.
184pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
185    VmError::CategorizedError {
186        message: message.into(),
187        category,
188    }
189}
190
191/// Extract error category from a VmError.
192///
193/// Classification priority:
194/// 1. Explicit CategorizedError variant (set by throw_error or internal code)
195/// 2. Thrown dict with a "category" field (user-created structured errors)
196/// 3. HTTP status code extraction (standard, unambiguous)
197/// 4. Deadline exceeded (VM-internal)
198/// 5. Fallback to Generic
199pub fn error_to_category(err: &VmError) -> ErrorCategory {
200    match err {
201        VmError::CategorizedError { category, .. } => category.clone(),
202        VmError::Thrown(VmValue::Dict(d)) => d
203            .get("category")
204            .map(|v| ErrorCategory::parse(&v.display()))
205            .unwrap_or(ErrorCategory::Generic),
206        VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
207        VmError::Runtime(msg) => classify_error_message(msg),
208        _ => ErrorCategory::Generic,
209    }
210}
211
212/// Classify an error message using HTTP status codes and well-known patterns.
213/// Prefers unambiguous signals (status codes) over substring heuristics.
214pub fn classify_error_message(msg: &str) -> ErrorCategory {
215    // 1. HTTP status codes — most reliable signal
216    if let Some(cat) = classify_by_http_status(msg) {
217        return cat;
218    }
219    // 2. Well-known error identifiers from major APIs
220    //    (Anthropic, OpenAI, and standard HTTP patterns)
221    let lower = msg.to_lowercase();
222    if lower.contains("cancelled") || lower.contains("canceled") {
223        return ErrorCategory::Cancelled;
224    }
225    if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
226        return ErrorCategory::Timeout;
227    }
228    if msg.contains("overloaded_error") {
229        // Anthropic overloaded_error surfaces as HTTP 529.
230        return ErrorCategory::Overloaded;
231    }
232    if msg.contains("api_error") {
233        // Anthropic catch-all server-side error.
234        return ErrorCategory::ServerError;
235    }
236    if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
237        // OpenAI-specific quota error types.
238        return ErrorCategory::RateLimit;
239    }
240    if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
241        return ErrorCategory::Auth;
242    }
243    if msg.contains("not_found_error") || msg.contains("model_not_found") {
244        return ErrorCategory::NotFound;
245    }
246    if msg.contains("circuit_open") {
247        return ErrorCategory::CircuitOpen;
248    }
249    // Network-level transient patterns (pre-HTTP-status, pre-provider-framing).
250    if lower.contains("connection reset")
251        || lower.contains("connection refused")
252        || lower.contains("connection closed")
253        || lower.contains("broken pipe")
254        || lower.contains("dns error")
255        || lower.contains("stream error")
256        || lower.contains("unexpected eof")
257    {
258        return ErrorCategory::TransientNetwork;
259    }
260    ErrorCategory::Generic
261}
262
263/// Classify errors by HTTP status code if one appears in the message.
264/// This is the most reliable classification method since status codes
265/// are standardized (RFC 9110) and unambiguous.
266fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
267    // Extract 3-digit HTTP status codes from common patterns:
268    // "HTTP 429", "status 429", "429 Too Many", "error: 401"
269    for code in extract_http_status_codes(msg) {
270        return Some(match code {
271            401 | 403 => ErrorCategory::Auth,
272            404 | 410 => ErrorCategory::NotFound,
273            408 | 504 | 522 | 524 => ErrorCategory::Timeout,
274            429 => ErrorCategory::RateLimit,
275            503 | 529 => ErrorCategory::Overloaded,
276            500 | 502 => ErrorCategory::ServerError,
277            _ => continue,
278        });
279    }
280    None
281}
282
283/// Extract plausible HTTP status codes from an error message.
284fn extract_http_status_codes(msg: &str) -> Vec<u16> {
285    let mut codes = Vec::new();
286    let bytes = msg.as_bytes();
287    for i in 0..bytes.len().saturating_sub(2) {
288        // Look for 3-digit sequences in the 100-599 range
289        if bytes[i].is_ascii_digit()
290            && bytes[i + 1].is_ascii_digit()
291            && bytes[i + 2].is_ascii_digit()
292        {
293            // Ensure it's not part of a longer number
294            let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
295            let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
296            if before_ok && after_ok {
297                if let Ok(code) = msg[i..i + 3].parse::<u16>() {
298                    if (400..=599).contains(&code) {
299                        codes.push(code);
300                    }
301                }
302            }
303        }
304    }
305    codes
306}
307
308impl std::fmt::Display for VmError {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        match self {
311            VmError::StackUnderflow => write!(f, "Stack underflow"),
312            VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
313            VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
314            VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
315            VmError::ImmutableAssignment(n) => {
316                write!(f, "Cannot assign to immutable binding: {n}")
317            }
318            VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
319            VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
320            VmError::DivisionByZero => write!(f, "Division by zero"),
321            VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
322            VmError::CategorizedError { message, category } => {
323                write!(f, "Error [{}]: {}", category.as_str(), message)
324            }
325            VmError::DaemonQueueFull {
326                daemon_id,
327                capacity,
328            } => write!(
329                f,
330                "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
331            ),
332            VmError::Return(_) => write!(f, "Return from function"),
333            VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
334            VmError::ArityMismatch(err) => {
335                let arg_word = match err.expected {
336                    ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
337                    _ => "arguments",
338                };
339                write!(
340                    f,
341                    "Arity mismatch: '{}' expects {} {}, got {}{}",
342                    err.callee,
343                    err.expected,
344                    arg_word,
345                    err.got,
346                    fmt_span_suffix(&err.span)
347                )
348            }
349            VmError::ArgTypeMismatch(err) => {
350                write!(
351                    f,
352                    "Type error: '{}' parameter `{}` expects {}, got {}{}",
353                    err.callee,
354                    err.param,
355                    err.expected,
356                    err.got,
357                    fmt_span_suffix(&err.span)
358                )
359            }
360        }
361    }
362}
363
364fn fmt_span_suffix(span: &Option<Span>) -> String {
365    match span {
366        Some(s) => format!(" (at byte {}..{})", s.start, s.end),
367        None => String::new(),
368    }
369}
370
371impl std::error::Error for VmError {}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn classifies_cancelled_messages() {
379        assert_eq!(
380            classify_error_message("Bridge: operation cancelled"),
381            ErrorCategory::Cancelled
382        );
383        assert_eq!(
384            classify_error_message("operation canceled by host"),
385            ErrorCategory::Cancelled
386        );
387    }
388}