1use serde::{Deserialize, Serialize};
2use talos_core::message::{AgentEvent, StopReason};
3
4pub(crate) const TERMINAL_DIAGNOSTIC_PREFIX: &str = "__TALOS_PROVIDER_TERMINAL_DIAGNOSTIC__:";
5const MAX_IDENTITY_CHARS: usize = 128;
6const MAX_REASON_CHARS: usize = 160;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ProviderTerminalOutcome {
11 Completed,
12 ToolUse,
13 Truncated,
14 Error,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ProviderTerminalSource {
20 Explicit,
21 MissingTerminal,
22 UnsupportedReason,
23 DecodeError,
24 TransportError,
25 Timeout,
26 ProviderError,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct ProviderTerminalDiagnostic {
31 pub version: u8,
32 pub turn_id: String,
33 pub response_ordinal: u32,
34 pub outcome: ProviderTerminalOutcome,
35 pub source: ProviderTerminalSource,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub reason: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub provider: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub model: Option<String>,
42}
43
44impl ProviderTerminalDiagnostic {
45 pub fn from_agent_event(
46 turn_id: &str,
47 response_ordinal: u32,
48 event: &AgentEvent,
49 provider: Option<&str>,
50 model: Option<&str>,
51 ) -> Option<Self> {
52 let (outcome, source, reason) = match event {
53 AgentEvent::TurnEnd { stop_reason, .. } => match stop_reason {
54 StopReason::EndTurn => (
55 ProviderTerminalOutcome::Completed,
56 ProviderTerminalSource::Explicit,
57 None,
58 ),
59 StopReason::ToolUse => (
60 ProviderTerminalOutcome::ToolUse,
61 ProviderTerminalSource::Explicit,
62 None,
63 ),
64 StopReason::MaxTokens => (
65 ProviderTerminalOutcome::Truncated,
66 ProviderTerminalSource::Explicit,
67 Some("max_tokens".to_string()),
68 ),
69 },
70 AgentEvent::Error { message } => {
71 let (source, reason) = classify_error(message);
72 (ProviderTerminalOutcome::Error, source, Some(reason))
73 }
74 _ => return None,
75 };
76 Some(Self {
77 version: 1,
78 turn_id: bound(turn_id, MAX_IDENTITY_CHARS),
79 response_ordinal: response_ordinal.max(1),
80 outcome,
81 source,
82 reason,
83 provider: provider.map(|value| bound(value, MAX_IDENTITY_CHARS)),
84 model: model.map(|value| bound(value, MAX_IDENTITY_CHARS)),
85 })
86 }
87}
88
89fn classify_error(message: &str) -> (ProviderTerminalSource, String) {
90 if message.contains("closed without explicit terminal signal") {
91 return (
92 ProviderTerminalSource::MissingTerminal,
93 "missing_explicit_terminal".into(),
94 );
95 }
96 for (prefix, reason) in [
97 (
98 "provider response filtered by content policy",
99 "content_filter",
100 ),
101 (
102 "provider requested deprecated legacy function_call",
103 "legacy_function_call",
104 ),
105 ("provider paused turn", "pause_turn"),
106 ("provider refused request", "refusal"),
107 ] {
108 if message.starts_with(prefix) {
109 return (ProviderTerminalSource::ProviderError, reason.into());
110 }
111 }
112 for prefix in [
113 "unsupported provider finish_reason:",
114 "unsupported provider stop_reason:",
115 ] {
116 if let Some(reason) = message.strip_prefix(prefix) {
117 return (
118 ProviderTerminalSource::UnsupportedReason,
119 bound(reason.trim(), MAX_REASON_CHARS),
120 );
121 }
122 }
123 if message.contains("decode error") || message.contains("invalid UTF-8") {
124 return (ProviderTerminalSource::DecodeError, "invalid_utf8".into());
125 }
126 if message.contains("transport read error") {
127 return (ProviderTerminalSource::TransportError, "read_error".into());
128 }
129 if message.contains("first-packet timeout") {
130 return (
131 ProviderTerminalSource::Timeout,
132 "first_packet_timeout".into(),
133 );
134 }
135 if message.contains("stream-idle timeout") {
136 return (
137 ProviderTerminalSource::Timeout,
138 "stream_idle_timeout".into(),
139 );
140 }
141 (
142 ProviderTerminalSource::ProviderError,
143 "provider_error".into(),
144 )
145}
146
147fn bound(value: &str, max_chars: usize) -> String {
148 let bounded = value
149 .chars()
150 .filter(|character| !character.is_control())
151 .take(max_chars)
152 .collect::<String>();
153 if bounded.is_empty() {
154 "unknown".into()
155 } else {
156 bounded
157 }
158}
159
160pub(crate) fn encode_terminal_diagnostic(
161 diagnostic: &ProviderTerminalDiagnostic,
162) -> Result<String, serde_json::Error> {
163 serde_json::to_string(diagnostic)
164 .map(|encoded| format!("{TERMINAL_DIAGNOSTIC_PREFIX}{encoded}"))
165}
166
167pub(crate) fn decode_terminal_diagnostic(content: &str) -> Option<ProviderTerminalDiagnostic> {
168 content
169 .strip_prefix(TERMINAL_DIAGNOSTIC_PREFIX)
170 .and_then(|encoded| serde_json::from_str(encoded).ok())
171}
172
173pub(crate) fn is_terminal_diagnostic_content(content: &str) -> bool {
174 content.starts_with(TERMINAL_DIAGNOSTIC_PREFIX)
175}