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