1use 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#[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#[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#[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#[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#[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}