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
47#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(tag = "type", rename_all = "snake_case")]
53pub enum Event {
54 Init {
56 model: String,
57 tools: Vec<String>,
58 working_directory: Option<String>,
59 metadata: HashMap<String, serde_json::Value>,
60 },
61
62 UserMessage { content: Vec<ContentBlock> },
64
65 AssistantMessage {
67 content: Vec<ContentBlock>,
68 usage: Option<Usage>,
69 #[serde(skip_serializing_if = "Option::is_none")]
72 parent_tool_use_id: Option<String>,
73 },
74
75 ToolExecution {
77 tool_name: String,
78 tool_id: String,
79 input: serde_json::Value,
80 result: ToolResult,
81 #[serde(skip_serializing_if = "Option::is_none")]
84 parent_tool_use_id: Option<String>,
85 },
86
87 Result {
89 success: bool,
90 message: Option<String>,
91 duration_ms: Option<u64>,
92 num_turns: Option<u32>,
93 },
94
95 Error {
97 message: String,
98 details: Option<serde_json::Value>,
99 },
100
101 PermissionRequest {
103 tool_name: String,
104 description: String,
105 granted: bool,
106 },
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(tag = "type", rename_all = "snake_case")]
112pub enum ContentBlock {
113 Text { text: String },
115
116 ToolUse {
118 id: String,
119 name: String,
120 input: serde_json::Value,
121 },
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ToolResult {
127 pub success: bool,
129
130 pub output: Option<String>,
132
133 pub error: Option<String>,
135
136 pub data: Option<serde_json::Value>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct Usage {
143 pub input_tokens: u64,
145
146 pub output_tokens: u64,
148
149 pub cache_read_tokens: Option<u64>,
151
152 pub cache_creation_tokens: Option<u64>,
154
155 pub web_search_requests: Option<u32>,
157
158 pub web_fetch_requests: Option<u32>,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
166#[serde(rename_all = "lowercase")]
167pub enum LogLevel {
168 Debug,
169 Info,
170 Warn,
171 Error,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct LogEntry {
179 pub level: LogLevel,
181
182 pub message: String,
184
185 pub data: Option<serde_json::Value>,
187
188 pub timestamp: Option<String>,
190}
191
192impl AgentOutput {
193 pub fn from_text(agent: &str, text: &str) -> Self {
197 debug!(
198 "Creating AgentOutput from text: agent={}, len={}",
199 agent,
200 text.len()
201 );
202 Self {
203 agent: agent.to_string(),
204 session_id: String::new(),
205 events: vec![Event::Result {
206 success: true,
207 message: Some(text.to_string()),
208 duration_ms: None,
209 num_turns: None,
210 }],
211 result: Some(text.to_string()),
212 is_error: false,
213 exit_code: None,
214 error_message: None,
215 total_cost_usd: None,
216 usage: None,
217 }
218 }
219
220 pub fn to_log_entries(&self, min_level: LogLevel) -> Vec<LogEntry> {
225 debug!(
226 "Extracting log entries from {} events (min_level={:?})",
227 self.events.len(),
228 min_level
229 );
230 let mut entries = Vec::new();
231
232 for event in &self.events {
233 if let Some(entry) = event_to_log_entry(event)
234 && entry.level >= min_level
235 {
236 entries.push(entry);
237 }
238 }
239
240 entries
241 }
242
243 pub fn final_result(&self) -> Option<&str> {
245 self.result.as_deref()
246 }
247
248 #[allow(dead_code)]
250 pub fn is_success(&self) -> bool {
251 !self.is_error
252 }
253
254 #[allow(dead_code)]
256 pub fn tool_executions(&self) -> Vec<&Event> {
257 self.events
258 .iter()
259 .filter(|e| matches!(e, Event::ToolExecution { .. }))
260 .collect()
261 }
262
263 #[allow(dead_code)]
265 pub fn errors(&self) -> Vec<&Event> {
266 self.events
267 .iter()
268 .filter(|e| matches!(e, Event::Error { .. }))
269 .collect()
270 }
271}
272
273fn event_to_log_entry(event: &Event) -> Option<LogEntry> {
275 match event {
276 Event::Init { model, .. } => Some(LogEntry {
277 level: LogLevel::Info,
278 message: format!("Initialized with model {}", model),
279 data: None,
280 timestamp: None,
281 }),
282
283 Event::AssistantMessage { content, .. } => {
284 let texts: Vec<String> = content
286 .iter()
287 .filter_map(|block| match block {
288 ContentBlock::Text { text } => Some(text.clone()),
289 _ => None,
290 })
291 .collect();
292
293 if !texts.is_empty() {
294 Some(LogEntry {
295 level: LogLevel::Debug,
296 message: texts.join("\n"),
297 data: None,
298 timestamp: None,
299 })
300 } else {
301 None
302 }
303 }
304
305 Event::ToolExecution {
306 tool_name, result, ..
307 } => {
308 let level = if result.success {
309 LogLevel::Debug
310 } else {
311 LogLevel::Warn
312 };
313
314 let message = if result.success {
315 format!("Tool '{}' executed successfully", tool_name)
316 } else {
317 format!(
318 "Tool '{}' failed: {}",
319 tool_name,
320 result.error.as_deref().unwrap_or("unknown error")
321 )
322 };
323
324 Some(LogEntry {
325 level,
326 message,
327 data: result.data.clone(),
328 timestamp: None,
329 })
330 }
331
332 Event::Result {
333 success, message, ..
334 } => {
335 let level = if *success {
336 LogLevel::Info
337 } else {
338 LogLevel::Error
339 };
340
341 Some(LogEntry {
342 level,
343 message: message.clone().unwrap_or_else(|| {
344 if *success {
345 "Session completed".to_string()
346 } else {
347 "Session failed".to_string()
348 }
349 }),
350 data: None,
351 timestamp: None,
352 })
353 }
354
355 Event::Error { message, details } => Some(LogEntry {
356 level: LogLevel::Error,
357 message: message.clone(),
358 data: details.clone(),
359 timestamp: None,
360 }),
361
362 Event::PermissionRequest {
363 tool_name, granted, ..
364 } => {
365 let level = if *granted {
366 LogLevel::Debug
367 } else {
368 LogLevel::Warn
369 };
370
371 let message = if *granted {
372 format!("Permission granted for tool '{}'", tool_name)
373 } else {
374 format!("Permission denied for tool '{}'", tool_name)
375 };
376
377 Some(LogEntry {
378 level,
379 message,
380 data: None,
381 timestamp: None,
382 })
383 }
384
385 Event::UserMessage { content } => {
386 let texts: Vec<String> = content
387 .iter()
388 .filter_map(|b| {
389 if let ContentBlock::Text { text } = b {
390 Some(text.clone())
391 } else {
392 None
393 }
394 })
395 .collect();
396 if texts.is_empty() {
397 None
398 } else {
399 Some(LogEntry {
400 level: LogLevel::Info,
401 message: texts.join("\n"),
402 data: None,
403 timestamp: None,
404 })
405 }
406 }
407 }
408}
409
410impl std::fmt::Display for LogEntry {
411 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412 let level_str = match self.level {
413 LogLevel::Debug => "DEBUG",
414 LogLevel::Info => "INFO",
415 LogLevel::Warn => "WARN",
416 LogLevel::Error => "ERROR",
417 };
418
419 write!(f, "[{}] {}", level_str, self.message)
420 }
421}
422
423fn get_tool_id_color(tool_id: &str) -> &'static str {
425 const TOOL_COLORS: [&str; 10] = [
427 "\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", ];
438
439 let hash: u32 = tool_id.bytes().map(|b| b as u32).sum();
441 let index = (hash as usize) % TOOL_COLORS.len();
442 TOOL_COLORS[index]
443}
444
445pub fn format_event_as_text(event: &Event) -> Option<String> {
449 const INDENT: &str = " ";
450 const INDENT_RESULT: &str = " "; const RECORD_ICON: &str = "⏺";
452 const ARROW_ICON: &str = "←";
453 const ORANGE: &str = "\x1b[38;5;208m";
454 const GREEN: &str = "\x1b[32m";
455 const RED: &str = "\x1b[31m";
456 const DIM: &str = "\x1b[38;5;240m"; const RESET: &str = "\x1b[0m";
458
459 match event {
460 Event::Init { model, .. } => {
461 Some(format!("\x1b[32m✓\x1b[0m Initialized with model {}", model))
462 }
463
464 Event::UserMessage { content } => {
465 let texts: Vec<String> = content
466 .iter()
467 .filter_map(|block| {
468 if let ContentBlock::Text { text } = block {
469 Some(format!("{}> {}{}", DIM, text, RESET))
470 } else {
471 None
472 }
473 })
474 .collect();
475 if texts.is_empty() {
476 None
477 } else {
478 Some(texts.join("\n"))
479 }
480 }
481
482 Event::AssistantMessage { content, .. } => {
483 let formatted: Vec<String> = content
484 .iter()
485 .filter_map(|block| match block {
486 ContentBlock::Text { text } => {
487 let lines: Vec<&str> = text.lines().collect();
490 if lines.is_empty() {
491 None
492 } else {
493 let mut formatted_lines = Vec::new();
494 for (i, line) in lines.iter().enumerate() {
495 if i == 0 {
496 formatted_lines.push(format!(
498 "{}{}{} {}{}",
499 INDENT, ORANGE, RECORD_ICON, line, RESET
500 ));
501 } else {
502 formatted_lines.push(format!(
504 "{}{}{}{}",
505 INDENT_RESULT, ORANGE, line, RESET
506 ));
507 }
508 }
509 Some(formatted_lines.join("\n"))
510 }
511 }
512 ContentBlock::ToolUse { id, name, input } => {
513 let id_suffix = &id[id.len().saturating_sub(4)..];
515 let id_color = get_tool_id_color(id_suffix);
516 const BLUE: &str = "\x1b[34m";
517
518 if name == "Bash"
520 && let serde_json::Value::Object(obj) = input
521 {
522 let description = obj
523 .get("description")
524 .and_then(|v| v.as_str())
525 .unwrap_or("Run command");
526 let command = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
527
528 return Some(format!(
529 "{}{}{} {}{} {}[{}]{}\n{}{}└── {}{}",
530 INDENT,
531 BLUE,
532 RECORD_ICON,
533 description,
534 RESET,
535 id_color,
536 id_suffix,
537 RESET,
538 INDENT_RESULT,
539 DIM,
540 command,
541 RESET
542 ));
543 }
544
545 let input_str = if let serde_json::Value::Object(obj) = input {
547 if obj.is_empty() {
548 String::new()
549 } else {
550 let params: Vec<String> = obj
552 .iter()
553 .map(|(key, value)| {
554 let value_str = match value {
555 serde_json::Value::String(s) => {
556 if s.len() > 60 {
558 format!("\"{}...\"", &s[..57])
559 } else {
560 format!("\"{}\"", s)
561 }
562 }
563 serde_json::Value::Number(n) => n.to_string(),
564 serde_json::Value::Bool(b) => b.to_string(),
565 serde_json::Value::Null => "null".to_string(),
566 _ => "...".to_string(),
567 };
568 format!("{}={}", key, value_str)
569 })
570 .collect();
571 params.join(", ")
572 }
573 } else {
574 "...".to_string()
575 };
576
577 Some(format!(
578 "{}{}{} {}({}) {}[{}]{}",
579 INDENT, BLUE, RECORD_ICON, name, input_str, id_color, id_suffix, RESET
580 ))
581 }
582 })
583 .collect();
584
585 if !formatted.is_empty() {
586 Some(format!("{}\n", formatted.join("\n")))
588 } else {
589 None
590 }
591 }
592
593 Event::ToolExecution {
594 tool_id, result, ..
595 } => {
596 let id_suffix = &tool_id[tool_id.len().saturating_sub(4)..];
597 let id_color = get_tool_id_color(id_suffix);
598 let (icon_color, status_text) = if result.success {
599 (GREEN, "success")
600 } else {
601 (RED, "failed")
602 };
603
604 let result_text = if result.success {
606 result.output.as_deref().unwrap_or(status_text)
607 } else {
608 result.error.as_deref().unwrap_or(status_text)
609 };
610
611 let mut lines: Vec<&str> = result_text.lines().collect();
613 if lines.is_empty() {
614 lines.push(status_text);
615 }
616
617 let mut formatted_lines = Vec::new();
618
619 formatted_lines.push(format!(
621 "{}{}{}{} {}[{}]{}",
622 INDENT, icon_color, ARROW_ICON, RESET, id_color, id_suffix, RESET
623 ));
624
625 for line in lines.iter() {
627 formatted_lines.push(format!("{}{}{}{}", INDENT_RESULT, DIM, line, RESET));
628 }
629
630 Some(format!("{}\n", formatted_lines.join("\n")))
632 }
633
634 Event::Result { .. } => {
635 None
637 }
638
639 Event::Error { message, .. } => Some(format!("\x1b[31mError:\x1b[0m {}", message)),
640
641 Event::PermissionRequest {
642 tool_name, granted, ..
643 } => {
644 if *granted {
645 Some(format!(
646 "\x1b[32m✓\x1b[0m Permission granted for tool '{}'",
647 tool_name
648 ))
649 } else {
650 Some(format!(
651 "\x1b[33m!\x1b[0m Permission denied for tool '{}'",
652 tool_name
653 ))
654 }
655 }
656 }
657}
658
659#[cfg(test)]
660#[path = "output_tests.rs"]
661mod tests;