Skip to main content

harn_vm/agent_events/
terminal.rs

1//! Typed terminal outcome for an agent-loop session (harn#4568).
2//!
3//! The loop seals a free-string `stop_reason` / `final_status`, which forces
4//! every host to substring-match to tell a natural completion from a user
5//! cancel, a provider or runtime error, a policy stop (budget / no-progress /
6//! guardrail), or a suspend. That guessing is exactly how a catch-all terminal
7//! message can hide a provider/VM/permission failure behind an agent-authored
8//! "I stopped" (Burin #4642).
9//!
10//! This module produces the classification ONCE, at the loop boundary, into a
11//! typed [`AgentTerminalKind`] plus a coarse [`AgentTerminalKind::owner`], and
12//! carries it alongside the lossless raw `reason`. Harn owns the agent stop
13//! vocabulary, so the classification is produced here rather than reconstructed
14//! in Burin or any other host. The typed outcome is *additive*: the raw
15//! `final_status` / `stop_reason` / `terminal_class` fields are unchanged.
16
17use serde::{Deserialize, Serialize};
18
19use super::agent::AgentEvent;
20
21/// Coarse, typed classification of why an agent-loop session terminated.
22/// Serialized `snake_case`. The vocabulary is deliberately extensible —
23/// [`Self::Unknown`] is the honest fallback when no rule matched and the raw
24/// `reason` is authoritative.
25#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum AgentTerminalKind {
28    /// The model/agent finished naturally — a clean completion or a verified
29    /// `done` judgement, with no policy stop and no error.
30    Natural,
31    /// A user or host explicitly cancelled the in-flight turn.
32    UserCancelled,
33    /// A budget/cap policy stopped the loop: max iterations, a token/cost
34    /// budget, a circuit breaker, or an exhausted verification cap.
35    PolicyBudget,
36    /// A no-progress policy stopped the loop: a thrash/stall hard stop or the
37    /// text-only nudge budget.
38    PolicyNoProgress,
39    /// A guardrail policy stopped the loop: an input tripwire or an
40    /// out-of-scope alert.
41    PolicyGuardrail,
42    /// A custom post-turn / terminal callback requested a stop that is not one
43    /// of the specific policy kinds above. The raw `reason` names it; a future
44    /// typed callback contract can attribute a finer owner.
45    PolicyStop,
46    /// The provider/transport failed terminally: rate limit, timeout, context
47    /// overflow, or provider misconfiguration.
48    ProviderError,
49    /// The harness/runtime failed terminally: a host-bridge gap, an internal
50    /// protocol failure, an uncaught throw, or a turn that made no LLM call.
51    RuntimeError,
52    /// The session suspended at a waitpoint and may resume later — its work is
53    /// not finished and was not abandoned.
54    Suspended,
55    /// No rule matched; the raw `reason` is authoritative.
56    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    /// The party responsible for the stop — a stable, coarse attribution that
89    /// pairs with the kind so hosts can bucket outcomes (agent-driven vs
90    /// user vs provider vs harness vs policy) without re-deriving it.
91    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
106/// Raw `stop_reason` values that seal a genuinely natural completion (a clean
107/// finish or a verified `done`). When `final_status` is `done`/empty, any
108/// `stop_reason` OUTSIDE this set is a policy/custom stop (e.g. a post-turn
109/// callback `stop`) that must NOT be reported as a natural completion — that
110/// conflation is the Burin #4642 failure mode. Sourced from the loop's own
111/// terminal-`done` assignments and `__agent_loop_sealed_stop_reason`.
112const 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/// Typed terminal outcome carried alongside the lossless raw reason. `owner` is
125/// derived from `kind` so a single field pins the responsible party.
126#[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    /// Build an outcome from a kind and the lossless raw `reason`, deriving the
135    /// `owner` from the kind.
136    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    /// Project the outcome onto the existing typed-checkpoint event stream.
153    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
171/// Classify an agent-loop terminal condition into a typed [`AgentTerminalKind`].
172///
173/// Inputs are the values the finalize boundary already has in hand:
174/// - `canonical_status`: `final_status` with empty normalized to `done`;
175/// - `stop_reason`: the sealed raw stop reason;
176/// - `has_error`: whether a terminal error was recorded;
177/// - `terminal_class`: the finalize host's error-only string class (e.g.
178///   `rate_limited`, `timeout`, `context_overflow`, `provider_misconfigured`,
179///   `host_bridge_unimplemented`, `no_llm_call`), used only to split an error
180///   into provider vs harness ownership.
181///
182/// This is the single place stringly stop vocabulary is interpreted; every
183/// consumer reads the typed result instead.
184pub 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        // A user/host cancel. The finalize loop does not itself seal a
193        // `cancelled` status — the ACP adapter observes the cancel notification
194        // one layer up and constructs the outcome directly — but classify still
195        // maps it so the vocabulary is total and any host that does route a
196        // cancel through finalize is attributed correctly rather than `Unknown`.
197        "cancelled" | "canceled" | "aborted" => AgentTerminalKind::UserCancelled,
198        "provider_error" => AgentTerminalKind::ProviderError,
199        "error" | "failed" => classify_error(terminal_class),
200        // A verification cap/budget was exhausted before `done` could be
201        // confirmed — a budget policy stop, not a hard error.
202        "budget_exhausted" | "verify_capped" | "verify_exhausted" => {
203            AgentTerminalKind::PolicyBudget
204        }
205        "stuck" => AgentTerminalKind::PolicyNoProgress,
206        // `input_guardrail`/`scope_alert` come from the loop's guardrail arms;
207        // `blocked` is the UserPromptSubmit-hook block result. All three are a
208        // guardrail policy denying the turn.
209        "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                // `done`/empty final status with a non-natural reason — a
217                // post-turn/custom policy stop wearing a completion status.
218                AgentTerminalKind::PolicyStop
219            }
220        }
221        _ => AgentTerminalKind::Unknown,
222    }
223}
224
225/// Split a terminal error into provider vs harness ownership using the
226/// finalize host's error class. Transport/provider classes attribute to the
227/// provider; everything else (host-bridge gaps, protocol failures, uncaught
228/// throws, no-LLM-call) is a harness/runtime fault.
229fn 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        // The wire table above must cover every variant.
264        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        // The Burin #4642 case: a post-turn callback stops with `final_status`
307        // empty (canonicalized to `done`) and a custom reason. It MUST classify
308        // as a policy stop, never a natural completion.
309        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        // A `done` status that nonetheless carries a terminal error is an error,
369        // classified by its class rather than reported as a completion.
370        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}