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 #[test]
553 fn classifies_cancelled_messages() {
554 assert_eq!(
555 classify_error_message("Bridge: operation cancelled"),
556 ErrorCategory::Cancelled
557 );
558 assert_eq!(
559 classify_error_message("operation canceled by host"),
560 ErrorCategory::Cancelled
561 );
562 }
563
564 #[test]
565 fn classifies_undefined_builtin_as_internal() {
566 assert_eq!(
568 error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
569 ErrorCategory::Internal
570 );
571 assert_eq!(
573 error_to_category(&VmError::InvalidInstruction(200)),
574 ErrorCategory::Internal
575 );
576 assert_eq!(
579 error_to_category(&VmError::Runtime(
580 "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
581 )),
582 ErrorCategory::Internal
583 );
584 assert_eq!(
585 classify_error_message("Undefined builtin: __host_agent_foo"),
586 ErrorCategory::Internal
587 );
588 assert!(!ErrorCategory::Internal.is_transient());
590 assert!(ErrorCategory::Internal.is_internal());
591 assert_eq!(ErrorCategory::Internal.as_str(), "internal");
593 assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
594 }
595
596 #[test]
597 fn classifies_openrouter_invalid_model_id_as_not_found() {
598 assert_eq!(
602 classify_error_message(
603 "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
604 ),
605 ErrorCategory::NotFound
606 );
607 assert_eq!(
608 classify_error_message("invalid model id supplied"),
609 ErrorCategory::NotFound
610 );
611 }
612
613 #[test]
614 fn categorized_error_lowers_to_structured_dict() {
615 let err = categorized_error(
619 "sandbox violation: /etc/passwd",
620 ErrorCategory::ToolRejected,
621 );
622 let VmValue::Dict(dict) = err.thrown_value() else {
623 panic!(
624 "categorized error must lower to a dict, got {:?}",
625 err.thrown_value()
626 );
627 };
628 assert_eq!(
629 dict.get("category").map(|v| v.display()).as_deref(),
630 Some("tool_rejected"),
631 );
632 assert_eq!(
633 dict.get("message").map(|v| v.display()).as_deref(),
634 Some("sandbox violation: /etc/passwd"),
635 );
636 let rendered = categorized_error("boom", ErrorCategory::Cancelled)
640 .thrown_value()
641 .display();
642 assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
643 assert!(rendered.contains("boom"), "rendered dict: {rendered}");
644 }
645
646 #[test]
647 fn thrown_value_passes_structured_thrown_through_unchanged() {
648 let original = VmValue::dict(std::collections::BTreeMap::from([(
651 "code".to_string(),
652 VmValue::Int(7),
653 )]));
654 let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
655 panic!("thrown dict must pass through as a dict");
656 };
657 assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
658 }
659
660 #[test]
661 fn deadlock_renders_with_stable_code() {
662 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
663 "mutex",
664 "__default__",
665 "re-entrant acquire",
666 )));
667 assert!(
668 err.to_string().starts_with("HARN-ORC-011"),
669 "deadlock Display must carry the stable code: {err}"
670 );
671 }
672
673 #[test]
674 fn deadlock_maps_to_generic_category() {
675 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
676 "task",
677 "task_1",
678 "self-join",
679 )));
680 let category = error_to_category(&err);
681 assert_eq!(category, ErrorCategory::Generic);
682 assert!(
683 !category.is_transient(),
684 "a deadlock must not be treated as a retryable transient error"
685 );
686 }
687}