1use log::debug;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AgentOutput {
17 pub agent: String,
19
20 pub session_id: String,
22
23 pub events: Vec<Event>,
25
26 pub result: Option<String>,
28
29 pub is_error: bool,
31
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub exit_code: Option<i32>,
35
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub error_message: Option<String>,
39
40 pub total_cost_usd: Option<f64>,
42
43 pub usage: Option<Usage>,
45
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub model: Option<String>,
49
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub provider: Option<String>,
53
54 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub log_path: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(tag = "type", rename_all = "snake_case")]
69pub enum Event {
70 Init {
72 model: String,
73 tools: Vec<String>,
74 working_directory: Option<String>,
75 metadata: HashMap<String, serde_json::Value>,
76 },
77
78 UserMessage { content: Vec<ContentBlock> },
80
81 AssistantMessage {
83 content: Vec<ContentBlock>,
84 usage: Option<Usage>,
85 #[serde(skip_serializing_if = "Option::is_none")]
88 parent_tool_use_id: Option<String>,
89 },
90
91 ToolExecution {
93 tool_name: String,
94 tool_id: String,
95 input: serde_json::Value,
96 result: ToolResult,
97 #[serde(skip_serializing_if = "Option::is_none")]
100 parent_tool_use_id: Option<String>,
101 },
102
103 TurnComplete {
113 stop_reason: Option<String>,
120 turn_index: u32,
122 usage: Option<Usage>,
124 },
125
126 Result {
132 success: bool,
133 message: Option<String>,
134 duration_ms: Option<u64>,
135 num_turns: Option<u32>,
136 },
137
138 Error {
140 message: String,
141 details: Option<serde_json::Value>,
142 },
143
144 PermissionRequest {
146 tool_name: String,
147 description: String,
148 granted: bool,
149 },
150
151 UsageLimitDetected {
157 provider: String,
159 scope: String,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 reset_at: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 raw: Option<String>,
167 },
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(tag = "type", rename_all = "snake_case")]
173pub enum ContentBlock {
174 Text { text: String },
176
177 ToolUse {
179 id: String,
180 name: String,
181 input: serde_json::Value,
182 },
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ToolResult {
188 pub success: bool,
190
191 pub output: Option<String>,
193
194 pub error: Option<String>,
196
197 pub data: Option<serde_json::Value>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct Usage {
204 pub input_tokens: u64,
206
207 pub output_tokens: u64,
209
210 pub cache_read_tokens: Option<u64>,
212
213 pub cache_creation_tokens: Option<u64>,
215
216 pub web_search_requests: Option<u32>,
218
219 pub web_fetch_requests: Option<u32>,
221}
222
223#[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#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct LogEntry {
240 pub level: LogLevel,
242
243 pub message: String,
245
246 pub data: Option<serde_json::Value>,
248
249 pub timestamp: Option<String>,
251}
252
253impl AgentOutput {
254 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 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 pub fn final_result(&self) -> Option<&str> {
309 self.result.as_deref()
310 }
311
312 #[allow(dead_code)]
314 pub fn is_success(&self) -> bool {
315 !self.is_error
316 }
317
318 #[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 #[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
337fn 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 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
517fn get_tool_id_color(tool_id: &str) -> &'static str {
519 const TOOL_COLORS: [&str; 10] = [
521 "\x1b[38;5;33m", "\x1b[38;5;35m", "\x1b[38;5;141m", "\x1b[38;5;208m", "\x1b[38;5;213m", "\x1b[38;5;51m", "\x1b[38;5;226m", "\x1b[38;5;205m", "\x1b[38;5;87m", "\x1b[38;5;215m", ];
532
533 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
539pub fn format_event_as_text(event: &Event) -> Option<String> {
543 const INDENT: &str = " ";
544 const INDENT_RESULT: &str = " "; 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"; 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 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 formatted_lines.push(format!(
592 "{INDENT}{ORANGE}{RECORD_ICON} {line}{RESET}"
593 ));
594 } else {
595 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 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 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 let input_str = if let serde_json::Value::Object(obj) = input {
627 if obj.is_empty() {
628 String::new()
629 } else {
630 let params: Vec<String> = obj
632 .iter()
633 .map(|(key, value)| {
634 let value_str = match value {
635 serde_json::Value::String(s) => {
636 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 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 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 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 formatted_lines.push(format!(
700 "{INDENT}{icon_color}{ARROW_ICON}{RESET} {id_color}[{id_suffix}]{RESET}"
701 ));
702
703 for line in lines.iter() {
705 formatted_lines.push(format!("{INDENT_RESULT}{DIM}{line}{RESET}"));
706 }
707
708 Some(format!("{}\n", formatted_lines.join("\n")))
710 }
711
712 Event::TurnComplete { .. } => {
713 None
715 }
716
717 Event::Result { .. } => {
718 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;