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