roboticus-core 0.11.1

Shared types, config parsing, personality system, and error types for the Roboticus agent runtime
Documentation
//! Typed delegation errors — structured error reporting for subagent delegation
//! failures, replacing opaque string errors in the pipeline.

use serde::{Deserialize, Serialize};

/// Typed delegation error variants. These replace the opaque string errors
/// previously used in the delegation pipeline (e.g., "Operation not allowed").
///
/// Each variant carries enough context for:
/// 1. User-facing display (via `Display` impl)
/// 2. Machine-readable persistence (via serde, stored in `task_events.detail_json`)
/// 3. Dashboard rendering with actionable detail
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum DelegationError {
    /// The LLM provider returned an error during subagent inference.
    LlmCallFailed {
        provider: String,
        model: String,
        reason: String,
    },
    /// The target subagent is not in a runnable state.
    SubagentUnavailable { name: String, state: String },
    /// A policy rule blocked the delegation.
    PolicyDenied { rule: String, reason: String },
    /// The delegation exceeded its time budget.
    Timeout { duration_ms: u64 },
}

impl DelegationError {
    /// Serialize to a JSON string suitable for `task_events.detail_json`.
    pub fn to_detail_json(&self) -> String {
        serde_json::to_string(self)
            .unwrap_or_else(|_| format!("{{\"type\":\"Unknown\",\"message\":\"{self}\"}}"))
    }

    /// Extract a short error type label for logging and display.
    pub fn error_type(&self) -> &'static str {
        match self {
            Self::LlmCallFailed { .. } => "LlmCallFailed",
            Self::SubagentUnavailable { .. } => "SubagentUnavailable",
            Self::PolicyDenied { .. } => "PolicyDenied",
            Self::Timeout { .. } => "Timeout",
        }
    }
}

impl std::fmt::Display for DelegationError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::LlmCallFailed {
                provider,
                model,
                reason,
            } => {
                write!(f, "LLM call to {provider}/{model} failed: {reason}")
            }
            Self::SubagentUnavailable { name, state } => {
                write!(f, "Subagent '{name}' unavailable (state: {state})")
            }
            Self::PolicyDenied { rule, reason } => {
                write!(f, "Policy denied by rule '{rule}': {reason}")
            }
            Self::Timeout { duration_ms } => {
                write!(f, "Delegation timed out after {duration_ms}ms")
            }
        }
    }
}

impl std::error::Error for DelegationError {}

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

    #[test]
    fn error_variants_serialize_to_tagged_json() {
        let err = DelegationError::LlmCallFailed {
            provider: "anthropic".into(),
            model: "claude-3-opus".into(),
            reason: "rate limited".into(),
        };
        let json = serde_json::to_string(&err).unwrap();
        assert!(json.contains("\"type\":\"LlmCallFailed\""));
        assert!(json.contains("\"provider\":\"anthropic\""));
    }

    #[test]
    fn subagent_unavailable_serializes() {
        let err = DelegationError::SubagentUnavailable {
            name: "code-analyst".into(),
            state: "stopped".into(),
        };
        let json = serde_json::to_string(&err).unwrap();
        assert!(json.contains("\"type\":\"SubagentUnavailable\""));
        assert!(json.contains("\"name\":\"code-analyst\""));
    }

    #[test]
    fn policy_denied_serializes() {
        let err = DelegationError::PolicyDenied {
            rule: "max_concurrent_delegations".into(),
            reason: "limit of 4 exceeded".into(),
        };
        let json = serde_json::to_string(&err).unwrap();
        assert!(json.contains("\"type\":\"PolicyDenied\""));
    }

    #[test]
    fn timeout_serializes() {
        let err = DelegationError::Timeout { duration_ms: 30000 };
        let json = serde_json::to_string(&err).unwrap();
        assert!(json.contains("\"type\":\"Timeout\""));
        assert!(json.contains("30000"));
    }

    #[test]
    fn display_is_human_readable() {
        let err = DelegationError::LlmCallFailed {
            provider: "anthropic".into(),
            model: "claude-3-opus".into(),
            reason: "rate limited".into(),
        };
        let display = err.to_string();
        assert!(display.contains("anthropic"));
        assert!(display.contains("rate limited"));
    }

    #[test]
    fn round_trip_through_json() {
        let errors = vec![
            DelegationError::LlmCallFailed {
                provider: "openai".into(),
                model: "gpt-4".into(),
                reason: "context length exceeded".into(),
            },
            DelegationError::SubagentUnavailable {
                name: "writer".into(),
                state: "booting".into(),
            },
            DelegationError::PolicyDenied {
                rule: "restricted_tool".into(),
                reason: "tool not allowed".into(),
            },
            DelegationError::Timeout { duration_ms: 60000 },
        ];
        for err in &errors {
            let json = serde_json::to_string(err).unwrap();
            let back: DelegationError = serde_json::from_str(&json).unwrap();
            assert_eq!(err.to_string(), back.to_string());
        }
    }

    #[test]
    fn to_detail_json_produces_valid_json() {
        let err = DelegationError::Timeout { duration_ms: 5000 };
        let json_str = err.to_detail_json();
        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
        assert_eq!(parsed["type"], "Timeout");
        assert_eq!(parsed["duration_ms"], 5000);
    }
}