Skip to main content

edgecrab_types/
harness.rs

1use serde::{Deserialize, Serialize};
2
3/// Terminal completion state for a run.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
5#[serde(rename_all = "snake_case")]
6pub enum CompletionDecision {
7    Completed,
8    NeedsUserInput,
9    Blocked,
10    BudgetExhausted,
11    Interrupted,
12    Failed,
13    #[default]
14    Incomplete,
15    NeedsVerification,
16}
17
18impl CompletionDecision {
19    pub fn as_str(self) -> &'static str {
20        match self {
21            Self::Completed => "completed",
22            Self::NeedsUserInput => "needs_user_input",
23            Self::Blocked => "blocked",
24            Self::BudgetExhausted => "budget_exhausted",
25            Self::Interrupted => "interrupted",
26            Self::Failed => "failed",
27            Self::Incomplete => "incomplete",
28            Self::NeedsVerification => "needs_verification",
29        }
30    }
31
32    pub fn emoji(self) -> &'static str {
33        match self {
34            Self::Completed => "✅",
35            Self::NeedsUserInput => "❓",
36            Self::Blocked => "⏸",
37            Self::BudgetExhausted => "⚠",
38            Self::Interrupted => "⛔",
39            Self::Failed => "❌",
40            Self::Incomplete => "⚠",
41            Self::NeedsVerification => "🔎",
42        }
43    }
44
45    pub fn headline(self) -> &'static str {
46        match self {
47            Self::Completed => "Completed — request satisfied and verified.",
48            Self::NeedsUserInput => "Needs input — more information is still required.",
49            Self::Blocked => "Blocked — waiting on approval or another dependency.",
50            Self::BudgetExhausted => {
51                "Stopped before completion — the iteration budget was exhausted."
52            }
53            Self::Interrupted => "Stopped — the run was interrupted.",
54            Self::Failed => "Failed — the run ended unexpectedly.",
55            Self::Incomplete => "Incomplete — work is still pending.",
56            Self::NeedsVerification => "Needs verification — concrete evidence is still missing.",
57        }
58    }
59
60    pub fn compact_label(self) -> &'static str {
61        match self {
62            Self::Completed => "done",
63            Self::NeedsUserInput => "reply needed",
64            Self::Blocked => "blocked",
65            Self::BudgetExhausted => "budget hit",
66            Self::Interrupted => "interrupted",
67            Self::Failed => "failed",
68            Self::Incomplete => "incomplete",
69            Self::NeedsVerification => "verify",
70        }
71    }
72
73    pub fn operator_hint(self) -> Option<&'static str> {
74        match self {
75            Self::NeedsUserInput => Some("Reply below and EdgeCrab can continue immediately."),
76            Self::Blocked => Some("Resolve the dependency or approval to let the run advance."),
77            Self::Incomplete => {
78                Some("The harness kept the run honest because unfinished work remained.")
79            }
80            Self::NeedsVerification => {
81                Some("The finish line only counts once there is concrete evidence.")
82            }
83            _ => None,
84        }
85    }
86
87    pub fn is_success(self) -> bool {
88        matches!(self, Self::Completed)
89    }
90}
91
92/// Concrete reason the run stopped.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
94#[serde(rename_all = "snake_case")]
95pub enum ExitReason {
96    ModelReturnedFinalText,
97    #[default]
98    NoMoreToolCalls,
99    BudgetExhausted,
100    Interrupted,
101    AwaitingClarification,
102    AwaitingApproval,
103    PendingTasks,
104    VerificationPending,
105    ToolFailure,
106    ModelError,
107}
108
109impl ExitReason {
110    pub fn as_str(self) -> &'static str {
111        match self {
112            Self::ModelReturnedFinalText => "model_returned_final_text",
113            Self::NoMoreToolCalls => "no_more_tool_calls",
114            Self::BudgetExhausted => "budget_exhausted",
115            Self::Interrupted => "interrupted",
116            Self::AwaitingClarification => "awaiting_clarification",
117            Self::AwaitingApproval => "awaiting_approval",
118            Self::PendingTasks => "pending_tasks",
119            Self::VerificationPending => "verification_pending",
120            Self::ToolFailure => "tool_failure",
121            Self::ModelError => "model_error",
122        }
123    }
124}
125
126/// Summary of whether the task was verified with concrete evidence.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
128pub struct VerificationSummary {
129    #[serde(default)]
130    pub required: bool,
131    #[serde(default)]
132    pub evidence_present: bool,
133    #[serde(default)]
134    pub evidence: Vec<String>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub debt_reason: Option<String>,
137}
138
139/// Structured terminal outcome for a conversation run.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
141pub struct RunOutcome {
142    pub state: CompletionDecision,
143    pub exit_reason: ExitReason,
144    pub user_summary: String,
145    #[serde(default)]
146    pub evidence: Vec<String>,
147    #[serde(default)]
148    pub verification: VerificationSummary,
149    #[serde(default)]
150    pub active_tasks: usize,
151    #[serde(default)]
152    pub blocked_tasks: usize,
153}
154
155impl RunOutcome {
156    pub fn new(
157        state: CompletionDecision,
158        exit_reason: ExitReason,
159        user_summary: impl Into<String>,
160    ) -> Self {
161        Self {
162            state,
163            exit_reason,
164            user_summary: user_summary.into(),
165            evidence: Vec::new(),
166            verification: VerificationSummary::default(),
167            active_tasks: 0,
168            blocked_tasks: 0,
169        }
170    }
171
172    pub fn is_success(&self) -> bool {
173        self.state.is_success()
174    }
175}
176
177/// Structured status signal emitted by the model via the report_task_status tool.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct ReportedTaskStatus {
180    pub status: TaskStatusKind,
181    pub summary: String,
182    #[serde(default)]
183    pub evidence: Vec<String>,
184    #[serde(default)]
185    pub remaining_steps: Vec<String>,
186}
187
188/// Status variants accepted by the report_task_status tool.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
190#[serde(rename_all = "snake_case")]
191pub enum TaskStatusKind {
192    InProgress,
193    Blocked,
194    Completed,
195}
196
197impl TaskStatusKind {
198    pub fn as_str(self) -> &'static str {
199        match self {
200            Self::InProgress => "in_progress",
201            Self::Blocked => "blocked",
202            Self::Completed => "completed",
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn completion_decision_string_labels_are_stable() {
213        assert_eq!(CompletionDecision::Completed.as_str(), "completed");
214        assert_eq!(CompletionDecision::Completed.emoji(), "✅");
215        assert_eq!(CompletionDecision::Completed.compact_label(), "done");
216        assert_eq!(
217            CompletionDecision::NeedsVerification.as_str(),
218            "needs_verification"
219        );
220    }
221
222    #[test]
223    fn run_outcome_defaults_to_incomplete() {
224        let outcome = RunOutcome::default();
225        assert_eq!(outcome.state, CompletionDecision::Incomplete);
226        assert_eq!(outcome.exit_reason, ExitReason::NoMoreToolCalls);
227    }
228
229    #[test]
230    fn reported_task_status_round_trips() {
231        let status = ReportedTaskStatus {
232            status: TaskStatusKind::Completed,
233            summary: "tests passed".into(),
234            evidence: vec!["cargo test --workspace".into()],
235            remaining_steps: Vec::new(),
236        };
237
238        let json = serde_json::to_string(&status).expect("json");
239        let parsed: ReportedTaskStatus = serde_json::from_str(&json).expect("parse");
240        assert_eq!(parsed.status, TaskStatusKind::Completed);
241        assert_eq!(parsed.evidence.len(), 1);
242    }
243}