Skip to main content

ironflow_core/
error.rs

1//! Error types for ironflow operations.
2//!
3//! This module defines two error enums:
4//!
5//! * [`OperationError`] - top-level error returned by both shell and agent operations.
6//! * [`AgentError`] - agent-specific error returned by [`AgentProvider::invoke`](crate::provider::AgentProvider::invoke).
7//!
8//! [`AgentError`] converts into [`OperationError`] via the [`From`] trait, so agent
9//! errors propagate naturally through the `?` operator.
10
11use std::any;
12use std::fmt;
13use std::time::Duration;
14
15use thiserror::Error;
16
17use crate::provider::DebugMessage;
18
19/// Top-level error for any workflow operation (shell or agent).
20///
21/// Every public operation in ironflow returns `Result<T, OperationError>`.
22#[derive(Debug, Error)]
23pub enum OperationError {
24    /// A shell command exited with a non-zero status code.
25    #[error("shell exited with code {exit_code}: {stderr}")]
26    Shell {
27        /// Process exit code, or `-1` if the process could not be spawned.
28        exit_code: i32,
29        /// Captured stderr, truncated to [`MAX_OUTPUT_SIZE`](crate::utils::MAX_OUTPUT_SIZE).
30        stderr: String,
31    },
32
33    /// An agent invocation failed.
34    ///
35    /// Wraps an [`AgentError`] with full detail about the failure.
36    #[error("agent error: {0}")]
37    Agent(#[from] AgentError),
38
39    /// An operation exceeded its configured timeout.
40    #[error("step '{step}' timed out after {limit:?}")]
41    Timeout {
42        /// Human-readable description of the timed-out step (usually the command string).
43        step: String,
44        /// The [`Duration`] that was exceeded.
45        limit: Duration,
46    },
47
48    /// An HTTP request failed at the transport layer or the response body
49    /// could not be read.
50    #[error("{}", match status {
51        Some(code) => format!("http error (status {code}): {message}"),
52        None => format!("http error: {message}"),
53    })]
54    Http {
55        /// HTTP status code, if a response was received.
56        status: Option<u16>,
57        /// Human-readable error description.
58        message: String,
59    },
60
61    /// Failed to deserialize a JSON response into the expected Rust type.
62    ///
63    /// Returned by [`AgentResult::json`](crate::operations::agent::AgentResult::json)
64    /// and [`HttpOutput::json`](crate::operations::http::HttpOutput::json).
65    #[error("failed to deserialize into {target_type}: {reason}")]
66    Deserialize {
67        /// The Rust type name that was expected.
68        target_type: String,
69        /// The underlying serde error message.
70        reason: String,
71    },
72
73    /// The secret store failed to read or decrypt a secret.
74    ///
75    /// Returned by [`SecretResolver::get`](crate::operation::SecretResolver::get)
76    /// implementations when the underlying storage or decryption layer errors.
77    #[error("secret error: {message}")]
78    Secret {
79        /// Human-readable error description.
80        message: String,
81    },
82
83    /// An error from an external library (e.g. `git2`, `gitlab`).
84    ///
85    /// Used by `ironflow-ops-*` crates to wrap third-party library errors
86    /// without introducing domain-specific variants into this enum.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use ironflow_core::error::OperationError;
92    ///
93    /// let err = OperationError::External {
94    ///     origin: "git".to_string(),
95    ///     message: "reference not found".to_string(),
96    /// };
97    /// assert!(err.to_string().contains("git error"));
98    /// ```
99    #[error("{origin} error: {message}")]
100    External {
101        /// Short identifier for the library or domain (e.g. `"git"`, `"gitlab"`).
102        origin: String,
103        /// Human-readable error description from the underlying library.
104        message: String,
105    },
106}
107
108impl OperationError {
109    /// Build a [`Deserialize`](OperationError::Deserialize) error for type `T`.
110    pub fn deserialize<T>(error: impl fmt::Display) -> Self {
111        Self::Deserialize {
112            target_type: any::type_name::<T>().to_string(),
113            reason: error.to_string(),
114        }
115    }
116}
117
118/// Partial usage data from a failed agent invocation.
119///
120/// When an agent step fails (e.g. structured output extraction), the CLI
121/// may still report cost, duration, and token counts. This struct carries
122/// those values so callers can persist them even on error paths.
123#[derive(Debug, Default)]
124pub struct PartialUsage {
125    /// Total cost in USD reported by the CLI.
126    pub cost_usd: Option<f64>,
127    /// Wall-clock duration reported by the CLI, in milliseconds.
128    pub duration_ms: Option<u64>,
129    /// Uncached input tokens consumed before the failure.
130    pub input_tokens: Option<u64>,
131    /// Input tokens served from the prompt cache before the failure.
132    pub cache_read_input_tokens: Option<u64>,
133    /// Input tokens written to the prompt cache before the failure.
134    pub cache_creation_input_tokens: Option<u64>,
135    /// Output tokens generated before the failure.
136    pub output_tokens: Option<u64>,
137}
138
139/// Error specific to agent (AI provider) invocations.
140///
141/// Returned by [`AgentProvider::invoke`](crate::provider::AgentProvider::invoke) and
142/// automatically wrapped into [`OperationError::Agent`] when propagated with `?`.
143#[derive(Debug, Error)]
144pub enum AgentError {
145    /// The agent process exited with a non-zero status code.
146    #[error("claude process exited with code {exit_code}: {stderr}")]
147    ProcessFailed {
148        /// Process exit code, or `-1` if spawning failed.
149        exit_code: i32,
150        /// Captured stderr.
151        stderr: String,
152    },
153
154    /// The agent output did not match the expected schema.
155    #[error("schema validation failed: expected {expected}, got {got}{}", raw_response.as_ref().map(|r| { let end = r.floor_char_boundary(200); format!(" (raw response: {}...)", &r[..end]) }).unwrap_or_default())]
156    SchemaValidation {
157        /// What was expected (e.g. `"structured_output field"`).
158        expected: String,
159        /// What was actually received.
160        got: String,
161        /// Verbose conversation trace captured before the validation failure.
162        ///
163        /// Populated when the agent ran in verbose (stream-json) mode so that
164        /// callers can persist the debug trail even on error paths.
165        debug_messages: Vec<DebugMessage>,
166        /// Partial usage data from the CLI response, available even though
167        /// structured output extraction failed. Boxed to keep `AgentError`
168        /// small on the stack.
169        partial_usage: Box<PartialUsage>,
170        /// Raw text response from the agent, truncated to ~4000 bytes.
171        ///
172        /// When structured output extraction fails, the model may still have
173        /// produced useful text in the `result` field. This captures it so
174        /// callers can persist it for debugging (e.g. in the step output).
175        raw_response: Option<String>,
176    },
177
178    /// The agent stopped because it exhausted its configured USD budget.
179    ///
180    /// Distinct from [`SchemaValidation`](AgentError::SchemaValidation): retrying
181    /// costs money and cannot succeed, since the budget is already spent. Treated
182    /// as non-retryable by [`is_retryable`](crate::retry::is_retryable) at the
183    /// operation level and by the engine at the run level.
184    #[error("agent budget exceeded: spent ${spent_usd:.4} of ${limit_usd:.4} limit")]
185    BudgetExceeded {
186        /// Total cost reported by the provider before it stopped, in USD.
187        spent_usd: f64,
188        /// The configured `max_budget_usd` limit, in USD.
189        limit_usd: f64,
190        /// Verbose conversation trace captured before the budget ran out.
191        debug_messages: Vec<DebugMessage>,
192        /// Usage data reported alongside the budget error. Boxed to keep
193        /// `AgentError` small on the stack.
194        partial_usage: Box<PartialUsage>,
195    },
196
197    /// The prompt exceeds the model's context window.
198    ///
199    /// Returned before spawning the process when the estimated token count
200    /// exceeds the model's known limit.
201    ///
202    /// * `chars` - number of characters in the combined prompt (system + user).
203    /// * `estimated_tokens` - approximate token count (chars / 4).
204    /// * `model_limit` - the model's context window in tokens.
205    #[error(
206        "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
207    )]
208    PromptTooLarge {
209        /// Number of characters in the prompt.
210        chars: usize,
211        /// Estimated token count (chars / 4 heuristic).
212        estimated_tokens: usize,
213        /// Model's context window in tokens.
214        model_limit: usize,
215    },
216
217    /// The agent did not complete within the configured timeout.
218    #[error("agent timed out after {limit:?}")]
219    Timeout {
220        /// The [`Duration`] that was exceeded.
221        limit: Duration,
222    },
223
224    /// The provider returned HTTP 429 Too Many Requests.
225    #[error("rate limited by {provider}, retry after {retry_after_secs:?}s")]
226    RateLimited {
227        /// Provider name (e.g. `"openai"`, `"anthropic"`).
228        provider: String,
229        /// Value from the `Retry-After` header, if present.
230        retry_after_secs: Option<u64>,
231    },
232
233    /// The provider returned an unexpected HTTP error or a transport-level failure.
234    ///
235    /// When `status_code` is `0`, no HTTP response was received (connection failure,
236    /// DNS resolution error, TLS handshake failure, or response body read error).
237    #[error("{provider} HTTP {status_code}: {message}")]
238    HttpProvider {
239        /// Provider name (e.g. `"openai"`, `"nvidia"`).
240        provider: String,
241        /// HTTP status code, or `0` for transport-level failures.
242        status_code: u16,
243        /// Error message from the provider response body, or transport error description.
244        message: String,
245    },
246
247    /// The step asked for a tool profile the provider does not have.
248    ///
249    /// Never falls back to other tools: a typo must not widen what the model
250    /// can do. Deterministic, so never retried.
251    #[error(
252        "unknown tool profile '{profile}' (registered profiles: {})",
253        list_or_none(available)
254    )]
255    UnknownToolProfile {
256        /// The profile the step asked for.
257        profile: String,
258        /// Profiles registered on the provider, sorted.
259        available: Vec<String>,
260    },
261
262    /// The step asked for a tool profile, but the provider cannot apply one.
263    ///
264    /// Claude CLI providers pick their tools with `allowed_tools` and MCP
265    /// configuration; they refuse a profile rather than ignore it.
266    #[error(
267        "{provider} does not support tool profiles (step asked for '{profile}'): pick its tools with allow_tool or an MCP config"
268    )]
269    ToolProfileUnsupported {
270        /// Provider that refused the profile (e.g. `"claude-code"`).
271        provider: String,
272        /// The profile the step asked for.
273        profile: String,
274    },
275}
276
277fn list_or_none(names: &[String]) -> String {
278    if names.is_empty() {
279        "none".to_string()
280    } else {
281        names.join(", ")
282    }
283}
284
285/// Error raised when accessing a typed answer on a
286/// [`DecisionOutput`](crate::decision::DecisionOutput) by name.
287///
288/// Distinct from [`AgentError`]: this is a lookup error on an already-received
289/// decision result, not a backend failure.
290///
291/// # Examples
292///
293/// ```
294/// use ironflow_core::error::DecisionError;
295///
296/// let err = DecisionError::NotFound("dept".to_string());
297/// assert_eq!(err.to_string(), "no decision answer named 'dept'");
298/// ```
299#[derive(Debug, Error)]
300pub enum DecisionError {
301    /// No answer exists under the requested name.
302    #[error("no decision answer named '{0}'")]
303    NotFound(String),
304
305    /// An answer exists but is a different kind than requested.
306    #[error("decision answer '{name}' is a {actual}, not a {expected}")]
307    TypeMismatch {
308        /// The answer name that was looked up.
309        name: String,
310        /// The kind the caller requested (`"noul"`, `"choice"`, or `"score"`).
311        expected: &'static str,
312        /// The kind the answer actually is.
313        actual: &'static str,
314    },
315
316    /// A choice answer picked an option the question did not offer.
317    #[error("decision answer '{name}' picked '{choice}', which is not one of its options")]
318    UnknownChoice {
319        /// The answer name that was read.
320        name: String,
321        /// The option the provider returned.
322        choice: String,
323    },
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn shell_display_format() {
332        let err = OperationError::Shell {
333            exit_code: 127,
334            stderr: "command not found".to_string(),
335        };
336        assert_eq!(
337            err.to_string(),
338            "shell exited with code 127: command not found"
339        );
340    }
341
342    #[test]
343    fn agent_display_delegates_to_agent_error() {
344        let inner = AgentError::ProcessFailed {
345            exit_code: 1,
346            stderr: "boom".to_string(),
347        };
348        let err = OperationError::Agent(inner);
349        assert_eq!(
350            err.to_string(),
351            "agent error: claude process exited with code 1: boom"
352        );
353    }
354
355    #[test]
356    fn timeout_display_format() {
357        let err = OperationError::Timeout {
358            step: "build".to_string(),
359            limit: Duration::from_secs(30),
360        };
361        assert_eq!(err.to_string(), "step 'build' timed out after 30s");
362    }
363
364    #[test]
365    fn agent_error_process_failed_display_zero_exit_code() {
366        let err = AgentError::ProcessFailed {
367            exit_code: 0,
368            stderr: "unexpected".to_string(),
369        };
370        assert_eq!(
371            err.to_string(),
372            "claude process exited with code 0: unexpected"
373        );
374    }
375
376    #[test]
377    fn agent_error_process_failed_display_negative_exit_code() {
378        let err = AgentError::ProcessFailed {
379            exit_code: -1,
380            stderr: "killed".to_string(),
381        };
382        assert!(err.to_string().contains("-1"));
383    }
384
385    #[test]
386    fn agent_error_schema_validation_display() {
387        let err = AgentError::SchemaValidation {
388            expected: "object".to_string(),
389            got: "string".to_string(),
390            debug_messages: Vec::new(),
391            partial_usage: Box::default(),
392            raw_response: None,
393        };
394        assert_eq!(
395            err.to_string(),
396            "schema validation failed: expected object, got string"
397        );
398    }
399
400    #[test]
401    fn agent_error_timeout_display() {
402        let err = AgentError::Timeout {
403            limit: Duration::from_secs(300),
404        };
405        assert_eq!(err.to_string(), "agent timed out after 300s");
406    }
407
408    #[test]
409    fn from_agent_error_process_failed() {
410        let agent_err = AgentError::ProcessFailed {
411            exit_code: 42,
412            stderr: "fail".to_string(),
413        };
414        let op_err: OperationError = agent_err.into();
415        assert!(matches!(
416            op_err,
417            OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
418        ));
419    }
420
421    #[test]
422    fn from_agent_error_schema_validation() {
423        let agent_err = AgentError::SchemaValidation {
424            expected: "a".to_string(),
425            got: "b".to_string(),
426            debug_messages: Vec::new(),
427            partial_usage: Box::default(),
428            raw_response: None,
429        };
430        let op_err: OperationError = agent_err.into();
431        assert!(matches!(
432            op_err,
433            OperationError::Agent(AgentError::SchemaValidation { .. })
434        ));
435    }
436
437    #[test]
438    fn from_agent_error_timeout() {
439        let agent_err = AgentError::Timeout {
440            limit: Duration::from_secs(60),
441        };
442        let op_err: OperationError = agent_err.into();
443        assert!(matches!(
444            op_err,
445            OperationError::Agent(AgentError::Timeout { .. })
446        ));
447    }
448
449    #[test]
450    fn operation_error_implements_std_error() {
451        use std::error::Error;
452        let err = OperationError::Shell {
453            exit_code: 1,
454            stderr: "x".to_string(),
455        };
456        let _: &dyn Error = &err;
457    }
458
459    #[test]
460    fn agent_error_implements_std_error() {
461        use std::error::Error;
462        let err = AgentError::Timeout {
463            limit: Duration::from_secs(60),
464        };
465        let _: &dyn Error = &err;
466    }
467
468    #[test]
469    fn empty_stderr_edge_case() {
470        let err = OperationError::Shell {
471            exit_code: 1,
472            stderr: String::new(),
473        };
474        assert_eq!(err.to_string(), "shell exited with code 1: ");
475    }
476
477    #[test]
478    fn multiline_stderr() {
479        let err = AgentError::ProcessFailed {
480            exit_code: 1,
481            stderr: "line1\nline2\nline3".to_string(),
482        };
483        assert!(err.to_string().contains("line1\nline2\nline3"));
484    }
485
486    #[test]
487    fn unicode_in_stderr() {
488        let err = OperationError::Shell {
489            exit_code: 1,
490            stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
491        };
492        assert!(err.to_string().contains("\u{1F4A5}"));
493    }
494
495    #[test]
496    fn http_error_with_status_display() {
497        let err = OperationError::Http {
498            status: Some(500),
499            message: "internal server error".to_string(),
500        };
501        assert_eq!(
502            err.to_string(),
503            "http error (status 500): internal server error"
504        );
505    }
506
507    #[test]
508    fn http_error_without_status_display() {
509        let err = OperationError::Http {
510            status: None,
511            message: "connection refused".to_string(),
512        };
513        assert_eq!(err.to_string(), "http error: connection refused");
514    }
515
516    #[test]
517    fn http_error_empty_message() {
518        let err = OperationError::Http {
519            status: Some(404),
520            message: String::new(),
521        };
522        assert_eq!(err.to_string(), "http error (status 404): ");
523    }
524
525    #[test]
526    fn subsecond_duration_in_timeout_display() {
527        let err = OperationError::Timeout {
528            step: "fast".to_string(),
529            limit: Duration::from_millis(500),
530        };
531        assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
532    }
533
534    #[test]
535    fn source_chains_agent_error() {
536        use std::error::Error;
537        let err = OperationError::Agent(AgentError::Timeout {
538            limit: Duration::from_secs(60),
539        });
540        assert!(err.source().is_some());
541    }
542
543    #[test]
544    fn source_none_for_shell() {
545        use std::error::Error;
546        let err = OperationError::Shell {
547            exit_code: 1,
548            stderr: "x".to_string(),
549        };
550        assert!(err.source().is_none());
551    }
552
553    #[test]
554    fn deserialize_helper_formats_correctly() {
555        let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
556        match &err {
557            OperationError::Deserialize {
558                target_type,
559                reason,
560            } => {
561                assert!(target_type.contains("Vec"));
562                assert!(target_type.contains("String"));
563                assert_eq!(reason, "missing field");
564            }
565            _ => panic!("expected Deserialize variant"),
566        }
567    }
568
569    #[test]
570    fn deserialize_display_format() {
571        let err = OperationError::Deserialize {
572            target_type: "MyStruct".to_string(),
573            reason: "bad input".to_string(),
574        };
575        assert_eq!(
576            err.to_string(),
577            "failed to deserialize into MyStruct: bad input"
578        );
579    }
580
581    #[test]
582    fn agent_error_prompt_too_large_display() {
583        let err = AgentError::PromptTooLarge {
584            chars: 966_007,
585            estimated_tokens: 241_501,
586            model_limit: 200_000,
587        };
588        let msg = err.to_string();
589        assert!(msg.contains("966007 chars"));
590        assert!(msg.contains("241501 tokens"));
591        assert!(msg.contains("200000 tokens"));
592    }
593
594    #[test]
595    fn from_agent_error_prompt_too_large() {
596        let agent_err = AgentError::PromptTooLarge {
597            chars: 1_000_000,
598            estimated_tokens: 250_000,
599            model_limit: 200_000,
600        };
601        let op_err: OperationError = agent_err.into();
602        assert!(matches!(
603            op_err,
604            OperationError::Agent(AgentError::PromptTooLarge {
605                model_limit: 200_000,
606                ..
607            })
608        ));
609    }
610
611    #[test]
612    fn source_none_for_http_timeout_deserialize() {
613        use std::error::Error;
614        let http = OperationError::Http {
615            status: Some(500),
616            message: "x".to_string(),
617        };
618        assert!(http.source().is_none());
619
620        let timeout = OperationError::Timeout {
621            step: "x".to_string(),
622            limit: Duration::from_secs(1),
623        };
624        assert!(timeout.source().is_none());
625
626        let deser = OperationError::Deserialize {
627            target_type: "T".to_string(),
628            reason: "r".to_string(),
629        };
630        assert!(deser.source().is_none());
631    }
632
633    #[test]
634    fn schema_validation_raw_response_preserved() {
635        let err = AgentError::SchemaValidation {
636            expected: "structured_output field".to_string(),
637            got: "null".to_string(),
638            debug_messages: Vec::new(),
639            partial_usage: Box::default(),
640            raw_response: Some("The model said something useful".to_string()),
641        };
642        match err {
643            AgentError::SchemaValidation { raw_response, .. } => {
644                assert_eq!(
645                    raw_response.as_deref(),
646                    Some("The model said something useful")
647                );
648            }
649            _ => panic!("expected SchemaValidation"),
650        }
651    }
652
653    #[test]
654    fn external_error_display() {
655        let err = OperationError::External {
656            origin: "git".to_string(),
657            message: "reference not found".to_string(),
658        };
659        assert_eq!(err.to_string(), "git error: reference not found");
660    }
661
662    #[test]
663    fn schema_validation_raw_response_none_by_default() {
664        let err = AgentError::SchemaValidation {
665            expected: "a".to_string(),
666            got: "b".to_string(),
667            debug_messages: Vec::new(),
668            partial_usage: Box::default(),
669            raw_response: None,
670        };
671        match err {
672            AgentError::SchemaValidation { raw_response, .. } => {
673                assert!(raw_response.is_none());
674            }
675            _ => panic!("expected SchemaValidation"),
676        }
677    }
678}