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
74impl OperationError {
75    /// Build a [`Deserialize`](OperationError::Deserialize) error for type `T`.
76    pub fn deserialize<T>(error: impl fmt::Display) -> Self {
77        Self::Deserialize {
78            target_type: any::type_name::<T>().to_string(),
79            reason: error.to_string(),
80        }
81    }
82}
83
84/// Partial usage data from a failed agent invocation.
85///
86/// When an agent step fails (e.g. structured output extraction), the CLI
87/// may still report cost, duration, and token counts. This struct carries
88/// those values so callers can persist them even on error paths.
89#[derive(Debug, Default)]
90pub struct PartialUsage {
91    /// Total cost in USD reported by the CLI.
92    pub cost_usd: Option<f64>,
93    /// Wall-clock duration reported by the CLI, in milliseconds.
94    pub duration_ms: Option<u64>,
95    /// Input tokens consumed before the failure.
96    pub input_tokens: Option<u64>,
97    /// Output tokens generated before the failure.
98    pub output_tokens: Option<u64>,
99}
100
101/// Error specific to agent (AI provider) invocations.
102///
103/// Returned by [`AgentProvider::invoke`](crate::provider::AgentProvider::invoke) and
104/// automatically wrapped into [`OperationError::Agent`] when propagated with `?`.
105#[derive(Debug, Error)]
106pub enum AgentError {
107    /// The agent process exited with a non-zero status code.
108    #[error("claude process exited with code {exit_code}: {stderr}")]
109    ProcessFailed {
110        /// Process exit code, or `-1` if spawning failed.
111        exit_code: i32,
112        /// Captured stderr.
113        stderr: String,
114    },
115
116    /// The agent output did not match the expected schema.
117    #[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())]
118    SchemaValidation {
119        /// What was expected (e.g. `"structured_output field"`).
120        expected: String,
121        /// What was actually received.
122        got: String,
123        /// Verbose conversation trace captured before the validation failure.
124        ///
125        /// Populated when the agent ran in verbose (stream-json) mode so that
126        /// callers can persist the debug trail even on error paths.
127        debug_messages: Vec<DebugMessage>,
128        /// Partial usage data from the CLI response, available even though
129        /// structured output extraction failed. Boxed to keep `AgentError`
130        /// small on the stack.
131        partial_usage: Box<PartialUsage>,
132        /// Raw text response from the agent, truncated to ~4000 bytes.
133        ///
134        /// When structured output extraction fails, the model may still have
135        /// produced useful text in the `result` field. This captures it so
136        /// callers can persist it for debugging (e.g. in the step output).
137        raw_response: Option<String>,
138    },
139
140    /// The prompt exceeds the model's context window.
141    ///
142    /// Returned before spawning the process when the estimated token count
143    /// exceeds the model's known limit.
144    ///
145    /// * `chars` - number of characters in the combined prompt (system + user).
146    /// * `estimated_tokens` - approximate token count (chars / 4).
147    /// * `model_limit` - the model's context window in tokens.
148    #[error(
149        "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
150    )]
151    PromptTooLarge {
152        /// Number of characters in the prompt.
153        chars: usize,
154        /// Estimated token count (chars / 4 heuristic).
155        estimated_tokens: usize,
156        /// Model's context window in tokens.
157        model_limit: usize,
158    },
159
160    /// The agent did not complete within the configured timeout.
161    #[error("agent timed out after {limit:?}")]
162    Timeout {
163        /// The [`Duration`] that was exceeded.
164        limit: Duration,
165    },
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn shell_display_format() {
174        let err = OperationError::Shell {
175            exit_code: 127,
176            stderr: "command not found".to_string(),
177        };
178        assert_eq!(
179            err.to_string(),
180            "shell exited with code 127: command not found"
181        );
182    }
183
184    #[test]
185    fn agent_display_delegates_to_agent_error() {
186        let inner = AgentError::ProcessFailed {
187            exit_code: 1,
188            stderr: "boom".to_string(),
189        };
190        let err = OperationError::Agent(inner);
191        assert_eq!(
192            err.to_string(),
193            "agent error: claude process exited with code 1: boom"
194        );
195    }
196
197    #[test]
198    fn timeout_display_format() {
199        let err = OperationError::Timeout {
200            step: "build".to_string(),
201            limit: Duration::from_secs(30),
202        };
203        assert_eq!(err.to_string(), "step 'build' timed out after 30s");
204    }
205
206    #[test]
207    fn agent_error_process_failed_display_zero_exit_code() {
208        let err = AgentError::ProcessFailed {
209            exit_code: 0,
210            stderr: "unexpected".to_string(),
211        };
212        assert_eq!(
213            err.to_string(),
214            "claude process exited with code 0: unexpected"
215        );
216    }
217
218    #[test]
219    fn agent_error_process_failed_display_negative_exit_code() {
220        let err = AgentError::ProcessFailed {
221            exit_code: -1,
222            stderr: "killed".to_string(),
223        };
224        assert!(err.to_string().contains("-1"));
225    }
226
227    #[test]
228    fn agent_error_schema_validation_display() {
229        let err = AgentError::SchemaValidation {
230            expected: "object".to_string(),
231            got: "string".to_string(),
232            debug_messages: Vec::new(),
233            partial_usage: Box::default(),
234            raw_response: None,
235        };
236        assert_eq!(
237            err.to_string(),
238            "schema validation failed: expected object, got string"
239        );
240    }
241
242    #[test]
243    fn agent_error_timeout_display() {
244        let err = AgentError::Timeout {
245            limit: Duration::from_secs(300),
246        };
247        assert_eq!(err.to_string(), "agent timed out after 300s");
248    }
249
250    #[test]
251    fn from_agent_error_process_failed() {
252        let agent_err = AgentError::ProcessFailed {
253            exit_code: 42,
254            stderr: "fail".to_string(),
255        };
256        let op_err: OperationError = agent_err.into();
257        assert!(matches!(
258            op_err,
259            OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
260        ));
261    }
262
263    #[test]
264    fn from_agent_error_schema_validation() {
265        let agent_err = AgentError::SchemaValidation {
266            expected: "a".to_string(),
267            got: "b".to_string(),
268            debug_messages: Vec::new(),
269            partial_usage: Box::default(),
270            raw_response: None,
271        };
272        let op_err: OperationError = agent_err.into();
273        assert!(matches!(
274            op_err,
275            OperationError::Agent(AgentError::SchemaValidation { .. })
276        ));
277    }
278
279    #[test]
280    fn from_agent_error_timeout() {
281        let agent_err = AgentError::Timeout {
282            limit: Duration::from_secs(60),
283        };
284        let op_err: OperationError = agent_err.into();
285        assert!(matches!(
286            op_err,
287            OperationError::Agent(AgentError::Timeout { .. })
288        ));
289    }
290
291    #[test]
292    fn operation_error_implements_std_error() {
293        use std::error::Error;
294        let err = OperationError::Shell {
295            exit_code: 1,
296            stderr: "x".to_string(),
297        };
298        let _: &dyn Error = &err;
299    }
300
301    #[test]
302    fn agent_error_implements_std_error() {
303        use std::error::Error;
304        let err = AgentError::Timeout {
305            limit: Duration::from_secs(60),
306        };
307        let _: &dyn Error = &err;
308    }
309
310    #[test]
311    fn empty_stderr_edge_case() {
312        let err = OperationError::Shell {
313            exit_code: 1,
314            stderr: String::new(),
315        };
316        assert_eq!(err.to_string(), "shell exited with code 1: ");
317    }
318
319    #[test]
320    fn multiline_stderr() {
321        let err = AgentError::ProcessFailed {
322            exit_code: 1,
323            stderr: "line1\nline2\nline3".to_string(),
324        };
325        assert!(err.to_string().contains("line1\nline2\nline3"));
326    }
327
328    #[test]
329    fn unicode_in_stderr() {
330        let err = OperationError::Shell {
331            exit_code: 1,
332            stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
333        };
334        assert!(err.to_string().contains("\u{1F4A5}"));
335    }
336
337    #[test]
338    fn http_error_with_status_display() {
339        let err = OperationError::Http {
340            status: Some(500),
341            message: "internal server error".to_string(),
342        };
343        assert_eq!(
344            err.to_string(),
345            "http error (status 500): internal server error"
346        );
347    }
348
349    #[test]
350    fn http_error_without_status_display() {
351        let err = OperationError::Http {
352            status: None,
353            message: "connection refused".to_string(),
354        };
355        assert_eq!(err.to_string(), "http error: connection refused");
356    }
357
358    #[test]
359    fn http_error_empty_message() {
360        let err = OperationError::Http {
361            status: Some(404),
362            message: String::new(),
363        };
364        assert_eq!(err.to_string(), "http error (status 404): ");
365    }
366
367    #[test]
368    fn subsecond_duration_in_timeout_display() {
369        let err = OperationError::Timeout {
370            step: "fast".to_string(),
371            limit: Duration::from_millis(500),
372        };
373        assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
374    }
375
376    #[test]
377    fn source_chains_agent_error() {
378        use std::error::Error;
379        let err = OperationError::Agent(AgentError::Timeout {
380            limit: Duration::from_secs(60),
381        });
382        assert!(err.source().is_some());
383    }
384
385    #[test]
386    fn source_none_for_shell() {
387        use std::error::Error;
388        let err = OperationError::Shell {
389            exit_code: 1,
390            stderr: "x".to_string(),
391        };
392        assert!(err.source().is_none());
393    }
394
395    #[test]
396    fn deserialize_helper_formats_correctly() {
397        let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
398        match &err {
399            OperationError::Deserialize {
400                target_type,
401                reason,
402            } => {
403                assert!(target_type.contains("Vec"));
404                assert!(target_type.contains("String"));
405                assert_eq!(reason, "missing field");
406            }
407            _ => panic!("expected Deserialize variant"),
408        }
409    }
410
411    #[test]
412    fn deserialize_display_format() {
413        let err = OperationError::Deserialize {
414            target_type: "MyStruct".to_string(),
415            reason: "bad input".to_string(),
416        };
417        assert_eq!(
418            err.to_string(),
419            "failed to deserialize into MyStruct: bad input"
420        );
421    }
422
423    #[test]
424    fn agent_error_prompt_too_large_display() {
425        let err = AgentError::PromptTooLarge {
426            chars: 966_007,
427            estimated_tokens: 241_501,
428            model_limit: 200_000,
429        };
430        let msg = err.to_string();
431        assert!(msg.contains("966007 chars"));
432        assert!(msg.contains("241501 tokens"));
433        assert!(msg.contains("200000 tokens"));
434    }
435
436    #[test]
437    fn from_agent_error_prompt_too_large() {
438        let agent_err = AgentError::PromptTooLarge {
439            chars: 1_000_000,
440            estimated_tokens: 250_000,
441            model_limit: 200_000,
442        };
443        let op_err: OperationError = agent_err.into();
444        assert!(matches!(
445            op_err,
446            OperationError::Agent(AgentError::PromptTooLarge {
447                model_limit: 200_000,
448                ..
449            })
450        ));
451    }
452
453    #[test]
454    fn source_none_for_http_timeout_deserialize() {
455        use std::error::Error;
456        let http = OperationError::Http {
457            status: Some(500),
458            message: "x".to_string(),
459        };
460        assert!(http.source().is_none());
461
462        let timeout = OperationError::Timeout {
463            step: "x".to_string(),
464            limit: Duration::from_secs(1),
465        };
466        assert!(timeout.source().is_none());
467
468        let deser = OperationError::Deserialize {
469            target_type: "T".to_string(),
470            reason: "r".to_string(),
471        };
472        assert!(deser.source().is_none());
473    }
474
475    #[test]
476    fn schema_validation_raw_response_preserved() {
477        let err = AgentError::SchemaValidation {
478            expected: "structured_output field".to_string(),
479            got: "null".to_string(),
480            debug_messages: Vec::new(),
481            partial_usage: Box::default(),
482            raw_response: Some("The model said something useful".to_string()),
483        };
484        match err {
485            AgentError::SchemaValidation { raw_response, .. } => {
486                assert_eq!(
487                    raw_response.as_deref(),
488                    Some("The model said something useful")
489                );
490            }
491            _ => panic!("expected SchemaValidation"),
492        }
493    }
494
495    #[test]
496    fn schema_validation_raw_response_none_by_default() {
497        let err = AgentError::SchemaValidation {
498            expected: "a".to_string(),
499            got: "b".to_string(),
500            debug_messages: Vec::new(),
501            partial_usage: Box::default(),
502            raw_response: None,
503        };
504        match err {
505            AgentError::SchemaValidation { raw_response, .. } => {
506                assert!(raw_response.is_none());
507            }
508            _ => panic!("expected SchemaValidation"),
509        }
510    }
511}