1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AgentOutcome {
13 pub status: OutcomeStatus,
15 pub summary: String,
17 pub evidence: Vec<Evidence>,
19 pub metrics: OutcomeMetrics,
21 pub timestamp: DateTime<Utc>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum OutcomeStatus {
29 Success,
31 PartialSuccess,
33 GiveUp,
35 Timeout,
37 Failure,
39 Done,
41}
42
43impl OutcomeStatus {
44 pub fn is_completed(&self) -> bool {
46 matches!(self, Self::Success | Self::PartialSuccess | Self::Done)
47 }
48
49 pub fn is_terminal(&self) -> bool {
51 true }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct Evidence {
58 pub kind: EvidenceKind,
60 pub description: String,
62 pub data: Option<Value>,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum EvidenceKind {
70 SelfAssessment,
72 ToolResult,
74 StateChange,
76 ExternalVerification,
78 StopReason,
80 Evaluator,
82}
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
86pub struct OutcomeMetrics {
87 pub turns: u32,
89 pub tool_calls: u32,
91 pub duration_ms: f64,
93 pub retries: u32,
95 pub actions_succeeded: u32,
97 pub actions_failed: u32,
99}
100
101impl AgentOutcome {
102 pub fn success(summary: &str) -> Self {
104 Self {
105 status: OutcomeStatus::Success,
106 summary: summary.to_string(),
107 evidence: Vec::new(),
108 metrics: OutcomeMetrics::default(),
109 timestamp: Utc::now(),
110 }
111 }
112
113 pub fn failure(summary: &str) -> Self {
115 Self {
116 status: OutcomeStatus::Failure,
117 summary: summary.to_string(),
118 evidence: Vec::new(),
119 metrics: OutcomeMetrics::default(),
120 timestamp: Utc::now(),
121 }
122 }
123
124 pub fn timeout(summary: &str, turns: u32, max_turns: u32) -> Self {
126 Self {
127 status: OutcomeStatus::Timeout,
128 summary: summary.to_string(),
129 evidence: vec![Evidence {
130 kind: EvidenceKind::StopReason,
131 description: format!("Reached {} of {} max turns", turns, max_turns),
132 data: Some(serde_json::json!({
133 "turns": turns,
134 "max_turns": max_turns,
135 })),
136 }],
137 metrics: OutcomeMetrics {
138 turns,
139 ..Default::default()
140 },
141 timestamp: Utc::now(),
142 }
143 }
144
145 pub fn give_up(reason: &str) -> Self {
147 Self {
148 status: OutcomeStatus::GiveUp,
149 summary: reason.to_string(),
150 evidence: vec![Evidence {
151 kind: EvidenceKind::SelfAssessment,
152 description: reason.to_string(),
153 data: None,
154 }],
155 metrics: OutcomeMetrics::default(),
156 timestamp: Utc::now(),
157 }
158 }
159
160 pub fn with_evidence(mut self, evidence: Evidence) -> Self {
162 self.evidence.push(evidence);
163 self
164 }
165
166 pub fn with_metrics(mut self, metrics: OutcomeMetrics) -> Self {
168 self.metrics = metrics;
169 self
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn test_outcome_status_classification() {
179 assert!(OutcomeStatus::Success.is_completed());
180 assert!(OutcomeStatus::PartialSuccess.is_completed());
181 assert!(OutcomeStatus::Done.is_completed());
182 assert!(!OutcomeStatus::Failure.is_completed());
183 assert!(!OutcomeStatus::Timeout.is_completed());
184 assert!(!OutcomeStatus::GiveUp.is_completed());
185 }
186
187 #[test]
188 fn test_all_statuses_are_terminal() {
189 assert!(OutcomeStatus::Success.is_terminal());
190 assert!(OutcomeStatus::PartialSuccess.is_terminal());
191 assert!(OutcomeStatus::Done.is_terminal());
192 assert!(OutcomeStatus::Failure.is_terminal());
193 assert!(OutcomeStatus::Timeout.is_terminal());
194 assert!(OutcomeStatus::GiveUp.is_terminal());
195 }
196
197 #[test]
198 fn test_timeout_outcome() {
199 let outcome = AgentOutcome::timeout("Exceeded step limit", 10, 10);
200 assert_eq!(outcome.status, OutcomeStatus::Timeout);
201 assert_eq!(outcome.metrics.turns, 10);
202 assert_eq!(outcome.evidence.len(), 1);
203 assert_eq!(outcome.evidence[0].kind, EvidenceKind::StopReason);
204 }
205
206 #[test]
207 fn test_outcome_with_evidence() {
208 let outcome = AgentOutcome::success("Task done")
209 .with_evidence(Evidence {
210 kind: EvidenceKind::ToolResult,
211 description: "File created".to_string(),
212 data: Some(serde_json::json!({"path": "/tmp/out.txt"})),
213 })
214 .with_evidence(Evidence {
215 kind: EvidenceKind::ExternalVerification,
216 description: "Tests passed".to_string(),
217 data: None,
218 });
219 assert_eq!(outcome.evidence.len(), 2);
220 }
221
222 #[test]
223 fn test_give_up_outcome() {
224 let outcome = AgentOutcome::give_up("Cannot access required API");
225 assert_eq!(outcome.status, OutcomeStatus::GiveUp);
226 assert_eq!(outcome.evidence.len(), 1);
227 assert_eq!(outcome.evidence[0].kind, EvidenceKind::SelfAssessment);
228 }
229
230 #[test]
231 fn test_failure_outcome() {
232 let outcome = AgentOutcome::failure("Connection refused");
233 assert_eq!(outcome.status, OutcomeStatus::Failure);
234 assert!(!outcome.status.is_completed());
235 }
236
237 #[test]
238 fn test_outcome_serde_roundtrip() {
239 let outcome = AgentOutcome::success("Done")
240 .with_evidence(Evidence {
241 kind: EvidenceKind::ToolResult,
242 description: "ok".to_string(),
243 data: Some(serde_json::json!(42)),
244 })
245 .with_metrics(OutcomeMetrics {
246 turns: 5,
247 tool_calls: 3,
248 duration_ms: 1234.5,
249 retries: 1,
250 actions_succeeded: 4,
251 actions_failed: 1,
252 });
253
254 let json = serde_json::to_string(&outcome).unwrap();
255 let roundtripped: AgentOutcome = serde_json::from_str(&json).unwrap();
256
257 assert_eq!(roundtripped.status, OutcomeStatus::Success);
258 assert_eq!(roundtripped.summary, "Done");
259 assert_eq!(roundtripped.evidence.len(), 1);
260 assert_eq!(roundtripped.metrics.turns, 5);
261 assert_eq!(roundtripped.metrics.tool_calls, 3);
262 }
263
264 #[test]
265 fn test_outcome_status_snake_case_serde() {
266 assert_eq!(
267 serde_json::to_string(&OutcomeStatus::PartialSuccess).unwrap(),
268 "\"partial_success\""
269 );
270 assert_eq!(
271 serde_json::to_string(&OutcomeStatus::GiveUp).unwrap(),
272 "\"give_up\""
273 );
274 }
275}