Skip to main content

zeph_core/debug_dump/
mod.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
10pub mod trace;
11
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicU32, Ordering};
14
15use base64::Engine as _;
16use zeph_llm::provider::{Message, MessagePart, Role, ToolDefinition};
17
18use crate::redact::scrub_content;
19
20pub use zeph_config::DumpFormat;
21
22pub struct DebugDumper {
23    dir: PathBuf,
24    counter: AtomicU32,
25    format: DumpFormat,
26}
27
28pub struct RequestDebugDump<'a> {
29    pub model_name: &'a str,
30    pub messages: &'a [Message],
31    pub tools: &'a [ToolDefinition],
32    pub provider_request: serde_json::Value,
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    /// Returns `true` when the dump format is [`DumpFormat::Trace`].
62    ///
63    /// In Trace mode `dump_request` returns early without using `provider_request`, so callers
64    /// can skip the expensive `debug_request_json` serialization.
65    #[must_use]
66    pub fn is_trace_format(&self) -> bool {
67        self.format == DumpFormat::Trace
68    }
69
70    fn next_id(&self) -> u32 {
71        self.counter.fetch_add(1, Ordering::Relaxed)
72    }
73
74    fn write(&self, filename: &str, content: &[u8]) {
75        let path = self.dir.join(filename);
76        if let Err(e) = std::fs::write(&path, content) {
77            tracing::warn!(path = %path.display(), error = %e, "debug dump write failed");
78        }
79    }
80
81    /// Dump the messages about to be sent to the LLM.
82    ///
83    /// Returns an ID that must be passed to [`dump_response`] to correlate request and response.
84    /// When `format = Trace`, no file is written (spans are collected by [`trace::TracingCollector`]).
85    pub fn dump_request(&self, request: &RequestDebugDump<'_>) -> u32 {
86        let id = self.next_id();
87        // In Trace format, skip legacy numbered files — span data lives in TracingCollector.
88        if self.format == DumpFormat::Trace {
89            return id;
90        }
91        let json = match self.format {
92            DumpFormat::Json => json_dump(request),
93            DumpFormat::Raw => raw_dump(request),
94            DumpFormat::Trace => unreachable!("handled above"),
95        };
96        self.write(&format!("{id:04}-request.json"), json.as_bytes());
97        id
98    }
99
100    /// Dump the LLM response corresponding to a prior [`dump_request`] call.
101    /// When `format = Trace`, this is a no-op.
102    pub fn dump_response(&self, id: u32, response: &str) {
103        if self.format == DumpFormat::Trace {
104            return;
105        }
106        self.write(&format!("{id:04}-response.txt"), response.as_bytes());
107    }
108
109    /// Dump raw tool output before any truncation or summarization.
110    /// When `format = Trace`, this is a no-op (tool output is recorded via `TracingCollector`).
111    pub fn dump_tool_output(&self, tool_name: &str, output: &str) {
112        if self.format == DumpFormat::Trace {
113            return;
114        }
115        let id = self.next_id();
116        let safe_name = sanitize_dump_name(tool_name);
117        self.write(&format!("{id:04}-tool-{safe_name}.txt"), output.as_bytes());
118    }
119
120    /// Dump pruning scores computed by task-aware or MIG scoring.
121    /// When `format = Trace`, this is a no-op.
122    pub(crate) fn dump_pruning_scores(
123        &self,
124        scores: &[crate::agent::compaction_strategy::BlockScore],
125    ) {
126        if self.format == DumpFormat::Trace {
127            return;
128        }
129        let id = self.next_id();
130        let payload: Vec<serde_json::Value> = scores
131            .iter()
132            .map(|s| {
133                serde_json::json!({
134                    "msg_index": s.msg_index,
135                    "relevance": s.relevance,
136                    "redundancy": s.redundancy,
137                    "mig": s.mig,
138                })
139            })
140            .collect();
141        match serde_json::to_string_pretty(&serde_json::json!({ "scores": payload })) {
142            Ok(json) => self.write(&format!("{id:04}-pruning-scores.json"), json.as_bytes()),
143            Err(e) => tracing::warn!("dump_pruning_scores: serialize failed: {e}"),
144        }
145    }
146
147    /// Dump an `AnchoredSummary` produced during structured compaction.
148    ///
149    /// Includes completeness metrics and a fallback flag.
150    /// When `format = Trace`, this is a no-op.
151    pub(crate) fn dump_anchored_summary(
152        &self,
153        summary: &zeph_memory::AnchoredSummary,
154        fallback: bool,
155        token_counter: &zeph_memory::TokenCounter,
156    ) {
157        if self.format == DumpFormat::Trace {
158            return;
159        }
160        let id = self.next_id();
161        let section_completeness = serde_json::json!({
162            "session_intent": !summary.session_intent.trim().is_empty(),
163            "files_modified": !summary.files_modified.is_empty(),
164            "decisions_made": !summary.decisions_made.is_empty(),
165            "open_questions": !summary.open_questions.is_empty(),
166            "next_steps": !summary.next_steps.is_empty(),
167        });
168        let total_items = summary.files_modified.len()
169            + summary.decisions_made.len()
170            + summary.open_questions.len()
171            + summary.next_steps.len();
172        let markdown = summary.to_markdown();
173        let token_estimate = token_counter.count_tokens(&markdown);
174        let payload = serde_json::json!({
175            "summary": summary,
176            "section_completeness": section_completeness,
177            "total_items": total_items,
178            "token_estimate": token_estimate,
179            "fallback": fallback,
180        });
181        match serde_json::to_string_pretty(&payload) {
182            Ok(json) => self.write(&format!("{id:04}-anchored-summary.json"), json.as_bytes()),
183            Err(e) => tracing::warn!("dump_anchored_summary: serialize failed: {e}"),
184        }
185    }
186
187    /// Dump the compaction probe result for a hard compaction event (#1609).
188    /// When `format = Trace`, this is a no-op.
189    pub(crate) fn dump_compaction_probe(&self, result: &zeph_memory::CompactionProbeResult) {
190        if self.format == DumpFormat::Trace {
191            return;
192        }
193        let id = self.next_id();
194        let questions: Vec<serde_json::Value> = result
195            .questions
196            .iter()
197            .zip(
198                result
199                    .answers
200                    .iter()
201                    .chain(std::iter::repeat(&String::new())),
202            )
203            .zip(
204                result
205                    .per_question_scores
206                    .iter()
207                    .chain(std::iter::repeat(&0.0_f32)),
208            )
209            .map(|((q, a), &s)| {
210                serde_json::json!({
211                    "question": scrub_content(&q.question),
212                    "expected": scrub_content(&q.expected_answer),
213                    "actual": scrub_content(a),
214                    "score": s,
215                    "category": format!("{:?}", q.category),
216                })
217            })
218            .collect();
219        let category_scores: Vec<serde_json::Value> = result
220            .category_scores
221            .iter()
222            .map(|cs| {
223                serde_json::json!({
224                    "category": format!("{:?}", cs.category),
225                    "score": cs.score,
226                    "probes_run": cs.probes_run,
227                })
228            })
229            .collect();
230        let payload = serde_json::json!({
231            "score": result.score,
232            "category_scores": category_scores,
233            "threshold": result.threshold,
234            "hard_fail_threshold": result.hard_fail_threshold,
235            "verdict": format!("{:?}", result.verdict),
236            "model": result.model,
237            "duration_ms": result.duration_ms,
238            "questions": questions,
239        });
240        match serde_json::to_string_pretty(&payload) {
241            Ok(json) => {
242                self.write(&format!("{id:04}-compaction-probe.json"), json.as_bytes());
243            }
244            Err(e) => tracing::warn!("dump_compaction_probe: serialize failed: {e}"),
245        }
246    }
247
248    /// Dump the accumulated Focus Agent knowledge blocks.
249    /// When `format = Trace`, this is a no-op.
250    pub fn dump_focus_knowledge(&self, knowledge: &str) {
251        if self.format == DumpFormat::Trace {
252            return;
253        }
254        let id = self.next_id();
255        self.write(
256            &format!("{id:04}-focus-knowledge.txt"),
257            knowledge.as_bytes(),
258        );
259    }
260
261    /// Dump `SideQuest` eviction state: cursor list with eviction flags and freed token count.
262    /// When `format = Trace`, this is a no-op.
263    pub(crate) fn dump_sidequest_eviction(
264        &self,
265        cursors: &[crate::agent::sidequest::ToolOutputCursor],
266        evicted_indices: &[usize],
267        freed_tokens: usize,
268    ) {
269        if self.format == DumpFormat::Trace {
270            return;
271        }
272        let id = self.next_id();
273        let cursor_info: Vec<serde_json::Value> = cursors
274            .iter()
275            .enumerate()
276            .map(|(i, c)| {
277                serde_json::json!({
278                    "cursor_id": i,
279                    "msg_index": c.msg_index,
280                    "part_index": c.part_index,
281                    "tool_name": c.tool_name,
282                    "token_count": c.token_count,
283                    "evicted": evicted_indices.contains(&i),
284                })
285            })
286            .collect();
287        let payload = serde_json::json!({
288            "cursors": cursor_info,
289            "evicted_indices": evicted_indices,
290            "freed_tokens": freed_tokens,
291        });
292        match serde_json::to_string_pretty(&payload) {
293            Ok(json) => self.write(&format!("{id:04}-sidequest-eviction.json"), json.as_bytes()),
294            Err(e) => tracing::warn!("dump_sidequest_eviction: serialize failed: {e}"),
295        }
296    }
297
298    /// Dump the subgoal registry state alongside a compaction event (#2022).
299    ///
300    /// Writes a human-readable text file listing each subgoal with its state and message span.
301    /// When `format = Trace`, this is a no-op.
302    pub(crate) fn dump_subgoal_registry(
303        &self,
304        registry: &crate::agent::compaction_strategy::SubgoalRegistry,
305    ) {
306        if self.format == DumpFormat::Trace {
307            return;
308        }
309        let id = self.next_id();
310        let mut output = String::from("=== Subgoal Registry ===\n");
311        if registry.subgoals.is_empty() {
312            output.push_str("(no subgoals tracked yet)\n");
313        } else {
314            for sg in &registry.subgoals {
315                let state_str = match sg.state {
316                    crate::agent::compaction_strategy::SubgoalState::Active => "Active   ",
317                    crate::agent::compaction_strategy::SubgoalState::Completed => "Completed",
318                };
319                let _ = std::fmt::write(
320                    &mut output,
321                    format_args!(
322                        "[{}] {state_str}: \"{}\" (msgs {}-{})\n",
323                        sg.id.0, sg.description, sg.start_msg_index, sg.end_msg_index,
324                    ),
325                );
326            }
327        }
328        self.write(&format!("{id:04}-subgoal-registry.txt"), output.as_bytes());
329    }
330
331    /// Dump a tool error with error classification for debugging transient/permanent failures.
332    /// When `format = Trace`, this is a no-op.
333    pub fn dump_tool_error(&self, tool_name: &str, error: &zeph_tools::ToolError) {
334        if self.format == DumpFormat::Trace {
335            return;
336        }
337        let id = self.next_id();
338        let safe_name = sanitize_dump_name(tool_name);
339        let payload = serde_json::json!({
340            "tool": tool_name,
341            "error": error.to_string(),
342            "kind": error.kind().to_string(),
343        });
344        match serde_json::to_string_pretty(&payload) {
345            Ok(json) => {
346                self.write(
347                    &format!("{id:04}-tool-error-{safe_name}.json"),
348                    json.as_bytes(),
349                );
350            }
351            Err(e) => {
352                tracing::warn!("dump_tool_error: failed to serialize error payload: {e}");
353            }
354        }
355    }
356}
357
358fn json_dump(request: &RequestDebugDump<'_>) -> String {
359    let payload = serde_json::json!({
360        "model": extract_model(&request.provider_request, request.model_name),
361        "max_tokens": extract_max_tokens(&request.provider_request),
362        "messages": serde_json::to_value(request.messages)
363            .unwrap_or(serde_json::Value::Array(vec![])),
364        "tools": extract_tools(&request.provider_request, request.tools),
365        "temperature": request
366            .provider_request
367            .get("temperature")
368            .cloned()
369            .unwrap_or(serde_json::Value::Null),
370        "cache_control": request
371            .provider_request
372            .get("cache_control")
373            .cloned()
374            .unwrap_or(serde_json::Value::Null),
375    });
376    serde_json::to_string_pretty(&payload).unwrap_or_else(|e| format!("serialization error: {e}"))
377}
378
379fn raw_dump(request: &RequestDebugDump<'_>) -> String {
380    let mut payload = if request.provider_request.is_object() {
381        request.provider_request.clone()
382    } else {
383        serde_json::json!({})
384    };
385    if let Some(obj) = payload.as_object_mut() {
386        obj.entry("model")
387            .or_insert_with(|| extract_model(&request.provider_request, request.model_name));
388        obj.entry("max_tokens")
389            .or_insert_with(|| extract_max_tokens(&request.provider_request));
390        obj.entry("tools")
391            .or_insert_with(|| extract_tools(&request.provider_request, request.tools));
392        obj.entry("temperature").or_insert_with(|| {
393            request
394                .provider_request
395                .get("temperature")
396                .cloned()
397                .unwrap_or(serde_json::Value::Null)
398        });
399        obj.entry("cache_control").or_insert_with(|| {
400            request
401                .provider_request
402                .get("cache_control")
403                .cloned()
404                .unwrap_or(serde_json::Value::Null)
405        });
406        if !obj.contains_key("messages") && !obj.contains_key("system") {
407            let generic = messages_to_api_value(request.messages);
408            if let Some(generic_obj) = generic.as_object() {
409                for (key, value) in generic_obj {
410                    obj.insert(key.clone(), value.clone());
411                }
412            }
413        }
414    }
415    serde_json::to_string_pretty(&payload).unwrap_or_else(|e| format!("serialization error: {e}"))
416}
417
418fn extract_model(payload: &serde_json::Value, fallback: &str) -> serde_json::Value {
419    payload
420        .get("model")
421        .cloned()
422        .unwrap_or_else(|| serde_json::json!(fallback))
423}
424
425fn extract_max_tokens(payload: &serde_json::Value) -> serde_json::Value {
426    payload
427        .get("max_tokens")
428        .cloned()
429        .or_else(|| payload.get("max_completion_tokens").cloned())
430        .unwrap_or(serde_json::Value::Null)
431}
432
433fn extract_tools(payload: &serde_json::Value, fallback: &[ToolDefinition]) -> serde_json::Value {
434    payload.get("tools").cloned().unwrap_or_else(|| {
435        serde_json::to_value(fallback).unwrap_or(serde_json::Value::Array(vec![]))
436    })
437}
438
439fn sanitize_dump_name(name: &str) -> String {
440    name.chars()
441        .map(|c| {
442            if c.is_alphanumeric() || c == '-' {
443                c
444            } else {
445                '_'
446            }
447        })
448        .collect()
449}
450
451/// Render messages as the API payload format (mirrors `split_messages_structured` in the
452/// Claude provider): system extracted, `agent_visible = false` messages filtered out,
453/// parts converted to typed content blocks (`text`, `tool_use`, `tool_result`, etc.).
454fn messages_to_api_value(messages: &[Message]) -> serde_json::Value {
455    let system: String = messages
456        .iter()
457        .filter(|m| m.metadata.agent_visible && m.role == Role::System)
458        .map(zeph_llm::provider::Message::to_llm_content)
459        .collect::<Vec<_>>()
460        .join("\n\n");
461
462    let chat: Vec<serde_json::Value> = messages
463        .iter()
464        .filter(|m| m.metadata.agent_visible && m.role != Role::System)
465        .filter_map(|m| {
466            let role = match m.role {
467                Role::User => "user",
468                Role::Assistant => "assistant",
469                Role::System => return None,
470            };
471            let is_assistant = m.role == Role::Assistant;
472            let has_structured = m.parts.iter().any(|p| {
473                matches!(
474                    p,
475                    MessagePart::ToolUse { .. }
476                        | MessagePart::ToolResult { .. }
477                        | MessagePart::Image(_)
478                        | MessagePart::ThinkingBlock { .. }
479                        | MessagePart::RedactedThinkingBlock { .. }
480                )
481            });
482            let content: serde_json::Value = if !has_structured || m.parts.is_empty() {
483                let text = m.to_llm_content();
484                if text.trim().is_empty() {
485                    return None;
486                }
487                serde_json::json!(text)
488            } else {
489                let blocks: Vec<serde_json::Value> = m
490                    .parts
491                    .iter()
492                    .filter_map(|p| part_to_block(p, is_assistant))
493                    .collect();
494                if blocks.is_empty() {
495                    return None;
496                }
497                serde_json::Value::Array(blocks)
498            };
499            Some(serde_json::json!({ "role": role, "content": content }))
500        })
501        .collect();
502
503    serde_json::json!({ "system": system, "messages": chat })
504}
505
506fn part_to_block(part: &MessagePart, is_assistant: bool) -> Option<serde_json::Value> {
507    match part {
508        MessagePart::Text { text }
509        | MessagePart::Recall { text }
510        | MessagePart::CodeContext { text }
511        | MessagePart::Summary { text }
512        | MessagePart::CrossSession { text } => {
513            if text.trim().is_empty() {
514                None
515            } else {
516                Some(serde_json::json!({ "type": "text", "text": text }))
517            }
518        }
519        MessagePart::ToolOutput {
520            tool_name,
521            body,
522            compacted_at,
523        } => {
524            let text = if compacted_at.is_some() {
525                if body.is_empty() {
526                    format!("[tool output: {tool_name}] (pruned)")
527                } else {
528                    format!("[tool output: {tool_name}] {body}")
529                }
530            } else {
531                format!("[tool output: {tool_name}]\n{body}")
532            };
533            Some(serde_json::json!({ "type": "text", "text": text }))
534        }
535        MessagePart::ToolUse { id, name, input } if is_assistant => {
536            Some(serde_json::json!({ "type": "tool_use", "id": id, "name": name, "input": input }))
537        }
538        MessagePart::ToolUse { name, input, .. } => Some(
539            serde_json::json!({ "type": "text", "text": format!("[tool_use: {name}] {input}") }),
540        ),
541        MessagePart::ToolResult {
542            tool_use_id,
543            content,
544            is_error,
545        } if !is_assistant => Some(
546            serde_json::json!({ "type": "tool_result", "tool_use_id": tool_use_id, "content": content, "is_error": is_error }),
547        ),
548        MessagePart::ToolResult { content, .. } => {
549            if content.trim().is_empty() {
550                None
551            } else {
552                Some(serde_json::json!({ "type": "text", "text": content }))
553            }
554        }
555        MessagePart::ThinkingBlock {
556            thinking,
557            signature,
558        } if is_assistant => Some(
559            serde_json::json!({ "type": "thinking", "thinking": thinking, "signature": signature }),
560        ),
561        MessagePart::RedactedThinkingBlock { data } if is_assistant => {
562            Some(serde_json::json!({ "type": "redacted_thinking", "data": data }))
563        }
564        MessagePart::ThinkingBlock { .. }
565        | MessagePart::RedactedThinkingBlock { .. }
566        | MessagePart::Compaction { .. }
567            if !is_assistant =>
568        {
569            None
570        }
571        MessagePart::ThinkingBlock { .. } | MessagePart::RedactedThinkingBlock { .. } => None,
572        MessagePart::Compaction { summary } => {
573            Some(serde_json::json!({ "type": "compaction", "summary": summary }))
574        }
575        MessagePart::Image(img) => Some(serde_json::json!({
576            "type": "image",
577            "source": {
578                "type": "base64",
579                "media_type": img.mime_type,
580                "data": base64::engine::general_purpose::STANDARD.encode(&img.data),
581            },
582        })),
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use tempfile::tempdir;
590
591    #[test]
592    fn dump_format_from_str_valid() {
593        assert_eq!("json".parse::<DumpFormat>().unwrap(), DumpFormat::Json);
594        assert_eq!("raw".parse::<DumpFormat>().unwrap(), DumpFormat::Raw);
595        assert_eq!("trace".parse::<DumpFormat>().unwrap(), DumpFormat::Trace);
596    }
597
598    #[test]
599    fn dump_format_from_str_invalid_returns_error() {
600        let err = "binary".parse::<DumpFormat>().unwrap_err();
601        assert!(
602            err.contains("unknown dump format"),
603            "error must mention unknown dump format: {err}"
604        );
605    }
606
607    fn sample_messages() -> Vec<Message> {
608        vec![
609            Message::from_legacy(Role::System, "system prompt"),
610            Message::from_legacy(Role::User, "hello"),
611        ]
612    }
613
614    fn sample_tools() -> Vec<ToolDefinition> {
615        vec![ToolDefinition {
616            name: "read_file".into(),
617            description: "Read a file".into(),
618            parameters: serde_json::json!({
619                "type": "object",
620                "properties": { "path": { "type": "string" } },
621            }),
622        }]
623    }
624
625    fn read_request_dump(dir: &Path) -> serde_json::Value {
626        let session = std::fs::read_dir(dir)
627            .unwrap()
628            .next()
629            .unwrap()
630            .unwrap()
631            .path();
632        serde_json::from_str(&std::fs::read_to_string(session.join("0000-request.json")).unwrap())
633            .unwrap()
634    }
635
636    #[test]
637    fn json_dump_request_includes_request_metadata() {
638        let dir = tempdir().unwrap();
639        let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
640        let messages = sample_messages();
641        let tools = sample_tools();
642
643        dumper.dump_request(&RequestDebugDump {
644            model_name: "claude-sonnet-test",
645            messages: &messages,
646            tools: &tools,
647            provider_request: serde_json::json!({
648                "model": "claude-sonnet-test",
649                "max_tokens": 4096,
650                "tools": [{ "name": "read_file" }],
651                "temperature": 0.7,
652                "cache_control": { "type": "ephemeral" }
653            }),
654        });
655
656        let payload = read_request_dump(dir.path());
657        assert_eq!(payload["model"], "claude-sonnet-test");
658        assert_eq!(payload["max_tokens"], 4096);
659        assert_eq!(payload["tools"][0]["name"], "read_file");
660        assert_eq!(payload["temperature"], 0.7);
661        assert_eq!(payload["cache_control"]["type"], "ephemeral");
662        assert_eq!(payload["messages"][1]["content"], "hello");
663    }
664
665    #[test]
666    fn raw_dump_request_includes_request_metadata() {
667        let dir = tempdir().unwrap();
668        let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
669        let messages = sample_messages();
670        let tools = sample_tools();
671
672        dumper.dump_request(&RequestDebugDump {
673            model_name: "gpt-5-mini",
674            messages: &messages,
675            tools: &tools,
676            provider_request: serde_json::json!({
677                "model": "gpt-5-mini",
678                "max_completion_tokens": 2048,
679                "messages": [{ "role": "user", "content": "hello" }],
680                "tools": [{ "type": "function", "function": { "name": "read_file" } }],
681                "temperature": 0.3,
682                "cache_control": null
683            }),
684        });
685
686        let payload = read_request_dump(dir.path());
687        assert_eq!(payload["model"], "gpt-5-mini");
688        assert_eq!(payload["max_tokens"], 2048);
689        assert_eq!(payload["tools"][0]["function"]["name"], "read_file");
690        assert_eq!(payload["temperature"], 0.3);
691        assert_eq!(payload["messages"][0]["content"], "hello");
692    }
693}