use crate::brain::provider::ProviderError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AgentError {
#[error("Provider error: {0}")]
Provider(#[from] ProviderError),
#[error("Database error: {0}")]
Database(String),
#[error("Session not found: {0}")]
SessionNotFound(uuid::Uuid),
#[error("Invalid request: {0}")]
InvalidRequest(String),
#[error("Context too large: {current} tokens exceeds limit of {limit}")]
ContextTooLarge { current: usize, limit: usize },
#[error("Tool execution error: {0}")]
ToolError(String),
#[error("Tool not found: {0}")]
ToolNotFound(String),
#[error("Maximum tool iterations exceeded: {0}")]
MaxIterationsExceeded(usize),
#[error("Cancelled")]
Cancelled,
#[error("Internal error: {0}")]
Internal(String),
}
impl AgentError {
pub fn db(e: anyhow::Error) -> Self {
AgentError::Database(format!("{:#}", e))
}
}
pub type Result<T> = std::result::Result<T, AgentError>;
pub fn format_user_error(err: &AgentError) -> String {
let raw = err.to_string();
if raw.contains("error decoding response body") {
return "Provider stream broke mid-response (connection dropped). \
Self-heal already retried with no luck. Try again or \
switch to a different model via `/models`."
.to_string();
}
if raw.contains("Repetition detected") {
return "I stopped the turn: I kept announcing the same action \
without actually running it, and the loop detector ended \
it rather than let it spin. This is usually the model \
struggling to emit a tool call it has described — \
rephrasing the request, or switching models via `/models`, \
normally clears it."
.to_string();
}
if let AgentError::ContextTooLarge { current, limit } = err {
return format!(
"Context too large: {current} tokens exceeds the {limit}-token \
limit. Run `/compact` to shrink the conversation, or start \
a fresh session."
);
}
if let Some(status) = extract_http_status(&raw) {
match status {
502..=504 => {
return format!(
"All fallback providers returned {status} within the \
retry window (likely shared upstream gateway outage). \
Self-heal already tried 4 fallbacks. Wait a minute \
and retry, or switch provider via `/models`."
);
}
429 => {
return "Rate limit hit on the active provider. Wait a \
minute or switch provider via `/models`."
.to_string();
}
401 | 403 => {
return format!(
"Authentication failed on the active provider \
({status}). Check your API key in `/onboard:provider` \
or `keys.toml`."
);
}
_ => {
if let Some(idx) = raw.find("All providers in the fallback chain failed") {
let summary: String = raw[idx..].chars().take(600).collect();
return format!("Provider returned HTTP {status}. {summary}");
}
let display_raw: String = if raw.chars().count() > 200 {
format!("{}...", raw.chars().take(200).collect::<String>())
} else {
raw.clone()
};
let advice = if (400u16..500).contains(&status) {
"Retrying won't help — the request is invalid for this \
provider. Switch provider via `/models`, or set up a \
fallback chain ([providers.fallback] in config.toml) so \
OpenCrabs fails over automatically."
} else {
"Try again, or switch provider via `/models`."
};
return format!("Provider returned HTTP {status}. {advice} Details: {display_raw}");
}
}
}
raw
}
fn extract_http_status(s: &str) -> Option<u16> {
let lower = s.to_lowercase();
for prefix in &["api error (", "http "] {
if let Some(idx) = lower.find(prefix) {
let tail = &s[idx + prefix.len()..];
let num: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect();
if !num.is_empty()
&& let Ok(n) = num.parse::<u16>()
&& (100..=599).contains(&n)
{
return Some(n);
}
}
}
None
}