Skip to main content

zag_agent/
output.rs

1/// Unified output structures for all agents.
2///
3/// This module provides a common interface for processing output from different
4/// AI coding agents (Claude, Codex, Gemini, Copilot). By normalizing outputs into
5/// a unified format, we can provide consistent logging, debugging, and observability
6/// across all agents.
7use log::debug;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// A unified event stream output from an agent session.
12///
13/// This represents the complete output from an agent execution, containing
14/// all events that occurred during the session.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AgentOutput {
17    /// The agent that produced this output
18    pub agent: String,
19
20    /// Unique session identifier
21    pub session_id: String,
22
23    /// Events that occurred during the session
24    pub events: Vec<Event>,
25
26    /// Final result text (if any)
27    pub result: Option<String>,
28
29    /// Whether the session ended in an error
30    pub is_error: bool,
31
32    /// Process exit code from the underlying provider (if available)
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub exit_code: Option<i32>,
35
36    /// Human-readable error message from the provider (if any)
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub error_message: Option<String>,
39
40    /// Total cost in USD (if available)
41    pub total_cost_usd: Option<f64>,
42
43    /// Aggregated usage statistics
44    pub usage: Option<Usage>,
45}
46
47/// A single event in an agent session.
48///
49/// Events represent discrete steps in the conversation flow, such as
50/// initialization, messages, tool calls, and results.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(tag = "type", rename_all = "snake_case")]
53pub enum Event {
54    /// Session initialization event
55    Init {
56        model: String,
57        tools: Vec<String>,
58        working_directory: Option<String>,
59        metadata: HashMap<String, serde_json::Value>,
60    },
61
62    /// Message from the user (replayed via --replay-user-messages)
63    UserMessage { content: Vec<ContentBlock> },
64
65    /// Message from the assistant
66    AssistantMessage {
67        content: Vec<ContentBlock>,
68        usage: Option<Usage>,
69        /// If this message comes from a sub-agent, the tool_use_id of the
70        /// parent Agent tool call that spawned it.
71        #[serde(skip_serializing_if = "Option::is_none")]
72        parent_tool_use_id: Option<String>,
73    },
74
75    /// Tool execution event
76    ToolExecution {
77        tool_name: String,
78        tool_id: String,
79        input: serde_json::Value,
80        result: ToolResult,
81        /// If this execution belongs to a sub-agent, the tool_use_id of the
82        /// parent Agent tool call that spawned it.
83        #[serde(skip_serializing_if = "Option::is_none")]
84        parent_tool_use_id: Option<String>,
85    },
86
87    /// Final session result
88    Result {
89        success: bool,
90        message: Option<String>,
91        duration_ms: Option<u64>,
92        num_turns: Option<u32>,
93    },
94
95    /// An error occurred
96    Error {
97        message: String,
98        details: Option<serde_json::Value>,
99    },
100
101    /// Permission was requested
102    PermissionRequest {
103        tool_name: String,
104        description: String,
105        granted: bool,
106    },
107}
108
109/// A block of content in a message.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(tag = "type", rename_all = "snake_case")]
112pub enum ContentBlock {
113    /// Plain text content
114    Text { text: String },
115
116    /// A tool invocation
117    ToolUse {
118        id: String,
119        name: String,
120        input: serde_json::Value,
121    },
122}
123
124/// Result from a tool execution.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ToolResult {
127    /// Whether the tool execution succeeded
128    pub success: bool,
129
130    /// Text output from the tool
131    pub output: Option<String>,
132
133    /// Error message (if failed)
134    pub error: Option<String>,
135
136    /// Structured result data (tool-specific)
137    pub data: Option<serde_json::Value>,
138}
139
140/// Usage statistics for an agent session.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct Usage {
143    /// Total input tokens
144    pub input_tokens: u64,
145
146    /// Total output tokens
147    pub output_tokens: u64,
148
149    /// Tokens read from cache (if applicable)
150    pub cache_read_tokens: Option<u64>,
151
152    /// Tokens written to cache (if applicable)
153    pub cache_creation_tokens: Option<u64>,
154
155    /// Number of web search requests (if applicable)
156    pub web_search_requests: Option<u32>,
157
158    /// Number of web fetch requests (if applicable)
159    pub web_fetch_requests: Option<u32>,
160}
161
162/// Log level for agent events.
163///
164/// Used to categorize events for filtering and display.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
166#[serde(rename_all = "lowercase")]
167pub enum LogLevel {
168    Debug,
169    Info,
170    Warn,
171    Error,
172}
173
174/// A log entry extracted from agent output.
175///
176/// This is a simplified view of events suitable for logging and debugging.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct LogEntry {
179    /// Log level
180    pub level: LogLevel,
181
182    /// Log message
183    pub message: String,
184
185    /// Optional structured data
186    pub data: Option<serde_json::Value>,
187
188    /// Timestamp (if available)
189    pub timestamp: Option<String>,
190}
191
192impl AgentOutput {
193    /// Create a minimal AgentOutput from captured text.
194    ///
195    /// Used by non-Claude agents when `capture_output` is enabled (e.g., for auto-selection).
196    pub fn from_text(agent: &str, text: &str) -> Self {
197        debug!(
198            "Creating AgentOutput from text: agent={}, len={}",
199            agent,
200            text.len()
201        );
202        Self {
203            agent: agent.to_string(),
204            session_id: String::new(),
205            events: vec![Event::Result {
206                success: true,
207                message: Some(text.to_string()),
208                duration_ms: None,
209                num_turns: None,
210            }],
211            result: Some(text.to_string()),
212            is_error: false,
213            exit_code: None,
214            error_message: None,
215            total_cost_usd: None,
216            usage: None,
217        }
218    }
219
220    /// Extract log entries from the agent output.
221    ///
222    /// This converts events into a flat list of log entries suitable for
223    /// display or filtering.
224    pub fn to_log_entries(&self, min_level: LogLevel) -> Vec<LogEntry> {
225        debug!(
226            "Extracting log entries from {} events (min_level={:?})",
227            self.events.len(),
228            min_level
229        );
230        let mut entries = Vec::new();
231
232        for event in &self.events {
233            if let Some(entry) = event_to_log_entry(event)
234                && entry.level >= min_level
235            {
236                entries.push(entry);
237            }
238        }
239
240        entries
241    }
242
243    /// Get the final result text.
244    pub fn final_result(&self) -> Option<&str> {
245        self.result.as_deref()
246    }
247
248    /// Check if the session completed successfully.
249    #[allow(dead_code)]
250    pub fn is_success(&self) -> bool {
251        !self.is_error
252    }
253
254    /// Get all tool executions from the session.
255    #[allow(dead_code)]
256    pub fn tool_executions(&self) -> Vec<&Event> {
257        self.events
258            .iter()
259            .filter(|e| matches!(e, Event::ToolExecution { .. }))
260            .collect()
261    }
262
263    /// Get all errors from the session.
264    #[allow(dead_code)]
265    pub fn errors(&self) -> Vec<&Event> {
266        self.events
267            .iter()
268            .filter(|e| matches!(e, Event::Error { .. }))
269            .collect()
270    }
271}
272
273/// Convert an event to a log entry.
274fn event_to_log_entry(event: &Event) -> Option<LogEntry> {
275    match event {
276        Event::Init { model, .. } => Some(LogEntry {
277            level: LogLevel::Info,
278            message: format!("Initialized with model {}", model),
279            data: None,
280            timestamp: None,
281        }),
282
283        Event::AssistantMessage { content, .. } => {
284            // Extract text from content blocks
285            let texts: Vec<String> = content
286                .iter()
287                .filter_map(|block| match block {
288                    ContentBlock::Text { text } => Some(text.clone()),
289                    _ => None,
290                })
291                .collect();
292
293            if !texts.is_empty() {
294                Some(LogEntry {
295                    level: LogLevel::Debug,
296                    message: texts.join("\n"),
297                    data: None,
298                    timestamp: None,
299                })
300            } else {
301                None
302            }
303        }
304
305        Event::ToolExecution {
306            tool_name, result, ..
307        } => {
308            let level = if result.success {
309                LogLevel::Debug
310            } else {
311                LogLevel::Warn
312            };
313
314            let message = if result.success {
315                format!("Tool '{}' executed successfully", tool_name)
316            } else {
317                format!(
318                    "Tool '{}' failed: {}",
319                    tool_name,
320                    result.error.as_deref().unwrap_or("unknown error")
321                )
322            };
323
324            Some(LogEntry {
325                level,
326                message,
327                data: result.data.clone(),
328                timestamp: None,
329            })
330        }
331
332        Event::Result {
333            success, message, ..
334        } => {
335            let level = if *success {
336                LogLevel::Info
337            } else {
338                LogLevel::Error
339            };
340
341            Some(LogEntry {
342                level,
343                message: message.clone().unwrap_or_else(|| {
344                    if *success {
345                        "Session completed".to_string()
346                    } else {
347                        "Session failed".to_string()
348                    }
349                }),
350                data: None,
351                timestamp: None,
352            })
353        }
354
355        Event::Error { message, details } => Some(LogEntry {
356            level: LogLevel::Error,
357            message: message.clone(),
358            data: details.clone(),
359            timestamp: None,
360        }),
361
362        Event::PermissionRequest {
363            tool_name, granted, ..
364        } => {
365            let level = if *granted {
366                LogLevel::Debug
367            } else {
368                LogLevel::Warn
369            };
370
371            let message = if *granted {
372                format!("Permission granted for tool '{}'", tool_name)
373            } else {
374                format!("Permission denied for tool '{}'", tool_name)
375            };
376
377            Some(LogEntry {
378                level,
379                message,
380                data: None,
381                timestamp: None,
382            })
383        }
384
385        Event::UserMessage { content } => {
386            let texts: Vec<String> = content
387                .iter()
388                .filter_map(|b| {
389                    if let ContentBlock::Text { text } = b {
390                        Some(text.clone())
391                    } else {
392                        None
393                    }
394                })
395                .collect();
396            if texts.is_empty() {
397                None
398            } else {
399                Some(LogEntry {
400                    level: LogLevel::Info,
401                    message: texts.join("\n"),
402                    data: None,
403                    timestamp: None,
404                })
405            }
406        }
407    }
408}
409
410impl std::fmt::Display for LogEntry {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        let level_str = match self.level {
413            LogLevel::Debug => "DEBUG",
414            LogLevel::Info => "INFO",
415            LogLevel::Warn => "WARN",
416            LogLevel::Error => "ERROR",
417        };
418
419        write!(f, "[{}] {}", level_str, self.message)
420    }
421}
422
423/// Get a consistent color for a tool ID using round-robin color selection.
424fn get_tool_id_color(tool_id: &str) -> &'static str {
425    // 10 distinct colors for tool IDs
426    const TOOL_COLORS: [&str; 10] = [
427        "\x1b[38;5;33m",  // Blue
428        "\x1b[38;5;35m",  // Green
429        "\x1b[38;5;141m", // Purple
430        "\x1b[38;5;208m", // Orange
431        "\x1b[38;5;213m", // Pink
432        "\x1b[38;5;51m",  // Cyan
433        "\x1b[38;5;226m", // Yellow
434        "\x1b[38;5;205m", // Magenta
435        "\x1b[38;5;87m",  // Aqua
436        "\x1b[38;5;215m", // Peach
437    ];
438
439    // Hash the tool_id to get a consistent color
440    let hash: u32 = tool_id.bytes().map(|b| b as u32).sum();
441    let index = (hash as usize) % TOOL_COLORS.len();
442    TOOL_COLORS[index]
443}
444
445/// Format a single event as beautiful text output.
446///
447/// This can be used to stream events in real-time with nice formatting.
448pub fn format_event_as_text(event: &Event) -> Option<String> {
449    const INDENT: &str = "    ";
450    const INDENT_RESULT: &str = "      "; // 6 spaces for tool result continuation
451    const RECORD_ICON: &str = "⏺";
452    const ARROW_ICON: &str = "←";
453    const ORANGE: &str = "\x1b[38;5;208m";
454    const GREEN: &str = "\x1b[32m";
455    const RED: &str = "\x1b[31m";
456    const DIM: &str = "\x1b[38;5;240m"; // Gray color for better visibility than dim
457    const RESET: &str = "\x1b[0m";
458
459    match event {
460        Event::Init { model, .. } => {
461            Some(format!("\x1b[32m✓\x1b[0m Initialized with model {}", model))
462        }
463
464        Event::UserMessage { content } => {
465            let texts: Vec<String> = content
466                .iter()
467                .filter_map(|block| {
468                    if let ContentBlock::Text { text } = block {
469                        Some(format!("{}> {}{}", DIM, text, RESET))
470                    } else {
471                        None
472                    }
473                })
474                .collect();
475            if texts.is_empty() {
476                None
477            } else {
478                Some(texts.join("\n"))
479            }
480        }
481
482        Event::AssistantMessage { content, .. } => {
483            let formatted: Vec<String> = content
484                .iter()
485                .filter_map(|block| match block {
486                    ContentBlock::Text { text } => {
487                        // Orange text with record icon, indented
488                        // Handle multi-line text - first line with icon, rest indented 6 spaces
489                        let lines: Vec<&str> = text.lines().collect();
490                        if lines.is_empty() {
491                            None
492                        } else {
493                            let mut formatted_lines = Vec::new();
494                            for (i, line) in lines.iter().enumerate() {
495                                if i == 0 {
496                                    // First line with record icon
497                                    formatted_lines.push(format!(
498                                        "{}{}{} {}{}",
499                                        INDENT, ORANGE, RECORD_ICON, line, RESET
500                                    ));
501                                } else {
502                                    // Subsequent lines, indented 6 spaces (still orange)
503                                    formatted_lines.push(format!(
504                                        "{}{}{}{}",
505                                        INDENT_RESULT, ORANGE, line, RESET
506                                    ));
507                                }
508                            }
509                            Some(formatted_lines.join("\n"))
510                        }
511                    }
512                    ContentBlock::ToolUse { id, name, input } => {
513                        // Tool call with colored id (last 4 chars)
514                        let id_suffix = &id[id.len().saturating_sub(4)..];
515                        let id_color = get_tool_id_color(id_suffix);
516                        const BLUE: &str = "\x1b[34m";
517
518                        // Special formatting for Bash tool
519                        if name == "Bash"
520                            && let serde_json::Value::Object(obj) = input
521                        {
522                            let description = obj
523                                .get("description")
524                                .and_then(|v| v.as_str())
525                                .unwrap_or("Run command");
526                            let command = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
527
528                            return Some(format!(
529                                "{}{}{} {}{} {}[{}]{}\n{}{}└── {}{}",
530                                INDENT,
531                                BLUE,
532                                RECORD_ICON,
533                                description,
534                                RESET,
535                                id_color,
536                                id_suffix,
537                                RESET,
538                                INDENT_RESULT,
539                                DIM,
540                                command,
541                                RESET
542                            ));
543                        }
544
545                        // Format input parameters for non-Bash tools
546                        let input_str = if let serde_json::Value::Object(obj) = input {
547                            if obj.is_empty() {
548                                String::new()
549                            } else {
550                                // Format the parameters as key=value pairs
551                                let params: Vec<String> = obj
552                                    .iter()
553                                    .map(|(key, value)| {
554                                        let value_str = match value {
555                                            serde_json::Value::String(s) => {
556                                                // Truncate long strings
557                                                if s.len() > 60 {
558                                                    format!("\"{}...\"", &s[..57])
559                                                } else {
560                                                    format!("\"{}\"", s)
561                                                }
562                                            }
563                                            serde_json::Value::Number(n) => n.to_string(),
564                                            serde_json::Value::Bool(b) => b.to_string(),
565                                            serde_json::Value::Null => "null".to_string(),
566                                            _ => "...".to_string(),
567                                        };
568                                        format!("{}={}", key, value_str)
569                                    })
570                                    .collect();
571                                params.join(", ")
572                            }
573                        } else {
574                            "...".to_string()
575                        };
576
577                        Some(format!(
578                            "{}{}{} {}({}) {}[{}]{}",
579                            INDENT, BLUE, RECORD_ICON, name, input_str, id_color, id_suffix, RESET
580                        ))
581                    }
582                })
583                .collect();
584
585            if !formatted.is_empty() {
586                // Add blank line after
587                Some(format!("{}\n", formatted.join("\n")))
588            } else {
589                None
590            }
591        }
592
593        Event::ToolExecution {
594            tool_id, result, ..
595        } => {
596            let id_suffix = &tool_id[tool_id.len().saturating_sub(4)..];
597            let id_color = get_tool_id_color(id_suffix);
598            let (icon_color, status_text) = if result.success {
599                (GREEN, "success")
600            } else {
601                (RED, "failed")
602            };
603
604            // Get full result text (all lines)
605            let result_text = if result.success {
606                result.output.as_deref().unwrap_or(status_text)
607            } else {
608                result.error.as_deref().unwrap_or(status_text)
609            };
610
611            // Split into lines and format each one
612            let mut lines: Vec<&str> = result_text.lines().collect();
613            if lines.is_empty() {
614                lines.push(status_text);
615            }
616
617            let mut formatted_lines = Vec::new();
618
619            // First line: arrow icon with tool ID
620            formatted_lines.push(format!(
621                "{}{}{}{} {}[{}]{}",
622                INDENT, icon_color, ARROW_ICON, RESET, id_color, id_suffix, RESET
623            ));
624
625            // All result lines indented at 6 spaces
626            for line in lines.iter() {
627                formatted_lines.push(format!("{}{}{}{}", INDENT_RESULT, DIM, line, RESET));
628            }
629
630            // Add blank line after
631            Some(format!("{}\n", formatted_lines.join("\n")))
632        }
633
634        Event::Result { .. } => {
635            // Don't output the final result since it's already been streamed
636            None
637        }
638
639        Event::Error { message, .. } => Some(format!("\x1b[31mError:\x1b[0m {}", message)),
640
641        Event::PermissionRequest {
642            tool_name, granted, ..
643        } => {
644            if *granted {
645                Some(format!(
646                    "\x1b[32m✓\x1b[0m Permission granted for tool '{}'",
647                    tool_name
648                ))
649            } else {
650                Some(format!(
651                    "\x1b[33m!\x1b[0m Permission denied for tool '{}'",
652                    tool_name
653                ))
654            }
655        }
656    }
657}
658
659#[cfg(test)]
660#[path = "output_tests.rs"]
661mod tests;