1use serde::{Deserialize, Serialize};
19
20use crate::invariant::InvariantClass;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[non_exhaustive]
35pub enum StopReason {
36 Converged,
42
43 CriteriaMet {
46 criteria: Vec<String>,
48 },
49
50 UserCancelled,
53
54 HumanInterventionRequired {
59 criteria: Vec<String>,
61 approval_refs: Vec<String>,
63 },
64
65 CycleBudgetExhausted {
71 cycles_executed: u32,
73 limit: u32,
75 },
76
77 FactBudgetExhausted {
80 facts_count: u32,
82 limit: u32,
84 },
85
86 TokenBudgetExhausted {
89 tokens_consumed: u64,
91 limit: u64,
93 },
94
95 TimeBudgetExhausted {
98 duration_ms: u64,
100 limit_ms: u64,
102 },
103
104 InvariantViolated {
110 class: InvariantClass,
112 name: String,
114 reason: String,
116 },
117
118 PromotionRejected {
121 proposal_id: String,
123 reason: String,
125 },
126
127 Error {
133 message: String,
135 category: ErrorCategory,
137 },
138
139 AgentRefused {
142 agent_id: String,
144 reason: String,
146 },
147
148 HitlGatePending {
156 gate_id: String,
158 proposal_id: String,
160 summary: String,
162 agent_id: String,
164 cycle: u32,
166 },
167}
168
169impl StopReason {
170 pub fn converged() -> Self {
176 Self::Converged
177 }
178
179 pub fn criteria_met(criteria: Vec<String>) -> Self {
181 Self::CriteriaMet { criteria }
182 }
183
184 pub fn user_cancelled() -> Self {
186 Self::UserCancelled
187 }
188
189 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 pub fn cycle_budget_exhausted(cycles_executed: u32, limit: u32) -> Self {
199 Self::CycleBudgetExhausted {
200 cycles_executed,
201 limit,
202 }
203 }
204
205 pub fn fact_budget_exhausted(facts_count: u32, limit: u32) -> Self {
207 Self::FactBudgetExhausted { facts_count, limit }
208 }
209
210 pub fn token_budget_exhausted(tokens_consumed: u64, limit: u64) -> Self {
212 Self::TokenBudgetExhausted {
213 tokens_consumed,
214 limit,
215 }
216 }
217
218 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 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 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 pub fn error(message: impl Into<String>, category: ErrorCategory) -> Self {
249 Self::Error {
250 message: message.into(),
251 category,
252 }
253 }
254
255 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 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 pub fn is_success(&self) -> bool {
286 matches!(
287 self,
288 Self::Converged | Self::CriteriaMet { .. } | Self::UserCancelled
289 )
290 }
291
292 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 pub fn is_validation_failure(&self) -> bool {
305 matches!(
306 self,
307 Self::InvariantViolated { .. } | Self::PromotionRejected { .. }
308 )
309 }
310
311 pub fn is_error(&self) -> bool {
313 matches!(self, Self::Error { .. } | Self::AgentRefused { .. })
314 }
315
316 pub fn is_hitl_pending(&self) -> bool {
318 matches!(self, Self::HitlGatePending { .. })
319 }
320
321 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
412pub enum ErrorCategory {
413 Internal,
415 Configuration,
417 External,
419 Resource,
421 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}