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
55#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(tag = "type", rename_all = "snake_case")]
61pub enum Event {
62 Init {
64 model: String,
65 tools: Vec<String>,
66 working_directory: Option<String>,
67 metadata: HashMap<String, serde_json::Value>,
68 },
69
70 UserMessage { content: Vec<ContentBlock> },
72
73 AssistantMessage {
75 content: Vec<ContentBlock>,
76 usage: Option<Usage>,
77 #[serde(skip_serializing_if = "Option::is_none")]
80 parent_tool_use_id: Option<String>,
81 },
82
83 ToolExecution {
85 tool_name: String,
86 tool_id: String,
87 input: serde_json::Value,
88 result: ToolResult,
89 #[serde(skip_serializing_if = "Option::is_none")]
92 parent_tool_use_id: Option<String>,
93 },
94
95 TurnComplete {
105 stop_reason: Option<String>,
112 turn_index: u32,
114 usage: Option<Usage>,
116 },
117
118 Result {
124 success: bool,
125 message: Option<String>,
126 duration_ms: Option<u64>,
127 num_turns: Option<u32>,
128 },
129
130 Error {
132 message: String,
133 details: Option<serde_json::Value>,
134 },
135
136 PermissionRequest {
138 tool_name: String,
139 description: String,
140 granted: bool,
141 },
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(tag = "type", rename_all = "snake_case")]
147pub enum ContentBlock {
148 Text { text: String },
150
151 ToolUse {
153 id: String,
154 name: String,
155 input: serde_json::Value,
156 },
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ToolResult {
162 pub success: bool,
164
165 pub output: Option<String>,
167
168 pub error: Option<String>,
170
171 pub data: Option<serde_json::Value>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Usage {
178 pub input_tokens: u64,
180
181 pub output_tokens: u64,
183
184 pub cache_read_tokens: Option<u64>,
186
187 pub cache_creation_tokens: Option<u64>,
189
190 pub web_search_requests: Option<u32>,
192
193 pub web_fetch_requests: Option<u32>,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
201#[serde(rename_all = "lowercase")]
202pub enum LogLevel {
203 Debug,
204 Info,
205 Warn,
206 Error,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct LogEntry {
214 pub level: LogLevel,
216
217 pub message: String,
219
220 pub data: Option<serde_json::Value>,
222
223 pub timestamp: Option<String>,
225}
226
227impl AgentOutput {
228 pub fn from_text(agent: &str, text: &str) -> Self {
232 debug!(
233 "Creating AgentOutput from text: agent={}, len={}",
234 agent,
235 text.len()
236 );
237 Self {
238 agent: agent.to_string(),
239 session_id: String::new(),
240 events: vec![Event::Result {
241 success: true,
242 message: Some(text.to_string()),
243 duration_ms: None,
244 num_turns: None,
245 }],
246 result: Some(text.to_string()),
247 is_error: false,
248 exit_code: None,
249 error_message: None,
250 total_cost_usd: None,
251 usage: None,
252 model: None,
253 provider: Some(agent.to_string()),
254 }
255 }
256
257 pub fn to_log_entries(&self, min_level: LogLevel) -> Vec<LogEntry> {
262 debug!(
263 "Extracting log entries from {} events (min_level={:?})",
264 self.events.len(),
265 min_level
266 );
267 let mut entries = Vec::new();
268
269 for event in &self.events {
270 if let Some(entry) = event_to_log_entry(event)
271 && entry.level >= min_level
272 {
273 entries.push(entry);
274 }
275 }
276
277 entries
278 }
279
280 pub fn final_result(&self) -> Option<&str> {
282 self.result.as_deref()
283 }
284
285 #[allow(dead_code)]
287 pub fn is_success(&self) -> bool {
288 !self.is_error
289 }
290
291 #[allow(dead_code)]
293 pub fn tool_executions(&self) -> Vec<&Event> {
294 self.events
295 .iter()
296 .filter(|e| matches!(e, Event::ToolExecution { .. }))
297 .collect()
298 }
299
300 #[allow(dead_code)]
302 pub fn errors(&self) -> Vec<&Event> {
303 self.events
304 .iter()
305 .filter(|e| matches!(e, Event::Error { .. }))
306 .collect()
307 }
308}
309
310fn event_to_log_entry(event: &Event) -> Option<LogEntry> {
312 match event {
313 Event::Init { model, .. } => Some(LogEntry {
314 level: LogLevel::Info,
315 message: format!("Initialized with model {model}"),
316 data: None,
317 timestamp: None,
318 }),
319
320 Event::AssistantMessage { content, .. } => {
321 let texts: Vec<String> = content
323 .iter()
324 .filter_map(|block| match block {
325 ContentBlock::Text { text } => Some(text.clone()),
326 _ => None,
327 })
328 .collect();
329
330 if !texts.is_empty() {
331 Some(LogEntry {
332 level: LogLevel::Debug,
333 message: texts.join("\n"),
334 data: None,
335 timestamp: None,
336 })
337 } else {
338 None
339 }
340 }
341
342 Event::ToolExecution {
343 tool_name, result, ..
344 } => {
345 let level = if result.success {
346 LogLevel::Debug
347 } else {
348 LogLevel::Warn
349 };
350
351 let message = if result.success {
352 format!("Tool '{tool_name}' executed successfully")
353 } else {
354 format!(
355 "Tool '{}' failed: {}",
356 tool_name,
357 result.error.as_deref().unwrap_or("unknown error")
358 )
359 };
360
361 Some(LogEntry {
362 level,
363 message,
364 data: result.data.clone(),
365 timestamp: None,
366 })
367 }
368
369 Event::Result {
370 success, message, ..
371 } => {
372 let level = if *success {
373 LogLevel::Info
374 } else {
375 LogLevel::Error
376 };
377
378 Some(LogEntry {
379 level,
380 message: message.clone().unwrap_or_else(|| {
381 if *success {
382 "Session completed".to_string()
383 } else {
384 "Session failed".to_string()
385 }
386 }),
387 data: None,
388 timestamp: None,
389 })
390 }
391
392 Event::Error { message, details } => Some(LogEntry {
393 level: LogLevel::Error,
394 message: message.clone(),
395 data: details.clone(),
396 timestamp: None,
397 }),
398
399 Event::PermissionRequest {
400 tool_name, granted, ..
401 } => {
402 let level = if *granted {
403 LogLevel::Debug
404 } else {
405 LogLevel::Warn
406 };
407
408 let message = if *granted {
409 format!("Permission granted for tool '{tool_name}'")
410 } else {
411 format!("Permission denied for tool '{tool_name}'")
412 };
413
414 Some(LogEntry {
415 level,
416 message,
417 data: None,
418 timestamp: None,
419 })
420 }
421
422 Event::UserMessage { content } => {
423 let texts: Vec<String> = content
424 .iter()
425 .filter_map(|b| {
426 if let ContentBlock::Text { text } = b {
427 Some(text.clone())
428 } else {
429 None
430 }
431 })
432 .collect();
433 if texts.is_empty() {
434 None
435 } else {
436 Some(LogEntry {
437 level: LogLevel::Info,
438 message: texts.join("\n"),
439 data: None,
440 timestamp: None,
441 })
442 }
443 }
444
445 Event::TurnComplete {
446 stop_reason,
447 turn_index,
448 ..
449 } => Some(LogEntry {
450 level: LogLevel::Debug,
451 message: format!(
452 "Turn {} complete (stop_reason: {})",
453 turn_index,
454 stop_reason.as_deref().unwrap_or("none")
455 ),
456 data: None,
457 timestamp: None,
458 }),
459 }
460}
461
462impl std::fmt::Display for LogEntry {
463 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464 let level_str = match self.level {
465 LogLevel::Debug => "DEBUG",
466 LogLevel::Info => "INFO",
467 LogLevel::Warn => "WARN",
468 LogLevel::Error => "ERROR",
469 };
470
471 write!(f, "[{}] {}", level_str, self.message)
472 }
473}
474
475fn get_tool_id_color(tool_id: &str) -> &'static str {
477 const TOOL_COLORS: [&str; 10] = [
479 "\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", ];
490
491 let hash: u32 = tool_id.bytes().map(|b| b as u32).sum();
493 let index = (hash as usize) % TOOL_COLORS.len();
494 TOOL_COLORS[index]
495}
496
497pub fn format_event_as_text(event: &Event) -> Option<String> {
501 const INDENT: &str = " ";
502 const INDENT_RESULT: &str = " "; const RECORD_ICON: &str = "⏺";
504 const ARROW_ICON: &str = "←";
505 const ORANGE: &str = "\x1b[38;5;208m";
506 const GREEN: &str = "\x1b[32m";
507 const RED: &str = "\x1b[31m";
508 const DIM: &str = "\x1b[38;5;240m"; const RESET: &str = "\x1b[0m";
510
511 match event {
512 Event::Init { model, .. } => {
513 Some(format!("\x1b[32m✓\x1b[0m Initialized with model {model}"))
514 }
515
516 Event::UserMessage { content } => {
517 let texts: Vec<String> = content
518 .iter()
519 .filter_map(|block| {
520 if let ContentBlock::Text { text } = block {
521 Some(format!("{DIM}> {text}{RESET}"))
522 } else {
523 None
524 }
525 })
526 .collect();
527 if texts.is_empty() {
528 None
529 } else {
530 Some(texts.join("\n"))
531 }
532 }
533
534 Event::AssistantMessage { content, .. } => {
535 let formatted: Vec<String> = content
536 .iter()
537 .filter_map(|block| match block {
538 ContentBlock::Text { text } => {
539 let lines: Vec<&str> = text.lines().collect();
542 if lines.is_empty() {
543 None
544 } else {
545 let mut formatted_lines = Vec::new();
546 for (i, line) in lines.iter().enumerate() {
547 if i == 0 {
548 formatted_lines.push(format!(
550 "{INDENT}{ORANGE}{RECORD_ICON} {line}{RESET}"
551 ));
552 } else {
553 formatted_lines.push(format!(
555 "{INDENT_RESULT}{ORANGE}{line}{RESET}"
556 ));
557 }
558 }
559 Some(formatted_lines.join("\n"))
560 }
561 }
562 ContentBlock::ToolUse { id, name, input } => {
563 let id_suffix = &id[id.len().saturating_sub(4)..];
565 let id_color = get_tool_id_color(id_suffix);
566 const BLUE: &str = "\x1b[34m";
567
568 if name == "Bash"
570 && let serde_json::Value::Object(obj) = input
571 {
572 let description = obj
573 .get("description")
574 .and_then(|v| v.as_str())
575 .unwrap_or("Run command");
576 let command = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
577
578 return Some(format!(
579 "{INDENT}{BLUE}{RECORD_ICON} {description}{RESET} {id_color}[{id_suffix}]{RESET}\n{INDENT_RESULT}{DIM}└── {command}{RESET}"
580 ));
581 }
582
583 let input_str = if let serde_json::Value::Object(obj) = input {
585 if obj.is_empty() {
586 String::new()
587 } else {
588 let params: Vec<String> = obj
590 .iter()
591 .map(|(key, value)| {
592 let value_str = match value {
593 serde_json::Value::String(s) => {
594 if s.len() > 60 {
596 format!("\"{}...\"", &s[..57])
597 } else {
598 format!("\"{s}\"")
599 }
600 }
601 serde_json::Value::Number(n) => n.to_string(),
602 serde_json::Value::Bool(b) => b.to_string(),
603 serde_json::Value::Null => "null".to_string(),
604 _ => "...".to_string(),
605 };
606 format!("{key}={value_str}")
607 })
608 .collect();
609 params.join(", ")
610 }
611 } else {
612 "...".to_string()
613 };
614
615 Some(format!(
616 "{INDENT}{BLUE}{RECORD_ICON} {name}({input_str}) {id_color}[{id_suffix}]{RESET}"
617 ))
618 }
619 })
620 .collect();
621
622 if !formatted.is_empty() {
623 Some(format!("{}\n", formatted.join("\n")))
625 } else {
626 None
627 }
628 }
629
630 Event::ToolExecution {
631 tool_id, result, ..
632 } => {
633 let id_suffix = &tool_id[tool_id.len().saturating_sub(4)..];
634 let id_color = get_tool_id_color(id_suffix);
635 let (icon_color, status_text) = if result.success {
636 (GREEN, "success")
637 } else {
638 (RED, "failed")
639 };
640
641 let result_text = if result.success {
643 result.output.as_deref().unwrap_or(status_text)
644 } else {
645 result.error.as_deref().unwrap_or(status_text)
646 };
647
648 let mut lines: Vec<&str> = result_text.lines().collect();
650 if lines.is_empty() {
651 lines.push(status_text);
652 }
653
654 let mut formatted_lines = Vec::new();
655
656 formatted_lines.push(format!(
658 "{INDENT}{icon_color}{ARROW_ICON}{RESET} {id_color}[{id_suffix}]{RESET}"
659 ));
660
661 for line in lines.iter() {
663 formatted_lines.push(format!("{INDENT_RESULT}{DIM}{line}{RESET}"));
664 }
665
666 Some(format!("{}\n", formatted_lines.join("\n")))
668 }
669
670 Event::TurnComplete { .. } => {
671 None
673 }
674
675 Event::Result { .. } => {
676 None
678 }
679
680 Event::Error { message, .. } => Some(format!("\x1b[31mError:\x1b[0m {message}")),
681
682 Event::PermissionRequest {
683 tool_name, granted, ..
684 } => {
685 if *granted {
686 Some(format!(
687 "\x1b[32m✓\x1b[0m Permission granted for tool '{tool_name}'"
688 ))
689 } else {
690 Some(format!(
691 "\x1b[33m!\x1b[0m Permission denied for tool '{tool_name}'"
692 ))
693 }
694 }
695 }
696}
697
698#[cfg(test)]
699#[path = "output_tests.rs"]
700mod tests;