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 AbandonedExecution,
118 Thrown(VmValue),
119 CategorizedError {
121 message: String,
122 category: ErrorCategory,
123 },
124 DaemonQueueFull {
125 daemon_id: String,
126 capacity: usize,
127 },
128 Deadlock(Box<DeadlockError>),
135 Return(VmValue),
136 InvalidInstruction(u8),
137 ArityMismatch(Box<ArityMismatchError>),
141 ArgTypeMismatch(Box<ArgTypeMismatchError>),
147}
148
149impl VmError {
150 pub fn thrown_value(&self) -> VmValue {
171 match self {
172 VmError::Thrown(v) => v.clone(),
173 VmError::CategorizedError { message, category } => {
174 let mut dict = std::collections::BTreeMap::new();
175 dict.put_str("category", category.as_str());
176 dict.put_str("message", message);
177 VmValue::dict(dict)
178 }
179 other => VmValue::String(arcstr::ArcStr::from(other.to_string())),
180 }
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum ErrorCategory {
187 Timeout,
189 Auth,
191 RateLimit,
193 Overloaded,
197 ServerError,
199 TransientNetwork,
202 SchemaValidation,
204 SchemaStreamAborted,
211 ToolError,
213 ToolRejected,
215 EgressBlocked,
217 Cancelled,
219 ChannelClosed,
221 NotFound,
223 CircuitOpen,
225 BudgetExceeded,
227 Internal,
235 Generic,
237}
238
239impl ErrorCategory {
240 pub fn as_str(&self) -> &'static str {
241 match self {
242 ErrorCategory::Timeout => "timeout",
243 ErrorCategory::Auth => "auth",
244 ErrorCategory::RateLimit => "rate_limit",
245 ErrorCategory::Overloaded => "overloaded",
246 ErrorCategory::ServerError => "server_error",
247 ErrorCategory::TransientNetwork => "transient_network",
248 ErrorCategory::SchemaValidation => "schema_validation",
249 ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
250 ErrorCategory::ToolError => "tool_error",
251 ErrorCategory::ToolRejected => "tool_rejected",
252 ErrorCategory::EgressBlocked => "egress_blocked",
253 ErrorCategory::Cancelled => "cancelled",
254 ErrorCategory::ChannelClosed => "channel_closed",
255 ErrorCategory::NotFound => "not_found",
256 ErrorCategory::CircuitOpen => "circuit_open",
257 ErrorCategory::BudgetExceeded => "budget_exceeded",
258 ErrorCategory::Internal => "internal",
259 ErrorCategory::Generic => "generic",
260 }
261 }
262
263 pub fn parse(s: &str) -> Self {
264 match s {
265 "timeout" => ErrorCategory::Timeout,
266 "auth" => ErrorCategory::Auth,
267 "rate_limit" => ErrorCategory::RateLimit,
268 "overloaded" => ErrorCategory::Overloaded,
269 "server_error" => ErrorCategory::ServerError,
270 "transient_network" => ErrorCategory::TransientNetwork,
271 "schema_validation" => ErrorCategory::SchemaValidation,
272 "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
273 "tool_error" => ErrorCategory::ToolError,
274 "tool_rejected" => ErrorCategory::ToolRejected,
275 "egress_blocked" => ErrorCategory::EgressBlocked,
276 "cancelled" => ErrorCategory::Cancelled,
277 "channel_closed" => ErrorCategory::ChannelClosed,
278 "not_found" => ErrorCategory::NotFound,
279 "circuit_open" => ErrorCategory::CircuitOpen,
280 "budget_exceeded" => ErrorCategory::BudgetExceeded,
281 "internal" => ErrorCategory::Internal,
282 _ => ErrorCategory::Generic,
283 }
284 }
285
286 pub fn is_internal(&self) -> bool {
289 matches!(self, ErrorCategory::Internal)
290 }
291
292 pub fn is_transient(&self) -> bool {
296 matches!(
297 self,
298 ErrorCategory::Timeout
299 | ErrorCategory::RateLimit
300 | ErrorCategory::Overloaded
301 | ErrorCategory::ServerError
302 | ErrorCategory::TransientNetwork
303 )
304 }
305}
306
307pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
309 VmError::CategorizedError {
310 message: message.into(),
311 category,
312 }
313}
314
315pub fn error_to_category(err: &VmError) -> ErrorCategory {
324 match err {
325 VmError::ExecutionDeadlineExceeded => ErrorCategory::Timeout,
326 VmError::AbandonedExecution => ErrorCategory::Cancelled,
327 VmError::CategorizedError { category, .. } => category.clone(),
328 VmError::Thrown(VmValue::Dict(d)) => d
329 .get("category")
330 .map(|v| ErrorCategory::parse(&v.display()))
331 .unwrap_or(ErrorCategory::Generic),
332 VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
333 VmError::Runtime(msg) => classify_error_message(msg),
334 VmError::UndefinedBuiltin(_) | VmError::InvalidInstruction(_) => ErrorCategory::Internal,
339 VmError::Deadlock(_) => ErrorCategory::Generic,
342 _ => ErrorCategory::Generic,
343 }
344}
345
346pub fn classify_error_message(msg: &str) -> ErrorCategory {
349 if let Some(cat) = classify_by_http_status(msg) {
351 return cat;
352 }
353 if msg.contains("Undefined builtin") {
358 return ErrorCategory::Internal;
359 }
360 let lower = msg.to_lowercase();
363 if lower.contains("cancelled") || lower.contains("canceled") {
364 return ErrorCategory::Cancelled;
365 }
366 if msg.contains("ChannelClosed") || lower.contains("channel closed") {
367 return ErrorCategory::ChannelClosed;
368 }
369 if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
370 return ErrorCategory::Timeout;
371 }
372 if msg.contains("overloaded_error") {
373 return ErrorCategory::Overloaded;
375 }
376 if msg.contains("api_error") {
377 return ErrorCategory::ServerError;
379 }
380 if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
381 return ErrorCategory::RateLimit;
383 }
384 if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
385 return ErrorCategory::Auth;
386 }
387 if msg.contains("not_found_error") || msg.contains("model_not_found") {
388 return ErrorCategory::NotFound;
389 }
390 if lower.contains("is not a valid model id") || lower.contains("invalid model id") {
396 return ErrorCategory::NotFound;
397 }
398 if msg.contains("circuit_open") {
399 return ErrorCategory::CircuitOpen;
400 }
401 if lower.contains("connection reset")
403 || lower.contains("connection refused")
404 || lower.contains("connection closed")
405 || lower.contains("broken pipe")
406 || lower.contains("dns error")
407 || lower.contains("stream error")
408 || lower.contains("unexpected eof")
409 {
410 return ErrorCategory::TransientNetwork;
411 }
412 ErrorCategory::Generic
413}
414
415fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
419 for code in extract_http_status_codes(msg) {
422 return Some(match code {
423 401 | 403 => ErrorCategory::Auth,
424 404 | 410 => ErrorCategory::NotFound,
425 408 | 504 | 522 | 524 => ErrorCategory::Timeout,
426 429 => ErrorCategory::RateLimit,
427 503 | 529 => ErrorCategory::Overloaded,
428 500 | 502 => ErrorCategory::ServerError,
429 _ => continue,
430 });
431 }
432 None
433}
434
435fn extract_http_status_codes(msg: &str) -> Vec<u16> {
437 let mut codes = Vec::new();
438 let bytes = msg.as_bytes();
439 for i in 0..bytes.len().saturating_sub(2) {
440 if bytes[i].is_ascii_digit()
442 && bytes[i + 1].is_ascii_digit()
443 && bytes[i + 2].is_ascii_digit()
444 {
445 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
447 let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
448 if before_ok && after_ok {
449 if let Ok(code) = msg[i..i + 3].parse::<u16>() {
450 if (400..=599).contains(&code) {
451 codes.push(code);
452 }
453 }
454 }
455 }
456 }
457 codes
458}
459
460impl std::fmt::Display for VmError {
461 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462 match self {
463 VmError::StackUnderflow => write!(f, "Stack underflow"),
464 VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
465 VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
466 VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
467 VmError::ImmutableAssignment(n) => {
468 write!(f, "Cannot assign to immutable binding: {n}")
469 }
470 VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
471 VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
472 VmError::DivisionByZero => write!(f, "Division by zero"),
473 VmError::ExecutionDeadlineExceeded => write!(f, "Execution deadline exceeded"),
474 VmError::AbandonedExecution => write!(
475 f,
476 "Execution future was abandoned; discard this VM and reset its exclusively owned execution context"
477 ),
478 VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
479 VmError::CategorizedError { message, category } => {
480 write!(f, "Error [{}]: {}", category.as_str(), message)
481 }
482 VmError::DaemonQueueFull {
483 daemon_id,
484 capacity,
485 } => write!(
486 f,
487 "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
488 ),
489 VmError::Deadlock(err) => match err.diagnostic {
490 DeadlockDiagnostic::SelfDeadlock => write!(
491 f,
492 "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
493 err.diagnostic.code(),
494 err.detail,
495 err.kind,
496 err.key
497 ),
498 DeadlockDiagnostic::WaitForGraph => write!(
499 f,
500 "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
501 err.diagnostic.code(),
502 err.detail,
503 err.kind,
504 err.key
505 ),
506 },
507 VmError::Return(_) => write!(f, "Return from function"),
508 VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
509 VmError::ArityMismatch(err) => {
510 let arg_word = match err.expected {
511 ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
512 _ => "arguments",
513 };
514 write!(
515 f,
516 "Arity mismatch: '{}' expects {} {}, got {}{}",
517 err.callee,
518 err.expected,
519 arg_word,
520 err.got,
521 fmt_span_suffix(&err.span)
522 )
523 }
524 VmError::ArgTypeMismatch(err) => {
525 write!(
526 f,
527 "Type error: '{}' parameter `{}` expects {}, got {}{}",
528 err.callee,
529 err.param,
530 err.expected,
531 err.got,
532 fmt_span_suffix(&err.span)
533 )
534 }
535 }
536 }
537}
538
539fn fmt_span_suffix(span: &Option<Span>) -> String {
540 match span {
541 Some(s) => format!(" (at byte {}..{})", s.start, s.end),
542 None => String::new(),
543 }
544}
545
546impl std::error::Error for VmError {}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 const ALL_CATEGORIES: &[ErrorCategory] = &[
556 ErrorCategory::Timeout,
557 ErrorCategory::Auth,
558 ErrorCategory::RateLimit,
559 ErrorCategory::Overloaded,
560 ErrorCategory::ServerError,
561 ErrorCategory::TransientNetwork,
562 ErrorCategory::SchemaValidation,
563 ErrorCategory::SchemaStreamAborted,
564 ErrorCategory::ToolError,
565 ErrorCategory::ToolRejected,
566 ErrorCategory::EgressBlocked,
567 ErrorCategory::Cancelled,
568 ErrorCategory::ChannelClosed,
569 ErrorCategory::NotFound,
570 ErrorCategory::CircuitOpen,
571 ErrorCategory::BudgetExceeded,
572 ErrorCategory::Internal,
573 ErrorCategory::Generic,
574 ];
575
576 #[test]
580 fn all_categories_is_exhaustive() {
581 for category in ALL_CATEGORIES {
582 match category {
583 ErrorCategory::Timeout
584 | ErrorCategory::Auth
585 | ErrorCategory::RateLimit
586 | ErrorCategory::Overloaded
587 | ErrorCategory::ServerError
588 | ErrorCategory::TransientNetwork
589 | ErrorCategory::SchemaValidation
590 | ErrorCategory::SchemaStreamAborted
591 | ErrorCategory::ToolError
592 | ErrorCategory::ToolRejected
593 | ErrorCategory::EgressBlocked
594 | ErrorCategory::Cancelled
595 | ErrorCategory::ChannelClosed
596 | ErrorCategory::NotFound
597 | ErrorCategory::CircuitOpen
598 | ErrorCategory::BudgetExceeded
599 | ErrorCategory::Internal
600 | ErrorCategory::Generic => {}
601 }
602 }
603 assert_eq!(
604 ALL_CATEGORIES.len(),
605 18,
606 "a category was added or removed — update ALL_CATEGORIES and the \
607 `Error categories` table in docs/src/builtins.md"
608 );
609 }
610
611 #[test]
612 fn every_category_round_trips_through_parse() {
613 for category in ALL_CATEGORIES {
614 assert_eq!(
615 &ErrorCategory::parse(category.as_str()),
616 category,
617 "`{}` does not round-trip — `parse` is missing an arm, so a \
618 host handing this category back to Harn silently gets \
619 `generic`",
620 category.as_str()
621 );
622 }
623 }
624
625 #[test]
630 fn every_category_is_documented_in_builtins_md() {
631 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/builtins.md");
632 let doc =
633 std::fs::read_to_string(path).unwrap_or_else(|err| panic!("cannot read {path}: {err}"));
634 let table = doc
635 .split_once("### Error categories")
636 .unwrap_or_else(|| {
637 panic!("docs/src/builtins.md lost its `### Error categories` section")
638 })
639 .1;
640 let table = table.split_once("\n## ").map_or(table, |(head, _)| head);
641 for category in ALL_CATEGORIES {
642 let row = format!("| `{}` |", category.as_str());
643 assert!(
644 table.contains(&row),
645 "`{}` is missing from the `Error categories` table in \
646 docs/src/builtins.md",
647 category.as_str()
648 );
649 }
650 }
651
652 #[test]
653 fn classifies_cancelled_messages() {
654 assert_eq!(
655 classify_error_message("Bridge: operation cancelled"),
656 ErrorCategory::Cancelled
657 );
658 assert_eq!(
659 classify_error_message("operation canceled by host"),
660 ErrorCategory::Cancelled
661 );
662 }
663
664 #[test]
665 fn classifies_undefined_builtin_as_internal() {
666 assert_eq!(
668 error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
669 ErrorCategory::Internal
670 );
671 assert_eq!(
673 error_to_category(&VmError::InvalidInstruction(200)),
674 ErrorCategory::Internal
675 );
676 assert_eq!(
679 error_to_category(&VmError::Runtime(
680 "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
681 )),
682 ErrorCategory::Internal
683 );
684 assert_eq!(
685 classify_error_message("Undefined builtin: __host_agent_foo"),
686 ErrorCategory::Internal
687 );
688 assert!(!ErrorCategory::Internal.is_transient());
690 assert!(ErrorCategory::Internal.is_internal());
691 assert_eq!(ErrorCategory::Internal.as_str(), "internal");
693 assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
694 }
695
696 #[test]
697 fn classifies_openrouter_invalid_model_id_as_not_found() {
698 assert_eq!(
702 classify_error_message(
703 "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
704 ),
705 ErrorCategory::NotFound
706 );
707 assert_eq!(
708 classify_error_message("invalid model id supplied"),
709 ErrorCategory::NotFound
710 );
711 }
712
713 #[test]
714 fn categorized_error_lowers_to_structured_dict() {
715 let err = categorized_error(
719 "sandbox violation: /etc/passwd",
720 ErrorCategory::ToolRejected,
721 );
722 let VmValue::Dict(dict) = err.thrown_value() else {
723 panic!(
724 "categorized error must lower to a dict, got {:?}",
725 err.thrown_value()
726 );
727 };
728 assert_eq!(
729 dict.get("category").map(|v| v.display()).as_deref(),
730 Some("tool_rejected"),
731 );
732 assert_eq!(
733 dict.get("message").map(|v| v.display()).as_deref(),
734 Some("sandbox violation: /etc/passwd"),
735 );
736 let rendered = categorized_error("boom", ErrorCategory::Cancelled)
740 .thrown_value()
741 .display();
742 assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
743 assert!(rendered.contains("boom"), "rendered dict: {rendered}");
744 }
745
746 #[test]
747 fn thrown_value_passes_structured_thrown_through_unchanged() {
748 let original = VmValue::dict(std::collections::BTreeMap::from([(
751 "code".to_string(),
752 VmValue::Int(7),
753 )]));
754 let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
755 panic!("thrown dict must pass through as a dict");
756 };
757 assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
758 }
759
760 #[test]
761 fn deadlock_renders_with_stable_code() {
762 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
763 "mutex",
764 "__default__",
765 "re-entrant acquire",
766 )));
767 assert!(
768 err.to_string().starts_with("HARN-ORC-011"),
769 "deadlock Display must carry the stable code: {err}"
770 );
771 }
772
773 #[test]
774 fn deadlock_maps_to_generic_category() {
775 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
776 "task",
777 "task_1",
778 "self-join",
779 )));
780 let category = error_to_category(&err);
781 assert_eq!(category, ErrorCategory::Generic);
782 assert!(
783 !category.is_transient(),
784 "a deadlock must not be treated as a retryable transient error"
785 );
786 }
787}