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}