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