Skip to main content

harn_vm/llm/
agent_terminal_class.rs

1//! Terminal-class taxonomy for a finalized agent-loop turn.
2//!
3//! One place classifies *why* a turn ended into a stable typed
4//! [`AgentTerminalClass`] — `context_overflow`, `provider_misconfigured`,
5//! `provider_unavailable`, `rate_limited`, `timeout`, `resource_busy`,
6//! `tool_policy_rejected`, `host_bridge_unimplemented`,
7//! `agent_loop_protocol_failure`, `parse_dropped`, or the `generic_throw`
8//! fallback. A structured
9//! error envelope (typed `category` /
10//! `reason` / `code` fields) is authoritative; legacy free-text matching is a
11//! Harn-side fallback only. `host_agent_session_finalize` consumes the class
12//! both to shape the terminal transcript event and to split an error into
13//! provider- vs harness-owned in the typed terminal outcome
14//! ([`super::super::agent_events::classify_agent_terminal`]).
15//!
16//! Split out of `agent_session_host` so the finalize host stays focused on
17//! session lifecycle while this self-contained taxonomy and its unit tests live
18//! together.
19
20use serde::{Deserialize, Serialize};
21
22/// Stable fine-grained reason for an errored agent turn.
23///
24/// This is distinct from the coarse `AgentTerminalKind`: hosts use this class
25/// for actionable recovery wording and diagnostics while the kind identifies
26/// the responsible owner. Serialized values are an additive wire contract used
27/// by terminal checkpoints and ACP prompt-error data.
28#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AgentTerminalClass {
31    ContextOverflow,
32    ProviderMisconfigured,
33    ProviderUnavailable,
34    RateLimited,
35    Timeout,
36    ResourceBusy,
37    ToolPolicyRejected,
38    HostBridgeUnimplemented,
39    AgentLoopProtocolFailure,
40    ParseDropped,
41    GenericThrow,
42}
43
44impl AgentTerminalClass {
45    pub const ALL: [Self; 11] = [
46        Self::ContextOverflow,
47        Self::ProviderMisconfigured,
48        Self::ProviderUnavailable,
49        Self::RateLimited,
50        Self::Timeout,
51        Self::ResourceBusy,
52        Self::ToolPolicyRejected,
53        Self::HostBridgeUnimplemented,
54        Self::AgentLoopProtocolFailure,
55        Self::ParseDropped,
56        Self::GenericThrow,
57    ];
58
59    pub fn as_str(self) -> &'static str {
60        match self {
61            Self::ContextOverflow => "context_overflow",
62            Self::ProviderMisconfigured => "provider_misconfigured",
63            Self::ProviderUnavailable => "provider_unavailable",
64            Self::RateLimited => "rate_limited",
65            Self::Timeout => "timeout",
66            Self::ResourceBusy => "resource_busy",
67            Self::ToolPolicyRejected => "tool_policy_rejected",
68            Self::HostBridgeUnimplemented => "host_bridge_unimplemented",
69            Self::AgentLoopProtocolFailure => "agent_loop_protocol_failure",
70            Self::ParseDropped => "parse_dropped",
71            Self::GenericThrow => "generic_throw",
72        }
73    }
74
75    pub fn is_provider_error(self) -> bool {
76        matches!(
77            self,
78            Self::ContextOverflow
79                | Self::ProviderMisconfigured
80                | Self::ProviderUnavailable
81                | Self::RateLimited
82                | Self::Timeout
83        )
84    }
85
86    pub fn from_wire(value: &str) -> Option<Self> {
87        match value {
88            "context_overflow" => Some(Self::ContextOverflow),
89            "provider_misconfigured" => Some(Self::ProviderMisconfigured),
90            "provider_unavailable" => Some(Self::ProviderUnavailable),
91            "rate_limited" => Some(Self::RateLimited),
92            "timeout" => Some(Self::Timeout),
93            "resource_busy" => Some(Self::ResourceBusy),
94            "tool_policy_rejected" => Some(Self::ToolPolicyRejected),
95            "host_bridge_unimplemented" => Some(Self::HostBridgeUnimplemented),
96            "agent_loop_protocol_failure" => Some(Self::AgentLoopProtocolFailure),
97            "parse_dropped" => Some(Self::ParseDropped),
98            "generic_throw" => Some(Self::GenericThrow),
99            _ => None,
100        }
101    }
102}
103
104impl std::fmt::Display for AgentTerminalClass {
105    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        formatter.write_str(self.as_str())
107    }
108}
109
110pub(crate) fn session_status_indicates_error(final_status: &str) -> bool {
111    matches!(
112        final_status,
113        "error" | "failed" | "provider_error" | "verify_exhausted" | "verify_capped" | "stuck"
114    )
115}
116
117/// Detect a model-less agent turn: the loop finalized a *completed* turn
118/// (empty status or `done`) but never actually called the provider. We
119/// treat "no iterations AND no tokens recorded for this session" as the
120/// signal, since any real provider round-trip increments iterations and
121/// records token usage.
122///
123/// Only the success-completion statuses qualify. Intentional non-terminal
124/// states — `suspended`, `blocked`, `paused`, `cancelled`, waitpoints — and
125/// already-errored turns legitimately finalize with zero iterations and must
126/// be left alone; otherwise we would turn an intentional pause into a
127/// spurious failure.
128pub(crate) fn agent_turn_made_no_llm_call(
129    final_status: &str,
130    has_terminal_error: bool,
131    iterations: i64,
132    input_tokens: i64,
133    output_tokens: i64,
134) -> bool {
135    let is_success_completion = final_status.is_empty() || final_status == "done";
136    !has_terminal_error
137        && is_success_completion
138        && iterations == 0
139        && input_tokens == 0
140        && output_tokens == 0
141}
142
143pub fn agent_terminal_class(
144    final_status: &str,
145    stop_reason: &str,
146    terminal_error: Option<&serde_json::Value>,
147) -> Option<AgentTerminalClass> {
148    if terminal_error.is_none() && !session_status_indicates_error(final_status) {
149        return None;
150    }
151    if let Some(error) = terminal_error {
152        if terminal_error_has_structured_signal(error) {
153            return Some(
154                agent_terminal_class_from_structured_error(error)
155                    .unwrap_or(AgentTerminalClass::GenericThrow),
156            );
157        }
158        if let Some(class) = agent_terminal_class_from_legacy_text(error) {
159            return Some(class);
160        }
161    }
162    if terminal_status_signal_matches(final_status, stop_reason, |signal| {
163        matches!(
164            signal,
165            "context_overflow"
166                | "no_llm_call"
167                | "provider_misconfigured"
168                | "provider_not_configured"
169                | "provider_unavailable"
170                | "rate_limit"
171                | "rate_limited"
172                | "timeout"
173                | "timed_out"
174                | "deadline_exceeded"
175                | "resource_busy"
176                | "tool_policy_rejected"
177                | "tool_rejected"
178                | "permission_denied"
179                | "policy_denied"
180                | "agent_loop_protocol_failure"
181                | "parse_dropped"
182        )
183    }) {
184        return terminal_class_from_exact_signal(final_status)
185            .or_else(|| terminal_class_from_exact_signal(stop_reason));
186    }
187    Some(AgentTerminalClass::GenericThrow)
188}
189
190fn agent_terminal_class_from_structured_error(
191    error: &serde_json::Value,
192) -> Option<AgentTerminalClass> {
193    if let Some(class) = error
194        .get("terminal_class")
195        .and_then(serde_json::Value::as_str)
196        .and_then(AgentTerminalClass::from_wire)
197    {
198        return Some(class);
199    }
200    if terminal_error_signal_matches(error, |signal| signal == "context_overflow") {
201        return Some(AgentTerminalClass::ContextOverflow);
202    }
203    if terminal_error_signal_matches(error, |signal| signal == "no_llm_call") {
204        return Some(AgentTerminalClass::ProviderMisconfigured);
205    }
206    if terminal_error_signal_matches(error, |signal| signal == "resource_busy") {
207        return Some(AgentTerminalClass::ResourceBusy);
208    }
209    if terminal_error_signal_matches(error, |signal| {
210        matches!(signal, "tool_rejected" | "egress_blocked")
211    }) {
212        return Some(AgentTerminalClass::ToolPolicyRejected);
213    }
214    if terminal_error_has_after_tool_result_format(error) {
215        return Some(AgentTerminalClass::AgentLoopProtocolFailure);
216    }
217    None
218}
219
220fn agent_terminal_class_from_legacy_text(error: &serde_json::Value) -> Option<AgentTerminalClass> {
221    if terminal_error_legacy_text_matches(error, |text| {
222        terminal_signal_contains_any(text, &["context_overflow"])
223    }) {
224        return Some(AgentTerminalClass::ContextOverflow);
225    }
226    if terminal_error_legacy_text_matches(error, |text| {
227        terminal_signal_contains_any(text, &["no_llm_call"])
228            || (terminal_signal_contains_any(
229                text,
230                &[
231                    "not configured",
232                    "no llm",
233                    "no model",
234                    "missing api key",
235                    "api key",
236                    "credential",
237                    "unauthorized",
238                    "authentication",
239                ],
240            ) && terminal_signal_contains_any(
241                text,
242                &["llm", "model", "provider", "key", "credential"],
243            ))
244    }) {
245        return Some(AgentTerminalClass::ProviderMisconfigured);
246    }
247    if terminal_error_legacy_text_matches(error, |text| {
248        terminal_signal_contains_any(text, &["rate_limit", "rate limit", "rate_limited", " 429 "])
249    }) {
250        return Some(AgentTerminalClass::RateLimited);
251    }
252    if terminal_error_legacy_text_matches(error, |text| {
253        terminal_signal_contains_any(text, &["timeout", "timed out", "deadline_exceeded"])
254    }) {
255        return Some(AgentTerminalClass::Timeout);
256    }
257    if terminal_error_legacy_text_matches(error, |text| {
258        terminal_signal_contains_any(
259            text,
260            &[
261                "tool_rejected",
262                "permission_denied",
263                "policy_denied",
264                "exceeds execution policy",
265                "bridged builtin",
266            ],
267        )
268    }) {
269        return Some(AgentTerminalClass::ToolPolicyRejected);
270    }
271    if terminal_error_legacy_text_matches(error, |text| {
272        terminal_signal_contains_any(
273            text,
274            &[
275                "-32601",
276                "host bridge tool",
277                "not implemented by burinhostresponder",
278            ],
279        )
280    }) {
281        return Some(AgentTerminalClass::HostBridgeUnimplemented);
282    }
283    if terminal_error_legacy_text_matches(error, |text| {
284        terminal_signal_contains_any(
285            text,
286            &[
287                "invalid_request",
288                "missing tool_name",
289                "missing `tool_name`",
290                "empty tool name",
291                "empty_tool_name",
292                "tool_caller",
293                "agent_loop:",
294                "session/prompt error",
295            ],
296        )
297    }) {
298        return Some(AgentTerminalClass::AgentLoopProtocolFailure);
299    }
300    None
301}
302
303fn terminal_class_from_exact_signal(signal: &str) -> Option<AgentTerminalClass> {
304    match signal {
305        "context_overflow" => Some(AgentTerminalClass::ContextOverflow),
306        "no_llm_call" | "provider_misconfigured" | "provider_not_configured" => {
307            Some(AgentTerminalClass::ProviderMisconfigured)
308        }
309        "provider_unavailable" => Some(AgentTerminalClass::ProviderUnavailable),
310        "rate_limit" | "rate_limited" => Some(AgentTerminalClass::RateLimited),
311        "timeout" | "timed_out" | "deadline_exceeded" => Some(AgentTerminalClass::Timeout),
312        "resource_busy" => Some(AgentTerminalClass::ResourceBusy),
313        "tool_policy_rejected" | "tool_rejected" | "permission_denied" | "policy_denied" => {
314            Some(AgentTerminalClass::ToolPolicyRejected)
315        }
316        "agent_loop_protocol_failure" => Some(AgentTerminalClass::AgentLoopProtocolFailure),
317        "parse_dropped" => Some(AgentTerminalClass::ParseDropped),
318        _ => None,
319    }
320}
321
322fn terminal_error_has_structured_signal(error: &serde_json::Value) -> bool {
323    const STRUCTURED_KEYS: &[&str] = &[
324        "category",
325        "error_category",
326        "reason",
327        "code",
328        "kind",
329        "phase",
330        "status",
331        "terminal_class",
332        "tool_format",
333        "after_tool_result",
334    ];
335    STRUCTURED_KEYS.iter().any(|key| error.get(*key).is_some())
336}
337
338fn terminal_error_signal_matches(
339    error: &serde_json::Value,
340    predicate: impl Fn(&str) -> bool,
341) -> bool {
342    const STRUCTURED_KEYS: &[&str] = &[
343        "category",
344        "error_category",
345        "reason",
346        "code",
347        "kind",
348        "phase",
349        "status",
350    ];
351    terminal_error_key_matches(error, STRUCTURED_KEYS, predicate)
352}
353
354fn terminal_error_legacy_text_matches(
355    error: &serde_json::Value,
356    predicate: impl Fn(&str) -> bool,
357) -> bool {
358    terminal_error_key_matches(error, &["message", "error"], predicate)
359}
360
361fn terminal_error_key_matches(
362    error: &serde_json::Value,
363    keys: &[&str],
364    predicate: impl Fn(&str) -> bool,
365) -> bool {
366    keys.iter().any(|key| {
367        error
368            .get(*key)
369            .and_then(terminal_signal_value)
370            .is_some_and(|signal| predicate(&signal))
371    })
372}
373
374fn terminal_status_signal_matches(
375    final_status: &str,
376    stop_reason: &str,
377    predicate: impl Fn(&str) -> bool,
378) -> bool {
379    [final_status, stop_reason]
380        .into_iter()
381        .filter(|signal| !signal.is_empty())
382        .any(predicate)
383}
384
385fn terminal_signal_value(value: &serde_json::Value) -> Option<String> {
386    match value {
387        serde_json::Value::String(value) => Some(value.to_ascii_lowercase()),
388        serde_json::Value::Number(value) => Some(value.to_string()),
389        _ => None,
390    }
391}
392
393fn terminal_signal_contains_any(signal: &str, needles: &[&str]) -> bool {
394    needles.iter().any(|needle| signal.contains(needle))
395}
396
397fn terminal_error_has_after_tool_result_format(error: &serde_json::Value) -> bool {
398    error
399        .get("tool_format")
400        .is_some_and(|value| !value.is_null())
401        && error
402            .get("after_tool_result")
403            .is_some_and(terminal_bool_signal)
404}
405
406fn terminal_bool_signal(value: &serde_json::Value) -> bool {
407    match value {
408        serde_json::Value::Bool(value) => *value,
409        serde_json::Value::String(value) => value.eq_ignore_ascii_case("true"),
410        _ => false,
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use serde_json::json;
418
419    #[test]
420    fn terminal_class_wire_values_are_stable_and_exhaustive() {
421        let pairs = [
422            (AgentTerminalClass::ContextOverflow, "context_overflow"),
423            (
424                AgentTerminalClass::ProviderMisconfigured,
425                "provider_misconfigured",
426            ),
427            (
428                AgentTerminalClass::ProviderUnavailable,
429                "provider_unavailable",
430            ),
431            (AgentTerminalClass::RateLimited, "rate_limited"),
432            (AgentTerminalClass::Timeout, "timeout"),
433            (AgentTerminalClass::ResourceBusy, "resource_busy"),
434            (
435                AgentTerminalClass::ToolPolicyRejected,
436                "tool_policy_rejected",
437            ),
438            (
439                AgentTerminalClass::HostBridgeUnimplemented,
440                "host_bridge_unimplemented",
441            ),
442            (
443                AgentTerminalClass::AgentLoopProtocolFailure,
444                "agent_loop_protocol_failure",
445            ),
446            (AgentTerminalClass::ParseDropped, "parse_dropped"),
447            (AgentTerminalClass::GenericThrow, "generic_throw"),
448        ];
449        for (class, wire) in pairs {
450            assert_eq!(class.as_str(), wire);
451            assert_eq!(serde_json::to_value(class).unwrap(), json!(wire));
452            assert_eq!(
453                serde_json::from_value::<AgentTerminalClass>(json!(wire)).unwrap(),
454                class
455            );
456        }
457        assert_eq!(pairs.len(), AgentTerminalClass::ALL.len());
458    }
459
460    #[test]
461    fn agent_terminal_class_prefers_structured_error_fields() {
462        let cases = [
463            (
464                json!({"category": "no_llm_call"}),
465                AgentTerminalClass::ProviderMisconfigured,
466            ),
467            (
468                json!({"category": "context_overflow"}),
469                AgentTerminalClass::ContextOverflow,
470            ),
471            (
472                json!({"terminal_class": "provider_unavailable"}),
473                AgentTerminalClass::ProviderUnavailable,
474            ),
475            (
476                json!({"terminal_class": "tool_policy_rejected"}),
477                AgentTerminalClass::ToolPolicyRejected,
478            ),
479            (
480                json!({"terminal_class": "rate_limited", "provider": "anthropic"}),
481                AgentTerminalClass::RateLimited,
482            ),
483            (
484                json!({"terminal_class": "timeout"}),
485                AgentTerminalClass::Timeout,
486            ),
487            (
488                json!({"category": "resource_busy"}),
489                AgentTerminalClass::ResourceBusy,
490            ),
491            (
492                json!({"terminal_class": "host_bridge_unimplemented"}),
493                AgentTerminalClass::HostBridgeUnimplemented,
494            ),
495            (
496                json!({"reason": "invalid_request", "tool_format": "native", "after_tool_result": true}),
497                AgentTerminalClass::AgentLoopProtocolFailure,
498            ),
499            (
500                json!({"tool_format": "native", "after_tool_result": true}),
501                AgentTerminalClass::AgentLoopProtocolFailure,
502            ),
503            (
504                json!({"terminal_class": "parse_dropped"}),
505                AgentTerminalClass::ParseDropped,
506            ),
507        ];
508        for (error, expected) in cases {
509            assert_eq!(
510                agent_terminal_class("error", "", Some(&error)),
511                Some(expected),
512                "error={error}"
513            );
514        }
515    }
516
517    #[test]
518    fn agent_terminal_class_uses_legacy_text_only_as_harn_side_fallback() {
519        let provider_wrapped = json!({
520            "message": "session/prompt error: agent_loop: provider not configured: missing API key"
521        });
522        assert_eq!(
523            agent_terminal_class("error", "", Some(&provider_wrapped)),
524            Some(AgentTerminalClass::ProviderMisconfigured)
525        );
526
527        let protocol_wrapped = json!({
528            "message": "session/prompt error [-32000]: agent_loop: tool_caller result missing `tool_name`"
529        });
530        assert_eq!(
531            agent_terminal_class("error", "", Some(&protocol_wrapped)),
532            Some(AgentTerminalClass::AgentLoopProtocolFailure)
533        );
534
535        assert_eq!(
536            agent_terminal_class("failed", "verify_exhausted", None),
537            Some(AgentTerminalClass::GenericThrow)
538        );
539        assert_eq!(
540            agent_terminal_class(
541                "error",
542                "",
543                Some(&json!({"tool_format": "native", "after_tool_result": false}))
544            ),
545            Some(AgentTerminalClass::GenericThrow)
546        );
547        assert_eq!(agent_terminal_class("done", "", None), None);
548    }
549
550    #[test]
551    fn agent_terminal_class_keeps_structured_fields_authoritative() {
552        assert_eq!(
553            agent_terminal_class(
554                "error",
555                "",
556                Some(&json!({
557                    "terminal_class": "rate_limited",
558                    "message": "session/prompt error: tool_caller result missing `tool_name`",
559                }))
560            ),
561            Some(AgentTerminalClass::RateLimited)
562        );
563        for category in ["auth", "timeout", "rate_limited", "overloaded"] {
564            assert_eq!(
565                agent_terminal_class(
566                    "error",
567                    "",
568                    Some(&json!({
569                        "category": category,
570                        "message": "provider-shaped prose without terminal provenance",
571                    }))
572                ),
573                Some(AgentTerminalClass::GenericThrow),
574                "generic VM category {category} must not claim provider provenance"
575            );
576        }
577        assert_eq!(
578            agent_terminal_class(
579                "error",
580                "",
581                Some(&json!({
582                    "provider": "anthropic",
583                    "model": "claude-sonnet",
584                    "message": "plain provider envelope without class authority",
585                }))
586            ),
587            Some(AgentTerminalClass::GenericThrow)
588        );
589
590        assert_eq!(
591            agent_terminal_class(
592                "error",
593                "",
594                Some(&json!({
595                    "category": "some_future_category",
596                    "message": "provider rate limit 429 in /tmp/run-429/result",
597                }))
598            ),
599            Some(AgentTerminalClass::GenericThrow),
600            "an explicit structured category must never fall through to message prose"
601        );
602    }
603
604    #[test]
605    fn model_less_turn_is_flagged_as_no_llm_call() {
606        // Zero iterations + zero tokens + non-error status = silent
607        // short-circuit. This is the model-less turn we must fail loud on.
608        assert!(agent_turn_made_no_llm_call("", false, 0, 0, 0));
609        assert!(agent_turn_made_no_llm_call("done", false, 0, 0, 0));
610    }
611
612    #[test]
613    fn real_turn_is_not_flagged_as_no_llm_call() {
614        // Any real provider round-trip records iterations and/or tokens.
615        assert!(!agent_turn_made_no_llm_call("done", false, 1, 0, 0));
616        assert!(!agent_turn_made_no_llm_call("done", false, 0, 12, 0));
617        assert!(!agent_turn_made_no_llm_call("done", false, 0, 0, 34));
618        // Already-errored or terminal-error turns are left as-is.
619        assert!(!agent_turn_made_no_llm_call("error", false, 0, 0, 0));
620        assert!(!agent_turn_made_no_llm_call("failed", false, 0, 0, 0));
621        assert!(!agent_turn_made_no_llm_call("", true, 0, 0, 0));
622    }
623}