use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentOutcome {
pub status: OutcomeStatus,
pub summary: String,
pub evidence: Vec<Evidence>,
pub metrics: OutcomeMetrics,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutcomeStatus {
Success,
PartialSuccess,
GiveUp,
Timeout,
Failure,
Done,
}
impl OutcomeStatus {
pub fn is_completed(&self) -> bool {
matches!(self, Self::Success | Self::PartialSuccess | Self::Done)
}
pub fn is_terminal(&self) -> bool {
true }
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Evidence {
pub kind: EvidenceKind,
pub description: String,
pub data: Option<Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceKind {
SelfAssessment,
ToolResult,
StateChange,
ExternalVerification,
StopReason,
Evaluator,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OutcomeMetrics {
pub turns: u32,
pub tool_calls: u32,
pub duration_ms: f64,
pub retries: u32,
pub actions_succeeded: u32,
pub actions_failed: u32,
}
impl AgentOutcome {
pub fn success(summary: &str) -> Self {
Self {
status: OutcomeStatus::Success,
summary: summary.to_string(),
evidence: Vec::new(),
metrics: OutcomeMetrics::default(),
timestamp: Utc::now(),
}
}
pub fn failure(summary: &str) -> Self {
Self {
status: OutcomeStatus::Failure,
summary: summary.to_string(),
evidence: Vec::new(),
metrics: OutcomeMetrics::default(),
timestamp: Utc::now(),
}
}
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(),
}
}
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(),
}
}
pub fn with_evidence(mut self, evidence: Evidence) -> Self {
self.evidence.push(evidence);
self
}
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\""
);
}
}