1use serde::{Deserialize, Serialize};
18
19use crate::llm::AgentTerminalClass;
20
21use super::agent::AgentEvent;
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum AgentTerminalKind {
30 Natural,
33 UserCancelled,
35 PolicyBudget,
38 PolicyNoProgress,
41 PolicyGuardrail,
44 PolicyStop,
48 ProviderError,
51 RuntimeError,
54 Suspended,
57 Unknown,
59}
60
61impl AgentTerminalKind {
62 pub const ALL: [Self; 10] = [
63 Self::Natural,
64 Self::UserCancelled,
65 Self::PolicyBudget,
66 Self::PolicyNoProgress,
67 Self::PolicyGuardrail,
68 Self::PolicyStop,
69 Self::ProviderError,
70 Self::RuntimeError,
71 Self::Suspended,
72 Self::Unknown,
73 ];
74
75 pub fn as_str(self) -> &'static str {
76 match self {
77 Self::Natural => "natural",
78 Self::UserCancelled => "user_cancelled",
79 Self::PolicyBudget => "policy_budget",
80 Self::PolicyNoProgress => "policy_no_progress",
81 Self::PolicyGuardrail => "policy_guardrail",
82 Self::PolicyStop => "policy_stop",
83 Self::ProviderError => "provider_error",
84 Self::RuntimeError => "runtime_error",
85 Self::Suspended => "suspended",
86 Self::Unknown => "unknown",
87 }
88 }
89
90 pub fn from_wire(value: &str) -> Option<Self> {
92 Self::ALL
93 .into_iter()
94 .find(|kind| kind.as_str() == value.trim())
95 }
96
97 pub const fn lifecycle_state(self) -> super::AgentLifecycleState {
101 match self {
102 Self::Natural => super::AgentLifecycleState::Completed,
103 Self::UserCancelled => super::AgentLifecycleState::Cancelled,
104 Self::PolicyBudget
105 | Self::PolicyNoProgress
106 | Self::PolicyGuardrail
107 | Self::PolicyStop => super::AgentLifecycleState::Stopped,
108 Self::ProviderError | Self::RuntimeError | Self::Unknown => {
109 super::AgentLifecycleState::Failed
110 }
111 Self::Suspended => super::AgentLifecycleState::Suspended,
112 }
113 }
114
115 pub fn owner(self) -> &'static str {
119 match self {
120 Self::Natural | Self::Suspended => "agent",
121 Self::UserCancelled => "user",
122 Self::PolicyBudget
123 | Self::PolicyNoProgress
124 | Self::PolicyGuardrail
125 | Self::PolicyStop => "policy",
126 Self::ProviderError => "provider",
127 Self::RuntimeError => "harness",
128 Self::Unknown => "unknown",
129 }
130 }
131}
132
133const NATURAL_STOP_REASONS: [&str; 9] = [
140 "",
141 "completed",
142 "natural",
143 "post_edit_reverify",
144 "repeated_verified_pass",
145 "required_tools_satisfied",
146 "sentinel",
147 "stalled_done_judge",
148 "done",
149];
150
151#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
154pub struct AgentTerminalOutcome {
155 pub kind: AgentTerminalKind,
156 pub reason: String,
157 pub owner: String,
158}
159
160impl AgentTerminalOutcome {
161 pub fn new(kind: AgentTerminalKind, reason: impl Into<String>) -> Self {
164 Self {
165 kind,
166 reason: reason.into(),
167 owner: kind.owner().to_string(),
168 }
169 }
170
171 pub fn to_json(&self) -> serde_json::Value {
172 serde_json::json!({
173 "kind": self.kind.as_str(),
174 "reason": self.reason,
175 "owner": self.owner,
176 })
177 }
178
179 pub fn checkpoint(
181 &self,
182 session_id: &str,
183 final_status: &str,
184 stop_reason: &str,
185 ) -> AgentEvent {
186 AgentEvent::TypedCheckpoint {
187 session_id: session_id.to_owned(),
188 checkpoint: serde_json::json!({
189 "schema": "harn.agent_terminal.v1",
190 "terminal": self.to_json(),
191 "final_status": final_status,
192 "stop_reason": stop_reason,
193 }),
194 }
195 }
196}
197
198pub fn classify_agent_terminal(
210 canonical_status: &str,
211 stop_reason: &str,
212 has_error: bool,
213 terminal_class: Option<&str>,
214) -> AgentTerminalKind {
215 classify_agent_terminal_with_class(
216 canonical_status,
217 stop_reason,
218 has_error,
219 terminal_class.and_then(AgentTerminalClass::from_wire),
220 )
221}
222
223pub fn classify_agent_terminal_with_class(
224 canonical_status: &str,
225 stop_reason: &str,
226 has_error: bool,
227 terminal_class: Option<AgentTerminalClass>,
228) -> AgentTerminalKind {
229 match canonical_status {
230 "suspended" => AgentTerminalKind::Suspended,
231 "cancelled" | "canceled" | "aborted" => AgentTerminalKind::UserCancelled,
237 "provider_error" => AgentTerminalKind::ProviderError,
238 "error" | "failed" => classify_error(terminal_class),
239 "budget_exhausted" | "verify_capped" | "verify_exhausted" => {
242 AgentTerminalKind::PolicyBudget
243 }
244 "stuck" => AgentTerminalKind::PolicyNoProgress,
245 "input_guardrail" | "scope_alert" | "blocked" => AgentTerminalKind::PolicyGuardrail,
249 "done" => {
250 if has_error {
251 classify_error(terminal_class)
252 } else if NATURAL_STOP_REASONS.contains(&stop_reason) {
253 AgentTerminalKind::Natural
254 } else {
255 AgentTerminalKind::PolicyStop
258 }
259 }
260 _ => AgentTerminalKind::Unknown,
261 }
262}
263
264fn classify_error(terminal_class: Option<AgentTerminalClass>) -> AgentTerminalKind {
269 if terminal_class.is_some_and(AgentTerminalClass::is_provider_error) {
270 AgentTerminalKind::ProviderError
271 } else {
272 AgentTerminalKind::RuntimeError
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn kind_wire_strings_are_stable_snake_case() {
282 let pairs = [
283 (AgentTerminalKind::Natural, "natural"),
284 (AgentTerminalKind::UserCancelled, "user_cancelled"),
285 (AgentTerminalKind::PolicyBudget, "policy_budget"),
286 (AgentTerminalKind::PolicyNoProgress, "policy_no_progress"),
287 (AgentTerminalKind::PolicyGuardrail, "policy_guardrail"),
288 (AgentTerminalKind::PolicyStop, "policy_stop"),
289 (AgentTerminalKind::ProviderError, "provider_error"),
290 (AgentTerminalKind::RuntimeError, "runtime_error"),
291 (AgentTerminalKind::Suspended, "suspended"),
292 (AgentTerminalKind::Unknown, "unknown"),
293 ];
294 for (variant, wire) in pairs {
295 assert_eq!(variant.as_str(), wire);
296 let encoded = serde_json::to_string(&variant).unwrap();
297 assert_eq!(encoded, format!("\"{wire}\""));
298 let decoded: AgentTerminalKind = serde_json::from_str(&encoded).unwrap();
299 assert_eq!(decoded, variant);
300 assert_eq!(AgentTerminalKind::from_wire(wire), Some(variant));
301 }
302 assert_eq!(pairs.len(), AgentTerminalKind::ALL.len());
304 }
305
306 #[test]
307 fn terminal_kinds_project_to_shared_lifecycle_states() {
308 use super::super::AgentLifecycleState;
309
310 assert_eq!(
311 AgentTerminalKind::Natural.lifecycle_state(),
312 AgentLifecycleState::Completed
313 );
314 assert_eq!(
315 AgentTerminalKind::Suspended.lifecycle_state(),
316 AgentLifecycleState::Suspended
317 );
318 assert_eq!(
319 AgentTerminalKind::UserCancelled.lifecycle_state(),
320 AgentLifecycleState::Cancelled
321 );
322 for kind in [
323 AgentTerminalKind::PolicyBudget,
324 AgentTerminalKind::PolicyNoProgress,
325 AgentTerminalKind::PolicyGuardrail,
326 AgentTerminalKind::PolicyStop,
327 ] {
328 assert_eq!(kind.lifecycle_state(), AgentLifecycleState::Stopped);
329 }
330 for kind in [
331 AgentTerminalKind::ProviderError,
332 AgentTerminalKind::RuntimeError,
333 AgentTerminalKind::Unknown,
334 ] {
335 assert_eq!(kind.lifecycle_state(), AgentLifecycleState::Failed);
336 }
337 }
338
339 #[test]
340 fn owner_attribution_is_coarse_and_total() {
341 for kind in AgentTerminalKind::ALL {
342 let owner = kind.owner();
343 assert!(
344 matches!(
345 owner,
346 "agent" | "user" | "policy" | "provider" | "harness" | "unknown"
347 ),
348 "{kind:?} has an unexpected owner {owner}"
349 );
350 }
351 assert_eq!(AgentTerminalKind::UserCancelled.owner(), "user");
352 assert_eq!(AgentTerminalKind::ProviderError.owner(), "provider");
353 assert_eq!(AgentTerminalKind::RuntimeError.owner(), "harness");
354 assert_eq!(AgentTerminalKind::PolicyStop.owner(), "policy");
355 }
356
357 #[test]
358 fn natural_completion_classifies_as_natural() {
359 for reason in [
360 "",
361 "completed",
362 "natural",
363 "post_edit_reverify",
364 "repeated_verified_pass",
365 "required_tools_satisfied",
366 "sentinel",
367 ] {
368 assert_eq!(
369 classify_agent_terminal("done", reason, false, None),
370 AgentTerminalKind::Natural,
371 "reason {reason:?} should be natural"
372 );
373 }
374 }
375
376 #[test]
377 fn post_turn_policy_stop_is_not_reported_as_natural() {
378 assert_eq!(
382 classify_agent_terminal("done", "post_turn_stop", false, None),
383 AgentTerminalKind::PolicyStop,
384 );
385 assert_eq!(
386 classify_agent_terminal("done", "custom_operator_halt", false, None),
387 AgentTerminalKind::PolicyStop,
388 );
389 }
390
391 #[test]
392 fn policy_stops_map_to_specific_kinds() {
393 assert_eq!(
394 classify_agent_terminal("budget_exhausted", "max_iterations", false, None),
395 AgentTerminalKind::PolicyBudget,
396 );
397 assert_eq!(
398 classify_agent_terminal("verify_exhausted", "done_judge_cap_reached", false, None),
399 AgentTerminalKind::PolicyBudget,
400 );
401 assert_eq!(
402 classify_agent_terminal("stuck", "thrash_hard_stop", false, None),
403 AgentTerminalKind::PolicyNoProgress,
404 );
405 assert_eq!(
406 classify_agent_terminal("input_guardrail", "input_guardrail_tripwire", false, None),
407 AgentTerminalKind::PolicyGuardrail,
408 );
409 assert_eq!(
410 classify_agent_terminal("scope_alert", "out_of_scope", false, None),
411 AgentTerminalKind::PolicyGuardrail,
412 );
413 }
414
415 #[test]
416 fn errors_split_provider_vs_harness_by_class() {
417 assert_eq!(
418 classify_agent_terminal("provider_error", "escalation_aborted", true, None),
419 AgentTerminalKind::ProviderError,
420 );
421 for class in AgentTerminalClass::ALL
422 .into_iter()
423 .filter(|class| class.is_provider_error())
424 {
425 assert_eq!(
426 classify_agent_terminal_with_class("error", "boom", true, Some(class)),
427 AgentTerminalKind::ProviderError,
428 "class {class} should attribute to the provider"
429 );
430 }
431 for class in AgentTerminalClass::ALL
432 .into_iter()
433 .filter(|class| !class.is_provider_error())
434 {
435 assert_eq!(
436 classify_agent_terminal_with_class("error", "boom", true, Some(class)),
437 AgentTerminalKind::RuntimeError,
438 "class {class} should attribute to the harness"
439 );
440 }
441 assert_eq!(
444 classify_agent_terminal_with_class(
445 "done",
446 "completed",
447 true,
448 Some(AgentTerminalClass::ProviderMisconfigured),
449 ),
450 AgentTerminalKind::ProviderError,
451 );
452 }
453
454 #[test]
455 fn cancel_and_block_statuses_classify_by_owner() {
456 for reason in ["cancelled", "canceled", "aborted"] {
457 assert_eq!(
458 classify_agent_terminal(reason, "user_cancel", false, None),
459 AgentTerminalKind::UserCancelled,
460 "status {reason} should attribute to the user"
461 );
462 }
463 assert_eq!(
464 classify_agent_terminal("blocked", "user_prompt_submit_blocked", false, None),
465 AgentTerminalKind::PolicyGuardrail,
466 );
467 }
468
469 #[test]
470 fn suspended_and_unknown() {
471 assert_eq!(
472 classify_agent_terminal("suspended", "suspended", false, None),
473 AgentTerminalKind::Suspended,
474 );
475 assert_eq!(
476 classify_agent_terminal("some_future_status", "whatever", false, None),
477 AgentTerminalKind::Unknown,
478 );
479 }
480
481 #[test]
482 fn outcome_carries_reason_and_derives_owner() {
483 let outcome = AgentTerminalOutcome::new(AgentTerminalKind::PolicyBudget, "max_iterations");
484 assert_eq!(outcome.kind, AgentTerminalKind::PolicyBudget);
485 assert_eq!(outcome.reason, "max_iterations");
486 assert_eq!(outcome.owner, "policy");
487 let json = outcome.to_json();
488 assert_eq!(json["kind"], "policy_budget");
489 assert_eq!(json["reason"], "max_iterations");
490 assert_eq!(json["owner"], "policy");
491 }
492}