roma-core 0.1.0

Core types, session, errors, and utilities for Roma Agent
Documentation
//! Structured error taxonomy for Roma Agent.
//!
//! Inspired by Hermes-Agent's `ClassifiedError`, each variant carries enough
//! context to decide a recovery strategy via [`ClassifiedError::recovery_hint`].

use std::time::Duration;

/// Classified error with recovery semantics.
#[derive(Debug, Clone, thiserror::Error)]
pub enum ClassifiedError {
    /// Provider returned 429 / quota exceeded.
    #[error("rate limited by {provider}, retry after {retry_after:?}")]
    RateLimited {
        provider: String,
        retry_after: Option<Duration>,
    },

    /// Provider is unreachable, 5xx, or degraded.
    #[error("provider unavailable: {provider} - {reason}")]
    ProviderUnavailable { provider: String, reason: String },

    /// Credential rejected (401/403).
    #[error("auth failed: {provider}")]
    AuthFailed { provider: String },

    /// Request size exceeds model context window.
    #[error("context overflow: {tokens} tokens")]
    ContextOverflow { tokens: u32 },

    /// Response truncated at `max_tokens`.
    #[error("output truncated at max_tokens")]
    OutputTruncated,

    /// SSE stream produced no chunk for the configured idle window.
    #[error("stream timeout: no chunk for {0:?}")]
    StreamTimeout(Duration),

    /// Tool reported a domain error (non-fatal).
    #[error("tool error: {tool} - {message}")]
    ToolError { tool: String, message: String },

    /// Referenced tool name is not registered.
    #[error("tool not found: {0}")]
    ToolNotFound(String),

    /// Iteration budget exhausted (including grace call).
    #[error("budget exhausted after {0} turns")]
    BudgetExhausted(u32),

    /// Filesystem or OS error.
    #[error("io: {0}")]
    Io(String),

    /// HTTP transport error.
    #[error("http: {0}")]
    Http(String),

    /// JSON or protocol parsing error.
    #[error("parse: {0}")]
    Parse(String),

    /// Configuration or setup problem.
    #[error("config: {0}")]
    Config(String),

    /// Session or conversation state problem.
    #[error("session: {0}")]
    Session(String),

    /// Storage / persistence problem (file I/O, L3 write).
    #[error("storage: {0}")]
    Storage(String),

    /// Runtime / agent loop problem.
    #[error("runtime: {0}")]
    Runtime(String),

    /// Invariant violation inside Roma itself.
    #[error("internal: {0}")]
    Internal(String),
}

impl ClassifiedError {
    /// Return the variant name as a static string.
    #[must_use]
    pub fn variant_name(&self) -> &'static str {
        match self {
            Self::RateLimited { .. } => "RateLimited",
            Self::ProviderUnavailable { .. } => "ProviderUnavailable",
            Self::AuthFailed { .. } => "AuthFailed",
            Self::ContextOverflow { .. } => "ContextOverflow",
            Self::OutputTruncated => "OutputTruncated",
            Self::StreamTimeout(_) => "StreamTimeout",
            Self::ToolError { .. } => "ToolError",
            Self::ToolNotFound(_) => "ToolNotFound",
            Self::BudgetExhausted(_) => "BudgetExhausted",
            Self::Io(_) => "Io",
            Self::Http(_) => "Http",
            Self::Parse(_) => "Parse",
            Self::Config(_) => "Config",
            Self::Session(_) => "Session",
            Self::Storage(_) => "Storage",
            Self::Runtime(_) => "Runtime",
            Self::Internal(_) => "Internal",
        }
    }

    /// Reconstruct an error from its serialized parts.
    ///
    /// `retry_after_secs` is only used for the `RateLimited` variant.
    #[must_use]
    pub fn from_tagged(kind: &str, msg: &str, retry_after_secs: Option<u64>) -> Self {
        match kind {
            "RateLimited" => Self::RateLimited {
                provider: "unknown".into(),
                retry_after: retry_after_secs.map(Duration::from_secs),
            },
            "ProviderUnavailable" => Self::ProviderUnavailable {
                provider: "unknown".into(),
                reason: msg.into(),
            },
            "AuthFailed" => Self::AuthFailed {
                provider: "unknown".into(),
            },
            "ContextOverflow" => Self::ContextOverflow { tokens: 0 },
            "OutputTruncated" => Self::OutputTruncated,
            "StreamTimeout" => Self::StreamTimeout(Duration::from_secs(0)),
            "ToolError" => Self::ToolError {
                tool: "unknown".into(),
                message: msg.into(),
            },
            "ToolNotFound" => Self::ToolNotFound(msg.into()),
            "BudgetExhausted" => Self::BudgetExhausted(0),
            "Io" => Self::Io(msg.into()),
            "Http" => Self::Http(msg.into()),
            "Parse" => Self::Parse(msg.into()),
            "Config" => Self::Config(msg.into()),
            "Session" => Self::Session(msg.into()),
            "Storage" => Self::Storage(msg.into()),
            "Runtime" => Self::Runtime(msg.into()),
            "Internal" => Self::Internal(msg.into()),
            _ => Self::Runtime(format!("{kind}: {msg}")),
        }
    }

    /// Whether the error is worth retrying (same or different provider).
    #[must_use]
    pub fn is_retryable(&self) -> bool {
        matches!(
            self,
            Self::RateLimited { .. }
                | Self::ProviderUnavailable { .. }
                | Self::StreamTimeout(_)
                | Self::Http(_)
        )
    }

    /// Suggested recovery action.
    #[must_use]
    pub fn recovery_hint(&self) -> RecoveryAction {
        match self {
            Self::RateLimited { retry_after, .. } => {
                RecoveryAction::RetryAfter(retry_after.unwrap_or_else(|| Duration::from_secs(5)))
            }
            Self::ProviderUnavailable { .. } | Self::StreamTimeout(_) | Self::Http(_) => {
                RecoveryAction::TryNextProvider
            }
            Self::Io(_) => RecoveryAction::Fail,
            Self::AuthFailed { .. } => RecoveryAction::TryNextCredential,
            Self::ContextOverflow { .. } => RecoveryAction::CompressHistory,
            Self::OutputTruncated => RecoveryAction::IncreaseMaxTokens,
            Self::BudgetExhausted(_) => RecoveryAction::RequestMoreBudget,
            Self::ToolError { .. }
            | Self::ToolNotFound(_)
            | Self::Parse(_)
            | Self::Config(_)
            | Self::Session(_)
            | Self::Storage(_)
            | Self::Runtime(_)
            | Self::Internal(_) => RecoveryAction::Fail,
        }
    }
}

impl From<std::io::Error> for ClassifiedError {
    fn from(err: std::io::Error) -> Self {
        Self::Io(err.to_string())
    }
}

/// Recommended recovery strategy for a classified error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecoveryAction {
    /// Wait the given duration and retry the same provider+credential.
    RetryAfter(Duration),
    /// Rotate to the next credential within the same provider.
    TryNextCredential,
    /// Fail over to the next provider in the chain.
    TryNextProvider,
    /// Compress chat history and retry.
    CompressHistory,
    /// Raise `max_tokens` and retry.
    IncreaseMaxTokens,
    /// Ask the orchestrator for a budget extension.
    RequestMoreBudget,
    /// Surface the error to the caller.
    Fail,
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn rate_limited_is_retryable_with_retry_after() {
        let err = ClassifiedError::RateLimited {
            provider: "openai".into(),
            retry_after: Some(Duration::from_secs(3)),
        };
        assert!(err.is_retryable());
        assert_eq!(
            err.recovery_hint(),
            RecoveryAction::RetryAfter(Duration::from_secs(3))
        );
    }

    #[test]
    fn rate_limited_defaults_to_5s_without_retry_after() {
        let err = ClassifiedError::RateLimited {
            provider: "openai".into(),
            retry_after: None,
        };
        assert_eq!(
            err.recovery_hint(),
            RecoveryAction::RetryAfter(Duration::from_secs(5))
        );
    }

    #[test]
    fn auth_failed_rotates_credential() {
        let err = ClassifiedError::AuthFailed {
            provider: "claude".into(),
        };
        assert!(!err.is_retryable());
        assert_eq!(err.recovery_hint(), RecoveryAction::TryNextCredential);
    }

    #[test]
    fn provider_unavailable_falls_over() {
        let err = ClassifiedError::ProviderUnavailable {
            provider: "x".into(),
            reason: "connection reset".into(),
        };
        assert!(err.is_retryable());
        assert_eq!(err.recovery_hint(), RecoveryAction::TryNextProvider);
    }

    #[test]
    fn context_overflow_compresses_history() {
        let err = ClassifiedError::ContextOverflow { tokens: 40_000 };
        assert!(!err.is_retryable());
        assert_eq!(err.recovery_hint(), RecoveryAction::CompressHistory);
    }

    #[test]
    fn tool_errors_are_fatal_to_the_call_site() {
        let err = ClassifiedError::ToolError {
            tool: "file_read".into(),
            message: "permission denied".into(),
        };
        assert!(!err.is_retryable());
        assert_eq!(err.recovery_hint(), RecoveryAction::Fail);
    }

    #[test]
    fn stream_timeout_falls_over() {
        let err = ClassifiedError::StreamTimeout(Duration::from_secs(90));
        assert!(err.is_retryable());
        assert_eq!(err.recovery_hint(), RecoveryAction::TryNextProvider);
    }

    #[test]
    fn budget_exhausted_requests_more() {
        let err = ClassifiedError::BudgetExhausted(100);
        assert_eq!(err.recovery_hint(), RecoveryAction::RequestMoreBudget);
    }
}