1use aether_core::core::{AgentDeps, Prompt};
2use aether_core::events::{
3 AgentEvent, Command, ContextEvent, LlmCallOutcome, MessageEvent, ModelEvent, ToolEvent, TurnEvent, TurnOutcome,
4};
5use aether_core::mcp::run_mcp_task::McpCommand;
6use aether_telemetry::TelemetryRuntime;
7use std::io;
8use std::process::ExitCode;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11use tracing::error;
12
13use crate::telemetry::build_telemetry_runtime;
14
15use super::error::CliError;
16use super::{CliEventKind, RunConfig};
17use crate::output::OutputFormat;
18use crate::runtime::RuntimeBuilder;
19use crate::slash_commands::{expand_slash_command, parse_slash_command};
20
21pub async fn run(config: RunConfig) -> Result<ExitCode, CliError> {
22 setup_tracing(config.verbose);
23
24 let telemetry = build_telemetry_runtime(config.telemetry.as_ref())?;
25 let result = run_agent(config, telemetry.clone()).await;
26
27 if let Some(telemetry) = telemetry {
28 telemetry.shutdown_or_log();
29 }
30 result
31}
32
33async fn run_agent(config: RunConfig, telemetry: Option<Arc<TelemetryRuntime>>) -> Result<ExitCode, CliError> {
34 let mut spec = config.spec;
35 if let Some(system_prompt) = config.system_prompt {
36 spec.prompts.push(Prompt::text(&system_prompt));
37 }
38
39 let deps =
40 AgentDeps::new(config.oauth_credential_store, telemetry.as_ref().map(|runtime| runtime.observer_factory()));
41 let (agent, _mcp_snapshot) = RuntimeBuilder::from_spec(config.cwd.clone(), spec)
42 .mcp_sources(config.mcp_config_sources)
43 .agent_deps(deps)
44 .build_ready(vec![])
45 .await?;
46
47 let prompt = expand_prompt(&agent.mcp_tx, config.prompt).await;
48
49 agent
50 .agent_tx
51 .send(Command::text(&prompt))
52 .await
53 .map_err(|e| CliError::AgentError(format!("Failed to send prompt: {e}")))?;
54
55 let exit_code = stream_output(agent.agent_rx, config.output, &config.events).await;
56
57 drop(agent.agent_tx);
58 agent.agent_handle.await_completion().await;
59
60 Ok(exit_code)
61}
62
63async fn expand_prompt(mcp_tx: &mpsc::Sender<McpCommand>, prompt: String) -> String {
64 let Some(slash_command) = parse_slash_command(&prompt) else {
65 return prompt;
66 };
67
68 match expand_slash_command(mcp_tx, slash_command.command_name, slash_command.args_text).await {
69 Ok(expanded) => expanded,
70 Err(error) => {
71 error!("Failed to expand slash command: {error}");
72 prompt
73 }
74 }
75}
76
77async fn stream_output(mut rx: mpsc::Receiver<AgentEvent>, format: OutputFormat, events: &[CliEventKind]) -> ExitCode {
78 while let Some(msg) = rx.recv().await {
79 if should_emit(&msg, events)
80 && let Err(error) = print_message(format, &msg)
81 {
82 eprintln!("Failed to serialize headless event: {error}");
83 return ExitCode::FAILURE;
84 }
85
86 if let Some(outcome) = msg.turn_outcome() {
87 return match outcome {
88 TurnOutcome::Failed { .. } => ExitCode::FAILURE,
89 TurnOutcome::Completed | TurnOutcome::Cancelled => ExitCode::SUCCESS,
90 };
91 }
92 }
93 ExitCode::SUCCESS
94}
95
96fn print_message(format: OutputFormat, msg: &AgentEvent) -> Result<(), serde_json::Error> {
97 match format {
98 OutputFormat::Text => {
99 if let Some(text) = format_text(msg) {
100 if matches!(msg, AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Failed { .. } })) {
101 eprintln!("{text}");
102 } else {
103 println!("{text}");
104 }
105 }
106 }
107 OutputFormat::Pretty => println!("{}", serde_json::to_string_pretty(msg)?),
108 OutputFormat::Json => println!("{}", serde_json::to_string(msg)?),
109 }
110
111 Ok(())
112}
113
114fn should_emit(msg: &AgentEvent, include: &[CliEventKind]) -> bool {
115 let Some(kind) = event_kind(msg) else { return false };
116 include.is_empty() || include.contains(&kind)
117}
118
119fn event_kind(msg: &AgentEvent) -> Option<CliEventKind> {
120 match msg {
121 AgentEvent::Message(MessageEvent::Text { is_complete: true, .. }) => Some(CliEventKind::Text),
122 AgentEvent::Message(MessageEvent::Thought { is_complete: true, .. }) => Some(CliEventKind::Thought),
123 AgentEvent::Tool(ToolEvent::Call { .. }) => Some(CliEventKind::ToolCall),
124 AgentEvent::Tool(ToolEvent::Result { .. }) => Some(CliEventKind::ToolResult),
125 AgentEvent::Tool(ToolEvent::Error { .. }) => Some(CliEventKind::ToolError),
126 AgentEvent::Turn(TurnEvent::AutoContinue { .. }) => Some(CliEventKind::AutoContinue),
127 AgentEvent::Model(ModelEvent::Switched { .. }) => Some(CliEventKind::ModelSwitched),
128 AgentEvent::Tool(ToolEvent::Progress { .. }) => Some(CliEventKind::ToolProgress),
129 AgentEvent::Context(ContextEvent::CompactionStarted { .. }) => Some(CliEventKind::ContextCompactionStarted),
130 AgentEvent::Context(ContextEvent::CompactionResult { .. }) => Some(CliEventKind::ContextCompactionResult),
131 AgentEvent::Context(ContextEvent::UsageUpdated { .. }) => Some(CliEventKind::ContextUsage),
132 AgentEvent::Context(ContextEvent::Cleared) => Some(CliEventKind::ContextCleared),
133 AgentEvent::Turn(TurnEvent::Started { .. }) => Some(CliEventKind::TurnStarted),
134 AgentEvent::Turn(TurnEvent::Ended { .. }) => Some(CliEventKind::TurnEnded),
135 AgentEvent::Turn(TurnEvent::RetryScheduled { .. }) => Some(CliEventKind::LlmRetryScheduled),
136 AgentEvent::Turn(TurnEvent::LlmCallStarted { .. }) => Some(CliEventKind::LlmCallStarted),
137 AgentEvent::Turn(TurnEvent::LlmCallEnded { .. }) => Some(CliEventKind::LlmCallEnded),
138 AgentEvent::Tool(ToolEvent::ExecutionStarted { .. }) => Some(CliEventKind::ToolExecutionStarted),
139 AgentEvent::Tool(ToolEvent::DefinitionsUpdated { .. }) => Some(CliEventKind::ToolDefinitionsUpdated),
140 AgentEvent::Message(
141 MessageEvent::Text { is_complete: false, .. } | MessageEvent::Thought { is_complete: false, .. },
142 )
143 | AgentEvent::Tool(ToolEvent::CallUpdate { .. }) => None,
144 }
145}
146
147fn format_text(msg: &AgentEvent) -> Option<String> {
148 match msg {
149 AgentEvent::Message(MessageEvent::Text { chunk, is_complete: true, .. }) => Some(chunk.clone()),
150
151 AgentEvent::Message(MessageEvent::Thought { chunk, is_complete: true, .. }) => {
152 Some(format!("Thought: {chunk}"))
153 }
154
155 AgentEvent::Tool(ToolEvent::Call { request, .. }) => {
156 Some(format!("Tool call: {}({})", request.name, request.arguments))
157 }
158
159 AgentEvent::Tool(ToolEvent::Result { result, .. }) => {
160 Some(format!("Tool result [{}]: {}", result.name, result.result))
161 }
162
163 AgentEvent::Tool(ToolEvent::Error { error, .. }) => {
164 Some(format!("Tool error [{}]: {}", error.name, error.error))
165 }
166
167 AgentEvent::Turn(TurnEvent::Ended { outcome }) => Some(match outcome {
168 TurnOutcome::Completed => "Done".to_string(),
169 TurnOutcome::Cancelled => "Cancelled".to_string(),
170 TurnOutcome::Failed { error } => format!("Error: {error}"),
171 }),
172
173 AgentEvent::Turn(TurnEvent::AutoContinue { attempt, max_attempts }) => {
174 Some(format!("Continuing ({attempt}/{max_attempts})..."))
175 }
176
177 AgentEvent::Turn(event @ TurnEvent::RetryScheduled { .. }) => event
178 .retry_info()
179 .map(|retry| format!("Retrying ({}/{}) in {}ms", retry.attempt, retry.max_attempts, retry.delay_ms)),
180
181 AgentEvent::Turn(TurnEvent::LlmCallEnded {
182 outcome: LlmCallOutcome::Failed { error, will_retry: true },
183 ..
184 }) => Some(format!("LLM call failed (will retry): {error}")),
185
186 AgentEvent::Model(ModelEvent::Switched { previous, new }) => {
187 Some(format!("Model switched: {previous} -> {new}"))
188 }
189
190 AgentEvent::Tool(ToolEvent::Progress { request, progress, total, message }) => {
191 let bar = match total {
192 Some(t) => format!("{progress}/{t}"),
193 None => format!("{progress}"),
194 };
195 let suffix = message.as_deref().map(|m| format!(" - {m}")).unwrap_or_default();
196 Some(format!("Tool progress [{}]: {bar}{suffix}", request.name))
197 }
198
199 AgentEvent::Context(ContextEvent::CompactionStarted { message_count }) => {
200 Some(format!("Context compaction started ({message_count} messages)"))
201 }
202
203 AgentEvent::Context(ContextEvent::CompactionResult { summary, messages_removed }) => {
204 Some(format!("Context compacted: {messages_removed} messages removed. {summary}"))
205 }
206
207 AgentEvent::Context(ContextEvent::UsageUpdated { usage }) => Some(format!(
208 "Tokens: {} in, {} out (total: {} in, {} out)",
209 usage.input_tokens, usage.output_tokens, usage.total_input_tokens, usage.total_output_tokens
210 )),
211
212 AgentEvent::Context(ContextEvent::Cleared) => Some("Context cleared".to_string()),
213
214 AgentEvent::Turn(
215 TurnEvent::Started { .. }
216 | TurnEvent::LlmCallStarted { .. }
217 | TurnEvent::LlmCallEnded {
218 outcome:
219 LlmCallOutcome::Completed { .. }
220 | LlmCallOutcome::Cancelled
221 | LlmCallOutcome::Failed { will_retry: false, .. },
222 ..
223 },
224 )
225 | AgentEvent::Tool(
226 ToolEvent::ExecutionStarted { .. } | ToolEvent::DefinitionsUpdated { .. } | ToolEvent::CallUpdate { .. },
227 )
228 | AgentEvent::Message(MessageEvent::Text { .. } | MessageEvent::Thought { .. }) => None,
229 }
230}
231
232fn setup_tracing(verbose: bool) {
233 use tracing_subscriber::Layer;
234 use tracing_subscriber::filter::EnvFilter;
235 use tracing_subscriber::fmt;
236 use tracing_subscriber::layer::SubscriberExt;
237 use tracing_subscriber::util::SubscriberInitExt;
238
239 let filter = if verbose { EnvFilter::new("debug,agent=off") } else { EnvFilter::new("error,agent=off") };
240 let layer = fmt::layer().with_writer(io::stderr).with_filter(filter);
241
242 tracing_subscriber::registry().with(layer).init();
243}
244
245#[cfg(test)]
246mod tests {
247 use aether_core::events::ContextUsage;
248
249 use super::*;
250
251 #[test]
252 fn format_text_formats_complete_text() {
253 assert_eq!(format_text(&AgentEvent::text("id", "hello world", true)), Some("hello world".to_string()));
254 }
255
256 #[test]
257 fn format_text_skips_incomplete_text() {
258 assert_eq!(format_text(&AgentEvent::text("id", "partial", false)), None);
259 }
260
261 #[test]
262 fn format_text_formats_complete_thought() {
263 assert_eq!(
264 format_text(&AgentEvent::thought("id", "reasoning here", true)),
265 Some("Thought: reasoning here".to_string())
266 );
267 }
268
269 #[test]
270 fn format_text_skips_incomplete_thought() {
271 assert_eq!(format_text(&AgentEvent::thought("id", "partial", false)), None);
272 }
273
274 #[test]
275 fn format_text_formats_tool_call() {
276 let msg = AgentEvent::Tool(ToolEvent::Call {
277 request: llm::ToolCallRequest {
278 id: "tc1".to_string(),
279 name: "bash".to_string(),
280 arguments: r#"{"cmd":"ls"}"#.to_string(),
281 },
282 });
283 assert_eq!(format_text(&msg), Some(r#"Tool call: bash({"cmd":"ls"})"#.to_string()));
284 }
285
286 #[test]
287 fn format_text_skips_tool_call_updates() {
288 let msg =
289 AgentEvent::Tool(ToolEvent::CallUpdate { tool_call_id: "tc1".to_string(), chunk: "partial".to_string() });
290 assert_eq!(format_text(&msg), None);
291 }
292
293 #[test]
294 fn format_text_formats_tool_result() {
295 assert_eq!(format_text(&tool_result_msg()), Some("Tool result [bash]: ok".to_string()));
296 }
297
298 #[test]
299 fn format_text_formats_tool_error() {
300 let msg = AgentEvent::Tool(ToolEvent::Error {
301 error: llm::ToolCallError {
302 id: "tc1".to_string(),
303 name: "bash".to_string(),
304 arguments: None,
305 error: "not found".to_string(),
306 },
307 });
308 assert_eq!(format_text(&msg), Some("Tool error [bash]: not found".to_string()));
309 }
310
311 #[test]
312 fn format_text_formats_failed_turn() {
313 let msg = AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Failed { error: "boom".to_string() } });
314 assert_eq!(format_text(&msg), Some("Error: boom".to_string()));
315 }
316
317 #[test]
318 fn format_text_formats_cancelled_turn() {
319 let msg = AgentEvent::turn_ended(TurnOutcome::Cancelled);
320 assert_eq!(format_text(&msg), Some("Cancelled".to_string()));
321 }
322
323 #[test]
324 fn format_text_formats_retry_schedule() {
325 let msg = retry_scheduled(1, 10);
326 assert_eq!(format_text(&msg), Some("Retrying (1/3) in 10ms".to_string()));
327 }
328
329 #[test]
330 fn format_text_formats_llm_call_failure_that_will_retry() {
331 let msg = AgentEvent::Turn(TurnEvent::LlmCallEnded {
332 purpose: aether_core::events::LlmCallPurpose::Chat,
333 outcome: LlmCallOutcome::Failed { error: "overloaded".to_string(), will_retry: true },
334 });
335 assert_eq!(format_text(&msg), Some("LLM call failed (will retry): overloaded".to_string()));
336 }
337
338 #[test]
339 fn format_text_skips_terminal_llm_call_failure() {
340 let msg = AgentEvent::Turn(TurnEvent::LlmCallEnded {
341 purpose: aether_core::events::LlmCallPurpose::Chat,
342 outcome: LlmCallOutcome::Failed { error: "boom".to_string(), will_retry: false },
343 });
344 assert_eq!(format_text(&msg), None);
345 }
346
347 #[test]
348 fn format_text_skips_first_call_start() {
349 assert_eq!(format_text(&llm_call_started(0)), None);
350 }
351
352 #[test]
353 fn format_text_formats_auto_continue() {
354 let msg = AgentEvent::Turn(TurnEvent::AutoContinue { attempt: 2, max_attempts: 5 });
355 assert_eq!(format_text(&msg), Some("Continuing (2/5)...".to_string()));
356 }
357
358 #[test]
359 fn format_text_formats_model_switched() {
360 let msg =
361 AgentEvent::Model(ModelEvent::Switched { previous: "old-model".to_string(), new: "new-model".to_string() });
362 assert_eq!(format_text(&msg), Some("Model switched: old-model -> new-model".to_string()));
363 }
364
365 #[test]
366 fn format_text_renders_completed_turn() {
367 assert_eq!(format_text(&AgentEvent::turn_ended(TurnOutcome::Completed)), Some("Done".to_string()));
368 }
369
370 #[test]
371 fn format_text_formats_tool_progress_with_total() {
372 let msg = tool_progress(50.0, Some(100.0), Some("halfway"));
373 assert_eq!(format_text(&msg), Some("Tool progress [bash]: 50/100 - halfway".to_string()));
374 }
375
376 #[test]
377 fn format_text_formats_tool_progress_without_total() {
378 let msg = tool_progress(42.0, None, None);
379 assert_eq!(format_text(&msg), Some("Tool progress [bash]: 42".to_string()));
380 }
381
382 #[test]
383 fn format_text_formats_context_compaction_started() {
384 let msg = AgentEvent::Context(ContextEvent::CompactionStarted { message_count: 42 });
385 assert_eq!(format_text(&msg), Some("Context compaction started (42 messages)".to_string()));
386 }
387
388 #[test]
389 fn format_text_formats_context_compaction_result() {
390 let msg = AgentEvent::Context(ContextEvent::CompactionResult {
391 summary: "summary here".to_string(),
392 messages_removed: 10,
393 });
394 assert_eq!(format_text(&msg), Some("Context compacted: 10 messages removed. summary here".to_string()));
395 }
396
397 #[test]
398 fn format_text_formats_context_usage_update() {
399 assert_eq!(
400 format_text(&usage_update()),
401 Some("Tokens: 1500 in, 250 out (total: 5000 in, 800 out)".to_string())
402 );
403 }
404
405 #[test]
406 fn format_text_formats_context_cleared() {
407 assert_eq!(format_text(&AgentEvent::Context(ContextEvent::Cleared)), Some("Context cleared".to_string()));
408 }
409
410 #[test]
411 fn event_kind_none_for_non_output_fragments() {
412 assert_eq!(event_kind(&AgentEvent::text("id", "x", false)), None);
413 assert_eq!(event_kind(&AgentEvent::thought("id", "x", false)), None);
414 assert_eq!(
415 event_kind(&AgentEvent::Tool(ToolEvent::CallUpdate {
416 tool_call_id: "tc1".to_string(),
417 chunk: "x".to_string(),
418 })),
419 None,
420 );
421 }
422
423 #[test]
424 fn event_kind_turn_ended_is_filterable() {
425 assert_eq!(event_kind(&AgentEvent::turn_ended(TurnOutcome::Completed)), Some(CliEventKind::TurnEnded));
426 }
427
428 #[test]
429 fn should_emit_empty_filter_rejects_non_output_events() {
430 assert!(should_emit(&tool_call_msg(), &[]));
431 assert!(should_emit(
432 &AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Failed { error: "e".to_string() } }),
433 &[]
434 ));
435 assert!(should_emit(&AgentEvent::turn_ended(TurnOutcome::Completed), &[]));
436 assert!(!should_emit(&AgentEvent::text("id", "x", false), &[]));
437 assert!(!should_emit(
438 &AgentEvent::Tool(ToolEvent::CallUpdate { tool_call_id: "tc1".to_string(), chunk: "x".to_string() }),
439 &[],
440 ));
441 }
442
443 #[test]
444 fn should_emit_single_type_whitelist() {
445 let filter = &[CliEventKind::ToolCall];
446 assert!(should_emit(&tool_call_msg(), filter));
447 assert!(!should_emit(&tool_result_msg(), filter));
448 assert!(!should_emit(&AgentEvent::turn_ended(TurnOutcome::Completed), filter));
449 }
450
451 #[test]
452 fn should_emit_multi_type_whitelist() {
453 let filter = &[CliEventKind::ToolCall, CliEventKind::ToolResult];
454 assert!(should_emit(&tool_call_msg(), filter));
455 assert!(should_emit(&tool_result_msg(), filter));
456 assert!(!should_emit(&AgentEvent::turn_ended(TurnOutcome::Completed), filter));
457 }
458
459 #[test]
460 fn should_emit_turn_ended_respects_filter() {
461 let msg = AgentEvent::turn_ended(TurnOutcome::Completed);
462 assert!(should_emit(&msg, &[CliEventKind::TurnEnded]));
463 assert!(!should_emit(&msg, &[CliEventKind::ToolCall]));
464 }
465
466 #[test]
467 fn event_kind_covers_every_cli_event_kind() {
468 use clap::ValueEnum;
469
470 let samples = vec![
471 (AgentEvent::text("id", "x", true), CliEventKind::Text),
472 (AgentEvent::thought("id", "x", true), CliEventKind::Thought),
473 (tool_call_msg(), CliEventKind::ToolCall),
474 (tool_result_msg(), CliEventKind::ToolResult),
475 (
476 AgentEvent::Tool(ToolEvent::Error {
477 error: llm::ToolCallError {
478 id: "tc1".to_string(),
479 name: "bash".to_string(),
480 arguments: None,
481 error: "boom".to_string(),
482 },
483 }),
484 CliEventKind::ToolError,
485 ),
486 (AgentEvent::Turn(TurnEvent::AutoContinue { attempt: 1, max_attempts: 3 }), CliEventKind::AutoContinue),
487 (
488 AgentEvent::Model(ModelEvent::Switched { previous: "a".to_string(), new: "b".to_string() }),
489 CliEventKind::ModelSwitched,
490 ),
491 (tool_progress(1.0, None, None), CliEventKind::ToolProgress),
492 (
493 AgentEvent::Context(ContextEvent::CompactionStarted { message_count: 1 }),
494 CliEventKind::ContextCompactionStarted,
495 ),
496 (
497 AgentEvent::Context(ContextEvent::CompactionResult { summary: "s".to_string(), messages_removed: 1 }),
498 CliEventKind::ContextCompactionResult,
499 ),
500 (usage_update(), CliEventKind::ContextUsage),
501 (AgentEvent::Context(ContextEvent::Cleared), CliEventKind::ContextCleared),
502 (AgentEvent::Turn(TurnEvent::Started { content: vec![] }), CliEventKind::TurnStarted),
503 (AgentEvent::turn_ended(TurnOutcome::Completed), CliEventKind::TurnEnded),
504 (retry_scheduled(1, 10), CliEventKind::LlmRetryScheduled),
505 (llm_call_started(0), CliEventKind::LlmCallStarted),
506 (
507 AgentEvent::Turn(TurnEvent::LlmCallEnded {
508 purpose: aether_core::events::LlmCallPurpose::Chat,
509 outcome: aether_core::events::LlmCallOutcome::Cancelled,
510 }),
511 CliEventKind::LlmCallEnded,
512 ),
513 (
514 AgentEvent::Tool(ToolEvent::ExecutionStarted {
515 tool_id: "tc1".to_string(),
516 tool_name: "bash".to_string(),
517 }),
518 CliEventKind::ToolExecutionStarted,
519 ),
520 (AgentEvent::Tool(ToolEvent::DefinitionsUpdated { tools: vec![] }), CliEventKind::ToolDefinitionsUpdated),
521 ];
522
523 for kind in CliEventKind::value_variants() {
524 assert!(samples.iter().any(|(_, k)| k == kind), "samples is missing a case for {kind:?}");
525 }
526
527 for (msg, kind) in &samples {
528 assert_eq!(event_kind(msg), Some(*kind), "event_kind disagrees for {kind:?}");
529 }
530 }
531
532 #[tokio::test]
533 async fn stream_output_turn_ended_breaks_loop_under_filter() {
534 let (tx, rx) = mpsc::channel(4);
535 tx.send(AgentEvent::turn_ended(TurnOutcome::Completed)).await.unwrap();
536 let filter = vec![CliEventKind::ToolCall];
537 let code = stream_output(rx, OutputFormat::Text, &filter).await;
538 assert_eq!(code, ExitCode::SUCCESS);
539 }
540
541 #[tokio::test]
542 async fn stream_output_failed_turn_exits_with_failure() {
543 let (tx, rx) = mpsc::channel(4);
544 tx.send(AgentEvent::turn_ended(TurnOutcome::Failed { error: "boom".to_string() })).await.unwrap();
545 let code = stream_output(rx, OutputFormat::Text, &[]).await;
546 assert_eq!(code, ExitCode::FAILURE);
547 }
548
549 fn tool_call_msg() -> AgentEvent {
550 AgentEvent::Tool(ToolEvent::Call {
551 request: llm::ToolCallRequest {
552 id: "tc1".to_string(),
553 name: "bash".to_string(),
554 arguments: "{}".to_string(),
555 },
556 })
557 }
558
559 fn tool_result_msg() -> AgentEvent {
560 AgentEvent::Tool(ToolEvent::Result {
561 result: llm::ToolCallResult {
562 id: "tc1".to_string(),
563 name: "bash".to_string(),
564 arguments: "{}".to_string(),
565 result: "ok".to_string(),
566 },
567 result_meta: None,
568 })
569 }
570
571 fn tool_progress(progress: f64, total: Option<f64>, message: Option<&str>) -> AgentEvent {
572 AgentEvent::Tool(ToolEvent::Progress {
573 request: llm::ToolCallRequest {
574 id: "tc1".to_string(),
575 name: "bash".to_string(),
576 arguments: "{}".to_string(),
577 },
578 progress,
579 total,
580 message: message.map(str::to_string),
581 })
582 }
583
584 fn retry_scheduled(attempt: u32, delay_ms: u64) -> AgentEvent {
585 AgentEvent::Turn(TurnEvent::RetryScheduled {
586 purpose: aether_core::events::LlmCallPurpose::Chat,
587 attempt,
588 max_attempts: 3,
589 delay_ms,
590 })
591 }
592
593 fn llm_call_started(attempt: u32) -> AgentEvent {
594 AgentEvent::Turn(TurnEvent::LlmCallStarted {
595 purpose: aether_core::events::LlmCallPurpose::Chat,
596 provider: None,
597 model: None,
598 display_name: "test".to_string(),
599 attempt,
600 max_attempts: 3,
601 })
602 }
603
604 fn usage_update() -> AgentEvent {
605 AgentEvent::Context(ContextEvent::UsageUpdated {
606 usage: ContextUsage {
607 input_tokens: 1500,
608 output_tokens: 250,
609 total_input_tokens: 5000,
610 total_output_tokens: 800,
611 ..Default::default()
612 },
613 })
614 }
615}