use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentTerminalKind {
Natural,
UserCancelled,
PolicyBudget,
PolicyNoProgress,
PolicyGuardrail,
PolicyStop,
ProviderError,
RuntimeError,
Suspended,
Unknown,
}
impl AgentTerminalKind {
pub const ALL: [Self; 10] = [
Self::Natural,
Self::UserCancelled,
Self::PolicyBudget,
Self::PolicyNoProgress,
Self::PolicyGuardrail,
Self::PolicyStop,
Self::ProviderError,
Self::RuntimeError,
Self::Suspended,
Self::Unknown,
];
pub fn as_str(self) -> &'static str {
match self {
Self::Natural => "natural",
Self::UserCancelled => "user_cancelled",
Self::PolicyBudget => "policy_budget",
Self::PolicyNoProgress => "policy_no_progress",
Self::PolicyGuardrail => "policy_guardrail",
Self::PolicyStop => "policy_stop",
Self::ProviderError => "provider_error",
Self::RuntimeError => "runtime_error",
Self::Suspended => "suspended",
Self::Unknown => "unknown",
}
}
pub fn owner(self) -> &'static str {
match self {
Self::Natural | Self::Suspended => "agent",
Self::UserCancelled => "user",
Self::PolicyBudget
| Self::PolicyNoProgress
| Self::PolicyGuardrail
| Self::PolicyStop => "policy",
Self::ProviderError => "provider",
Self::RuntimeError => "harness",
Self::Unknown => "unknown",
}
}
}
const NATURAL_STOP_REASONS: [&str; 6] = [
"",
"completed",
"post_edit_reverify",
"repeated_verified_pass",
"stalled_done_judge",
"done",
];
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AgentTerminalOutcome {
pub kind: AgentTerminalKind,
pub reason: String,
pub owner: String,
}
impl AgentTerminalOutcome {
pub fn new(kind: AgentTerminalKind, reason: impl Into<String>) -> Self {
Self {
kind,
reason: reason.into(),
owner: kind.owner().to_string(),
}
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"kind": self.kind.as_str(),
"reason": self.reason,
"owner": self.owner,
})
}
}
pub fn classify_agent_terminal(
canonical_status: &str,
stop_reason: &str,
has_error: bool,
terminal_class: Option<&str>,
) -> AgentTerminalKind {
match canonical_status {
"suspended" => AgentTerminalKind::Suspended,
"cancelled" | "canceled" | "aborted" => AgentTerminalKind::UserCancelled,
"provider_error" => AgentTerminalKind::ProviderError,
"error" | "failed" => classify_error(terminal_class),
"budget_exhausted" | "verify_capped" | "verify_exhausted" => {
AgentTerminalKind::PolicyBudget
}
"stuck" => AgentTerminalKind::PolicyNoProgress,
"input_guardrail" | "scope_alert" | "blocked" => AgentTerminalKind::PolicyGuardrail,
"done" => {
if has_error {
classify_error(terminal_class)
} else if NATURAL_STOP_REASONS.contains(&stop_reason) {
AgentTerminalKind::Natural
} else {
AgentTerminalKind::PolicyStop
}
}
_ => AgentTerminalKind::Unknown,
}
}
fn classify_error(terminal_class: Option<&str>) -> AgentTerminalKind {
match terminal_class {
Some("rate_limited" | "timeout" | "context_overflow" | "provider_misconfigured") => {
AgentTerminalKind::ProviderError
}
_ => AgentTerminalKind::RuntimeError,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_wire_strings_are_stable_snake_case() {
let pairs = [
(AgentTerminalKind::Natural, "natural"),
(AgentTerminalKind::UserCancelled, "user_cancelled"),
(AgentTerminalKind::PolicyBudget, "policy_budget"),
(AgentTerminalKind::PolicyNoProgress, "policy_no_progress"),
(AgentTerminalKind::PolicyGuardrail, "policy_guardrail"),
(AgentTerminalKind::PolicyStop, "policy_stop"),
(AgentTerminalKind::ProviderError, "provider_error"),
(AgentTerminalKind::RuntimeError, "runtime_error"),
(AgentTerminalKind::Suspended, "suspended"),
(AgentTerminalKind::Unknown, "unknown"),
];
for (variant, wire) in pairs {
assert_eq!(variant.as_str(), wire);
let encoded = serde_json::to_string(&variant).unwrap();
assert_eq!(encoded, format!("\"{wire}\""));
let decoded: AgentTerminalKind = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded, variant);
}
assert_eq!(pairs.len(), AgentTerminalKind::ALL.len());
}
#[test]
fn owner_attribution_is_coarse_and_total() {
for kind in AgentTerminalKind::ALL {
let owner = kind.owner();
assert!(
matches!(
owner,
"agent" | "user" | "policy" | "provider" | "harness" | "unknown"
),
"{kind:?} has an unexpected owner {owner}"
);
}
assert_eq!(AgentTerminalKind::UserCancelled.owner(), "user");
assert_eq!(AgentTerminalKind::ProviderError.owner(), "provider");
assert_eq!(AgentTerminalKind::RuntimeError.owner(), "harness");
assert_eq!(AgentTerminalKind::PolicyStop.owner(), "policy");
}
#[test]
fn natural_completion_classifies_as_natural() {
for reason in [
"",
"completed",
"post_edit_reverify",
"repeated_verified_pass",
] {
assert_eq!(
classify_agent_terminal("done", reason, false, None),
AgentTerminalKind::Natural,
"reason {reason:?} should be natural"
);
}
}
#[test]
fn post_turn_policy_stop_is_not_reported_as_natural() {
assert_eq!(
classify_agent_terminal("done", "post_turn_stop", false, None),
AgentTerminalKind::PolicyStop,
);
assert_eq!(
classify_agent_terminal("done", "custom_operator_halt", false, None),
AgentTerminalKind::PolicyStop,
);
}
#[test]
fn policy_stops_map_to_specific_kinds() {
assert_eq!(
classify_agent_terminal("budget_exhausted", "max_iterations", false, None),
AgentTerminalKind::PolicyBudget,
);
assert_eq!(
classify_agent_terminal("verify_exhausted", "done_judge_cap_reached", false, None),
AgentTerminalKind::PolicyBudget,
);
assert_eq!(
classify_agent_terminal("stuck", "thrash_hard_stop", false, None),
AgentTerminalKind::PolicyNoProgress,
);
assert_eq!(
classify_agent_terminal("input_guardrail", "input_guardrail_tripwire", false, None),
AgentTerminalKind::PolicyGuardrail,
);
assert_eq!(
classify_agent_terminal("scope_alert", "out_of_scope", false, None),
AgentTerminalKind::PolicyGuardrail,
);
}
#[test]
fn errors_split_provider_vs_harness_by_class() {
assert_eq!(
classify_agent_terminal("provider_error", "escalation_aborted", true, None),
AgentTerminalKind::ProviderError,
);
for class in [
"rate_limited",
"timeout",
"context_overflow",
"provider_misconfigured",
] {
assert_eq!(
classify_agent_terminal("error", "boom", true, Some(class)),
AgentTerminalKind::ProviderError,
"class {class} should attribute to the provider"
);
}
for class in ["host_bridge_unimplemented", "no_llm_call", "generic_throw"] {
assert_eq!(
classify_agent_terminal("error", "boom", true, Some(class)),
AgentTerminalKind::RuntimeError,
"class {class} should attribute to the harness"
);
}
assert_eq!(
classify_agent_terminal("done", "completed", true, Some("no_llm_call")),
AgentTerminalKind::RuntimeError,
);
}
#[test]
fn cancel_and_block_statuses_classify_by_owner() {
for reason in ["cancelled", "canceled", "aborted"] {
assert_eq!(
classify_agent_terminal(reason, "user_cancel", false, None),
AgentTerminalKind::UserCancelled,
"status {reason} should attribute to the user"
);
}
assert_eq!(
classify_agent_terminal("blocked", "user_prompt_submit_blocked", false, None),
AgentTerminalKind::PolicyGuardrail,
);
}
#[test]
fn suspended_and_unknown() {
assert_eq!(
classify_agent_terminal("suspended", "suspended", false, None),
AgentTerminalKind::Suspended,
);
assert_eq!(
classify_agent_terminal("some_future_status", "whatever", false, None),
AgentTerminalKind::Unknown,
);
}
#[test]
fn outcome_carries_reason_and_derives_owner() {
let outcome = AgentTerminalOutcome::new(AgentTerminalKind::PolicyBudget, "max_iterations");
assert_eq!(outcome.kind, AgentTerminalKind::PolicyBudget);
assert_eq!(outcome.reason, "max_iterations");
assert_eq!(outcome.owner, "policy");
let json = outcome.to_json();
assert_eq!(json["kind"], "policy_budget");
assert_eq!(json["reason"], "max_iterations");
assert_eq!(json["owner"], "policy");
}
}