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 crate::llm::AgentTerminalClass;
20
21use super::agent::AgentEvent;
22
23/// Coarse, typed classification of why an agent-loop session terminated.
24/// Serialized `snake_case`. The vocabulary is deliberately extensible —
25/// [`Self::Unknown`] is the honest fallback when no rule matched and the raw
26/// `reason` is authoritative.
27#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum AgentTerminalKind {
30    /// The model/agent finished naturally — a clean completion or a verified
31    /// `done` judgement, with no policy stop and no error.
32    Natural,
33    /// A user or host explicitly cancelled the in-flight turn.
34    UserCancelled,
35    /// A budget/cap policy stopped the loop: max iterations, a token/cost
36    /// budget, a circuit breaker, or an exhausted verification cap.
37    PolicyBudget,
38    /// A no-progress policy stopped the loop: a thrash/stall hard stop or the
39    /// text-only nudge budget.
40    PolicyNoProgress,
41    /// A guardrail policy stopped the loop: an input tripwire or an
42    /// out-of-scope alert.
43    PolicyGuardrail,
44    /// A custom post-turn / terminal callback requested a stop that is not one
45    /// of the specific policy kinds above. The raw `reason` names it; a future
46    /// typed callback contract can attribute a finer owner.
47    PolicyStop,
48    /// The provider/transport failed terminally: rate limit, timeout, context
49    /// overflow, or provider misconfiguration.
50    ProviderError,
51    /// The harness/runtime failed terminally: a host-bridge gap, an internal
52    /// protocol failure, an uncaught throw, or a turn that made no LLM call.
53    RuntimeError,
54    /// The session suspended at a waitpoint and may resume later — its work is
55    /// not finished and was not abandoned.
56    Suspended,
57    /// No rule matched; the raw `reason` is authoritative.
58    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    /// Parse the stable wire value emitted in `AgentResult.terminal.kind`.
91    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    /// Project the producer-owned terminal decision onto the shared
98    /// agent/run lifecycle vocabulary. Protocol and persistence adapters use
99    /// this projection instead of independently interpreting status strings.
100    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    /// The party responsible for the stop — a stable, coarse attribution that
116    /// pairs with the kind so hosts can bucket outcomes (agent-driven vs
117    /// user vs provider vs harness vs policy) without re-deriving it.
118    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
133/// Raw `stop_reason` values that seal a genuinely natural completion (a clean
134/// finish or a verified `done`). When `final_status` is `done`/empty, any
135/// `stop_reason` OUTSIDE this set is a policy/custom stop (e.g. a post-turn
136/// callback `stop`) that must NOT be reported as a natural completion — that
137/// conflation is the Burin #4642 failure mode. Sourced from the loop's own
138/// terminal-`done` assignments and `__agent_loop_sealed_stop_reason`.
139const 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/// Typed terminal outcome carried alongside the lossless raw reason. `owner` is
152/// derived from `kind` so a single field pins the responsible party.
153#[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    /// Build an outcome from a kind and the lossless raw `reason`, deriving the
162    /// `owner` from the kind.
163    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    /// Project the outcome onto the existing typed-checkpoint event stream.
180    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
198/// Classify an agent-loop terminal condition into a typed [`AgentTerminalKind`].
199///
200/// Inputs are the values the finalize boundary already has in hand:
201/// - `canonical_status`: `final_status` with empty normalized to `done`;
202/// - `stop_reason`: the sealed raw stop reason;
203/// - `has_error`: whether a terminal error was recorded;
204/// - `terminal_class`: the finalize host's fine-grained error class, used only
205///   to split an error into provider vs harness ownership.
206///
207/// This is the single place stringly stop vocabulary is interpreted; every
208/// consumer reads the typed result instead.
209pub 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        // A user/host cancel. The finalize loop does not itself seal a
232        // `cancelled` status — the ACP adapter observes the cancel notification
233        // one layer up and constructs the outcome directly — but classify still
234        // maps it so the vocabulary is total and any host that does route a
235        // cancel through finalize is attributed correctly rather than `Unknown`.
236        "cancelled" | "canceled" | "aborted" => AgentTerminalKind::UserCancelled,
237        "provider_error" => AgentTerminalKind::ProviderError,
238        "error" | "failed" => classify_error(terminal_class),
239        // A verification cap/budget was exhausted before `done` could be
240        // confirmed — a budget policy stop, not a hard error.
241        "budget_exhausted" | "verify_capped" | "verify_exhausted" => {
242            AgentTerminalKind::PolicyBudget
243        }
244        "stuck" => AgentTerminalKind::PolicyNoProgress,
245        // `input_guardrail`/`scope_alert` come from the loop's guardrail arms;
246        // `blocked` is the UserPromptSubmit-hook block result. All three are a
247        // guardrail policy denying the turn.
248        "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                // `done`/empty final status with a non-natural reason — a
256                // post-turn/custom policy stop wearing a completion status.
257                AgentTerminalKind::PolicyStop
258            }
259        }
260        _ => AgentTerminalKind::Unknown,
261    }
262}
263
264/// Split a terminal error into provider vs harness ownership using the
265/// finalize host's error class. Transport/provider classes attribute to the
266/// provider; everything else (host-bridge gaps, protocol failures, uncaught
267/// throws) is a harness/runtime fault.
268fn 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        // The wire table above must cover every variant.
303        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        // The Burin #4642 case: a post-turn callback stops with `final_status`
379        // empty (canonicalized to `done`) and a custom reason. It MUST classify
380        // as a policy stop, never a natural completion.
381        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        // A `done` status that nonetheless carries a terminal error is an error,
442        // classified by its class rather than reported as a completion.
443        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}