1use harn_lexer::Span;
2
3use super::{VmDictExt, VmValue};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ArityExpect {
10 Exact(usize),
12 Range { min: usize, max: usize },
14 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#[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)]
107pub struct BindingTypeMismatchError {
108 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 ExecutionDeadlineExceeded,
128 ProcessExit(i32),
133 AbandonedExecution,
138 McpInputRequired(Box<crate::mcp_input::McpInputRequired>),
142 Thrown(VmValue),
143 CategorizedError {
145 message: String,
146 category: ErrorCategory,
147 },
148 ProviderStreamFailure(Box<ProviderStreamFailure>),
153 SchemaStreamAbort(Box<SchemaStreamAbort>),
158 DaemonQueueFull {
159 daemon_id: String,
160 capacity: usize,
161 },
162 Deadlock(Box<DeadlockError>),
169 Return(VmValue),
170 InvalidInstruction(u8),
171 ArityMismatch(Box<ArityMismatchError>),
175 ArgTypeMismatch(Box<ArgTypeMismatchError>),
181 BindingTypeMismatch(Box<BindingTypeMismatchError>),
185}
186
187impl VmError {
188 pub fn is_uncatchable_control_flow(&self) -> bool {
191 matches!(
192 self,
193 Self::ExecutionDeadlineExceeded | Self::ProcessExit(_) | Self::McpInputRequired(_)
194 )
195 }
196
197 pub fn process_exit_code(&self) -> Option<i32> {
200 match self {
201 Self::ProcessExit(code) => Some(*code),
202 _ => None,
203 }
204 }
205
206 pub fn thrown_value(&self) -> VmValue {
227 match self {
228 VmError::Thrown(v) => v.clone(),
229 VmError::CategorizedError { message, category } => {
230 let mut dict = std::collections::BTreeMap::new();
231 dict.put_str("category", category.as_str());
232 dict.put_str("message", message);
233 VmValue::dict(dict)
234 }
235 VmError::ProviderStreamFailure(failure) => failure.thrown_value(),
236 VmError::SchemaStreamAbort(abort) => abort.thrown_value(),
237 other => VmValue::String(arcstr::ArcStr::from(other.to_string())),
238 }
239 }
240
241 pub fn provider_stream_failure(&self) -> Option<&ProviderStreamFailure> {
242 match self {
243 Self::ProviderStreamFailure(failure) => Some(failure),
244 _ => None,
245 }
246 }
247
248 pub fn schema_stream_abort(&self) -> Option<&SchemaStreamAbort> {
249 match self {
250 Self::SchemaStreamAbort(abort) => Some(abort),
251 _ => None,
252 }
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum ProviderStreamPhase {
258 AwaitingFirstChunk,
259 Streaming,
260}
261
262impl ProviderStreamPhase {
263 pub fn as_str(self) -> &'static str {
264 match self {
265 Self::AwaitingFirstChunk => "awaiting_first_chunk",
266 Self::Streaming => "streaming",
267 }
268 }
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum ProviderStreamFailureReason {
273 Read,
274 PrematureEof,
275 Deadline,
276}
277
278impl ProviderStreamFailureReason {
279 pub fn as_str(self) -> &'static str {
280 match self {
281 Self::Read => "read",
282 Self::PrematureEof => "premature_eof",
283 Self::Deadline => "deadline",
284 }
285 }
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum ProviderStreamDeadline {
290 Total,
291 FirstChunk,
292 Idle,
293}
294
295impl ProviderStreamDeadline {
296 pub fn as_str(self) -> &'static str {
297 match self {
298 Self::Total => "total",
299 Self::FirstChunk => "first_chunk",
300 Self::Idle => "idle",
301 }
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct ProviderStreamFailure {
307 pub provider: String,
308 pub phase: ProviderStreamPhase,
309 pub reason: ProviderStreamFailureReason,
310 pub deadline: Option<ProviderStreamDeadline>,
311 pub partial: bool,
312 pub detail: String,
313}
314
315impl ProviderStreamFailure {
316 pub fn category(&self) -> ErrorCategory {
317 if self.deadline.is_some() {
318 ErrorCategory::Timeout
319 } else {
320 ErrorCategory::TransientNetwork
321 }
322 }
323
324 fn thrown_value(&self) -> VmValue {
325 let mut dict = std::collections::BTreeMap::new();
326 dict.put_str("category", self.category().as_str());
327 dict.put_str("message", self.to_string());
328 dict.put_str("source", "provider_stream");
329 dict.put_str("phase", self.phase.as_str());
330 dict.put_str("reason", self.reason.as_str());
331 dict.insert(
332 "deadline".to_string(),
333 self.deadline
334 .map(|deadline| VmValue::String(arcstr::ArcStr::from(deadline.as_str())))
335 .unwrap_or(VmValue::Nil),
336 );
337 dict.insert("partial".to_string(), VmValue::Bool(self.partial));
338 VmValue::dict(dict)
339 }
340}
341
342impl std::fmt::Display for ProviderStreamFailure {
343 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344 write!(
345 f,
346 "{} provider stream failure (phase={}, reason={}",
347 self.provider,
348 self.phase.as_str(),
349 self.reason.as_str()
350 )?;
351 if let Some(deadline) = self.deadline {
352 write!(f, ", deadline={}", deadline.as_str())?;
353 }
354 write!(f, ", partial={}): {}", self.partial, self.detail)
355 }
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum SchemaValidationReasonKind {
361 InvalidJson,
362 InvalidSchema,
363 WrongType,
364 MissingRequired,
365 UnexpectedProperty,
366 MaxLength,
367 MinLength,
368 ConstraintViolation,
369}
370
371impl SchemaValidationReasonKind {
372 pub fn as_str(self) -> &'static str {
373 match self {
374 Self::InvalidJson => "invalid_json",
375 Self::InvalidSchema => "invalid_schema",
376 Self::WrongType => "wrong_type",
377 Self::MissingRequired => "missing_required",
378 Self::UnexpectedProperty => "unexpected_property",
379 Self::MaxLength => "max_length",
380 Self::MinLength => "min_length",
381 Self::ConstraintViolation => "constraint_violation",
382 }
383 }
384}
385
386#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct SchemaStreamAbort {
390 pub provider: String,
391 pub model: String,
392 pub reason_kind: SchemaValidationReasonKind,
393 pub reason: String,
394 pub path: String,
395 pub chunks_consumed: usize,
396}
397
398impl SchemaStreamAbort {
399 pub fn category(&self) -> ErrorCategory {
400 ErrorCategory::SchemaStreamAborted
401 }
402
403 fn thrown_value(&self) -> VmValue {
404 let mut cause = std::collections::BTreeMap::new();
405 cause.put_str("kind", self.reason_kind.as_str());
406 cause.put_str("detail", self.reason.as_str());
407 cause.put_str("path", self.path.as_str());
408 cause.insert(
409 "chunks_consumed".to_string(),
410 VmValue::Int(self.chunks_consumed as i64),
411 );
412 cause.put_str("provider", self.provider.as_str());
413 cause.put_str("model", self.model.as_str());
414
415 let mut dict = std::collections::BTreeMap::new();
416 dict.put_str("category", self.category().as_str());
417 dict.put_str("message", self.to_string());
418 dict.put_str("source", "schema_stream");
419 dict.insert("schema_failure".to_string(), VmValue::dict(cause));
420 VmValue::dict(dict)
421 }
422}
423
424impl std::fmt::Display for SchemaStreamAbort {
425 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426 write!(
427 f,
428 "schema_stream_aborted at {}: {} (provider={} model={} chunks_consumed={})",
429 self.path, self.reason, self.provider, self.model, self.chunks_consumed
430 )
431 }
432}
433
434#[derive(Debug, Clone, PartialEq, Eq)]
436pub enum ErrorCategory {
437 Timeout,
439 Auth,
441 InvalidRequest,
443 RateLimit,
445 Overloaded,
449 ServerError,
451 TransientNetwork,
454 ResourceBusy,
457 SchemaIncompatible,
460 SchemaValidation,
462 SchemaStreamAborted,
469 ToolError,
471 ToolRejected,
473 EgressBlocked,
475 Cancelled,
477 ChannelClosed,
479 NotFound,
481 CircuitOpen,
483 BudgetExceeded,
485 Internal,
493 Environment,
502 Generic,
504}
505
506impl ErrorCategory {
507 pub const ALL: [Self; 22] = [
516 Self::Timeout,
517 Self::Auth,
518 Self::InvalidRequest,
519 Self::RateLimit,
520 Self::Overloaded,
521 Self::ServerError,
522 Self::TransientNetwork,
523 Self::ResourceBusy,
524 Self::SchemaIncompatible,
525 Self::SchemaValidation,
526 Self::SchemaStreamAborted,
527 Self::ToolError,
528 Self::ToolRejected,
529 Self::EgressBlocked,
530 Self::Cancelled,
531 Self::ChannelClosed,
532 Self::NotFound,
533 Self::CircuitOpen,
534 Self::BudgetExceeded,
535 Self::Internal,
536 Self::Environment,
537 Self::Generic,
538 ];
539
540 pub fn as_str(&self) -> &'static str {
541 match self {
542 ErrorCategory::Timeout => "timeout",
543 ErrorCategory::Auth => "auth",
544 ErrorCategory::InvalidRequest => "invalid_request",
545 ErrorCategory::RateLimit => "rate_limit",
546 ErrorCategory::Overloaded => "overloaded",
547 ErrorCategory::ServerError => "server_error",
548 ErrorCategory::TransientNetwork => "transient_network",
549 ErrorCategory::ResourceBusy => "resource_busy",
550 ErrorCategory::SchemaIncompatible => "schema_incompatible",
551 ErrorCategory::SchemaValidation => "schema_validation",
552 ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
553 ErrorCategory::ToolError => "tool_error",
554 ErrorCategory::ToolRejected => "tool_rejected",
555 ErrorCategory::EgressBlocked => "egress_blocked",
556 ErrorCategory::Cancelled => "cancelled",
557 ErrorCategory::ChannelClosed => "channel_closed",
558 ErrorCategory::NotFound => "not_found",
559 ErrorCategory::CircuitOpen => "circuit_open",
560 ErrorCategory::BudgetExceeded => "budget_exceeded",
561 ErrorCategory::Internal => "internal",
562 ErrorCategory::Environment => "environment",
563 ErrorCategory::Generic => "generic",
564 }
565 }
566
567 pub fn parse(s: &str) -> Self {
568 match s {
569 "timeout" => ErrorCategory::Timeout,
570 "auth" => ErrorCategory::Auth,
571 "invalid_request" => ErrorCategory::InvalidRequest,
572 "rate_limit" => ErrorCategory::RateLimit,
573 "overloaded" => ErrorCategory::Overloaded,
574 "server_error" => ErrorCategory::ServerError,
575 "transient_network" => ErrorCategory::TransientNetwork,
576 "resource_busy" => ErrorCategory::ResourceBusy,
577 "schema_incompatible" => ErrorCategory::SchemaIncompatible,
578 "schema_validation" => ErrorCategory::SchemaValidation,
579 "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
580 "tool_error" => ErrorCategory::ToolError,
581 "tool_rejected" => ErrorCategory::ToolRejected,
582 "egress_blocked" => ErrorCategory::EgressBlocked,
583 "cancelled" => ErrorCategory::Cancelled,
584 "channel_closed" => ErrorCategory::ChannelClosed,
585 "not_found" => ErrorCategory::NotFound,
586 "circuit_open" => ErrorCategory::CircuitOpen,
587 "budget_exceeded" => ErrorCategory::BudgetExceeded,
588 "internal" => ErrorCategory::Internal,
589 "environment" => ErrorCategory::Environment,
590 _ => ErrorCategory::Generic,
591 }
592 }
593
594 pub fn is_internal(&self) -> bool {
597 matches!(self, ErrorCategory::Internal)
598 }
599
600 pub fn is_transient(&self) -> bool {
604 matches!(
605 self,
606 ErrorCategory::Timeout
607 | ErrorCategory::RateLimit
608 | ErrorCategory::Overloaded
609 | ErrorCategory::ServerError
610 | ErrorCategory::TransientNetwork
611 | ErrorCategory::ResourceBusy
612 )
613 }
614}
615
616pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
618 VmError::CategorizedError {
619 message: message.into(),
620 category,
621 }
622}
623
624pub fn error_to_category(err: &VmError) -> ErrorCategory {
633 match err {
634 VmError::ExecutionDeadlineExceeded => ErrorCategory::Timeout,
635 VmError::ProcessExit(_) => ErrorCategory::Generic,
639 VmError::AbandonedExecution => ErrorCategory::Cancelled,
640 VmError::CategorizedError { category, .. } => category.clone(),
641 VmError::ProviderStreamFailure(failure) => failure.category(),
642 VmError::SchemaStreamAbort(abort) => abort.category(),
643 VmError::Thrown(VmValue::Dict(d)) => d
644 .get("category")
645 .map(|v| ErrorCategory::parse(&v.display()))
646 .unwrap_or(ErrorCategory::Generic),
647 VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
648 VmError::Runtime(msg) => classify_error_message(msg),
649 VmError::UndefinedBuiltin(_) | VmError::InvalidInstruction(_) => ErrorCategory::Internal,
654 VmError::Deadlock(_) => ErrorCategory::Generic,
657 _ => ErrorCategory::Generic,
658 }
659}
660
661pub fn classify_error_message(msg: &str) -> ErrorCategory {
664 if let Some(cat) = classify_by_http_status(msg) {
666 return cat;
667 }
668 if msg.contains("Undefined builtin") {
673 return ErrorCategory::Internal;
674 }
675 let lower = msg.to_lowercase();
678 if lower.contains("cancelled") || lower.contains("canceled") {
679 return ErrorCategory::Cancelled;
680 }
681 if msg.contains("ChannelClosed") || lower.contains("channel closed") {
682 return ErrorCategory::ChannelClosed;
683 }
684 if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
685 return ErrorCategory::Timeout;
686 }
687 if msg.contains("overloaded_error") {
688 return ErrorCategory::Overloaded;
690 }
691 if msg.contains("api_error") {
692 return ErrorCategory::ServerError;
694 }
695 if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
696 return ErrorCategory::RateLimit;
698 }
699 if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
700 return ErrorCategory::Auth;
701 }
702 if msg.contains("not_found_error") || msg.contains("model_not_found") {
703 return ErrorCategory::NotFound;
704 }
705 if lower.contains("is not a valid model id") || lower.contains("invalid model id") {
711 return ErrorCategory::NotFound;
712 }
713 if msg.contains("circuit_open") {
714 return ErrorCategory::CircuitOpen;
715 }
716 if lower.contains("connection reset")
718 || lower.contains("connection refused")
719 || lower.contains("connection closed")
720 || lower.contains("broken pipe")
721 || lower.contains("dns error")
722 || lower.contains("stream error")
723 || lower.contains("unexpected eof")
724 {
725 return ErrorCategory::TransientNetwork;
726 }
727 ErrorCategory::Generic
728}
729
730fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
734 for code in extract_http_status_codes(msg) {
737 if let Some(category) = error_category_for_http_status(code) {
738 return Some(category);
739 }
740 }
741 None
742}
743
744pub(crate) fn error_category_for_http_status(status: u16) -> Option<ErrorCategory> {
751 match status {
752 401 | 403 => Some(ErrorCategory::Auth),
753 404 | 410 => Some(ErrorCategory::NotFound),
754 408 | 504 | 522 | 524 => Some(ErrorCategory::Timeout),
755 429 => Some(ErrorCategory::RateLimit),
756 503 | 529 => Some(ErrorCategory::Overloaded),
757 500 | 502 => Some(ErrorCategory::ServerError),
758 _ => None,
759 }
760}
761
762#[expect(
764 clippy::string_slice,
765 reason = "i..i + 3 spans bytes verified to be ASCII digits"
766)]
767fn extract_http_status_codes(msg: &str) -> Vec<u16> {
768 let mut codes = Vec::new();
769 let bytes = msg.as_bytes();
770 for i in 0..bytes.len().saturating_sub(2) {
771 if bytes[i].is_ascii_digit()
773 && bytes[i + 1].is_ascii_digit()
774 && bytes[i + 2].is_ascii_digit()
775 {
776 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
778 let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
779 if before_ok && after_ok {
780 if let Ok(code) = msg[i..i + 3].parse::<u16>() {
781 if (400..=599).contains(&code) {
782 codes.push(code);
783 }
784 }
785 }
786 }
787 }
788 codes
789}
790
791impl std::fmt::Display for VmError {
792 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
793 match self {
794 VmError::StackUnderflow => write!(f, "Stack underflow"),
795 VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
796 VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
797 VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
798 VmError::ImmutableAssignment(n) => {
799 write!(f, "Cannot assign to immutable binding: {n}")
800 }
801 VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
802 VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
803 VmError::DivisionByZero => write!(f, "Division by zero"),
804 VmError::ExecutionDeadlineExceeded => write!(f, "Execution deadline exceeded"),
805 VmError::ProcessExit(code) => write!(f, "Process exit requested: {code}"),
806 VmError::AbandonedExecution => write!(
807 f,
808 "Execution future was abandoned; discard this VM and reset its exclusively owned execution context"
809 ),
810 VmError::McpInputRequired(_) => write!(f, "MCP client input required"),
811 VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
812 VmError::CategorizedError { message, category } => {
813 write!(f, "Error [{}]: {}", category.as_str(), message)
814 }
815 VmError::ProviderStreamFailure(failure) => failure.fmt(f),
816 VmError::SchemaStreamAbort(abort) => abort.fmt(f),
817 VmError::DaemonQueueFull {
818 daemon_id,
819 capacity,
820 } => write!(
821 f,
822 "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
823 ),
824 VmError::Deadlock(err) => match err.diagnostic {
825 DeadlockDiagnostic::SelfDeadlock => write!(
826 f,
827 "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
828 err.diagnostic.code(),
829 err.detail,
830 err.kind,
831 err.key
832 ),
833 DeadlockDiagnostic::WaitForGraph => write!(
834 f,
835 "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
836 err.diagnostic.code(),
837 err.detail,
838 err.kind,
839 err.key
840 ),
841 },
842 VmError::Return(_) => write!(f, "Return from function"),
843 VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
844 VmError::ArityMismatch(err) => {
845 let arg_word = match err.expected {
846 ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
847 _ => "arguments",
848 };
849 write!(
850 f,
851 "Arity mismatch: '{}' expects {} {}, got {}{}",
852 err.callee,
853 err.expected,
854 arg_word,
855 err.got,
856 fmt_span_suffix(&err.span)
857 )
858 }
859 VmError::ArgTypeMismatch(err) => {
860 write!(
861 f,
862 "Type error: '{}' parameter `{}` expects {}, got {}{}",
863 err.callee,
864 err.param,
865 err.expected,
866 err.got,
867 fmt_span_suffix(&err.span)
868 )
869 }
870 VmError::BindingTypeMismatch(err) => {
871 write!(
872 f,
873 "Type error: binding `{}` expects {}, got {}{}",
874 err.binding,
875 err.expected,
876 err.got,
877 fmt_span_suffix(&err.span)
878 )
879 }
880 }
881 }
882}
883
884fn fmt_span_suffix(span: &Option<Span>) -> String {
885 match span {
886 Some(s) => format!(" (at byte {}..{})", s.start, s.end),
887 None => String::new(),
888 }
889}
890
891impl std::error::Error for VmError {}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896
897 #[test]
901 fn all_categories_is_exhaustive() {
902 for category in &ErrorCategory::ALL {
903 match category {
904 ErrorCategory::Timeout
905 | ErrorCategory::Auth
906 | ErrorCategory::InvalidRequest
907 | ErrorCategory::RateLimit
908 | ErrorCategory::Overloaded
909 | ErrorCategory::ServerError
910 | ErrorCategory::TransientNetwork
911 | ErrorCategory::ResourceBusy
912 | ErrorCategory::SchemaIncompatible
913 | ErrorCategory::SchemaValidation
914 | ErrorCategory::SchemaStreamAborted
915 | ErrorCategory::ToolError
916 | ErrorCategory::ToolRejected
917 | ErrorCategory::EgressBlocked
918 | ErrorCategory::Cancelled
919 | ErrorCategory::ChannelClosed
920 | ErrorCategory::NotFound
921 | ErrorCategory::CircuitOpen
922 | ErrorCategory::BudgetExceeded
923 | ErrorCategory::Internal
924 | ErrorCategory::Environment
925 | ErrorCategory::Generic => {}
926 }
927 }
928 assert_eq!(
929 ErrorCategory::ALL.len(),
930 22,
931 "a category was added or removed — update `ErrorCategory::ALL` and the \
932 `Error categories` table in docs/src/builtins.md"
933 );
934 }
935
936 #[test]
937 fn every_category_round_trips_through_parse() {
938 for category in &ErrorCategory::ALL {
939 assert_eq!(
940 &ErrorCategory::parse(category.as_str()),
941 category,
942 "`{}` does not round-trip — `parse` is missing an arm, so a \
943 host handing this category back to Harn silently gets \
944 `generic`",
945 category.as_str()
946 );
947 }
948 }
949
950 #[test]
955 fn every_category_is_documented_in_builtins_md() {
956 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/builtins.md");
957 let doc =
958 std::fs::read_to_string(path).unwrap_or_else(|err| panic!("cannot read {path}: {err}"));
959 let table = doc
960 .split_once("### Error categories")
961 .unwrap_or_else(|| {
962 panic!("docs/src/builtins.md lost its `### Error categories` section")
963 })
964 .1;
965 let table = table.split_once("\n## ").map_or(table, |(head, _)| head);
966 for category in &ErrorCategory::ALL {
967 let row = format!("| `{}` |", category.as_str());
968 assert!(
969 table.contains(&row),
970 "`{}` is missing from the `Error categories` table in \
971 docs/src/builtins.md",
972 category.as_str()
973 );
974 }
975 }
976
977 #[test]
978 fn classifies_cancelled_messages() {
979 assert_eq!(
980 classify_error_message("Bridge: operation cancelled"),
981 ErrorCategory::Cancelled
982 );
983 assert_eq!(
984 classify_error_message("operation canceled by host"),
985 ErrorCategory::Cancelled
986 );
987 }
988
989 #[test]
990 fn classifies_undefined_builtin_as_internal() {
991 assert_eq!(
993 error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
994 ErrorCategory::Internal
995 );
996 assert_eq!(
998 error_to_category(&VmError::InvalidInstruction(200)),
999 ErrorCategory::Internal
1000 );
1001 assert_eq!(
1004 error_to_category(&VmError::Runtime(
1005 "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
1006 )),
1007 ErrorCategory::Internal
1008 );
1009 assert_eq!(
1010 classify_error_message("Undefined builtin: __host_agent_foo"),
1011 ErrorCategory::Internal
1012 );
1013 assert!(!ErrorCategory::Internal.is_transient());
1015 assert!(ErrorCategory::Internal.is_internal());
1016 assert_eq!(ErrorCategory::Internal.as_str(), "internal");
1018 assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
1019 }
1020
1021 #[test]
1022 fn classifies_openrouter_invalid_model_id_as_not_found() {
1023 assert_eq!(
1027 classify_error_message(
1028 "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
1029 ),
1030 ErrorCategory::NotFound
1031 );
1032 assert_eq!(
1033 classify_error_message("invalid model id supplied"),
1034 ErrorCategory::NotFound
1035 );
1036 }
1037
1038 #[test]
1039 fn categorized_error_lowers_to_structured_dict() {
1040 let err = categorized_error(
1044 "sandbox violation: /etc/passwd",
1045 ErrorCategory::ToolRejected,
1046 );
1047 let VmValue::Dict(dict) = err.thrown_value() else {
1048 panic!(
1049 "categorized error must lower to a dict, got {:?}",
1050 err.thrown_value()
1051 );
1052 };
1053 assert_eq!(
1054 dict.get("category").map(|v| v.display()).as_deref(),
1055 Some("tool_rejected"),
1056 );
1057 assert_eq!(
1058 dict.get("message").map(|v| v.display()).as_deref(),
1059 Some("sandbox violation: /etc/passwd"),
1060 );
1061 let rendered = categorized_error("boom", ErrorCategory::Cancelled)
1065 .thrown_value()
1066 .display();
1067 assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
1068 assert!(rendered.contains("boom"), "rendered dict: {rendered}");
1069 }
1070
1071 #[test]
1072 fn thrown_value_passes_structured_thrown_through_unchanged() {
1073 let original = VmValue::dict(std::collections::BTreeMap::from([(
1076 "code".to_string(),
1077 VmValue::Int(7),
1078 )]));
1079 let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
1080 panic!("thrown dict must pass through as a dict");
1081 };
1082 assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
1083 }
1084
1085 #[test]
1086 fn deadlock_renders_with_stable_code() {
1087 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
1088 "mutex",
1089 "__default__",
1090 "re-entrant acquire",
1091 )));
1092 assert!(
1093 err.to_string().starts_with("HARN-ORC-011"),
1094 "deadlock Display must carry the stable code: {err}"
1095 );
1096 }
1097
1098 #[test]
1099 fn deadlock_maps_to_generic_category() {
1100 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
1101 "task",
1102 "task_1",
1103 "self-join",
1104 )));
1105 let category = error_to_category(&err);
1106 assert_eq!(category, ErrorCategory::Generic);
1107 assert!(
1108 !category.is_transient(),
1109 "a deadlock must not be treated as a retryable transient error"
1110 );
1111 }
1112}