Skip to main content

telos_agent/
diagnostics.rs

1//! Structured diagnostics for tool execution failures.
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use tokio::io::AsyncWriteExt;
9use tokio::sync::Mutex;
10
11use crate::error::AgentError;
12
13/// Normalized classes for failures produced by the tool executor.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ToolFailureKind {
17    ValidationError,
18    PermissionDenied,
19    PermissionRequired,
20    PermissionError,
21    ToolNotFound,
22    ExecutionError,
23    ExecutionPanic,
24}
25
26impl ToolFailureKind {
27    pub fn as_str(self) -> &'static str {
28        match self {
29            ToolFailureKind::ValidationError => "validation_error",
30            ToolFailureKind::PermissionDenied => "permission_denied",
31            ToolFailureKind::PermissionRequired => "permission_required",
32            ToolFailureKind::PermissionError => "permission_error",
33            ToolFailureKind::ToolNotFound => "tool_not_found",
34            ToolFailureKind::ExecutionError => "execution_error",
35            ToolFailureKind::ExecutionPanic => "execution_panic",
36        }
37    }
38}
39
40/// Sanitized failure details safe to persist locally and summarize externally.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct SanitizedToolFailure {
43    pub argument_summary: String,
44    pub error_summary: String,
45    pub exit_code: Option<i32>,
46}
47
48/// A structured, sanitized record for one failed tool invocation.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ToolFailureEvent {
51    pub timestamp_unix_secs: u64,
52    pub session_id: String,
53    pub turn_id: u64,
54    pub tool_call_id: String,
55    pub tool_name: String,
56    pub failure_kind: ToolFailureKind,
57    pub argument_summary: String,
58    pub error_summary: String,
59    pub exit_code: Option<i32>,
60    pub signature: String,
61}
62
63impl ToolFailureEvent {
64    pub fn new(
65        session_id: impl Into<String>,
66        turn_id: u64,
67        tool_call_id: impl Into<String>,
68        tool_name: impl Into<String>,
69        failure_kind: ToolFailureKind,
70        failure: SanitizedToolFailure,
71    ) -> Self {
72        let tool_name = tool_name.into();
73        let signature =
74            signature_for(&tool_name, failure_kind, &failure.argument_summary, &failure, 240);
75        Self {
76            timestamp_unix_secs: unix_timestamp(),
77            session_id: session_id.into(),
78            turn_id,
79            tool_call_id: tool_call_id.into(),
80            tool_name,
81            failure_kind,
82            argument_summary: failure.argument_summary,
83            error_summary: failure.error_summary,
84            exit_code: failure.exit_code,
85            signature,
86        }
87    }
88}
89
90/// Sanitizes tool arguments and errors before diagnostics persistence.
91#[derive(Debug, Clone)]
92pub struct ToolFailureSanitizer {
93    project_root: PathBuf,
94    env_values: Vec<String>,
95    pub home_dir: Option<PathBuf>,
96}
97
98impl ToolFailureSanitizer {
99    pub fn new(project_root: PathBuf, env: HashMap<String, String>) -> Self {
100        let mut env_values = env.into_values().filter(|value| value.len() >= 4).collect::<Vec<_>>();
101        env_values.sort_by_key(|value| std::cmp::Reverse(value.len()));
102        Self { project_root, env_values, home_dir: std::env::var_os("HOME").map(PathBuf::from) }
103    }
104
105    pub fn sanitize_failure(
106        &self,
107        tool_name: &str,
108        arguments: &serde_json::Value,
109        error: &str,
110    ) -> SanitizedToolFailure {
111        let argument_summary = self.sanitize_text(&argument_summary(tool_name, arguments));
112        let error_summary = trim_chars(&self.sanitize_text(error), 2_000);
113        SanitizedToolFailure {
114            argument_summary,
115            exit_code: extract_exit_code(error),
116            error_summary,
117        }
118    }
119
120    pub fn sanitize_text(&self, text: &str) -> String {
121        let mut sanitized = text.to_string();
122        sanitized = replace_path(&sanitized, &self.project_root, "<PROJECT>");
123        if let Some(home_dir) = &self.home_dir {
124            sanitized = replace_path(&sanitized, home_dir, "<HOME>");
125        }
126        sanitized = replace_path(&sanitized, Path::new("/tmp"), "<TMP>");
127        for value in &self.env_values {
128            sanitized = sanitized.replace(value, "<ENV_VALUE>");
129        }
130        sanitized = strip_url_queries(&sanitized);
131        sanitized = redact_emails(&sanitized);
132        sanitized = redact_secret_like_tokens(&sanitized);
133        sanitized
134    }
135}
136
137/// Receives sanitized diagnostics events from the executor.
138#[async_trait]
139pub trait ToolDiagnosticsSink: Send + Sync {
140    async fn record(&self, event: ToolFailureEvent) -> Result<(), AgentError>;
141}
142
143#[derive(Debug, Default)]
144pub struct NoopToolDiagnosticsSink;
145
146#[async_trait]
147impl ToolDiagnosticsSink for NoopToolDiagnosticsSink {
148    async fn record(&self, _event: ToolFailureEvent) -> Result<(), AgentError> {
149        Ok(())
150    }
151}
152
153#[derive(Debug)]
154pub struct JsonlToolDiagnosticsSink {
155    path: PathBuf,
156    write_lock: Mutex<()>,
157}
158
159impl JsonlToolDiagnosticsSink {
160    pub fn new(path: impl Into<PathBuf>) -> Self {
161        Self { path: path.into(), write_lock: Mutex::new(()) }
162    }
163
164    pub fn path(&self) -> &Path {
165        &self.path
166    }
167}
168
169#[async_trait]
170impl ToolDiagnosticsSink for JsonlToolDiagnosticsSink {
171    async fn record(&self, event: ToolFailureEvent) -> Result<(), AgentError> {
172        let _guard = self.write_lock.lock().await;
173        if let Some(parent) = self.path.parent() {
174            tokio::fs::create_dir_all(parent)
175                .await
176                .map_err(|err| AgentError::Config(err.to_string()))?;
177        }
178        let mut file = tokio::fs::OpenOptions::new()
179            .create(true)
180            .append(true)
181            .open(&self.path)
182            .await
183            .map_err(|err| AgentError::Config(err.to_string()))?;
184        let line =
185            serde_json::to_string(&event).map_err(|err| AgentError::Config(err.to_string()))?;
186        file.write_all(line.as_bytes()).await.map_err(|err| AgentError::Config(err.to_string()))?;
187        file.write_all(b"\n").await.map_err(|err| AgentError::Config(err.to_string()))?;
188        Ok(())
189    }
190}
191
192pub fn sanitized_event_for_failure(
193    session_id: &str,
194    turn_id: u64,
195    tool_call_id: &str,
196    tool_name: &str,
197    failure_kind: ToolFailureKind,
198    arguments: &serde_json::Value,
199    error: &str,
200    cwd: &Path,
201    env: &HashMap<String, String>,
202) -> ToolFailureEvent {
203    let sanitizer = ToolFailureSanitizer::new(cwd.to_path_buf(), env.clone());
204    let failure = sanitizer.sanitize_failure(tool_name, arguments, error);
205    ToolFailureEvent::new(session_id, turn_id, tool_call_id, tool_name, failure_kind, failure)
206}
207
208fn argument_summary(tool_name: &str, arguments: &serde_json::Value) -> String {
209    let name = tool_name.to_ascii_lowercase();
210    if (name == "bash" || name == "powershell")
211        && let Some(command) = arguments.get("command").and_then(|value| value.as_str())
212    {
213        return summarize_command(command);
214    }
215    for key in ["file_path", "path", "pattern", "query", "url", "description"] {
216        if let Some(value) = arguments.get(key).and_then(|value| value.as_str()) {
217            return trim_chars(value, 240);
218        }
219    }
220    trim_chars(&json!(arguments).to_string(), 240)
221}
222
223fn summarize_command(command: &str) -> String {
224    let first_line = command.lines().next().unwrap_or(command).trim();
225    let mut parts = first_line.split_whitespace();
226    match (parts.next(), parts.next()) {
227        (Some(first), Some(second))
228            if matches!(first, "cargo" | "git" | "npm" | "pnpm" | "yarn") =>
229        {
230            format!("{first} {second}")
231        }
232        (Some(first), _) => first.to_string(),
233        _ => String::new(),
234    }
235}
236
237fn signature_for(
238    tool_name: &str,
239    failure_kind: ToolFailureKind,
240    argument_summary: &str,
241    failure: &SanitizedToolFailure,
242    max_error_chars: usize,
243) -> String {
244    let exit = failure.exit_code.map(|code| format!(":exit={code}")).unwrap_or_default();
245    let leading_error = failure.error_summary.lines().next().unwrap_or("").trim();
246    format!(
247        "{}:{}{}:{}:{}",
248        tool_name,
249        failure_kind.as_str(),
250        exit,
251        argument_summary,
252        trim_chars(leading_error, max_error_chars)
253    )
254}
255
256fn replace_path(text: &str, path: &Path, replacement: &str) -> String {
257    let path = path.to_string_lossy();
258    if path.is_empty() || path == "." {
259        return text.to_string();
260    }
261    text.replace(path.as_ref(), replacement)
262}
263
264fn strip_url_queries(text: &str) -> String {
265    text.split_whitespace()
266        .map(|part| {
267            if let Some(query_start) = part.find('?')
268                && (part.starts_with("http://") || part.starts_with("https://"))
269            {
270                let (base, tail) = part.split_at(query_start);
271                let suffix = tail
272                    .find(|ch| [' ', '\n', '\t'].contains(&ch))
273                    .map(|idx| &tail[idx..])
274                    .unwrap_or("");
275                return format!("{base}<QUERY>{suffix}");
276            }
277            part.to_string()
278        })
279        .collect::<Vec<_>>()
280        .join(" ")
281}
282
283fn redact_emails(text: &str) -> String {
284    text.split_whitespace()
285        .map(|part| {
286            let has_email_shape = part.contains('@') && part.rsplit_once('.').is_some();
287            if has_email_shape { "<EMAIL>".to_string() } else { part.to_string() }
288        })
289        .collect::<Vec<_>>()
290        .join(" ")
291}
292
293fn redact_secret_like_tokens(text: &str) -> String {
294    text.split_whitespace()
295        .map(|part| {
296            let trimmed =
297                part.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_');
298            let lower = trimmed.to_ascii_lowercase();
299            let secret_like = lower.starts_with("sk-")
300                || lower.starts_with("ghp_")
301                || lower.starts_with("github_pat_")
302                || lower.starts_with("bearer");
303            if secret_like { part.replace(trimmed, "<SECRET>") } else { part.to_string() }
304        })
305        .collect::<Vec<_>>()
306        .join(" ")
307}
308
309fn extract_exit_code(error: &str) -> Option<i32> {
310    let marker = "exit code Some(";
311    let start = error.find(marker)? + marker.len();
312    let rest = &error[start..];
313    let end = rest.find(')')?;
314    rest[..end].parse().ok()
315}
316
317fn trim_chars(text: &str, max_chars: usize) -> String {
318    if text.chars().count() <= max_chars {
319        return text.to_string();
320    }
321    let preview = text.chars().take(max_chars).collect::<String>();
322    format!("{preview}<truncated>")
323}
324
325fn unix_timestamp() -> u64 {
326    std::time::SystemTime::now()
327        .duration_since(std::time::UNIX_EPOCH)
328        .map(|duration| duration.as_secs())
329        .unwrap_or(0)
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use std::collections::HashMap;
336    use std::path::PathBuf;
337
338    #[test]
339    fn sanitizer_redacts_sensitive_values() {
340        let mut sanitizer = ToolFailureSanitizer::new(
341            PathBuf::from("/home/alice/project"),
342            HashMap::from([("API_KEY".into(), "sk-secret-value".into())]),
343        );
344        sanitizer.home_dir = Some(PathBuf::from("/home/alice"));
345        let text = "token sk-secret-value at /home/alice/project/src/main.rs user a@example.com https://example.com/path?secret=1";
346        let sanitized = sanitizer.sanitize_text(text);
347        assert!(!sanitized.contains("sk-secret-value"));
348        assert!(!sanitized.contains("/home/alice"));
349        assert!(!sanitized.contains("a@example.com"));
350        assert!(!sanitized.contains("secret=1"));
351        assert!(sanitized.contains("<ENV_VALUE>"));
352        assert!(sanitized.contains("<PROJECT>"));
353        assert!(sanitized.contains("<EMAIL>"));
354    }
355
356    #[test]
357    fn event_signature_is_stable_for_sanitized_failures() {
358        let event = ToolFailureEvent::new(
359            "s1",
360            2,
361            "call-1",
362            "Bash",
363            ToolFailureKind::ExecutionError,
364            SanitizedToolFailure {
365                argument_summary: "cargo test".into(),
366                error_summary: "Command failed with exit code Some(101)".into(),
367                exit_code: Some(101),
368            },
369        );
370        assert_eq!(
371            event.signature,
372            "Bash:execution_error:exit=101:cargo test:Command failed with exit code Some(101)"
373        );
374    }
375
376    #[test]
377    fn powershell_arguments_are_summarized_like_shell_commands() {
378        let summary = argument_summary(
379            "PowerShell",
380            &json!({ "command": "Get-ChildItem -Force C:\\Users\\alice" }),
381        );
382
383        assert_eq!(summary, "Get-ChildItem");
384    }
385}