Skip to main content

meerkat_core/
error.rs

1//! Core error types for Meerkat
2
3use crate::hooks::{HookId, HookPoint, HookReasonCode};
4use crate::tool_catalog::ToolUnavailableReason;
5use crate::types::SessionId;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq)]
9#[non_exhaustive]
10pub enum LlmFailureReason {
11    RateLimited {
12        retry_after: Option<std::time::Duration>,
13    },
14    ContextExceeded {
15        max: u32,
16        requested: u32,
17    },
18    AuthError,
19    InvalidModel(String),
20    ProviderError(LlmProviderError),
21    /// Provider/client-native network timeout (owned by client layer)
22    NetworkTimeout {
23        duration_ms: u64,
24    },
25    /// Agent-loop hard call timeout (owned by agent loop policy)
26    CallTimeout {
27        duration_ms: u64,
28    },
29}
30
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum LlmProviderErrorKind {
35    InvalidRequest,
36    ContentFiltered,
37    ServerError,
38    ServerOverloaded,
39    ConnectionReset,
40    Unknown,
41    StreamParseError,
42    IncompleteResponse,
43}
44
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum LlmProviderErrorRetryability {
49    Retryable,
50    NonRetryable,
51}
52
53impl LlmProviderErrorRetryability {
54    pub fn is_retryable(self) -> bool {
55        matches!(self, Self::Retryable)
56    }
57}
58
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct LlmProviderError {
62    pub kind: LlmProviderErrorKind,
63    pub retryability: LlmProviderErrorRetryability,
64    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
65    pub details: serde_json::Value,
66}
67
68impl LlmProviderError {
69    pub fn new(
70        kind: LlmProviderErrorKind,
71        retryability: LlmProviderErrorRetryability,
72        details: serde_json::Value,
73    ) -> Self {
74        Self {
75            kind,
76            retryability,
77            details,
78        }
79    }
80
81    pub fn retryable(kind: LlmProviderErrorKind, details: serde_json::Value) -> Self {
82        Self::new(kind, LlmProviderErrorRetryability::Retryable, details)
83    }
84
85    pub fn non_retryable(kind: LlmProviderErrorKind, details: serde_json::Value) -> Self {
86        Self::new(kind, LlmProviderErrorRetryability::NonRetryable, details)
87    }
88
89    pub fn is_retryable(&self) -> bool {
90        self.retryability.is_retryable()
91    }
92}
93
94/// Errors that can occur during tool validation
95#[derive(Debug, Clone, thiserror::Error, PartialEq)]
96pub enum ToolValidationError {
97    /// The requested tool was not found
98    #[error("Tool not found: {name}")]
99    NotFound { name: String },
100    /// The tool arguments failed validation
101    #[error("Invalid arguments for tool '{name}': {reason}")]
102    InvalidArguments { name: String, reason: String },
103}
104
105impl ToolValidationError {
106    pub fn not_found(name: impl Into<String>) -> Self {
107        Self::NotFound { name: name.into() }
108    }
109    pub fn invalid_arguments(name: impl Into<String>, reason: impl Into<String>) -> Self {
110        Self::InvalidArguments {
111            name: name.into(),
112            reason: reason.into(),
113        }
114    }
115}
116
117/// Error returned by tool dispatch operations.
118#[derive(Debug, Clone, PartialEq, thiserror::Error)]
119pub enum ToolError {
120    /// The requested tool was not found
121    #[error("Tool not found: {name}")]
122    NotFound { name: String },
123
124    /// The tool exists but is currently unavailable
125    #[error("Tool '{name}' is currently unavailable: {reason}")]
126    Unavailable {
127        name: String,
128        reason: ToolUnavailableReason,
129    },
130
131    /// The tool arguments failed validation
132    #[error("Invalid arguments for tool '{name}': {reason}")]
133    InvalidArguments { name: String, reason: String },
134
135    /// The tool execution failed
136    #[error("Tool execution failed: {message}")]
137    ExecutionFailed { message: String },
138
139    /// The tool execution failed with structured error data for protocol surfaces.
140    #[error("Tool execution failed: {message}")]
141    ExecutionFailedWithData {
142        message: String,
143        data: serde_json::Value,
144    },
145
146    /// The tool execution timed out
147    #[error("Tool '{name}' timed out after {timeout_ms}ms")]
148    Timeout { name: String, timeout_ms: u64 },
149
150    /// Tool access was denied by policy
151    #[error("Tool '{name}' is not allowed by policy")]
152    AccessDenied { name: String },
153
154    /// A generic tool error with a message
155    #[error("{0}")]
156    Other(String),
157
158    /// Tool call must be routed externally (callback pending)
159    ///
160    /// This variant signals that a tool call cannot be handled internally
161    /// and must be routed to an external handler. The payload contains
162    /// serialized information about the pending tool call.
163    #[error("Callback pending for tool '{tool_name}'")]
164    CallbackPending {
165        tool_name: String,
166        args: serde_json::Value,
167    },
168}
169
170impl ToolError {
171    pub fn error_code(&self) -> &'static str {
172        match self {
173            Self::NotFound { .. } => "tool_not_found",
174            Self::Unavailable { .. } => "tool_unavailable",
175            Self::InvalidArguments { .. } => "invalid_arguments",
176            Self::ExecutionFailed { .. } | Self::ExecutionFailedWithData { .. } => {
177                "execution_failed"
178            }
179            Self::Timeout { .. } => "timeout",
180            Self::AccessDenied { .. } => "access_denied",
181            Self::Other(_) => "tool_error",
182            Self::CallbackPending { .. } => "callback_pending",
183        }
184    }
185
186    pub fn to_error_payload(&self) -> serde_json::Value {
187        let mut payload = serde_json::json!({
188            "error": self.error_code(),
189            "message": self.to_string(),
190        });
191        if let Some(data) = self.structured_data() {
192            payload["data"] = data;
193        }
194        payload
195    }
196
197    /// Render the canonical model-facing transcript text for this typed error.
198    ///
199    /// This is the single render boundary over the typed error: it projects
200    /// [`Self::to_error_payload`] into the JSON string that appears in the
201    /// conversation transcript. It is infallible — `serde_json::Value` always
202    /// renders via its `Display` impl — so there is no fallback string that
203    /// could mask a serialization fault.
204    #[must_use]
205    pub fn to_transcript_content(&self) -> String {
206        self.to_error_payload().to_string()
207    }
208
209    pub fn not_found(name: impl Into<String>) -> Self {
210        Self::NotFound { name: name.into() }
211    }
212    pub fn unavailable(name: impl Into<String>, reason: ToolUnavailableReason) -> Self {
213        Self::Unavailable {
214            name: name.into(),
215            reason,
216        }
217    }
218    pub fn invalid_arguments(name: impl Into<String>, reason: impl Into<String>) -> Self {
219        Self::InvalidArguments {
220            name: name.into(),
221            reason: reason.into(),
222        }
223    }
224    pub fn execution_failed(message: impl Into<String>) -> Self {
225        Self::ExecutionFailed {
226            message: message.into(),
227        }
228    }
229    pub fn execution_failed_with_data(message: impl Into<String>, data: serde_json::Value) -> Self {
230        Self::ExecutionFailedWithData {
231            message: message.into(),
232            data,
233        }
234    }
235    pub fn structured_data(&self) -> Option<serde_json::Value> {
236        match self {
237            Self::ExecutionFailedWithData { data, .. } => Some(data.clone()),
238            _ => None,
239        }
240    }
241    pub fn timeout(name: impl Into<String>, timeout_ms: u64) -> Self {
242        Self::Timeout {
243            name: name.into(),
244            timeout_ms,
245        }
246    }
247    pub fn access_denied(name: impl Into<String>) -> Self {
248        Self::AccessDenied { name: name.into() }
249    }
250    pub fn other(message: impl Into<String>) -> Self {
251        Self::Other(message.into())
252    }
253
254    /// Create a callback pending error for external tool routing
255    pub fn callback_pending(tool_name: impl Into<String>, args: serde_json::Value) -> Self {
256        Self::CallbackPending {
257            tool_name: tool_name.into(),
258            args,
259        }
260    }
261
262    /// Check if this is a callback pending error
263    pub fn is_callback_pending(&self) -> bool {
264        matches!(self, Self::CallbackPending { .. })
265    }
266
267    /// Extract callback pending info if this is a CallbackPending error
268    pub fn as_callback_pending(&self) -> Option<(&str, &serde_json::Value)> {
269        match self {
270            Self::CallbackPending { tool_name, args } => Some((tool_name, args)),
271            _ => None,
272        }
273    }
274}
275
276impl From<String> for ToolError {
277    fn from(s: String) -> Self {
278        Self::Other(s)
279    }
280}
281impl From<&str> for ToolError {
282    fn from(s: &str) -> Self {
283        Self::Other(s.to_string())
284    }
285}
286
287/// Errors that can occur during agent execution
288#[derive(Debug, thiserror::Error)]
289#[non_exhaustive]
290pub enum AgentError {
291    #[error("LLM error ({provider}): {message}")]
292    Llm {
293        provider: &'static str,
294        reason: LlmFailureReason,
295        message: String,
296    },
297    #[error("Storage error: {0}")]
298    StoreError(String),
299    /// A tool failure carrying the typed [`ToolError`] cause.
300    ///
301    /// The typed cause is preserved so the `access_denied` vs `not_found`
302    /// vs `invalid_arguments` distinction survives to
303    /// [`ToolError::error_code`] and the wire surface.
304    #[error("Tool error: {error}")]
305    Tool { error: ToolError },
306    #[error("MCP error: {0}")]
307    McpError(String),
308    #[error("Session not found: {0}")]
309    SessionNotFound(SessionId),
310    #[error("Token budget exceeded: used {used}, limit {limit}")]
311    TokenBudgetExceeded { used: u64, limit: u64 },
312    #[error("Time budget exceeded: {elapsed_secs}s > {limit_secs}s")]
313    TimeBudgetExceeded { elapsed_secs: u64, limit_secs: u64 },
314    #[error("Tool call budget exceeded: {count} calls > {limit} limit")]
315    ToolCallBudgetExceeded { count: usize, limit: usize },
316    #[error("Max tokens reached on turn {turn}, partial output: {partial}")]
317    MaxTokensReached { turn: u32, partial: String },
318    #[error("Content filtered on turn {turn}")]
319    ContentFiltered { turn: u32 },
320    #[error("Max turns reached: {turns}")]
321    MaxTurnsReached { turns: u32 },
322    #[error("Run was cancelled")]
323    Cancelled,
324    #[error("Invalid state transition: {from} -> {to}")]
325    InvalidStateTransition { from: String, to: String },
326    #[error("Operation not found: {0}")]
327    OperationNotFound(String),
328    #[error("Depth limit exceeded: {depth} > {max}")]
329    DepthLimitExceeded { depth: u32, max: u32 },
330    #[error("Concurrency limit exceeded")]
331    ConcurrencyLimitExceeded,
332    #[error("Configuration error: {0}")]
333    ConfigError(String),
334    #[error("Invalid tool in access policy: {tool}")]
335    InvalidToolAccess { tool: String },
336    #[error("Skill resolution failed for {skill_key:?}: {reason}")]
337    SkillResolutionFailed {
338        skill_key: Option<crate::skills::SkillKey>,
339        reason: Box<crate::event::SkillResolutionFailureReason>,
340    },
341    #[error("Internal error: {0}")]
342    InternalError(String),
343
344    /// Agent construction failed (e.g. missing API key, unknown provider).
345    #[error("Build error: {0}")]
346    BuildError(String),
347
348    /// Agent construction attempted to claim a session identity that is already live.
349    #[error("Session identity already active: {0}")]
350    SessionIdentityInUse(SessionId),
351
352    /// MeerkatMachine DSL observed an auth lease in `reauth_required`
353    /// state at a CallingLlm boundary; the lease cannot proceed
354    /// until the user re-authenticates (`rkat auth login`). This is a
355    /// machine-owned terminal class (Phase 1.5-rev), distinct from
356    /// [`AgentError::InternalError`] which is for genuinely
357    /// unexpected failures.
358    #[error("Connection `{binding_key}` requires re-authentication: {message}")]
359    AuthReauthRequired {
360        binding_key: String,
361        message: String,
362    },
363
364    /// A tool call must be routed externally (callback pending)
365    #[error("Callback pending for tool '{tool_name}'")]
366    CallbackPending {
367        tool_name: String,
368        args: serde_json::Value,
369    },
370
371    /// Structured output validation failed after retries
372    #[error("Structured output validation failed after {attempts} attempts: {reason}")]
373    StructuredOutputValidationFailed {
374        attempts: u32,
375        reason: String,
376        last_output: String,
377    },
378
379    /// Invalid output schema provided
380    #[error("Invalid output schema: {0}")]
381    InvalidOutputSchema(String),
382
383    #[error("Hook '{hook_id}' denied at {point:?}: {reason_code:?} - {message}")]
384    HookDenied {
385        hook_id: HookId,
386        point: HookPoint,
387        reason_code: HookReasonCode,
388        message: String,
389        payload: Option<serde_json::Value>,
390    },
391
392    #[error("Hook '{hook_id}' timed out after {timeout_ms}ms")]
393    HookTimeout { hook_id: HookId, timeout_ms: u64 },
394
395    #[error("Hook execution failed for '{hook_id}': {reason}")]
396    HookExecutionFailed { hook_id: HookId, reason: String },
397
398    #[error("Hook configuration invalid: {reason}")]
399    HookConfigInvalid { reason: String },
400
401    /// Turn execution reached a terminal outcome classified as HardFailure.
402    #[error("Terminal failure: {outcome:?} ({cause_kind:?}): {message}")]
403    TerminalFailure {
404        outcome: crate::turn_execution_authority::TurnTerminalOutcome,
405        cause_kind: crate::turn_execution_authority::TurnTerminalCauseKind,
406        message: String,
407    },
408
409    /// Generated pending-continuation authority found no resumable boundary.
410    ///
411    /// The caller should treat this as a successful no-op (no turn ran, no
412    /// output produced).
413    #[error("no pending boundary for resume")]
414    NoPendingBoundary,
415
416    /// The session agent does not support durable-snapshot synchronization
417    /// (the default `SessionAgent::sync_session_from_durable_snapshot`
418    /// capability). Consumers treat this as "skip live sync", distinct from a
419    /// genuine [`AgentError::ConfigError`].
420    #[error("durable session snapshot synchronization is not supported by this session agent")]
421    DurableSnapshotSyncUnsupported,
422}
423
424impl AgentError {
425    /// Wrap a typed [`ToolError`] as a terminal agent failure, preserving the
426    /// typed cause (and thus its [`ToolError::error_code`]).
427    pub fn tool(error: ToolError) -> Self {
428        Self::Tool { error }
429    }
430
431    /// Stable error code for the typed tool cause, when this is the
432    /// [`AgentError::Tool`] variant. Returns `None` for every other variant.
433    ///
434    /// This is the seam that lets `access_denied` / `not_found` /
435    /// `invalid_arguments` survive to the wire instead of being flattened
436    /// into an opaque message.
437    pub fn tool_error_code(&self) -> Option<&'static str> {
438        match self {
439            Self::Tool { error } => Some(error.error_code()),
440            _ => None,
441        }
442    }
443
444    pub fn llm(
445        provider: &'static str,
446        reason: LlmFailureReason,
447        message: impl Into<String>,
448    ) -> Self {
449        Self::Llm {
450            provider,
451            reason,
452            message: message.into(),
453        }
454    }
455
456    pub fn llm_empty_response(provider: &'static str) -> Self {
457        Self::llm(
458            provider,
459            LlmFailureReason::ProviderError(LlmProviderError::retryable(
460                LlmProviderErrorKind::IncompleteResponse,
461                serde_json::json!({
462                    "reason": "provider completed without user-visible text, images, or tool calls"
463                }),
464            )),
465            "LLM completed without user-visible text, images, or tool calls",
466        )
467    }
468
469    pub fn is_graceful(&self) -> bool {
470        matches!(
471            self,
472            Self::TokenBudgetExceeded { .. }
473                | Self::TimeBudgetExceeded { .. }
474                | Self::ToolCallBudgetExceeded { .. }
475                | Self::MaxTurnsReached { .. }
476        )
477    }
478    pub fn is_rate_limited(&self) -> bool {
479        matches!(
480            self,
481            Self::Llm {
482                reason: LlmFailureReason::RateLimited { .. },
483                ..
484            }
485        )
486    }
487
488    pub fn retry_after_hint(&self) -> Option<std::time::Duration> {
489        match self {
490            Self::Llm {
491                reason: LlmFailureReason::RateLimited { retry_after },
492                ..
493            } => *retry_after,
494            _ => None,
495        }
496    }
497
498    pub fn is_recoverable(&self) -> bool {
499        match self {
500            Self::Llm { reason, .. } => match reason {
501                LlmFailureReason::RateLimited { .. } => true,
502                LlmFailureReason::NetworkTimeout { .. } => true,
503                LlmFailureReason::CallTimeout { .. } => true,
504                LlmFailureReason::ProviderError(provider_error) => provider_error.is_retryable(),
505                _ => false,
506            },
507            _ => false,
508        }
509    }
510}
511
512pub fn store_error(err: impl std::fmt::Display) -> AgentError {
513    AgentError::StoreError(store_error_message(err))
514}
515pub fn invalid_session_id(err: impl std::fmt::Display) -> AgentError {
516    AgentError::StoreError(invalid_session_id_message(err))
517}
518pub fn store_error_message(err: impl std::fmt::Display) -> String {
519    err.to_string()
520}
521pub fn invalid_session_id_message(err: impl std::fmt::Display) -> String {
522    format!("Invalid session ID: {err}")
523}
524
525#[cfg(test)]
526#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn test_network_timeout_is_recoverable() {
532        let err = AgentError::llm(
533            "anthropic",
534            LlmFailureReason::NetworkTimeout { duration_ms: 30000 },
535            "network timeout after 30s",
536        );
537        assert!(err.is_recoverable());
538    }
539
540    #[test]
541    fn test_call_timeout_is_recoverable() {
542        let err = AgentError::llm(
543            "anthropic",
544            LlmFailureReason::CallTimeout { duration_ms: 45000 },
545            "call timeout after 45s",
546        );
547        assert!(err.is_recoverable());
548    }
549
550    #[test]
551    fn test_network_timeout_typed_mapping() {
552        let reason = LlmFailureReason::NetworkTimeout { duration_ms: 5000 };
553        match reason {
554            LlmFailureReason::NetworkTimeout { duration_ms } => {
555                assert_eq!(duration_ms, 5000);
556            }
557            _ => panic!("expected NetworkTimeout"),
558        }
559    }
560
561    #[test]
562    fn test_call_timeout_typed_mapping() {
563        let reason = LlmFailureReason::CallTimeout { duration_ms: 60000 };
564        match reason {
565            LlmFailureReason::CallTimeout { duration_ms } => {
566                assert_eq!(duration_ms, 60000);
567            }
568            _ => panic!("expected CallTimeout"),
569        }
570    }
571
572    #[test]
573    fn test_timeout_variants_are_distinct() {
574        let net = LlmFailureReason::NetworkTimeout { duration_ms: 1000 };
575        let call = LlmFailureReason::CallTimeout { duration_ms: 1000 };
576        assert_ne!(net, call);
577    }
578
579    #[test]
580    fn test_auth_error_not_recoverable() {
581        let err = AgentError::llm("anthropic", LlmFailureReason::AuthError, "bad key");
582        assert!(!err.is_recoverable());
583    }
584
585    #[test]
586    fn provider_error_uses_typed_retryability_for_recovery() {
587        let err = AgentError::llm(
588            "anthropic",
589            LlmFailureReason::ProviderError(LlmProviderError::retryable(
590                LlmProviderErrorKind::ServerOverloaded,
591                serde_json::json!({
592                    "message": "provider overloaded"
593                }),
594            )),
595            "provider overloaded",
596        );
597
598        assert!(err.is_recoverable());
599    }
600
601    #[test]
602    fn provider_error_fails_closed_when_json_claims_retryable() {
603        let err = AgentError::llm(
604            "anthropic",
605            LlmFailureReason::ProviderError(LlmProviderError::non_retryable(
606                LlmProviderErrorKind::InvalidRequest,
607                serde_json::json!({
608                    "kind": "server_overloaded",
609                    "retryable": true,
610                    "message": "json payload must not control retryability"
611                }),
612            )),
613            "invalid request",
614        );
615
616        assert!(!err.is_recoverable());
617    }
618
619    // -- Rate-limit helper tests (PR #156 port) --
620
621    #[test]
622    fn test_is_rate_limited_true_for_rate_limit_error() {
623        let err = AgentError::llm(
624            "anthropic",
625            LlmFailureReason::RateLimited {
626                retry_after: Some(std::time::Duration::from_secs(30)),
627            },
628            "rate limited",
629        );
630        assert!(err.is_rate_limited());
631    }
632
633    #[test]
634    fn test_is_rate_limited_false_for_other_errors() {
635        let err = AgentError::llm(
636            "anthropic",
637            LlmFailureReason::NetworkTimeout { duration_ms: 5000 },
638            "timeout",
639        );
640        assert!(!err.is_rate_limited());
641
642        let err = AgentError::llm("anthropic", LlmFailureReason::AuthError, "bad key");
643        assert!(!err.is_rate_limited());
644    }
645
646    #[test]
647    fn test_retry_after_hint_returns_duration_for_rate_limit() {
648        let err = AgentError::llm(
649            "anthropic",
650            LlmFailureReason::RateLimited {
651                retry_after: Some(std::time::Duration::from_secs(60)),
652            },
653            "rate limited",
654        );
655        assert_eq!(
656            err.retry_after_hint(),
657            Some(std::time::Duration::from_secs(60))
658        );
659    }
660
661    #[test]
662    fn test_retry_after_hint_returns_none_for_non_rate_limit() {
663        let err = AgentError::llm(
664            "anthropic",
665            LlmFailureReason::NetworkTimeout { duration_ms: 5000 },
666            "timeout",
667        );
668        assert_eq!(err.retry_after_hint(), None);
669    }
670
671    #[test]
672    fn test_timeout_variants_not_graceful() {
673        let err = AgentError::llm(
674            "anthropic",
675            LlmFailureReason::NetworkTimeout { duration_ms: 1000 },
676            "timeout",
677        );
678        assert!(!err.is_graceful());
679
680        let err = AgentError::llm(
681            "anthropic",
682            LlmFailureReason::CallTimeout { duration_ms: 1000 },
683            "timeout",
684        );
685        assert!(!err.is_graceful());
686    }
687
688    // -- P2-6: Typed BuildError variant --
689
690    #[test]
691    fn test_build_error_variant_exists_and_carries_message() {
692        let err = AgentError::BuildError("Missing API key for provider 'anthropic'".to_string());
693        match &err {
694            AgentError::BuildError(msg) => {
695                assert!(
696                    msg.contains("API key"),
697                    "message should contain source text"
698                );
699            }
700            other => panic!("expected BuildError, got: {other}"),
701        }
702    }
703
704    #[test]
705    fn test_build_error_is_not_recoverable() {
706        let err = AgentError::BuildError("Unknown provider for model 'llama-3'".to_string());
707        assert!(!err.is_recoverable(), "build errors are not recoverable");
708    }
709
710    #[test]
711    fn test_build_error_is_not_graceful() {
712        let err = AgentError::BuildError("Missing API key".to_string());
713        assert!(!err.is_graceful(), "build errors are not graceful");
714    }
715
716    #[test]
717    fn test_build_error_display() {
718        let err = AgentError::BuildError("Missing API key for provider 'anthropic'".to_string());
719        let display = err.to_string();
720        assert!(
721            display.contains("Build error")
722                || display.contains("build error")
723                || display.contains("Missing API key"),
724            "display should mention the build error: {display}"
725        );
726    }
727
728    // -- P2-7: Typed TerminalFailure outcome --
729
730    #[test]
731    fn test_terminal_failure_carries_typed_outcome() {
732        use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
733
734        // TerminalFailure must carry typed enums, not Debug-formatted strings.
735        let err = AgentError::TerminalFailure {
736            outcome: TurnTerminalOutcome::Failed,
737            cause_kind: TurnTerminalCauseKind::LlmFailure,
738            message: "llm failed".to_string(),
739        };
740        match &err {
741            AgentError::TerminalFailure {
742                outcome,
743                cause_kind,
744                ..
745            } => {
746                // If this compiles, outcome/cause_kind are typed enums, not Strings.
747                assert_eq!(*outcome, TurnTerminalOutcome::Failed);
748                assert_eq!(*cause_kind, TurnTerminalCauseKind::LlmFailure);
749            }
750            other => panic!("expected TerminalFailure, got: {other}"),
751        }
752    }
753
754    #[test]
755    fn test_terminal_failure_display_includes_outcome() {
756        use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
757
758        let err = AgentError::TerminalFailure {
759            outcome: TurnTerminalOutcome::TimeBudgetExceeded,
760            cause_kind: TurnTerminalCauseKind::TimeBudgetExceeded,
761            message: "deadline reached".to_string(),
762        };
763        let display = err.to_string();
764        assert!(
765            display.contains("TimeBudgetExceeded"),
766            "display should include the outcome variant name: {display}"
767        );
768        assert!(
769            display.contains("TimeBudgetExceeded") && display.contains("deadline reached"),
770            "display should include cause and display message: {display}"
771        );
772    }
773
774    // -- Rows 12 / 57: typed ToolError cause survives on AgentError --
775
776    #[test]
777    fn tool_variant_preserves_access_denied_error_code() {
778        // Row 12: a hidden-tool denial must terminalize carrying the typed
779        // ToolError so `access_denied` survives to error_code() (not flattened
780        // into an opaque string).
781        let err = AgentError::tool(ToolError::access_denied("secret_tool"));
782        match &err {
783            AgentError::Tool { error } => {
784                assert_eq!(error.error_code(), "access_denied");
785            }
786            other => panic!("expected AgentError::Tool, got: {other}"),
787        }
788        assert_eq!(err.tool_error_code(), Some("access_denied"));
789    }
790
791    #[test]
792    fn tool_variant_preserves_not_found_error_code() {
793        // Row 12 sibling: not_found must remain distinct from access_denied.
794        let err = AgentError::tool(ToolError::not_found("missing_tool"));
795        assert_eq!(err.tool_error_code(), Some("tool_not_found"));
796        assert_ne!(
797            err.tool_error_code(),
798            AgentError::tool(ToolError::access_denied("missing_tool")).tool_error_code(),
799            "not_found must stay distinct from access_denied"
800        );
801    }
802
803    #[test]
804    fn tool_variant_preserves_invalid_arguments_error_code() {
805        // Row 57: an invalid tool-call args projection failure must terminalize
806        // carrying the typed invalid_arguments ToolError, not a flattened string.
807        let err = AgentError::tool(ToolError::invalid_arguments(
808            "search",
809            "tool call arguments projection failed: bad json",
810        ));
811        match &err {
812            AgentError::Tool { error } => {
813                assert_eq!(error.error_code(), "invalid_arguments");
814            }
815            other => panic!("expected AgentError::Tool, got: {other}"),
816        }
817        assert_eq!(err.tool_error_code(), Some("invalid_arguments"));
818    }
819
820    #[test]
821    fn test_terminal_failure_all_hard_failure_outcomes() {
822        use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
823
824        // Both hard-failure outcomes should be representable.
825        for outcome in [
826            TurnTerminalOutcome::Failed,
827            TurnTerminalOutcome::TimeBudgetExceeded,
828        ] {
829            let err = AgentError::TerminalFailure {
830                outcome,
831                cause_kind: TurnTerminalCauseKind::FatalFailure,
832                message: "terminal".to_string(),
833            };
834            assert!(
835                !err.is_graceful(),
836                "TerminalFailure({outcome:?}) should not be graceful"
837            );
838        }
839    }
840}