use std::fmt;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamTimeoutPhase {
Bootstrap,
TransportIdle,
FirstSemantic,
SemanticIdle,
}
impl fmt::Display for StreamTimeoutPhase {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Bootstrap => "bootstrap",
Self::TransportIdle => "transport_idle",
Self::FirstSemantic => "first_semantic",
Self::SemanticIdle => "semantic_idle",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamTimeoutError {
phase: StreamTimeoutPhase,
deadline: Duration,
provider: Option<String>,
model: Option<String>,
last_transport: Duration,
last_semantic: Option<Duration>,
turn_retry_eligible: bool,
}
impl StreamTimeoutError {
pub fn new(
phase: StreamTimeoutPhase,
deadline: Duration,
provider: Option<String>,
model: Option<String>,
last_transport: Duration,
last_semantic: Option<Duration>,
turn_retry_eligible: bool,
) -> Self {
Self {
phase,
deadline,
provider,
model,
last_transport,
last_semantic,
turn_retry_eligible,
}
}
pub fn phase(&self) -> StreamTimeoutPhase {
self.phase
}
pub fn retry_safe(&self) -> bool {
self.turn_retry_eligible && self.last_semantic.is_none()
}
pub fn semantic_output_started(&self) -> bool {
self.last_semantic.is_some()
}
}
impl fmt::Display for StreamTimeoutError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let last_semantic = self
.last_semantic
.map(|duration| format!("{}ms", duration.as_millis()))
.unwrap_or_else(|| "never".to_string());
write!(
formatter,
"phase={}, deadline_ms={}, provider={}, model={}, last_transport_ms_ago={}, \
last_semantic_ms_ago={}, semantic_output_started={}, retry_safe={}",
self.phase,
self.deadline.as_millis(),
self.provider.as_deref().unwrap_or("unknown"),
self.model.as_deref().unwrap_or("unknown"),
self.last_transport.as_millis(),
last_semantic,
self.semantic_output_started(),
self.retry_safe(),
)
}
}
#[derive(Error, Debug)]
pub enum AgentError {
#[error("Session not found: {0}")]
SessionNotFound(String),
#[error("LLM error: {0}")]
LLM(String),
#[error("Empty assistant response from LLM (response_id={response_id:?})")]
EmptyAssistantResponse {
response_id: Option<String>,
},
#[error("LLM overflow: {0}")]
LLMOverflow(String),
#[error("Stream timed out: {0}")]
StreamTimeout(StreamTimeoutError),
#[error("Tool error: {0}")]
Tool(String),
#[error("Project context error: {0}")]
ProjectContext(String),
#[error("Hook suspended: {0}")]
HookSuspended(String),
#[error("Budget error: {0}")]
Budget(String),
#[error("Cancelled")]
Cancelled,
#[error("Worker unresponsive: {0}")]
WorkerUnresponsive(String),
}
impl AgentError {
pub fn is_cancelled(&self) -> bool {
matches!(self, AgentError::Cancelled)
}
pub fn is_hook_suspended(&self) -> bool {
matches!(self, AgentError::HookSuspended(_))
}
}
#[cfg(test)]
mod tests {
use super::{AgentError, StreamTimeoutError, StreamTimeoutPhase};
use std::time::Duration;
#[test]
fn empty_assistant_response_is_typed_and_has_secret_free_diagnostics() {
let with_id = AgentError::EmptyAssistantResponse {
response_id: Some("resp_740".to_string()),
};
assert!(matches!(
&with_id,
AgentError::EmptyAssistantResponse {
response_id: Some(response_id)
} if response_id == "resp_740"
));
assert_eq!(
with_id.to_string(),
"Empty assistant response from LLM (response_id=Some(\"resp_740\"))"
);
let without_id = AgentError::EmptyAssistantResponse { response_id: None };
assert_eq!(
without_id.to_string(),
"Empty assistant response from LLM (response_id=None)"
);
}
#[test]
fn stream_timeout_retry_safety_is_structured_not_message_parsed() {
let timeout = StreamTimeoutError::new(
StreamTimeoutPhase::Bootstrap,
Duration::from_secs(120),
Some("provider-id".to_string()),
Some("model-id".to_string()),
Duration::from_secs(120),
None,
true,
);
assert_eq!(timeout.phase(), StreamTimeoutPhase::Bootstrap);
assert!(timeout.retry_safe());
assert!(!timeout.semantic_output_started());
assert_eq!(
AgentError::StreamTimeout(timeout).to_string(),
"Stream timed out: phase=bootstrap, deadline_ms=120000, provider=provider-id, \
model=model-id, last_transport_ms_ago=120000, last_semantic_ms_ago=never, \
semantic_output_started=false, retry_safe=true"
);
}
}