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,
},
#[error("App build error: missing {missing}")]
AppBuildError {
missing: &'static str,
},
#[error("{message}")]
Other {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
},
}
impl Error {
pub fn wrap<E>(message: impl Into<String>, source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::Other {
message: message.into(),
source: Some(Box::new(source)),
}
}
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 { .. }
| Self::AppBuildError { .. }
| Self::Other { .. } => false,
}
}
}
#[cfg(test)]
mod other_variant_tests {
use super::*;
use std::error::Error as StdError;
#[test]
fn other_message_surfaces_through_display() {
let e = Error::Other {
message: "quality loop: short draft".into(),
source: None,
};
assert_eq!(e.to_string(), "quality loop: short draft");
}
#[test]
fn other_preserves_source_chain() {
let inner = std::io::Error::other("disk gone");
let e = Error::Other {
message: "ledger write failed".into(),
source: Some(Box::new(inner)),
};
let src = e.source().expect("source must be preserved");
assert_eq!(src.to_string(), "disk gone");
}
#[test]
fn other_without_source_returns_none() {
let e = Error::Other {
message: "no inner".into(),
source: None,
};
assert!(e.source().is_none());
}
#[test]
fn wrap_ctor_boxes_source_and_carries_message() {
let inner = std::io::Error::other("disk gone");
let e = Error::wrap("ledger write failed", inner);
assert_eq!(e.to_string(), "ledger write failed");
let src = e.source().expect("source must be preserved by wrap()");
assert_eq!(src.to_string(), "disk gone");
}
#[test]
fn other_is_not_retryable() {
let with_source = Error::Other {
message: "x".into(),
source: Some(Box::new(std::io::Error::other("y"))),
};
let without_source = Error::Other {
message: "x".into(),
source: None,
};
assert!(!with_source.retryable());
assert!(!without_source.retryable());
}
}
#[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)]
#[non_exhaustive]
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,
#[error("cancelled")]
Cancelled,
}
impl ToolError {
pub fn retryable(&self) -> bool {
matches!(self, Self::Retryable { .. } | Self::Timeout)
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
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)]
#[non_exhaustive]
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),
#[error("unsupported operation: {0}")]
Unsupported(String),
#[error("invalid argument: {0}")]
Invalid(String),
}
impl BusError {
pub fn retryable(&self) -> bool {
matches!(
self,
Self::Connection(_) | Self::Timeout | Self::Retryable(_)
)
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
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());
}
#[test]
fn cancelled_variant_renders_stable_message() {
let e = ToolError::Cancelled;
assert_eq!(e.to_string(), "cancelled");
assert!(!e.retryable());
}
}