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 TurnComplete {
97 stop_reason: Option<String>,
104 turn_index: u32,
106 usage: Option<Usage>,
108 },
109
110 Result {
116 success: bool,
117 message: Option<String>,
118 duration_ms: Option<u64>,
119 num_turns: Option<u32>,
120 },
121
122 Error {
124 message: String,
125 details: Option<serde_json::Value>,
126 },
127
128 PermissionRequest {
130 tool_name: String,
131 description: String,
132 granted: bool,
133 },
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(tag = "type", rename_all = "snake_case")]
139pub enum ContentBlock {
140 Text { text: String },
142
143 ToolUse {
145 id: String,
146 name: String,
147 input: serde_json::Value,
148 },
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ToolResult {
154 pub success: bool,
156
157 pub output: Option<String>,
159
160 pub error: Option<String>,
162
163 pub data: Option<serde_json::Value>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct Usage {
170 pub input_tokens: u64,
172
173 pub output_tokens: u64,
175
176 pub cache_read_tokens: Option<u64>,
178
179 pub cache_creation_tokens: Option<u64>,
181
182 pub web_search_requests: Option<u32>,
184
185 pub web_fetch_requests: Option<u32>,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
193#[serde(rename_all = "lowercase")]
194pub enum LogLevel {
195 Debug,
196 Info,
197 Warn,
198 Error,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct LogEntry {
206 pub level: LogLevel,
208
209 pub message: String,
211
212 pub data: Option<serde_json::Value>,
214
215 pub timestamp: Option<String>,
217}
218
219impl AgentOutput {
220 pub fn from_text(agent: &str, text: &str) -> Self {
224 debug!(
225 "Creating AgentOutput from text: agent={}, len={}",
226 agent,
227 text.len()
228 );
229 Self {
230 agent: agent.to_string(),
231 session_id: String::new(),
232 events: vec![Event::Result {
233 success: true,
234 message: Some(text.to_string()),
235 duration_ms: None,
236 num_turns: None,
237 }],
238 result: Some(text.to_string()),
239 is_error: false,
240 exit_code: None,
241 error_message: None,
242 total_cost_usd: None,
243 usage: None,
244 }
245 }
246
247 pub fn to_log_entries(&self, min_level: LogLevel) -> Vec<LogEntry> {
252 debug!(
253 "Extracting log entries from {} events (min_level={:?})",
254 self.events.len(),
255 min_level
256 );
257 let mut entries = Vec::new();
258
259 for event in &self.events {
260 if let Some(entry) = event_to_log_entry(event)
261 && entry.level >= min_level
262 {
263 entries.push(entry);
264 }
265 }
266
267 entries
268 }
269
270 pub fn final_result(&self) -> Option<&str> {
272 self.result.as_deref()
273 }
274
275 #[allow(dead_code)]
277 pub fn is_success(&self) -> bool {
278 !self.is_error
279 }
280
281 #[allow(dead_code)]
283 pub fn tool_executions(&self) -> Vec<&Event> {
284 self.events
285 .iter()
286 .filter(|e| matches!(e, Event::ToolExecution { .. }))
287 .collect()
288 }
289
290 #[allow(dead_code)]
292 pub fn errors(&self) -> Vec<&Event> {
293 self.events
294 .iter()
295 .filter(|e| matches!(e, Event::Error { .. }))
296 .collect()
297 }
298}
299
300fn event_to_log_entry(event: &Event) -> Option<LogEntry> {
302 match event {
303 Event::Init { model, .. } => Some(LogEntry {
304 level: LogLevel::Info,
305 message: format!("Initialized with model {}", model),
306 data: None,
307 timestamp: None,
308 }),
309
310 Event::AssistantMessage { content, .. } => {
311 let texts: Vec<String> = content
313 .iter()
314 .filter_map(|block| match block {
315 ContentBlock::Text { text } => Some(text.clone()),
316 _ => None,
317 })
318 .collect();
319
320 if !texts.is_empty() {
321 Some(LogEntry {
322 level: LogLevel::Debug,
323 message: texts.join("\n"),
324 data: None,
325 timestamp: None,
326 })
327 } else {
328 None
329 }
330 }
331
332 Event::ToolExecution {
333 tool_name, result, ..
334 } => {
335 let level = if result.success {
336 LogLevel::Debug
337 } else {
338 LogLevel::Warn
339 };
340
341 let message = if result.success {
342 format!("Tool '{}' executed successfully", tool_name)
343 } else {
344 format!(
345 "Tool '{}' failed: {}",
346 tool_name,
347 result.error.as_deref().unwrap_or("unknown error")
348 )
349 };
350
351 Some(LogEntry {
352 level,
353 message,
354 data: result.data.clone(),
355 timestamp: None,
356 })
357 }
358
359 Event::Result {
360 success, message, ..
361 } => {
362 let level = if *success {
363 LogLevel::Info
364 } else {
365 LogLevel::Error
366 };
367
368 Some(LogEntry {
369 level,
370 message: message.clone().unwrap_or_else(|| {
371 if *success {
372 "Session completed".to_string()
373 } else {
374 "Session failed".to_string()
375 }
376 }),
377 data: None,
378 timestamp: None,
379 })
380 }
381
382 Event::Error { message, details } => Some(LogEntry {
383 level: LogLevel::Error,
384 message: message.clone(),
385 data: details.clone(),
386 timestamp: None,
387 }),
388
389 Event::PermissionRequest {
390 tool_name, granted, ..
391 } => {
392 let level = if *granted {
393 LogLevel::Debug
394 } else {
395 LogLevel::Warn
396 };
397
398 let message = if *granted {
399 format!("Permission granted for tool '{}'", tool_name)
400 } else {
401 format!("Permission denied for tool '{}'", tool_name)
402 };
403
404 Some(LogEntry {
405 level,
406 message,
407 data: None,
408 timestamp: None,
409 })
410 }
411
412 Event::UserMessage { content } => {
413 let texts: Vec<String> = content
414 .iter()
415 .filter_map(|b| {
416 if let ContentBlock::Text { text } = b {
417 Some(text.clone())
418 } else {
419 None
420 }
421 })
422 .collect();
423 if texts.is_empty() {
424 None
425 } else {
426 Some(LogEntry {
427 level: LogLevel::Info,
428 message: texts.join("\n"),
429 data: None,
430 timestamp: None,
431 })
432 }
433 }
434
435 Event::TurnComplete {
436 stop_reason,
437 turn_index,
438 ..
439 } => Some(LogEntry {
440 level: LogLevel::Debug,
441 message: format!(
442 "Turn {} complete (stop_reason: {})",
443 turn_index,
444 stop_reason.as_deref().unwrap_or("none")
445 ),
446 data: None,
447 timestamp: None,
448 }),
449 }
450}
451
452impl std::fmt::Display for LogEntry {
453 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454 let level_str = match self.level {
455 LogLevel::Debug => "DEBUG",
456 LogLevel::Info => "INFO",
457 LogLevel::Warn => "WARN",
458 LogLevel::Error => "ERROR",
459 };
460
461 write!(f, "[{}] {}", level_str, self.message)
462 }
463}
464
465fn get_tool_id_color(tool_id: &str) -> &'static str {
467 const TOOL_COLORS: [&str; 10] = [
469 "\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", ];
480
481 let hash: u32 = tool_id.bytes().map(|b| b as u32).sum();
483 let index = (hash as usize) % TOOL_COLORS.len();
484 TOOL_COLORS[index]
485}
486
487pub fn format_event_as_text(event: &Event) -> Option<String> {
491 const INDENT: &str = " ";
492 const INDENT_RESULT: &str = " "; const RECORD_ICON: &str = "⏺";
494 const ARROW_ICON: &str = "←";
495 const ORANGE: &str = "\x1b[38;5;208m";
496 const GREEN: &str = "\x1b[32m";
497 const RED: &str = "\x1b[31m";
498 const DIM: &str = "\x1b[38;5;240m"; const RESET: &str = "\x1b[0m";
500
501 match event {
502 Event::Init { model, .. } => {
503 Some(format!("\x1b[32m✓\x1b[0m Initialized with model {}", model))
504 }
505
506 Event::UserMessage { content } => {
507 let texts: Vec<String> = content
508 .iter()
509 .filter_map(|block| {
510 if let ContentBlock::Text { text } = block {
511 Some(format!("{}> {}{}", DIM, text, RESET))
512 } else {
513 None
514 }
515 })
516 .collect();
517 if texts.is_empty() {
518 None
519 } else {
520 Some(texts.join("\n"))
521 }
522 }
523
524 Event::AssistantMessage { content, .. } => {
525 let formatted: Vec<String> = content
526 .iter()
527 .filter_map(|block| match block {
528 ContentBlock::Text { text } => {
529 let lines: Vec<&str> = text.lines().collect();
532 if lines.is_empty() {
533 None
534 } else {
535 let mut formatted_lines = Vec::new();
536 for (i, line) in lines.iter().enumerate() {
537 if i == 0 {
538 formatted_lines.push(format!(
540 "{}{}{} {}{}",
541 INDENT, ORANGE, RECORD_ICON, line, RESET
542 ));
543 } else {
544 formatted_lines.push(format!(
546 "{}{}{}{}",
547 INDENT_RESULT, ORANGE, line, RESET
548 ));
549 }
550 }
551 Some(formatted_lines.join("\n"))
552 }
553 }
554 ContentBlock::ToolUse { id, name, input } => {
555 let id_suffix = &id[id.len().saturating_sub(4)..];
557 let id_color = get_tool_id_color(id_suffix);
558 const BLUE: &str = "\x1b[34m";
559
560 if name == "Bash"
562 && let serde_json::Value::Object(obj) = input
563 {
564 let description = obj
565 .get("description")
566 .and_then(|v| v.as_str())
567 .unwrap_or("Run command");
568 let command = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
569
570 return Some(format!(
571 "{}{}{} {}{} {}[{}]{}\n{}{}└── {}{}",
572 INDENT,
573 BLUE,
574 RECORD_ICON,
575 description,
576 RESET,
577 id_color,
578 id_suffix,
579 RESET,
580 INDENT_RESULT,
581 DIM,
582 command,
583 RESET
584 ));
585 }
586
587 let input_str = if let serde_json::Value::Object(obj) = input {
589 if obj.is_empty() {
590 String::new()
591 } else {
592 let params: Vec<String> = obj
594 .iter()
595 .map(|(key, value)| {
596 let value_str = match value {
597 serde_json::Value::String(s) => {
598 if s.len() > 60 {
600 format!("\"{}...\"", &s[..57])
601 } else {
602 format!("\"{}\"", s)
603 }
604 }
605 serde_json::Value::Number(n) => n.to_string(),
606 serde_json::Value::Bool(b) => b.to_string(),
607 serde_json::Value::Null => "null".to_string(),
608 _ => "...".to_string(),
609 };
610 format!("{}={}", key, value_str)
611 })
612 .collect();
613 params.join(", ")
614 }
615 } else {
616 "...".to_string()
617 };
618
619 Some(format!(
620 "{}{}{} {}({}) {}[{}]{}",
621 INDENT, BLUE, RECORD_ICON, name, input_str, id_color, id_suffix, RESET
622 ))
623 }
624 })
625 .collect();
626
627 if !formatted.is_empty() {
628 Some(format!("{}\n", formatted.join("\n")))
630 } else {
631 None
632 }
633 }
634
635 Event::ToolExecution {
636 tool_id, result, ..
637 } => {
638 let id_suffix = &tool_id[tool_id.len().saturating_sub(4)..];
639 let id_color = get_tool_id_color(id_suffix);
640 let (icon_color, status_text) = if result.success {
641 (GREEN, "success")
642 } else {
643 (RED, "failed")
644 };
645
646 let result_text = if result.success {
648 result.output.as_deref().unwrap_or(status_text)
649 } else {
650 result.error.as_deref().unwrap_or(status_text)
651 };
652
653 let mut lines: Vec<&str> = result_text.lines().collect();
655 if lines.is_empty() {
656 lines.push(status_text);
657 }
658
659 let mut formatted_lines = Vec::new();
660
661 formatted_lines.push(format!(
663 "{}{}{}{} {}[{}]{}",
664 INDENT, icon_color, ARROW_ICON, RESET, id_color, id_suffix, RESET
665 ));
666
667 for line in lines.iter() {
669 formatted_lines.push(format!("{}{}{}{}", INDENT_RESULT, DIM, line, RESET));
670 }
671
672 Some(format!("{}\n", formatted_lines.join("\n")))
674 }
675
676 Event::TurnComplete { .. } => {
677 None
679 }
680
681 Event::Result { .. } => {
682 None
684 }
685
686 Event::Error { message, .. } => Some(format!("\x1b[31mError:\x1b[0m {}", message)),
687
688 Event::PermissionRequest {
689 tool_name, granted, ..
690 } => {
691 if *granted {
692 Some(format!(
693 "\x1b[32m✓\x1b[0m Permission granted for tool '{}'",
694 tool_name
695 ))
696 } else {
697 Some(format!(
698 "\x1b[33m!\x1b[0m Permission denied for tool '{}'",
699 tool_name
700 ))
701 }
702 }
703 }
704}
705
706#[cfg(test)]
707#[path = "output_tests.rs"]
708mod tests;