Skip to main content

av_events/
stop_reason.rs

1//! Stop-reason identifiers.
2//!
3//! Values 0–4 follow upstream OCSF PR #1704. 90 = provider content filter
4//! (provider-native, not enforcement). 91–94 are AgentVisor AI enforcement
5//! extensions. 99 = Other (catch-all; forward-compatible).
6
7use serde::{Deserialize, Serialize};
8
9/// Why an agent execution step (or session) stopped.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[non_exhaustive]
12pub enum StopReason {
13    /// Unknown / not reported.
14    Unknown,
15    /// Natural completion (provider `stop`).
16    Stop,
17    /// Provider truncated at max tokens.
18    MaxTokens,
19    /// Stopped to invoke a tool.
20    ToolUse,
21    /// Provider content filter.
22    ContentFilter,
23    /// AgentVisor AI loop breaker tripped (Module A).
24    LoopDetected,
25    /// Action/token budget exhausted (Module B).
26    BudgetExceeded,
27    /// Policy engine blocked the action (Module B).
28    PolicyBlocked,
29    /// NHI identity validation rejected the caller (Module D).
30    IdentityRejected,
31    /// Session explicitly closed.
32    SessionClosed,
33    /// Other (see `stop_reason` free text).
34    ///
35    /// Round-29 F7: `#[serde(other)]` makes this the deserialize
36    /// fallback for any unrecognised variant. Heterogeneous
37    /// cluster upgrades (harness-N publishing a new stop reason
38    /// variant, harness-N-1 reading it back from the bridge
39    /// during recovery) would otherwise fail the whole event
40    /// parse — dropping evidence and breaking chain
41    /// reconstruction on stragglers. Forward-compat: a peer
42    /// emitter that adds `"FutureVariant"` deserializes to
43    /// `Other`; re-serialization emits `"Other"` (lossy on the
44    /// specific variant name, but the free-text `stop_reason`
45    /// field is the intended carrier for that detail anyway).
46    #[serde(other)]
47    Other,
48}
49
50impl StopReason {
51    /// Numeric `stop_reason_id` for the wire format.
52    pub fn id(self) -> u8 {
53        match self {
54            Self::Unknown => 0,
55            Self::Stop => 1,
56            Self::MaxTokens => 2,
57            Self::ToolUse => 3,
58            Self::SessionClosed => 4,
59            Self::ContentFilter => 90,
60            Self::LoopDetected => 91,
61            Self::BudgetExceeded => 92,
62            Self::PolicyBlocked => 93,
63            Self::IdentityRejected => 94,
64            Self::Other => 99,
65        }
66    }
67
68    /// Canonical caption for the wire format.
69    pub fn caption(self) -> &'static str {
70        match self {
71            Self::Unknown => "Unknown",
72            Self::Stop => "Stop",
73            Self::MaxTokens => "Length",
74            Self::ToolUse => "Tool Use",
75            Self::ContentFilter => "Content Filter",
76            Self::LoopDetected => "Loop Detected",
77            Self::BudgetExceeded => "Budget Exceeded",
78            Self::PolicyBlocked => "Policy Blocked",
79            Self::IdentityRejected => "Identity Rejected",
80            Self::SessionClosed => "Session Closed",
81            Self::Other => "Other",
82        }
83    }
84
85    /// Parse a numeric id back into a reason (inbound tolerance: unknown ids
86    /// map to `Unknown`, never an error — forward compatibility).
87    pub fn from_id(id: u8) -> Self {
88        match id {
89            0 => Self::Unknown,
90            1 => Self::Stop,
91            2 => Self::MaxTokens,
92            3 => Self::ToolUse,
93            4 => Self::SessionClosed,
94            90 => Self::ContentFilter,
95            91 => Self::LoopDetected,
96            92 => Self::BudgetExceeded,
97            93 => Self::PolicyBlocked,
98            94 => Self::IdentityRejected,
99            99 => Self::Other,
100            _ => Self::Unknown,
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    const ALL: &[StopReason] = &[
110        StopReason::Unknown,
111        StopReason::Stop,
112        StopReason::MaxTokens,
113        StopReason::ToolUse,
114        StopReason::ContentFilter,
115        StopReason::LoopDetected,
116        StopReason::BudgetExceeded,
117        StopReason::PolicyBlocked,
118        StopReason::IdentityRejected,
119        StopReason::SessionClosed,
120        StopReason::Other,
121    ];
122
123    #[test]
124    fn id_roundtrip_all_variants() {
125        for &r in ALL {
126            assert_eq!(StopReason::from_id(r.id()), r, "roundtrip failed for {r:?}");
127        }
128    }
129
130    #[test]
131    fn ids_are_unique() {
132        let mut ids: Vec<u8> = ALL.iter().map(|r| r.id()).collect();
133        ids.sort_unstable();
134        ids.dedup();
135        assert_eq!(ids.len(), ALL.len(), "duplicate stop_reason_id values");
136    }
137
138    #[test]
139    fn unknown_id_tolerated() {
140        assert_eq!(StopReason::from_id(42), StopReason::Unknown);
141        assert_eq!(StopReason::from_id(255), StopReason::Unknown);
142    }
143
144    /// Round-29 F7: `#[serde(other)]` makes `Other` the deserialize
145    /// fallback for any unrecognised discriminant. Heterogeneous
146    /// cluster upgrades (harness-N publishing a new variant,
147    /// harness-N-1 reading it back from the bridge during recovery)
148    /// would otherwise fail the whole event parse — dropping
149    /// evidence and breaking chain reconstruction on stragglers.
150    #[test]
151    #[allow(clippy::unwrap_used)]
152    fn unknown_serde_variant_falls_back_to_other() {
153        let unknown: StopReason = serde_json::from_str("\"FutureVariant\"").unwrap();
154        assert_eq!(unknown, StopReason::Other);
155        // Known variants still parse to themselves — the fallback
156        // does not shadow them.
157        let known: StopReason = serde_json::from_str("\"MaxTokens\"").unwrap();
158        assert_eq!(known, StopReason::MaxTokens);
159        // Emitted representation of Other stays "Other" (no invisible
160        // renaming of the fallback).
161        assert_eq!(serde_json::to_string(&StopReason::Other).unwrap(), "\"Other\"");
162    }
163}