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