car-ir 0.13.0

Agent IR types for Common Agent Runtime
Documentation
//! Agent execution outcome semantics.
//!
//! Provides typed outcomes for agent execution loops, replacing ad-hoc
//! success/failure heuristics with structured completion semantics.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// The outcome of an agent execution loop.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentOutcome {
    /// The outcome type.
    pub status: OutcomeStatus,
    /// Human-readable summary of what happened.
    pub summary: String,
    /// Evidence supporting the outcome classification.
    pub evidence: Vec<Evidence>,
    /// Metrics from the execution.
    pub metrics: OutcomeMetrics,
    /// When the outcome was determined.
    pub timestamp: DateTime<Utc>,
}

/// Outcome classification for an agent execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutcomeStatus {
    /// Task completed successfully with all goals met.
    Success,
    /// Task completed but only some goals were met.
    PartialSuccess,
    /// Agent explicitly determined it cannot complete the task.
    GiveUp,
    /// Execution exceeded time or step limits.
    Timeout,
    /// Execution failed due to errors.
    Failure,
    /// Agent explicitly signaled it is done (neutral -- may or may not have succeeded).
    Done,
}

impl OutcomeStatus {
    /// Whether this outcome represents any form of completion (not failure/timeout).
    pub fn is_completed(&self) -> bool {
        matches!(self, Self::Success | Self::PartialSuccess | Self::Done)
    }

    /// Whether this outcome represents a terminal state (no more work possible).
    pub fn is_terminal(&self) -> bool {
        true // All outcome statuses are terminal
    }
}

/// Evidence supporting an outcome classification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Evidence {
    /// What kind of evidence this is.
    pub kind: EvidenceKind,
    /// Human-readable description.
    pub description: String,
    /// Optional structured data.
    pub data: Option<Value>,
}

/// Types of evidence that can support an outcome.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceKind {
    /// Agent's own assessment of task completion.
    SelfAssessment,
    /// Tool call results that demonstrate completion.
    ToolResult,
    /// State changes that demonstrate completion.
    StateChange,
    /// External verification (e.g., test passed).
    ExternalVerification,
    /// The reason execution stopped (timeout, max steps, etc.).
    StopReason,
    /// Product-level evaluator result.
    Evaluator,
}

/// Execution metrics associated with an outcome.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OutcomeMetrics {
    /// Total turns/steps executed.
    pub turns: u32,
    /// Total tool calls made.
    pub tool_calls: u32,
    /// Wall-clock duration in milliseconds.
    pub duration_ms: f64,
    /// Number of retries/replans attempted.
    pub retries: u32,
    /// Number of actions that succeeded.
    pub actions_succeeded: u32,
    /// Number of actions that failed.
    pub actions_failed: u32,
}

impl AgentOutcome {
    /// Create a successful outcome.
    pub fn success(summary: &str) -> Self {
        Self {
            status: OutcomeStatus::Success,
            summary: summary.to_string(),
            evidence: Vec::new(),
            metrics: OutcomeMetrics::default(),
            timestamp: Utc::now(),
        }
    }

    /// Create a failure outcome.
    pub fn failure(summary: &str) -> Self {
        Self {
            status: OutcomeStatus::Failure,
            summary: summary.to_string(),
            evidence: Vec::new(),
            metrics: OutcomeMetrics::default(),
            timestamp: Utc::now(),
        }
    }

    /// Create a timeout outcome.
    pub fn timeout(summary: &str, turns: u32, max_turns: u32) -> Self {
        Self {
            status: OutcomeStatus::Timeout,
            summary: summary.to_string(),
            evidence: vec![Evidence {
                kind: EvidenceKind::StopReason,
                description: format!("Reached {} of {} max turns", turns, max_turns),
                data: Some(serde_json::json!({
                    "turns": turns,
                    "max_turns": max_turns,
                })),
            }],
            metrics: OutcomeMetrics {
                turns,
                ..Default::default()
            },
            timestamp: Utc::now(),
        }
    }

    /// Create a give-up outcome.
    pub fn give_up(reason: &str) -> Self {
        Self {
            status: OutcomeStatus::GiveUp,
            summary: reason.to_string(),
            evidence: vec![Evidence {
                kind: EvidenceKind::SelfAssessment,
                description: reason.to_string(),
                data: None,
            }],
            metrics: OutcomeMetrics::default(),
            timestamp: Utc::now(),
        }
    }

    /// Add evidence to this outcome.
    pub fn with_evidence(mut self, evidence: Evidence) -> Self {
        self.evidence.push(evidence);
        self
    }

    /// Set metrics on this outcome.
    pub fn with_metrics(mut self, metrics: OutcomeMetrics) -> Self {
        self.metrics = metrics;
        self
    }
}

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

    #[test]
    fn test_outcome_status_classification() {
        assert!(OutcomeStatus::Success.is_completed());
        assert!(OutcomeStatus::PartialSuccess.is_completed());
        assert!(OutcomeStatus::Done.is_completed());
        assert!(!OutcomeStatus::Failure.is_completed());
        assert!(!OutcomeStatus::Timeout.is_completed());
        assert!(!OutcomeStatus::GiveUp.is_completed());
    }

    #[test]
    fn test_all_statuses_are_terminal() {
        assert!(OutcomeStatus::Success.is_terminal());
        assert!(OutcomeStatus::PartialSuccess.is_terminal());
        assert!(OutcomeStatus::Done.is_terminal());
        assert!(OutcomeStatus::Failure.is_terminal());
        assert!(OutcomeStatus::Timeout.is_terminal());
        assert!(OutcomeStatus::GiveUp.is_terminal());
    }

    #[test]
    fn test_timeout_outcome() {
        let outcome = AgentOutcome::timeout("Exceeded step limit", 10, 10);
        assert_eq!(outcome.status, OutcomeStatus::Timeout);
        assert_eq!(outcome.metrics.turns, 10);
        assert_eq!(outcome.evidence.len(), 1);
        assert_eq!(outcome.evidence[0].kind, EvidenceKind::StopReason);
    }

    #[test]
    fn test_outcome_with_evidence() {
        let outcome = AgentOutcome::success("Task done")
            .with_evidence(Evidence {
                kind: EvidenceKind::ToolResult,
                description: "File created".to_string(),
                data: Some(serde_json::json!({"path": "/tmp/out.txt"})),
            })
            .with_evidence(Evidence {
                kind: EvidenceKind::ExternalVerification,
                description: "Tests passed".to_string(),
                data: None,
            });
        assert_eq!(outcome.evidence.len(), 2);
    }

    #[test]
    fn test_give_up_outcome() {
        let outcome = AgentOutcome::give_up("Cannot access required API");
        assert_eq!(outcome.status, OutcomeStatus::GiveUp);
        assert_eq!(outcome.evidence.len(), 1);
        assert_eq!(outcome.evidence[0].kind, EvidenceKind::SelfAssessment);
    }

    #[test]
    fn test_failure_outcome() {
        let outcome = AgentOutcome::failure("Connection refused");
        assert_eq!(outcome.status, OutcomeStatus::Failure);
        assert!(!outcome.status.is_completed());
    }

    #[test]
    fn test_outcome_serde_roundtrip() {
        let outcome = AgentOutcome::success("Done")
            .with_evidence(Evidence {
                kind: EvidenceKind::ToolResult,
                description: "ok".to_string(),
                data: Some(serde_json::json!(42)),
            })
            .with_metrics(OutcomeMetrics {
                turns: 5,
                tool_calls: 3,
                duration_ms: 1234.5,
                retries: 1,
                actions_succeeded: 4,
                actions_failed: 1,
            });

        let json = serde_json::to_string(&outcome).unwrap();
        let roundtripped: AgentOutcome = serde_json::from_str(&json).unwrap();

        assert_eq!(roundtripped.status, OutcomeStatus::Success);
        assert_eq!(roundtripped.summary, "Done");
        assert_eq!(roundtripped.evidence.len(), 1);
        assert_eq!(roundtripped.metrics.turns, 5);
        assert_eq!(roundtripped.metrics.tool_calls, 3);
    }

    #[test]
    fn test_outcome_status_snake_case_serde() {
        assert_eq!(
            serde_json::to_string(&OutcomeStatus::PartialSuccess).unwrap(),
            "\"partial_success\""
        );
        assert_eq!(
            serde_json::to_string(&OutcomeStatus::GiveUp).unwrap(),
            "\"give_up\""
        );
    }
}