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/// One externally routed callback tool call inside a suspended assistant
9/// tool-use batch.
10#[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    /// Provider/client-native network timeout (owned by client layer)
32    NetworkTimeout {
33        duration_ms: u64,
34    },
35    /// Agent-loop hard call timeout (owned by agent loop policy)
36    CallTimeout {
37        duration_ms: u64,
38    },
39    /// Agent-loop stream-inactivity watchdog fired: the provider stream
40    /// produced no events for the configured window (owned by agent loop
41    /// policy). Distinct from [`Self::CallTimeout`], which bounds the whole
42    /// call regardless of stream liveness.
43    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    /// The provider rejected (or preflight proved) a serialized request body
54    /// that exceeds its request-size cap.
55    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/// Errors that can occur during tool validation
115#[derive(Debug, Clone, thiserror::Error, PartialEq)]
116pub enum ToolValidationError {
117    /// The requested tool was not found
118    #[error("Tool not found: {name}")]
119    NotFound { name: String },
120    /// The tool arguments failed validation
121    #[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/// Error returned by tool dispatch operations.
138#[derive(Debug, Clone, PartialEq, thiserror::Error)]
139pub enum ToolError {
140    /// The requested tool was not found
141    #[error("Tool not found: {name}")]
142    NotFound { name: String },
143
144    /// The tool exists but is currently unavailable
145    #[error("Tool '{name}' is currently unavailable: {reason}")]
146    Unavailable {
147        name: String,
148        reason: ToolUnavailableReason,
149    },
150
151    /// The tool arguments failed validation
152    #[error("Invalid arguments for tool '{name}': {reason}")]
153    InvalidArguments { name: String, reason: String },
154
155    /// The tool execution failed
156    #[error("Tool execution failed: {message}")]
157    ExecutionFailed { message: String },
158
159    /// The tool execution failed with structured error data for protocol surfaces.
160    #[error("Tool execution failed: {message}")]
161    ExecutionFailedWithData {
162        message: String,
163        data: serde_json::Value,
164    },
165
166    /// The tool execution timed out
167    #[error("Tool '{name}' timed out after {timeout_ms}ms")]
168    Timeout { name: String, timeout_ms: u64 },
169
170    /// A declared streaming tool stopped producing accepted progress.
171    #[error("Streaming tool '{name}' stalled after {inactivity_ms}ms of inactivity")]
172    InactivityTimeout { name: String, inactivity_ms: u64 },
173
174    /// Tool access was denied by policy
175    #[error("Tool '{name}' is not allowed by policy")]
176    AccessDenied { name: String },
177
178    /// A generic tool error with a message
179    #[error("{0}")]
180    Other(String),
181
182    /// Tool call must be routed externally (callback pending)
183    ///
184    /// This variant signals that a tool call cannot be handled internally
185    /// and must be routed to an external handler. The payload contains
186    /// serialized information about the pending tool call.
187    #[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    /// Render the canonical model-facing transcript text for this typed error.
223    ///
224    /// This is the single render boundary over the typed error: it projects
225    /// [`Self::to_error_payload`] into the JSON string that appears in the
226    /// conversation transcript. It is infallible — `serde_json::Value` always
227    /// renders via its `Display` impl — so there is no fallback string that
228    /// could mask a serialization fault.
229    #[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    /// Create a callback pending error for external tool routing
286    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    /// Check if this is a callback pending error
294    pub fn is_callback_pending(&self) -> bool {
295        matches!(self, Self::CallbackPending { .. })
296    }
297
298    /// Extract callback pending info if this is a CallbackPending error
299    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/// Errors that can occur during agent execution
319#[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    /// A tool failure carrying the typed [`ToolError`] cause.
331    ///
332    /// The typed cause is preserved so the `access_denied` vs `not_found`
333    /// vs `invalid_arguments` distinction survives to
334    /// [`ToolError::error_code`] and the wire surface.
335    #[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    /// A supervised sticky-fallback durability saga could not prove whether
376    /// runtime session authority and generated routing authority converged.
377    /// The live executor must be handed to canonical teardown; ordinary
378    /// failed-batch retry could continue against split model identity.
379    #[error("Sticky model fallback authority outcome is unknown: {message}")]
380    StickyModelFallbackAuthorityUnknown { message: String },
381
382    /// One live session projection advanced but its paired durable/session
383    /// authority did not provably converge. Reusing the actor could duplicate
384    /// or omit an already-observed fact, so the runtime must tear it down and
385    /// reload from durable authority instead of entering failed-batch retry.
386    #[error("Session durable projection authority outcome is unknown: {message}")]
387    SessionDurableProjectionAuthorityUnknown { message: String },
388
389    /// Agent construction failed (e.g. missing API key, unknown provider).
390    #[error("Build error: {0}")]
391    BuildError(String),
392
393    /// Agent construction attempted to claim a session identity that is already live.
394    #[error("Session identity already active: {0}")]
395    SessionIdentityInUse(SessionId),
396
397    /// MeerkatMachine DSL observed an auth lease in `reauth_required`
398    /// state at a CallingLlm boundary; the lease cannot proceed
399    /// until the user re-authenticates (`rkat auth login`). This is a
400    /// machine-owned terminal class (Phase 1.5-rev), distinct from
401    /// [`AgentError::InternalError`] which is for genuinely
402    /// unexpected failures.
403    #[error("Connection `{binding_key}` requires re-authentication: {message}")]
404    AuthReauthRequired {
405        binding_key: String,
406        message: String,
407    },
408
409    /// A tool call must be routed externally (callback pending)
410    #[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    /// Multiple callback tools from one assistant batch are pending together.
418    ///
419    /// The complete typed set is surfaced so callers can supply one exact
420    /// result set; no callback is silently selected or dropped.
421    #[error("Callback batch pending for {} tools", pending_tool_calls.len())]
422    CallbackBatchPending {
423        pending_tool_calls: Vec<PendingCallbackToolCall>,
424    },
425
426    /// Structured output validation failed after retries
427    #[error("Structured output validation failed after {attempts} attempts: {reason}")]
428    StructuredOutputValidationFailed {
429        attempts: u32,
430        reason: String,
431        last_output: String,
432    },
433
434    /// Invalid output schema provided
435    #[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    /// Turn execution reached a terminal outcome classified as HardFailure.
457    #[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    /// Generated pending-continuation authority found no resumable boundary.
465    ///
466    /// The caller should treat this as a successful no-op (no turn ran, no
467    /// output produced).
468    #[error("no pending boundary for resume")]
469    NoPendingBoundary,
470
471    /// The session agent does not support durable-snapshot synchronization
472    /// (the default `SessionAgent::sync_session_from_durable_snapshot`
473    /// capability). Consumers treat this as "skip live sync", distinct from a
474    /// genuine [`AgentError::ConfigError`].
475    #[error("durable session snapshot synchronization is not supported by this session agent")]
476    DurableSnapshotSyncUnsupported,
477}
478
479impl AgentError {
480    /// Wrap a typed [`ToolError`] as a terminal agent failure, preserving the
481    /// typed cause (and thus its [`ToolError::error_code`]).
482    pub fn tool(error: ToolError) -> Self {
483        Self::Tool { error }
484    }
485
486    /// Stable error code for the typed tool cause, when this is the
487    /// [`AgentError::Tool`] variant. Returns `None` for every other variant.
488    ///
489    /// This is the seam that lets `access_denied` / `not_found` /
490    /// `invalid_arguments` survive to the wire instead of being flattened
491    /// into an opaque message.
492    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    /// Whether this error must survive surface/cleanup wrappers so the runtime
568    /// can hand the live executor to canonical teardown.
569    pub fn requires_session_teardown(&self) -> bool {
570        matches!(
571            self,
572            Self::StickyModelFallbackAuthorityUnknown { .. }
573                | Self::SessionDurableProjectionAuthorityUnknown { .. }
574        )
575    }
576
577    /// Mint the general teardown-required failure for a session projection
578    /// whose paired authority can no longer be proved coherent.
579    pub fn session_durable_projection_authority_unknown(message: impl Into<String>) -> Self {
580        Self::SessionDurableProjectionAuthorityUnknown {
581            message: message.into(),
582        }
583    }
584
585    /// Attach an ancillary cleanup failure without erasing a teardown-required
586    /// primary cause. Non-teardown errors retain the historical combined
587    /// internal-error shape.
588    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    // -- Rate-limit helper tests (PR #156 port) --
767
768    #[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    // -- P2-6: Typed BuildError variant --
836
837    #[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    // -- P2-7: Typed TerminalFailure outcome --
876
877    #[test]
878    fn test_terminal_failure_carries_typed_outcome() {
879        use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
880
881        // TerminalFailure must carry typed enums, not Debug-formatted strings.
882        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                // If this compiles, outcome/cause_kind are typed enums, not Strings.
894                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    // -- Rows 12 / 57: typed ToolError cause survives on AgentError --
922
923    #[test]
924    fn tool_variant_preserves_access_denied_error_code() {
925        // Row 12: a hidden-tool denial must terminalize carrying the typed
926        // ToolError so `access_denied` survives to error_code() (not flattened
927        // into an opaque string).
928        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        // Row 12 sibling: not_found must remain distinct from access_denied.
941        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        // Row 57: an invalid tool-call args projection failure must terminalize
953        // carrying the typed invalid_arguments ToolError, not a flattened string.
954        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        // Both hard-failure outcomes should be representable.
972        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}