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 host dropped a polled top-level execution future. Interpreter state is
114    /// intentionally not resumed after arbitrary async cancellation. Discard
115    /// this VM; see [`crate::Vm::execute_with_timeout`] for ambient-state
116    /// cleanup requirements.
117    AbandonedExecution,
118    Thrown(VmValue),
119    /// Thrown with error category for structured error handling.
120    CategorizedError {
121        message: String,
122        category: ErrorCategory,
123    },
124    DaemonQueueFull {
125        daemon_id: String,
126        capacity: usize,
127    },
128    /// A deterministic, provably-unresolvable self-deadlock caught before the
129    /// VM would block forever (Rust's borrow checker prevents data races but
130    /// not deadlocks; this is the Go-runtime "all goroutines asleep" analogue
131    /// for the cases we can prove). Boxed — like [`VmError::ArityMismatch`] —
132    /// so the rare three-`String` payload doesn't enlarge `VmError` on the
133    /// pervasive `Result<VmValue, VmError>` hot path. Carries `HARN-ORC-011`.
134    Deadlock(Box<DeadlockError>),
135    Return(VmValue),
136    InvalidInstruction(u8),
137    /// Wrong number of arguments at a call site. Distinct from
138    /// [`VmError::TypeError`] so the runtime can match-and-recover (and
139    /// so error UX renders `expected 2..=3 got 1` consistently).
140    ArityMismatch(Box<ArityMismatchError>),
141    /// Argument value did not satisfy the declared parameter type.
142    /// `expected` is a pretty-printed type expression; `got` is the value's
143    /// runtime type name (`VmValue::type_name`). Used for both
144    /// user-defined function parameters (with declared types) and
145    /// registry-known builtin parameters.
146    ArgTypeMismatch(Box<ArgTypeMismatchError>),
147}
148
149impl VmError {
150    /// The `VmValue` a `catch` binding (or a `parallel settle` result) observes
151    /// for this error: the raw thrown value for [`VmError::Thrown`] (so a
152    /// structured error — e.g. a `{category, message}` dict from `throw_error` —
153    /// keeps its shape and category), a structured `{category, message}` dict for
154    /// [`VmError::CategorizedError`] (so consumers branch on the typed category
155    /// rather than substring-matching rendered prose), otherwise the rendered
156    /// message.
157    ///
158    /// Single source of truth for VM-error-to-value lowering so every seam that
159    /// surfaces a caught error to Harn (`try`/`catch` via `handle_error`,
160    /// `parallel settle`) exposes identical, structure-preserving values. Before
161    /// this was shared, `parallel settle` stringified errors via `to_string()`,
162    /// so a categorized error thrown in a settle branch lost its category (a
163    /// `cancelled`/`internal` fault that must propagate looked `generic`).
164    ///
165    /// The `category` key uses [`ErrorCategory::as_str`] — a canonical,
166    /// exhaustively-matched snake_case contract — so a new variant added there
167    /// is compiler-forced to name its key. `message` preserves the original
168    /// rendered text, so a `catch` that stringifies the caught value still reads
169    /// sensibly (the dict renders both fields).
170    pub fn thrown_value(&self) -> VmValue {
171        match self {
172            VmError::Thrown(v) => v.clone(),
173            VmError::CategorizedError { message, category } => {
174                let mut dict = std::collections::BTreeMap::new();
175                dict.put_str("category", category.as_str());
176                dict.put_str("message", message);
177                VmValue::dict(dict)
178            }
179            other => VmValue::String(arcstr::ArcStr::from(other.to_string())),
180        }
181    }
182}
183
184/// Error categories for structured error handling in agent orchestration.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum ErrorCategory {
187    /// Network/connection timeout
188    Timeout,
189    /// Authentication/authorization failure
190    Auth,
191    /// Rate limit exceeded (HTTP 429 / quota)
192    RateLimit,
193    /// Upstream provider is overloaded (HTTP 503 / 529).
194    /// Distinct from RateLimit: the client hasn't exceeded a quota — the
195    /// provider is shedding load and will recover on its own.
196    Overloaded,
197    /// Provider-side 5xx error (500, 502) that isn't specifically overload.
198    ServerError,
199    /// Network-level transient failure (connection reset, DNS hiccup,
200    /// partial stream) — retryable but not provider-status-coded.
201    TransientNetwork,
202    /// LLM output failed schema validation. Retryable via `schema_retries`.
203    SchemaValidation,
204    /// LLM streaming response was aborted mid-stream because the partial
205    /// JSON content could not conceivably satisfy `output_schema`. Surfaced
206    /// by `llm_call` when `schema_stream_abort` is on (the default for
207    /// schema-bearing calls). Consumes one `schema_retries` budget slot;
208    /// the retry replays the prompt with a corrective nudge that cites
209    /// the abort path + reason.
210    SchemaStreamAborted,
211    /// Tool execution failure
212    ToolError,
213    /// Tool was rejected by the host (not permitted / not in allowlist)
214    ToolRejected,
215    /// Outbound network egress was blocked by policy.
216    EgressBlocked,
217    /// Operation was cancelled
218    Cancelled,
219    /// Channel was closed before the operation could complete.
220    ChannelClosed,
221    /// Resource not found
222    NotFound,
223    /// Circuit breaker is open
224    CircuitOpen,
225    /// LLM cost or token budget would be exceeded
226    BudgetExceeded,
227    /// An internal engine/wiring bug — an undefined builtin, corrupt bytecode,
228    /// or another VM invariant violation that no amount of retrying or model
229    /// reasoning can fix. Distinct from `Generic` so callers (notably the agent
230    /// loop) can re-raise it loudly instead of folding it into a tool-error
231    /// observation and marching on to a `done` status. This is the category
232    /// that keeps a mis-wired builtin (e.g. a `#[harn_builtin]` def missing
233    /// from its install array) from shipping silently inert.
234    Internal,
235    /// Generic/unclassified error
236    Generic,
237}
238
239impl ErrorCategory {
240    pub fn as_str(&self) -> &'static str {
241        match self {
242            ErrorCategory::Timeout => "timeout",
243            ErrorCategory::Auth => "auth",
244            ErrorCategory::RateLimit => "rate_limit",
245            ErrorCategory::Overloaded => "overloaded",
246            ErrorCategory::ServerError => "server_error",
247            ErrorCategory::TransientNetwork => "transient_network",
248            ErrorCategory::SchemaValidation => "schema_validation",
249            ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
250            ErrorCategory::ToolError => "tool_error",
251            ErrorCategory::ToolRejected => "tool_rejected",
252            ErrorCategory::EgressBlocked => "egress_blocked",
253            ErrorCategory::Cancelled => "cancelled",
254            ErrorCategory::ChannelClosed => "channel_closed",
255            ErrorCategory::NotFound => "not_found",
256            ErrorCategory::CircuitOpen => "circuit_open",
257            ErrorCategory::BudgetExceeded => "budget_exceeded",
258            ErrorCategory::Internal => "internal",
259            ErrorCategory::Generic => "generic",
260        }
261    }
262
263    pub fn parse(s: &str) -> Self {
264        match s {
265            "timeout" => ErrorCategory::Timeout,
266            "auth" => ErrorCategory::Auth,
267            "rate_limit" => ErrorCategory::RateLimit,
268            "overloaded" => ErrorCategory::Overloaded,
269            "server_error" => ErrorCategory::ServerError,
270            "transient_network" => ErrorCategory::TransientNetwork,
271            "schema_validation" => ErrorCategory::SchemaValidation,
272            "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
273            "tool_error" => ErrorCategory::ToolError,
274            "tool_rejected" => ErrorCategory::ToolRejected,
275            "egress_blocked" => ErrorCategory::EgressBlocked,
276            "cancelled" => ErrorCategory::Cancelled,
277            "channel_closed" => ErrorCategory::ChannelClosed,
278            "not_found" => ErrorCategory::NotFound,
279            "circuit_open" => ErrorCategory::CircuitOpen,
280            "budget_exceeded" => ErrorCategory::BudgetExceeded,
281            "internal" => ErrorCategory::Internal,
282            _ => ErrorCategory::Generic,
283        }
284    }
285
286    /// Whether this category represents an internal engine/wiring bug that must
287    /// be surfaced rather than retried or swallowed as a recoverable failure.
288    pub fn is_internal(&self) -> bool {
289        matches!(self, ErrorCategory::Internal)
290    }
291
292    /// Whether an error of this category is worth retrying for a transient
293    /// provider-side reason. Agent loops consult this to decide whether to
294    /// back off and retry vs surface the error to the user.
295    pub fn is_transient(&self) -> bool {
296        matches!(
297            self,
298            ErrorCategory::Timeout
299                | ErrorCategory::RateLimit
300                | ErrorCategory::Overloaded
301                | ErrorCategory::ServerError
302                | ErrorCategory::TransientNetwork
303        )
304    }
305}
306
307/// Create a categorized error conveniently.
308pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
309    VmError::CategorizedError {
310        message: message.into(),
311        category,
312    }
313}
314
315/// Extract error category from a VmError.
316///
317/// Classification priority:
318/// 1. Explicit CategorizedError variant (set by throw_error or internal code)
319/// 2. Thrown dict with a "category" field (user-created structured errors)
320/// 3. HTTP status code extraction (standard, unambiguous)
321/// 4. Deadline exceeded (VM-internal)
322/// 5. Fallback to Generic
323pub fn error_to_category(err: &VmError) -> ErrorCategory {
324    match err {
325        VmError::ExecutionDeadlineExceeded => ErrorCategory::Timeout,
326        VmError::AbandonedExecution => ErrorCategory::Cancelled,
327        VmError::CategorizedError { category, .. } => category.clone(),
328        VmError::Thrown(VmValue::Dict(d)) => d
329            .get("category")
330            .map(|v| ErrorCategory::parse(&v.display()))
331            .unwrap_or(ErrorCategory::Generic),
332        VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
333        VmError::Runtime(msg) => classify_error_message(msg),
334        // Engine/wiring bugs: an undefined builtin (declared but not installed,
335        // or a typo in stdlib/host code) or corrupt bytecode. No retry or model
336        // reasoning fixes these, so they get their own category the agent loop
337        // re-raises instead of swallowing.
338        VmError::UndefinedBuiltin(_) | VmError::InvalidInstruction(_) => ErrorCategory::Internal,
339        // A deadlock is permanently non-retryable and not provider-related —
340        // `Generic` is the correct "surface it, don't back off" bucket.
341        VmError::Deadlock(_) => ErrorCategory::Generic,
342        _ => ErrorCategory::Generic,
343    }
344}
345
346/// Classify an error message using HTTP status codes and well-known patterns.
347/// Prefers unambiguous signals (status codes) over substring heuristics.
348pub fn classify_error_message(msg: &str) -> ErrorCategory {
349    // 1. HTTP status codes — most reliable signal
350    if let Some(cat) = classify_by_http_status(msg) {
351        return cat;
352    }
353    // 2. Internal engine/wiring bug surfaced as a plain message. Some call
354    //    sites build `Runtime("Undefined builtin: …")` strings instead of the
355    //    structured `VmError::UndefinedBuiltin` variant; classify both the same
356    //    so the agent loop re-raises rather than swallows.
357    if msg.contains("Undefined builtin") {
358        return ErrorCategory::Internal;
359    }
360    // 3. Well-known error identifiers from major APIs
361    //    (Anthropic, OpenAI, and standard HTTP patterns)
362    let lower = msg.to_lowercase();
363    if lower.contains("cancelled") || lower.contains("canceled") {
364        return ErrorCategory::Cancelled;
365    }
366    if msg.contains("ChannelClosed") || lower.contains("channel closed") {
367        return ErrorCategory::ChannelClosed;
368    }
369    if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
370        return ErrorCategory::Timeout;
371    }
372    if msg.contains("overloaded_error") {
373        // Anthropic overloaded_error surfaces as HTTP 529.
374        return ErrorCategory::Overloaded;
375    }
376    if msg.contains("api_error") {
377        // Anthropic catch-all server-side error.
378        return ErrorCategory::ServerError;
379    }
380    if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
381        // OpenAI-specific quota error types.
382        return ErrorCategory::RateLimit;
383    }
384    if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
385        return ErrorCategory::Auth;
386    }
387    if msg.contains("not_found_error") || msg.contains("model_not_found") {
388        return ErrorCategory::NotFound;
389    }
390    // OpenRouter reports an unknown model as HTTP 400 with the body
391    // "<id> is not a valid model ID" — no status-code or typed-error signal
392    // that `classify_by_http_status` / the checks above can latch onto. Map
393    // the prose to NotFound so it lines up with Cerebras's 404 path (and with
394    // `errors::is_model_unavailable`'s reason taxonomy).
395    if lower.contains("is not a valid model id") || lower.contains("invalid model id") {
396        return ErrorCategory::NotFound;
397    }
398    if msg.contains("circuit_open") {
399        return ErrorCategory::CircuitOpen;
400    }
401    // Network-level transient patterns (pre-HTTP-status, pre-provider-framing).
402    if lower.contains("connection reset")
403        || lower.contains("connection refused")
404        || lower.contains("connection closed")
405        || lower.contains("broken pipe")
406        || lower.contains("dns error")
407        || lower.contains("stream error")
408        || lower.contains("unexpected eof")
409    {
410        return ErrorCategory::TransientNetwork;
411    }
412    ErrorCategory::Generic
413}
414
415/// Classify errors by HTTP status code if one appears in the message.
416/// This is the most reliable classification method since status codes
417/// are standardized (RFC 9110) and unambiguous.
418fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
419    // Extract 3-digit HTTP status codes from common patterns:
420    // "HTTP 429", "status 429", "429 Too Many", "error: 401"
421    for code in extract_http_status_codes(msg) {
422        return Some(match code {
423            401 | 403 => ErrorCategory::Auth,
424            404 | 410 => ErrorCategory::NotFound,
425            408 | 504 | 522 | 524 => ErrorCategory::Timeout,
426            429 => ErrorCategory::RateLimit,
427            503 | 529 => ErrorCategory::Overloaded,
428            500 | 502 => ErrorCategory::ServerError,
429            _ => continue,
430        });
431    }
432    None
433}
434
435/// Extract plausible HTTP status codes from an error message.
436fn extract_http_status_codes(msg: &str) -> Vec<u16> {
437    let mut codes = Vec::new();
438    let bytes = msg.as_bytes();
439    for i in 0..bytes.len().saturating_sub(2) {
440        // Look for 3-digit sequences in the 100-599 range
441        if bytes[i].is_ascii_digit()
442            && bytes[i + 1].is_ascii_digit()
443            && bytes[i + 2].is_ascii_digit()
444        {
445            // Ensure it's not part of a longer number
446            let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
447            let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
448            if before_ok && after_ok {
449                if let Ok(code) = msg[i..i + 3].parse::<u16>() {
450                    if (400..=599).contains(&code) {
451                        codes.push(code);
452                    }
453                }
454            }
455        }
456    }
457    codes
458}
459
460impl std::fmt::Display for VmError {
461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462        match self {
463            VmError::StackUnderflow => write!(f, "Stack underflow"),
464            VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
465            VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
466            VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
467            VmError::ImmutableAssignment(n) => {
468                write!(f, "Cannot assign to immutable binding: {n}")
469            }
470            VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
471            VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
472            VmError::DivisionByZero => write!(f, "Division by zero"),
473            VmError::ExecutionDeadlineExceeded => write!(f, "Execution deadline exceeded"),
474            VmError::AbandonedExecution => write!(
475                f,
476                "Execution future was abandoned; discard this VM and reset its exclusively owned execution context"
477            ),
478            VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
479            VmError::CategorizedError { message, category } => {
480                write!(f, "Error [{}]: {}", category.as_str(), message)
481            }
482            VmError::DaemonQueueFull {
483                daemon_id,
484                capacity,
485            } => write!(
486                f,
487                "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
488            ),
489            VmError::Deadlock(err) => match err.diagnostic {
490                DeadlockDiagnostic::SelfDeadlock => write!(
491                    f,
492                    "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
493                    err.diagnostic.code(),
494                    err.detail,
495                    err.kind,
496                    err.key
497                ),
498                DeadlockDiagnostic::WaitForGraph => write!(
499                    f,
500                    "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
501                    err.diagnostic.code(),
502                    err.detail,
503                    err.kind,
504                    err.key
505                ),
506            },
507            VmError::Return(_) => write!(f, "Return from function"),
508            VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
509            VmError::ArityMismatch(err) => {
510                let arg_word = match err.expected {
511                    ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
512                    _ => "arguments",
513                };
514                write!(
515                    f,
516                    "Arity mismatch: '{}' expects {} {}, got {}{}",
517                    err.callee,
518                    err.expected,
519                    arg_word,
520                    err.got,
521                    fmt_span_suffix(&err.span)
522                )
523            }
524            VmError::ArgTypeMismatch(err) => {
525                write!(
526                    f,
527                    "Type error: '{}' parameter `{}` expects {}, got {}{}",
528                    err.callee,
529                    err.param,
530                    err.expected,
531                    err.got,
532                    fmt_span_suffix(&err.span)
533                )
534            }
535        }
536    }
537}
538
539fn fmt_span_suffix(span: &Option<Span>) -> String {
540    match span {
541        Some(s) => format!(" (at byte {}..{})", s.start, s.end),
542        None => String::new(),
543    }
544}
545
546impl std::error::Error for VmError {}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn classifies_cancelled_messages() {
554        assert_eq!(
555            classify_error_message("Bridge: operation cancelled"),
556            ErrorCategory::Cancelled
557        );
558        assert_eq!(
559            classify_error_message("operation canceled by host"),
560            ErrorCategory::Cancelled
561        );
562    }
563
564    #[test]
565    fn classifies_undefined_builtin_as_internal() {
566        // Structured variant (dispatch table miss / uninstalled builtin).
567        assert_eq!(
568            error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
569            ErrorCategory::Internal
570        );
571        // Corrupt bytecode / compiler-VM opcode drift.
572        assert_eq!(
573            error_to_category(&VmError::InvalidInstruction(200)),
574            ErrorCategory::Internal
575        );
576        // Stringly form: some call sites build a `Runtime("Undefined builtin: …")`
577        // message instead of the structured variant — both must classify the same.
578        assert_eq!(
579            error_to_category(&VmError::Runtime(
580                "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
581            )),
582            ErrorCategory::Internal
583        );
584        assert_eq!(
585            classify_error_message("Undefined builtin: __host_agent_foo"),
586            ErrorCategory::Internal
587        );
588        // Internal errors are never treated as transient/retryable.
589        assert!(!ErrorCategory::Internal.is_transient());
590        assert!(ErrorCategory::Internal.is_internal());
591        // Round-trips through the string form the agent loop compares against.
592        assert_eq!(ErrorCategory::Internal.as_str(), "internal");
593        assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
594    }
595
596    #[test]
597    fn classifies_openrouter_invalid_model_id_as_not_found() {
598        // OpenRouter reports an unknown model as HTTP 400 + prose. The 400 is
599        // not classified by status, so the prose substring must lift it to
600        // NotFound to match Cerebras's 404 path.
601        assert_eq!(
602            classify_error_message(
603                "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
604            ),
605            ErrorCategory::NotFound
606        );
607        assert_eq!(
608            classify_error_message("invalid model id supplied"),
609            ErrorCategory::NotFound
610        );
611    }
612
613    #[test]
614    fn categorized_error_lowers_to_structured_dict() {
615        // A caught `CategorizedError` must surface as a `{category, message}`
616        // dict so `.harn` consumers branch on the typed category instead of
617        // substring-matching the rendered prose (issue #4420).
618        let err = categorized_error(
619            "sandbox violation: /etc/passwd",
620            ErrorCategory::ToolRejected,
621        );
622        let VmValue::Dict(dict) = err.thrown_value() else {
623            panic!(
624                "categorized error must lower to a dict, got {:?}",
625                err.thrown_value()
626            );
627        };
628        assert_eq!(
629            dict.get("category").map(|v| v.display()).as_deref(),
630            Some("tool_rejected"),
631        );
632        assert_eq!(
633            dict.get("message").map(|v| v.display()).as_deref(),
634            Some("sandbox violation: /etc/passwd"),
635        );
636        // The key is the canonical, exhaustively-matched `ErrorCategory::as_str`
637        // contract — not the Display prose. A stringified catch still renders the
638        // message + category (so generic catch-and-log stays sensible).
639        let rendered = categorized_error("boom", ErrorCategory::Cancelled)
640            .thrown_value()
641            .display();
642        assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
643        assert!(rendered.contains("boom"), "rendered dict: {rendered}");
644    }
645
646    #[test]
647    fn thrown_value_passes_structured_thrown_through_unchanged() {
648        // A user `throw` of a structured value keeps its exact shape — the
649        // lowering seam must not stringify or re-wrap it.
650        let original = VmValue::dict(std::collections::BTreeMap::from([(
651            "code".to_string(),
652            VmValue::Int(7),
653        )]));
654        let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
655            panic!("thrown dict must pass through as a dict");
656        };
657        assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
658    }
659
660    #[test]
661    fn deadlock_renders_with_stable_code() {
662        let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
663            "mutex",
664            "__default__",
665            "re-entrant acquire",
666        )));
667        assert!(
668            err.to_string().starts_with("HARN-ORC-011"),
669            "deadlock Display must carry the stable code: {err}"
670        );
671    }
672
673    #[test]
674    fn deadlock_maps_to_generic_category() {
675        let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
676            "task",
677            "task_1",
678            "self-join",
679        )));
680        let category = error_to_category(&err);
681        assert_eq!(category, ErrorCategory::Generic);
682        assert!(
683            !category.is_transient(),
684            "a deadlock must not be treated as a retryable transient error"
685        );
686    }
687}