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 Thrown(VmValue),
111 CategorizedError {
113 message: String,
114 category: ErrorCategory,
115 },
116 DaemonQueueFull {
117 daemon_id: String,
118 capacity: usize,
119 },
120 Deadlock(Box<DeadlockError>),
127 Return(VmValue),
128 InvalidInstruction(u8),
129 ArityMismatch(Box<ArityMismatchError>),
133 ArgTypeMismatch(Box<ArgTypeMismatchError>),
139}
140
141impl VmError {
142 pub fn thrown_value(&self) -> VmValue {
163 match self {
164 VmError::Thrown(v) => v.clone(),
165 VmError::CategorizedError { message, category } => {
166 let mut dict = std::collections::BTreeMap::new();
167 dict.put_str("category", category.as_str());
168 dict.put_str("message", message);
169 VmValue::dict(dict)
170 }
171 other => VmValue::String(arcstr::ArcStr::from(other.to_string())),
172 }
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum ErrorCategory {
179 Timeout,
181 Auth,
183 RateLimit,
185 Overloaded,
189 ServerError,
191 TransientNetwork,
194 SchemaValidation,
196 SchemaStreamAborted,
203 ToolError,
205 ToolRejected,
207 EgressBlocked,
209 Cancelled,
211 ChannelClosed,
213 NotFound,
215 CircuitOpen,
217 BudgetExceeded,
219 Internal,
227 Generic,
229}
230
231impl ErrorCategory {
232 pub fn as_str(&self) -> &'static str {
233 match self {
234 ErrorCategory::Timeout => "timeout",
235 ErrorCategory::Auth => "auth",
236 ErrorCategory::RateLimit => "rate_limit",
237 ErrorCategory::Overloaded => "overloaded",
238 ErrorCategory::ServerError => "server_error",
239 ErrorCategory::TransientNetwork => "transient_network",
240 ErrorCategory::SchemaValidation => "schema_validation",
241 ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
242 ErrorCategory::ToolError => "tool_error",
243 ErrorCategory::ToolRejected => "tool_rejected",
244 ErrorCategory::EgressBlocked => "egress_blocked",
245 ErrorCategory::Cancelled => "cancelled",
246 ErrorCategory::ChannelClosed => "channel_closed",
247 ErrorCategory::NotFound => "not_found",
248 ErrorCategory::CircuitOpen => "circuit_open",
249 ErrorCategory::BudgetExceeded => "budget_exceeded",
250 ErrorCategory::Internal => "internal",
251 ErrorCategory::Generic => "generic",
252 }
253 }
254
255 pub fn parse(s: &str) -> Self {
256 match s {
257 "timeout" => ErrorCategory::Timeout,
258 "auth" => ErrorCategory::Auth,
259 "rate_limit" => ErrorCategory::RateLimit,
260 "overloaded" => ErrorCategory::Overloaded,
261 "server_error" => ErrorCategory::ServerError,
262 "transient_network" => ErrorCategory::TransientNetwork,
263 "schema_validation" => ErrorCategory::SchemaValidation,
264 "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
265 "tool_error" => ErrorCategory::ToolError,
266 "tool_rejected" => ErrorCategory::ToolRejected,
267 "egress_blocked" => ErrorCategory::EgressBlocked,
268 "cancelled" => ErrorCategory::Cancelled,
269 "channel_closed" => ErrorCategory::ChannelClosed,
270 "not_found" => ErrorCategory::NotFound,
271 "circuit_open" => ErrorCategory::CircuitOpen,
272 "budget_exceeded" => ErrorCategory::BudgetExceeded,
273 "internal" => ErrorCategory::Internal,
274 _ => ErrorCategory::Generic,
275 }
276 }
277
278 pub fn is_internal(&self) -> bool {
281 matches!(self, ErrorCategory::Internal)
282 }
283
284 pub fn is_transient(&self) -> bool {
288 matches!(
289 self,
290 ErrorCategory::Timeout
291 | ErrorCategory::RateLimit
292 | ErrorCategory::Overloaded
293 | ErrorCategory::ServerError
294 | ErrorCategory::TransientNetwork
295 )
296 }
297}
298
299pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
301 VmError::CategorizedError {
302 message: message.into(),
303 category,
304 }
305}
306
307pub fn error_to_category(err: &VmError) -> ErrorCategory {
316 match err {
317 VmError::CategorizedError { category, .. } => category.clone(),
318 VmError::Thrown(VmValue::Dict(d)) => d
319 .get("category")
320 .map(|v| ErrorCategory::parse(&v.display()))
321 .unwrap_or(ErrorCategory::Generic),
322 VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
323 VmError::Runtime(msg) => classify_error_message(msg),
324 VmError::UndefinedBuiltin(_) | VmError::InvalidInstruction(_) => ErrorCategory::Internal,
329 VmError::Deadlock(_) => ErrorCategory::Generic,
332 _ => ErrorCategory::Generic,
333 }
334}
335
336pub fn classify_error_message(msg: &str) -> ErrorCategory {
339 if let Some(cat) = classify_by_http_status(msg) {
341 return cat;
342 }
343 if msg.contains("Undefined builtin") {
348 return ErrorCategory::Internal;
349 }
350 let lower = msg.to_lowercase();
353 if lower.contains("cancelled") || lower.contains("canceled") {
354 return ErrorCategory::Cancelled;
355 }
356 if msg.contains("ChannelClosed") || lower.contains("channel closed") {
357 return ErrorCategory::ChannelClosed;
358 }
359 if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
360 return ErrorCategory::Timeout;
361 }
362 if msg.contains("overloaded_error") {
363 return ErrorCategory::Overloaded;
365 }
366 if msg.contains("api_error") {
367 return ErrorCategory::ServerError;
369 }
370 if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
371 return ErrorCategory::RateLimit;
373 }
374 if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
375 return ErrorCategory::Auth;
376 }
377 if msg.contains("not_found_error") || msg.contains("model_not_found") {
378 return ErrorCategory::NotFound;
379 }
380 if lower.contains("is not a valid model id") || lower.contains("invalid model id") {
386 return ErrorCategory::NotFound;
387 }
388 if msg.contains("circuit_open") {
389 return ErrorCategory::CircuitOpen;
390 }
391 if lower.contains("connection reset")
393 || lower.contains("connection refused")
394 || lower.contains("connection closed")
395 || lower.contains("broken pipe")
396 || lower.contains("dns error")
397 || lower.contains("stream error")
398 || lower.contains("unexpected eof")
399 {
400 return ErrorCategory::TransientNetwork;
401 }
402 ErrorCategory::Generic
403}
404
405fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
409 for code in extract_http_status_codes(msg) {
412 return Some(match code {
413 401 | 403 => ErrorCategory::Auth,
414 404 | 410 => ErrorCategory::NotFound,
415 408 | 504 | 522 | 524 => ErrorCategory::Timeout,
416 429 => ErrorCategory::RateLimit,
417 503 | 529 => ErrorCategory::Overloaded,
418 500 | 502 => ErrorCategory::ServerError,
419 _ => continue,
420 });
421 }
422 None
423}
424
425fn extract_http_status_codes(msg: &str) -> Vec<u16> {
427 let mut codes = Vec::new();
428 let bytes = msg.as_bytes();
429 for i in 0..bytes.len().saturating_sub(2) {
430 if bytes[i].is_ascii_digit()
432 && bytes[i + 1].is_ascii_digit()
433 && bytes[i + 2].is_ascii_digit()
434 {
435 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
437 let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
438 if before_ok && after_ok {
439 if let Ok(code) = msg[i..i + 3].parse::<u16>() {
440 if (400..=599).contains(&code) {
441 codes.push(code);
442 }
443 }
444 }
445 }
446 }
447 codes
448}
449
450impl std::fmt::Display for VmError {
451 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
452 match self {
453 VmError::StackUnderflow => write!(f, "Stack underflow"),
454 VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
455 VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
456 VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
457 VmError::ImmutableAssignment(n) => {
458 write!(f, "Cannot assign to immutable binding: {n}")
459 }
460 VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
461 VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
462 VmError::DivisionByZero => write!(f, "Division by zero"),
463 VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
464 VmError::CategorizedError { message, category } => {
465 write!(f, "Error [{}]: {}", category.as_str(), message)
466 }
467 VmError::DaemonQueueFull {
468 daemon_id,
469 capacity,
470 } => write!(
471 f,
472 "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
473 ),
474 VmError::Deadlock(err) => match err.diagnostic {
475 DeadlockDiagnostic::SelfDeadlock => write!(
476 f,
477 "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
478 err.diagnostic.code(),
479 err.detail,
480 err.kind,
481 err.key
482 ),
483 DeadlockDiagnostic::WaitForGraph => write!(
484 f,
485 "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
486 err.diagnostic.code(),
487 err.detail,
488 err.kind,
489 err.key
490 ),
491 },
492 VmError::Return(_) => write!(f, "Return from function"),
493 VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
494 VmError::ArityMismatch(err) => {
495 let arg_word = match err.expected {
496 ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
497 _ => "arguments",
498 };
499 write!(
500 f,
501 "Arity mismatch: '{}' expects {} {}, got {}{}",
502 err.callee,
503 err.expected,
504 arg_word,
505 err.got,
506 fmt_span_suffix(&err.span)
507 )
508 }
509 VmError::ArgTypeMismatch(err) => {
510 write!(
511 f,
512 "Type error: '{}' parameter `{}` expects {}, got {}{}",
513 err.callee,
514 err.param,
515 err.expected,
516 err.got,
517 fmt_span_suffix(&err.span)
518 )
519 }
520 }
521 }
522}
523
524fn fmt_span_suffix(span: &Option<Span>) -> String {
525 match span {
526 Some(s) => format!(" (at byte {}..{})", s.start, s.end),
527 None => String::new(),
528 }
529}
530
531impl std::error::Error for VmError {}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn classifies_cancelled_messages() {
539 assert_eq!(
540 classify_error_message("Bridge: operation cancelled"),
541 ErrorCategory::Cancelled
542 );
543 assert_eq!(
544 classify_error_message("operation canceled by host"),
545 ErrorCategory::Cancelled
546 );
547 }
548
549 #[test]
550 fn classifies_undefined_builtin_as_internal() {
551 assert_eq!(
553 error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
554 ErrorCategory::Internal
555 );
556 assert_eq!(
558 error_to_category(&VmError::InvalidInstruction(200)),
559 ErrorCategory::Internal
560 );
561 assert_eq!(
564 error_to_category(&VmError::Runtime(
565 "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
566 )),
567 ErrorCategory::Internal
568 );
569 assert_eq!(
570 classify_error_message("Undefined builtin: __host_agent_foo"),
571 ErrorCategory::Internal
572 );
573 assert!(!ErrorCategory::Internal.is_transient());
575 assert!(ErrorCategory::Internal.is_internal());
576 assert_eq!(ErrorCategory::Internal.as_str(), "internal");
578 assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
579 }
580
581 #[test]
582 fn classifies_openrouter_invalid_model_id_as_not_found() {
583 assert_eq!(
587 classify_error_message(
588 "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
589 ),
590 ErrorCategory::NotFound
591 );
592 assert_eq!(
593 classify_error_message("invalid model id supplied"),
594 ErrorCategory::NotFound
595 );
596 }
597
598 #[test]
599 fn categorized_error_lowers_to_structured_dict() {
600 let err = categorized_error(
604 "sandbox violation: /etc/passwd",
605 ErrorCategory::ToolRejected,
606 );
607 let VmValue::Dict(dict) = err.thrown_value() else {
608 panic!(
609 "categorized error must lower to a dict, got {:?}",
610 err.thrown_value()
611 );
612 };
613 assert_eq!(
614 dict.get("category").map(|v| v.display()).as_deref(),
615 Some("tool_rejected"),
616 );
617 assert_eq!(
618 dict.get("message").map(|v| v.display()).as_deref(),
619 Some("sandbox violation: /etc/passwd"),
620 );
621 let rendered = categorized_error("boom", ErrorCategory::Cancelled)
625 .thrown_value()
626 .display();
627 assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
628 assert!(rendered.contains("boom"), "rendered dict: {rendered}");
629 }
630
631 #[test]
632 fn thrown_value_passes_structured_thrown_through_unchanged() {
633 let original = VmValue::dict(std::collections::BTreeMap::from([(
636 "code".to_string(),
637 VmValue::Int(7),
638 )]));
639 let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
640 panic!("thrown dict must pass through as a dict");
641 };
642 assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
643 }
644
645 #[test]
646 fn deadlock_renders_with_stable_code() {
647 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
648 "mutex",
649 "__default__",
650 "re-entrant acquire",
651 )));
652 assert!(
653 err.to_string().starts_with("HARN-ORC-011"),
654 "deadlock Display must carry the stable code: {err}"
655 );
656 }
657
658 #[test]
659 fn deadlock_maps_to_generic_category() {
660 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
661 "task",
662 "task_1",
663 "self-join",
664 )));
665 let category = error_to_category(&err);
666 assert_eq!(category, ErrorCategory::Generic);
667 assert!(
668 !category.is_transient(),
669 "a deadlock must not be treated as a retryable transient error"
670 );
671 }
672}