1use crate::exec::events::ThreadCompletionSubtype;
4use crate::exec::events::ThreadEvent;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Task {
11 pub id: String,
13 pub title: String,
15 pub description: String,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub instructions: Option<String>,
20}
21
22impl Task {
23 pub fn new(id: String, title: String, description: String) -> Self {
25 Self { id, title, description, instructions: None }
26 }
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ContextItem {
32 pub id: String,
34 pub content: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum TaskOutcome {
41 Success,
42 StoppedNoAction,
43 TurnLimitReached {
44 max_turns: usize,
45 actual_turns: usize,
46 },
47 BudgetLimitReached {
48 max_budget_usd: f64,
49 actual_cost_usd: f64,
50 },
51 ToolLoopLimitReached {
52 max_tool_loops: usize,
53 actual_tool_loops: usize,
54 },
55 LoopDetected,
56 Cancelled,
57 HandedOff {
58 target: String,
59 },
60 Escalated {
61 reason: String,
62 tool_name: String,
63 },
64 Refused {
68 reason: String,
69 },
70 Failed {
71 reason: String,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 accomplished: Vec<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 recovery_suggestion: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 checkpoint_path: Option<String>,
81 },
82 Unknown,
83}
84
85impl TaskOutcome {
86 pub fn is_success(&self) -> bool {
87 matches!(self, Self::Success | Self::StoppedNoAction)
88 }
89
90 pub fn is_hard_block(&self) -> bool {
91 matches!(self, Self::ToolLoopLimitReached { .. } | Self::LoopDetected)
92 }
93
94 pub fn description(&self) -> String {
95 match self {
96 Self::Success => "Task completed successfully".into(),
97 Self::StoppedNoAction => "Stopped after agent signaled no further actions".into(),
98 Self::TurnLimitReached { max_turns, actual_turns } => {
99 format!("Stopped after reaching turn limit (max: {max_turns}, reached: {actual_turns})")
100 }
101 Self::BudgetLimitReached { max_budget_usd, actual_cost_usd } => {
102 format!("Stopped after reaching budget limit (max: ${max_budget_usd:.4}, spent: ${actual_cost_usd:.4})")
103 }
104 Self::ToolLoopLimitReached { max_tool_loops, actual_tool_loops } => {
105 if *max_tool_loops == 0 {
106 format!("Stopped after a tool-loop safeguard halted execution (reached: {actual_tool_loops})")
107 } else {
108 format!(
109 "Stopped after reaching tool loop limit (max: {max_tool_loops}, reached: {actual_tool_loops})"
110 )
111 }
112 }
113 Self::LoopDetected => "Stopped due to infinite loop detection".into(),
114 Self::Cancelled => "Task cancelled by user".into(),
115 Self::HandedOff { target } => format!("Task handed off to `{target}`"),
116 Self::Escalated { reason, tool_name } => {
117 format!("Task escalated: {tool_name} — {reason}")
118 }
119 Self::Refused { reason } => reason.clone(),
120 Self::Failed { reason, accomplished, recovery_suggestion, .. } => {
121 let mut parts = vec![format!("Task failed: {reason}")];
122 if !accomplished.is_empty() {
123 parts.push(format!("Accomplished: {}", accomplished.join(", ")));
124 }
125 if let Some(suggestion) = recovery_suggestion {
126 parts.push(format!("Suggestion: {suggestion}"));
127 }
128 parts.join("\n")
129 }
130 Self::Unknown => "Task outcome could not be determined".into(),
131 }
132 }
133
134 pub fn code(&self) -> &'static str {
135 match self {
136 Self::Success => "success",
137 Self::StoppedNoAction => "stopped_no_action",
138 Self::TurnLimitReached { .. } => "turn_limit_reached",
139 Self::BudgetLimitReached { .. } => "budget_limit_reached",
140 Self::ToolLoopLimitReached { .. } => "tool_loop_limit_reached",
141 Self::LoopDetected => "loop_detected",
142 Self::Cancelled => "cancelled",
143 Self::HandedOff { .. } => "handed_off",
144 Self::Escalated { .. } => "escalated",
145 Self::Refused { .. } => "refused",
146 Self::Failed { .. } => "failed",
147 Self::Unknown => "unknown",
148 }
149 }
150
151 pub fn thread_completion_subtype(&self) -> ThreadCompletionSubtype {
152 match self {
153 Self::Success | Self::StoppedNoAction => ThreadCompletionSubtype::Success,
154 Self::TurnLimitReached { .. } => ThreadCompletionSubtype::ErrorMaxTurns,
155 Self::BudgetLimitReached { .. } => ThreadCompletionSubtype::ErrorMaxBudgetUsd,
156 Self::Cancelled => ThreadCompletionSubtype::Cancelled,
157 Self::ToolLoopLimitReached { .. }
158 | Self::LoopDetected
159 | Self::HandedOff { .. }
160 | Self::Escalated { .. }
161 | Self::Refused { .. }
162 | Self::Failed { .. }
163 | Self::Unknown => ThreadCompletionSubtype::ErrorDuringExecution,
164 }
165 }
166
167 pub fn success() -> Self {
168 Self::Success
169 }
170
171 pub fn turn_limit_reached(max_turns: usize, actual_turns: usize) -> Self {
172 Self::TurnLimitReached { max_turns, actual_turns }
173 }
174
175 pub fn budget_limit_reached(max_budget_usd: f64, actual_cost_usd: f64) -> Self {
176 Self::BudgetLimitReached { max_budget_usd, actual_cost_usd }
177 }
178
179 pub fn tool_loop_limit_reached(max_tool_loops: usize, actual_tool_loops: usize) -> Self {
180 Self::ToolLoopLimitReached { max_tool_loops, actual_tool_loops }
181 }
182
183 pub fn failed(
184 reason: String,
185 accomplished: Vec<String>,
186 recovery_suggestion: Option<String>,
187 checkpoint_path: Option<String>,
188 ) -> Self {
189 Self::Failed {
190 reason,
191 accomplished,
192 recovery_suggestion,
193 checkpoint_path,
194 }
195 }
196
197 pub fn escalated(reason: String, tool_name: String) -> Self {
198 Self::Escalated { reason, tool_name }
199 }
200
201 pub fn refused(reason: String) -> Self {
202 Self::Refused { reason }
203 }
204
205 pub fn handed_off(target: String) -> Self {
206 Self::HandedOff { target }
207 }
208}
209
210impl fmt::Display for TaskOutcome {
211 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212 f.write_str(self.code())
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::{TaskOutcome, ThreadCompletionSubtype};
219
220 #[test]
221 fn tool_loop_limit_description_handles_disabled_limit() {
222 let description = TaskOutcome::tool_loop_limit_reached(0, 4).description();
223
224 assert!(description.contains("tool-loop safeguard halted execution"));
225 assert!(description.contains("reached: 4"));
226 }
227
228 #[test]
229 fn thread_completion_subtype_matches_public_result_states() {
230 assert_eq!(TaskOutcome::Success.thread_completion_subtype(), ThreadCompletionSubtype::Success);
231 assert_eq!(TaskOutcome::StoppedNoAction.thread_completion_subtype(), ThreadCompletionSubtype::Success);
232 assert_eq!(
233 TaskOutcome::turn_limit_reached(3, 3).thread_completion_subtype(),
234 ThreadCompletionSubtype::ErrorMaxTurns
235 );
236 assert_eq!(
237 TaskOutcome::budget_limit_reached(1.0, 1.2).thread_completion_subtype(),
238 ThreadCompletionSubtype::ErrorMaxBudgetUsd
239 );
240 assert_eq!(TaskOutcome::Cancelled.thread_completion_subtype(), ThreadCompletionSubtype::Cancelled);
241 assert_eq!(
242 (TaskOutcome::failed("boom".to_string(), vec![], None, None)).thread_completion_subtype(),
243 ThreadCompletionSubtype::ErrorDuringExecution
244 );
245 assert_eq!(
246 TaskOutcome::refused("declined".to_string()).thread_completion_subtype(),
247 ThreadCompletionSubtype::ErrorDuringExecution
248 );
249 }
250
251 #[test]
252 fn refused_outcome_is_a_distinct_failure_that_carries_the_reason() {
253 let outcome = TaskOutcome::refused("The model declined this request.".to_string());
254
255 assert!(!outcome.is_success());
256 assert!(!outcome.is_hard_block());
257 assert_eq!(outcome.code(), "refused");
258 assert_eq!(outcome.description(), "The model declined this request.");
259
260 let json = serde_json::to_value(&outcome).expect("serialize refused outcome");
261 assert_eq!(json, serde_json::json!({"refused": {"reason": "The model declined this request."}}));
262 let round_trip: TaskOutcome = serde_json::from_value(json).expect("deserialize refused outcome");
263 assert_eq!(round_trip, outcome);
264 }
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct TaskResults {
270 #[serde(default)]
272 pub created_contexts: Vec<String>,
273 #[serde(default)]
275 pub modified_files: Vec<String>,
276 #[serde(default)]
278 pub executed_commands: Vec<String>,
279 pub summary: String,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub stop_reason: Option<String>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub total_cost_usd: Option<f64>,
287 #[serde(default)]
289 pub warnings: Vec<String>,
290 #[serde(default)]
292 pub thread_events: Vec<ThreadEvent>,
293 pub outcome: TaskOutcome,
295 pub turns_executed: usize,
297 pub total_duration_ms: u128,
299 #[serde(default)]
301 pub average_turn_duration_ms: Option<f64>,
302 #[serde(default)]
304 pub max_turn_duration_ms: Option<u128>,
305 #[serde(default)]
307 pub turn_durations_ms: Vec<u128>,
308}