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
152#[derive(Debug, Clone, Serialize, Deserialize)]
154#[serde(tag = "type", rename_all = "snake_case")]
155pub enum ContentBlock {
156 Text { text: String },
158
159 ToolUse {
161 id: String,
162 name: String,
163 input: serde_json::Value,
164 },
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct ToolResult {
170 pub success: bool,
172
173 pub output: Option<String>,
175
176 pub error: Option<String>,
178
179 pub data: Option<serde_json::Value>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct Usage {
186 pub input_tokens: u64,
188
189 pub output_tokens: u64,
191
192 pub cache_read_tokens: Option<u64>,
194
195 pub cache_creation_tokens: Option<u64>,
197
198 pub web_search_requests: Option<u32>,
200
201 pub web_fetch_requests: Option<u32>,
203}
204
205#[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#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct LogEntry {
222 pub level: LogLevel,
224
225 pub message: String,
227
228 pub data: Option<serde_json::Value>,
230
231 pub timestamp: Option<String>,
233}
234
235impl AgentOutput {
236 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 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 pub fn final_result(&self) -> Option<&str> {
291 self.result.as_deref()
292 }
293
294 #[allow(dead_code)]
296 pub fn is_success(&self) -> bool {
297 !self.is_error
298 }
299
300 #[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 #[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
319fn 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 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
484fn get_tool_id_color(tool_id: &str) -> &'static str {
486 const TOOL_COLORS: [&str; 10] = [
488 "\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", ];
499
500 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
506pub fn format_event_as_text(event: &Event) -> Option<String> {
510 const INDENT: &str = " ";
511 const INDENT_RESULT: &str = " "; 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"; 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 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 formatted_lines.push(format!(
559 "{INDENT}{ORANGE}{RECORD_ICON} {line}{RESET}"
560 ));
561 } else {
562 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 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 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 let input_str = if let serde_json::Value::Object(obj) = input {
594 if obj.is_empty() {
595 String::new()
596 } else {
597 let params: Vec<String> = obj
599 .iter()
600 .map(|(key, value)| {
601 let value_str = match value {
602 serde_json::Value::String(s) => {
603 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 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 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 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 formatted_lines.push(format!(
667 "{INDENT}{icon_color}{ARROW_ICON}{RESET} {id_color}[{id_suffix}]{RESET}"
668 ));
669
670 for line in lines.iter() {
672 formatted_lines.push(format!("{INDENT_RESULT}{DIM}{line}{RESET}"));
673 }
674
675 Some(format!("{}\n", formatted_lines.join("\n")))
677 }
678
679 Event::TurnComplete { .. } => {
680 None
682 }
683
684 Event::Result { .. } => {
685 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;