use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
#[error("LLM error: {0}")]
Llm(#[from] LlmError),
#[error("Tool error: {0}")]
Tool(#[from] ToolError),
#[error("Memory error: {0}")]
Memory(#[from] MemoryError),
#[error("Bus error: {0}")]
Bus(#[from] BusError),
#[error("max steps exceeded ({steps})")]
MaxStepsExceeded {
steps: u32,
},
#[error("cancelled")]
Cancelled,
#[error("config error: {0}")]
Config(#[from] ConfigError),
#[error("bad response: {0}")]
BadResponse(String),
#[error("guardrail refused: {reason}")]
Refused {
reason: String,
},
#[error("guardrail handoff to {agent}: {reason}")]
Handoff {
agent: String,
reason: String,
},
}
impl Error {
pub fn retryable(&self) -> bool {
match self {
Self::Llm(e) => e.retryable(),
Self::Tool(e) => e.retryable(),
Self::Bus(e) => e.retryable(),
Self::Memory(_)
| Self::MaxStepsExceeded { .. }
| Self::Cancelled
| Self::Config(_)
| Self::BadResponse(_)
| Self::Refused { .. }
| Self::Handoff { .. } => false,
}
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum LlmError {
#[error("network error: {0}")]
Network(String),
#[error("timeout")]
Timeout,
#[error("rate limited (retry after {retry_after_secs}s)")]
RateLimit {
retry_after_secs: u32,
},
#[error("unauthorized")]
Unauthorized,
#[error("bad request: {0}")]
BadRequest(String),
#[error("server error: {0}")]
Server(String),
#[error("decoding error: {0}")]
Decoding(String),
#[error("unsupported capability: {0}")]
Unsupported(String),
#[error("operation cancelled")]
Cancelled,
}
impl LlmError {
pub fn retryable(&self) -> bool {
matches!(
self,
Self::Network(_) | Self::Timeout | Self::RateLimit { .. } | Self::Server(_)
)
}
}
#[derive(Debug, Error)]
pub enum ToolError {
#[error("unknown tool: {0}")]
UnknownTool(String),
#[error("invalid args: {0}")]
InvalidArgs(String),
#[error("retryable: {message} (retry after {retry_after_secs}s)")]
Retryable {
message: String,
retry_after_secs: u32,
},
#[error("permanent: {0}")]
Permanent(String),
#[error("timeout")]
Timeout,
}
impl ToolError {
pub fn retryable(&self) -> bool {
matches!(self, Self::Retryable { .. } | Self::Timeout)
}
}
#[derive(Debug, Error)]
pub enum MemoryError {
#[error("store error: {0}")]
Store(String),
#[error("not found")]
NotFound,
#[error("embedding failed: {0}")]
Embedding(String),
#[error("serialization: {0}")]
Serialization(String),
}
#[derive(Debug, Error)]
pub enum BusError {
#[error("connection error: {0}")]
Connection(String),
#[error("not found: {0}")]
NotFound(String),
#[error("cas conflict (expected {expected}, got {actual})")]
CasConflict {
expected: u64,
actual: u64,
},
#[error("timeout")]
Timeout,
#[error("retryable: {0}")]
Retryable(String),
#[error("permanent: {0}")]
Permanent(String),
}
impl BusError {
pub fn retryable(&self) -> bool {
matches!(
self,
Self::Connection(_) | Self::Timeout | Self::Retryable(_)
)
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("missing required key: {0}")]
MissingKey(String),
#[error("invalid value for {key}: {reason}")]
InvalidValue {
key: String,
reason: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn llm_timeout_is_retryable() {
let e: Error = LlmError::Timeout.into();
assert!(e.retryable());
}
#[test]
fn llm_unauthorized_is_not_retryable() {
let e: Error = LlmError::Unauthorized.into();
assert!(!e.retryable());
}
#[test]
fn tool_invalid_args_is_not_retryable() {
let e: Error = ToolError::InvalidArgs("bad json".into()).into();
assert!(!e.retryable());
}
#[test]
fn config_error_is_not_retryable() {
let e: Error = ConfigError::MissingKey("x".into()).into();
assert!(!e.retryable());
}
#[test]
fn refused_is_not_retryable() {
let e = Error::Refused {
reason: "policy".into(),
};
assert!(!e.retryable());
}
#[test]
fn handoff_is_not_retryable() {
let e = Error::Handoff {
agent: "specialist".into(),
reason: "out of scope".into(),
};
assert!(!e.retryable());
}
}