use serde::{Deserialize, Serialize};
use crate::invariant::InvariantClass;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum StopReason {
Converged,
CriteriaMet {
criteria: Vec<String>,
},
UserCancelled,
HumanInterventionRequired {
criteria: Vec<String>,
approval_refs: Vec<String>,
},
CycleBudgetExhausted {
cycles_executed: u32,
limit: u32,
},
FactBudgetExhausted {
facts_count: u32,
limit: u32,
},
TokenBudgetExhausted {
tokens_consumed: u64,
limit: u64,
},
TimeBudgetExhausted {
duration_ms: u64,
limit_ms: u64,
},
InvariantViolated {
class: InvariantClass,
name: String,
reason: String,
},
PromotionRejected {
proposal_id: String,
reason: String,
},
Error {
message: String,
category: ErrorCategory,
},
AgentRefused {
agent_id: String,
reason: String,
},
HitlGatePending {
gate_id: String,
proposal_id: String,
summary: String,
agent_id: String,
cycle: u32,
},
}
impl StopReason {
pub fn converged() -> Self {
Self::Converged
}
pub fn criteria_met(criteria: Vec<String>) -> Self {
Self::CriteriaMet { criteria }
}
pub fn user_cancelled() -> Self {
Self::UserCancelled
}
pub fn human_intervention_required(criteria: Vec<String>, approval_refs: Vec<String>) -> Self {
Self::HumanInterventionRequired {
criteria,
approval_refs,
}
}
pub fn cycle_budget_exhausted(cycles_executed: u32, limit: u32) -> Self {
Self::CycleBudgetExhausted {
cycles_executed,
limit,
}
}
pub fn fact_budget_exhausted(facts_count: u32, limit: u32) -> Self {
Self::FactBudgetExhausted { facts_count, limit }
}
pub fn token_budget_exhausted(tokens_consumed: u64, limit: u64) -> Self {
Self::TokenBudgetExhausted {
tokens_consumed,
limit,
}
}
pub fn time_budget_exhausted(duration_ms: u64, limit_ms: u64) -> Self {
Self::TimeBudgetExhausted {
duration_ms,
limit_ms,
}
}
pub fn invariant_violated(
class: InvariantClass,
name: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self::InvariantViolated {
class,
name: name.into(),
reason: reason.into(),
}
}
pub fn promotion_rejected(proposal_id: impl Into<String>, reason: impl Into<String>) -> Self {
Self::PromotionRejected {
proposal_id: proposal_id.into(),
reason: reason.into(),
}
}
pub fn error(message: impl Into<String>, category: ErrorCategory) -> Self {
Self::Error {
message: message.into(),
category,
}
}
pub fn agent_refused(agent_id: impl Into<String>, reason: impl Into<String>) -> Self {
Self::AgentRefused {
agent_id: agent_id.into(),
reason: reason.into(),
}
}
pub fn hitl_gate_pending(
gate_id: impl Into<String>,
proposal_id: impl Into<String>,
summary: impl Into<String>,
agent_id: impl Into<String>,
cycle: u32,
) -> Self {
Self::HitlGatePending {
gate_id: gate_id.into(),
proposal_id: proposal_id.into(),
summary: summary.into(),
agent_id: agent_id.into(),
cycle,
}
}
pub fn is_success(&self) -> bool {
matches!(
self,
Self::Converged | Self::CriteriaMet { .. } | Self::UserCancelled
)
}
pub fn is_budget_exhausted(&self) -> bool {
matches!(
self,
Self::CycleBudgetExhausted { .. }
| Self::FactBudgetExhausted { .. }
| Self::TokenBudgetExhausted { .. }
| Self::TimeBudgetExhausted { .. }
)
}
pub fn is_validation_failure(&self) -> bool {
matches!(
self,
Self::InvariantViolated { .. } | Self::PromotionRejected { .. }
)
}
pub fn is_error(&self) -> bool {
matches!(self, Self::Error { .. } | Self::AgentRefused { .. })
}
pub fn is_hitl_pending(&self) -> bool {
matches!(self, Self::HitlGatePending { .. })
}
pub fn is_human_intervention_required(&self) -> bool {
matches!(self, Self::HumanInterventionRequired { .. })
}
}
impl std::fmt::Display for StopReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Converged => write!(f, "Converged"),
Self::CriteriaMet { criteria } => {
write!(f, "Criteria met: {}", criteria.join(", "))
}
Self::UserCancelled => write!(f, "User cancelled"),
Self::HumanInterventionRequired {
criteria,
approval_refs,
} => {
if approval_refs.is_empty() {
write!(
f,
"Human intervention required for: {}",
criteria.join(", ")
)
} else {
write!(
f,
"Human intervention required for: {} (refs: {})",
criteria.join(", "),
approval_refs.join(", ")
)
}
}
Self::CycleBudgetExhausted {
cycles_executed,
limit,
} => {
write!(f, "Cycle budget exhausted: {}/{}", cycles_executed, limit)
}
Self::FactBudgetExhausted { facts_count, limit } => {
write!(f, "Fact budget exhausted: {}/{}", facts_count, limit)
}
Self::TokenBudgetExhausted {
tokens_consumed,
limit,
} => {
write!(f, "Token budget exhausted: {}/{}", tokens_consumed, limit)
}
Self::TimeBudgetExhausted {
duration_ms,
limit_ms,
} => {
write!(f, "Time budget exhausted: {}ms/{}ms", duration_ms, limit_ms)
}
Self::InvariantViolated {
class,
name,
reason,
} => {
write!(f, "{:?} invariant '{}' violated: {}", class, name, reason)
}
Self::PromotionRejected {
proposal_id,
reason,
} => {
write!(f, "Promotion rejected for '{}': {}", proposal_id, reason)
}
Self::Error { message, category } => {
write!(f, "Error ({:?}): {}", category, message)
}
Self::AgentRefused { agent_id, reason } => {
write!(f, "Agent '{}' refused: {}", agent_id, reason)
}
Self::HitlGatePending {
gate_id,
agent_id,
cycle,
..
} => {
write!(
f,
"HITL gate pending: {} (agent: {}, cycle: {})",
gate_id, agent_id, cycle
)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ErrorCategory {
Internal,
Configuration,
External,
Resource,
Unknown,
}
impl Default for ErrorCategory {
fn default() -> Self {
Self::Unknown
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_converged_constructor() {
let reason = StopReason::converged();
assert!(matches!(reason, StopReason::Converged));
assert!(reason.is_success());
assert!(!reason.is_budget_exhausted());
assert!(!reason.is_validation_failure());
assert!(!reason.is_error());
}
#[test]
fn test_criteria_met_constructor() {
let reason = StopReason::criteria_met(vec!["goal1".into(), "goal2".into()]);
if let StopReason::CriteriaMet { criteria } = &reason {
assert_eq!(criteria.len(), 2);
assert_eq!(criteria[0], "goal1");
assert_eq!(criteria[1], "goal2");
} else {
panic!("Expected CriteriaMet");
}
assert!(reason.is_success());
}
#[test]
fn test_user_cancelled_constructor() {
let reason = StopReason::user_cancelled();
assert!(matches!(reason, StopReason::UserCancelled));
assert!(reason.is_success());
}
#[test]
fn test_human_intervention_required_constructor() {
let reason = StopReason::human_intervention_required(
vec!["payment.confirmed".into()],
vec!["approval:top-up".into()],
);
if let StopReason::HumanInterventionRequired {
criteria,
approval_refs,
} = &reason
{
assert_eq!(criteria, &vec!["payment.confirmed".to_string()]);
assert_eq!(approval_refs, &vec!["approval:top-up".to_string()]);
} else {
panic!("Expected HumanInterventionRequired");
}
assert!(!reason.is_success());
assert!(reason.is_human_intervention_required());
}
#[test]
fn test_cycle_budget_exhausted_constructor() {
let reason = StopReason::cycle_budget_exhausted(100, 100);
if let StopReason::CycleBudgetExhausted {
cycles_executed,
limit,
} = &reason
{
assert_eq!(*cycles_executed, 100);
assert_eq!(*limit, 100);
} else {
panic!("Expected CycleBudgetExhausted");
}
assert!(!reason.is_success());
assert!(reason.is_budget_exhausted());
}
#[test]
fn test_fact_budget_exhausted_constructor() {
let reason = StopReason::fact_budget_exhausted(10000, 10000);
assert!(reason.is_budget_exhausted());
}
#[test]
fn test_token_budget_exhausted_constructor() {
let reason = StopReason::token_budget_exhausted(1_000_000, 1_000_000);
assert!(reason.is_budget_exhausted());
}
#[test]
fn test_time_budget_exhausted_constructor() {
let reason = StopReason::time_budget_exhausted(60000, 60000);
assert!(reason.is_budget_exhausted());
}
#[test]
fn test_invariant_violated_constructor() {
let reason = StopReason::invariant_violated(
InvariantClass::Structural,
"no_empty_facts",
"Found empty fact content",
);
if let StopReason::InvariantViolated {
class,
name,
reason: r,
} = &reason
{
assert_eq!(*class, InvariantClass::Structural);
assert_eq!(name, "no_empty_facts");
assert_eq!(r, "Found empty fact content");
} else {
panic!("Expected InvariantViolated");
}
assert!(reason.is_validation_failure());
}
#[test]
fn test_promotion_rejected_constructor() {
let reason = StopReason::promotion_rejected("proposal-123", "schema validation failed");
assert!(reason.is_validation_failure());
}
#[test]
fn test_error_constructor() {
let reason = StopReason::error("connection refused", ErrorCategory::External);
if let StopReason::Error { message, category } = &reason {
assert_eq!(message, "connection refused");
assert_eq!(*category, ErrorCategory::External);
} else {
panic!("Expected Error");
}
assert!(reason.is_error());
}
#[test]
fn test_agent_refused_constructor() {
let reason = StopReason::agent_refused("agent-1", "cannot process unsafe content");
assert!(reason.is_error());
}
#[test]
fn test_display_converged() {
let reason = StopReason::converged();
assert_eq!(reason.to_string(), "Converged");
}
#[test]
fn test_display_criteria_met() {
let reason = StopReason::criteria_met(vec!["g1".into(), "g2".into()]);
assert_eq!(reason.to_string(), "Criteria met: g1, g2");
}
#[test]
fn test_display_human_intervention_required() {
let reason = StopReason::human_intervention_required(
vec!["payment.confirmed".into()],
vec!["approval:top-up".into()],
);
assert_eq!(
reason.to_string(),
"Human intervention required for: payment.confirmed (refs: approval:top-up)"
);
}
#[test]
fn test_display_cycle_budget_exhausted() {
let reason = StopReason::cycle_budget_exhausted(50, 100);
assert_eq!(reason.to_string(), "Cycle budget exhausted: 50/100");
}
#[test]
fn test_display_invariant_violated() {
let reason =
StopReason::invariant_violated(InvariantClass::Semantic, "test_inv", "test reason");
assert_eq!(
reason.to_string(),
"Semantic invariant 'test_inv' violated: test reason"
);
}
#[test]
fn test_display_error() {
let reason = StopReason::error("oops", ErrorCategory::Internal);
assert_eq!(reason.to_string(), "Error (Internal): oops");
}
#[test]
fn test_serde_roundtrip() {
let reasons = vec![
StopReason::converged(),
StopReason::criteria_met(vec!["done".into()]),
StopReason::human_intervention_required(
vec!["approval".into()],
vec!["workflow:1".into()],
),
StopReason::cycle_budget_exhausted(10, 10),
StopReason::invariant_violated(InvariantClass::Acceptance, "test", "reason"),
StopReason::error("msg", ErrorCategory::Configuration),
];
for reason in reasons {
let json = serde_json::to_string(&reason).expect("serialize");
let back: StopReason = serde_json::from_str(&json).expect("deserialize");
assert_eq!(reason, back);
}
}
#[test]
fn test_error_category_default() {
let category = ErrorCategory::default();
assert_eq!(category, ErrorCategory::Unknown);
}
}