use crate::exec::events::ThreadCompletionSubtype;
use crate::exec::events::ThreadEvent;
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub title: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
impl Task {
pub fn new(id: String, title: String, description: String) -> Self {
Self { id, title, description, instructions: None }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextItem {
pub id: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskOutcome {
Success,
StoppedNoAction,
TurnLimitReached {
max_turns: usize,
actual_turns: usize,
},
BudgetLimitReached {
max_budget_usd: f64,
actual_cost_usd: f64,
},
ToolLoopLimitReached {
max_tool_loops: usize,
actual_tool_loops: usize,
},
LoopDetected,
Cancelled,
HandedOff {
target: String,
},
Escalated {
reason: String,
tool_name: String,
},
Failed {
reason: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
accomplished: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
recovery_suggestion: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
checkpoint_path: Option<String>,
},
Unknown,
}
impl TaskOutcome {
pub fn is_success(&self) -> bool {
matches!(self, Self::Success | Self::StoppedNoAction)
}
pub fn is_hard_block(&self) -> bool {
matches!(self, Self::ToolLoopLimitReached { .. } | Self::LoopDetected)
}
pub fn description(&self) -> String {
match self {
Self::Success => "Task completed successfully".into(),
Self::StoppedNoAction => "Stopped after agent signaled no further actions".into(),
Self::TurnLimitReached { max_turns, actual_turns } => {
format!("Stopped after reaching turn limit (max: {max_turns}, reached: {actual_turns})")
}
Self::BudgetLimitReached { max_budget_usd, actual_cost_usd } => {
format!("Stopped after reaching budget limit (max: ${max_budget_usd:.4}, spent: ${actual_cost_usd:.4})")
}
Self::ToolLoopLimitReached { max_tool_loops, actual_tool_loops } => {
if *max_tool_loops == 0 {
format!("Stopped after a tool-loop safeguard halted execution (reached: {actual_tool_loops})")
} else {
format!(
"Stopped after reaching tool loop limit (max: {max_tool_loops}, reached: {actual_tool_loops})"
)
}
}
Self::LoopDetected => "Stopped due to infinite loop detection".into(),
Self::Cancelled => "Task cancelled by user".into(),
Self::HandedOff { target } => format!("Task handed off to `{target}`"),
Self::Escalated { reason, tool_name } => {
format!("Task escalated: {tool_name} — {reason}")
}
Self::Failed { reason, accomplished, recovery_suggestion, .. } => {
let mut parts = vec![format!("Task failed: {reason}")];
if !accomplished.is_empty() {
parts.push(format!("Accomplished: {}", accomplished.join(", ")));
}
if let Some(suggestion) = recovery_suggestion {
parts.push(format!("Suggestion: {suggestion}"));
}
parts.join("\n")
}
Self::Unknown => "Task outcome could not be determined".into(),
}
}
pub fn code(&self) -> &'static str {
match self {
Self::Success => "success",
Self::StoppedNoAction => "stopped_no_action",
Self::TurnLimitReached { .. } => "turn_limit_reached",
Self::BudgetLimitReached { .. } => "budget_limit_reached",
Self::ToolLoopLimitReached { .. } => "tool_loop_limit_reached",
Self::LoopDetected => "loop_detected",
Self::Cancelled => "cancelled",
Self::HandedOff { .. } => "handed_off",
Self::Escalated { .. } => "escalated",
Self::Failed { .. } => "failed",
Self::Unknown => "unknown",
}
}
pub fn thread_completion_subtype(&self) -> ThreadCompletionSubtype {
match self {
Self::Success | Self::StoppedNoAction => ThreadCompletionSubtype::Success,
Self::TurnLimitReached { .. } => ThreadCompletionSubtype::ErrorMaxTurns,
Self::BudgetLimitReached { .. } => ThreadCompletionSubtype::ErrorMaxBudgetUsd,
Self::Cancelled => ThreadCompletionSubtype::Cancelled,
Self::ToolLoopLimitReached { .. }
| Self::LoopDetected
| Self::HandedOff { .. }
| Self::Escalated { .. }
| Self::Failed { .. }
| Self::Unknown => ThreadCompletionSubtype::ErrorDuringExecution,
}
}
pub fn success() -> Self {
Self::Success
}
pub fn turn_limit_reached(max_turns: usize, actual_turns: usize) -> Self {
Self::TurnLimitReached { max_turns, actual_turns }
}
pub fn budget_limit_reached(max_budget_usd: f64, actual_cost_usd: f64) -> Self {
Self::BudgetLimitReached { max_budget_usd, actual_cost_usd }
}
pub fn tool_loop_limit_reached(max_tool_loops: usize, actual_tool_loops: usize) -> Self {
Self::ToolLoopLimitReached { max_tool_loops, actual_tool_loops }
}
pub fn failed(
reason: String,
accomplished: Vec<String>,
recovery_suggestion: Option<String>,
checkpoint_path: Option<String>,
) -> Self {
Self::Failed {
reason,
accomplished,
recovery_suggestion,
checkpoint_path,
}
}
pub fn escalated(reason: String, tool_name: String) -> Self {
Self::Escalated { reason, tool_name }
}
pub fn handed_off(target: String) -> Self {
Self::HandedOff { target }
}
}
impl fmt::Display for TaskOutcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
#[cfg(test)]
mod tests {
use super::{TaskOutcome, ThreadCompletionSubtype};
#[test]
fn tool_loop_limit_description_handles_disabled_limit() {
let description = TaskOutcome::tool_loop_limit_reached(0, 4).description();
assert!(description.contains("tool-loop safeguard halted execution"));
assert!(description.contains("reached: 4"));
}
#[test]
fn thread_completion_subtype_matches_public_result_states() {
assert_eq!(TaskOutcome::Success.thread_completion_subtype(), ThreadCompletionSubtype::Success);
assert_eq!(TaskOutcome::StoppedNoAction.thread_completion_subtype(), ThreadCompletionSubtype::Success);
assert_eq!(
TaskOutcome::turn_limit_reached(3, 3).thread_completion_subtype(),
ThreadCompletionSubtype::ErrorMaxTurns
);
assert_eq!(
TaskOutcome::budget_limit_reached(1.0, 1.2).thread_completion_subtype(),
ThreadCompletionSubtype::ErrorMaxBudgetUsd
);
assert_eq!(TaskOutcome::Cancelled.thread_completion_subtype(), ThreadCompletionSubtype::Cancelled);
assert_eq!(
(TaskOutcome::failed("boom".to_string(), vec![], None, None)).thread_completion_subtype(),
ThreadCompletionSubtype::ErrorDuringExecution
);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskResults {
#[serde(default)]
pub created_contexts: Vec<String>,
#[serde(default)]
pub modified_files: Vec<String>,
#[serde(default)]
pub executed_commands: Vec<String>,
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost_usd: Option<f64>,
#[serde(default)]
pub warnings: Vec<String>,
#[serde(default)]
pub thread_events: Vec<ThreadEvent>,
pub outcome: TaskOutcome,
pub turns_executed: usize,
pub total_duration_ms: u128,
#[serde(default)]
pub average_turn_duration_ms: Option<f64>,
#[serde(default)]
pub max_turn_duration_ms: Option<u128>,
#[serde(default)]
pub turn_durations_ms: Vec<u128>,
}