atman-runtime 1.8.0

atman flow execution runtime: evaluator, tool dispatch, provider dispatch, executor, memory stores
Documentation
use thiserror::Error;

#[derive(Debug, Clone, Error)]
pub enum RuntimeError {
    #[error("undefined variable: {0}")]
    UndefinedVar(String),

    #[error("undefined tool: {0}")]
    UndefinedTool(String),

    #[error("type mismatch: expected {expected}, got {actual}")]
    TypeMismatch { expected: String, actual: String },

    #[error("missing argument: {0}")]
    MissingArg(String),

    #[error("tool failed: {0}")]
    ToolFailed(String),

    #[error("cancelled: {0}")]
    Cancelled(String),

    #[error("aborted: {0}")]
    Aborted(String),

    #[error("redirect to flow `{0}`")]
    Redirect(String),

    #[error("l2 restart: {correction_text}")]
    L2Restart {
        correction_text: String,
        partial_output: String,
        partial_tokens: u64,
    },

    #[error("attachment error: {reason}")]
    AttachmentError { reason: String },

    #[error("thinking signature missing")]
    ThinkingSignatureMissing,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorKind {
    Transient,
    Timeout,
    RateLimit,
    AuthFailed,
    ContentFilter,
    InvalidRequest,
    ProviderDown,
    ToolError,
    TypeMismatch,
    MissingArg,
    Cancelled,
    UserError,
    Internal,
}

impl ErrorKind {
    pub fn as_str(&self) -> &'static str {
        match self {
            ErrorKind::Transient => "transient",
            ErrorKind::Timeout => "timeout",
            ErrorKind::RateLimit => "rate_limit",
            ErrorKind::AuthFailed => "auth_failed",
            ErrorKind::ContentFilter => "content_filter",
            ErrorKind::InvalidRequest => "invalid_request",
            ErrorKind::ProviderDown => "provider_down",
            ErrorKind::ToolError => "tool_error",
            ErrorKind::TypeMismatch => "type_mismatch",
            ErrorKind::MissingArg => "missing_arg",
            ErrorKind::Cancelled => "cancelled",
            ErrorKind::UserError => "user_error",
            ErrorKind::Internal => "internal",
        }
    }

    pub fn from_name(name: &str) -> Option<Self> {
        Some(match name {
            "transient" => ErrorKind::Transient,
            "timeout" => ErrorKind::Timeout,
            "rate_limit" => ErrorKind::RateLimit,
            "auth_failed" => ErrorKind::AuthFailed,
            "content_filter" => ErrorKind::ContentFilter,
            "invalid_request" => ErrorKind::InvalidRequest,
            "provider_down" => ErrorKind::ProviderDown,
            "tool_error" => ErrorKind::ToolError,
            "type_mismatch" => ErrorKind::TypeMismatch,
            "missing_arg" => ErrorKind::MissingArg,
            "cancelled" => ErrorKind::Cancelled,
            "user_error" => ErrorKind::UserError,
            "internal" => ErrorKind::Internal,
            _ => return None,
        })
    }
}

impl RuntimeError {
    pub fn kind(&self) -> ErrorKind {
        match self {
            RuntimeError::UndefinedVar(_) => ErrorKind::InvalidRequest,
            RuntimeError::UndefinedTool(_) => ErrorKind::InvalidRequest,
            RuntimeError::TypeMismatch { .. } => ErrorKind::TypeMismatch,
            RuntimeError::MissingArg(_) => ErrorKind::MissingArg,
            RuntimeError::Cancelled(_) => ErrorKind::Cancelled,
            RuntimeError::Aborted(_) => ErrorKind::UserError,
            RuntimeError::Redirect(_) => ErrorKind::Cancelled,
            RuntimeError::L2Restart { .. } => ErrorKind::UserError,
            RuntimeError::ToolFailed(msg) => classify_tool_failed(msg),
            RuntimeError::AttachmentError { .. } => ErrorKind::InvalidRequest,
            RuntimeError::ThinkingSignatureMissing => ErrorKind::InvalidRequest,
        }
    }
}

fn classify_tool_failed(msg: &str) -> ErrorKind {
    let m = msg.to_ascii_lowercase();
    if m.contains("timeout") || m.contains("timed out") {
        return ErrorKind::Timeout;
    }
    if m.contains("429") || m.contains("rate limit") || m.contains("rate-limit") {
        return ErrorKind::RateLimit;
    }
    if m.contains(" 401")
        || m.contains(" 403")
        || m.contains("unauthorized")
        || m.contains("forbidden")
    {
        return ErrorKind::AuthFailed;
    }
    if m.contains("content_filter")
        || m.contains("content filter")
        || m.contains("safety")
        || m.contains("policy violation")
        || m.contains("policy_violation")
    {
        return ErrorKind::ContentFilter;
    }
    if m.contains(" 500")
        || m.contains(" 502")
        || m.contains(" 503")
        || m.contains(" 504")
        || m.contains("upstream")
        || m.contains("bad gateway")
        || m.contains("service unavailable")
    {
        return ErrorKind::ProviderDown;
    }
    if m.contains("network")
        || m.contains("connection")
        || m.contains("connect ")
        || m.contains("reset by peer")
    {
        return ErrorKind::Transient;
    }
    if m.contains(" 400") || m.contains("bad request") || m.contains("invalid request") {
        return ErrorKind::InvalidRequest;
    }
    ErrorKind::ToolError
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classify_covers_common_provider_error_strings() {
        let cases: &[(&str, ErrorKind)] = &[
            ("openai net: request timed out", ErrorKind::Timeout),
            ("anthropic: 429 rate limit exceeded", ErrorKind::RateLimit),
            ("openai http 401: unauthorized", ErrorKind::AuthFailed),
            (
                "anthropic http 400: content_filter block",
                ErrorKind::ContentFilter,
            ),
            ("openai http 502 Bad Gateway", ErrorKind::ProviderDown),
            ("hyper: connection reset by peer", ErrorKind::Transient),
            (
                "openai http 400: bad request schema",
                ErrorKind::InvalidRequest,
            ),
            ("fs.read: no such file", ErrorKind::ToolError),
        ];
        for (msg, expected) in cases {
            let err = RuntimeError::ToolFailed(msg.to_string());
            assert_eq!(err.kind(), *expected, "input: {msg}");
        }
    }

    #[test]
    fn structural_variants_map_to_semantic_kinds() {
        assert_eq!(
            RuntimeError::TypeMismatch {
                expected: "int".into(),
                actual: "string".into()
            }
            .kind(),
            ErrorKind::TypeMismatch
        );
        assert_eq!(
            RuntimeError::MissingArg("model".into()).kind(),
            ErrorKind::MissingArg
        );
        assert_eq!(
            RuntimeError::Cancelled("user hit ctrl-c".into()).kind(),
            ErrorKind::Cancelled
        );
        assert_eq!(
            RuntimeError::Aborted("watch tripped".into()).kind(),
            ErrorKind::UserError
        );
        assert_eq!(
            RuntimeError::UndefinedTool("fs.nope".into()).kind(),
            ErrorKind::InvalidRequest
        );
    }

    #[test]
    fn error_kind_round_trips_through_names() {
        for k in [
            ErrorKind::Transient,
            ErrorKind::Timeout,
            ErrorKind::RateLimit,
            ErrorKind::AuthFailed,
            ErrorKind::ContentFilter,
            ErrorKind::InvalidRequest,
            ErrorKind::ProviderDown,
            ErrorKind::ToolError,
            ErrorKind::TypeMismatch,
            ErrorKind::MissingArg,
            ErrorKind::Cancelled,
            ErrorKind::UserError,
            ErrorKind::Internal,
        ] {
            assert_eq!(ErrorKind::from_name(k.as_str()), Some(k));
        }
        assert_eq!(ErrorKind::from_name("nope"), None);
    }
}