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: String = tool_name
95 .chars()
96 .map(|c| {
97 if c.is_alphanumeric() || c == '-' {
98 c
99 } else {
100 '_'
101 }
102 })
103 .collect();
104 self.write(&format!("{id:04}-tool-{safe_name}.txt"), output.as_bytes());
105 }
106}
107
108fn messages_to_api_json(messages: &[Message]) -> String {
112 let system: String = messages
113 .iter()
114 .filter(|m| m.metadata.agent_visible && m.role == Role::System)
115 .map(zeph_llm::provider::Message::to_llm_content)
116 .collect::<Vec<_>>()
117 .join("\n\n");
118
119 let chat: Vec<serde_json::Value> = messages
120 .iter()
121 .filter(|m| m.metadata.agent_visible && m.role != Role::System)
122 .filter_map(|m| {
123 let role = match m.role {
124 Role::User => "user",
125 Role::Assistant => "assistant",
126 Role::System => return None,
127 };
128 let is_assistant = m.role == Role::Assistant;
129 let has_structured = m.parts.iter().any(|p| {
130 matches!(
131 p,
132 MessagePart::ToolUse { .. }
133 | MessagePart::ToolResult { .. }
134 | MessagePart::Image(_)
135 | MessagePart::ThinkingBlock { .. }
136 | MessagePart::RedactedThinkingBlock { .. }
137 )
138 });
139 let content: serde_json::Value = if !has_structured || m.parts.is_empty() {
140 let text = m.to_llm_content();
141 if text.trim().is_empty() {
142 return None;
143 }
144 serde_json::json!(text)
145 } else {
146 let blocks: Vec<serde_json::Value> = m
147 .parts
148 .iter()
149 .filter_map(|p| part_to_block(p, is_assistant))
150 .collect();
151 if blocks.is_empty() {
152 return None;
153 }
154 serde_json::Value::Array(blocks)
155 };
156 Some(serde_json::json!({ "role": role, "content": content }))
157 })
158 .collect();
159
160 let payload = serde_json::json!({ "system": system, "messages": chat });
161 serde_json::to_string_pretty(&payload).unwrap_or_else(|e| format!("serialization error: {e}"))
162}
163
164fn part_to_block(part: &MessagePart, is_assistant: bool) -> Option<serde_json::Value> {
165 match part {
166 MessagePart::Text { text }
167 | MessagePart::Recall { text }
168 | MessagePart::CodeContext { text }
169 | MessagePart::Summary { text }
170 | MessagePart::CrossSession { text } => {
171 if text.trim().is_empty() {
172 None
173 } else {
174 Some(serde_json::json!({ "type": "text", "text": text }))
175 }
176 }
177 MessagePart::ToolOutput {
178 tool_name,
179 body,
180 compacted_at,
181 } => {
182 let text = if compacted_at.is_some() {
183 format!("[tool output: {tool_name}] (pruned)")
184 } else {
185 format!("[tool output: {tool_name}]\n{body}")
186 };
187 Some(serde_json::json!({ "type": "text", "text": text }))
188 }
189 MessagePart::ToolUse { id, name, input } if is_assistant => {
190 Some(serde_json::json!({ "type": "tool_use", "id": id, "name": name, "input": input }))
191 }
192 MessagePart::ToolUse { name, input, .. } => Some(
193 serde_json::json!({ "type": "text", "text": format!("[tool_use: {name}] {input}") }),
194 ),
195 MessagePart::ToolResult {
196 tool_use_id,
197 content,
198 is_error,
199 } if !is_assistant => Some(
200 serde_json::json!({ "type": "tool_result", "tool_use_id": tool_use_id, "content": content, "is_error": is_error }),
201 ),
202 MessagePart::ToolResult { content, .. } => {
203 if content.trim().is_empty() {
204 None
205 } else {
206 Some(serde_json::json!({ "type": "text", "text": content }))
207 }
208 }
209 MessagePart::ThinkingBlock {
210 thinking,
211 signature,
212 } if is_assistant => Some(
213 serde_json::json!({ "type": "thinking", "thinking": thinking, "signature": signature }),
214 ),
215 MessagePart::RedactedThinkingBlock { data } if is_assistant => {
216 Some(serde_json::json!({ "type": "redacted_thinking", "data": data }))
217 }
218 MessagePart::ThinkingBlock { .. } | MessagePart::RedactedThinkingBlock { .. } => None,
219 MessagePart::Image(img) => Some(serde_json::json!({
220 "type": "image",
221 "source": {
222 "type": "base64",
223 "media_type": img.mime_type,
224 "data": base64::engine::general_purpose::STANDARD.encode(&img.data),
225 },
226 })),
227 }
228}