Skip to main content

cookie_agent_protocol/
diagnostics.rs

1//! Bounded user-facing diagnostics. Preserve diagnostic content while removing
2//! terminal/format controls and truncating text to the display budget.
3use std::sync::LazyLock;
4
5use regex::Regex;
6use serde_json::Value;
7
8use crate::{DiagnosticText, ModelErrorSummary, SafeErrorMessage};
9
10/// Preserve LF/tab, remove terminal/format controls, and truncate at UTF-8 boundaries.
11pub 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        // Preserve both status/context and final cause when adding the prefix.
135        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    // Give status/source context and each output field separate budgets.
152    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    // Named stream headings come from the runtime in declaration order.
172    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
231// Keep the head and tail: final causes often follow a noisy stream.
232fn 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
252/// Only diagnostic fields are selected from RPC data. Debug remains redacted.
253pub 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}