Skip to main content

aether_cli/headless/
run.rs

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                // No tracing output — text mode writes directly to stdout/stderr.
165                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    // --- emit_event tests (Pretty/Json mode) ---
245
246    #[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    // --- emit_text tests (Text mode) ---
352
353    #[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}