1use aether_core::core::Prompt;
2use aether_core::events::{AgentMessage, UserMessage};
3use std::io;
4use std::process::ExitCode;
5use tokio::sync::mpsc;
6
7use super::error::CliError;
8use super::{OutputFormat, RunConfig};
9use crate::runtime::RuntimeBuilder;
10
11pub async fn run(config: RunConfig) -> Result<ExitCode, CliError> {
12 setup_tracing(config.verbose, &config.output);
13
14 let agent = RuntimeBuilder::from_spec(config.cwd.clone(), config.spec)
15 .mcp_config_opt(config.mcp_config)
16 .build(config.system_prompt.as_deref().map(Prompt::text), None)
17 .await?;
18
19 agent
20 .agent_tx
21 .send(UserMessage::text(&config.prompt))
22 .await
23 .map_err(|e| CliError::AgentError(format!("Failed to send prompt: {e}")))?;
24
25 Ok(stream_output(agent.agent_rx, &config.output).await)
26}
27
28async fn stream_output(mut rx: mpsc::Receiver<AgentMessage>, format: &OutputFormat) -> ExitCode {
29 while let Some(msg) = rx.recv().await {
30 match format {
31 OutputFormat::Text => emit_text(&msg),
32 OutputFormat::Pretty | OutputFormat::Json => emit_event(&msg),
33 }
34 if matches!(msg, AgentMessage::Done) {
35 break;
36 }
37 }
38 ExitCode::SUCCESS
39}
40
41fn format_text(msg: &AgentMessage) -> Option<String> {
42 match msg {
43 AgentMessage::Text {
44 chunk,
45 is_complete: true,
46 ..
47 } => Some(chunk.clone()),
48
49 AgentMessage::Thought {
50 chunk,
51 is_complete: true,
52 ..
53 } => Some(format!("Thought: {chunk}")),
54
55 AgentMessage::ToolCall { request, .. } => Some(format!(
56 "Tool call: {}({})",
57 request.name, request.arguments
58 )),
59
60 AgentMessage::ToolResult { result, .. } => {
61 Some(format!("Tool result [{}]: {}", result.name, result.result))
62 }
63
64 AgentMessage::ToolError { error, .. } => {
65 Some(format!("Tool error [{}]: {}", error.name, error.error))
66 }
67
68 AgentMessage::Error { message } => Some(format!("Error: {message}")),
69
70 AgentMessage::Cancelled { message } => Some(format!("Cancelled: {message}")),
71
72 AgentMessage::AutoContinue {
73 attempt,
74 max_attempts,
75 } => Some(format!("Continuing ({attempt}/{max_attempts})...")),
76
77 AgentMessage::ModelSwitched { previous, new } => {
78 Some(format!("Model switched: {previous} -> {new}"))
79 }
80
81 _ => None,
82 }
83}
84
85fn emit_text(msg: &AgentMessage) {
86 if let Some(text) = format_text(msg) {
87 if matches!(msg, AgentMessage::Error { .. }) {
88 eprintln!("{text}");
89 } else {
90 println!("{text}");
91 }
92 }
93}
94
95fn emit_event(msg: &AgentMessage) {
96 match msg {
97 AgentMessage::Text {
98 chunk,
99 is_complete: true,
100 ..
101 } => tracing::info!(target: "agent", "{chunk}"),
102
103 AgentMessage::Thought {
104 chunk,
105 is_complete: true,
106 ..
107 } => tracing::info!(target: "agent", thought = %chunk),
108
109 AgentMessage::ToolCall { request, .. } => {
110 tracing::info!(target: "agent", tool = %request.name, arguments = %request.arguments);
111 }
112
113 AgentMessage::ToolResult { result, .. } => {
114 tracing::info!(target: "agent", tool = %result.name, result = %result.result);
115 }
116
117 AgentMessage::ToolError { error, .. } => {
118 tracing::warn!(target: "agent", tool = %error.name, error = %error.error);
119 }
120
121 AgentMessage::Error { message } => tracing::error!(target: "agent", "{message}"),
122
123 AgentMessage::Cancelled { message } => {
124 tracing::info!(target: "agent", cancelled = %message);
125 }
126
127 AgentMessage::AutoContinue {
128 attempt,
129 max_attempts,
130 } => tracing::info!(target: "agent", "Continuing ({attempt}/{max_attempts})..."),
131
132 AgentMessage::ModelSwitched { previous, new } => {
133 tracing::info!(target: "agent", "Model switched: {previous} -> {new}");
134 }
135
136 _ => {}
137 }
138}
139
140fn setup_tracing(verbose: bool, format: &OutputFormat) {
141 use tracing_subscriber::Layer;
142 use tracing_subscriber::filter::{self, EnvFilter};
143 use tracing_subscriber::fmt;
144 use tracing_subscriber::layer::SubscriberExt;
145 use tracing_subscriber::util::SubscriberInitExt;
146
147 let diag_filter = if verbose {
148 EnvFilter::new("debug,agent=off")
149 } else {
150 EnvFilter::new("error,agent=off")
151 };
152
153 let diag_layer = fmt::layer()
154 .with_writer(io::stderr)
155 .with_filter(diag_filter);
156
157 let agent_filter = filter::filter_fn(|meta| meta.target().starts_with("agent"));
158
159 match format {
160 OutputFormat::Text => {
161 if verbose {
162 tracing_subscriber::registry().with(diag_layer).init();
163 } else {
164 tracing_subscriber::registry().init();
166 }
167 }
168 OutputFormat::Pretty => {
169 let agent_layer = fmt::layer()
170 .with_writer(io::stdout)
171 .pretty()
172 .with_filter(agent_filter);
173 tracing_subscriber::registry()
174 .with(diag_layer)
175 .with(agent_layer)
176 .init();
177 }
178 OutputFormat::Json => {
179 let agent_layer = fmt::layer()
180 .with_writer(io::stdout)
181 .json()
182 .with_filter(agent_filter);
183 tracing_subscriber::registry()
184 .with(diag_layer)
185 .with(agent_layer)
186 .init();
187 }
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use std::sync::{Arc, Mutex};
195
196 use tracing_subscriber::Layer;
197 use tracing_subscriber::fmt;
198 use tracing_subscriber::layer::SubscriberExt;
199
200 fn with_test_subscriber<F: FnOnce()>(f: F) -> String {
201 let buf = Arc::new(Mutex::new(Vec::new()));
202 let buf_clone = Arc::clone(&buf);
203
204 let writer = move || -> TestWriter {
205 TestWriter {
206 buf: Arc::clone(&buf_clone),
207 }
208 };
209
210 let layer = fmt::layer()
211 .with_writer(writer)
212 .with_ansi(false)
213 .with_level(false)
214 .with_target(false)
215 .with_timer(fmt::time::uptime())
216 .with_filter(tracing_subscriber::filter::filter_fn(|meta| {
217 meta.target().starts_with("agent")
218 }));
219
220 let subscriber = tracing_subscriber::registry().with(layer);
221
222 tracing::subscriber::with_default(subscriber, f);
223
224 let bytes = buf.lock().unwrap();
225 String::from_utf8(bytes.clone()).unwrap()
226 }
227
228 #[derive(Clone)]
229 struct TestWriter {
230 buf: Arc<Mutex<Vec<u8>>>,
231 }
232
233 impl io::Write for TestWriter {
234 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
235 self.buf.lock().unwrap().extend_from_slice(data);
236 Ok(data.len())
237 }
238
239 fn flush(&mut self) -> io::Result<()> {
240 Ok(())
241 }
242 }
243
244 #[test]
247 fn emit_event_emits_complete_text() {
248 let output = with_test_subscriber(|| {
249 emit_event(&AgentMessage::text("id", "hello", true, "model"));
250 });
251 assert!(output.contains("hello"), "expected 'hello' in: {output}");
252 }
253
254 #[test]
255 fn emit_event_skips_incomplete_text() {
256 let output = with_test_subscriber(|| {
257 emit_event(&AgentMessage::text("id", "hello", false, "model"));
258 });
259 assert!(output.is_empty(), "expected empty output, got: {output}");
260 }
261
262 #[test]
263 fn emit_event_emits_complete_thought() {
264 let output = with_test_subscriber(|| {
265 emit_event(&AgentMessage::thought("id", "deep thinking", true, "model"));
266 });
267 assert!(
268 output.contains("deep thinking"),
269 "expected 'deep thinking' in: {output}"
270 );
271 }
272
273 #[test]
274 fn emit_event_skips_incomplete_thought() {
275 let output = with_test_subscriber(|| {
276 emit_event(&AgentMessage::thought("id", "partial", false, "model"));
277 });
278 assert!(output.is_empty(), "expected empty output, got: {output}");
279 }
280
281 #[test]
282 fn emit_event_emits_tool_call() {
283 let msg = AgentMessage::ToolCall {
284 request: llm::ToolCallRequest {
285 id: "tc1".to_string(),
286 name: "bash".to_string(),
287 arguments: "{}".to_string(),
288 },
289 model_name: "test".to_string(),
290 };
291 let output = with_test_subscriber(|| {
292 emit_event(&msg);
293 });
294 assert!(output.contains("bash"), "expected 'bash' in: {output}");
295 }
296
297 #[test]
298 fn emit_event_skips_tool_call_updates() {
299 let msg = AgentMessage::ToolCallUpdate {
300 tool_call_id: "tc1".to_string(),
301 chunk: "{\"partial".to_string(),
302 model_name: "test".to_string(),
303 };
304 let output = with_test_subscriber(|| {
305 emit_event(&msg);
306 });
307 assert!(output.is_empty(), "expected empty output, got: {output}");
308 }
309
310 #[test]
311 fn emit_event_emits_tool_result() {
312 let msg = AgentMessage::ToolResult {
313 result: llm::ToolCallResult {
314 id: "tc1".to_string(),
315 name: "bash".to_string(),
316 arguments: "{}".to_string(),
317 result: "ok".to_string(),
318 },
319 result_meta: None,
320 model_name: "test".to_string(),
321 };
322 let output = with_test_subscriber(|| {
323 emit_event(&msg);
324 });
325 assert!(output.contains("bash"), "expected 'bash' in: {output}");
326 assert!(output.contains("ok"), "expected 'ok' in: {output}");
327 }
328
329 #[test]
330 fn emit_event_emits_error() {
331 let msg = AgentMessage::Error {
332 message: "something broke".to_string(),
333 };
334 let output = with_test_subscriber(|| {
335 emit_event(&msg);
336 });
337 assert!(
338 output.contains("something broke"),
339 "expected 'something broke' in: {output}"
340 );
341 }
342
343 #[test]
344 fn emit_event_skips_done() {
345 let output = with_test_subscriber(|| {
346 emit_event(&AgentMessage::Done);
347 });
348 assert!(output.is_empty(), "expected empty output, got: {output}");
349 }
350
351 #[test]
354 fn emit_text_formats_complete_text() {
355 assert_eq!(
356 format_text(&AgentMessage::text("id", "hello world", true, "m")),
357 Some("hello world".to_string())
358 );
359 }
360
361 #[test]
362 fn emit_text_skips_incomplete_text() {
363 assert_eq!(
364 format_text(&AgentMessage::text("id", "partial", false, "m")),
365 None
366 );
367 }
368
369 #[test]
370 fn emit_text_formats_complete_thought() {
371 assert_eq!(
372 format_text(&AgentMessage::thought("id", "reasoning here", true, "m")),
373 Some("Thought: reasoning here".to_string())
374 );
375 }
376
377 #[test]
378 fn emit_text_skips_incomplete_thought() {
379 assert_eq!(
380 format_text(&AgentMessage::thought("id", "partial", false, "m")),
381 None
382 );
383 }
384
385 #[test]
386 fn emit_text_formats_tool_call() {
387 let msg = AgentMessage::ToolCall {
388 request: llm::ToolCallRequest {
389 id: "tc1".to_string(),
390 name: "bash".to_string(),
391 arguments: r#"{"cmd":"ls"}"#.to_string(),
392 },
393 model_name: "test".to_string(),
394 };
395 assert_eq!(
396 format_text(&msg),
397 Some(r#"Tool call: bash({"cmd":"ls"})"#.to_string())
398 );
399 }
400
401 #[test]
402 fn emit_text_skips_tool_call_updates() {
403 let msg = AgentMessage::ToolCallUpdate {
404 tool_call_id: "tc1".to_string(),
405 chunk: "partial".to_string(),
406 model_name: "test".to_string(),
407 };
408 assert_eq!(format_text(&msg), None);
409 }
410
411 #[test]
412 fn emit_text_formats_tool_result() {
413 let msg = AgentMessage::ToolResult {
414 result: llm::ToolCallResult {
415 id: "tc1".to_string(),
416 name: "bash".to_string(),
417 arguments: "{}".to_string(),
418 result: "output".to_string(),
419 },
420 result_meta: None,
421 model_name: "test".to_string(),
422 };
423 assert_eq!(
424 format_text(&msg),
425 Some("Tool result [bash]: output".to_string())
426 );
427 }
428
429 #[test]
430 fn emit_text_formats_tool_error() {
431 let msg = AgentMessage::ToolError {
432 error: llm::ToolCallError {
433 id: "tc1".to_string(),
434 name: "bash".to_string(),
435 arguments: None,
436 error: "not found".to_string(),
437 },
438 model_name: "test".to_string(),
439 };
440 assert_eq!(
441 format_text(&msg),
442 Some("Tool error [bash]: not found".to_string())
443 );
444 }
445
446 #[test]
447 fn emit_text_formats_error() {
448 let msg = AgentMessage::Error {
449 message: "boom".to_string(),
450 };
451 assert_eq!(format_text(&msg), Some("Error: boom".to_string()));
452 }
453
454 #[test]
455 fn emit_text_formats_cancelled() {
456 let msg = AgentMessage::Cancelled {
457 message: "user stopped".to_string(),
458 };
459 assert_eq!(
460 format_text(&msg),
461 Some("Cancelled: user stopped".to_string())
462 );
463 }
464
465 #[test]
466 fn emit_text_formats_auto_continue() {
467 let msg = AgentMessage::AutoContinue {
468 attempt: 2,
469 max_attempts: 5,
470 };
471 assert_eq!(format_text(&msg), Some("Continuing (2/5)...".to_string()));
472 }
473
474 #[test]
475 fn emit_text_formats_model_switched() {
476 let msg = AgentMessage::ModelSwitched {
477 previous: "old-model".to_string(),
478 new: "new-model".to_string(),
479 };
480 assert_eq!(
481 format_text(&msg),
482 Some("Model switched: old-model -> new-model".to_string())
483 );
484 }
485
486 #[test]
487 fn emit_text_skips_done() {
488 assert_eq!(format_text(&AgentMessage::Done), None);
489 }
490}