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 = total.map_or_else(|| progress.to_string(), |total| format!("{progress}/{total}"));
88 let suffix = message.as_deref().map(|message| format!(" - {message}")).unwrap_or_default();
89 Some(format!("Tool progress [{}]: {bar}{suffix}", request.name))
90 }
91 AgentEvent::Tool(ToolEvent::DisplayUpdate { request, meta }) => {
92 Some(format!("Tool progress [{}]: {} - {}", request.name, meta.display.title, meta.display.value))
93 }
94 AgentEvent::Tool(ToolEvent::SubAgentProgress { payload, .. }) => match &payload.event {
95 AgentEvent::SessionUsage(_) => None,
96 event => {
97 format_text(event).map(|text| format!("Sub-agent {} [{}]: {text}", payload.agent_name, payload.task_id))
98 }
99 },
100 AgentEvent::Context(ContextEvent::CompactionStarted { message_count, .. }) => {
101 Some(format!("Context compaction started ({message_count} messages)"))
102 }
103 AgentEvent::Context(ContextEvent::CompactionEnded { outcome, .. }) => Some(match outcome {
104 CompactionOutcome::Completed => "Context compaction completed".to_string(),
105 CompactionOutcome::Failed { error } => format!("Context compaction failed: {error}"),
106 CompactionOutcome::Cancelled => "Context compaction cancelled".to_string(),
107 }),
108 AgentEvent::Context(ContextEvent::CompactionResult { summary, messages_removed, .. }) => {
109 Some(format!("Context compacted: {messages_removed} messages removed. {summary}"))
110 }
111 AgentEvent::Context(ContextEvent::UsageUpdated { usage }) => Some(format_context_usage(usage)),
112 AgentEvent::Context(ContextEvent::Cleared) => Some("Context cleared".to_string()),
113 AgentEvent::SessionUsage(usage) => Some(format_session_usage(usage)),
114 AgentEvent::Turn(
115 TurnEvent::Started { .. }
116 | TurnEvent::LlmCallStarted { .. }
117 | TurnEvent::LlmCallEnded {
118 outcome:
119 LlmCallOutcome::Completed { .. }
120 | LlmCallOutcome::Cancelled
121 | LlmCallOutcome::Failed { will_retry: false, .. },
122 ..
123 },
124 )
125 | AgentEvent::Tool(
126 ToolEvent::InputStarted { .. } | ToolEvent::InputDelta { .. } | ToolEvent::DefinitionsUpdated { .. },
127 )
128 | AgentEvent::Message(MessageEvent::Text { .. } | MessageEvent::Thought { .. }) => None,
129 }
130}
131
132fn format_context_usage(usage: &llm::ContextUsage) -> String {
133 match (usage.context_limit, usage.usage_ratio) {
134 (Some(limit), Some(ratio)) => {
135 format!("Context: {} / {limit} tokens ({:.1}%)", usage.input_tokens, ratio * 100.0)
136 }
137 _ => format!("Context: {} tokens", usage.input_tokens),
138 }
139}
140
141fn format_session_usage(usage: &llm::SessionUsageEvent) -> String {
142 let call_cost =
143 usage.estimated_cost.map_or_else(|| "unknown".to_string(), |cost| format!("${:.6}", cost.total_usd));
144 let totals = &usage.totals;
145 let cumulative_cost = if totals.is_fully_priced() {
146 format!("estimated total: ${:.6}", totals.estimated_usd)
147 } else {
148 format!("known subtotal: ${:.6}, {} unpriced calls", totals.estimated_usd, totals.unpriced_calls)
149 };
150 format!(
151 "Session usage #{} [{}]: {} in, {} out (call cost: {}, cumulative: {} tokens, {})",
152 usage.sequence,
153 usage.source.agent_name,
154 usage.tokens.input_tokens,
155 usage.tokens.output_tokens,
156 call_cost,
157 totals.tokens.total_tokens(),
158 cumulative_cost,
159 )
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use aether_core::events::StreamState;
166
167 #[test]
168 fn format_text_formats_complete_text() {
169 assert_eq!(
170 format_text(&AgentEvent::text("id", "hello world", StreamState::Complete)),
171 Some("hello world".to_string())
172 );
173 }
174
175 #[test]
176 fn format_text_skips_incomplete_text() {
177 assert_eq!(format_text(&AgentEvent::text("id", "partial", StreamState::Partial)), None);
178 }
179
180 #[test]
181 fn format_text_formats_complete_thought() {
182 assert_eq!(
183 format_text(&AgentEvent::thought("id", "reasoning here", StreamState::Complete)),
184 Some("Thought: reasoning here".to_string())
185 );
186 }
187
188 #[test]
189 fn format_text_skips_incomplete_thought() {
190 assert_eq!(format_text(&AgentEvent::thought("id", "partial", StreamState::Partial)), None);
191 }
192
193 #[test]
194 fn format_text_formats_tool_call() {
195 let message = AgentEvent::Tool(ToolEvent::Call {
196 request: llm::ToolCallRequest {
197 id: "tc1".to_string(),
198 name: "bash".to_string(),
199 arguments: r#"{"cmd":"ls"}"#.to_string(),
200 },
201 });
202 assert_eq!(format_text(&message), Some(r#"Tool call: bash({"cmd":"ls"})"#.to_string()));
203 }
204
205 #[test]
206 fn format_text_skips_streamed_tool_input() {
207 let started = AgentEvent::Tool(ToolEvent::InputStarted { id: "tc1".to_string(), name: "bash".to_string() });
208 let delta = AgentEvent::Tool(ToolEvent::InputDelta { id: "tc1".to_string(), chunk: "partial".to_string() });
209 assert_eq!(format_text(&started), None);
210 assert_eq!(format_text(&delta), None);
211 }
212
213 #[test]
214 fn format_text_formats_tool_result() {
215 assert_eq!(format_text(&tool_result()), Some("Tool result [bash]: ok".to_string()));
216 }
217
218 #[test]
219 fn format_text_formats_tool_error() {
220 let message = AgentEvent::Tool(ToolEvent::Error {
221 error: llm::ToolCallError {
222 id: "tc1".to_string(),
223 name: "bash".to_string(),
224 arguments: None,
225 error: "not found".to_string(),
226 },
227 });
228 assert_eq!(format_text(&message), Some("Tool error [bash]: not found".to_string()));
229 }
230
231 #[test]
232 fn format_text_formats_turn_outcomes() {
233 assert_eq!(
234 format_text(&AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::failed("boom") })),
235 Some("Error: boom".to_string())
236 );
237 assert_eq!(format_text(&AgentEvent::turn_ended(TurnOutcome::Cancelled)), Some("Cancelled".to_string()));
238 assert_eq!(format_text(&AgentEvent::turn_ended(TurnOutcome::Completed)), Some("Done".to_string()));
239 }
240
241 #[test]
242 fn format_text_formats_retry_events() {
243 let started = AgentEvent::Turn(TurnEvent::LlmCallStarted {
244 purpose: llm::LlmCallPurpose::Chat,
245 model: llm::ModelIdentity::default(),
246 display_name: "test".to_string(),
247 attempt: 0,
248 max_attempts: 3,
249 });
250 assert_eq!(format_text(&started), None);
251 assert_eq!(format_text(&retry_scheduled()), Some("Retrying (1/3) in 10ms".to_string()));
252 let retrying = AgentEvent::Turn(TurnEvent::LlmCallEnded {
253 purpose: llm::LlmCallPurpose::Chat,
254 outcome: LlmCallOutcome::failed("overloaded", true),
255 });
256 assert_eq!(format_text(&retrying), Some("LLM call failed (will retry): overloaded".to_string()));
257 let terminal = AgentEvent::Turn(TurnEvent::LlmCallEnded {
258 purpose: llm::LlmCallPurpose::Chat,
259 outcome: LlmCallOutcome::failed("boom", false),
260 });
261 assert_eq!(format_text(&terminal), None);
262 }
263
264 #[test]
265 fn format_text_formats_auto_continue_and_model_switch() {
266 let continuing = AgentEvent::Turn(TurnEvent::AutoContinue {
267 attempt: 2,
268 max_attempts: 5,
269 message_id: llm::MessageId::new(),
270 content: vec![],
271 });
272 assert_eq!(format_text(&continuing), Some("Continuing (2/5)...".to_string()));
273 let switched =
274 AgentEvent::Model(ModelEvent::Switched { previous: "old-model".to_string(), new: "new-model".to_string() });
275 assert_eq!(format_text(&switched), Some("Model switched: old-model -> new-model".to_string()));
276 }
277
278 #[test]
279 fn format_text_formats_tool_progress() {
280 assert_eq!(
281 format_text(&tool_progress(50.0, Some(100.0), Some("halfway"))),
282 Some("Tool progress [bash]: 50/100 - halfway".to_string())
283 );
284 assert_eq!(format_text(&tool_progress(42.0, None, None)), Some("Tool progress [bash]: 42".to_string()));
285 }
286
287 #[test]
288 fn format_text_formats_context_events() {
289 let started = AgentEvent::Context(ContextEvent::CompactionStarted {
290 compaction_id: "compaction".into(),
291 message_count: 42,
292 });
293 assert_eq!(format_text(&started), Some("Context compaction started (42 messages)".to_string()));
294 let result = AgentEvent::Context(ContextEvent::CompactionResult {
295 compaction_id: "compaction".into(),
296 message_id: llm::MessageId::new(),
297 summary: "summary here".to_string(),
298 messages_removed: 10,
299 });
300 assert_eq!(format_text(&result), Some("Context compacted: 10 messages removed. summary here".to_string()));
301 assert_eq!(format_text(&usage_update()), Some("Context: 100000 / 200000 tokens (50.0%)".to_string()));
302 assert_eq!(format_text(&AgentEvent::Context(ContextEvent::Cleared)), Some("Context cleared".to_string()));
303 }
304
305 fn tool_result() -> AgentEvent {
306 AgentEvent::Tool(ToolEvent::Result {
307 result: llm::ToolCallResult {
308 id: "tc1".to_string(),
309 name: "bash".to_string(),
310 arguments: "{}".to_string(),
311 result: "ok".to_string(),
312 },
313 result_meta: None,
314 })
315 }
316
317 fn tool_progress(progress: f64, total: Option<f64>, message: Option<&str>) -> AgentEvent {
318 AgentEvent::Tool(ToolEvent::Progress {
319 request: llm::ToolCallRequest {
320 id: "tc1".to_string(),
321 name: "bash".to_string(),
322 arguments: "{}".to_string(),
323 },
324 progress,
325 total,
326 message: message.map(str::to_string),
327 })
328 }
329
330 fn retry_scheduled() -> AgentEvent {
331 AgentEvent::Turn(TurnEvent::RetryScheduled {
332 purpose: llm::LlmCallPurpose::Chat,
333 attempt: 1,
334 max_attempts: 3,
335 delay_ms: 10,
336 })
337 }
338
339 fn usage_update() -> AgentEvent {
340 AgentEvent::Context(ContextEvent::UsageUpdated {
341 usage: llm::ContextUsage {
342 input_tokens: 100_000.into(),
343 context_limit: Some(200_000.into()),
344 usage_ratio: Some(0.5),
345 },
346 })
347 }
348}