Skip to main content

forge_guardrails/
error.rs

1use std::fmt;
2
3/// Base error type for the forge-guardrails framework.
4///
5/// All framework errors except `ToolResolutionError` are represented as
6/// variants of this enum, preserving catch-as-base semantics.
7#[derive(Debug, thiserror::Error)]
8pub enum ForgeError {
9    /// The model is not supported.
10    #[error(transparent)]
11    UnsupportedModel(#[from] UnsupportedModelError),
12    /// Failed to parse/construct a tool call.
13    #[error(transparent)]
14    ToolCall(#[from] ToolCallError),
15    /// Tool execution failed.
16    #[error(transparent)]
17    ToolExecution(#[from] ToolExecutionError),
18    /// Workflow cancelled.
19    #[error(transparent)]
20    WorkflowCancelled(#[from] WorkflowCancelledError),
21    /// Max iterations reached.
22    #[error(transparent)]
23    MaxIterations(#[from] MaxIterationsError),
24    /// Premature terminal tool call step violation.
25    #[error(transparent)]
26    StepEnforcement(#[from] StepEnforcementError),
27    /// Prerequisite step check failed.
28    #[error(transparent)]
29    Prerequisite(#[from] PrerequisiteError),
30    /// Context budget tokens exceeded.
31    #[error(transparent)]
32    ContextBudgetExceeded(#[from] ContextBudgetExceeded),
33    /// Hardware detection failed.
34    #[error(transparent)]
35    HardwareDetection(#[from] HardwareDetectionError),
36    /// Context length discovery failed.
37    #[error(transparent)]
38    ContextDiscovery(#[from] ContextDiscoveryError),
39    /// Budget resolution failed.
40    #[error(transparent)]
41    BudgetResolution(#[from] BudgetResolutionError),
42    /// Backend request failed.
43    #[error(transparent)]
44    Backend(#[from] BackendError),
45    /// Stream error.
46    #[error(transparent)]
47    Stream(#[from] StreamError),
48}
49
50/// Error indicating that a model is not supported because sampling defaults are missing and strict mode is active.
51#[derive(Debug, thiserror::Error)]
52pub struct UnsupportedModelError {
53    /// The name of the unsupported model.
54    pub model: String,
55}
56
57impl UnsupportedModelError {
58    /// Creates a new `UnsupportedModelError` for the given model name.
59    pub fn new(model: impl Into<String>) -> Self {
60        Self {
61            model: model.into(),
62        }
63    }
64}
65
66impl fmt::Display for UnsupportedModelError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        write!(
69            f,
70            "Unsupported model '{}'. Add sampling defaults or use non-strict mode.",
71            self.model
72        )
73    }
74}
75
76/// Error indicating a failure to parse or construct a tool call from model output.
77#[derive(Debug, thiserror::Error)]
78pub struct ToolCallError {
79    /// The error message.
80    pub message: String,
81    /// The raw response from the model, if available.
82    pub raw_response: Option<String>,
83    /// The underlying cause of the parsing/construction failure, if available.
84    pub cause: Option<String>,
85}
86
87impl ToolCallError {
88    /// Creates a new `ToolCallError` with the given message.
89    pub fn new(message: impl Into<String>) -> Self {
90        Self {
91            message: message.into(),
92            raw_response: None,
93            cause: None,
94        }
95    }
96
97    /// Sets the raw response associated with this error.
98    pub fn with_raw_response(mut self, raw: impl Into<String>) -> Self {
99        self.raw_response = Some(raw.into());
100        self
101    }
102
103    /// Sets the cause of this error.
104    pub fn with_cause(mut self, cause: impl Into<String>) -> Self {
105        self.cause = Some(cause.into());
106        self
107    }
108}
109
110impl fmt::Display for ToolCallError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(f, "{}", self.message)
113    }
114}
115
116/// Error indicating that a tool execution failed.
117#[derive(Debug, thiserror::Error)]
118pub struct ToolExecutionError {
119    /// The name of the tool whose execution failed.
120    pub tool_name: String,
121    /// The detailed cause of the execution failure.
122    pub cause: String,
123}
124
125impl ToolExecutionError {
126    /// Creates a new `ToolExecutionError` for the given tool name and cause.
127    pub fn new(tool_name: impl Into<String>, cause: impl Into<String>) -> Self {
128        Self {
129            tool_name: tool_name.into(),
130            cause: cause.into(),
131        }
132    }
133}
134
135impl fmt::Display for ToolExecutionError {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        write!(
138            f,
139            "Tool '{}' execution failed: {}",
140            self.tool_name, self.cause
141        )
142    }
143}
144
145/// A standalone error type that is NOT a subtype of ForgeError.
146/// Raised by tool callables to signal non-fatal resolution failure.
147#[derive(Debug, Clone, thiserror::Error, PartialEq)]
148pub struct ToolResolutionError {
149    /// Description of the resolution failure.
150    pub message: String,
151    /// Name of the tool, if available.
152    pub tool_name: Option<String>,
153}
154
155impl ToolResolutionError {
156    /// Creates a new `ToolResolutionError` with the given message.
157    pub fn new(message: impl Into<String>) -> Self {
158        Self {
159            message: message.into(),
160            tool_name: None,
161        }
162    }
163
164    /// Sets the tool name associated with this resolution failure.
165    pub fn with_tool_name(mut self, tool_name: impl Into<String>) -> Self {
166        self.tool_name = Some(tool_name.into());
167        self
168    }
169}
170
171impl fmt::Display for ToolResolutionError {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        write!(f, "{}", self.message)
174    }
175}
176
177/// Unified tool error returned by async tool callables.
178#[derive(Debug, Clone, thiserror::Error, PartialEq)]
179pub enum ToolError {
180    /// The tool could not be resolved or matched.
181    #[error(transparent)]
182    Resolution(#[from] ToolResolutionError),
183    /// The tool resolved but failed during execution.
184    #[error("Tool execution failed: {0}")]
185    Execution(String),
186}
187
188/// Error indicating that a workflow was cancelled.
189#[derive(Debug, thiserror::Error)]
190pub struct WorkflowCancelledError {
191    /// The conversation history messages prior to cancellation.
192    pub messages: Vec<String>,
193    /// Steps that were successfully completed before cancellation.
194    pub completed_steps: indexmap::IndexMap<String, ()>,
195    /// The workflow loop iteration count when cancellation occurred.
196    pub iteration: i64,
197}
198
199impl WorkflowCancelledError {
200    /// Creates a new `WorkflowCancelledError`.
201    pub fn new(
202        messages: Vec<String>,
203        completed_steps: indexmap::IndexMap<String, ()>,
204        iteration: i64,
205    ) -> Self {
206        Self {
207            messages,
208            completed_steps,
209            iteration,
210        }
211    }
212}
213
214impl fmt::Display for WorkflowCancelledError {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        let step_names: Vec<&str> = self.completed_steps.keys().map(|s| s.as_str()).collect();
217        write!(
218            f,
219            "Workflow cancelled at iteration {} with completed steps: [{}]",
220            self.iteration,
221            step_names.join(", ")
222        )
223    }
224}
225
226/// Error indicating that a workflow reached its maximum allowed iteration limit.
227#[derive(Debug, thiserror::Error)]
228pub struct MaxIterationsError {
229    /// The iteration limit that was reached.
230    pub iterations: i64,
231    /// Steps that were successfully completed before reaching the limit.
232    pub completed_steps: indexmap::IndexMap<String, ()>,
233    /// Steps that are still pending when execution was terminated.
234    pub pending_steps: Vec<String>,
235}
236
237impl MaxIterationsError {
238    /// Creates a new `MaxIterationsError`.
239    pub fn new(
240        iterations: i64,
241        completed_steps: indexmap::IndexMap<String, ()>,
242        pending_steps: Vec<String>,
243    ) -> Self {
244        Self {
245            iterations,
246            completed_steps,
247            pending_steps,
248        }
249    }
250}
251
252impl fmt::Display for MaxIterationsError {
253    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
254        let completed: Vec<&str> = self.completed_steps.keys().map(|s| s.as_str()).collect();
255        write!(
256            f,
257            "Max iterations ({}) reached. Completed: [{}]. Pending: [{}]",
258            self.iterations,
259            completed.join(", "),
260            self.pending_steps.join(", ")
261        )
262    }
263}
264
265/// Error indicating that a terminal tool was called prematurely before all required steps were satisfied.
266#[derive(Debug, thiserror::Error)]
267pub struct StepEnforcementError {
268    /// Name of the terminal tool that was called prematurely.
269    pub terminal_tool: String,
270    /// Number of premature attempts recorded.
271    pub attempts: i64,
272    /// The required workflow steps that remain pending.
273    pub pending_steps: Vec<String>,
274}
275
276impl StepEnforcementError {
277    /// Creates a new `StepEnforcementError`.
278    pub fn new(
279        terminal_tool: impl Into<String>,
280        attempts: i64,
281        pending_steps: Vec<String>,
282    ) -> Self {
283        Self {
284            terminal_tool: terminal_tool.into(),
285            attempts,
286            pending_steps,
287        }
288    }
289}
290
291impl fmt::Display for StepEnforcementError {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        write!(
294            f,
295            "Terminal tool '{}' called prematurely (attempt {}), pending steps: [{}]",
296            self.terminal_tool,
297            self.attempts,
298            self.pending_steps.join(", ")
299        )
300    }
301}
302
303/// Error indicating that a tool prerequisite was violated.
304#[derive(Debug, thiserror::Error)]
305pub struct PrerequisiteError {
306    /// Name of the tool whose prerequisite was violated.
307    pub tool_name: String,
308    /// Number of prerequisite violations recorded.
309    pub violations: i64,
310    /// Prerequisite step descriptions that were missing/unsatisfied.
311    pub missing_prereqs: Vec<String>,
312}
313
314impl PrerequisiteError {
315    /// Creates a new `PrerequisiteError`.
316    pub fn new(
317        tool_name: impl Into<String>,
318        violations: i64,
319        missing_prereqs: Vec<String>,
320    ) -> Self {
321        Self {
322            tool_name: tool_name.into(),
323            violations,
324            missing_prereqs,
325        }
326    }
327}
328
329impl fmt::Display for PrerequisiteError {
330    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
331        write!(
332            f,
333            "Prerequisite violation for '{}' ({} violations), missing: [{}]",
334            self.tool_name,
335            self.violations,
336            self.missing_prereqs.join(", ")
337        )
338    }
339}
340
341/// Error indicating that the context token limit has been exceeded.
342#[derive(Debug, thiserror::Error)]
343pub struct ContextBudgetExceeded {
344    /// Estimated tokens required for the request.
345    pub estimated_tokens: i64,
346    /// The allocated budget of context tokens.
347    pub budget_tokens: i64,
348}
349
350impl ContextBudgetExceeded {
351    /// Creates a new `ContextBudgetExceeded` error.
352    pub fn new(estimated_tokens: i64, budget_tokens: i64) -> Self {
353        Self {
354            estimated_tokens,
355            budget_tokens,
356        }
357    }
358}
359
360impl fmt::Display for ContextBudgetExceeded {
361    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362        write!(
363            f,
364            "Context budget exceeded: estimated {} tokens, budget {} tokens",
365            self.estimated_tokens, self.budget_tokens
366        )
367    }
368}
369
370/// Error indicating a failure to auto-detect hardware profile.
371#[derive(Debug, thiserror::Error)]
372pub struct HardwareDetectionError {
373    /// Description of why hardware detection failed.
374    pub cause: String,
375}
376
377impl HardwareDetectionError {
378    /// Creates a new `HardwareDetectionError` with the given cause.
379    pub fn new(cause: impl Into<String>) -> Self {
380        Self {
381            cause: cause.into(),
382        }
383    }
384}
385
386impl fmt::Display for HardwareDetectionError {
387    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388        write!(f, "Hardware detection failed: {}", self.cause)
389    }
390}
391
392/// Error indicating a failure to query backend context limit support.
393#[derive(Debug, thiserror::Error)]
394pub struct ContextDiscoveryError {
395    /// Description of why context length discovery failed.
396    pub cause: String,
397}
398
399impl ContextDiscoveryError {
400    /// Creates a new `ContextDiscoveryError` with the given cause.
401    pub fn new(cause: impl Into<String>) -> Self {
402        Self {
403            cause: cause.into(),
404        }
405    }
406}
407
408impl fmt::Display for ContextDiscoveryError {
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        write!(f, "Context length discovery failed: {}", self.cause)
411    }
412}
413
414/// Error indicating a failure to resolve context budget limits.
415#[derive(Debug, thiserror::Error)]
416pub struct BudgetResolutionError {
417    /// Detailed cause of the budget resolution failure, if available.
418    pub cause: Option<String>,
419}
420
421impl BudgetResolutionError {
422    /// Creates a new `BudgetResolutionError` with no cause.
423    pub fn new() -> Self {
424        Self { cause: None }
425    }
426
427    /// Sets the cause of the budget resolution failure.
428    pub fn with_cause(mut self, cause: impl Into<String>) -> Self {
429        self.cause = Some(cause.into());
430        self
431    }
432}
433
434impl Default for BudgetResolutionError {
435    fn default() -> Self {
436        Self::new()
437    }
438}
439
440impl fmt::Display for BudgetResolutionError {
441    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
442        match &self.cause {
443            Some(c) => write!(f, "Could not determine context budget: {}", c),
444            None => write!(f, "No GPU detected and no explicit budget provided"),
445        }
446    }
447}
448
449/// Error indicating that the backend request failed.
450#[derive(Debug, thiserror::Error)]
451pub enum BackendError {
452    /// A generic backend failure with status code and body description.
453    #[error("Backend error (status {status_code}): {body}")]
454    Generic {
455        /// The HTTP or API status code returned by the backend.
456        status_code: i64,
457        /// The response body containing error details.
458        body: String,
459    },
460    /// Error indicating that thinking mode is not supported by the model.
461    #[error("Thinking mode not supported for model '{model}'")]
462    ThinkingNotSupported {
463        /// The model name.
464        model: String,
465        /// The status code.
466        status_code: i64,
467        /// The body.
468        body: String,
469    },
470}
471
472impl BackendError {
473    /// Creates a new generic `BackendError`.
474    pub fn new(status_code: i64, body: impl Into<String>) -> Self {
475        Self::Generic {
476            status_code,
477            body: body.into(),
478        }
479    }
480
481    /// Returns the HTTP/API status code carried by this error (`0` for a
482    /// transport-level failure where no HTTP response was received).
483    pub fn status_code(&self) -> i64 {
484        match self {
485            Self::Generic { status_code, .. } | Self::ThinkingNotSupported { status_code, .. } => {
486                *status_code
487            }
488        }
489    }
490
491    /// Recovers the status code from a `BackendError::Generic` Display string
492    /// (`Backend error (status N): ...`).
493    ///
494    /// This is for boundaries where an upstream error has already been flattened
495    /// to text (for example a stream-start failure surfaced as a `StreamError`).
496    /// Typed call sites use [`BackendError::status_code`] instead, so this parser
497    /// is never applied to arbitrary wrapped messages.
498    pub fn status_from_display(message: &str) -> Option<i64> {
499        let marker = "Backend error (status ";
500        let start = message.find(marker)? + marker.len();
501        let rest = &message[start..];
502        let end = rest.find(')')?;
503        rest[..end].trim().parse::<i64>().ok()
504    }
505
506    /// Creates a `ThinkingNotSupported` backend error.
507    pub fn thinking_not_supported(model: impl Into<String>) -> Self {
508        Self::ThinkingNotSupported {
509            model: model.into(),
510            status_code: 400,
511            body: String::new(),
512        }
513    }
514}
515
516/// ThinkingNotSupportedError is an alias that constructs the ThinkingNotSupported
517/// variant of BackendError. This preserves the catch-as-BackendError semantics.
518pub type ThinkingNotSupportedError = BackendError;
519
520/// Error indicating that stream processing failed.
521#[derive(Debug, thiserror::Error)]
522pub struct StreamError {
523    /// Description of the stream failure.
524    pub message: String,
525}
526
527impl StreamError {
528    /// Creates a new `StreamError`.
529    pub fn new(message: impl Into<String>) -> Self {
530        Self {
531            message: message.into(),
532        }
533    }
534}
535
536impl Default for StreamError {
537    fn default() -> Self {
538        Self::new("Stream ended without a final chunk")
539    }
540}
541
542impl fmt::Display for StreamError {
543    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
544        write!(f, "{}", self.message)
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn status_code_reads_both_variants() {
554        assert_eq!(BackendError::new(429, "x").status_code(), 429);
555        assert_eq!(BackendError::new(0, "x").status_code(), 0);
556        assert_eq!(BackendError::thinking_not_supported("m").status_code(), 400);
557    }
558
559    #[test]
560    fn status_from_display_recovers_marker() {
561        assert_eq!(
562            BackendError::status_from_display(
563                "Backend error (status 429): {\"error\":\"rate limited\"}"
564            ),
565            Some(429)
566        );
567        assert_eq!(
568            BackendError::status_from_display("Backend error (status 503): boom"),
569            Some(503)
570        );
571        // The round-trip is exact: building the Display and parsing it back.
572        let display = BackendError::new(504, "gateway timeout").to_string();
573        assert_eq!(BackendError::status_from_display(&display), Some(504));
574    }
575
576    #[test]
577    fn status_from_display_ignores_unmarked_messages() {
578        assert_eq!(
579            BackendError::status_from_display("model failed guarded tool-call validation"),
580            None
581        );
582        assert_eq!(
583            BackendError::status_from_display("some other failure"),
584            None
585        );
586    }
587}