Skip to main content

aether_cli/acp/
mod.rs

1pub(crate) mod config_setting;
2pub(crate) mod handlers;
3pub(crate) mod mappers;
4pub(crate) mod model_config;
5pub(crate) mod relay;
6pub(crate) mod session;
7pub(crate) mod session_manager;
8pub(crate) mod session_registry;
9pub(crate) mod session_store;
10pub mod testing;
11
12pub use mappers::map_mcp_prompt_to_available_command;
13pub use session_manager::SessionManager;
14
15use crate::settings_args::SettingsSourceArgs;
16use agent_client_protocol::{self as acp, ByteStreams};
17use llm::ReasoningEffort;
18use std::sync::Arc;
19use std::{fs::create_dir_all, path::PathBuf};
20use tokio::io::{stdin, stdout};
21use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
22use tracing::info;
23use tracing_appender::rolling::daily;
24use tracing_subscriber::EnvFilter;
25
26use llm::oauth::OAuthCredentialStore;
27use session_manager::{InitialSessionSelection, SessionManagerConfig};
28use session_registry::SessionRegistry;
29use session_store::SessionStore;
30
31#[derive(clap::Args, Debug)]
32pub struct AcpArgs {
33    /// Path to log file directory (default: /tmp/aether-acp-logs)
34    #[clap(long, default_value = "/tmp/aether-acp-logs")]
35    pub log_dir: PathBuf,
36
37    /// Initial agent (mode) to select for new sessions. Mutually exclusive with `--model` and `--reasoning-effort`.
38    #[clap(long, conflicts_with_all = ["model", "reasoning_effort"])]
39    pub agent: Option<String>,
40
41    /// Initial model id (e.g. `anthropic:claude-sonnet-4-5`) for new sessions.
42    /// Mutually exclusive with `--agent`.
43    #[clap(long, conflicts_with = "agent")]
44    pub model: Option<String>,
45
46    /// Initial reasoning effort for an explicit model session. Requires `--model` and is mutually exclusive with `--agent`.
47    #[clap(long, value_name = "low|medium|high|xhigh", requires = "model", conflicts_with = "agent")]
48    pub reasoning_effort: Option<ReasoningEffort>,
49
50    #[command(flatten)]
51    pub settings_source: SettingsSourceArgs,
52}
53
54/// Outcome of running the ACP server successfully.
55#[derive(Debug)]
56pub enum AcpRunOutcome {
57    /// The client disconnected cleanly (e.g. EOF on stdin).
58    CleanDisconnect,
59}
60
61/// Errors that terminate the ACP server run.
62#[derive(Debug)]
63pub enum AcpRunError {
64    Protocol(acp::Error),
65    OAuth(llm::oauth::OAuthError),
66}
67
68impl std::fmt::Display for AcpRunError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            AcpRunError::Protocol(e) => write!(f, "ACP protocol error: {e}"),
72            AcpRunError::OAuth(e) => write!(f, "ACP OAuth error: {e}"),
73        }
74    }
75}
76
77impl std::error::Error for AcpRunError {
78    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
79        match self {
80            AcpRunError::Protocol(e) => Some(e),
81            AcpRunError::OAuth(e) => Some(e),
82        }
83    }
84}
85
86pub async fn run_acp(args: AcpArgs) -> Result<AcpRunOutcome, AcpRunError> {
87    info!("Starting Aether ACP server");
88
89    setup_logging(&args);
90
91    let initial_selection = if let Some(agent) = args.agent.clone() {
92        InitialSessionSelection::agent(agent)
93    } else if let Some(model) = args.model.clone() {
94        InitialSessionSelection::model(model, args.reasoning_effort)
95    } else {
96        InitialSessionSelection::default()
97    };
98    let session_store =
99        SessionStore::new().map_or_else(|e| panic!("Failed to initialize session store: {e}"), Arc::new);
100    let oauth_credential_store = OAuthCredentialStore::with_platform_store().map_err(AcpRunError::OAuth)?;
101    let manager = Arc::new(SessionManager::new(SessionManagerConfig {
102        registry: Arc::new(SessionRegistry::new()),
103        session_store,
104        oauth_credential_store,
105        initial_selection,
106        settings_source: args.settings_source,
107    }));
108
109    let transport = ByteStreams::new(stdout().compat_write(), stdin().compat());
110    let connect_result = handlers::acp_agent_builder(manager.clone()).connect_to(transport).await;
111
112    manager.shutdown_all_sessions().await;
113
114    match connect_result {
115        Ok(()) => Ok(AcpRunOutcome::CleanDisconnect),
116        Err(err) => Err(AcpRunError::Protocol(err)),
117    }
118}
119
120fn setup_logging(args: &AcpArgs) {
121    create_dir_all(&args.log_dir).ok();
122    tracing_subscriber::fmt()
123        .with_writer(daily(&args.log_dir, "aether-acp.log"))
124        .with_ansi(false) // No ANSI colors in log files
125        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")))
126        .pretty()
127        .init();
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use clap::Parser;
134
135    #[derive(Debug, Parser)]
136    struct TestCli {
137        #[command(flatten)]
138        args: AcpArgs,
139    }
140
141    #[test]
142    fn agent_conflicts_with_model() {
143        let err = TestCli::try_parse_from(["test", "--agent", "planner", "--model", "anthropic:claude-sonnet-4-5"])
144            .expect_err("agent and model should conflict");
145        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
146    }
147
148    #[test]
149    fn agent_conflicts_with_reasoning_effort() {
150        let err = TestCli::try_parse_from(["test", "--agent", "planner", "--reasoning-effort", "high"])
151            .expect_err("agent and reasoning effort should conflict");
152        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
153    }
154
155    #[test]
156    fn reasoning_effort_requires_model() {
157        let err = TestCli::try_parse_from(["test", "--reasoning-effort", "high"])
158            .expect_err("reasoning effort should require model");
159        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
160    }
161
162    #[test]
163    fn reasoning_effort_with_model_is_allowed() {
164        let cli =
165            TestCli::try_parse_from(["test", "--model", "anthropic:claude-sonnet-4-5", "--reasoning-effort", "high"])
166                .expect("reasoning effort can configure an explicit model session");
167        assert_eq!(cli.args.reasoning_effort, Some(ReasoningEffort::High));
168    }
169}