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