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)]
107pub struct BindingTypeMismatchError {
108 pub binding: String,
110 pub expected: String,
111 pub got: &'static str,
112 pub span: Option<Span>,
113}
114
115#[derive(Debug, Clone)]
116pub enum VmError {
117 StackUnderflow,
118 StackOverflow,
119 UndefinedVariable(String),
120 UndefinedBuiltin(String),
121 ImmutableAssignment(String),
122 TypeError(String),
123 Runtime(String),
124 DivisionByZero,
125 ExecutionDeadlineExceeded,
128 ProcessExit(i32),
133 AbandonedExecution,
138 McpInputRequired(Box<crate::mcp_input::McpInputRequired>),
142 Thrown(VmValue),
143 CategorizedError {
145 message: String,
146 category: ErrorCategory,
147 },
148 ProviderStreamFailure(Box<ProviderStreamFailure>),
153 SchemaStreamAbort(Box<SchemaStreamAbort>),
158 SandboxMechanismUnavailable(Box<crate::process_sandbox::SandboxMechanismUnavailable>),
163 DaemonQueueFull {
164 daemon_id: String,
165 capacity: usize,
166 },
167 Deadlock(Box<DeadlockError>),
174 Return(VmValue),
175 InvalidInstruction(u8),
176 ArityMismatch(Box<ArityMismatchError>),
180 ArgTypeMismatch(Box<ArgTypeMismatchError>),
186 BindingTypeMismatch(Box<BindingTypeMismatchError>),
190}
191
192impl VmError {
193 pub fn is_uncatchable_control_flow(&self) -> bool {
196 matches!(
197 self,
198 Self::ExecutionDeadlineExceeded | Self::ProcessExit(_) | Self::McpInputRequired(_)
199 )
200 }
201
202 pub fn process_exit_code(&self) -> Option<i32> {
205 match self {
206 Self::ProcessExit(code) => Some(*code),
207 _ => None,
208 }
209 }
210
211 pub fn thrown_value(&self) -> VmValue {
232 match self {
233 VmError::Thrown(v) => v.clone(),
234 VmError::CategorizedError { message, category } => {
235 let mut dict = std::collections::BTreeMap::new();
236 dict.put_str("category", category.as_str());
237 dict.put_str("message", message);
238 VmValue::dict(dict)
239 }
240 VmError::ProviderStreamFailure(failure) => failure.thrown_value(),
241 VmError::SchemaStreamAbort(abort) => abort.thrown_value(),
242 VmError::SandboxMechanismUnavailable(refusal) => refusal.thrown_value(),
243 other => VmValue::String(arcstr::ArcStr::from(other.to_string())),
244 }
245 }
246
247 pub fn provider_stream_failure(&self) -> Option<&ProviderStreamFailure> {
248 match self {
249 Self::ProviderStreamFailure(failure) => Some(failure),
250 _ => None,
251 }
252 }
253
254 pub fn schema_stream_abort(&self) -> Option<&SchemaStreamAbort> {
255 match self {
256 Self::SchemaStreamAbort(abort) => Some(abort),
257 _ => None,
258 }
259 }
260
261 pub fn sandbox_mechanism_unavailable(
264 &self,
265 ) -> Option<&crate::process_sandbox::SandboxMechanismUnavailable> {
266 match self {
267 Self::SandboxMechanismUnavailable(refusal) => Some(refusal),
268 _ => None,
269 }
270 }
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum ProviderStreamPhase {
275 AwaitingFirstChunk,
276 Streaming,
277}
278
279impl ProviderStreamPhase {
280 pub fn as_str(self) -> &'static str {
281 match self {
282 Self::AwaitingFirstChunk => "awaiting_first_chunk",
283 Self::Streaming => "streaming",
284 }
285 }
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum ProviderStreamFailureReason {
290 Read,
291 PrematureEof,
292 Deadline,
293}
294
295impl ProviderStreamFailureReason {
296 pub fn as_str(self) -> &'static str {
297 match self {
298 Self::Read => "read",
299 Self::PrematureEof => "premature_eof",
300 Self::Deadline => "deadline",
301 }
302 }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum ProviderStreamDeadline {
307 Total,
308 FirstChunk,
309 Idle,
310}
311
312impl ProviderStreamDeadline {
313 pub fn as_str(self) -> &'static str {
314 match self {
315 Self::Total => "total",
316 Self::FirstChunk => "first_chunk",
317 Self::Idle => "idle",
318 }
319 }
320}
321
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct ProviderStreamFailure {
324 pub provider: String,
325 pub phase: ProviderStreamPhase,
326 pub reason: ProviderStreamFailureReason,
327 pub deadline: Option<ProviderStreamDeadline>,
328 pub partial: bool,
329 pub detail: String,
330}
331
332impl ProviderStreamFailure {
333 pub fn category(&self) -> ErrorCategory {
334 if self.deadline.is_some() {
335 ErrorCategory::Timeout
336 } else {
337 ErrorCategory::TransientNetwork
338 }
339 }
340
341 fn thrown_value(&self) -> VmValue {
342 let mut dict = std::collections::BTreeMap::new();
343 dict.put_str("category", self.category().as_str());
344 dict.put_str("message", self.to_string());
345 dict.put_str("source", "provider_stream");
346 dict.put_str("phase", self.phase.as_str());
347 dict.put_str("reason", self.reason.as_str());
348 dict.insert(
349 "deadline".to_string(),
350 self.deadline
351 .map(|deadline| VmValue::String(arcstr::ArcStr::from(deadline.as_str())))
352 .unwrap_or(VmValue::Nil),
353 );
354 dict.insert("partial".to_string(), VmValue::Bool(self.partial));
355 VmValue::dict(dict)
356 }
357}
358
359impl std::fmt::Display for ProviderStreamFailure {
360 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361 write!(
362 f,
363 "{} provider stream failure (phase={}, reason={}",
364 self.provider,
365 self.phase.as_str(),
366 self.reason.as_str()
367 )?;
368 if let Some(deadline) = self.deadline {
369 write!(f, ", deadline={}", deadline.as_str())?;
370 }
371 write!(f, ", partial={}): {}", self.partial, self.detail)
372 }
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub enum SchemaValidationReasonKind {
378 InvalidJson,
379 InvalidSchema,
380 WrongType,
381 MissingRequired,
382 UnexpectedProperty,
383 MaxLength,
384 MinLength,
385 ConstraintViolation,
386}
387
388impl SchemaValidationReasonKind {
389 pub fn as_str(self) -> &'static str {
390 match self {
391 Self::InvalidJson => "invalid_json",
392 Self::InvalidSchema => "invalid_schema",
393 Self::WrongType => "wrong_type",
394 Self::MissingRequired => "missing_required",
395 Self::UnexpectedProperty => "unexpected_property",
396 Self::MaxLength => "max_length",
397 Self::MinLength => "min_length",
398 Self::ConstraintViolation => "constraint_violation",
399 }
400 }
401}
402
403#[derive(Debug, Clone, PartialEq, Eq)]
406pub struct SchemaStreamAbort {
407 pub provider: String,
408 pub model: String,
409 pub reason_kind: SchemaValidationReasonKind,
410 pub reason: String,
411 pub path: String,
412 pub chunks_consumed: usize,
413}
414
415impl SchemaStreamAbort {
416 pub fn category(&self) -> ErrorCategory {
417 ErrorCategory::SchemaStreamAborted
418 }
419
420 fn thrown_value(&self) -> VmValue {
421 let mut cause = std::collections::BTreeMap::new();
422 cause.put_str("kind", self.reason_kind.as_str());
423 cause.put_str("detail", self.reason.as_str());
424 cause.put_str("path", self.path.as_str());
425 cause.insert(
426 "chunks_consumed".to_string(),
427 VmValue::Int(self.chunks_consumed as i64),
428 );
429 cause.put_str("provider", self.provider.as_str());
430 cause.put_str("model", self.model.as_str());
431
432 let mut dict = std::collections::BTreeMap::new();
433 dict.put_str("category", self.category().as_str());
434 dict.put_str("message", self.to_string());
435 dict.put_str("source", "schema_stream");
436 dict.insert("schema_failure".to_string(), VmValue::dict(cause));
437 VmValue::dict(dict)
438 }
439}
440
441impl std::fmt::Display for SchemaStreamAbort {
442 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443 write!(
444 f,
445 "schema_stream_aborted at {}: {} (provider={} model={} chunks_consumed={})",
446 self.path, self.reason, self.provider, self.model, self.chunks_consumed
447 )
448 }
449}
450
451#[derive(Debug, Clone, PartialEq, Eq)]
453pub enum ErrorCategory {
454 Timeout,
456 Auth,
458 InvalidRequest,
460 RateLimit,
462 Overloaded,
466 ServerError,
468 TransientNetwork,
471 ResourceBusy,
474 SchemaIncompatible,
477 SchemaValidation,
479 SchemaStreamAborted,
486 ToolError,
488 ToolRejected,
490 EgressBlocked,
492 Cancelled,
494 ChannelClosed,
496 NotFound,
498 CircuitOpen,
500 BudgetExceeded,
502 Internal,
510 Environment,
519 Generic,
521}
522
523impl ErrorCategory {
524 pub const ALL: [Self; 22] = [
533 Self::Timeout,
534 Self::Auth,
535 Self::InvalidRequest,
536 Self::RateLimit,
537 Self::Overloaded,
538 Self::ServerError,
539 Self::TransientNetwork,
540 Self::ResourceBusy,
541 Self::SchemaIncompatible,
542 Self::SchemaValidation,
543 Self::SchemaStreamAborted,
544 Self::ToolError,
545 Self::ToolRejected,
546 Self::EgressBlocked,
547 Self::Cancelled,
548 Self::ChannelClosed,
549 Self::NotFound,
550 Self::CircuitOpen,
551 Self::BudgetExceeded,
552 Self::Internal,
553 Self::Environment,
554 Self::Generic,
555 ];
556
557 pub fn as_str(&self) -> &'static str {
558 match self {
559 ErrorCategory::Timeout => "timeout",
560 ErrorCategory::Auth => "auth",
561 ErrorCategory::InvalidRequest => "invalid_request",
562 ErrorCategory::RateLimit => "rate_limit",
563 ErrorCategory::Overloaded => "overloaded",
564 ErrorCategory::ServerError => "server_error",
565 ErrorCategory::TransientNetwork => "transient_network",
566 ErrorCategory::ResourceBusy => "resource_busy",
567 ErrorCategory::SchemaIncompatible => "schema_incompatible",
568 ErrorCategory::SchemaValidation => "schema_validation",
569 ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
570 ErrorCategory::ToolError => "tool_error",
571 ErrorCategory::ToolRejected => "tool_rejected",
572 ErrorCategory::EgressBlocked => "egress_blocked",
573 ErrorCategory::Cancelled => "cancelled",
574 ErrorCategory::ChannelClosed => "channel_closed",
575 ErrorCategory::NotFound => "not_found",
576 ErrorCategory::CircuitOpen => "circuit_open",
577 ErrorCategory::BudgetExceeded => "budget_exceeded",
578 ErrorCategory::Internal => "internal",
579 ErrorCategory::Environment => "environment",
580 ErrorCategory::Generic => "generic",
581 }
582 }
583
584 pub fn parse(s: &str) -> Self {
585 match s {
586 "timeout" => ErrorCategory::Timeout,
587 "auth" => ErrorCategory::Auth,
588 "invalid_request" => ErrorCategory::InvalidRequest,
589 "rate_limit" => ErrorCategory::RateLimit,
590 "overloaded" => ErrorCategory::Overloaded,
591 "server_error" => ErrorCategory::ServerError,
592 "transient_network" => ErrorCategory::TransientNetwork,
593 "resource_busy" => ErrorCategory::ResourceBusy,
594 "schema_incompatible" => ErrorCategory::SchemaIncompatible,
595 "schema_validation" => ErrorCategory::SchemaValidation,
596 "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
597 "tool_error" => ErrorCategory::ToolError,
598 "tool_rejected" => ErrorCategory::ToolRejected,
599 "egress_blocked" => ErrorCategory::EgressBlocked,
600 "cancelled" => ErrorCategory::Cancelled,
601 "channel_closed" => ErrorCategory::ChannelClosed,
602 "not_found" => ErrorCategory::NotFound,
603 "circuit_open" => ErrorCategory::CircuitOpen,
604 "budget_exceeded" => ErrorCategory::BudgetExceeded,
605 "internal" => ErrorCategory::Internal,
606 "environment" => ErrorCategory::Environment,
607 _ => ErrorCategory::Generic,
608 }
609 }
610
611 pub fn is_internal(&self) -> bool {
614 matches!(self, ErrorCategory::Internal)
615 }
616
617 pub fn is_transient(&self) -> bool {
621 matches!(
622 self,
623 ErrorCategory::Timeout
624 | ErrorCategory::RateLimit
625 | ErrorCategory::Overloaded
626 | ErrorCategory::ServerError
627 | ErrorCategory::TransientNetwork
628 | ErrorCategory::ResourceBusy
629 )
630 }
631}
632
633pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
635 VmError::CategorizedError {
636 message: message.into(),
637 category,
638 }
639}
640
641pub fn error_to_category(err: &VmError) -> ErrorCategory {
650 match err {
651 VmError::ExecutionDeadlineExceeded => ErrorCategory::Timeout,
652 VmError::ProcessExit(_) => ErrorCategory::Generic,
656 VmError::AbandonedExecution => ErrorCategory::Cancelled,
657 VmError::CategorizedError { category, .. } => category.clone(),
658 VmError::ProviderStreamFailure(failure) => failure.category(),
659 VmError::SchemaStreamAbort(abort) => abort.category(),
660 VmError::Thrown(VmValue::Dict(d)) => d
661 .get("category")
662 .map(|v| ErrorCategory::parse(&v.display()))
663 .unwrap_or(ErrorCategory::Generic),
664 VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
665 VmError::Runtime(msg) => classify_error_message(msg),
666 VmError::UndefinedBuiltin(_) | VmError::InvalidInstruction(_) => ErrorCategory::Internal,
671 VmError::Deadlock(_) => ErrorCategory::Generic,
674 _ => ErrorCategory::Generic,
675 }
676}
677
678pub fn classify_error_message(msg: &str) -> ErrorCategory {
681 if let Some(cat) = classify_by_http_status(msg) {
683 return cat;
684 }
685 if msg.contains("Undefined builtin") {
690 return ErrorCategory::Internal;
691 }
692 let lower = msg.to_lowercase();
695 if lower.contains("cancelled") || lower.contains("canceled") {
696 return ErrorCategory::Cancelled;
697 }
698 if msg.contains("ChannelClosed") || lower.contains("channel closed") {
699 return ErrorCategory::ChannelClosed;
700 }
701 if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
702 return ErrorCategory::Timeout;
703 }
704 if msg.contains("overloaded_error") {
705 return ErrorCategory::Overloaded;
707 }
708 if msg.contains("api_error") {
709 return ErrorCategory::ServerError;
711 }
712 if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
713 return ErrorCategory::RateLimit;
715 }
716 if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
717 return ErrorCategory::Auth;
718 }
719 if msg.contains("not_found_error") || msg.contains("model_not_found") {
720 return ErrorCategory::NotFound;
721 }
722 if lower.contains("is not a valid model id") || lower.contains("invalid model id") {
728 return ErrorCategory::NotFound;
729 }
730 if msg.contains("circuit_open") {
731 return ErrorCategory::CircuitOpen;
732 }
733 if lower.contains("connection reset")
735 || lower.contains("connection refused")
736 || lower.contains("connection closed")
737 || lower.contains("broken pipe")
738 || lower.contains("dns error")
739 || lower.contains("stream error")
740 || lower.contains("unexpected eof")
741 {
742 return ErrorCategory::TransientNetwork;
743 }
744 ErrorCategory::Generic
745}
746
747fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
751 for code in extract_http_status_codes(msg) {
754 if let Some(category) = error_category_for_http_status(code) {
755 return Some(category);
756 }
757 }
758 None
759}
760
761pub(crate) fn error_category_for_http_status(status: u16) -> Option<ErrorCategory> {
768 match status {
769 401 | 403 => Some(ErrorCategory::Auth),
770 404 | 410 => Some(ErrorCategory::NotFound),
771 408 | 504 | 522 | 524 => Some(ErrorCategory::Timeout),
772 429 => Some(ErrorCategory::RateLimit),
773 503 | 529 => Some(ErrorCategory::Overloaded),
774 500 | 502 => Some(ErrorCategory::ServerError),
775 _ => None,
776 }
777}
778
779#[expect(
781 clippy::string_slice,
782 reason = "i..i + 3 spans bytes verified to be ASCII digits"
783)]
784fn extract_http_status_codes(msg: &str) -> Vec<u16> {
785 let mut codes = Vec::new();
786 let bytes = msg.as_bytes();
787 for i in 0..bytes.len().saturating_sub(2) {
788 if bytes[i].is_ascii_digit()
790 && bytes[i + 1].is_ascii_digit()
791 && bytes[i + 2].is_ascii_digit()
792 {
793 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
795 let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
796 if before_ok && after_ok {
797 if let Ok(code) = msg[i..i + 3].parse::<u16>() {
798 if (400..=599).contains(&code) {
799 codes.push(code);
800 }
801 }
802 }
803 }
804 }
805 codes
806}
807
808impl std::fmt::Display for VmError {
809 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
810 match self {
811 VmError::StackUnderflow => write!(f, "Stack underflow"),
812 VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
813 VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
814 VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
815 VmError::ImmutableAssignment(n) => {
816 write!(f, "Cannot assign to immutable binding: {n}")
817 }
818 VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
819 VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
820 VmError::DivisionByZero => write!(f, "Division by zero"),
821 VmError::ExecutionDeadlineExceeded => write!(f, "Execution deadline exceeded"),
822 VmError::ProcessExit(code) => write!(f, "Process exit requested: {code}"),
823 VmError::AbandonedExecution => write!(
824 f,
825 "Execution future was abandoned; discard this VM and reset its exclusively owned execution context"
826 ),
827 VmError::McpInputRequired(_) => write!(f, "MCP client input required"),
828 VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
829 VmError::CategorizedError { message, category } => {
830 write!(f, "Error [{}]: {}", category.as_str(), message)
831 }
832 VmError::ProviderStreamFailure(failure) => failure.fmt(f),
833 VmError::SchemaStreamAbort(abort) => abort.fmt(f),
834 VmError::SandboxMechanismUnavailable(refusal) => {
835 write!(f, "Error [{}]: {}", refusal.category().as_str(), refusal)
836 }
837 VmError::DaemonQueueFull {
838 daemon_id,
839 capacity,
840 } => write!(
841 f,
842 "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
843 ),
844 VmError::Deadlock(err) => match err.diagnostic {
845 DeadlockDiagnostic::SelfDeadlock => write!(
846 f,
847 "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
848 err.diagnostic.code(),
849 err.detail,
850 err.kind,
851 err.key
852 ),
853 DeadlockDiagnostic::WaitForGraph => write!(
854 f,
855 "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
856 err.diagnostic.code(),
857 err.detail,
858 err.kind,
859 err.key
860 ),
861 },
862 VmError::Return(_) => write!(f, "Return from function"),
863 VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
864 VmError::ArityMismatch(err) => {
865 let arg_word = match err.expected {
866 ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
867 _ => "arguments",
868 };
869 write!(
870 f,
871 "Arity mismatch: '{}' expects {} {}, got {}{}",
872 err.callee,
873 err.expected,
874 arg_word,
875 err.got,
876 fmt_span_suffix(&err.span)
877 )
878 }
879 VmError::ArgTypeMismatch(err) => {
880 write!(
881 f,
882 "Type error: '{}' parameter `{}` expects {}, got {}{}",
883 err.callee,
884 err.param,
885 err.expected,
886 err.got,
887 fmt_span_suffix(&err.span)
888 )
889 }
890 VmError::BindingTypeMismatch(err) => {
891 write!(
892 f,
893 "Type error: binding `{}` expects {}, got {}{}",
894 err.binding,
895 err.expected,
896 err.got,
897 fmt_span_suffix(&err.span)
898 )
899 }
900 }
901 }
902}
903
904fn fmt_span_suffix(span: &Option<Span>) -> String {
905 match span {
906 Some(s) => format!(" (at byte {}..{})", s.start, s.end),
907 None => String::new(),
908 }
909}
910
911impl std::error::Error for VmError {}
912
913#[cfg(test)]
914mod tests {
915 use super::*;
916
917 #[test]
921 fn all_categories_is_exhaustive() {
922 for category in &ErrorCategory::ALL {
923 match category {
924 ErrorCategory::Timeout
925 | ErrorCategory::Auth
926 | ErrorCategory::InvalidRequest
927 | ErrorCategory::RateLimit
928 | ErrorCategory::Overloaded
929 | ErrorCategory::ServerError
930 | ErrorCategory::TransientNetwork
931 | ErrorCategory::ResourceBusy
932 | ErrorCategory::SchemaIncompatible
933 | ErrorCategory::SchemaValidation
934 | ErrorCategory::SchemaStreamAborted
935 | ErrorCategory::ToolError
936 | ErrorCategory::ToolRejected
937 | ErrorCategory::EgressBlocked
938 | ErrorCategory::Cancelled
939 | ErrorCategory::ChannelClosed
940 | ErrorCategory::NotFound
941 | ErrorCategory::CircuitOpen
942 | ErrorCategory::BudgetExceeded
943 | ErrorCategory::Internal
944 | ErrorCategory::Environment
945 | ErrorCategory::Generic => {}
946 }
947 }
948 assert_eq!(
949 ErrorCategory::ALL.len(),
950 22,
951 "a category was added or removed — update `ErrorCategory::ALL` and the \
952 `Error categories` table in docs/src/builtins.md"
953 );
954 }
955
956 #[test]
957 fn every_category_round_trips_through_parse() {
958 for category in &ErrorCategory::ALL {
959 assert_eq!(
960 &ErrorCategory::parse(category.as_str()),
961 category,
962 "`{}` does not round-trip — `parse` is missing an arm, so a \
963 host handing this category back to Harn silently gets \
964 `generic`",
965 category.as_str()
966 );
967 }
968 }
969
970 #[test]
975 fn every_category_is_documented_in_builtins_md() {
976 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/builtins.md");
977 let doc =
978 std::fs::read_to_string(path).unwrap_or_else(|err| panic!("cannot read {path}: {err}"));
979 let table = doc
980 .split_once("### Error categories")
981 .unwrap_or_else(|| {
982 panic!("docs/src/builtins.md lost its `### Error categories` section")
983 })
984 .1;
985 let table = table.split_once("\n## ").map_or(table, |(head, _)| head);
986 for category in &ErrorCategory::ALL {
987 let row = format!("| `{}` |", category.as_str());
988 assert!(
989 table.contains(&row),
990 "`{}` is missing from the `Error categories` table in \
991 docs/src/builtins.md",
992 category.as_str()
993 );
994 }
995 }
996
997 #[test]
998 fn classifies_cancelled_messages() {
999 assert_eq!(
1000 classify_error_message("Bridge: operation cancelled"),
1001 ErrorCategory::Cancelled
1002 );
1003 assert_eq!(
1004 classify_error_message("operation canceled by host"),
1005 ErrorCategory::Cancelled
1006 );
1007 }
1008
1009 #[test]
1010 fn classifies_undefined_builtin_as_internal() {
1011 assert_eq!(
1013 error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
1014 ErrorCategory::Internal
1015 );
1016 assert_eq!(
1018 error_to_category(&VmError::InvalidInstruction(200)),
1019 ErrorCategory::Internal
1020 );
1021 assert_eq!(
1024 error_to_category(&VmError::Runtime(
1025 "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
1026 )),
1027 ErrorCategory::Internal
1028 );
1029 assert_eq!(
1030 classify_error_message("Undefined builtin: __host_agent_foo"),
1031 ErrorCategory::Internal
1032 );
1033 assert!(!ErrorCategory::Internal.is_transient());
1035 assert!(ErrorCategory::Internal.is_internal());
1036 assert_eq!(ErrorCategory::Internal.as_str(), "internal");
1038 assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
1039 }
1040
1041 #[test]
1042 fn classifies_openrouter_invalid_model_id_as_not_found() {
1043 assert_eq!(
1047 classify_error_message(
1048 "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
1049 ),
1050 ErrorCategory::NotFound
1051 );
1052 assert_eq!(
1053 classify_error_message("invalid model id supplied"),
1054 ErrorCategory::NotFound
1055 );
1056 }
1057
1058 #[test]
1059 fn categorized_error_lowers_to_structured_dict() {
1060 let err = categorized_error(
1064 "sandbox violation: /etc/passwd",
1065 ErrorCategory::ToolRejected,
1066 );
1067 let VmValue::Dict(dict) = err.thrown_value() else {
1068 panic!(
1069 "categorized error must lower to a dict, got {:?}",
1070 err.thrown_value()
1071 );
1072 };
1073 assert_eq!(
1074 dict.get("category").map(|v| v.display()).as_deref(),
1075 Some("tool_rejected"),
1076 );
1077 assert_eq!(
1078 dict.get("message").map(|v| v.display()).as_deref(),
1079 Some("sandbox violation: /etc/passwd"),
1080 );
1081 let rendered = categorized_error("boom", ErrorCategory::Cancelled)
1085 .thrown_value()
1086 .display();
1087 assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
1088 assert!(rendered.contains("boom"), "rendered dict: {rendered}");
1089 }
1090
1091 #[test]
1092 fn thrown_value_passes_structured_thrown_through_unchanged() {
1093 let original = VmValue::dict(std::collections::BTreeMap::from([(
1096 "code".to_string(),
1097 VmValue::Int(7),
1098 )]));
1099 let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
1100 panic!("thrown dict must pass through as a dict");
1101 };
1102 assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
1103 }
1104
1105 #[test]
1106 fn deadlock_renders_with_stable_code() {
1107 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
1108 "mutex",
1109 "__default__",
1110 "re-entrant acquire",
1111 )));
1112 assert!(
1113 err.to_string().starts_with("HARN-ORC-011"),
1114 "deadlock Display must carry the stable code: {err}"
1115 );
1116 }
1117
1118 #[test]
1119 fn deadlock_maps_to_generic_category() {
1120 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
1121 "task",
1122 "task_1",
1123 "self-join",
1124 )));
1125 let category = error_to_category(&err);
1126 assert_eq!(category, ErrorCategory::Generic);
1127 assert!(
1128 !category.is_transient(),
1129 "a deadlock must not be treated as a retryable transient error"
1130 );
1131 }
1132}