Skip to main content

converge_core/gates/
stop.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Stop reasons for engine termination.
5//!
6//! StopReason is an exhaustive enumeration of why execution stopped.
7//! This provides complete audit trails and enables proper handling
8//! of different termination conditions.
9//!
10//! # Categories
11//!
12//! - **Successful**: Converged, CriteriaMet, UserCancelled
13//! - **Human intervention**: HumanInterventionRequired, HitlGatePending
14//! - **Budget exhaustion**: CycleBudgetExhausted, FactBudgetExhausted, TokenBudgetExhausted
15//! - **Validation failures**: InvariantViolated, PromotionRejected
16//! - **System errors**: Error, AgentRefused
17
18use serde::{Deserialize, Serialize};
19
20use crate::invariant::InvariantClass;
21
22/// Why execution stopped. Exhaustive enumeration for audit trails.
23///
24/// Every engine run terminates with a StopReason. This enables:
25/// - Audit: Know exactly why execution ended
26/// - Recovery: Different reasons may have different retry strategies
27/// - Monitoring: Track termination patterns
28///
29/// # Non-Exhaustive
30///
31/// Marked `#[non_exhaustive]` to allow adding new reasons without
32/// breaking existing match statements.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[non_exhaustive]
35pub enum StopReason {
36    // ========================================================================
37    // Successful termination
38    // ========================================================================
39    /// Convergence reached - context stabilized (fixed point).
40    /// This is the ideal outcome.
41    Converged,
42
43    /// Intent criteria satisfied before convergence.
44    /// All success conditions met, no need to continue.
45    CriteriaMet {
46        /// Which criteria were satisfied
47        criteria: Vec<String>,
48    },
49
50    /// User requested stop via cancellation.
51    /// Graceful termination, not an error.
52    UserCancelled,
53
54    /// Agents converged, but completion is blocked on human intervention.
55    /// Unlike `HitlGatePending`, the engine did not pause mid-merge; the
56    /// application-level criteria evaluation determined that a human must act
57    /// before the truth can be considered complete.
58    HumanInterventionRequired {
59        /// Which required criteria are blocked.
60        criteria: Vec<String>,
61        /// Optional approval/workflow references surfaced by the evaluator.
62        approval_refs: Vec<String>,
63    },
64
65    // ========================================================================
66    // Budget exhaustion
67    // ========================================================================
68    /// Maximum execution cycles exceeded.
69    /// May indicate non-converging problem or need for larger budget.
70    CycleBudgetExhausted {
71        /// How many cycles were executed
72        cycles_executed: u32,
73        /// What the limit was
74        limit: u32,
75    },
76
77    /// Maximum facts in context exceeded.
78    /// Prevents unbounded context growth.
79    FactBudgetExhausted {
80        /// How many facts were in context
81        facts_count: u32,
82        /// What the limit was
83        limit: u32,
84    },
85
86    /// Maximum LLM tokens exceeded.
87    /// Cost control for LLM-based operations.
88    TokenBudgetExhausted {
89        /// How many tokens were consumed
90        tokens_consumed: u64,
91        /// What the limit was
92        limit: u64,
93    },
94
95    /// Maximum wall-clock time exceeded.
96    /// Prevents indefinite execution.
97    TimeBudgetExhausted {
98        /// How long execution ran (milliseconds)
99        duration_ms: u64,
100        /// What the limit was (milliseconds)
101        limit_ms: u64,
102    },
103
104    // ========================================================================
105    // Validation failures
106    // ========================================================================
107    /// An invariant was violated.
108    /// Includes the class (Structural/Semantic/Acceptance) and invariant name.
109    InvariantViolated {
110        /// Which class of invariant
111        class: InvariantClass,
112        /// Name of the invariant
113        name: String,
114        /// Description of the violation
115        reason: String,
116    },
117
118    /// Promotion gate rejected a proposal.
119    /// Proposal failed validation and could not be promoted.
120    PromotionRejected {
121        /// ID of the rejected proposal
122        proposal_id: String,
123        /// Why it was rejected
124        reason: String,
125    },
126
127    // ========================================================================
128    // System errors
129    // ========================================================================
130    /// Unrecoverable error during execution.
131    /// Something went wrong that couldn't be handled.
132    Error {
133        /// Error message
134        message: String,
135        /// Error category for programmatic handling
136        category: ErrorCategory,
137    },
138
139    /// An agent refused to continue.
140    /// Suggestor explicitly declined to produce output.
141    AgentRefused {
142        /// ID of the refusing agent
143        agent_id: String,
144        /// Why it refused
145        reason: String,
146    },
147
148    // ========================================================================
149    // HITL gate pause
150    // ========================================================================
151    /// Convergence paused at a human-in-the-loop gate.
152    /// A proposal requires human approval before convergence can continue.
153    /// The hosting application should notify the human and call
154    /// `Engine::resume()` with the decision.
155    HitlGatePending {
156        /// Unique ID for this gate (used to resume)
157        gate_id: String,
158        /// ID of the proposal awaiting approval
159        proposal_id: String,
160        /// Human-readable summary of the proposal
161        summary: String,
162        /// Which agent made the proposal
163        agent_id: String,
164        /// Cycle at which convergence was paused
165        cycle: u32,
166    },
167}
168
169impl StopReason {
170    // ========================================================================
171    // Constructor helpers
172    // ========================================================================
173
174    /// Create a Converged stop reason.
175    pub fn converged() -> Self {
176        Self::Converged
177    }
178
179    /// Create a CriteriaMet stop reason.
180    pub fn criteria_met(criteria: Vec<String>) -> Self {
181        Self::CriteriaMet { criteria }
182    }
183
184    /// Create a UserCancelled stop reason.
185    pub fn user_cancelled() -> Self {
186        Self::UserCancelled
187    }
188
189    /// Create a HumanInterventionRequired stop reason.
190    pub fn human_intervention_required(criteria: Vec<String>, approval_refs: Vec<String>) -> Self {
191        Self::HumanInterventionRequired {
192            criteria,
193            approval_refs,
194        }
195    }
196
197    /// Create a CycleBudgetExhausted stop reason.
198    pub fn cycle_budget_exhausted(cycles_executed: u32, limit: u32) -> Self {
199        Self::CycleBudgetExhausted {
200            cycles_executed,
201            limit,
202        }
203    }
204
205    /// Create a FactBudgetExhausted stop reason.
206    pub fn fact_budget_exhausted(facts_count: u32, limit: u32) -> Self {
207        Self::FactBudgetExhausted { facts_count, limit }
208    }
209
210    /// Create a TokenBudgetExhausted stop reason.
211    pub fn token_budget_exhausted(tokens_consumed: u64, limit: u64) -> Self {
212        Self::TokenBudgetExhausted {
213            tokens_consumed,
214            limit,
215        }
216    }
217
218    /// Create a TimeBudgetExhausted stop reason.
219    pub fn time_budget_exhausted(duration_ms: u64, limit_ms: u64) -> Self {
220        Self::TimeBudgetExhausted {
221            duration_ms,
222            limit_ms,
223        }
224    }
225
226    /// Create an InvariantViolated stop reason.
227    pub fn invariant_violated(
228        class: InvariantClass,
229        name: impl Into<String>,
230        reason: impl Into<String>,
231    ) -> Self {
232        Self::InvariantViolated {
233            class,
234            name: name.into(),
235            reason: reason.into(),
236        }
237    }
238
239    /// Create a PromotionRejected stop reason.
240    pub fn promotion_rejected(proposal_id: impl Into<String>, reason: impl Into<String>) -> Self {
241        Self::PromotionRejected {
242            proposal_id: proposal_id.into(),
243            reason: reason.into(),
244        }
245    }
246
247    /// Create an Error stop reason.
248    pub fn error(message: impl Into<String>, category: ErrorCategory) -> Self {
249        Self::Error {
250            message: message.into(),
251            category,
252        }
253    }
254
255    /// Create an AgentRefused stop reason.
256    pub fn agent_refused(agent_id: impl Into<String>, reason: impl Into<String>) -> Self {
257        Self::AgentRefused {
258            agent_id: agent_id.into(),
259            reason: reason.into(),
260        }
261    }
262
263    /// Create a HitlGatePending stop reason.
264    pub fn hitl_gate_pending(
265        gate_id: impl Into<String>,
266        proposal_id: impl Into<String>,
267        summary: impl Into<String>,
268        agent_id: impl Into<String>,
269        cycle: u32,
270    ) -> Self {
271        Self::HitlGatePending {
272            gate_id: gate_id.into(),
273            proposal_id: proposal_id.into(),
274            summary: summary.into(),
275            agent_id: agent_id.into(),
276            cycle,
277        }
278    }
279
280    // ========================================================================
281    // Query methods
282    // ========================================================================
283
284    /// Returns true if this is a successful termination.
285    pub fn is_success(&self) -> bool {
286        matches!(
287            self,
288            Self::Converged | Self::CriteriaMet { .. } | Self::UserCancelled
289        )
290    }
291
292    /// Returns true if this is a budget exhaustion.
293    pub fn is_budget_exhausted(&self) -> bool {
294        matches!(
295            self,
296            Self::CycleBudgetExhausted { .. }
297                | Self::FactBudgetExhausted { .. }
298                | Self::TokenBudgetExhausted { .. }
299                | Self::TimeBudgetExhausted { .. }
300        )
301    }
302
303    /// Returns true if this is a validation failure.
304    pub fn is_validation_failure(&self) -> bool {
305        matches!(
306            self,
307            Self::InvariantViolated { .. } | Self::PromotionRejected { .. }
308        )
309    }
310
311    /// Returns true if this is an error condition.
312    pub fn is_error(&self) -> bool {
313        matches!(self, Self::Error { .. } | Self::AgentRefused { .. })
314    }
315
316    /// Returns true if convergence is paused at a HITL gate.
317    pub fn is_hitl_pending(&self) -> bool {
318        matches!(self, Self::HitlGatePending { .. })
319    }
320
321    /// Returns true if completion is blocked on human intervention.
322    pub fn is_human_intervention_required(&self) -> bool {
323        matches!(self, Self::HumanInterventionRequired { .. })
324    }
325}
326
327impl std::fmt::Display for StopReason {
328    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329        match self {
330            Self::Converged => write!(f, "Converged"),
331            Self::CriteriaMet { criteria } => {
332                write!(f, "Criteria met: {}", criteria.join(", "))
333            }
334            Self::UserCancelled => write!(f, "User cancelled"),
335            Self::HumanInterventionRequired {
336                criteria,
337                approval_refs,
338            } => {
339                if approval_refs.is_empty() {
340                    write!(
341                        f,
342                        "Human intervention required for: {}",
343                        criteria.join(", ")
344                    )
345                } else {
346                    write!(
347                        f,
348                        "Human intervention required for: {} (refs: {})",
349                        criteria.join(", "),
350                        approval_refs.join(", ")
351                    )
352                }
353            }
354            Self::CycleBudgetExhausted {
355                cycles_executed,
356                limit,
357            } => {
358                write!(f, "Cycle budget exhausted: {}/{}", cycles_executed, limit)
359            }
360            Self::FactBudgetExhausted { facts_count, limit } => {
361                write!(f, "Fact budget exhausted: {}/{}", facts_count, limit)
362            }
363            Self::TokenBudgetExhausted {
364                tokens_consumed,
365                limit,
366            } => {
367                write!(f, "Token budget exhausted: {}/{}", tokens_consumed, limit)
368            }
369            Self::TimeBudgetExhausted {
370                duration_ms,
371                limit_ms,
372            } => {
373                write!(f, "Time budget exhausted: {}ms/{}ms", duration_ms, limit_ms)
374            }
375            Self::InvariantViolated {
376                class,
377                name,
378                reason,
379            } => {
380                write!(f, "{:?} invariant '{}' violated: {}", class, name, reason)
381            }
382            Self::PromotionRejected {
383                proposal_id,
384                reason,
385            } => {
386                write!(f, "Promotion rejected for '{}': {}", proposal_id, reason)
387            }
388            Self::Error { message, category } => {
389                write!(f, "Error ({:?}): {}", category, message)
390            }
391            Self::AgentRefused { agent_id, reason } => {
392                write!(f, "Suggestor '{}' refused: {}", agent_id, reason)
393            }
394            Self::HitlGatePending {
395                gate_id,
396                agent_id,
397                cycle,
398                ..
399            } => {
400                write!(
401                    f,
402                    "HITL gate pending: {} (agent: {}, cycle: {})",
403                    gate_id, agent_id, cycle
404                )
405            }
406        }
407    }
408}
409
410/// Category of error for programmatic handling.
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
412pub enum ErrorCategory {
413    /// Internal error in the engine
414    Internal,
415    /// Configuration error
416    Configuration,
417    /// External service error (LLM, database, etc.)
418    External,
419    /// Resource error (memory, disk, etc.)
420    Resource,
421    /// Unknown/uncategorized error
422    Unknown,
423}
424
425impl Default for ErrorCategory {
426    fn default() -> Self {
427        Self::Unknown
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn test_converged_constructor() {
437        let reason = StopReason::converged();
438        assert!(matches!(reason, StopReason::Converged));
439        assert!(reason.is_success());
440        assert!(!reason.is_budget_exhausted());
441        assert!(!reason.is_validation_failure());
442        assert!(!reason.is_error());
443    }
444
445    #[test]
446    fn test_criteria_met_constructor() {
447        let reason = StopReason::criteria_met(vec!["goal1".into(), "goal2".into()]);
448        if let StopReason::CriteriaMet { criteria } = &reason {
449            assert_eq!(criteria.len(), 2);
450            assert_eq!(criteria[0], "goal1");
451            assert_eq!(criteria[1], "goal2");
452        } else {
453            panic!("Expected CriteriaMet");
454        }
455        assert!(reason.is_success());
456    }
457
458    #[test]
459    fn test_user_cancelled_constructor() {
460        let reason = StopReason::user_cancelled();
461        assert!(matches!(reason, StopReason::UserCancelled));
462        assert!(reason.is_success());
463    }
464
465    #[test]
466    fn test_human_intervention_required_constructor() {
467        let reason = StopReason::human_intervention_required(
468            vec!["payment.confirmed".into()],
469            vec!["approval:top-up".into()],
470        );
471        if let StopReason::HumanInterventionRequired {
472            criteria,
473            approval_refs,
474        } = &reason
475        {
476            assert_eq!(criteria, &vec!["payment.confirmed".to_string()]);
477            assert_eq!(approval_refs, &vec!["approval:top-up".to_string()]);
478        } else {
479            panic!("Expected HumanInterventionRequired");
480        }
481        assert!(!reason.is_success());
482        assert!(reason.is_human_intervention_required());
483    }
484
485    #[test]
486    fn test_cycle_budget_exhausted_constructor() {
487        let reason = StopReason::cycle_budget_exhausted(100, 100);
488        if let StopReason::CycleBudgetExhausted {
489            cycles_executed,
490            limit,
491        } = &reason
492        {
493            assert_eq!(*cycles_executed, 100);
494            assert_eq!(*limit, 100);
495        } else {
496            panic!("Expected CycleBudgetExhausted");
497        }
498        assert!(!reason.is_success());
499        assert!(reason.is_budget_exhausted());
500    }
501
502    #[test]
503    fn test_fact_budget_exhausted_constructor() {
504        let reason = StopReason::fact_budget_exhausted(10000, 10000);
505        assert!(reason.is_budget_exhausted());
506    }
507
508    #[test]
509    fn test_token_budget_exhausted_constructor() {
510        let reason = StopReason::token_budget_exhausted(1_000_000, 1_000_000);
511        assert!(reason.is_budget_exhausted());
512    }
513
514    #[test]
515    fn test_time_budget_exhausted_constructor() {
516        let reason = StopReason::time_budget_exhausted(60000, 60000);
517        assert!(reason.is_budget_exhausted());
518    }
519
520    #[test]
521    fn test_invariant_violated_constructor() {
522        let reason = StopReason::invariant_violated(
523            InvariantClass::Structural,
524            "no_empty_facts",
525            "Found empty fact content",
526        );
527        if let StopReason::InvariantViolated {
528            class,
529            name,
530            reason: r,
531        } = &reason
532        {
533            assert_eq!(*class, InvariantClass::Structural);
534            assert_eq!(name, "no_empty_facts");
535            assert_eq!(r, "Found empty fact content");
536        } else {
537            panic!("Expected InvariantViolated");
538        }
539        assert!(reason.is_validation_failure());
540    }
541
542    #[test]
543    fn test_promotion_rejected_constructor() {
544        let reason = StopReason::promotion_rejected("proposal-123", "schema validation failed");
545        assert!(reason.is_validation_failure());
546    }
547
548    #[test]
549    fn test_error_constructor() {
550        let reason = StopReason::error("connection refused", ErrorCategory::External);
551        if let StopReason::Error { message, category } = &reason {
552            assert_eq!(message, "connection refused");
553            assert_eq!(*category, ErrorCategory::External);
554        } else {
555            panic!("Expected Error");
556        }
557        assert!(reason.is_error());
558    }
559
560    #[test]
561    fn test_agent_refused_constructor() {
562        let reason = StopReason::agent_refused("agent-1", "cannot process unsafe content");
563        assert!(reason.is_error());
564    }
565
566    #[test]
567    fn test_display_converged() {
568        let reason = StopReason::converged();
569        assert_eq!(reason.to_string(), "Converged");
570    }
571
572    #[test]
573    fn test_display_criteria_met() {
574        let reason = StopReason::criteria_met(vec!["g1".into(), "g2".into()]);
575        assert_eq!(reason.to_string(), "Criteria met: g1, g2");
576    }
577
578    #[test]
579    fn test_display_human_intervention_required() {
580        let reason = StopReason::human_intervention_required(
581            vec!["payment.confirmed".into()],
582            vec!["approval:top-up".into()],
583        );
584        assert_eq!(
585            reason.to_string(),
586            "Human intervention required for: payment.confirmed (refs: approval:top-up)"
587        );
588    }
589
590    #[test]
591    fn test_display_cycle_budget_exhausted() {
592        let reason = StopReason::cycle_budget_exhausted(50, 100);
593        assert_eq!(reason.to_string(), "Cycle budget exhausted: 50/100");
594    }
595
596    #[test]
597    fn test_display_invariant_violated() {
598        let reason =
599            StopReason::invariant_violated(InvariantClass::Semantic, "test_inv", "test reason");
600        assert_eq!(
601            reason.to_string(),
602            "Semantic invariant 'test_inv' violated: test reason"
603        );
604    }
605
606    #[test]
607    fn test_display_error() {
608        let reason = StopReason::error("oops", ErrorCategory::Internal);
609        assert_eq!(reason.to_string(), "Error (Internal): oops");
610    }
611
612    #[test]
613    fn test_serde_roundtrip() {
614        let reasons = vec![
615            StopReason::converged(),
616            StopReason::criteria_met(vec!["done".into()]),
617            StopReason::human_intervention_required(
618                vec!["approval".into()],
619                vec!["workflow:1".into()],
620            ),
621            StopReason::cycle_budget_exhausted(10, 10),
622            StopReason::invariant_violated(InvariantClass::Acceptance, "test", "reason"),
623            StopReason::error("msg", ErrorCategory::Configuration),
624        ];
625
626        for reason in reasons {
627            let json = serde_json::to_string(&reason).expect("serialize");
628            let back: StopReason = serde_json::from_str(&json).expect("deserialize");
629            assert_eq!(reason, back);
630        }
631    }
632
633    #[test]
634    fn test_error_category_default() {
635        let category = ErrorCategory::default();
636        assert_eq!(category, ErrorCategory::Unknown);
637    }
638}