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