1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum DumpFormat {
21 #[default]
23 Json,
24 Raw,
27}
28
29pub struct DebugDumper {
30 dir: PathBuf,
31 counter: AtomicU32,
32 format: DumpFormat,
33}
34
35impl DebugDumper {
36 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 #[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 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 pub fn dump_response(&self, id: u32, response: &str) {
88 self.write(&format!("{id:04}-response.txt"), response.as_bytes());
89 }
90
91 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 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
133fn 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}