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)]
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 ExecutionDeadlineExceeded,
113 ProcessExit(i32),
118 AbandonedExecution,
123 Thrown(VmValue),
124 CategorizedError {
126 message: String,
127 category: ErrorCategory,
128 },
129 DaemonQueueFull {
130 daemon_id: String,
131 capacity: usize,
132 },
133 Deadlock(Box<DeadlockError>),
140 Return(VmValue),
141 InvalidInstruction(u8),
142 ArityMismatch(Box<ArityMismatchError>),
146 ArgTypeMismatch(Box<ArgTypeMismatchError>),
152}
153
154impl VmError {
155 pub fn is_uncatchable_control_flow(&self) -> bool {
158 matches!(self, Self::ExecutionDeadlineExceeded | Self::ProcessExit(_))
159 }
160
161 pub fn process_exit_code(&self) -> Option<i32> {
164 match self {
165 Self::ProcessExit(code) => Some(*code),
166 _ => None,
167 }
168 }
169
170 pub fn thrown_value(&self) -> VmValue {
191 match self {
192 VmError::Thrown(v) => v.clone(),
193 VmError::CategorizedError { message, category } => {
194 let mut dict = std::collections::BTreeMap::new();
195 dict.put_str("category", category.as_str());
196 dict.put_str("message", message);
197 VmValue::dict(dict)
198 }
199 other => VmValue::String(arcstr::ArcStr::from(other.to_string())),
200 }
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum ErrorCategory {
207 Timeout,
209 Auth,
211 RateLimit,
213 Overloaded,
217 ServerError,
219 TransientNetwork,
222 ResourceBusy,
225 SchemaValidation,
227 SchemaStreamAborted,
234 ToolError,
236 ToolRejected,
238 EgressBlocked,
240 Cancelled,
242 ChannelClosed,
244 NotFound,
246 CircuitOpen,
248 BudgetExceeded,
250 Internal,
258 Environment,
267 Generic,
269}
270
271impl ErrorCategory {
272 pub fn as_str(&self) -> &'static str {
273 match self {
274 ErrorCategory::Timeout => "timeout",
275 ErrorCategory::Auth => "auth",
276 ErrorCategory::RateLimit => "rate_limit",
277 ErrorCategory::Overloaded => "overloaded",
278 ErrorCategory::ServerError => "server_error",
279 ErrorCategory::TransientNetwork => "transient_network",
280 ErrorCategory::ResourceBusy => "resource_busy",
281 ErrorCategory::SchemaValidation => "schema_validation",
282 ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
283 ErrorCategory::ToolError => "tool_error",
284 ErrorCategory::ToolRejected => "tool_rejected",
285 ErrorCategory::EgressBlocked => "egress_blocked",
286 ErrorCategory::Cancelled => "cancelled",
287 ErrorCategory::ChannelClosed => "channel_closed",
288 ErrorCategory::NotFound => "not_found",
289 ErrorCategory::CircuitOpen => "circuit_open",
290 ErrorCategory::BudgetExceeded => "budget_exceeded",
291 ErrorCategory::Internal => "internal",
292 ErrorCategory::Environment => "environment",
293 ErrorCategory::Generic => "generic",
294 }
295 }
296
297 pub fn parse(s: &str) -> Self {
298 match s {
299 "timeout" => ErrorCategory::Timeout,
300 "auth" => ErrorCategory::Auth,
301 "rate_limit" => ErrorCategory::RateLimit,
302 "overloaded" => ErrorCategory::Overloaded,
303 "server_error" => ErrorCategory::ServerError,
304 "transient_network" => ErrorCategory::TransientNetwork,
305 "resource_busy" => ErrorCategory::ResourceBusy,
306 "schema_validation" => ErrorCategory::SchemaValidation,
307 "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
308 "tool_error" => ErrorCategory::ToolError,
309 "tool_rejected" => ErrorCategory::ToolRejected,
310 "egress_blocked" => ErrorCategory::EgressBlocked,
311 "cancelled" => ErrorCategory::Cancelled,
312 "channel_closed" => ErrorCategory::ChannelClosed,
313 "not_found" => ErrorCategory::NotFound,
314 "circuit_open" => ErrorCategory::CircuitOpen,
315 "budget_exceeded" => ErrorCategory::BudgetExceeded,
316 "internal" => ErrorCategory::Internal,
317 "environment" => ErrorCategory::Environment,
318 _ => ErrorCategory::Generic,
319 }
320 }
321
322 pub fn is_internal(&self) -> bool {
325 matches!(self, ErrorCategory::Internal)
326 }
327
328 pub fn is_transient(&self) -> bool {
332 matches!(
333 self,
334 ErrorCategory::Timeout
335 | ErrorCategory::RateLimit
336 | ErrorCategory::Overloaded
337 | ErrorCategory::ServerError
338 | ErrorCategory::TransientNetwork
339 | ErrorCategory::ResourceBusy
340 )
341 }
342}
343
344pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
346 VmError::CategorizedError {
347 message: message.into(),
348 category,
349 }
350}
351
352pub fn error_to_category(err: &VmError) -> ErrorCategory {
361 match err {
362 VmError::ExecutionDeadlineExceeded => ErrorCategory::Timeout,
363 VmError::ProcessExit(_) => ErrorCategory::Generic,
367 VmError::AbandonedExecution => ErrorCategory::Cancelled,
368 VmError::CategorizedError { category, .. } => category.clone(),
369 VmError::Thrown(VmValue::Dict(d)) => d
370 .get("category")
371 .map(|v| ErrorCategory::parse(&v.display()))
372 .unwrap_or(ErrorCategory::Generic),
373 VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
374 VmError::Runtime(msg) => classify_error_message(msg),
375 VmError::UndefinedBuiltin(_) | VmError::InvalidInstruction(_) => ErrorCategory::Internal,
380 VmError::Deadlock(_) => ErrorCategory::Generic,
383 _ => ErrorCategory::Generic,
384 }
385}
386
387pub fn classify_error_message(msg: &str) -> ErrorCategory {
390 if let Some(cat) = classify_by_http_status(msg) {
392 return cat;
393 }
394 if msg.contains("Undefined builtin") {
399 return ErrorCategory::Internal;
400 }
401 let lower = msg.to_lowercase();
404 if lower.contains("cancelled") || lower.contains("canceled") {
405 return ErrorCategory::Cancelled;
406 }
407 if msg.contains("ChannelClosed") || lower.contains("channel closed") {
408 return ErrorCategory::ChannelClosed;
409 }
410 if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
411 return ErrorCategory::Timeout;
412 }
413 if msg.contains("overloaded_error") {
414 return ErrorCategory::Overloaded;
416 }
417 if msg.contains("api_error") {
418 return ErrorCategory::ServerError;
420 }
421 if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
422 return ErrorCategory::RateLimit;
424 }
425 if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
426 return ErrorCategory::Auth;
427 }
428 if msg.contains("not_found_error") || msg.contains("model_not_found") {
429 return ErrorCategory::NotFound;
430 }
431 if lower.contains("is not a valid model id") || lower.contains("invalid model id") {
437 return ErrorCategory::NotFound;
438 }
439 if msg.contains("circuit_open") {
440 return ErrorCategory::CircuitOpen;
441 }
442 if lower.contains("connection reset")
444 || lower.contains("connection refused")
445 || lower.contains("connection closed")
446 || lower.contains("broken pipe")
447 || lower.contains("dns error")
448 || lower.contains("stream error")
449 || lower.contains("unexpected eof")
450 {
451 return ErrorCategory::TransientNetwork;
452 }
453 ErrorCategory::Generic
454}
455
456fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
460 for code in extract_http_status_codes(msg) {
463 return Some(match code {
464 401 | 403 => ErrorCategory::Auth,
465 404 | 410 => ErrorCategory::NotFound,
466 408 | 504 | 522 | 524 => ErrorCategory::Timeout,
467 429 => ErrorCategory::RateLimit,
468 503 | 529 => ErrorCategory::Overloaded,
469 500 | 502 => ErrorCategory::ServerError,
470 _ => continue,
471 });
472 }
473 None
474}
475
476fn extract_http_status_codes(msg: &str) -> Vec<u16> {
478 let mut codes = Vec::new();
479 let bytes = msg.as_bytes();
480 for i in 0..bytes.len().saturating_sub(2) {
481 if bytes[i].is_ascii_digit()
483 && bytes[i + 1].is_ascii_digit()
484 && bytes[i + 2].is_ascii_digit()
485 {
486 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
488 let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
489 if before_ok && after_ok {
490 if let Ok(code) = msg[i..i + 3].parse::<u16>() {
491 if (400..=599).contains(&code) {
492 codes.push(code);
493 }
494 }
495 }
496 }
497 }
498 codes
499}
500
501impl std::fmt::Display for VmError {
502 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
503 match self {
504 VmError::StackUnderflow => write!(f, "Stack underflow"),
505 VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
506 VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
507 VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
508 VmError::ImmutableAssignment(n) => {
509 write!(f, "Cannot assign to immutable binding: {n}")
510 }
511 VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
512 VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
513 VmError::DivisionByZero => write!(f, "Division by zero"),
514 VmError::ExecutionDeadlineExceeded => write!(f, "Execution deadline exceeded"),
515 VmError::ProcessExit(code) => write!(f, "Process exit requested: {code}"),
516 VmError::AbandonedExecution => write!(
517 f,
518 "Execution future was abandoned; discard this VM and reset its exclusively owned execution context"
519 ),
520 VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
521 VmError::CategorizedError { message, category } => {
522 write!(f, "Error [{}]: {}", category.as_str(), message)
523 }
524 VmError::DaemonQueueFull {
525 daemon_id,
526 capacity,
527 } => write!(
528 f,
529 "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
530 ),
531 VmError::Deadlock(err) => match err.diagnostic {
532 DeadlockDiagnostic::SelfDeadlock => write!(
533 f,
534 "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
535 err.diagnostic.code(),
536 err.detail,
537 err.kind,
538 err.key
539 ),
540 DeadlockDiagnostic::WaitForGraph => write!(
541 f,
542 "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
543 err.diagnostic.code(),
544 err.detail,
545 err.kind,
546 err.key
547 ),
548 },
549 VmError::Return(_) => write!(f, "Return from function"),
550 VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
551 VmError::ArityMismatch(err) => {
552 let arg_word = match err.expected {
553 ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
554 _ => "arguments",
555 };
556 write!(
557 f,
558 "Arity mismatch: '{}' expects {} {}, got {}{}",
559 err.callee,
560 err.expected,
561 arg_word,
562 err.got,
563 fmt_span_suffix(&err.span)
564 )
565 }
566 VmError::ArgTypeMismatch(err) => {
567 write!(
568 f,
569 "Type error: '{}' parameter `{}` expects {}, got {}{}",
570 err.callee,
571 err.param,
572 err.expected,
573 err.got,
574 fmt_span_suffix(&err.span)
575 )
576 }
577 }
578 }
579}
580
581fn fmt_span_suffix(span: &Option<Span>) -> String {
582 match span {
583 Some(s) => format!(" (at byte {}..{})", s.start, s.end),
584 None => String::new(),
585 }
586}
587
588impl std::error::Error for VmError {}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 const ALL_CATEGORIES: &[ErrorCategory] = &[
598 ErrorCategory::Timeout,
599 ErrorCategory::Auth,
600 ErrorCategory::RateLimit,
601 ErrorCategory::Overloaded,
602 ErrorCategory::ServerError,
603 ErrorCategory::TransientNetwork,
604 ErrorCategory::ResourceBusy,
605 ErrorCategory::SchemaValidation,
606 ErrorCategory::SchemaStreamAborted,
607 ErrorCategory::ToolError,
608 ErrorCategory::ToolRejected,
609 ErrorCategory::EgressBlocked,
610 ErrorCategory::Cancelled,
611 ErrorCategory::ChannelClosed,
612 ErrorCategory::NotFound,
613 ErrorCategory::CircuitOpen,
614 ErrorCategory::BudgetExceeded,
615 ErrorCategory::Internal,
616 ErrorCategory::Environment,
617 ErrorCategory::Generic,
618 ];
619
620 #[test]
624 fn all_categories_is_exhaustive() {
625 for category in ALL_CATEGORIES {
626 match category {
627 ErrorCategory::Timeout
628 | ErrorCategory::Auth
629 | ErrorCategory::RateLimit
630 | ErrorCategory::Overloaded
631 | ErrorCategory::ServerError
632 | ErrorCategory::TransientNetwork
633 | ErrorCategory::ResourceBusy
634 | ErrorCategory::SchemaValidation
635 | ErrorCategory::SchemaStreamAborted
636 | ErrorCategory::ToolError
637 | ErrorCategory::ToolRejected
638 | ErrorCategory::EgressBlocked
639 | ErrorCategory::Cancelled
640 | ErrorCategory::ChannelClosed
641 | ErrorCategory::NotFound
642 | ErrorCategory::CircuitOpen
643 | ErrorCategory::BudgetExceeded
644 | ErrorCategory::Internal
645 | ErrorCategory::Environment
646 | ErrorCategory::Generic => {}
647 }
648 }
649 assert_eq!(
650 ALL_CATEGORIES.len(),
651 20,
652 "a category was added or removed — update ALL_CATEGORIES and the \
653 `Error categories` table in docs/src/builtins.md"
654 );
655 }
656
657 #[test]
658 fn every_category_round_trips_through_parse() {
659 for category in ALL_CATEGORIES {
660 assert_eq!(
661 &ErrorCategory::parse(category.as_str()),
662 category,
663 "`{}` does not round-trip — `parse` is missing an arm, so a \
664 host handing this category back to Harn silently gets \
665 `generic`",
666 category.as_str()
667 );
668 }
669 }
670
671 #[test]
676 fn every_category_is_documented_in_builtins_md() {
677 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/builtins.md");
678 let doc =
679 std::fs::read_to_string(path).unwrap_or_else(|err| panic!("cannot read {path}: {err}"));
680 let table = doc
681 .split_once("### Error categories")
682 .unwrap_or_else(|| {
683 panic!("docs/src/builtins.md lost its `### Error categories` section")
684 })
685 .1;
686 let table = table.split_once("\n## ").map_or(table, |(head, _)| head);
687 for category in ALL_CATEGORIES {
688 let row = format!("| `{}` |", category.as_str());
689 assert!(
690 table.contains(&row),
691 "`{}` is missing from the `Error categories` table in \
692 docs/src/builtins.md",
693 category.as_str()
694 );
695 }
696 }
697
698 #[test]
699 fn classifies_cancelled_messages() {
700 assert_eq!(
701 classify_error_message("Bridge: operation cancelled"),
702 ErrorCategory::Cancelled
703 );
704 assert_eq!(
705 classify_error_message("operation canceled by host"),
706 ErrorCategory::Cancelled
707 );
708 }
709
710 #[test]
711 fn classifies_undefined_builtin_as_internal() {
712 assert_eq!(
714 error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
715 ErrorCategory::Internal
716 );
717 assert_eq!(
719 error_to_category(&VmError::InvalidInstruction(200)),
720 ErrorCategory::Internal
721 );
722 assert_eq!(
725 error_to_category(&VmError::Runtime(
726 "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
727 )),
728 ErrorCategory::Internal
729 );
730 assert_eq!(
731 classify_error_message("Undefined builtin: __host_agent_foo"),
732 ErrorCategory::Internal
733 );
734 assert!(!ErrorCategory::Internal.is_transient());
736 assert!(ErrorCategory::Internal.is_internal());
737 assert_eq!(ErrorCategory::Internal.as_str(), "internal");
739 assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
740 }
741
742 #[test]
743 fn classifies_openrouter_invalid_model_id_as_not_found() {
744 assert_eq!(
748 classify_error_message(
749 "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
750 ),
751 ErrorCategory::NotFound
752 );
753 assert_eq!(
754 classify_error_message("invalid model id supplied"),
755 ErrorCategory::NotFound
756 );
757 }
758
759 #[test]
760 fn categorized_error_lowers_to_structured_dict() {
761 let err = categorized_error(
765 "sandbox violation: /etc/passwd",
766 ErrorCategory::ToolRejected,
767 );
768 let VmValue::Dict(dict) = err.thrown_value() else {
769 panic!(
770 "categorized error must lower to a dict, got {:?}",
771 err.thrown_value()
772 );
773 };
774 assert_eq!(
775 dict.get("category").map(|v| v.display()).as_deref(),
776 Some("tool_rejected"),
777 );
778 assert_eq!(
779 dict.get("message").map(|v| v.display()).as_deref(),
780 Some("sandbox violation: /etc/passwd"),
781 );
782 let rendered = categorized_error("boom", ErrorCategory::Cancelled)
786 .thrown_value()
787 .display();
788 assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
789 assert!(rendered.contains("boom"), "rendered dict: {rendered}");
790 }
791
792 #[test]
793 fn thrown_value_passes_structured_thrown_through_unchanged() {
794 let original = VmValue::dict(std::collections::BTreeMap::from([(
797 "code".to_string(),
798 VmValue::Int(7),
799 )]));
800 let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
801 panic!("thrown dict must pass through as a dict");
802 };
803 assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
804 }
805
806 #[test]
807 fn deadlock_renders_with_stable_code() {
808 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
809 "mutex",
810 "__default__",
811 "re-entrant acquire",
812 )));
813 assert!(
814 err.to_string().starts_with("HARN-ORC-011"),
815 "deadlock Display must carry the stable code: {err}"
816 );
817 }
818
819 #[test]
820 fn deadlock_maps_to_generic_category() {
821 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
822 "task",
823 "task_1",
824 "self-join",
825 )));
826 let category = error_to_category(&err);
827 assert_eq!(category, ErrorCategory::Generic);
828 assert!(
829 !category.is_transient(),
830 "a deadlock must not be treated as a retryable transient error"
831 );
832 }
833}