1use std::any;
12use std::fmt;
13use std::time::Duration;
14
15use thiserror::Error;
16
17use crate::provider::DebugMessage;
18
19#[derive(Debug, Error)]
23pub enum OperationError {
24 #[error("shell exited with code {exit_code}: {stderr}")]
26 Shell {
27 exit_code: i32,
29 stderr: String,
31 },
32
33 #[error("agent error: {0}")]
37 Agent(#[from] AgentError),
38
39 #[error("step '{step}' timed out after {limit:?}")]
41 Timeout {
42 step: String,
44 limit: Duration,
46 },
47
48 #[error("{}", match status {
51 Some(code) => format!("http error (status {code}): {message}"),
52 None => format!("http error: {message}"),
53 })]
54 Http {
55 status: Option<u16>,
57 message: String,
59 },
60
61 #[error("failed to deserialize into {target_type}: {reason}")]
66 Deserialize {
67 target_type: String,
69 reason: String,
71 },
72
73 #[error("secret error: {message}")]
78 Secret {
79 message: String,
81 },
82
83 #[error("{origin} error: {message}")]
100 External {
101 origin: String,
103 message: String,
105 },
106}
107
108impl OperationError {
109 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#[derive(Debug, Default)]
124pub struct PartialUsage {
125 pub cost_usd: Option<f64>,
127 pub duration_ms: Option<u64>,
129 pub input_tokens: Option<u64>,
131 pub cache_read_input_tokens: Option<u64>,
133 pub cache_creation_input_tokens: Option<u64>,
135 pub output_tokens: Option<u64>,
137}
138
139#[derive(Debug, Error)]
144pub enum AgentError {
145 #[error("claude process exited with code {exit_code}: {stderr}")]
147 ProcessFailed {
148 exit_code: i32,
150 stderr: String,
152 },
153
154 #[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 expected: String,
159 got: String,
161 debug_messages: Vec<DebugMessage>,
166 partial_usage: Box<PartialUsage>,
170 raw_response: Option<String>,
176 },
177
178 #[error("agent budget exceeded: spent ${spent_usd:.4} of ${limit_usd:.4} limit")]
185 BudgetExceeded {
186 spent_usd: f64,
188 limit_usd: f64,
190 debug_messages: Vec<DebugMessage>,
192 partial_usage: Box<PartialUsage>,
195 },
196
197 #[error(
206 "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
207 )]
208 PromptTooLarge {
209 chars: usize,
211 estimated_tokens: usize,
213 model_limit: usize,
215 },
216
217 #[error("agent timed out after {limit:?}")]
219 Timeout {
220 limit: Duration,
222 },
223
224 #[error("rate limited by {provider}, retry after {retry_after_secs:?}s")]
226 RateLimited {
227 provider: String,
229 retry_after_secs: Option<u64>,
231 },
232
233 #[error("{provider} HTTP {status_code}: {message}")]
238 HttpProvider {
239 provider: String,
241 status_code: u16,
243 message: String,
245 },
246
247 #[error(
252 "unknown tool profile '{profile}' (registered profiles: {})",
253 list_or_none(available)
254 )]
255 UnknownToolProfile {
256 profile: String,
258 available: Vec<String>,
260 },
261
262 #[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: String,
272 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#[derive(Debug, Error)]
300pub enum DecisionError {
301 #[error("no decision answer named '{0}'")]
303 NotFound(String),
304
305 #[error("decision answer '{name}' is a {actual}, not a {expected}")]
307 TypeMismatch {
308 name: String,
310 expected: &'static str,
312 actual: &'static str,
314 },
315
316 #[error("decision answer '{name}' picked '{choice}', which is not one of its options")]
318 UnknownChoice {
319 name: String,
321 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}