Skip to main content

aether_cli/
output.rs

1use aether_core::events::{
2    AgentEvent, CompactionOutcome, ContextEvent, LlmCallOutcome, MessageEvent, ModelEvent, ToolEvent, TurnEvent,
3    TurnOutcome,
4};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8#[derive(Clone, Copy, PartialEq, Eq, Debug, clap::ValueEnum, Deserialize, Serialize, JsonSchema)]
9#[serde(rename_all = "lowercase")]
10pub enum OutputFormat {
11    Text,
12    Pretty,
13    Json,
14}
15
16pub(crate) fn print_message(format: OutputFormat, message: &AgentEvent) -> Result<(), serde_json::Error> {
17    match format {
18        OutputFormat::Text => {
19            if let Some(text) = format_text(message) {
20                if matches!(message, AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Failed { .. } })) {
21                    eprintln!("{text}");
22                } else {
23                    println!("{text}");
24                }
25            }
26        }
27        OutputFormat::Pretty => println!("{}", serde_json::to_string_pretty(message)?),
28        OutputFormat::Json => println!("{}", serde_json::to_string(message)?),
29    }
30
31    Ok(())
32}
33
34fn format_text(message: &AgentEvent) -> Option<String> {
35    match message {
36        AgentEvent::Message(MessageEvent::Text { chunk, is_complete: true, .. }) => Some(chunk.clone()),
37        AgentEvent::Message(MessageEvent::Thought { chunk, is_complete: true, .. }) => {
38            Some(format!("Thought: {chunk}"))
39        }
40        AgentEvent::Tool(ToolEvent::Call { request, .. }) => {
41            Some(format!("Tool call: {}({})", request.name, request.arguments))
42        }
43        AgentEvent::Tool(ToolEvent::Result { result, .. }) => {
44            Some(format!("Tool result [{}]: {}", result.name, result.result))
45        }
46        AgentEvent::Tool(ToolEvent::Error { error, .. }) => {
47            Some(format!("Tool error [{}]: {}", error.name, error.error))
48        }
49        AgentEvent::Tool(ToolEvent::TaskStatus { request, task_id, status, status_message }) => Some(format!(
50            "Task status [{}]: {} {}{}",
51            request.name,
52            task_id,
53            status,
54            status_message.as_deref().map(|message| format!(" - {message}")).unwrap_or_default()
55        )),
56        AgentEvent::Tool(ToolEvent::TaskCreated { request, task_id, .. }) => {
57            Some(format!("Tool deferred [{}]: task {}", request.name, task_id))
58        }
59        AgentEvent::Tool(ToolEvent::TaskCompleted { request, task_id, result, .. }) => {
60            Some(format!("Background task completed [{}]: {}: {}", request.name, task_id, result.result))
61        }
62        AgentEvent::Tool(ToolEvent::TaskFailed { request, task_id, error, .. }) => {
63            Some(format!("Background task failed [{}]: {}: {}", request.name, task_id, error.error))
64        }
65        AgentEvent::Tool(ToolEvent::TaskCancelled { request, task_id, .. }) => {
66            Some(format!("Background task cancelled [{}]: {task_id}", request.name))
67        }
68        AgentEvent::Turn(TurnEvent::Ended { outcome }) => Some(match outcome {
69            TurnOutcome::Completed => "Done".to_string(),
70            TurnOutcome::Cancelled => "Cancelled".to_string(),
71            TurnOutcome::Failed { error } => format!("Error: {error}"),
72        }),
73        AgentEvent::Turn(TurnEvent::AutoContinue { attempt, max_attempts, .. }) => {
74            Some(format!("Continuing ({attempt}/{max_attempts})..."))
75        }
76        AgentEvent::Turn(event @ TurnEvent::RetryScheduled { .. }) => event
77            .retry_info()
78            .map(|retry| format!("Retrying ({}/{}) in {}ms", retry.attempt, retry.max_attempts, retry.delay_ms)),
79        AgentEvent::Turn(TurnEvent::LlmCallEnded {
80            outcome: LlmCallOutcome::Failed { error, will_retry: true, .. },
81            ..
82        }) => Some(format!("LLM call failed (will retry): {error}")),
83        AgentEvent::Model(ModelEvent::Switched { previous, new }) => {
84            Some(format!("Model switched: {previous} -> {new}"))
85        }
86        AgentEvent::Tool(ToolEvent::Progress { request, progress, total, message }) => {
87            let bar = match total {
88                Some(total) => format!("{progress}/{total}"),
89                None => format!("{progress}"),
90            };
91            let suffix = message.as_deref().map(|message| format!(" - {message}")).unwrap_or_default();
92            Some(format!("Tool progress [{}]: {bar}{suffix}", request.name))
93        }
94        AgentEvent::Tool(ToolEvent::DisplayUpdate { request, meta }) => {
95            Some(format!("Tool progress [{}]: {} - {}", request.name, meta.display.title, meta.display.value))
96        }
97        AgentEvent::Tool(ToolEvent::SubAgentProgress { payload, .. }) => match &payload.event {
98            AgentEvent::SessionUsage(_) => None,
99            event => {
100                format_text(event).map(|text| format!("Sub-agent {} [{}]: {text}", payload.agent_name, payload.task_id))
101            }
102        },
103        AgentEvent::Context(ContextEvent::CompactionStarted { message_count, .. }) => {
104            Some(format!("Context compaction started ({message_count} messages)"))
105        }
106        AgentEvent::Context(ContextEvent::CompactionEnded { outcome, .. }) => Some(match outcome {
107            CompactionOutcome::Completed => "Context compaction completed".to_string(),
108            CompactionOutcome::Failed { error } => format!("Context compaction failed: {error}"),
109            CompactionOutcome::Cancelled => "Context compaction cancelled".to_string(),
110        }),
111        AgentEvent::Context(ContextEvent::CompactionResult { summary, messages_removed, .. }) => {
112            Some(format!("Context compacted: {messages_removed} messages removed. {summary}"))
113        }
114        AgentEvent::Context(ContextEvent::UsageUpdated { usage }) => Some(format_context_usage(usage)),
115        AgentEvent::Context(ContextEvent::Cleared) => Some("Context cleared".to_string()),
116        AgentEvent::SessionUsage(usage) => Some(format_session_usage(usage)),
117        AgentEvent::Turn(
118            TurnEvent::Started { .. }
119            | TurnEvent::LlmCallStarted { .. }
120            | TurnEvent::LlmCallEnded {
121                outcome:
122                    LlmCallOutcome::Completed { .. }
123                    | LlmCallOutcome::Cancelled
124                    | LlmCallOutcome::Failed { will_retry: false, .. },
125                ..
126            },
127        )
128        | AgentEvent::Tool(
129            ToolEvent::ExecutionStarted { .. } | ToolEvent::DefinitionsUpdated { .. } | ToolEvent::CallUpdate { .. },
130        )
131        | AgentEvent::Message(MessageEvent::Text { .. } | MessageEvent::Thought { .. }) => None,
132    }
133}
134
135fn format_context_usage(usage: &llm::ContextUsage) -> String {
136    match (usage.context_limit, usage.usage_ratio) {
137        (Some(limit), Some(ratio)) => {
138            format!("Context: {} / {limit} tokens ({:.1}%)", usage.input_tokens, ratio * 100.0)
139        }
140        _ => format!("Context: {} tokens", usage.input_tokens),
141    }
142}
143
144fn format_session_usage(usage: &llm::SessionUsageEvent) -> String {
145    let call_cost =
146        usage.estimated_cost.map_or_else(|| "unknown".to_string(), |cost| format!("${:.6}", cost.total_usd));
147    let totals = &usage.totals;
148    let cumulative_cost = if totals.is_fully_priced() {
149        format!("estimated total: ${:.6}", totals.estimated_usd)
150    } else {
151        format!("known subtotal: ${:.6}, {} unpriced calls", totals.estimated_usd, totals.unpriced_calls)
152    };
153    format!(
154        "Session usage #{} [{}]: {} in, {} out (call cost: {}, cumulative: {} tokens, {})",
155        usage.sequence,
156        usage.source.agent_name,
157        usage.tokens.input_tokens,
158        usage.tokens.output_tokens,
159        call_cost,
160        totals.tokens.total_tokens(),
161        cumulative_cost,
162    )
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use aether_core::events::StreamState;
169
170    #[test]
171    fn format_text_formats_complete_text() {
172        assert_eq!(
173            format_text(&AgentEvent::text("id", "hello world", StreamState::Complete)),
174            Some("hello world".to_string())
175        );
176    }
177
178    #[test]
179    fn format_text_skips_incomplete_text() {
180        assert_eq!(format_text(&AgentEvent::text("id", "partial", StreamState::Partial)), None);
181    }
182
183    #[test]
184    fn format_text_formats_complete_thought() {
185        assert_eq!(
186            format_text(&AgentEvent::thought("id", "reasoning here", StreamState::Complete)),
187            Some("Thought: reasoning here".to_string())
188        );
189    }
190
191    #[test]
192    fn format_text_skips_incomplete_thought() {
193        assert_eq!(format_text(&AgentEvent::thought("id", "partial", StreamState::Partial)), None);
194    }
195
196    #[test]
197    fn format_text_formats_tool_call() {
198        let message = AgentEvent::Tool(ToolEvent::Call {
199            request: llm::ToolCallRequest {
200                id: "tc1".to_string(),
201                name: "bash".to_string(),
202                arguments: r#"{"cmd":"ls"}"#.to_string(),
203            },
204        });
205        assert_eq!(format_text(&message), Some(r#"Tool call: bash({"cmd":"ls"})"#.to_string()));
206    }
207
208    #[test]
209    fn format_text_skips_tool_call_updates() {
210        let message =
211            AgentEvent::Tool(ToolEvent::CallUpdate { tool_call_id: "tc1".to_string(), chunk: "partial".to_string() });
212        assert_eq!(format_text(&message), None);
213    }
214
215    #[test]
216    fn format_text_formats_tool_result() {
217        assert_eq!(format_text(&tool_result()), Some("Tool result [bash]: ok".to_string()));
218    }
219
220    #[test]
221    fn format_text_formats_tool_error() {
222        let message = AgentEvent::Tool(ToolEvent::Error {
223            error: llm::ToolCallError {
224                id: "tc1".to_string(),
225                name: "bash".to_string(),
226                arguments: None,
227                error: "not found".to_string(),
228            },
229        });
230        assert_eq!(format_text(&message), Some("Tool error [bash]: not found".to_string()));
231    }
232
233    #[test]
234    fn format_text_formats_turn_outcomes() {
235        assert_eq!(
236            format_text(&AgentEvent::Turn(TurnEvent::Ended {
237                outcome: TurnOutcome::Failed { error: "boom".to_string() }
238            })),
239            Some("Error: boom".to_string())
240        );
241        assert_eq!(format_text(&AgentEvent::turn_ended(TurnOutcome::Cancelled)), Some("Cancelled".to_string()));
242        assert_eq!(format_text(&AgentEvent::turn_ended(TurnOutcome::Completed)), Some("Done".to_string()));
243    }
244
245    #[test]
246    fn format_text_formats_retry_events() {
247        let started = AgentEvent::Turn(TurnEvent::LlmCallStarted {
248            purpose: llm::LlmCallPurpose::Chat,
249            model: llm::ModelIdentity::default(),
250            display_name: "test".to_string(),
251            attempt: 0,
252            max_attempts: 3,
253        });
254        assert_eq!(format_text(&started), None);
255        assert_eq!(format_text(&retry_scheduled()), Some("Retrying (1/3) in 10ms".to_string()));
256        let retrying = AgentEvent::Turn(TurnEvent::LlmCallEnded {
257            purpose: llm::LlmCallPurpose::Chat,
258            outcome: LlmCallOutcome::failed("overloaded", true),
259        });
260        assert_eq!(format_text(&retrying), Some("LLM call failed (will retry): overloaded".to_string()));
261        let terminal = AgentEvent::Turn(TurnEvent::LlmCallEnded {
262            purpose: llm::LlmCallPurpose::Chat,
263            outcome: LlmCallOutcome::failed("boom", false),
264        });
265        assert_eq!(format_text(&terminal), None);
266    }
267
268    #[test]
269    fn format_text_formats_auto_continue_and_model_switch() {
270        let continuing = AgentEvent::Turn(TurnEvent::AutoContinue {
271            attempt: 2,
272            max_attempts: 5,
273            message_id: llm::MessageId::new(),
274            content: vec![],
275        });
276        assert_eq!(format_text(&continuing), Some("Continuing (2/5)...".to_string()));
277        let switched =
278            AgentEvent::Model(ModelEvent::Switched { previous: "old-model".to_string(), new: "new-model".to_string() });
279        assert_eq!(format_text(&switched), Some("Model switched: old-model -> new-model".to_string()));
280    }
281
282    #[test]
283    fn format_text_formats_tool_progress() {
284        assert_eq!(
285            format_text(&tool_progress(50.0, Some(100.0), Some("halfway"))),
286            Some("Tool progress [bash]: 50/100 - halfway".to_string())
287        );
288        assert_eq!(format_text(&tool_progress(42.0, None, None)), Some("Tool progress [bash]: 42".to_string()));
289    }
290
291    #[test]
292    fn format_text_formats_context_events() {
293        let started = AgentEvent::Context(ContextEvent::CompactionStarted {
294            compaction_id: "compaction".into(),
295            message_count: 42,
296        });
297        assert_eq!(format_text(&started), Some("Context compaction started (42 messages)".to_string()));
298        let result = AgentEvent::Context(ContextEvent::CompactionResult {
299            compaction_id: "compaction".into(),
300            message_id: llm::MessageId::new(),
301            summary: "summary here".to_string(),
302            messages_removed: 10,
303        });
304        assert_eq!(format_text(&result), Some("Context compacted: 10 messages removed. summary here".to_string()));
305        assert_eq!(format_text(&usage_update()), Some("Context: 100000 / 200000 tokens (50.0%)".to_string()));
306        assert_eq!(format_text(&AgentEvent::Context(ContextEvent::Cleared)), Some("Context cleared".to_string()));
307    }
308
309    fn tool_result() -> AgentEvent {
310        AgentEvent::Tool(ToolEvent::Result {
311            result: llm::ToolCallResult {
312                id: "tc1".to_string(),
313                name: "bash".to_string(),
314                arguments: "{}".to_string(),
315                result: "ok".to_string(),
316            },
317            result_meta: None,
318        })
319    }
320
321    fn tool_progress(progress: f64, total: Option<f64>, message: Option<&str>) -> AgentEvent {
322        AgentEvent::Tool(ToolEvent::Progress {
323            request: llm::ToolCallRequest {
324                id: "tc1".to_string(),
325                name: "bash".to_string(),
326                arguments: "{}".to_string(),
327            },
328            progress,
329            total,
330            message: message.map(str::to_string),
331        })
332    }
333
334    fn retry_scheduled() -> AgentEvent {
335        AgentEvent::Turn(TurnEvent::RetryScheduled {
336            purpose: llm::LlmCallPurpose::Chat,
337            attempt: 1,
338            max_attempts: 3,
339            delay_ms: 10,
340        })
341    }
342
343    fn usage_update() -> AgentEvent {
344        AgentEvent::Context(ContextEvent::UsageUpdated {
345            usage: llm::ContextUsage {
346                input_tokens: 100_000.into(),
347                context_limit: Some(200_000.into()),
348                usage_ratio: Some(0.5),
349            },
350        })
351    }
352}