Skip to main content

aether_cli/acp/
mod.rs

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