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 '{}' executed successfully", tool_name)
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 "{}{}{} {}{}",
551 INDENT, ORANGE, RECORD_ICON, line, RESET
552 ));
553 } else {
554 formatted_lines.push(format!(
556 "{}{}{}{}",
557 INDENT_RESULT, ORANGE, line, RESET
558 ));
559 }
560 }
561 Some(formatted_lines.join("\n"))
562 }
563 }
564 ContentBlock::ToolUse { id, name, input } => {
565 let id_suffix = &id[id.len().saturating_sub(4)..];
567 let id_color = get_tool_id_color(id_suffix);
568 const BLUE: &str = "\x1b[34m";
569
570 if name == "Bash"
572 && let serde_json::Value::Object(obj) = input
573 {
574 let description = obj
575 .get("description")
576 .and_then(|v| v.as_str())
577 .unwrap_or("Run command");
578 let command = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
579
580 return Some(format!(
581 "{}{}{} {}{} {}[{}]{}\n{}{}└── {}{}",
582 INDENT,
583 BLUE,
584 RECORD_ICON,
585 description,
586 RESET,
587 id_color,
588 id_suffix,
589 RESET,
590 INDENT_RESULT,
591 DIM,
592 command,
593 RESET
594 ));
595 }
596
597 let input_str = if let serde_json::Value::Object(obj) = input {
599 if obj.is_empty() {
600 String::new()
601 } else {
602 let params: Vec<String> = obj
604 .iter()
605 .map(|(key, value)| {
606 let value_str = match value {
607 serde_json::Value::String(s) => {
608 if s.len() > 60 {
610 format!("\"{}...\"", &s[..57])
611 } else {
612 format!("\"{}\"", s)
613 }
614 }
615 serde_json::Value::Number(n) => n.to_string(),
616 serde_json::Value::Bool(b) => b.to_string(),
617 serde_json::Value::Null => "null".to_string(),
618 _ => "...".to_string(),
619 };
620 format!("{}={}", key, value_str)
621 })
622 .collect();
623 params.join(", ")
624 }
625 } else {
626 "...".to_string()
627 };
628
629 Some(format!(
630 "{}{}{} {}({}) {}[{}]{}",
631 INDENT, BLUE, RECORD_ICON, name, input_str, id_color, id_suffix, RESET
632 ))
633 }
634 })
635 .collect();
636
637 if !formatted.is_empty() {
638 Some(format!("{}\n", formatted.join("\n")))
640 } else {
641 None
642 }
643 }
644
645 Event::ToolExecution {
646 tool_id, result, ..
647 } => {
648 let id_suffix = &tool_id[tool_id.len().saturating_sub(4)..];
649 let id_color = get_tool_id_color(id_suffix);
650 let (icon_color, status_text) = if result.success {
651 (GREEN, "success")
652 } else {
653 (RED, "failed")
654 };
655
656 let result_text = if result.success {
658 result.output.as_deref().unwrap_or(status_text)
659 } else {
660 result.error.as_deref().unwrap_or(status_text)
661 };
662
663 let mut lines: Vec<&str> = result_text.lines().collect();
665 if lines.is_empty() {
666 lines.push(status_text);
667 }
668
669 let mut formatted_lines = Vec::new();
670
671 formatted_lines.push(format!(
673 "{}{}{}{} {}[{}]{}",
674 INDENT, icon_color, ARROW_ICON, RESET, id_color, id_suffix, RESET
675 ));
676
677 for line in lines.iter() {
679 formatted_lines.push(format!("{}{}{}{}", INDENT_RESULT, DIM, line, RESET));
680 }
681
682 Some(format!("{}\n", formatted_lines.join("\n")))
684 }
685
686 Event::TurnComplete { .. } => {
687 None
689 }
690
691 Event::Result { .. } => {
692 None
694 }
695
696 Event::Error { message, .. } => Some(format!("\x1b[31mError:\x1b[0m {}", message)),
697
698 Event::PermissionRequest {
699 tool_name, granted, ..
700 } => {
701 if *granted {
702 Some(format!(
703 "\x1b[32m✓\x1b[0m Permission granted for tool '{}'",
704 tool_name
705 ))
706 } else {
707 Some(format!(
708 "\x1b[33m!\x1b[0m Permission denied for tool '{}'",
709 tool_name
710 ))
711 }
712 }
713 }
714}
715
716#[cfg(test)]
717#[path = "output_tests.rs"]
718mod tests;