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