Skip to main content

greentic_aw_runtime/
error.rs

1//! All error and termination types used by the AW runtime.
2//!
3//! IMPORTANT: external surfaces (runner, designer) MUST render
4//! end-user-facing replies via [`AgentError::user_facing_message`].
5//! Raw `Display` of `AgentError` is for internal logs only.
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10use crate::config::AgentConfig;
11
12#[derive(Debug, Error)]
13pub enum AgentError {
14    #[error("agent state load failed: {0}")]
15    StateLoad(#[from] StateError),
16
17    #[error("llm provider unavailable")]
18    LlmProviderUnavailable,
19
20    #[error("llm error: {0}")]
21    Llm(#[from] LlmError),
22
23    #[error("config error: {0}")]
24    Config(#[from] ConfigError),
25
26    #[error("tool dispatch error: {0}")]
27    ToolDispatch(String),
28
29    #[error("daily token budget exceeded")]
30    TokenBudgetExceeded,
31
32    #[error("credit budget exceeded")]
33    CreditBudgetExceeded,
34
35    #[error("session lock could not be acquired within wait window")]
36    LockTimeout,
37
38    #[error("loop exceeded max iterations")]
39    MaxIterations,
40
41    #[error("step timed out")]
42    Timeout,
43
44    #[error("internal: {0}")]
45    Internal(String),
46
47    #[error("guardrail denied ({direction}): {message}")]
48    GuardrailDenied {
49        direction: crate::guardrail::GuardrailDirection,
50        code: String,
51        message: String,
52        details: Option<String>,
53    },
54}
55
56impl AgentError {
57    /// Returns a sanitised, end-user-appropriate string. No Rust error
58    /// chain, no internal detail, no PII leakage. Tenants can override
59    /// the LLM-unavailability + budget messages via `AgentLimits`.
60    pub fn user_facing_message(&self, config: &AgentConfig) -> String {
61        match self {
62            Self::LlmProviderUnavailable | Self::Llm(_) => config
63                .limits
64                .provider_failure_message
65                .clone()
66                .unwrap_or_else(|| {
67                    "I'm having trouble reaching my reasoning system. \
68                     Please try again in a moment."
69                        .into()
70                }),
71            Self::TokenBudgetExceeded => "Daily usage limit reached. \
72                 Please try again tomorrow or contact your administrator."
73                .to_string(),
74            Self::CreditBudgetExceeded => "Your account has no remaining credits. \
75                 Please top up your balance or contact your administrator."
76                .to_string(),
77            Self::Timeout => {
78                "I'm taking longer than expected — please try a simpler request.".to_string()
79            }
80            Self::MaxIterations => "I wasn't able to finish reasoning about that. \
81                 Could you rephrase or break it into smaller steps?"
82                .to_string(),
83            _ => "Something went wrong. Please try again.".to_string(),
84        }
85    }
86}
87
88#[derive(Debug, Error)]
89pub enum StateError {
90    #[error("redis error: {0}")]
91    Redis(String),
92    #[error("schema version {found} not supported (max supported: {supported})")]
93    SchemaIncompatible { found: u32, supported: u32 },
94    #[error("decode error: {0}")]
95    Decode(String),
96    #[error("lock acquisition timed out after {0:?}")]
97    LockTimeout(std::time::Duration),
98}
99
100#[derive(Debug, Error)]
101pub enum LlmError {
102    #[error("provider returned 5xx after retries")]
103    ServiceUnavailable,
104    #[error("provider returned 4xx: {0}")]
105    BadRequest(String),
106    #[error("transport: {0}")]
107    Transport(String),
108    #[error("decode: {0}")]
109    Decode(String),
110}
111
112#[derive(Debug, Error)]
113pub enum MemoryError {
114    #[error("memory provider unavailable: {0}")]
115    Backend(String),
116    #[error("memory provider not configured")]
117    NotConfigured,
118}
119
120#[derive(Debug, Error)]
121pub enum ConfigError {
122    #[error("agent_id {0} not found for tenant")]
123    AgentNotFound(String),
124    #[error("provider misconfigured: {0}")]
125    Misconfigured(String),
126    #[error("internal: {0}")]
127    Internal(String),
128}
129
130/// Reason the Plan-Act-Observe loop exited. Surfaced via
131/// [`crate::AgentOutput::terminated_by`] and as an OTel attribute on
132/// the `aw.step` span.
133#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum TerminationReason {
136    FinalReply,
137    MaxIterations,
138    Timeout,
139    Error,
140    TokenBudgetExceeded,
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::config::{AgentConfig, AgentLimits, LlmProviderRef};
147
148    fn config_with(message: Option<&str>) -> AgentConfig {
149        AgentConfig {
150            agent_id: "a".into(),
151            system_prompt: "".into(),
152            tools: vec![],
153            guardrails: vec![],
154            llm: LlmProviderRef {
155                provider: "openai".into(),
156                model: "gpt-4".into(),
157                credential_ref: None,
158            },
159            limits: AgentLimits {
160                provider_failure_message: message.map(str::to_string),
161                ..AgentLimits::default()
162            },
163            memory: None,
164            knowledge: None,
165        }
166    }
167
168    #[test]
169    fn user_facing_message_defaults_for_provider_unavailable() {
170        let cfg = config_with(None);
171        let msg = AgentError::LlmProviderUnavailable.user_facing_message(&cfg);
172        assert!(msg.contains("reasoning system"));
173        assert!(msg.contains("try again"));
174    }
175
176    #[test]
177    fn user_facing_message_uses_tenant_override_when_set() {
178        let cfg = config_with(Some("Please retry in 5 minutes."));
179        let msg = AgentError::LlmProviderUnavailable.user_facing_message(&cfg);
180        assert_eq!(msg, "Please retry in 5 minutes.");
181    }
182
183    #[test]
184    fn user_facing_message_never_leaks_internal_detail() {
185        let cfg = config_with(None);
186        let leaky = AgentError::Internal("DATABASE_HOST=192.168.1.5".into());
187        let msg = leaky.user_facing_message(&cfg);
188        assert!(!msg.contains("DATABASE_HOST"));
189        assert!(!msg.contains("192.168"));
190    }
191
192    #[test]
193    fn user_facing_message_budget_distinct_from_default() {
194        let cfg = config_with(None);
195        let budget = AgentError::TokenBudgetExceeded.user_facing_message(&cfg);
196        assert!(budget.contains("limit"));
197        assert_ne!(
198            budget,
199            AgentError::Internal("x".into()).user_facing_message(&cfg)
200        );
201    }
202}