1use serde::{Deserialize, Serialize};
18
19use super::agent::AgentEvent;
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum AgentTerminalKind {
28 Natural,
31 UserCancelled,
33 PolicyBudget,
36 PolicyNoProgress,
39 PolicyGuardrail,
42 PolicyStop,
46 ProviderError,
49 RuntimeError,
52 Suspended,
55 Unknown,
57}
58
59impl AgentTerminalKind {
60 pub const ALL: [Self; 10] = [
61 Self::Natural,
62 Self::UserCancelled,
63 Self::PolicyBudget,
64 Self::PolicyNoProgress,
65 Self::PolicyGuardrail,
66 Self::PolicyStop,
67 Self::ProviderError,
68 Self::RuntimeError,
69 Self::Suspended,
70 Self::Unknown,
71 ];
72
73 pub fn as_str(self) -> &'static str {
74 match self {
75 Self::Natural => "natural",
76 Self::UserCancelled => "user_cancelled",
77 Self::PolicyBudget => "policy_budget",
78 Self::PolicyNoProgress => "policy_no_progress",
79 Self::PolicyGuardrail => "policy_guardrail",
80 Self::PolicyStop => "policy_stop",
81 Self::ProviderError => "provider_error",
82 Self::RuntimeError => "runtime_error",
83 Self::Suspended => "suspended",
84 Self::Unknown => "unknown",
85 }
86 }
87
88 pub fn owner(self) -> &'static str {
92 match self {
93 Self::Natural | Self::Suspended => "agent",
94 Self::UserCancelled => "user",
95 Self::PolicyBudget
96 | Self::PolicyNoProgress
97 | Self::PolicyGuardrail
98 | Self::PolicyStop => "policy",
99 Self::ProviderError => "provider",
100 Self::RuntimeError => "harness",
101 Self::Unknown => "unknown",
102 }
103 }
104}
105
106const NATURAL_STOP_REASONS: [&str; 9] = [
113 "",
114 "completed",
115 "natural",
116 "post_edit_reverify",
117 "repeated_verified_pass",
118 "required_tools_satisfied",
119 "sentinel",
120 "stalled_done_judge",
121 "done",
122];
123
124#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
127pub struct AgentTerminalOutcome {
128 pub kind: AgentTerminalKind,
129 pub reason: String,
130 pub owner: String,
131}
132
133impl AgentTerminalOutcome {
134 pub fn new(kind: AgentTerminalKind, reason: impl Into<String>) -> Self {
137 Self {
138 kind,
139 reason: reason.into(),
140 owner: kind.owner().to_string(),
141 }
142 }
143
144 pub fn to_json(&self) -> serde_json::Value {
145 serde_json::json!({
146 "kind": self.kind.as_str(),
147 "reason": self.reason,
148 "owner": self.owner,
149 })
150 }
151
152 pub fn checkpoint(
154 &self,
155 session_id: &str,
156 final_status: &str,
157 stop_reason: &str,
158 ) -> AgentEvent {
159 AgentEvent::TypedCheckpoint {
160 session_id: session_id.to_owned(),
161 checkpoint: serde_json::json!({
162 "schema": "harn.agent_terminal.v1",
163 "terminal": self.to_json(),
164 "final_status": final_status,
165 "stop_reason": stop_reason,
166 }),
167 }
168 }
169}
170
171pub fn classify_agent_terminal(
185 canonical_status: &str,
186 stop_reason: &str,
187 has_error: bool,
188 terminal_class: Option<&str>,
189) -> AgentTerminalKind {
190 match canonical_status {
191 "suspended" => AgentTerminalKind::Suspended,
192 "cancelled" | "canceled" | "aborted" => AgentTerminalKind::UserCancelled,
198 "provider_error" => AgentTerminalKind::ProviderError,
199 "error" | "failed" => classify_error(terminal_class),
200 "budget_exhausted" | "verify_capped" | "verify_exhausted" => {
203 AgentTerminalKind::PolicyBudget
204 }
205 "stuck" => AgentTerminalKind::PolicyNoProgress,
206 "input_guardrail" | "scope_alert" | "blocked" => AgentTerminalKind::PolicyGuardrail,
210 "done" => {
211 if has_error {
212 classify_error(terminal_class)
213 } else if NATURAL_STOP_REASONS.contains(&stop_reason) {
214 AgentTerminalKind::Natural
215 } else {
216 AgentTerminalKind::PolicyStop
219 }
220 }
221 _ => AgentTerminalKind::Unknown,
222 }
223}
224
225fn classify_error(terminal_class: Option<&str>) -> AgentTerminalKind {
230 match terminal_class {
231 Some("rate_limited" | "timeout" | "context_overflow" | "provider_misconfigured") => {
232 AgentTerminalKind::ProviderError
233 }
234 _ => AgentTerminalKind::RuntimeError,
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn kind_wire_strings_are_stable_snake_case() {
244 let pairs = [
245 (AgentTerminalKind::Natural, "natural"),
246 (AgentTerminalKind::UserCancelled, "user_cancelled"),
247 (AgentTerminalKind::PolicyBudget, "policy_budget"),
248 (AgentTerminalKind::PolicyNoProgress, "policy_no_progress"),
249 (AgentTerminalKind::PolicyGuardrail, "policy_guardrail"),
250 (AgentTerminalKind::PolicyStop, "policy_stop"),
251 (AgentTerminalKind::ProviderError, "provider_error"),
252 (AgentTerminalKind::RuntimeError, "runtime_error"),
253 (AgentTerminalKind::Suspended, "suspended"),
254 (AgentTerminalKind::Unknown, "unknown"),
255 ];
256 for (variant, wire) in pairs {
257 assert_eq!(variant.as_str(), wire);
258 let encoded = serde_json::to_string(&variant).unwrap();
259 assert_eq!(encoded, format!("\"{wire}\""));
260 let decoded: AgentTerminalKind = serde_json::from_str(&encoded).unwrap();
261 assert_eq!(decoded, variant);
262 }
263 assert_eq!(pairs.len(), AgentTerminalKind::ALL.len());
265 }
266
267 #[test]
268 fn owner_attribution_is_coarse_and_total() {
269 for kind in AgentTerminalKind::ALL {
270 let owner = kind.owner();
271 assert!(
272 matches!(
273 owner,
274 "agent" | "user" | "policy" | "provider" | "harness" | "unknown"
275 ),
276 "{kind:?} has an unexpected owner {owner}"
277 );
278 }
279 assert_eq!(AgentTerminalKind::UserCancelled.owner(), "user");
280 assert_eq!(AgentTerminalKind::ProviderError.owner(), "provider");
281 assert_eq!(AgentTerminalKind::RuntimeError.owner(), "harness");
282 assert_eq!(AgentTerminalKind::PolicyStop.owner(), "policy");
283 }
284
285 #[test]
286 fn natural_completion_classifies_as_natural() {
287 for reason in [
288 "",
289 "completed",
290 "natural",
291 "post_edit_reverify",
292 "repeated_verified_pass",
293 "required_tools_satisfied",
294 "sentinel",
295 ] {
296 assert_eq!(
297 classify_agent_terminal("done", reason, false, None),
298 AgentTerminalKind::Natural,
299 "reason {reason:?} should be natural"
300 );
301 }
302 }
303
304 #[test]
305 fn post_turn_policy_stop_is_not_reported_as_natural() {
306 assert_eq!(
310 classify_agent_terminal("done", "post_turn_stop", false, None),
311 AgentTerminalKind::PolicyStop,
312 );
313 assert_eq!(
314 classify_agent_terminal("done", "custom_operator_halt", false, None),
315 AgentTerminalKind::PolicyStop,
316 );
317 }
318
319 #[test]
320 fn policy_stops_map_to_specific_kinds() {
321 assert_eq!(
322 classify_agent_terminal("budget_exhausted", "max_iterations", false, None),
323 AgentTerminalKind::PolicyBudget,
324 );
325 assert_eq!(
326 classify_agent_terminal("verify_exhausted", "done_judge_cap_reached", false, None),
327 AgentTerminalKind::PolicyBudget,
328 );
329 assert_eq!(
330 classify_agent_terminal("stuck", "thrash_hard_stop", false, None),
331 AgentTerminalKind::PolicyNoProgress,
332 );
333 assert_eq!(
334 classify_agent_terminal("input_guardrail", "input_guardrail_tripwire", false, None),
335 AgentTerminalKind::PolicyGuardrail,
336 );
337 assert_eq!(
338 classify_agent_terminal("scope_alert", "out_of_scope", false, None),
339 AgentTerminalKind::PolicyGuardrail,
340 );
341 }
342
343 #[test]
344 fn errors_split_provider_vs_harness_by_class() {
345 assert_eq!(
346 classify_agent_terminal("provider_error", "escalation_aborted", true, None),
347 AgentTerminalKind::ProviderError,
348 );
349 for class in [
350 "rate_limited",
351 "timeout",
352 "context_overflow",
353 "provider_misconfigured",
354 ] {
355 assert_eq!(
356 classify_agent_terminal("error", "boom", true, Some(class)),
357 AgentTerminalKind::ProviderError,
358 "class {class} should attribute to the provider"
359 );
360 }
361 for class in ["host_bridge_unimplemented", "no_llm_call", "generic_throw"] {
362 assert_eq!(
363 classify_agent_terminal("error", "boom", true, Some(class)),
364 AgentTerminalKind::RuntimeError,
365 "class {class} should attribute to the harness"
366 );
367 }
368 assert_eq!(
371 classify_agent_terminal("done", "completed", true, Some("no_llm_call")),
372 AgentTerminalKind::RuntimeError,
373 );
374 }
375
376 #[test]
377 fn cancel_and_block_statuses_classify_by_owner() {
378 for reason in ["cancelled", "canceled", "aborted"] {
379 assert_eq!(
380 classify_agent_terminal(reason, "user_cancel", false, None),
381 AgentTerminalKind::UserCancelled,
382 "status {reason} should attribute to the user"
383 );
384 }
385 assert_eq!(
386 classify_agent_terminal("blocked", "user_prompt_submit_blocked", false, None),
387 AgentTerminalKind::PolicyGuardrail,
388 );
389 }
390
391 #[test]
392 fn suspended_and_unknown() {
393 assert_eq!(
394 classify_agent_terminal("suspended", "suspended", false, None),
395 AgentTerminalKind::Suspended,
396 );
397 assert_eq!(
398 classify_agent_terminal("some_future_status", "whatever", false, None),
399 AgentTerminalKind::Unknown,
400 );
401 }
402
403 #[test]
404 fn outcome_carries_reason_and_derives_owner() {
405 let outcome = AgentTerminalOutcome::new(AgentTerminalKind::PolicyBudget, "max_iterations");
406 assert_eq!(outcome.kind, AgentTerminalKind::PolicyBudget);
407 assert_eq!(outcome.reason, "max_iterations");
408 assert_eq!(outcome.owner, "policy");
409 let json = outcome.to_json();
410 assert_eq!(json["kind"], "policy_budget");
411 assert_eq!(json["reason"], "max_iterations");
412 assert_eq!(json["owner"], "policy");
413 }
414}