Skip to main content

imp_core/agent/
loop_state.rs

1use serde::{Deserialize, Serialize};
2
3use crate::workflow::{VerificationCloseoutEffect, VerificationGate, VerificationGateStatus};
4
5/// Coarse-grained phase of a single agent turn.
6///
7/// This is intentionally small and mechanical: it describes where the runtime
8/// is, not why the runtime should continue or stop. Policy decisions live in
9/// [`LoopDecision`] / [`RunFinalStatus`].
10#[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/// Minimal visible state for a turn. This is the first slice of making turn
45/// state explicit; later slices can add durable IDs and recovery cursors.
46#[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/// Why the agent is allowed to spend another turn.
84///
85/// There should be no anonymous `continue`; long-running autonomy is safe only
86/// when each continuation has a concrete, inspectable reason.
87#[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/// Semantic reason the runtime stopped intentionally.
112#[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/// Tool risk visible before execution. This is deliberately conservative and
302/// can be refined as tool metadata grows.
303#[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}