Skip to main content

harn_vm/value/
error.rs

1use harn_lexer::Span;
2
3use super::{VmDictExt, 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, Copy, PartialEq, Eq)]
38pub enum DeadlockDiagnostic {
39    SelfDeadlock,
40    WaitForGraph,
41}
42
43impl DeadlockDiagnostic {
44    fn code(self) -> &'static str {
45        match self {
46            Self::SelfDeadlock => "HARN-ORC-011",
47            Self::WaitForGraph => "HARN-ORC-012",
48        }
49    }
50}
51
52/// Payload for [`VmError::Deadlock`]. `kind` is the primitive kind
53/// (`"mutex"`, `"channel"`) or `"task"`; `key` is the primitive key or task
54/// id; `detail` names the specific footgun.
55#[derive(Debug, Clone)]
56pub struct DeadlockError {
57    pub diagnostic: DeadlockDiagnostic,
58    pub kind: String,
59    pub key: String,
60    pub detail: String,
61}
62
63impl DeadlockError {
64    pub(crate) fn self_deadlock(
65        kind: impl Into<String>,
66        key: impl Into<String>,
67        detail: impl Into<String>,
68    ) -> Self {
69        Self {
70            diagnostic: DeadlockDiagnostic::SelfDeadlock,
71            kind: kind.into(),
72            key: key.into(),
73            detail: detail.into(),
74        }
75    }
76
77    pub(crate) fn wait_for_graph(
78        kind: impl Into<String>,
79        key: impl Into<String>,
80        detail: impl Into<String>,
81    ) -> Self {
82        Self {
83            diagnostic: DeadlockDiagnostic::WaitForGraph,
84            kind: kind.into(),
85            key: key.into(),
86            detail: detail.into(),
87        }
88    }
89}
90
91#[derive(Debug, Clone)]
92pub struct ArgTypeMismatchError {
93    pub callee: String,
94    pub param: String,
95    pub expected: String,
96    pub got: &'static str,
97    pub span: Option<Span>,
98}
99
100#[derive(Debug, Clone)]
101pub enum VmError {
102    StackUnderflow,
103    StackOverflow,
104    UndefinedVariable(String),
105    UndefinedBuiltin(String),
106    ImmutableAssignment(String),
107    TypeError(String),
108    Runtime(String),
109    DivisionByZero,
110    /// A host-imposed deadline expired while executing the VM. Unlike a Harn
111    /// `deadline` block, this control-plane stop cannot be caught by user code.
112    ExecutionDeadlineExceeded,
113    /// A Harn program requested that its embedding process terminate with this
114    /// status code. Like a host deadline, this is control flow rather than a
115    /// catchable Harn error; the embedding boundary owns the final cleanup and
116    /// process exit.
117    ProcessExit(i32),
118    /// A host dropped a polled top-level execution future. Interpreter state is
119    /// intentionally not resumed after arbitrary async cancellation. Discard
120    /// this VM; see [`crate::Vm::execute_with_timeout`] for ambient-state
121    /// cleanup requirements.
122    AbandonedExecution,
123    Thrown(VmValue),
124    /// Thrown with error category for structured error handling.
125    CategorizedError {
126        message: String,
127        category: ErrorCategory,
128    },
129    DaemonQueueFull {
130        daemon_id: String,
131        capacity: usize,
132    },
133    /// A deterministic, provably-unresolvable self-deadlock caught before the
134    /// VM would block forever (Rust's borrow checker prevents data races but
135    /// not deadlocks; this is the Go-runtime "all goroutines asleep" analogue
136    /// for the cases we can prove). Boxed — like [`VmError::ArityMismatch`] —
137    /// so the rare three-`String` payload doesn't enlarge `VmError` on the
138    /// pervasive `Result<VmValue, VmError>` hot path. Carries `HARN-ORC-011`.
139    Deadlock(Box<DeadlockError>),
140    Return(VmValue),
141    InvalidInstruction(u8),
142    /// Wrong number of arguments at a call site. Distinct from
143    /// [`VmError::TypeError`] so the runtime can match-and-recover (and
144    /// so error UX renders `expected 2..=3 got 1` consistently).
145    ArityMismatch(Box<ArityMismatchError>),
146    /// Argument value did not satisfy the declared parameter type.
147    /// `expected` is a pretty-printed type expression; `got` is the value's
148    /// runtime type name (`VmValue::type_name`). Used for both
149    /// user-defined function parameters (with declared types) and
150    /// registry-known builtin parameters.
151    ArgTypeMismatch(Box<ArgTypeMismatchError>),
152}
153
154impl VmError {
155    /// Whether this error is VM control flow that user `catch` blocks and
156    /// error-as-data combinators must propagate unchanged.
157    pub fn is_uncatchable_control_flow(&self) -> bool {
158        matches!(self, Self::ExecutionDeadlineExceeded | Self::ProcessExit(_))
159    }
160
161    /// The requested host-process exit status, when this is an explicit Harn
162    /// `exit(code)` control signal.
163    pub fn process_exit_code(&self) -> Option<i32> {
164        match self {
165            Self::ProcessExit(code) => Some(*code),
166            _ => None,
167        }
168    }
169
170    /// The `VmValue` a `catch` binding (or a `parallel settle` result) observes
171    /// for this error: the raw thrown value for [`VmError::Thrown`] (so a
172    /// structured error — e.g. a `{category, message}` dict from `throw_error` —
173    /// keeps its shape and category), a structured `{category, message}` dict for
174    /// [`VmError::CategorizedError`] (so consumers branch on the typed category
175    /// rather than substring-matching rendered prose), otherwise the rendered
176    /// message.
177    ///
178    /// Single source of truth for VM-error-to-value lowering so every seam that
179    /// surfaces a caught error to Harn (`try`/`catch` via `handle_error`,
180    /// `parallel settle`) exposes identical, structure-preserving values. Before
181    /// this was shared, `parallel settle` stringified errors via `to_string()`,
182    /// so a categorized error thrown in a settle branch lost its category (a
183    /// `cancelled`/`internal` fault that must propagate looked `generic`).
184    ///
185    /// The `category` key uses [`ErrorCategory::as_str`] — a canonical,
186    /// exhaustively-matched snake_case contract — so a new variant added there
187    /// is compiler-forced to name its key. `message` preserves the original
188    /// rendered text, so a `catch` that stringifies the caught value still reads
189    /// sensibly (the dict renders both fields).
190    pub fn thrown_value(&self) -> VmValue {
191        match self {
192            VmError::Thrown(v) => v.clone(),
193            VmError::CategorizedError { message, category } => {
194                let mut dict = std::collections::BTreeMap::new();
195                dict.put_str("category", category.as_str());
196                dict.put_str("message", message);
197                VmValue::dict(dict)
198            }
199            other => VmValue::String(arcstr::ArcStr::from(other.to_string())),
200        }
201    }
202}
203
204/// Error categories for structured error handling in agent orchestration.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum ErrorCategory {
207    /// Network/connection timeout
208    Timeout,
209    /// Authentication/authorization failure
210    Auth,
211    /// Rate limit exceeded (HTTP 429 / quota)
212    RateLimit,
213    /// Upstream provider is overloaded (HTTP 503 / 529).
214    /// Distinct from RateLimit: the client hasn't exceeded a quota — the
215    /// provider is shedding load and will recover on its own.
216    Overloaded,
217    /// Provider-side 5xx error (500, 502) that isn't specifically overload.
218    ServerError,
219    /// Network-level transient failure (connection reset, DNS hiccup,
220    /// partial stream) — retryable but not provider-status-coded.
221    TransientNetwork,
222    /// A shared local resource is temporarily unavailable, such as a
223    /// contended database write lock.
224    ResourceBusy,
225    /// LLM output failed schema validation. Retryable via `schema_retries`.
226    SchemaValidation,
227    /// LLM streaming response was aborted mid-stream because the partial
228    /// JSON content could not conceivably satisfy `output_schema`. Surfaced
229    /// by `llm_call` when `schema_stream_abort` is on (the default for
230    /// schema-bearing calls). Consumes one `schema_retries` budget slot;
231    /// the retry replays the prompt with a corrective nudge that cites
232    /// the abort path + reason.
233    SchemaStreamAborted,
234    /// Tool execution failure
235    ToolError,
236    /// Tool was rejected by the host (not permitted / not in allowlist)
237    ToolRejected,
238    /// Outbound network egress was blocked by policy.
239    EgressBlocked,
240    /// Operation was cancelled
241    Cancelled,
242    /// Channel was closed before the operation could complete.
243    ChannelClosed,
244    /// Resource not found
245    NotFound,
246    /// Circuit breaker is open
247    CircuitOpen,
248    /// LLM cost or token budget would be exceeded
249    BudgetExceeded,
250    /// An internal engine/wiring bug — an undefined builtin, corrupt bytecode,
251    /// or another VM invariant violation that no amount of retrying or model
252    /// reasoning can fix. Distinct from `Generic` so callers (notably the agent
253    /// loop) can re-raise it loudly instead of folding it into a tool-error
254    /// observation and marching on to a `done` status. This is the category
255    /// that keeps a mis-wired builtin (e.g. a `#[harn_builtin]` def missing
256    /// from its install array) from shipping silently inert.
257    Internal,
258    /// A host environment / infrastructure problem that is not the workload's
259    /// code defect: a required developer-toolchain root or cache lies outside
260    /// the sandbox profile, a needed system binary is missing, or another
261    /// machine-provisioning gap. Distinct from `ToolRejected` (the host
262    /// deliberately refused an action) and `Internal` (an engine bug): the fix
263    /// is to widen the sandbox/config or provision the host, not to change the
264    /// agent's code. Callers (and embedders) branch on this to avoid blaming
265    /// the model for an environment gap.
266    Environment,
267    /// Generic/unclassified error
268    Generic,
269}
270
271impl ErrorCategory {
272    pub fn as_str(&self) -> &'static str {
273        match self {
274            ErrorCategory::Timeout => "timeout",
275            ErrorCategory::Auth => "auth",
276            ErrorCategory::RateLimit => "rate_limit",
277            ErrorCategory::Overloaded => "overloaded",
278            ErrorCategory::ServerError => "server_error",
279            ErrorCategory::TransientNetwork => "transient_network",
280            ErrorCategory::ResourceBusy => "resource_busy",
281            ErrorCategory::SchemaValidation => "schema_validation",
282            ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
283            ErrorCategory::ToolError => "tool_error",
284            ErrorCategory::ToolRejected => "tool_rejected",
285            ErrorCategory::EgressBlocked => "egress_blocked",
286            ErrorCategory::Cancelled => "cancelled",
287            ErrorCategory::ChannelClosed => "channel_closed",
288            ErrorCategory::NotFound => "not_found",
289            ErrorCategory::CircuitOpen => "circuit_open",
290            ErrorCategory::BudgetExceeded => "budget_exceeded",
291            ErrorCategory::Internal => "internal",
292            ErrorCategory::Environment => "environment",
293            ErrorCategory::Generic => "generic",
294        }
295    }
296
297    pub fn parse(s: &str) -> Self {
298        match s {
299            "timeout" => ErrorCategory::Timeout,
300            "auth" => ErrorCategory::Auth,
301            "rate_limit" => ErrorCategory::RateLimit,
302            "overloaded" => ErrorCategory::Overloaded,
303            "server_error" => ErrorCategory::ServerError,
304            "transient_network" => ErrorCategory::TransientNetwork,
305            "resource_busy" => ErrorCategory::ResourceBusy,
306            "schema_validation" => ErrorCategory::SchemaValidation,
307            "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
308            "tool_error" => ErrorCategory::ToolError,
309            "tool_rejected" => ErrorCategory::ToolRejected,
310            "egress_blocked" => ErrorCategory::EgressBlocked,
311            "cancelled" => ErrorCategory::Cancelled,
312            "channel_closed" => ErrorCategory::ChannelClosed,
313            "not_found" => ErrorCategory::NotFound,
314            "circuit_open" => ErrorCategory::CircuitOpen,
315            "budget_exceeded" => ErrorCategory::BudgetExceeded,
316            "internal" => ErrorCategory::Internal,
317            "environment" => ErrorCategory::Environment,
318            _ => ErrorCategory::Generic,
319        }
320    }
321
322    /// Whether this category represents an internal engine/wiring bug that must
323    /// be surfaced rather than retried or swallowed as a recoverable failure.
324    pub fn is_internal(&self) -> bool {
325        matches!(self, ErrorCategory::Internal)
326    }
327
328    /// Whether an error of this category is worth retrying because the
329    /// underlying condition is transient. Agent loops consult this to decide
330    /// whether to back off and retry vs surface the error to the user.
331    pub fn is_transient(&self) -> bool {
332        matches!(
333            self,
334            ErrorCategory::Timeout
335                | ErrorCategory::RateLimit
336                | ErrorCategory::Overloaded
337                | ErrorCategory::ServerError
338                | ErrorCategory::TransientNetwork
339                | ErrorCategory::ResourceBusy
340        )
341    }
342}
343
344/// Create a categorized error conveniently.
345pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
346    VmError::CategorizedError {
347        message: message.into(),
348        category,
349    }
350}
351
352/// Extract error category from a VmError.
353///
354/// Classification priority:
355/// 1. Explicit CategorizedError variant (set by throw_error or internal code)
356/// 2. Thrown dict with a "category" field (user-created structured errors)
357/// 3. HTTP status code extraction (standard, unambiguous)
358/// 4. Deadline exceeded (VM-internal)
359/// 5. Fallback to Generic
360pub fn error_to_category(err: &VmError) -> ErrorCategory {
361    match err {
362        VmError::ExecutionDeadlineExceeded => ErrorCategory::Timeout,
363        // ProcessExit is uncatchable control flow rather than an agent-facing
364        // failure. Keep this fallback total for callers that classify an
365        // arbitrary VmError without treating the request as retryable.
366        VmError::ProcessExit(_) => ErrorCategory::Generic,
367        VmError::AbandonedExecution => ErrorCategory::Cancelled,
368        VmError::CategorizedError { category, .. } => category.clone(),
369        VmError::Thrown(VmValue::Dict(d)) => d
370            .get("category")
371            .map(|v| ErrorCategory::parse(&v.display()))
372            .unwrap_or(ErrorCategory::Generic),
373        VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
374        VmError::Runtime(msg) => classify_error_message(msg),
375        // Engine/wiring bugs: an undefined builtin (declared but not installed,
376        // or a typo in stdlib/host code) or corrupt bytecode. No retry or model
377        // reasoning fixes these, so they get their own category the agent loop
378        // re-raises instead of swallowing.
379        VmError::UndefinedBuiltin(_) | VmError::InvalidInstruction(_) => ErrorCategory::Internal,
380        // A deadlock is permanently non-retryable and not provider-related —
381        // `Generic` is the correct "surface it, don't back off" bucket.
382        VmError::Deadlock(_) => ErrorCategory::Generic,
383        _ => ErrorCategory::Generic,
384    }
385}
386
387/// Classify an error message using HTTP status codes and well-known patterns.
388/// Prefers unambiguous signals (status codes) over substring heuristics.
389pub fn classify_error_message(msg: &str) -> ErrorCategory {
390    // 1. HTTP status codes — most reliable signal
391    if let Some(cat) = classify_by_http_status(msg) {
392        return cat;
393    }
394    // 2. Internal engine/wiring bug surfaced as a plain message. Some call
395    //    sites build `Runtime("Undefined builtin: …")` strings instead of the
396    //    structured `VmError::UndefinedBuiltin` variant; classify both the same
397    //    so the agent loop re-raises rather than swallows.
398    if msg.contains("Undefined builtin") {
399        return ErrorCategory::Internal;
400    }
401    // 3. Well-known error identifiers from major APIs
402    //    (Anthropic, OpenAI, and standard HTTP patterns)
403    let lower = msg.to_lowercase();
404    if lower.contains("cancelled") || lower.contains("canceled") {
405        return ErrorCategory::Cancelled;
406    }
407    if msg.contains("ChannelClosed") || lower.contains("channel closed") {
408        return ErrorCategory::ChannelClosed;
409    }
410    if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
411        return ErrorCategory::Timeout;
412    }
413    if msg.contains("overloaded_error") {
414        // Anthropic overloaded_error surfaces as HTTP 529.
415        return ErrorCategory::Overloaded;
416    }
417    if msg.contains("api_error") {
418        // Anthropic catch-all server-side error.
419        return ErrorCategory::ServerError;
420    }
421    if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
422        // OpenAI-specific quota error types.
423        return ErrorCategory::RateLimit;
424    }
425    if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
426        return ErrorCategory::Auth;
427    }
428    if msg.contains("not_found_error") || msg.contains("model_not_found") {
429        return ErrorCategory::NotFound;
430    }
431    // OpenRouter reports an unknown model as HTTP 400 with the body
432    // "<id> is not a valid model ID" — no status-code or typed-error signal
433    // that `classify_by_http_status` / the checks above can latch onto. Map
434    // the prose to NotFound so it lines up with Cerebras's 404 path (and with
435    // `errors::is_model_unavailable`'s reason taxonomy).
436    if lower.contains("is not a valid model id") || lower.contains("invalid model id") {
437        return ErrorCategory::NotFound;
438    }
439    if msg.contains("circuit_open") {
440        return ErrorCategory::CircuitOpen;
441    }
442    // Network-level transient patterns (pre-HTTP-status, pre-provider-framing).
443    if lower.contains("connection reset")
444        || lower.contains("connection refused")
445        || lower.contains("connection closed")
446        || lower.contains("broken pipe")
447        || lower.contains("dns error")
448        || lower.contains("stream error")
449        || lower.contains("unexpected eof")
450    {
451        return ErrorCategory::TransientNetwork;
452    }
453    ErrorCategory::Generic
454}
455
456/// Classify errors by HTTP status code if one appears in the message.
457/// This is the most reliable classification method since status codes
458/// are standardized (RFC 9110) and unambiguous.
459fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
460    // Extract 3-digit HTTP status codes from common patterns:
461    // "HTTP 429", "status 429", "429 Too Many", "error: 401"
462    for code in extract_http_status_codes(msg) {
463        return Some(match code {
464            401 | 403 => ErrorCategory::Auth,
465            404 | 410 => ErrorCategory::NotFound,
466            408 | 504 | 522 | 524 => ErrorCategory::Timeout,
467            429 => ErrorCategory::RateLimit,
468            503 | 529 => ErrorCategory::Overloaded,
469            500 | 502 => ErrorCategory::ServerError,
470            _ => continue,
471        });
472    }
473    None
474}
475
476/// Extract plausible HTTP status codes from an error message.
477fn extract_http_status_codes(msg: &str) -> Vec<u16> {
478    let mut codes = Vec::new();
479    let bytes = msg.as_bytes();
480    for i in 0..bytes.len().saturating_sub(2) {
481        // Look for 3-digit sequences in the 100-599 range
482        if bytes[i].is_ascii_digit()
483            && bytes[i + 1].is_ascii_digit()
484            && bytes[i + 2].is_ascii_digit()
485        {
486            // Ensure it's not part of a longer number
487            let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
488            let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
489            if before_ok && after_ok {
490                if let Ok(code) = msg[i..i + 3].parse::<u16>() {
491                    if (400..=599).contains(&code) {
492                        codes.push(code);
493                    }
494                }
495            }
496        }
497    }
498    codes
499}
500
501impl std::fmt::Display for VmError {
502    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
503        match self {
504            VmError::StackUnderflow => write!(f, "Stack underflow"),
505            VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
506            VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
507            VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
508            VmError::ImmutableAssignment(n) => {
509                write!(f, "Cannot assign to immutable binding: {n}")
510            }
511            VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
512            VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
513            VmError::DivisionByZero => write!(f, "Division by zero"),
514            VmError::ExecutionDeadlineExceeded => write!(f, "Execution deadline exceeded"),
515            VmError::ProcessExit(code) => write!(f, "Process exit requested: {code}"),
516            VmError::AbandonedExecution => write!(
517                f,
518                "Execution future was abandoned; discard this VM and reset its exclusively owned execution context"
519            ),
520            VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
521            VmError::CategorizedError { message, category } => {
522                write!(f, "Error [{}]: {}", category.as_str(), message)
523            }
524            VmError::DaemonQueueFull {
525                daemon_id,
526                capacity,
527            } => write!(
528                f,
529                "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
530            ),
531            VmError::Deadlock(err) => match err.diagnostic {
532                DeadlockDiagnostic::SelfDeadlock => write!(
533                    f,
534                    "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
535                    err.diagnostic.code(),
536                    err.detail,
537                    err.kind,
538                    err.key
539                ),
540                DeadlockDiagnostic::WaitForGraph => write!(
541                    f,
542                    "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
543                    err.diagnostic.code(),
544                    err.detail,
545                    err.kind,
546                    err.key
547                ),
548            },
549            VmError::Return(_) => write!(f, "Return from function"),
550            VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
551            VmError::ArityMismatch(err) => {
552                let arg_word = match err.expected {
553                    ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
554                    _ => "arguments",
555                };
556                write!(
557                    f,
558                    "Arity mismatch: '{}' expects {} {}, got {}{}",
559                    err.callee,
560                    err.expected,
561                    arg_word,
562                    err.got,
563                    fmt_span_suffix(&err.span)
564                )
565            }
566            VmError::ArgTypeMismatch(err) => {
567                write!(
568                    f,
569                    "Type error: '{}' parameter `{}` expects {}, got {}{}",
570                    err.callee,
571                    err.param,
572                    err.expected,
573                    err.got,
574                    fmt_span_suffix(&err.span)
575                )
576            }
577        }
578    }
579}
580
581fn fmt_span_suffix(span: &Option<Span>) -> String {
582    match span {
583        Some(s) => format!(" (at byte {}..{})", s.start, s.end),
584        None => String::new(),
585    }
586}
587
588impl std::error::Error for VmError {}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    /// Every category. `as_str` already matches exhaustively, so a new
595    /// variant fails to compile until it is spelled there; this list is what
596    /// the round-trip and documentation guards below enumerate.
597    const ALL_CATEGORIES: &[ErrorCategory] = &[
598        ErrorCategory::Timeout,
599        ErrorCategory::Auth,
600        ErrorCategory::RateLimit,
601        ErrorCategory::Overloaded,
602        ErrorCategory::ServerError,
603        ErrorCategory::TransientNetwork,
604        ErrorCategory::ResourceBusy,
605        ErrorCategory::SchemaValidation,
606        ErrorCategory::SchemaStreamAborted,
607        ErrorCategory::ToolError,
608        ErrorCategory::ToolRejected,
609        ErrorCategory::EgressBlocked,
610        ErrorCategory::Cancelled,
611        ErrorCategory::ChannelClosed,
612        ErrorCategory::NotFound,
613        ErrorCategory::CircuitOpen,
614        ErrorCategory::BudgetExceeded,
615        ErrorCategory::Internal,
616        ErrorCategory::Environment,
617        ErrorCategory::Generic,
618    ];
619
620    /// A new variant must be added to `ALL_CATEGORIES`, or the guards below
621    /// silently stop covering it. This match is the tripwire: it fails to
622    /// compile until the variant is named, and the arm points at the list.
623    #[test]
624    fn all_categories_is_exhaustive() {
625        for category in ALL_CATEGORIES {
626            match category {
627                ErrorCategory::Timeout
628                | ErrorCategory::Auth
629                | ErrorCategory::RateLimit
630                | ErrorCategory::Overloaded
631                | ErrorCategory::ServerError
632                | ErrorCategory::TransientNetwork
633                | ErrorCategory::ResourceBusy
634                | ErrorCategory::SchemaValidation
635                | ErrorCategory::SchemaStreamAborted
636                | ErrorCategory::ToolError
637                | ErrorCategory::ToolRejected
638                | ErrorCategory::EgressBlocked
639                | ErrorCategory::Cancelled
640                | ErrorCategory::ChannelClosed
641                | ErrorCategory::NotFound
642                | ErrorCategory::CircuitOpen
643                | ErrorCategory::BudgetExceeded
644                | ErrorCategory::Internal
645                | ErrorCategory::Environment
646                | ErrorCategory::Generic => {}
647            }
648        }
649        assert_eq!(
650            ALL_CATEGORIES.len(),
651            20,
652            "a category was added or removed — update ALL_CATEGORIES and the \
653             `Error categories` table in docs/src/builtins.md"
654        );
655    }
656
657    #[test]
658    fn every_category_round_trips_through_parse() {
659        for category in ALL_CATEGORIES {
660            assert_eq!(
661                &ErrorCategory::parse(category.as_str()),
662                category,
663                "`{}` does not round-trip — `parse` is missing an arm, so a \
664                 host handing this category back to Harn silently gets \
665                 `generic`",
666                category.as_str()
667            );
668        }
669    }
670
671    /// Scripts branch on these strings, so an undocumented category is a
672    /// caller writing a match that cannot handle a value the runtime emits.
673    /// `error_category()` used to advertise 10 of the full category set — a dead-port probe
674    /// returning `transient_network` fell outside its own documented list.
675    #[test]
676    fn every_category_is_documented_in_builtins_md() {
677        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/builtins.md");
678        let doc =
679            std::fs::read_to_string(path).unwrap_or_else(|err| panic!("cannot read {path}: {err}"));
680        let table = doc
681            .split_once("### Error categories")
682            .unwrap_or_else(|| {
683                panic!("docs/src/builtins.md lost its `### Error categories` section")
684            })
685            .1;
686        let table = table.split_once("\n## ").map_or(table, |(head, _)| head);
687        for category in ALL_CATEGORIES {
688            let row = format!("| `{}` |", category.as_str());
689            assert!(
690                table.contains(&row),
691                "`{}` is missing from the `Error categories` table in \
692                 docs/src/builtins.md",
693                category.as_str()
694            );
695        }
696    }
697
698    #[test]
699    fn classifies_cancelled_messages() {
700        assert_eq!(
701            classify_error_message("Bridge: operation cancelled"),
702            ErrorCategory::Cancelled
703        );
704        assert_eq!(
705            classify_error_message("operation canceled by host"),
706            ErrorCategory::Cancelled
707        );
708    }
709
710    #[test]
711    fn classifies_undefined_builtin_as_internal() {
712        // Structured variant (dispatch table miss / uninstalled builtin).
713        assert_eq!(
714            error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
715            ErrorCategory::Internal
716        );
717        // Corrupt bytecode / compiler-VM opcode drift.
718        assert_eq!(
719            error_to_category(&VmError::InvalidInstruction(200)),
720            ErrorCategory::Internal
721        );
722        // Stringly form: some call sites build a `Runtime("Undefined builtin: …")`
723        // message instead of the structured variant — both must classify the same.
724        assert_eq!(
725            error_to_category(&VmError::Runtime(
726                "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
727            )),
728            ErrorCategory::Internal
729        );
730        assert_eq!(
731            classify_error_message("Undefined builtin: __host_agent_foo"),
732            ErrorCategory::Internal
733        );
734        // Internal errors are never treated as transient/retryable.
735        assert!(!ErrorCategory::Internal.is_transient());
736        assert!(ErrorCategory::Internal.is_internal());
737        // Round-trips through the string form the agent loop compares against.
738        assert_eq!(ErrorCategory::Internal.as_str(), "internal");
739        assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
740    }
741
742    #[test]
743    fn classifies_openrouter_invalid_model_id_as_not_found() {
744        // OpenRouter reports an unknown model as HTTP 400 + prose. The 400 is
745        // not classified by status, so the prose substring must lift it to
746        // NotFound to match Cerebras's 404 path.
747        assert_eq!(
748            classify_error_message(
749                "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
750            ),
751            ErrorCategory::NotFound
752        );
753        assert_eq!(
754            classify_error_message("invalid model id supplied"),
755            ErrorCategory::NotFound
756        );
757    }
758
759    #[test]
760    fn categorized_error_lowers_to_structured_dict() {
761        // A caught `CategorizedError` must surface as a `{category, message}`
762        // dict so `.harn` consumers branch on the typed category instead of
763        // substring-matching the rendered prose (issue #4420).
764        let err = categorized_error(
765            "sandbox violation: /etc/passwd",
766            ErrorCategory::ToolRejected,
767        );
768        let VmValue::Dict(dict) = err.thrown_value() else {
769            panic!(
770                "categorized error must lower to a dict, got {:?}",
771                err.thrown_value()
772            );
773        };
774        assert_eq!(
775            dict.get("category").map(|v| v.display()).as_deref(),
776            Some("tool_rejected"),
777        );
778        assert_eq!(
779            dict.get("message").map(|v| v.display()).as_deref(),
780            Some("sandbox violation: /etc/passwd"),
781        );
782        // The key is the canonical, exhaustively-matched `ErrorCategory::as_str`
783        // contract — not the Display prose. A stringified catch still renders the
784        // message + category (so generic catch-and-log stays sensible).
785        let rendered = categorized_error("boom", ErrorCategory::Cancelled)
786            .thrown_value()
787            .display();
788        assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
789        assert!(rendered.contains("boom"), "rendered dict: {rendered}");
790    }
791
792    #[test]
793    fn thrown_value_passes_structured_thrown_through_unchanged() {
794        // A user `throw` of a structured value keeps its exact shape — the
795        // lowering seam must not stringify or re-wrap it.
796        let original = VmValue::dict(std::collections::BTreeMap::from([(
797            "code".to_string(),
798            VmValue::Int(7),
799        )]));
800        let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
801            panic!("thrown dict must pass through as a dict");
802        };
803        assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
804    }
805
806    #[test]
807    fn deadlock_renders_with_stable_code() {
808        let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
809            "mutex",
810            "__default__",
811            "re-entrant acquire",
812        )));
813        assert!(
814            err.to_string().starts_with("HARN-ORC-011"),
815            "deadlock Display must carry the stable code: {err}"
816        );
817    }
818
819    #[test]
820    fn deadlock_maps_to_generic_category() {
821        let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
822            "task",
823            "task_1",
824            "self-join",
825        )));
826        let category = error_to_category(&err);
827        assert_eq!(category, ErrorCategory::Generic);
828        assert!(
829            !category.is_transient(),
830            "a deadlock must not be treated as a retryable transient error"
831        );
832    }
833}