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