1use std::sync::LazyLock;
4
5use regex::Regex;
6use serde_json::Value;
7
8use crate::{DiagnosticText, ModelErrorSummary, SafeErrorMessage};
9
10pub fn sanitize(value: &str, maximum: usize) -> String {
12 let mut text = if let Ok(json) = serde_json::from_str::<Value>(value) {
13 serde_json::to_string_pretty(&json).expect("JSON value serializes")
14 } else {
15 value.to_owned()
16 };
17 static CONTROLS: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r"[\p{Cc}\p{Cf}&&[^\n\t]]").unwrap());
19 text = CONTROLS.replace_all(&text, " ").into_owned();
20 if text.trim().is_empty() {
21 text = "No error detail was supplied".into();
22 }
23 const MARKER: &str = "\n[diagnostic truncated]";
24 if text.len() > maximum {
25 let mut end = maximum.saturating_sub(MARKER.len());
26 while !text.is_char_boundary(end) {
27 end -= 1;
28 }
29 text.truncate(end);
30 text.push_str(&MARKER[..MARKER.len().min(maximum)]);
31 }
32 text
33}
34
35pub fn detail(value: &str) -> DiagnosticText {
36 DiagnosticText::new(sanitize(value, DiagnosticText::MAX_BYTES)).expect("sanitized diagnostic")
37}
38
39pub fn headline(value: &str) -> SafeErrorMessage {
40 SafeErrorMessage::new(sanitize(value, SafeErrorMessage::MAX_BYTES).replace(['\n', '\t'], " "))
41 .expect("sanitized headline")
42}
43
44pub fn error_chain(error: &(dyn std::error::Error + 'static)) -> String {
45 let mut parts = vec![error.to_string()];
46 let mut source = error.source();
47 for _ in 0..16 {
48 let Some(cause) = source else {
49 break;
50 };
51 let text = cause.to_string();
52 if !parts.last().is_some_and(|last| last.contains(&text)) {
53 parts.push(text);
54 }
55 source = cause.source();
56 }
57 detail(&parts.join(": ")).to_string()
58}
59
60pub fn model(error: &ModelErrorSummary) -> String {
61 let mut text = error.message.to_string();
62 if let Some(status) = error.http_status {
63 text.push_str(&format!(" · HTTP {status}"));
64 }
65 if let Some(code) = &error.vendor_code {
66 text.push_str(&format!(" · code {code}"));
67 }
68 if let Some(id) = &error.request_id {
69 text.push_str(&format!(" · request {id}"));
70 }
71 text.push_str(&format!(
72 " · stage {:?} · retryable {} · {} bytes received",
73 error.stage, error.retryable, error.bytes_received
74 ));
75 if let Some(delay) = error.retry_after_ms {
76 text.push_str(&format!(" · retry after {delay}ms"));
77 }
78 if let Some(body) = &error.response_body {
79 text.push_str(&format!("\nResponse body:\n{}", detail(body.as_str())));
80 }
81 text
82}
83
84pub fn run_error(
85 error: &SafeErrorMessage,
86 model_error: Option<&ModelErrorSummary>,
87 model: Option<&crate::ResolvedModelRef>,
88) -> String {
89 let mut text = error.to_string();
90 if let Some(model) = model {
91 text.push_str(&format!(
92 "\nModel: {} (provider {}, adapter {:?})",
93 model.selection.model, model.provider_id, model.adapter_id
94 ));
95 }
96 if let Some(model_error) = model_error {
97 text.push_str(&format!("\n{}", self::model(model_error)));
98 }
99 text
100}
101
102pub fn internal(failure: &crate::InternalAgentFailure) -> String {
103 match &failure.model_error {
104 Some(error) => format!("{}\n{}", failure.message, model(error)),
105 None => failure.message.to_string(),
106 }
107}
108
109pub fn internal_fallback(
110 kind: crate::InternalAgentKind,
111 from: &crate::InternalAgentBackend,
112 to: &crate::InternalAgentBackend,
113 attempts: u32,
114 failure: &crate::InternalAgentFailure,
115) -> String {
116 fn backend(value: &crate::InternalAgentBackend) -> String {
117 match value {
118 crate::InternalAgentBackend::Model { resolved_model } => {
119 resolved_model.selection.model.to_string()
120 }
121 crate::InternalAgentBackend::Builtin { name, .. } => name.to_string(),
122 }
123 }
124 format!(
125 "internal agent {kind:?} fallback {} → {} after {attempts} attempt(s): {}",
126 backend(from),
127 backend(to),
128 internal(failure)
129 )
130}
131
132pub fn tool(termination: &crate::ToolCallTermination) -> String {
133 if let Some(error) = &termination.error {
134 return excerpt(
136 &format!("{}: {}", error.code, error.message),
137 DiagnosticText::MAX_BYTES,
138 );
139 }
140 if let Some(result) = &termination.result {
141 return tool_result(result);
142 }
143 format!(
144 "tool ended with {:?}; no diagnostic supplied",
145 termination.outcome
146 )
147}
148
149pub fn tool_result(result: &crate::PersistedToolResult) -> String {
150 let mut parts = vec![excerpt(result.title.as_str(), 256)];
151 for key in [
153 "status",
154 "exit_code",
155 "exitCode",
156 "signal",
157 "source",
158 "path",
159 "filePath",
160 "error",
161 "message",
162 ] {
163 if let Some(value) = result.metadata.get(key).filter(|value| !value.is_null()) {
164 let value = value
165 .as_str()
166 .map_or_else(|| value.to_string(), str::to_owned);
167 parts.push(format!("{key}: {}", excerpt(&value, 160)));
168 }
169 }
170 let mut sections = tool_sections(result);
171 sections.sort_by_key(|(name, _)| match *name {
173 "stderr" | "diagnostics" | "errors" | "error" => 0,
174 "stdout" => 2,
175 _ => 1,
176 });
177 let used = parts.iter().map(|part| part.len() + 1).sum::<usize>();
178 let budget = DiagnosticText::MAX_BYTES.saturating_sub(used);
179 if sections.is_empty() {
180 parts.push(excerpt(
181 result
182 .display
183 .as_deref()
184 .filter(|value| !value.trim().is_empty())
185 .unwrap_or("Tool reported failure without a reason"),
186 budget.saturating_sub(1),
187 ));
188 } else {
189 let per_section = budget / sections.len();
190 for (name, value) in sections {
191 let heading = format!("[{name}]\n");
192 parts.push(format!(
193 "{heading}{}",
194 excerpt(value, per_section.saturating_sub(heading.len() + 1))
195 ));
196 }
197 }
198 detail(&parts.join("\n")).to_string()
199}
200
201fn tool_sections(result: &crate::PersistedToolResult) -> Vec<(&str, &str)> {
202 let mut sections = Vec::new();
203 if let Some(retained) = &result.retained_output {
204 let mut end = result.output.len();
205 for (index, stream) in retained.streams.iter().enumerate().rev() {
206 let Some(name) = stream.name.as_deref() else {
207 break;
208 };
209 let marker = if index == 0 {
210 format!("[{name}]\n")
211 } else {
212 format!("\n\n[{name}]\n")
213 };
214 let Some(start) = result.output[..end].rfind(&marker) else {
215 sections.clear();
216 break;
217 };
218 let value = &result.output[start + marker.len()..end];
219 if !value.trim().is_empty() {
220 sections.push((name, value));
221 }
222 end = start;
223 }
224 }
225 if sections.is_empty() && !result.output.trim().is_empty() {
226 sections.push(("output", &result.output));
227 }
228 sections
229}
230
231fn excerpt(value: &str, maximum: usize) -> String {
233 let clean = sanitize(value, usize::MAX);
234 if clean.len() <= maximum {
235 return clean;
236 }
237 const MARKER: &str = "\n[diagnostic truncated]\n";
238 if maximum < MARKER.len() {
239 return sanitize(&clean, maximum);
240 }
241 let mut head = (maximum - MARKER.len()) / 2;
242 let mut tail = clean.len() - (maximum - MARKER.len() - head);
243 while !clean.is_char_boundary(head) {
244 head -= 1;
245 }
246 while !clean.is_char_boundary(tail) {
247 tail += 1;
248 }
249 format!("{}{MARKER}{}", &clean[..head], &clean[tail..])
250}
251
252pub fn rpc(error: &crate::JsonRpcError) -> String {
254 let mut text = format!("{} (RPC {})", error.message, error.code);
255 if let Some(Value::Object(data)) = &error.data {
256 for key in [
257 "message",
258 "reason",
259 "detail",
260 "cause",
261 "code",
262 "path",
263 "line",
264 "column",
265 "request_id",
266 "expected_revision",
267 "found_revision",
268 ] {
269 if let Some(value) = data.get(key).filter(|value| !value.is_null()) {
270 let value = value.as_str().map_or_else(
271 || detail(&value.to_string()).to_string(),
272 |value| detail(value).to_string(),
273 );
274 text.push_str(&format!("\n{key}: {value}"));
275 }
276 }
277 }
278 detail(&text).to_string()
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn diagnostics_preserve_credentials_in_json_and_prose() {
287 let input = r#"{"password":"hidden","nested":[{"api_key":"hidden2"}],"access_token":"literal","echo":"Bearer opaque Basic abc sk-other https://user:pass@host/?token=query&sig=signed","headers":[{"name":"Authorization","value":"credential"}]}"#;
288 let text = sanitize(input, 4096);
289 assert_eq!(
290 serde_json::from_str::<Value>(&text).unwrap(),
291 serde_json::from_str::<Value>(input).unwrap()
292 );
293 for input in [
294 format!("gateway: {input}"),
295 r#"Couldn't decode response: {"password":"review\"secret-tail"#.into(),
296 "Bearer opaque Basic abc sk-other https://user:pass@host/?key=query&sig=signed".into(),
297 ] {
298 assert_eq!(sanitize(&input, 4096), input);
299 }
300 }
301
302 #[test]
303 fn diagnostic_bounds_utf8_controls_and_preserves_gateway_text() {
304 let text = sanitize(
305 &format!(
306 "<html>Gateway\nfailed</html>\u{1b}\u{202e}{}",
307 "é".repeat(4096)
308 ),
309 4096,
310 );
311 assert!(text.starts_with("<html>Gateway\nfailed</html>"));
312 assert!(!text.contains(['\u{1b}', '\u{202e}']));
313 assert!(text.len() <= 4096);
314 assert!(text.ends_with("[diagnostic truncated]"));
315 }
316
317 #[test]
318 fn diagnostic_wire_rejects_terminal_controls_and_oversized_text() {
319 assert!(DiagnosticText::new("one\ntwo\tthree").is_ok());
320 assert!(DiagnosticText::new("escape\u{1b}").is_err());
321 assert!(DiagnosticText::new("bidi\u{202e}").is_err());
322 assert!(DiagnosticText::new("x".repeat(4097)).is_err());
323 }
324
325 #[test]
326 fn persisted_tool_error_wrapper_preserves_status_and_unicode_final_cause() {
327 let result = crate::PersistedToolResult {
328 title: crate::SafeDisplayText::new("Generic tool").unwrap(),
329 output: format!("{}\nRequired asset missing é", "é".repeat(6000)),
330 metadata: serde_json::json!({"status":23}),
331 display: None,
332 retained_output: None,
333 truncation: None,
334 attachments: vec![],
335 additional_messages: vec![],
336 };
337 let message = headline(&tool_result(&result));
338 assert!(message.as_str().len() >= 4090);
339 assert!(message.as_str().ends_with("Required asset missing é"));
340 let termination = crate::ToolCallTermination {
341 tool_call_id: crate::ToolCallId::new_v7(),
342 owner: crate::AssistantToolCallRef {
343 model_turn_seq: 1,
344 content_index: 0,
345 model_call_id: crate::ModelCallId::new("call").unwrap(),
346 provider_item_id: None,
347 },
348 outcome: crate::ToolTerminationOutcome::Failed,
349 result: Some(result),
350 error: Some(crate::SafeToolError {
351 code: crate::SafeCode::new("execution_failed").unwrap(),
352 message,
353 }),
354 };
355 let persisted: crate::ToolCallTermination =
356 serde_json::from_value(serde_json::to_value(termination).unwrap()).unwrap();
357 let output = tool(&persisted);
358 assert!(output.contains("status: 23"), "{output}");
359 assert!(output.ends_with("Required asset missing é"), "{output}");
360 assert!(output.contains("[diagnostic truncated]"));
361 assert_eq!(output.matches("execution_failed:").count(), 1);
362 assert!(output.len() <= DiagnosticText::MAX_BYTES);
363 }
364
365 #[test]
366 fn custom_streams_and_generic_output_keep_final_causes_with_status() {
367 for named in [false, true] {
368 let digest = crate::Sha256Digest::of_bytes(b"fixture");
369 let reference = crate::ArtifactReference {
370 uri: format!("artifact://sha256/{digest}"),
371 };
372 let result = crate::PersistedToolResult {
373 title: crate::SafeDisplayText::new("Search").unwrap(),
374 output: format!(
375 "[results]\n{}\n\n[trace]\n{}\nCustom final cause",
376 "r".repeat(6000),
377 "t".repeat(6000)
378 ),
379 display: Some("Finished".into()),
380 metadata: serde_json::json!({"status":23,"source":"/work/input"}),
381 truncation: None,
382 retained_output: named.then(|| crate::RetainedToolOutput {
383 reference: reference.clone(),
384 incomplete: true,
385 streams: ["results", "trace"]
386 .into_iter()
387 .map(|name| crate::RetainedToolStream {
388 name: Some(name.into()),
389 reference: reference.clone(),
390 sha256: digest.clone(),
391 byte_length: 6000,
392 line_count: 1,
393 truncated: false,
394 next_offset: None,
395 })
396 .collect(),
397 }),
398 attachments: vec![],
399 additional_messages: vec![],
400 };
401 let text = tool_result(&result);
402 assert!(text.contains("Custom final cause"), "{text}");
403 assert!(text.contains("status: 23"));
404 assert!(text.contains("source: /work/input"));
405 assert!(text.contains("[diagnostic truncated]"));
406 assert!(text.len() <= DiagnosticText::MAX_BYTES);
407 }
408 }
409
410 #[test]
411 fn legacy_failures_and_summaries_read_and_new_diagnostics_round_trip() {
412 let old = serde_json::json!({"type":"run_failed", "error":"invalid request"});
413 let mut event: crate::EventPayload = serde_json::from_value(old.clone()).unwrap();
414 assert_eq!(serde_json::to_value(&event).unwrap(), old);
415 let summary: ModelErrorSummary = serde_json::from_value(serde_json::json!({"kind":"invalid_request","message":"invalid request","retryable":false,"stage":"response_body","http_status":400,"bytes_received":10,"vendor_code":null,"request_id":null,"retry_after_ms":null})).unwrap();
416 assert!(summary.response_body.is_none());
417 if let crate::EventPayload::RunFailed { model_error, .. } = &mut event {
418 *model_error = Some(ModelErrorSummary {
419 response_body: Some(detail("line one\nline two")),
420 ..summary
421 });
422 }
423 let wire = serde_json::to_value(&event).unwrap();
424 let decoded: crate::EventPayload = serde_json::from_value(wire.clone()).unwrap();
425 assert_eq!(serde_json::to_value(decoded).unwrap(), wire);
426 }
427
428 #[test]
429 fn rpc_display_selects_cause_and_debug_remains_redacted() {
430 let error = crate::JsonRpcError {
431 code: -32603,
432 message: "operation failed".into(),
433 data: Some(
434 serde_json::json!({"cause":"password=visible", "path":"/work/config.toml", "line":7}),
435 ),
436 };
437 let display = rpc(&error);
438 assert!(display.contains("password=visible"));
439 assert!(display.contains("/work/config.toml"));
440 assert!(display.contains("line: 7"));
441 assert!(!format!("{error:?}").contains("password=visible"));
442 }
443}