1use serde::{Deserialize, Serialize};
21
22#[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
117pub(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 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 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 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}