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 '{}' executed successfully", tool_name)
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                                        "{}{}{} {}{}",
551                                        INDENT, ORANGE, RECORD_ICON, line, RESET
552                                    ));
553                                } else {
554                                    // Subsequent lines, indented 6 spaces (still orange)
555                                    formatted_lines.push(format!(
556                                        "{}{}{}{}",
557                                        INDENT_RESULT, ORANGE, line, RESET
558                                    ));
559                                }
560                            }
561                            Some(formatted_lines.join("\n"))
562                        }
563                    }
564                    ContentBlock::ToolUse { id, name, input } => {
565                        // Tool call with colored id (last 4 chars)
566                        let id_suffix = &id[id.len().saturating_sub(4)..];
567                        let id_color = get_tool_id_color(id_suffix);
568                        const BLUE: &str = "\x1b[34m";
569
570                        // Special formatting for Bash tool
571                        if name == "Bash"
572                            && let serde_json::Value::Object(obj) = input
573                        {
574                            let description = obj
575                                .get("description")
576                                .and_then(|v| v.as_str())
577                                .unwrap_or("Run command");
578                            let command = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
579
580                            return Some(format!(
581                                "{}{}{} {}{} {}[{}]{}\n{}{}└── {}{}",
582                                INDENT,
583                                BLUE,
584                                RECORD_ICON,
585                                description,
586                                RESET,
587                                id_color,
588                                id_suffix,
589                                RESET,
590                                INDENT_RESULT,
591                                DIM,
592                                command,
593                                RESET
594                            ));
595                        }
596
597                        // Format input parameters for non-Bash tools
598                        let input_str = if let serde_json::Value::Object(obj) = input {
599                            if obj.is_empty() {
600                                String::new()
601                            } else {
602                                // Format the parameters as key=value pairs
603                                let params: Vec<String> = obj
604                                    .iter()
605                                    .map(|(key, value)| {
606                                        let value_str = match value {
607                                            serde_json::Value::String(s) => {
608                                                // Truncate long strings
609                                                if s.len() > 60 {
610                                                    format!("\"{}...\"", &s[..57])
611                                                } else {
612                                                    format!("\"{}\"", s)
613                                                }
614                                            }
615                                            serde_json::Value::Number(n) => n.to_string(),
616                                            serde_json::Value::Bool(b) => b.to_string(),
617                                            serde_json::Value::Null => "null".to_string(),
618                                            _ => "...".to_string(),
619                                        };
620                                        format!("{}={}", key, value_str)
621                                    })
622                                    .collect();
623                                params.join(", ")
624                            }
625                        } else {
626                            "...".to_string()
627                        };
628
629                        Some(format!(
630                            "{}{}{} {}({}) {}[{}]{}",
631                            INDENT, BLUE, RECORD_ICON, name, input_str, id_color, id_suffix, RESET
632                        ))
633                    }
634                })
635                .collect();
636
637            if !formatted.is_empty() {
638                // Add blank line after
639                Some(format!("{}\n", formatted.join("\n")))
640            } else {
641                None
642            }
643        }
644
645        Event::ToolExecution {
646            tool_id, result, ..
647        } => {
648            let id_suffix = &tool_id[tool_id.len().saturating_sub(4)..];
649            let id_color = get_tool_id_color(id_suffix);
650            let (icon_color, status_text) = if result.success {
651                (GREEN, "success")
652            } else {
653                (RED, "failed")
654            };
655
656            // Get full result text (all lines)
657            let result_text = if result.success {
658                result.output.as_deref().unwrap_or(status_text)
659            } else {
660                result.error.as_deref().unwrap_or(status_text)
661            };
662
663            // Split into lines and format each one
664            let mut lines: Vec<&str> = result_text.lines().collect();
665            if lines.is_empty() {
666                lines.push(status_text);
667            }
668
669            let mut formatted_lines = Vec::new();
670
671            // First line: arrow icon with tool ID
672            formatted_lines.push(format!(
673                "{}{}{}{} {}[{}]{}",
674                INDENT, icon_color, ARROW_ICON, RESET, id_color, id_suffix, RESET
675            ));
676
677            // All result lines indented at 6 spaces
678            for line in lines.iter() {
679                formatted_lines.push(format!("{}{}{}{}", INDENT_RESULT, DIM, line, RESET));
680            }
681
682            // Add blank line after
683            Some(format!("{}\n", formatted_lines.join("\n")))
684        }
685
686        Event::TurnComplete { .. } => {
687            // Turn boundary marker — not surfaced in terminal display.
688            None
689        }
690
691        Event::Result { .. } => {
692            // Don't output the final result since it's already been streamed
693            None
694        }
695
696        Event::Error { message, .. } => Some(format!("\x1b[31mError:\x1b[0m {}", message)),
697
698        Event::PermissionRequest {
699            tool_name, granted, ..
700        } => {
701            if *granted {
702                Some(format!(
703                    "\x1b[32m✓\x1b[0m Permission granted for tool '{}'",
704                    tool_name
705                ))
706            } else {
707                Some(format!(
708                    "\x1b[33m!\x1b[0m Permission denied for tool '{}'",
709                    tool_name
710                ))
711            }
712        }
713    }
714}
715
716#[cfg(test)]
717#[path = "output_tests.rs"]
718mod tests;