git_iris/agents/
debug.rs

1//! Debug observability module for Iris agent operations
2//!
3//! All debug output goes through tracing. Use `-l <file>` to log to file,
4//! `--debug` to enable debug-level output.
5
6use std::fs::{self, OpenOptions};
7use std::io::{self, Write};
8use std::path::PathBuf;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::time::{Duration, Instant};
11
12#[cfg(unix)]
13use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
14
15const DEBUG_DIR_ENV: &str = "GIT_IRIS_DEBUG_DIR";
16
17/// Global debug mode flag
18static DEBUG_MODE: AtomicBool = AtomicBool::new(false);
19
20/// Enable debug mode
21pub fn enable_debug_mode() {
22    DEBUG_MODE.store(true, Ordering::SeqCst);
23}
24
25/// Disable debug mode
26pub fn disable_debug_mode() {
27    DEBUG_MODE.store(false, Ordering::SeqCst);
28}
29
30/// Check if debug mode is enabled
31pub fn is_debug_enabled() -> bool {
32    DEBUG_MODE.load(Ordering::SeqCst)
33}
34
35/// Resolve the directory used for storing debug artifacts (LLM dumps, extracted JSON)
36fn debug_artifacts_dir() -> io::Result<PathBuf> {
37    let base = std::env::var_os(DEBUG_DIR_ENV)
38        .map(PathBuf::from)
39        .or_else(|| {
40            dirs::cache_dir().map(|mut dir| {
41                dir.push("git-iris");
42                dir.push("debug-artifacts");
43                dir
44            })
45        })
46        .unwrap_or_else(|| {
47            std::env::temp_dir()
48                .join("git-iris")
49                .join("debug-artifacts")
50        });
51
52    if !base.exists() {
53        fs::create_dir_all(&base)?;
54    }
55
56    #[cfg(unix)]
57    {
58        let _ = fs::set_permissions(&base, fs::Permissions::from_mode(0o700));
59    }
60
61    Ok(base)
62}
63
64/// Write debug artifact with restrictive permissions and return the file path.
65pub fn write_debug_artifact(filename: &str, contents: &str) -> io::Result<PathBuf> {
66    let mut path = debug_artifacts_dir()?;
67    path.push(filename);
68
69    write_secure_file(&path, contents)?;
70    Ok(path)
71}
72
73fn write_secure_file(path: &PathBuf, contents: &str) -> io::Result<()> {
74    #[cfg(unix)]
75    {
76        let mut options = OpenOptions::new();
77        options.write(true).create(true).truncate(true).mode(0o600);
78        let mut file = options.open(path)?;
79        file.write_all(contents.as_bytes())?;
80        Ok(())
81    }
82
83    #[cfg(not(unix))]
84    {
85        let mut file = OpenOptions::new()
86            .write(true)
87            .create(true)
88            .truncate(true)
89            .open(path)?;
90        file.write_all(contents.as_bytes())
91    }
92}
93
94/// Format duration in a human-readable way
95fn format_duration(duration: Duration) -> String {
96    if duration.as_secs() > 0 {
97        format!("{:.2}s", duration.as_secs_f64())
98    } else if duration.as_millis() > 0 {
99        format!("{}ms", duration.as_millis())
100    } else {
101        format!("{}μs", duration.as_micros())
102    }
103}
104
105/// Safely truncate a string at a character boundary
106fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str {
107    if s.len() <= max_bytes {
108        return s;
109    }
110    let mut end = max_bytes;
111    while end > 0 && !s.is_char_boundary(end) {
112        end -= 1;
113    }
114    &s[..end]
115}
116
117/// Print a debug header
118pub fn debug_header(title: &str) {
119    if !is_debug_enabled() {
120        return;
121    }
122    tracing::debug!(target: "iris", "══════════════════════════════════════════════════════════════════════════════");
123    tracing::debug!(target: "iris", "◆ {} ◆", title);
124    tracing::debug!(target: "iris", "══════════════════════════════════════════════════════════════════════════════");
125}
126
127/// Print a debug section
128pub fn debug_section(title: &str) {
129    if !is_debug_enabled() {
130        return;
131    }
132    tracing::debug!(target: "iris", "▸ {}", title);
133    tracing::debug!(target: "iris", "──────────────────────────────────────────────────────────────────────────────");
134}
135
136/// Print tool call information
137pub fn debug_tool_call(tool_name: &str, args: &str) {
138    if !is_debug_enabled() {
139        return;
140    }
141
142    tracing::debug!(target: "iris", "🔧 Tool Call: {}", tool_name);
143
144    if !args.is_empty() {
145        let truncated = if args.len() > 200 {
146            format!("{}...", truncate_at_char_boundary(args, 200))
147        } else {
148            args.to_string()
149        };
150        tracing::debug!(target: "iris", "   Args: {}", truncated);
151    }
152}
153
154/// Print tool response information
155pub fn debug_tool_response(tool_name: &str, response: &str, duration: Duration) {
156    if !is_debug_enabled() {
157        return;
158    }
159
160    let truncated = if response.len() > 500 {
161        format!("{}...", truncate_at_char_boundary(response, 500))
162    } else {
163        response.to_string()
164    };
165
166    tracing::debug!(target: "iris", "✓ Tool Response: {} ({})", tool_name, format_duration(duration));
167    tracing::debug!(target: "iris", "   {}", truncated);
168}
169
170/// Print LLM request information
171pub fn debug_llm_request(prompt: &str, max_tokens: Option<usize>) {
172    if !is_debug_enabled() {
173        return;
174    }
175
176    let char_count = prompt.chars().count();
177    let word_count = prompt.split_whitespace().count();
178
179    tracing::debug!(target: "iris", "🧠 LLM Request: {} chars, {} words {}",
180        char_count,
181        word_count,
182        max_tokens.map(|t| format!("(max {} tokens)", t)).unwrap_or_default()
183    );
184
185    // Show first few lines of prompt
186    for line in prompt.lines().take(5) {
187        let truncated = if line.len() > 120 {
188            format!("{}...", truncate_at_char_boundary(line, 120))
189        } else {
190            line.to_string()
191        };
192        tracing::debug!(target: "iris", "   {}", truncated);
193    }
194    if prompt.lines().count() > 5 {
195        tracing::debug!(target: "iris", "   ... ({} more lines)", prompt.lines().count() - 5);
196    }
197
198    // Save full prompt to debug artifact
199    if let Ok(path) = write_debug_artifact("iris_last_prompt.txt", prompt) {
200        tracing::debug!(target: "iris", "   Full prompt saved to: {}", path.display());
201    }
202}
203
204/// Print streaming chunk
205pub fn debug_stream_chunk(_chunk: &str, chunk_number: usize) {
206    if !is_debug_enabled() {
207        return;
208    }
209
210    // Only print every 10th chunk to avoid overwhelming output
211    if chunk_number.is_multiple_of(10) {
212        tracing::debug!(target: "iris", "▹ chunk #{}", chunk_number);
213    }
214}
215
216/// Print complete LLM response
217pub fn debug_llm_response(response: &str, duration: Duration, tokens_used: Option<usize>) {
218    if !is_debug_enabled() {
219        return;
220    }
221
222    let char_count = response.chars().count();
223    let word_count = response.split_whitespace().count();
224
225    tracing::debug!(target: "iris", "✨ LLM Response: {} chars, {} words ({})",
226        char_count,
227        word_count,
228        format_duration(duration)
229    );
230
231    if let Some(tokens) = tokens_used {
232        tracing::debug!(target: "iris", "   Tokens: {}", tokens);
233    }
234
235    // Save full response to file for deep debugging
236    if let Ok(path) = write_debug_artifact("iris_last_response.txt", response) {
237        tracing::debug!(target: "iris", "   Full response saved to: {}", path.display());
238    }
239
240    // Show response (truncated if too long)
241    let truncated = if response.len() > 1000 {
242        format!(
243            "{}...\n\n... ({} more characters)",
244            truncate_at_char_boundary(response, 1000),
245            response.len() - 1000
246        )
247    } else {
248        response.to_string()
249    };
250    for line in truncated.lines() {
251        tracing::debug!(target: "iris", "{}", line);
252    }
253}
254
255/// Print JSON parsing attempt
256pub fn debug_json_parse_attempt(json_str: &str) {
257    if !is_debug_enabled() {
258        return;
259    }
260
261    tracing::debug!(target: "iris", "📝 JSON Parse Attempt: {} chars", json_str.len());
262
263    // Show first 500 chars
264    let head = if json_str.len() > 500 {
265        format!("{}...", truncate_at_char_boundary(json_str, 500))
266    } else {
267        json_str.to_string()
268    };
269    tracing::debug!(target: "iris", "{}", head);
270
271    // Show last 200 chars to see where it got cut off
272    if json_str.len() > 700 {
273        tracing::debug!(target: "iris", "... truncated ...");
274        let mut tail_start = json_str.len().saturating_sub(200);
275        while tail_start < json_str.len() && !json_str.is_char_boundary(tail_start) {
276            tail_start += 1;
277        }
278        tracing::debug!(target: "iris", "{}", &json_str[tail_start..]);
279    }
280}
281
282/// Print JSON parse success
283pub fn debug_json_parse_success(type_name: &str) {
284    if !is_debug_enabled() {
285        return;
286    }
287    tracing::debug!(target: "iris", "✓ JSON Parsed: {}", type_name);
288}
289
290/// Print JSON parse error
291pub fn debug_json_parse_error(error: &str) {
292    if !is_debug_enabled() {
293        return;
294    }
295    tracing::warn!(target: "iris", "✗ JSON Parse Error: {}", error);
296}
297
298/// Print context management decision
299pub fn debug_context_management(action: &str, details: &str) {
300    if !is_debug_enabled() {
301        return;
302    }
303    tracing::debug!(target: "iris", "🔍 {} {}", action, details);
304}
305
306/// Print an error
307pub fn debug_error(error: &str) {
308    if !is_debug_enabled() {
309        return;
310    }
311    tracing::error!(target: "iris", "✗ Error: {}", error);
312}
313
314/// Print a warning
315pub fn debug_warning(warning: &str) {
316    if !is_debug_enabled() {
317        return;
318    }
319    tracing::warn!(target: "iris", "⚠ {}", warning);
320}
321
322/// Print agent phase change
323pub fn debug_phase_change(phase: &str) {
324    if !is_debug_enabled() {
325        return;
326    }
327    tracing::debug!(target: "iris", "◆ {}", phase);
328    tracing::debug!(target: "iris", "──────────────────────────────────────────────────────────────────────────────");
329}
330
331/// Timer for measuring operation duration
332pub struct DebugTimer {
333    start: Instant,
334    operation: String,
335}
336
337impl DebugTimer {
338    pub fn start(operation: &str) -> Self {
339        if is_debug_enabled() {
340            tracing::debug!(target: "iris", "⏱ Started: {}", operation);
341        }
342
343        Self {
344            start: Instant::now(),
345            operation: operation.to_string(),
346        }
347    }
348
349    pub fn finish(self) {
350        if is_debug_enabled() {
351            let duration = self.start.elapsed();
352            tracing::debug!(target: "iris", "✓ Completed: {} ({})", self.operation, format_duration(duration));
353        }
354    }
355}