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 const ALL: [Self; 20] = [
281 Self::Timeout,
282 Self::Auth,
283 Self::RateLimit,
284 Self::Overloaded,
285 Self::ServerError,
286 Self::TransientNetwork,
287 Self::ResourceBusy,
288 Self::SchemaValidation,
289 Self::SchemaStreamAborted,
290 Self::ToolError,
291 Self::ToolRejected,
292 Self::EgressBlocked,
293 Self::Cancelled,
294 Self::ChannelClosed,
295 Self::NotFound,
296 Self::CircuitOpen,
297 Self::BudgetExceeded,
298 Self::Internal,
299 Self::Environment,
300 Self::Generic,
301 ];
302
303 pub fn as_str(&self) -> &'static str {
304 match self {
305 ErrorCategory::Timeout => "timeout",
306 ErrorCategory::Auth => "auth",
307 ErrorCategory::RateLimit => "rate_limit",
308 ErrorCategory::Overloaded => "overloaded",
309 ErrorCategory::ServerError => "server_error",
310 ErrorCategory::TransientNetwork => "transient_network",
311 ErrorCategory::ResourceBusy => "resource_busy",
312 ErrorCategory::SchemaValidation => "schema_validation",
313 ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
314 ErrorCategory::ToolError => "tool_error",
315 ErrorCategory::ToolRejected => "tool_rejected",
316 ErrorCategory::EgressBlocked => "egress_blocked",
317 ErrorCategory::Cancelled => "cancelled",
318 ErrorCategory::ChannelClosed => "channel_closed",
319 ErrorCategory::NotFound => "not_found",
320 ErrorCategory::CircuitOpen => "circuit_open",
321 ErrorCategory::BudgetExceeded => "budget_exceeded",
322 ErrorCategory::Internal => "internal",
323 ErrorCategory::Environment => "environment",
324 ErrorCategory::Generic => "generic",
325 }
326 }
327
328 pub fn parse(s: &str) -> Self {
329 match s {
330 "timeout" => ErrorCategory::Timeout,
331 "auth" => ErrorCategory::Auth,
332 "rate_limit" => ErrorCategory::RateLimit,
333 "overloaded" => ErrorCategory::Overloaded,
334 "server_error" => ErrorCategory::ServerError,
335 "transient_network" => ErrorCategory::TransientNetwork,
336 "resource_busy" => ErrorCategory::ResourceBusy,
337 "schema_validation" => ErrorCategory::SchemaValidation,
338 "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
339 "tool_error" => ErrorCategory::ToolError,
340 "tool_rejected" => ErrorCategory::ToolRejected,
341 "egress_blocked" => ErrorCategory::EgressBlocked,
342 "cancelled" => ErrorCategory::Cancelled,
343 "channel_closed" => ErrorCategory::ChannelClosed,
344 "not_found" => ErrorCategory::NotFound,
345 "circuit_open" => ErrorCategory::CircuitOpen,
346 "budget_exceeded" => ErrorCategory::BudgetExceeded,
347 "internal" => ErrorCategory::Internal,
348 "environment" => ErrorCategory::Environment,
349 _ => ErrorCategory::Generic,
350 }
351 }
352
353 pub fn is_internal(&self) -> bool {
356 matches!(self, ErrorCategory::Internal)
357 }
358
359 pub fn is_transient(&self) -> bool {
363 matches!(
364 self,
365 ErrorCategory::Timeout
366 | ErrorCategory::RateLimit
367 | ErrorCategory::Overloaded
368 | ErrorCategory::ServerError
369 | ErrorCategory::TransientNetwork
370 | ErrorCategory::ResourceBusy
371 )
372 }
373}
374
375pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
377 VmError::CategorizedError {
378 message: message.into(),
379 category,
380 }
381}
382
383pub fn error_to_category(err: &VmError) -> ErrorCategory {
392 match err {
393 VmError::ExecutionDeadlineExceeded => ErrorCategory::Timeout,
394 VmError::ProcessExit(_) => ErrorCategory::Generic,
398 VmError::AbandonedExecution => ErrorCategory::Cancelled,
399 VmError::CategorizedError { category, .. } => category.clone(),
400 VmError::Thrown(VmValue::Dict(d)) => d
401 .get("category")
402 .map(|v| ErrorCategory::parse(&v.display()))
403 .unwrap_or(ErrorCategory::Generic),
404 VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
405 VmError::Runtime(msg) => classify_error_message(msg),
406 VmError::UndefinedBuiltin(_) | VmError::InvalidInstruction(_) => ErrorCategory::Internal,
411 VmError::Deadlock(_) => ErrorCategory::Generic,
414 _ => ErrorCategory::Generic,
415 }
416}
417
418pub fn classify_error_message(msg: &str) -> ErrorCategory {
421 if let Some(cat) = classify_by_http_status(msg) {
423 return cat;
424 }
425 if msg.contains("Undefined builtin") {
430 return ErrorCategory::Internal;
431 }
432 let lower = msg.to_lowercase();
435 if lower.contains("cancelled") || lower.contains("canceled") {
436 return ErrorCategory::Cancelled;
437 }
438 if msg.contains("ChannelClosed") || lower.contains("channel closed") {
439 return ErrorCategory::ChannelClosed;
440 }
441 if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
442 return ErrorCategory::Timeout;
443 }
444 if msg.contains("overloaded_error") {
445 return ErrorCategory::Overloaded;
447 }
448 if msg.contains("api_error") {
449 return ErrorCategory::ServerError;
451 }
452 if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
453 return ErrorCategory::RateLimit;
455 }
456 if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
457 return ErrorCategory::Auth;
458 }
459 if msg.contains("not_found_error") || msg.contains("model_not_found") {
460 return ErrorCategory::NotFound;
461 }
462 if lower.contains("is not a valid model id") || lower.contains("invalid model id") {
468 return ErrorCategory::NotFound;
469 }
470 if msg.contains("circuit_open") {
471 return ErrorCategory::CircuitOpen;
472 }
473 if lower.contains("connection reset")
475 || lower.contains("connection refused")
476 || lower.contains("connection closed")
477 || lower.contains("broken pipe")
478 || lower.contains("dns error")
479 || lower.contains("stream error")
480 || lower.contains("unexpected eof")
481 {
482 return ErrorCategory::TransientNetwork;
483 }
484 ErrorCategory::Generic
485}
486
487fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
491 for code in extract_http_status_codes(msg) {
494 return Some(match code {
495 401 | 403 => ErrorCategory::Auth,
496 404 | 410 => ErrorCategory::NotFound,
497 408 | 504 | 522 | 524 => ErrorCategory::Timeout,
498 429 => ErrorCategory::RateLimit,
499 503 | 529 => ErrorCategory::Overloaded,
500 500 | 502 => ErrorCategory::ServerError,
501 _ => continue,
502 });
503 }
504 None
505}
506
507fn extract_http_status_codes(msg: &str) -> Vec<u16> {
509 let mut codes = Vec::new();
510 let bytes = msg.as_bytes();
511 for i in 0..bytes.len().saturating_sub(2) {
512 if bytes[i].is_ascii_digit()
514 && bytes[i + 1].is_ascii_digit()
515 && bytes[i + 2].is_ascii_digit()
516 {
517 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
519 let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
520 if before_ok && after_ok {
521 if let Ok(code) = msg[i..i + 3].parse::<u16>() {
522 if (400..=599).contains(&code) {
523 codes.push(code);
524 }
525 }
526 }
527 }
528 }
529 codes
530}
531
532impl std::fmt::Display for VmError {
533 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
534 match self {
535 VmError::StackUnderflow => write!(f, "Stack underflow"),
536 VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
537 VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
538 VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
539 VmError::ImmutableAssignment(n) => {
540 write!(f, "Cannot assign to immutable binding: {n}")
541 }
542 VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
543 VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
544 VmError::DivisionByZero => write!(f, "Division by zero"),
545 VmError::ExecutionDeadlineExceeded => write!(f, "Execution deadline exceeded"),
546 VmError::ProcessExit(code) => write!(f, "Process exit requested: {code}"),
547 VmError::AbandonedExecution => write!(
548 f,
549 "Execution future was abandoned; discard this VM and reset its exclusively owned execution context"
550 ),
551 VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
552 VmError::CategorizedError { message, category } => {
553 write!(f, "Error [{}]: {}", category.as_str(), message)
554 }
555 VmError::DaemonQueueFull {
556 daemon_id,
557 capacity,
558 } => write!(
559 f,
560 "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
561 ),
562 VmError::Deadlock(err) => match err.diagnostic {
563 DeadlockDiagnostic::SelfDeadlock => write!(
564 f,
565 "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
566 err.diagnostic.code(),
567 err.detail,
568 err.kind,
569 err.key
570 ),
571 DeadlockDiagnostic::WaitForGraph => write!(
572 f,
573 "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
574 err.diagnostic.code(),
575 err.detail,
576 err.kind,
577 err.key
578 ),
579 },
580 VmError::Return(_) => write!(f, "Return from function"),
581 VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
582 VmError::ArityMismatch(err) => {
583 let arg_word = match err.expected {
584 ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
585 _ => "arguments",
586 };
587 write!(
588 f,
589 "Arity mismatch: '{}' expects {} {}, got {}{}",
590 err.callee,
591 err.expected,
592 arg_word,
593 err.got,
594 fmt_span_suffix(&err.span)
595 )
596 }
597 VmError::ArgTypeMismatch(err) => {
598 write!(
599 f,
600 "Type error: '{}' parameter `{}` expects {}, got {}{}",
601 err.callee,
602 err.param,
603 err.expected,
604 err.got,
605 fmt_span_suffix(&err.span)
606 )
607 }
608 }
609 }
610}
611
612fn fmt_span_suffix(span: &Option<Span>) -> String {
613 match span {
614 Some(s) => format!(" (at byte {}..{})", s.start, s.end),
615 None => String::new(),
616 }
617}
618
619impl std::error::Error for VmError {}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
629 fn all_categories_is_exhaustive() {
630 for category in &ErrorCategory::ALL {
631 match category {
632 ErrorCategory::Timeout
633 | ErrorCategory::Auth
634 | ErrorCategory::RateLimit
635 | ErrorCategory::Overloaded
636 | ErrorCategory::ServerError
637 | ErrorCategory::TransientNetwork
638 | ErrorCategory::ResourceBusy
639 | ErrorCategory::SchemaValidation
640 | ErrorCategory::SchemaStreamAborted
641 | ErrorCategory::ToolError
642 | ErrorCategory::ToolRejected
643 | ErrorCategory::EgressBlocked
644 | ErrorCategory::Cancelled
645 | ErrorCategory::ChannelClosed
646 | ErrorCategory::NotFound
647 | ErrorCategory::CircuitOpen
648 | ErrorCategory::BudgetExceeded
649 | ErrorCategory::Internal
650 | ErrorCategory::Environment
651 | ErrorCategory::Generic => {}
652 }
653 }
654 assert_eq!(
655 ErrorCategory::ALL.len(),
656 20,
657 "a category was added or removed — update `ErrorCategory::ALL` and the \
658 `Error categories` table in docs/src/builtins.md"
659 );
660 }
661
662 #[test]
663 fn every_category_round_trips_through_parse() {
664 for category in &ErrorCategory::ALL {
665 assert_eq!(
666 &ErrorCategory::parse(category.as_str()),
667 category,
668 "`{}` does not round-trip — `parse` is missing an arm, so a \
669 host handing this category back to Harn silently gets \
670 `generic`",
671 category.as_str()
672 );
673 }
674 }
675
676 #[test]
681 fn every_category_is_documented_in_builtins_md() {
682 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/builtins.md");
683 let doc =
684 std::fs::read_to_string(path).unwrap_or_else(|err| panic!("cannot read {path}: {err}"));
685 let table = doc
686 .split_once("### Error categories")
687 .unwrap_or_else(|| {
688 panic!("docs/src/builtins.md lost its `### Error categories` section")
689 })
690 .1;
691 let table = table.split_once("\n## ").map_or(table, |(head, _)| head);
692 for category in &ErrorCategory::ALL {
693 let row = format!("| `{}` |", category.as_str());
694 assert!(
695 table.contains(&row),
696 "`{}` is missing from the `Error categories` table in \
697 docs/src/builtins.md",
698 category.as_str()
699 );
700 }
701 }
702
703 #[test]
704 fn classifies_cancelled_messages() {
705 assert_eq!(
706 classify_error_message("Bridge: operation cancelled"),
707 ErrorCategory::Cancelled
708 );
709 assert_eq!(
710 classify_error_message("operation canceled by host"),
711 ErrorCategory::Cancelled
712 );
713 }
714
715 #[test]
716 fn classifies_undefined_builtin_as_internal() {
717 assert_eq!(
719 error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
720 ErrorCategory::Internal
721 );
722 assert_eq!(
724 error_to_category(&VmError::InvalidInstruction(200)),
725 ErrorCategory::Internal
726 );
727 assert_eq!(
730 error_to_category(&VmError::Runtime(
731 "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
732 )),
733 ErrorCategory::Internal
734 );
735 assert_eq!(
736 classify_error_message("Undefined builtin: __host_agent_foo"),
737 ErrorCategory::Internal
738 );
739 assert!(!ErrorCategory::Internal.is_transient());
741 assert!(ErrorCategory::Internal.is_internal());
742 assert_eq!(ErrorCategory::Internal.as_str(), "internal");
744 assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
745 }
746
747 #[test]
748 fn classifies_openrouter_invalid_model_id_as_not_found() {
749 assert_eq!(
753 classify_error_message(
754 "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
755 ),
756 ErrorCategory::NotFound
757 );
758 assert_eq!(
759 classify_error_message("invalid model id supplied"),
760 ErrorCategory::NotFound
761 );
762 }
763
764 #[test]
765 fn categorized_error_lowers_to_structured_dict() {
766 let err = categorized_error(
770 "sandbox violation: /etc/passwd",
771 ErrorCategory::ToolRejected,
772 );
773 let VmValue::Dict(dict) = err.thrown_value() else {
774 panic!(
775 "categorized error must lower to a dict, got {:?}",
776 err.thrown_value()
777 );
778 };
779 assert_eq!(
780 dict.get("category").map(|v| v.display()).as_deref(),
781 Some("tool_rejected"),
782 );
783 assert_eq!(
784 dict.get("message").map(|v| v.display()).as_deref(),
785 Some("sandbox violation: /etc/passwd"),
786 );
787 let rendered = categorized_error("boom", ErrorCategory::Cancelled)
791 .thrown_value()
792 .display();
793 assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
794 assert!(rendered.contains("boom"), "rendered dict: {rendered}");
795 }
796
797 #[test]
798 fn thrown_value_passes_structured_thrown_through_unchanged() {
799 let original = VmValue::dict(std::collections::BTreeMap::from([(
802 "code".to_string(),
803 VmValue::Int(7),
804 )]));
805 let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
806 panic!("thrown dict must pass through as a dict");
807 };
808 assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
809 }
810
811 #[test]
812 fn deadlock_renders_with_stable_code() {
813 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
814 "mutex",
815 "__default__",
816 "re-entrant acquire",
817 )));
818 assert!(
819 err.to_string().starts_with("HARN-ORC-011"),
820 "deadlock Display must carry the stable code: {err}"
821 );
822 }
823
824 #[test]
825 fn deadlock_maps_to_generic_category() {
826 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
827 "task",
828 "task_1",
829 "self-join",
830 )));
831 let category = error_to_category(&err);
832 assert_eq!(category, ErrorCategory::Generic);
833 assert!(
834 !category.is_transient(),
835 "a deadlock must not be treated as a retryable transient error"
836 );
837 }
838}