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