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