1use crate::hooks::{HookId, HookPoint, HookReasonCode};
4use crate::tool_catalog::ToolUnavailableReason;
5use crate::types::SessionId;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
12pub struct PendingCallbackToolCall {
13 pub tool_use_id: String,
14 pub tool_name: String,
15 pub args: serde_json::Value,
16}
17
18#[derive(Debug, Clone, PartialEq)]
19#[non_exhaustive]
20pub enum LlmFailureReason {
21 RateLimited {
22 retry_after: Option<std::time::Duration>,
23 },
24 ContextExceeded {
25 max: u32,
26 requested: u32,
27 },
28 AuthError,
29 InvalidModel(String),
30 ProviderError(LlmProviderError),
31 NetworkTimeout {
33 duration_ms: u64,
34 },
35 CallTimeout {
37 duration_ms: u64,
38 },
39 StreamStalled {
44 inactivity_ms: u64,
45 },
46}
47
48#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum LlmProviderErrorKind {
52 InvalidRequest,
53 RequestTooLarge,
56 ContentFiltered,
57 ServerError,
58 ServerOverloaded,
59 ConnectionReset,
60 Unknown,
61 StreamParseError,
62 IncompleteResponse,
63}
64
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum LlmProviderErrorRetryability {
69 Retryable,
70 NonRetryable,
71}
72
73impl LlmProviderErrorRetryability {
74 pub fn is_retryable(self) -> bool {
75 matches!(self, Self::Retryable)
76 }
77}
78
79#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct LlmProviderError {
82 pub kind: LlmProviderErrorKind,
83 pub retryability: LlmProviderErrorRetryability,
84 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
85 pub details: serde_json::Value,
86}
87
88impl LlmProviderError {
89 pub fn new(
90 kind: LlmProviderErrorKind,
91 retryability: LlmProviderErrorRetryability,
92 details: serde_json::Value,
93 ) -> Self {
94 Self {
95 kind,
96 retryability,
97 details,
98 }
99 }
100
101 pub fn retryable(kind: LlmProviderErrorKind, details: serde_json::Value) -> Self {
102 Self::new(kind, LlmProviderErrorRetryability::Retryable, details)
103 }
104
105 pub fn non_retryable(kind: LlmProviderErrorKind, details: serde_json::Value) -> Self {
106 Self::new(kind, LlmProviderErrorRetryability::NonRetryable, details)
107 }
108
109 pub fn is_retryable(&self) -> bool {
110 self.retryability.is_retryable()
111 }
112}
113
114#[derive(Debug, Clone, thiserror::Error, PartialEq)]
116pub enum ToolValidationError {
117 #[error("Tool not found: {name}")]
119 NotFound { name: String },
120 #[error("Invalid arguments for tool '{name}': {reason}")]
122 InvalidArguments { name: String, reason: String },
123}
124
125impl ToolValidationError {
126 pub fn not_found(name: impl Into<String>) -> Self {
127 Self::NotFound { name: name.into() }
128 }
129 pub fn invalid_arguments(name: impl Into<String>, reason: impl Into<String>) -> Self {
130 Self::InvalidArguments {
131 name: name.into(),
132 reason: reason.into(),
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, thiserror::Error)]
139pub enum ToolError {
140 #[error("Tool not found: {name}")]
142 NotFound { name: String },
143
144 #[error("Tool '{name}' is currently unavailable: {reason}")]
146 Unavailable {
147 name: String,
148 reason: ToolUnavailableReason,
149 },
150
151 #[error("Invalid arguments for tool '{name}': {reason}")]
153 InvalidArguments { name: String, reason: String },
154
155 #[error("Tool execution failed: {message}")]
157 ExecutionFailed { message: String },
158
159 #[error("Tool execution failed: {message}")]
161 ExecutionFailedWithData {
162 message: String,
163 data: serde_json::Value,
164 },
165
166 #[error("Tool '{name}' timed out after {timeout_ms}ms")]
168 Timeout { name: String, timeout_ms: u64 },
169
170 #[error("Streaming tool '{name}' stalled after {inactivity_ms}ms of inactivity")]
172 InactivityTimeout { name: String, inactivity_ms: u64 },
173
174 #[error("Tool '{name}' is not allowed by policy")]
176 AccessDenied { name: String },
177
178 #[error("{0}")]
180 Other(String),
181
182 #[error("Callback pending for tool '{tool_name}'")]
188 CallbackPending {
189 tool_name: String,
190 args: serde_json::Value,
191 },
192}
193
194impl ToolError {
195 pub fn error_code(&self) -> &'static str {
196 match self {
197 Self::NotFound { .. } => "tool_not_found",
198 Self::Unavailable { .. } => "tool_unavailable",
199 Self::InvalidArguments { .. } => "invalid_arguments",
200 Self::ExecutionFailed { .. } | Self::ExecutionFailedWithData { .. } => {
201 "execution_failed"
202 }
203 Self::Timeout { .. } => "timeout",
204 Self::InactivityTimeout { .. } => "inactivity_timeout",
205 Self::AccessDenied { .. } => "access_denied",
206 Self::Other(_) => "tool_error",
207 Self::CallbackPending { .. } => "callback_pending",
208 }
209 }
210
211 pub fn to_error_payload(&self) -> serde_json::Value {
212 let mut payload = serde_json::json!({
213 "error": self.error_code(),
214 "message": self.to_string(),
215 });
216 if let Some(data) = self.structured_data() {
217 payload["data"] = data;
218 }
219 payload
220 }
221
222 #[must_use]
230 pub fn to_transcript_content(&self) -> String {
231 self.to_error_payload().to_string()
232 }
233
234 pub fn not_found(name: impl Into<String>) -> Self {
235 Self::NotFound { name: name.into() }
236 }
237 pub fn unavailable(name: impl Into<String>, reason: ToolUnavailableReason) -> Self {
238 Self::Unavailable {
239 name: name.into(),
240 reason,
241 }
242 }
243 pub fn invalid_arguments(name: impl Into<String>, reason: impl Into<String>) -> Self {
244 Self::InvalidArguments {
245 name: name.into(),
246 reason: reason.into(),
247 }
248 }
249 pub fn execution_failed(message: impl Into<String>) -> Self {
250 Self::ExecutionFailed {
251 message: message.into(),
252 }
253 }
254 pub fn execution_failed_with_data(message: impl Into<String>, data: serde_json::Value) -> Self {
255 Self::ExecutionFailedWithData {
256 message: message.into(),
257 data,
258 }
259 }
260 pub fn structured_data(&self) -> Option<serde_json::Value> {
261 match self {
262 Self::ExecutionFailedWithData { data, .. } => Some(data.clone()),
263 _ => None,
264 }
265 }
266 pub fn timeout(name: impl Into<String>, timeout_ms: u64) -> Self {
267 Self::Timeout {
268 name: name.into(),
269 timeout_ms,
270 }
271 }
272 pub fn inactivity_timeout(name: impl Into<String>, inactivity_ms: u64) -> Self {
273 Self::InactivityTimeout {
274 name: name.into(),
275 inactivity_ms,
276 }
277 }
278 pub fn access_denied(name: impl Into<String>) -> Self {
279 Self::AccessDenied { name: name.into() }
280 }
281 pub fn other(message: impl Into<String>) -> Self {
282 Self::Other(message.into())
283 }
284
285 pub fn callback_pending(tool_name: impl Into<String>, args: serde_json::Value) -> Self {
287 Self::CallbackPending {
288 tool_name: tool_name.into(),
289 args,
290 }
291 }
292
293 pub fn is_callback_pending(&self) -> bool {
295 matches!(self, Self::CallbackPending { .. })
296 }
297
298 pub fn as_callback_pending(&self) -> Option<(&str, &serde_json::Value)> {
300 match self {
301 Self::CallbackPending { tool_name, args } => Some((tool_name, args)),
302 _ => None,
303 }
304 }
305}
306
307impl From<String> for ToolError {
308 fn from(s: String) -> Self {
309 Self::Other(s)
310 }
311}
312impl From<&str> for ToolError {
313 fn from(s: &str) -> Self {
314 Self::Other(s.to_string())
315 }
316}
317
318#[derive(Debug, thiserror::Error)]
320#[non_exhaustive]
321pub enum AgentError {
322 #[error("LLM error ({provider}): {message}")]
323 Llm {
324 provider: &'static str,
325 reason: LlmFailureReason,
326 message: String,
327 },
328 #[error("Storage error: {0}")]
329 StoreError(String),
330 #[error("Tool error: {error}")]
336 Tool { error: ToolError },
337 #[error("MCP error: {0}")]
338 McpError(String),
339 #[error("Session not found: {0}")]
340 SessionNotFound(SessionId),
341 #[error("Token budget exceeded: used {used}, limit {limit}")]
342 TokenBudgetExceeded { used: u64, limit: u64 },
343 #[error("Time budget exceeded: {elapsed_secs}s > {limit_secs}s")]
344 TimeBudgetExceeded { elapsed_secs: u64, limit_secs: u64 },
345 #[error("Tool call budget exceeded: {count} calls > {limit} limit")]
346 ToolCallBudgetExceeded { count: usize, limit: usize },
347 #[error("Max tokens reached on turn {turn}, partial output: {partial}")]
348 MaxTokensReached { turn: u32, partial: String },
349 #[error("Content filtered on turn {turn}")]
350 ContentFiltered { turn: u32 },
351 #[error("Max turns reached: {turns}")]
352 MaxTurnsReached { turns: u32 },
353 #[error("Run was cancelled")]
354 Cancelled,
355 #[error("Invalid state transition: {from} -> {to}")]
356 InvalidStateTransition { from: String, to: String },
357 #[error("Operation not found: {0}")]
358 OperationNotFound(String),
359 #[error("Depth limit exceeded: {depth} > {max}")]
360 DepthLimitExceeded { depth: u32, max: u32 },
361 #[error("Concurrency limit exceeded")]
362 ConcurrencyLimitExceeded,
363 #[error("Configuration error: {0}")]
364 ConfigError(String),
365 #[error("Invalid tool in access policy: {tool}")]
366 InvalidToolAccess { tool: String },
367 #[error("Skill resolution failed for {skill_key:?}: {reason}")]
368 SkillResolutionFailed {
369 skill_key: Option<crate::skills::SkillKey>,
370 reason: Box<crate::event::SkillResolutionFailureReason>,
371 },
372 #[error("Internal error: {0}")]
373 InternalError(String),
374
375 #[error("Sticky model fallback authority outcome is unknown: {message}")]
380 StickyModelFallbackAuthorityUnknown { message: String },
381
382 #[error("Session durable projection authority outcome is unknown: {message}")]
387 SessionDurableProjectionAuthorityUnknown { message: String },
388
389 #[error("Build error: {0}")]
391 BuildError(String),
392
393 #[error("Session identity already active: {0}")]
395 SessionIdentityInUse(SessionId),
396
397 #[error("Connection `{binding_key}` requires re-authentication: {message}")]
404 AuthReauthRequired {
405 binding_key: String,
406 message: String,
407 },
408
409 #[error("Callback pending for tool '{tool_name}'")]
411 CallbackPending {
412 tool_use_id: String,
413 tool_name: String,
414 args: serde_json::Value,
415 },
416
417 #[error("Callback batch pending for {} tools", pending_tool_calls.len())]
422 CallbackBatchPending {
423 pending_tool_calls: Vec<PendingCallbackToolCall>,
424 },
425
426 #[error("Structured output validation failed after {attempts} attempts: {reason}")]
428 StructuredOutputValidationFailed {
429 attempts: u32,
430 reason: String,
431 last_output: String,
432 },
433
434 #[error("Invalid output schema: {0}")]
436 InvalidOutputSchema(String),
437
438 #[error("Hook '{hook_id}' denied at {point:?}: {reason_code:?} - {message}")]
439 HookDenied {
440 hook_id: HookId,
441 point: HookPoint,
442 reason_code: HookReasonCode,
443 message: String,
444 payload: Option<serde_json::Value>,
445 },
446
447 #[error("Hook '{hook_id}' timed out after {timeout_ms}ms")]
448 HookTimeout { hook_id: HookId, timeout_ms: u64 },
449
450 #[error("Hook execution failed for '{hook_id}': {reason}")]
451 HookExecutionFailed { hook_id: HookId, reason: String },
452
453 #[error("Hook configuration invalid: {reason}")]
454 HookConfigInvalid { reason: String },
455
456 #[error("Terminal failure: {outcome:?} ({cause_kind:?}): {message}")]
458 TerminalFailure {
459 outcome: crate::turn_execution_authority::TurnTerminalOutcome,
460 cause_kind: crate::turn_execution_authority::TurnTerminalCauseKind,
461 message: String,
462 },
463
464 #[error("no pending boundary for resume")]
469 NoPendingBoundary,
470
471 #[error("durable session snapshot synchronization is not supported by this session agent")]
476 DurableSnapshotSyncUnsupported,
477}
478
479impl AgentError {
480 pub fn tool(error: ToolError) -> Self {
483 Self::Tool { error }
484 }
485
486 pub fn tool_error_code(&self) -> Option<&'static str> {
493 match self {
494 Self::Tool { error } => Some(error.error_code()),
495 _ => None,
496 }
497 }
498
499 pub fn llm(
500 provider: &'static str,
501 reason: LlmFailureReason,
502 message: impl Into<String>,
503 ) -> Self {
504 Self::Llm {
505 provider,
506 reason,
507 message: message.into(),
508 }
509 }
510
511 pub fn llm_empty_response(provider: &'static str) -> Self {
512 Self::llm(
513 provider,
514 LlmFailureReason::ProviderError(LlmProviderError::retryable(
515 LlmProviderErrorKind::IncompleteResponse,
516 serde_json::json!({
517 "reason": "provider completed without user-visible text, images, or tool calls"
518 }),
519 )),
520 "LLM completed without user-visible text, images, or tool calls",
521 )
522 }
523
524 pub fn is_graceful(&self) -> bool {
525 matches!(
526 self,
527 Self::TokenBudgetExceeded { .. }
528 | Self::TimeBudgetExceeded { .. }
529 | Self::ToolCallBudgetExceeded { .. }
530 | Self::MaxTurnsReached { .. }
531 )
532 }
533 pub fn is_rate_limited(&self) -> bool {
534 matches!(
535 self,
536 Self::Llm {
537 reason: LlmFailureReason::RateLimited { .. },
538 ..
539 }
540 )
541 }
542
543 pub fn retry_after_hint(&self) -> Option<std::time::Duration> {
544 match self {
545 Self::Llm {
546 reason: LlmFailureReason::RateLimited { retry_after },
547 ..
548 } => *retry_after,
549 _ => None,
550 }
551 }
552
553 pub fn is_recoverable(&self) -> bool {
554 match self {
555 Self::Llm { reason, .. } => match reason {
556 LlmFailureReason::RateLimited { .. } => true,
557 LlmFailureReason::NetworkTimeout { .. } => true,
558 LlmFailureReason::CallTimeout { .. } => true,
559 LlmFailureReason::StreamStalled { .. } => true,
560 LlmFailureReason::ProviderError(provider_error) => provider_error.is_retryable(),
561 _ => false,
562 },
563 _ => false,
564 }
565 }
566
567 pub fn requires_session_teardown(&self) -> bool {
570 matches!(
571 self,
572 Self::StickyModelFallbackAuthorityUnknown { .. }
573 | Self::SessionDurableProjectionAuthorityUnknown { .. }
574 )
575 }
576
577 pub fn session_durable_projection_authority_unknown(message: impl Into<String>) -> Self {
580 Self::SessionDurableProjectionAuthorityUnknown {
581 message: message.into(),
582 }
583 }
584
585 pub fn with_ancillary_failure(self, context: &str, failure: impl std::fmt::Display) -> Self {
589 match self {
590 Self::StickyModelFallbackAuthorityUnknown { message } => {
591 Self::StickyModelFallbackAuthorityUnknown {
592 message: format!("{message}; additionally {context}: {failure}"),
593 }
594 }
595 Self::SessionDurableProjectionAuthorityUnknown { message } => {
596 Self::SessionDurableProjectionAuthorityUnknown {
597 message: format!("{message}; additionally {context}: {failure}"),
598 }
599 }
600 other => Self::InternalError(format!("{other}; additionally {context}: {failure}")),
601 }
602 }
603}
604
605pub fn store_error(err: impl std::fmt::Display) -> AgentError {
606 AgentError::StoreError(store_error_message(err))
607}
608pub fn invalid_session_id(err: impl std::fmt::Display) -> AgentError {
609 AgentError::StoreError(invalid_session_id_message(err))
610}
611pub fn store_error_message(err: impl std::fmt::Display) -> String {
612 err.to_string()
613}
614pub fn invalid_session_id_message(err: impl std::fmt::Display) -> String {
615 format!("Invalid session ID: {err}")
616}
617
618#[cfg(test)]
619#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
620mod tests {
621 use super::*;
622
623 #[test]
624 fn test_network_timeout_is_recoverable() {
625 let err = AgentError::llm(
626 "anthropic",
627 LlmFailureReason::NetworkTimeout { duration_ms: 30000 },
628 "network timeout after 30s",
629 );
630 assert!(err.is_recoverable());
631 }
632
633 #[test]
634 fn ancillary_failure_preserves_teardown_required_variant() {
635 let combined = AgentError::StickyModelFallbackAuthorityUnknown {
636 message: "fallback CAS outcome unknown".to_string(),
637 }
638 .with_ancillary_failure("failed to clear overlay", "synthetic cleanup fault");
639
640 assert!(combined.requires_session_teardown());
641 assert!(matches!(
642 combined,
643 AgentError::StickyModelFallbackAuthorityUnknown { ref message }
644 if message.contains("fallback CAS outcome unknown")
645 && message.contains("synthetic cleanup fault")
646 ));
647 }
648
649 #[test]
650 fn durable_projection_authority_unknown_requires_teardown_and_survives_cleanup() {
651 let combined = AgentError::session_durable_projection_authority_unknown(
652 "durable projection advanced before store commit",
653 )
654 .with_ancillary_failure("failed to clear overlay", "synthetic cleanup fault");
655
656 assert!(combined.requires_session_teardown());
657 assert!(matches!(
658 combined,
659 AgentError::SessionDurableProjectionAuthorityUnknown { ref message }
660 if message.contains("projection advanced before store commit")
661 && message.contains("synthetic cleanup fault")
662 ));
663 }
664
665 #[test]
666 fn test_call_timeout_is_recoverable() {
667 let err = AgentError::llm(
668 "anthropic",
669 LlmFailureReason::CallTimeout { duration_ms: 45000 },
670 "call timeout after 45s",
671 );
672 assert!(err.is_recoverable());
673 }
674
675 #[test]
676 fn test_network_timeout_typed_mapping() {
677 let reason = LlmFailureReason::NetworkTimeout { duration_ms: 5000 };
678 match reason {
679 LlmFailureReason::NetworkTimeout { duration_ms } => {
680 assert_eq!(duration_ms, 5000);
681 }
682 _ => panic!("expected NetworkTimeout"),
683 }
684 }
685
686 #[test]
687 fn test_call_timeout_typed_mapping() {
688 let reason = LlmFailureReason::CallTimeout { duration_ms: 60000 };
689 match reason {
690 LlmFailureReason::CallTimeout { duration_ms } => {
691 assert_eq!(duration_ms, 60000);
692 }
693 _ => panic!("expected CallTimeout"),
694 }
695 }
696
697 #[test]
698 fn test_timeout_variants_are_distinct() {
699 let net = LlmFailureReason::NetworkTimeout { duration_ms: 1000 };
700 let call = LlmFailureReason::CallTimeout { duration_ms: 1000 };
701 assert_ne!(net, call);
702 }
703
704 #[test]
705 fn test_stream_stalled_is_recoverable() {
706 let err = AgentError::llm(
707 "anthropic",
708 LlmFailureReason::StreamStalled {
709 inactivity_ms: 300_000,
710 },
711 "stream stalled after 300s of inactivity",
712 );
713 assert!(err.is_recoverable());
714 assert!(!err.is_graceful());
715 }
716
717 #[test]
718 fn test_stream_stalled_distinct_from_call_timeout() {
719 let stalled = LlmFailureReason::StreamStalled {
720 inactivity_ms: 1000,
721 };
722 let call = LlmFailureReason::CallTimeout { duration_ms: 1000 };
723 assert_ne!(stalled, call);
724 }
725
726 #[test]
727 fn test_auth_error_not_recoverable() {
728 let err = AgentError::llm("anthropic", LlmFailureReason::AuthError, "bad key");
729 assert!(!err.is_recoverable());
730 }
731
732 #[test]
733 fn provider_error_uses_typed_retryability_for_recovery() {
734 let err = AgentError::llm(
735 "anthropic",
736 LlmFailureReason::ProviderError(LlmProviderError::retryable(
737 LlmProviderErrorKind::ServerOverloaded,
738 serde_json::json!({
739 "message": "provider overloaded"
740 }),
741 )),
742 "provider overloaded",
743 );
744
745 assert!(err.is_recoverable());
746 }
747
748 #[test]
749 fn provider_error_fails_closed_when_json_claims_retryable() {
750 let err = AgentError::llm(
751 "anthropic",
752 LlmFailureReason::ProviderError(LlmProviderError::non_retryable(
753 LlmProviderErrorKind::InvalidRequest,
754 serde_json::json!({
755 "kind": "server_overloaded",
756 "retryable": true,
757 "message": "json payload must not control retryability"
758 }),
759 )),
760 "invalid request",
761 );
762
763 assert!(!err.is_recoverable());
764 }
765
766 #[test]
769 fn test_is_rate_limited_true_for_rate_limit_error() {
770 let err = AgentError::llm(
771 "anthropic",
772 LlmFailureReason::RateLimited {
773 retry_after: Some(std::time::Duration::from_secs(30)),
774 },
775 "rate limited",
776 );
777 assert!(err.is_rate_limited());
778 }
779
780 #[test]
781 fn test_is_rate_limited_false_for_other_errors() {
782 let err = AgentError::llm(
783 "anthropic",
784 LlmFailureReason::NetworkTimeout { duration_ms: 5000 },
785 "timeout",
786 );
787 assert!(!err.is_rate_limited());
788
789 let err = AgentError::llm("anthropic", LlmFailureReason::AuthError, "bad key");
790 assert!(!err.is_rate_limited());
791 }
792
793 #[test]
794 fn test_retry_after_hint_returns_duration_for_rate_limit() {
795 let err = AgentError::llm(
796 "anthropic",
797 LlmFailureReason::RateLimited {
798 retry_after: Some(std::time::Duration::from_secs(60)),
799 },
800 "rate limited",
801 );
802 assert_eq!(
803 err.retry_after_hint(),
804 Some(std::time::Duration::from_secs(60))
805 );
806 }
807
808 #[test]
809 fn test_retry_after_hint_returns_none_for_non_rate_limit() {
810 let err = AgentError::llm(
811 "anthropic",
812 LlmFailureReason::NetworkTimeout { duration_ms: 5000 },
813 "timeout",
814 );
815 assert_eq!(err.retry_after_hint(), None);
816 }
817
818 #[test]
819 fn test_timeout_variants_not_graceful() {
820 let err = AgentError::llm(
821 "anthropic",
822 LlmFailureReason::NetworkTimeout { duration_ms: 1000 },
823 "timeout",
824 );
825 assert!(!err.is_graceful());
826
827 let err = AgentError::llm(
828 "anthropic",
829 LlmFailureReason::CallTimeout { duration_ms: 1000 },
830 "timeout",
831 );
832 assert!(!err.is_graceful());
833 }
834
835 #[test]
838 fn test_build_error_variant_exists_and_carries_message() {
839 let err = AgentError::BuildError("Missing API key for provider 'anthropic'".to_string());
840 match &err {
841 AgentError::BuildError(msg) => {
842 assert!(
843 msg.contains("API key"),
844 "message should contain source text"
845 );
846 }
847 other => panic!("expected BuildError, got: {other}"),
848 }
849 }
850
851 #[test]
852 fn test_build_error_is_not_recoverable() {
853 let err = AgentError::BuildError("Unknown provider for model 'llama-3'".to_string());
854 assert!(!err.is_recoverable(), "build errors are not recoverable");
855 }
856
857 #[test]
858 fn test_build_error_is_not_graceful() {
859 let err = AgentError::BuildError("Missing API key".to_string());
860 assert!(!err.is_graceful(), "build errors are not graceful");
861 }
862
863 #[test]
864 fn test_build_error_display() {
865 let err = AgentError::BuildError("Missing API key for provider 'anthropic'".to_string());
866 let display = err.to_string();
867 assert!(
868 display.contains("Build error")
869 || display.contains("build error")
870 || display.contains("Missing API key"),
871 "display should mention the build error: {display}"
872 );
873 }
874
875 #[test]
878 fn test_terminal_failure_carries_typed_outcome() {
879 use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
880
881 let err = AgentError::TerminalFailure {
883 outcome: TurnTerminalOutcome::Failed,
884 cause_kind: TurnTerminalCauseKind::LlmFailure,
885 message: "llm failed".to_string(),
886 };
887 match &err {
888 AgentError::TerminalFailure {
889 outcome,
890 cause_kind,
891 ..
892 } => {
893 assert_eq!(*outcome, TurnTerminalOutcome::Failed);
895 assert_eq!(*cause_kind, TurnTerminalCauseKind::LlmFailure);
896 }
897 other => panic!("expected TerminalFailure, got: {other}"),
898 }
899 }
900
901 #[test]
902 fn test_terminal_failure_display_includes_outcome() {
903 use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
904
905 let err = AgentError::TerminalFailure {
906 outcome: TurnTerminalOutcome::TimeBudgetExceeded,
907 cause_kind: TurnTerminalCauseKind::TimeBudgetExceeded,
908 message: "deadline reached".to_string(),
909 };
910 let display = err.to_string();
911 assert!(
912 display.contains("TimeBudgetExceeded"),
913 "display should include the outcome variant name: {display}"
914 );
915 assert!(
916 display.contains("TimeBudgetExceeded") && display.contains("deadline reached"),
917 "display should include cause and display message: {display}"
918 );
919 }
920
921 #[test]
924 fn tool_variant_preserves_access_denied_error_code() {
925 let err = AgentError::tool(ToolError::access_denied("secret_tool"));
929 match &err {
930 AgentError::Tool { error } => {
931 assert_eq!(error.error_code(), "access_denied");
932 }
933 other => panic!("expected AgentError::Tool, got: {other}"),
934 }
935 assert_eq!(err.tool_error_code(), Some("access_denied"));
936 }
937
938 #[test]
939 fn tool_variant_preserves_not_found_error_code() {
940 let err = AgentError::tool(ToolError::not_found("missing_tool"));
942 assert_eq!(err.tool_error_code(), Some("tool_not_found"));
943 assert_ne!(
944 err.tool_error_code(),
945 AgentError::tool(ToolError::access_denied("missing_tool")).tool_error_code(),
946 "not_found must stay distinct from access_denied"
947 );
948 }
949
950 #[test]
951 fn tool_variant_preserves_invalid_arguments_error_code() {
952 let err = AgentError::tool(ToolError::invalid_arguments(
955 "search",
956 "tool call arguments projection failed: bad json",
957 ));
958 match &err {
959 AgentError::Tool { error } => {
960 assert_eq!(error.error_code(), "invalid_arguments");
961 }
962 other => panic!("expected AgentError::Tool, got: {other}"),
963 }
964 assert_eq!(err.tool_error_code(), Some("invalid_arguments"));
965 }
966
967 #[test]
968 fn test_terminal_failure_all_hard_failure_outcomes() {
969 use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
970
971 for outcome in [
973 TurnTerminalOutcome::Failed,
974 TurnTerminalOutcome::TimeBudgetExceeded,
975 ] {
976 let err = AgentError::TerminalFailure {
977 outcome,
978 cause_kind: TurnTerminalCauseKind::FatalFailure,
979 message: "terminal".to_string(),
980 };
981 assert!(
982 !err.is_graceful(),
983 "TerminalFailure({outcome:?}) should not be graceful"
984 );
985 }
986 }
987}