Skip to main content

talos_session/
diagnostic.rs

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}