1use serde::{Deserialize, Serialize};
2
3use crate::workflow::{VerificationCloseoutEffect, VerificationGate, VerificationGateStatus};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum TurnPhase {
13 ReceiveCommands,
14 PreTurn,
15 BuildContext,
16 SampleModel,
17 FinalizeAssistantMessage,
18 PlanTools,
19 ExecuteTools,
20 RecordObservations,
21 AssessTurn,
22 DecideNext,
23 Finish,
24}
25
26impl TurnPhase {
27 pub fn as_str(self) -> &'static str {
28 match self {
29 Self::ReceiveCommands => "receive_commands",
30 Self::PreTurn => "pre_turn",
31 Self::BuildContext => "build_context",
32 Self::SampleModel => "sample_model",
33 Self::FinalizeAssistantMessage => "finalize_assistant_message",
34 Self::PlanTools => "plan_tools",
35 Self::ExecuteTools => "execute_tools",
36 Self::RecordObservations => "record_observations",
37 Self::AssessTurn => "assess_turn",
38 Self::DecideNext => "decide_next",
39 Self::Finish => "finish",
40 }
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct TurnState {
48 pub index: u32,
49 pub phase: TurnPhase,
50 pub continue_reason: Option<ContinueReason>,
51 pub planned_tools: usize,
52 pub completed_tools: usize,
53}
54
55impl TurnState {
56 pub fn new(index: u32) -> Self {
57 Self {
58 index,
59 phase: TurnPhase::ReceiveCommands,
60 continue_reason: None,
61 planned_tools: 0,
62 completed_tools: 0,
63 }
64 }
65
66 pub fn enter(&mut self, phase: TurnPhase) {
67 self.phase = phase;
68 }
69
70 pub fn record_continue(&mut self, reason: ContinueReason) {
71 self.continue_reason = Some(reason);
72 }
73
74 pub fn record_tool_plan(&mut self, planned_tools: usize) {
75 self.planned_tools = planned_tools;
76 }
77
78 pub fn record_tool_results(&mut self, completed_tools: usize) {
79 self.completed_tools = completed_tools;
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum ContinueReason {
90 ExternalizationNeeded,
91 HighConfidenceVisibleNextStep,
92 ExecutionDebt,
93 ToolResultsNeedInterpretation,
94 QueuedUserFollowUp,
95 RecoveryContinuation,
96}
97
98impl ContinueReason {
99 pub fn as_str(self) -> &'static str {
100 match self {
101 Self::ExternalizationNeeded => "externalization_needed",
102 Self::HighConfidenceVisibleNextStep => "high_confidence_visible_next_step",
103 Self::ExecutionDebt => "execution_debt",
104 Self::ToolResultsNeedInterpretation => "tool_results_need_interpretation",
105 Self::QueuedUserFollowUp => "queued_user_follow_up",
106 Self::RecoveryContinuation => "recovery_continuation",
107 }
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum StopReason {
115 NoAutomaticFollowUp,
116 NoProgress,
117 RepeatedAction,
118 UserBlocker,
119 ExecutionBlocked,
120 DecompositionCompleted,
121 WorkCompleted,
122}
123
124impl StopReason {
125 pub fn as_str(self) -> &'static str {
126 match self {
127 Self::NoAutomaticFollowUp => "no_automatic_follow_up",
128 Self::NoProgress => "no_progress",
129 Self::RepeatedAction => "repeated_action",
130 Self::UserBlocker => "user_blocker",
131 Self::ExecutionBlocked => "execution_blocked",
132 Self::DecompositionCompleted => "decomposition_completed",
133 Self::WorkCompleted => "work_completed",
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case", tag = "type")]
140pub enum LoopDecision {
141 Continue {
142 reason: ContinueReason,
143 prompt: String,
144 },
145 Finish {
146 status: RunFinalStatus,
147 },
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case", tag = "type")]
152pub enum RunFinalStatus {
153 Done {
154 reason: StopReason,
155 },
156 DoneWithConcerns {
157 reason: StopReason,
158 concerns: Vec<String>,
159 },
160 Blocked {
161 reason: StopReason,
162 message: String,
163 },
164 NeedsUserInput {
165 question: String,
166 },
167 Cancelled,
168 Failed {
169 message: String,
170 },
171}
172
173impl RunFinalStatus {
174 pub fn from_stop_reason(reason: StopReason) -> Self {
175 match reason {
176 StopReason::UserBlocker | StopReason::ExecutionBlocked | StopReason::RepeatedAction => {
177 Self::Blocked {
178 reason,
179 message: reason.as_str().to_string(),
180 }
181 }
182 StopReason::NoProgress => Self::DoneWithConcerns {
183 reason,
184 concerns: vec!["stopped because no justified continuation was available".into()],
185 },
186 _ => Self::Done { reason },
187 }
188 }
189 pub fn with_concern(self, concern: impl Into<String>) -> Self {
190 match self {
191 Self::Done { reason } => Self::DoneWithConcerns {
192 reason,
193 concerns: vec![concern.into()],
194 },
195 Self::DoneWithConcerns {
196 reason,
197 mut concerns,
198 } => {
199 concerns.push(concern.into());
200 Self::DoneWithConcerns { reason, concerns }
201 }
202 other => other,
203 }
204 }
205}
206
207pub fn enforce_verification_closeout(
208 proposed: RunFinalStatus,
209 gates: &[VerificationGate],
210) -> RunFinalStatus {
211 if gates.is_empty() {
212 return proposed;
213 }
214
215 let mut concerns = Vec::new();
216 let mut blocked = Vec::new();
217 for gate in gates.iter().filter(|gate| gate.is_required()) {
218 match gate.closeout_effect() {
219 VerificationCloseoutEffect::AllowsDone => {}
220 VerificationCloseoutEffect::BlocksDone => blocked.push(verification_gate_message(gate)),
221 VerificationCloseoutEffect::BlocksDoneWithConcerns => {
222 concerns.push(verification_gate_message(gate));
223 }
224 }
225 }
226
227 if blocked.is_empty() && concerns.is_empty() {
228 return proposed;
229 }
230
231 if !blocked.is_empty() {
232 let message = blocked.join("; ");
233 return match proposed {
234 RunFinalStatus::Cancelled | RunFinalStatus::Failed { .. } => proposed,
235 _ => RunFinalStatus::Blocked {
236 reason: StopReason::ExecutionBlocked,
237 message,
238 },
239 };
240 }
241
242 match proposed {
243 RunFinalStatus::Done { reason } => RunFinalStatus::DoneWithConcerns { reason, concerns },
244 RunFinalStatus::DoneWithConcerns {
245 reason,
246 concerns: mut existing,
247 } => {
248 existing.extend(concerns);
249 RunFinalStatus::DoneWithConcerns {
250 reason,
251 concerns: existing,
252 }
253 }
254 RunFinalStatus::Blocked { .. }
255 | RunFinalStatus::NeedsUserInput { .. }
256 | RunFinalStatus::Cancelled
257 | RunFinalStatus::Failed { .. } => proposed,
258 }
259}
260
261#[allow(dead_code)]
262pub fn enforce_verification_decision(
263 decision: LoopDecision,
264 gates: &[VerificationGate],
265) -> LoopDecision {
266 match decision {
267 LoopDecision::Finish { status } => LoopDecision::Finish {
268 status: enforce_verification_closeout(status, gates),
269 },
270 LoopDecision::Continue { .. } => decision,
271 }
272}
273
274fn verification_gate_message(gate: &VerificationGate) -> String {
275 let name = if gate.name.is_empty() {
276 &gate.id
277 } else {
278 &gate.name
279 };
280 let status = match gate.status {
281 VerificationGateStatus::Pending => "pending",
282 VerificationGateStatus::Running => "still running",
283 VerificationGateStatus::Passed => "passed",
284 VerificationGateStatus::Failed => "failed",
285 VerificationGateStatus::Skipped => "skipped",
286 VerificationGateStatus::Blocked => "blocked",
287 };
288 let detail = gate.reason.as_deref().or_else(|| {
289 gate.result
290 .as_ref()
291 .and_then(|result| result.summary.as_deref())
292 });
293 match detail {
294 Some(detail) if !detail.is_empty() => {
295 format!("required verification {status}: {name} ({detail})")
296 }
297 _ => format!("required verification {status}: {name}"),
298 }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(rename_all = "snake_case")]
305pub enum ToolRisk {
306 ReadOnly,
307 Mutable,
308 ExternalSideEffect,
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "snake_case")]
313pub enum ToolExecutionMode {
314 ParallelReadonlyThenSequentialMutable,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318pub struct PlannedToolCall {
319 pub index: usize,
320 pub id: String,
321 pub name: String,
322 pub args: serde_json::Value,
323 pub risk: ToolRisk,
324 pub retry_safe: bool,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328pub struct ToolPlan {
329 pub mode: ToolExecutionMode,
330 pub calls: Vec<PlannedToolCall>,
331}
332
333impl ToolPlan {
334 pub fn empty() -> Self {
335 Self {
336 mode: ToolExecutionMode::ParallelReadonlyThenSequentialMutable,
337 calls: Vec::new(),
338 }
339 }
340
341 pub fn len(&self) -> usize {
342 self.calls.len()
343 }
344
345 pub fn is_empty(&self) -> bool {
346 self.calls.is_empty()
347 }
348}
349
350#[cfg(test)]
351mod workflow_closeout_tests {
352 use super::*;
353 use crate::workflow::{
354 VerificationGate, VerificationGateRequirement, VerificationGateResult,
355 VerificationGateStatus,
356 };
357
358 fn done() -> RunFinalStatus {
359 RunFinalStatus::Done {
360 reason: StopReason::WorkCompleted,
361 }
362 }
363
364 #[test]
365 fn workflow_closeout_preserves_done_when_no_gates_or_required_passed() {
366 assert_eq!(enforce_verification_closeout(done(), &[]), done());
367
368 let mut gate = VerificationGate::command("unit", "cargo test");
369 gate.mark_passed(VerificationGateResult::passed(0));
370 assert_eq!(enforce_verification_closeout(done(), &[gate]), done());
371 }
372
373 #[test]
374 fn workflow_closeout_downgrades_done_for_required_failed_or_skipped_gates() {
375 let mut failed = VerificationGate::command("unit", "cargo test");
376 failed.mark_failed(VerificationGateResult {
377 summary: Some("tests failed".into()),
378 ..VerificationGateResult::failed(101)
379 });
380 let status = enforce_verification_closeout(done(), &[failed]);
381 match status {
382 RunFinalStatus::DoneWithConcerns { reason, concerns } => {
383 assert_eq!(reason, StopReason::WorkCompleted);
384 assert!(concerns
385 .iter()
386 .any(|concern| concern.contains("required verification failed: unit")));
387 assert!(concerns
388 .iter()
389 .any(|concern| concern.contains("tests failed")));
390 }
391 other => panic!("expected DoneWithConcerns, got {other:?}"),
392 }
393
394 let mut skipped = VerificationGate::command("fmt", "cargo fmt --check");
395 skipped.mark_skipped("formatter unavailable");
396 let status = enforce_verification_closeout(done(), &[skipped]);
397 match status {
398 RunFinalStatus::DoneWithConcerns { concerns, .. } => {
399 assert!(concerns
400 .iter()
401 .any(|concern| concern.contains("required verification skipped: fmt")));
402 assert!(concerns
403 .iter()
404 .any(|concern| concern.contains("formatter unavailable")));
405 }
406 other => panic!("expected DoneWithConcerns, got {other:?}"),
407 }
408 }
409
410 #[test]
411 fn workflow_closeout_blocks_done_for_required_blocked_gates() {
412 let mut gate = VerificationGate::command("unit", "cargo test");
413 gate.mark_blocked("cargo missing");
414 let status = enforce_verification_closeout(done(), &[gate]);
415 match status {
416 RunFinalStatus::Blocked { reason, message } => {
417 assert_eq!(reason, StopReason::ExecutionBlocked);
418 assert!(message.contains("required verification blocked: unit"));
419 assert!(message.contains("cargo missing"));
420 }
421 other => panic!("expected Blocked, got {other:?}"),
422 }
423 }
424
425 #[test]
426 fn workflow_closeout_pending_and_running_required_gates_cannot_report_done() {
427 let pending = VerificationGate::command("unit", "cargo test");
428 let status = enforce_verification_closeout(done(), &[pending]);
429 assert!(matches!(status, RunFinalStatus::DoneWithConcerns { .. }));
430
431 let mut running = VerificationGate::command("fmt", "cargo fmt --check");
432 running.mark_running();
433 let status = enforce_verification_closeout(done(), &[running]);
434 match status {
435 RunFinalStatus::DoneWithConcerns { concerns, .. } => {
436 assert!(concerns
437 .iter()
438 .any(|concern| concern.contains("required verification still running: fmt")));
439 }
440 other => panic!("expected DoneWithConcerns, got {other:?}"),
441 }
442 }
443
444 #[test]
445 fn workflow_closeout_optional_failed_gate_does_not_block_done() {
446 let mut gate = VerificationGate::command("smoke", "cargo test smoke");
447 gate.requirement = VerificationGateRequirement::Optional;
448 gate.mark_failed(VerificationGateResult::failed(1));
449 assert_eq!(enforce_verification_closeout(done(), &[gate]), done());
450 }
451
452 #[test]
453 fn workflow_closeout_merges_existing_concerns_and_wraps_loop_decision() {
454 let mut gate = VerificationGate::command("fmt", "cargo fmt --check");
455 gate.status = VerificationGateStatus::Failed;
456 let proposed = RunFinalStatus::DoneWithConcerns {
457 reason: StopReason::NoProgress,
458 concerns: vec!["pre-existing".into()],
459 };
460 let status = enforce_verification_closeout(proposed, &[gate]);
461 match status {
462 RunFinalStatus::DoneWithConcerns { reason, concerns } => {
463 assert_eq!(reason, StopReason::NoProgress);
464 assert!(concerns.iter().any(|concern| concern == "pre-existing"));
465 assert!(concerns
466 .iter()
467 .any(|concern| concern.contains("required verification failed: fmt")));
468 }
469 other => panic!("expected DoneWithConcerns, got {other:?}"),
470 }
471
472 let mut blocked = VerificationGate::command("unit", "cargo test");
473 blocked.mark_blocked("timeout");
474 let decision = LoopDecision::Finish { status: done() };
475 assert!(matches!(
476 enforce_verification_decision(decision, &[blocked]),
477 LoopDecision::Finish {
478 status: RunFinalStatus::Blocked { .. }
479 }
480 ));
481 }
482}