1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10fn default_outcome_timestamp() -> DateTime<Utc> {
15 Utc::now()
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct AgentOutcome {
27 pub status: OutcomeStatus,
29 #[serde(default)]
31 pub summary: String,
32 #[serde(default)]
34 pub evidence: Vec<Evidence>,
35 #[serde(default)]
37 pub metrics: OutcomeMetrics,
38 #[serde(default = "default_outcome_timestamp")]
40 pub timestamp: DateTime<Utc>,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum OutcomeStatus {
47 Success,
49 PartialSuccess,
51 GiveUp,
53 Timeout,
55 Failure,
57 Done,
59 Stalled,
61 NoTerminal,
63}
64
65impl OutcomeStatus {
66 pub fn is_completed(&self) -> bool {
68 matches!(self, Self::Success | Self::PartialSuccess | Self::Done)
69 }
70
71 pub fn is_terminal(&self) -> bool {
73 true }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct Evidence {
80 pub kind: EvidenceKind,
82 pub description: String,
84 pub data: Option<Value>,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum EvidenceKind {
92 SelfAssessment,
94 ToolResult,
96 StateChange,
98 ExternalVerification,
100 StopReason,
102 Evaluator,
104}
105
106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct OutcomeMetrics {
113 #[serde(default)]
115 pub turns: u32,
116 #[serde(default)]
118 pub tool_calls: u32,
119 #[serde(default)]
121 pub duration_ms: f64,
122 #[serde(default)]
124 pub retries: u32,
125 #[serde(default)]
127 pub actions_succeeded: u32,
128 #[serde(default)]
130 pub actions_failed: u32,
131}
132
133impl AgentOutcome {
134 pub fn success(summary: &str) -> Self {
136 Self {
137 status: OutcomeStatus::Success,
138 summary: summary.to_string(),
139 evidence: Vec::new(),
140 metrics: OutcomeMetrics::default(),
141 timestamp: Utc::now(),
142 }
143 }
144
145 pub fn failure(summary: &str) -> Self {
147 Self {
148 status: OutcomeStatus::Failure,
149 summary: summary.to_string(),
150 evidence: Vec::new(),
151 metrics: OutcomeMetrics::default(),
152 timestamp: Utc::now(),
153 }
154 }
155
156 pub fn timeout(summary: &str, turns: u32, max_turns: u32) -> Self {
158 Self {
159 status: OutcomeStatus::Timeout,
160 summary: summary.to_string(),
161 evidence: vec![Evidence {
162 kind: EvidenceKind::StopReason,
163 description: format!("Reached {} of {} max turns", turns, max_turns),
164 data: Some(serde_json::json!({
165 "turns": turns,
166 "max_turns": max_turns,
167 })),
168 }],
169 metrics: OutcomeMetrics {
170 turns,
171 ..Default::default()
172 },
173 timestamp: Utc::now(),
174 }
175 }
176
177 pub fn give_up(reason: &str) -> Self {
179 Self {
180 status: OutcomeStatus::GiveUp,
181 summary: reason.to_string(),
182 evidence: vec![Evidence {
183 kind: EvidenceKind::SelfAssessment,
184 description: reason.to_string(),
185 data: None,
186 }],
187 metrics: OutcomeMetrics::default(),
188 timestamp: Utc::now(),
189 }
190 }
191
192 pub fn with_evidence(mut self, evidence: Evidence) -> Self {
194 self.evidence.push(evidence);
195 self
196 }
197
198 pub fn with_metrics(mut self, metrics: OutcomeMetrics) -> Self {
200 self.metrics = metrics;
201 self
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn test_outcome_status_classification() {
211 assert!(OutcomeStatus::Success.is_completed());
212 assert!(OutcomeStatus::PartialSuccess.is_completed());
213 assert!(OutcomeStatus::Done.is_completed());
214 assert!(!OutcomeStatus::Failure.is_completed());
215 assert!(!OutcomeStatus::Timeout.is_completed());
216 assert!(!OutcomeStatus::GiveUp.is_completed());
217 assert!(!OutcomeStatus::Stalled.is_completed());
218 assert!(!OutcomeStatus::NoTerminal.is_completed());
219 }
220
221 #[test]
222 fn test_all_statuses_are_terminal() {
223 assert!(OutcomeStatus::Success.is_terminal());
224 assert!(OutcomeStatus::PartialSuccess.is_terminal());
225 assert!(OutcomeStatus::Done.is_terminal());
226 assert!(OutcomeStatus::Failure.is_terminal());
227 assert!(OutcomeStatus::Timeout.is_terminal());
228 assert!(OutcomeStatus::GiveUp.is_terminal());
229 }
230
231 #[test]
232 fn test_timeout_outcome() {
233 let outcome = AgentOutcome::timeout("Exceeded step limit", 10, 10);
234 assert_eq!(outcome.status, OutcomeStatus::Timeout);
235 assert_eq!(outcome.metrics.turns, 10);
236 assert_eq!(outcome.evidence.len(), 1);
237 assert_eq!(outcome.evidence[0].kind, EvidenceKind::StopReason);
238 }
239
240 #[test]
241 fn test_outcome_with_evidence() {
242 let outcome = AgentOutcome::success("Task done")
243 .with_evidence(Evidence {
244 kind: EvidenceKind::ToolResult,
245 description: "File created".to_string(),
246 data: Some(serde_json::json!({"path": "/tmp/out.txt"})),
247 })
248 .with_evidence(Evidence {
249 kind: EvidenceKind::ExternalVerification,
250 description: "Tests passed".to_string(),
251 data: None,
252 });
253 assert_eq!(outcome.evidence.len(), 2);
254 }
255
256 #[test]
257 fn test_give_up_outcome() {
258 let outcome = AgentOutcome::give_up("Cannot access required API");
259 assert_eq!(outcome.status, OutcomeStatus::GiveUp);
260 assert_eq!(outcome.evidence.len(), 1);
261 assert_eq!(outcome.evidence[0].kind, EvidenceKind::SelfAssessment);
262 }
263
264 #[test]
265 fn test_failure_outcome() {
266 let outcome = AgentOutcome::failure("Connection refused");
267 assert_eq!(outcome.status, OutcomeStatus::Failure);
268 assert!(!outcome.status.is_completed());
269 }
270
271 #[test]
272 fn test_outcome_serde_roundtrip() {
273 let outcome = AgentOutcome::success("Done")
274 .with_evidence(Evidence {
275 kind: EvidenceKind::ToolResult,
276 description: "ok".to_string(),
277 data: Some(serde_json::json!(42)),
278 })
279 .with_metrics(OutcomeMetrics {
280 turns: 5,
281 tool_calls: 3,
282 duration_ms: 1234.5,
283 retries: 1,
284 actions_succeeded: 4,
285 actions_failed: 1,
286 });
287
288 let json = serde_json::to_string(&outcome).unwrap();
289 let roundtripped: AgentOutcome = serde_json::from_str(&json).unwrap();
290
291 assert_eq!(roundtripped.status, OutcomeStatus::Success);
292 assert_eq!(roundtripped.summary, "Done");
293 assert_eq!(roundtripped.evidence.len(), 1);
294 assert_eq!(roundtripped.metrics.turns, 5);
295 assert_eq!(roundtripped.metrics.tool_calls, 3);
296 }
297
298 #[test]
305 fn test_outcome_deserializes_harness_shape_without_timestamp() {
306 let harness = r#"{"status":"success","summary":"Created file","evidence":[],"metrics":{"turns":3,"tool_calls":3,"actions_succeeded":3,"actions_failed":0},"tools_called":["drive_cli","check_outcome","finish"]}"#;
307 let outcome: AgentOutcome =
308 serde_json::from_str(harness).expect("harness shape must deserialize");
309 assert_eq!(outcome.status, OutcomeStatus::Success);
310 assert_eq!(outcome.summary, "Created file");
311 assert!(outcome.evidence.is_empty());
312 assert_eq!(outcome.metrics.turns, 3);
313 assert_eq!(outcome.metrics.tool_calls, 3);
314 assert_eq!(outcome.metrics.actions_succeeded, 3);
315 assert_eq!(outcome.metrics.actions_failed, 0);
316 assert_eq!(outcome.metrics.duration_ms, 0.0);
318 assert_eq!(outcome.metrics.retries, 0);
319 let json = serde_json::to_string(&outcome).unwrap();
321 assert!(json.contains("\"timestamp\""));
322 }
323
324 #[test]
325 fn test_outcome_status_snake_case_serde() {
326 assert_eq!(
327 serde_json::to_string(&OutcomeStatus::PartialSuccess).unwrap(),
328 "\"partial_success\""
329 );
330 assert_eq!(
331 serde_json::to_string(&OutcomeStatus::GiveUp).unwrap(),
332 "\"give_up\""
333 );
334 }
335}