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