Skip to main content

zeph_core/
debug_dump.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Debug dump writer for a single agent session.
5//!
6//! When active, every LLM request/response pair and raw tool output is written to
7//! numbered files in a timestamped subdirectory of the configured output directory.
8//! Intended for context debugging only — do not use in production.
9
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU32, Ordering};
12
13use base64::Engine as _;
14use serde::{Deserialize, Serialize};
15use zeph_llm::provider::{Message, MessagePart, Role};
16
17/// Output format for debug dump files.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum DumpFormat {
21    /// Write LLM requests as pretty-printed internal zeph-llm JSON (`{id}-request.json`).
22    #[default]
23    Json,
24    /// Write LLM requests as the actual API payload sent to the provider (`{id}-request.json`):
25    /// system extracted, `agent_invisible` messages filtered, parts rendered as content blocks.
26    Raw,
27}
28
29pub struct DebugDumper {
30    dir: PathBuf,
31    counter: AtomicU32,
32    format: DumpFormat,
33}
34
35impl DebugDumper {
36    /// Create a new dumper, creating a timestamped subdirectory under `base_dir`.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the directory cannot be created.
41    pub fn new(base_dir: &Path, format: DumpFormat) -> std::io::Result<Self> {
42        let ts = std::time::SystemTime::now()
43            .duration_since(std::time::UNIX_EPOCH)
44            .map_or(0, |d| d.as_secs());
45        let dir = base_dir.join(ts.to_string());
46        std::fs::create_dir_all(&dir)?;
47        tracing::info!(path = %dir.display(), format = ?format, "debug dump directory created");
48        Ok(Self {
49            dir,
50            counter: AtomicU32::new(0),
51            format,
52        })
53    }
54
55    /// Return the session dump directory.
56    #[must_use]
57    pub fn dir(&self) -> &Path {
58        &self.dir
59    }
60
61    fn next_id(&self) -> u32 {
62        self.counter.fetch_add(1, Ordering::Relaxed)
63    }
64
65    fn write(&self, filename: &str, content: &[u8]) {
66        let path = self.dir.join(filename);
67        if let Err(e) = std::fs::write(&path, content) {
68            tracing::warn!(path = %path.display(), error = %e, "debug dump write failed");
69        }
70    }
71
72    /// Dump the messages about to be sent to the LLM.
73    ///
74    /// Returns an ID that must be passed to [`dump_response`] to correlate request and response.
75    pub fn dump_request(&self, messages: &[Message]) -> u32 {
76        let id = self.next_id();
77        let json = match self.format {
78            DumpFormat::Json => serde_json::to_string_pretty(messages)
79                .unwrap_or_else(|e| format!("serialization error: {e}")),
80            DumpFormat::Raw => messages_to_api_json(messages),
81        };
82        self.write(&format!("{id:04}-request.json"), json.as_bytes());
83        id
84    }
85
86    /// Dump the LLM response corresponding to a prior [`dump_request`] call.
87    pub fn dump_response(&self, id: u32, response: &str) {
88        self.write(&format!("{id:04}-response.txt"), response.as_bytes());
89    }
90
91    /// Dump raw tool output before any truncation or summarization.
92    pub fn dump_tool_output(&self, tool_name: &str, output: &str) {
93        let id = self.next_id();
94        let safe_name = sanitize_dump_name(tool_name);
95        self.write(&format!("{id:04}-tool-{safe_name}.txt"), output.as_bytes());
96    }
97
98    /// Dump a tool error with error classification for debugging transient/permanent failures.
99    pub fn dump_tool_error(&self, tool_name: &str, error: &zeph_tools::ToolError) {
100        let id = self.next_id();
101        let safe_name = sanitize_dump_name(tool_name);
102        let payload = serde_json::json!({
103            "tool": tool_name,
104            "error": error.to_string(),
105            "kind": error.kind().to_string(),
106        });
107        match serde_json::to_string_pretty(&payload) {
108            Ok(json) => {
109                self.write(
110                    &format!("{id:04}-tool-error-{safe_name}.json"),
111                    json.as_bytes(),
112                );
113            }
114            Err(e) => {
115                tracing::warn!("dump_tool_error: failed to serialize error payload: {e}");
116            }
117        }
118    }
119}
120
121fn sanitize_dump_name(name: &str) -> String {
122    name.chars()
123        .map(|c| {
124            if c.is_alphanumeric() || c == '-' {
125                c
126            } else {
127                '_'
128            }
129        })
130        .collect()
131}
132
133/// Render messages as the API payload format (mirrors `split_messages_structured` in the
134/// Claude provider): system extracted, `agent_visible = false` messages filtered out,
135/// parts converted to typed content blocks (`text`, `tool_use`, `tool_result`, etc.).
136fn messages_to_api_json(messages: &[Message]) -> String {
137    let system: String = messages
138        .iter()
139        .filter(|m| m.metadata.agent_visible && m.role == Role::System)
140        .map(zeph_llm::provider::Message::to_llm_content)
141        .collect::<Vec<_>>()
142        .join("\n\n");
143
144    let chat: Vec<serde_json::Value> = messages
145        .iter()
146        .filter(|m| m.metadata.agent_visible && m.role != Role::System)
147        .filter_map(|m| {
148            let role = match m.role {
149                Role::User => "user",
150                Role::Assistant => "assistant",
151                Role::System => return None,
152            };
153            let is_assistant = m.role == Role::Assistant;
154            let has_structured = m.parts.iter().any(|p| {
155                matches!(
156                    p,
157                    MessagePart::ToolUse { .. }
158                        | MessagePart::ToolResult { .. }
159                        | MessagePart::Image(_)
160                        | MessagePart::ThinkingBlock { .. }
161                        | MessagePart::RedactedThinkingBlock { .. }
162                )
163            });
164            let content: serde_json::Value = if !has_structured || m.parts.is_empty() {
165                let text = m.to_llm_content();
166                if text.trim().is_empty() {
167                    return None;
168                }
169                serde_json::json!(text)
170            } else {
171                let blocks: Vec<serde_json::Value> = m
172                    .parts
173                    .iter()
174                    .filter_map(|p| part_to_block(p, is_assistant))
175                    .collect();
176                if blocks.is_empty() {
177                    return None;
178                }
179                serde_json::Value::Array(blocks)
180            };
181            Some(serde_json::json!({ "role": role, "content": content }))
182        })
183        .collect();
184
185    let payload = serde_json::json!({ "system": system, "messages": chat });
186    serde_json::to_string_pretty(&payload).unwrap_or_else(|e| format!("serialization error: {e}"))
187}
188
189fn part_to_block(part: &MessagePart, is_assistant: bool) -> Option<serde_json::Value> {
190    match part {
191        MessagePart::Text { text }
192        | MessagePart::Recall { text }
193        | MessagePart::CodeContext { text }
194        | MessagePart::Summary { text }
195        | MessagePart::CrossSession { text } => {
196            if text.trim().is_empty() {
197                None
198            } else {
199                Some(serde_json::json!({ "type": "text", "text": text }))
200            }
201        }
202        MessagePart::ToolOutput {
203            tool_name,
204            body,
205            compacted_at,
206        } => {
207            let text = if compacted_at.is_some() {
208                format!("[tool output: {tool_name}] (pruned)")
209            } else {
210                format!("[tool output: {tool_name}]\n{body}")
211            };
212            Some(serde_json::json!({ "type": "text", "text": text }))
213        }
214        MessagePart::ToolUse { id, name, input } if is_assistant => {
215            Some(serde_json::json!({ "type": "tool_use", "id": id, "name": name, "input": input }))
216        }
217        MessagePart::ToolUse { name, input, .. } => Some(
218            serde_json::json!({ "type": "text", "text": format!("[tool_use: {name}] {input}") }),
219        ),
220        MessagePart::ToolResult {
221            tool_use_id,
222            content,
223            is_error,
224        } if !is_assistant => Some(
225            serde_json::json!({ "type": "tool_result", "tool_use_id": tool_use_id, "content": content, "is_error": is_error }),
226        ),
227        MessagePart::ToolResult { content, .. } => {
228            if content.trim().is_empty() {
229                None
230            } else {
231                Some(serde_json::json!({ "type": "text", "text": content }))
232            }
233        }
234        MessagePart::ThinkingBlock {
235            thinking,
236            signature,
237        } if is_assistant => Some(
238            serde_json::json!({ "type": "thinking", "thinking": thinking, "signature": signature }),
239        ),
240        MessagePart::RedactedThinkingBlock { data } if is_assistant => {
241            Some(serde_json::json!({ "type": "redacted_thinking", "data": data }))
242        }
243        MessagePart::ThinkingBlock { .. } | MessagePart::RedactedThinkingBlock { .. } => None,
244        MessagePart::Image(img) => Some(serde_json::json!({
245            "type": "image",
246            "source": {
247                "type": "base64",
248                "media_type": img.mime_type,
249                "data": base64::engine::general_purpose::STANDARD.encode(&img.data),
250            },
251        })),
252    }
253}