Skip to main content

aether_cli/acp/
mod.rs

1pub(crate) mod agent;
2pub(crate) mod agent_key;
3pub(crate) mod agent_runtime;
4pub(crate) mod config_setting;
5pub(crate) mod error;
6pub(crate) mod fake_prompt_mcp;
7pub(crate) mod model_config;
8pub(crate) mod prompt_history_index;
9pub(crate) mod protocol;
10pub(crate) mod session_actor;
11pub(crate) mod session_config_state;
12pub(crate) mod session_factory;
13pub(crate) mod session_store;
14pub(crate) mod slash_commands;
15pub(crate) mod state;
16pub(crate) mod stdio;
17pub mod testing;
18
19pub use protocol::map_mcp_prompt_to_available_command;
20
21use crate::acp::agent::acp_agent_builder;
22use crate::acp::state::{AcpState, AcpStateConfig};
23use crate::acp::stdio::Stdio;
24use crate::provider_connection_args::ProviderConnectionArgs;
25use crate::settings_args::{ConflictingSettingsSources, SettingsSourceArgs};
26use aether_project::AetherSettings;
27use agent_client_protocol as acp;
28use llm::{ProviderConnectionOverride, ProviderConnectionOverrides, ReasoningEffort};
29use std::collections::BTreeMap;
30use std::env::current_dir;
31use std::io;
32use std::sync::Arc;
33use std::{
34    fs::create_dir_all,
35    path::{Path, PathBuf},
36};
37use thiserror::Error;
38use tracing::info;
39use tracing_appender::rolling::daily;
40use tracing_subscriber::EnvFilter;
41
42use crate::credentials::build_oauth_credential_store;
43use crate::workspace::WorkspaceManager;
44use aether_auth::OAuthError;
45use session_factory::InitialSessionSelection;
46use session_store::SessionStore;
47
48#[derive(clap::Args, Debug)]
49pub struct AcpArgs {
50    /// JSON object with ACP launch options. Intended for SDKs and other programmatic clients.
51    #[clap(
52        long = "options-json",
53        hide = true,
54        conflicts_with_all = ["log_dir", "agent", "model", "reasoning_effort", "providers", "settings_json", "settings_file"]
55    )]
56    pub options_json: Option<String>,
57
58    /// Path to log file directory (default: /tmp/aether-acp-logs)
59    #[clap(long)]
60    pub log_dir: Option<PathBuf>,
61
62    /// Initial agent (mode) to select for new sessions. Mutually exclusive with `--model` and `--reasoning-effort`.
63    #[clap(long, conflicts_with_all = ["model", "reasoning_effort"])]
64    pub agent: Option<String>,
65
66    /// Initial model id (e.g. `anthropic:claude-sonnet-4-5`) for new sessions.
67    /// Mutually exclusive with `--agent`.
68    #[clap(long, conflicts_with = "agent")]
69    pub model: Option<String>,
70
71    /// Initial reasoning effort for an explicit model session. Requires `--model` and is mutually exclusive with `--agent`.
72    #[clap(long, value_name = "low|medium|high|xhigh", requires = "model", conflicts_with = "agent")]
73    pub reasoning_effort: Option<ReasoningEffort>,
74
75    #[command(flatten)]
76    pub provider_connection: ProviderConnectionArgs,
77
78    #[command(flatten)]
79    pub settings_source: SettingsSourceArgs,
80}
81
82#[derive(Clone, Debug, Default, serde::Deserialize, schemars::JsonSchema)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct AcpOptions {
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub log_dir: Option<PathBuf>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub providers: Option<BTreeMap<String, ProviderConnectionOverride>>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub settings: Option<AetherSettings>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub settings_file: Option<PathBuf>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub agent: Option<String>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub model: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub reasoning_effort: Option<ReasoningEffort>,
99}
100
101/// Outcome of running the ACP server successfully.
102#[derive(Debug)]
103pub enum AcpRunOutcome {
104    /// The client disconnected cleanly (e.g. EOF on stdin).
105    CleanDisconnect,
106}
107
108/// Errors that terminate the ACP server run.
109#[derive(Debug, Error)]
110pub enum AcpRunError {
111    #[error("ACP protocol error: {0}")]
112    Protocol(#[from] acp::Error),
113
114    #[error("Invalid --options-json: {0}")]
115    OptionsJson(#[from] AcpOptionsJsonError),
116
117    #[error("Failed to initialize OAuth credential store: {0}")]
118    CredentialStore(#[from] OAuthError),
119
120    #[error("Failed to initialize session store: {0}")]
121    SessionStore(#[source] io::Error),
122
123    #[error("Failed to initialize workspace manager: {0}")]
124    WorkspaceManager(#[source] io::Error),
125}
126
127#[derive(Debug, Error)]
128pub enum AcpOptionsJsonError {
129    #[error("{0}")]
130    Parse(#[from] serde_json::Error),
131    #[error(transparent)]
132    ConflictingSettingsSources(#[from] ConflictingSettingsSources),
133    #[error("agent and model cannot both be supplied")]
134    ConflictingAgentSelection,
135    #[error("reasoningEffort requires model")]
136    ReasoningEffortWithoutModel,
137}
138
139pub async fn run_acp(args: AcpArgs) -> Result<AcpRunOutcome, AcpRunError> {
140    info!("Starting Aether ACP server");
141
142    let config = AcpRunConfig::from_args(args)?;
143    setup_logging(&config.log_dir);
144
145    let initial_selection = if let Some(agent) = config.agent.clone() {
146        InitialSessionSelection::agent(agent)
147    } else if let Some(model) = config.model.clone() {
148        InitialSessionSelection::model(model, config.reasoning_effort)
149    } else {
150        InitialSessionSelection::default()
151    };
152    let session_store = Arc::new(SessionStore::new().map_err(AcpRunError::SessionStore)?);
153    let workspace_manager = Arc::new(WorkspaceManager::new().map_err(AcpRunError::WorkspaceManager)?);
154    let cwd = current_dir().unwrap_or_else(|_| PathBuf::from("."));
155    let oauth_credential_store = build_oauth_credential_store(&config.settings_source, &cwd)?;
156    let state = Arc::new(AcpState::new(AcpStateConfig {
157        session_store,
158        workspace_manager,
159        oauth_credential_store,
160        initial_selection,
161        settings_source: config.settings_source,
162        provider_connections: config.provider_connections,
163    }));
164
165    let connect_result = acp_agent_builder(state.clone()).connect_to(Stdio::new()).await;
166    state.shutdown_all().await;
167
168    match connect_result {
169        Ok(()) => Ok(AcpRunOutcome::CleanDisconnect),
170        Err(err) => Err(AcpRunError::Protocol(err)),
171    }
172}
173
174#[derive(Debug)]
175struct AcpRunConfig {
176    log_dir: PathBuf,
177    agent: Option<String>,
178    model: Option<String>,
179    reasoning_effort: Option<ReasoningEffort>,
180    provider_connections: ProviderConnectionOverrides,
181    settings_source: SettingsSourceArgs,
182}
183
184impl AcpRunConfig {
185    fn from_args(args: AcpArgs) -> Result<Self, AcpOptionsJsonError> {
186        if let Some(json) = args.options_json {
187            return Self::from_options(serde_json::from_str(&json)?);
188        }
189
190        Ok(Self {
191            log_dir: args.log_dir.unwrap_or_else(default_log_dir),
192            agent: args.agent,
193            model: args.model,
194            reasoning_effort: args.reasoning_effort,
195            provider_connections: args.provider_connection.into_overrides(),
196            settings_source: args.settings_source,
197        })
198    }
199
200    fn from_options(options: AcpOptions) -> Result<Self, AcpOptionsJsonError> {
201        if options.agent.is_some() && options.model.is_some() {
202            return Err(AcpOptionsJsonError::ConflictingAgentSelection);
203        }
204        if options.reasoning_effort.is_some() && options.model.is_none() {
205            return Err(AcpOptionsJsonError::ReasoningEffortWithoutModel);
206        }
207
208        let settings_source = SettingsSourceArgs::from_json_options(options.settings, options.settings_file)?;
209
210        Ok(Self {
211            log_dir: options.log_dir.unwrap_or_else(default_log_dir),
212            agent: options.agent,
213            model: options.model,
214            reasoning_effort: options.reasoning_effort,
215            provider_connections: ProviderConnectionOverrides::new(options.providers.unwrap_or_default()),
216            settings_source,
217        })
218    }
219}
220
221fn setup_logging(log_dir: &Path) {
222    create_dir_all(log_dir).ok();
223    tracing_subscriber::fmt()
224        .with_writer(daily(log_dir, "aether-acp.log"))
225        .with_ansi(false) // No ANSI colors in log files
226        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")))
227        .pretty()
228        .init();
229}
230
231fn default_log_dir() -> PathBuf {
232    PathBuf::from("/tmp/aether-acp-logs")
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use clap::Parser;
239
240    #[derive(Debug, Parser)]
241    struct TestCli {
242        #[command(flatten)]
243        args: AcpArgs,
244    }
245
246    #[test]
247    fn agent_conflicts_with_model() {
248        let err = TestCli::try_parse_from(["test", "--agent", "planner", "--model", "anthropic:claude-sonnet-4-5"])
249            .expect_err("agent and model should conflict");
250        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
251    }
252
253    #[test]
254    fn agent_conflicts_with_reasoning_effort() {
255        let err = TestCli::try_parse_from(["test", "--agent", "planner", "--reasoning-effort", "high"])
256            .expect_err("agent and reasoning effort should conflict");
257        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
258    }
259
260    #[test]
261    fn reasoning_effort_requires_model() {
262        let err = TestCli::try_parse_from(["test", "--reasoning-effort", "high"])
263            .expect_err("reasoning effort should require model");
264        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
265    }
266
267    #[test]
268    fn reasoning_effort_with_model_is_allowed() {
269        let cli =
270            TestCli::try_parse_from(["test", "--model", "anthropic:claude-sonnet-4-5", "--reasoning-effort", "high"])
271                .expect("reasoning effort can configure an explicit model session");
272        assert_eq!(cli.args.reasoning_effort, Some(ReasoningEffort::High));
273    }
274
275    #[test]
276    fn options_json_conflicts_with_individual_flags() {
277        let err = TestCli::try_parse_from([
278            "test",
279            "--options-json",
280            r#"{"model":"anthropic:claude-sonnet-4-5"}"#,
281            "--model",
282            "anthropic:claude-sonnet-4-5",
283        ])
284        .expect_err("options JSON should conflict with individual ACP flags");
285        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
286    }
287
288    #[test]
289    fn resolves_options_json() {
290        let cli = TestCli::try_parse_from([
291            "test",
292            "--options-json",
293            r#"{"logDir":"/tmp/custom-aether-logs","settings":{"agents":[]},"model":"anthropic:claude-sonnet-4-5","reasoningEffort":"high","providers":{"bedrock":{"url":"http://127.0.0.1:8787","auth":"none"}}}"#,
294        ])
295        .unwrap();
296
297        let config = AcpRunConfig::from_args(cli.args).unwrap();
298
299        assert_eq!(config.log_dir, PathBuf::from("/tmp/custom-aether-logs"));
300        assert_eq!(config.model.as_deref(), Some("anthropic:claude-sonnet-4-5"));
301        assert_eq!(config.reasoning_effort, Some(ReasoningEffort::High));
302        assert!(config.settings_source.settings_json.as_deref().unwrap().contains(r#""agents":[]"#));
303        let bedrock = config.provider_connections.config_for("bedrock");
304        assert_eq!(bedrock.base_url.as_deref(), Some("http://127.0.0.1:8787"));
305        assert_eq!(bedrock.auth_mode, llm::ProviderAuthMode::None);
306    }
307
308    #[test]
309    fn options_json_validates_selection_rules() {
310        let err = AcpRunConfig::from_options(AcpOptions {
311            reasoning_effort: Some(ReasoningEffort::High),
312            ..AcpOptions::default()
313        })
314        .unwrap_err();
315
316        assert!(matches!(err, AcpOptionsJsonError::ReasoningEffortWithoutModel));
317    }
318}