Skip to main content

vtcode_core/core/agent/
task.rs

1//! Task-related data structures shared across the agent runner modules.
2
3use crate::exec::events::ThreadCompletionSubtype;
4use crate::exec::events::ThreadEvent;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8/// Task specification consumed by the benchmark/autonomous runner.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Task {
11    /// Stable identifier for reporting.
12    pub id: String,
13    /// Human-readable task title displayed in progress messages.
14    pub title: String,
15    /// High-level description of the task objective.
16    pub description: String,
17    /// Optional explicit instructions appended to the conversation.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub instructions: Option<String>,
20}
21
22impl Task {
23    /// Construct a task with the provided metadata.
24    pub fn new(id: String, title: String, description: String) -> Self {
25        Self { id, title, description, instructions: None }
26    }
27}
28
29/// Context entry supplied alongside the benchmark task.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ContextItem {
32    /// Identifier used when referencing the context in prompts.
33    pub id: String,
34    /// Raw textual content exposed to the agent.
35    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    /// The provider declined the request (`FinishReason::Refusal`). Terminal
65    /// for the prompt: resending it is refused again. `reason` is the
66    /// user-facing notice from [`crate::core::agent::refusal::refusal_reason`].
67    Refused {
68        reason: String,
69    },
70    Failed {
71        reason: String,
72        /// What was accomplished before the failure occurred.
73        #[serde(default, skip_serializing_if = "Vec::is_empty")]
74        accomplished: Vec<String>,
75        /// Optional suggestion for recovery or escalation.
76        #[serde(default, skip_serializing_if = "Option::is_none")]
77        recovery_suggestion: Option<String>,
78        /// Optional path to preserved state for task resume.
79        #[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/// Aggregated results returned by the autonomous agent runner.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct TaskResults {
270    /// Identifiers of any contexts created during execution.
271    #[serde(default)]
272    pub created_contexts: Vec<String>,
273    /// File paths modified during the task.
274    #[serde(default)]
275    pub modified_files: Vec<String>,
276    /// Terminal commands executed while solving the task.
277    #[serde(default)]
278    pub executed_commands: Vec<String>,
279    /// Natural-language summary of the run assembled by the agent.
280    pub summary: String,
281    /// Provider stop reason associated with the last model turn, when available.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub stop_reason: Option<String>,
284    /// Estimated total API cost in USD, when pricing metadata is available.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub total_cost_usd: Option<f64>,
287    /// Collected warnings emitted while processing the task.
288    #[serde(default)]
289    pub warnings: Vec<String>,
290    /// Structured execution timeline for headless modes.
291    #[serde(default)]
292    pub thread_events: Vec<ThreadEvent>,
293    /// Finalized outcome of the task.
294    pub outcome: TaskOutcome,
295    /// Number of autonomous turns executed.
296    pub turns_executed: usize,
297    /// Total runtime in milliseconds.
298    pub total_duration_ms: u128,
299    /// Average turn duration in milliseconds (if turns executed).
300    #[serde(default)]
301    pub average_turn_duration_ms: Option<f64>,
302    /// Longest individual turn duration in milliseconds.
303    #[serde(default)]
304    pub max_turn_duration_ms: Option<u128>,
305    /// Per-turn duration metrics in milliseconds.
306    #[serde(default)]
307    pub turn_durations_ms: Vec<u128>,
308}