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