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;
12pub use session::hooks::{IdleHook, SessionHooks};
13
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)]
85#[schemars(rename = "AetherAcpOptions")]
86pub struct AcpOptions {
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub log_dir: Option<PathBuf>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub providers: Option<BTreeMap<String, ProviderConnectionOverride>>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub settings: Option<AetherSettings>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub settings_file: Option<PathBuf>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub agent: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub model: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub reasoning_effort: Option<ReasoningEffort>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub trace_context: Option<AgentTraceContext>,
103}
104
105/// Outcome of running the ACP server successfully.
106#[derive(Debug)]
107pub enum AcpRunOutcome {
108    /// The client disconnected cleanly (e.g. EOF on stdin).
109    CleanDisconnect,
110}
111
112/// Errors that terminate the ACP server run.
113#[derive(Debug, Error)]
114pub enum AcpRunError {
115    #[error("ACP protocol error: {0}")]
116    Protocol(#[from] acp::Error),
117
118    #[error("Invalid --options-json: {0}")]
119    OptionsJson(#[from] AcpOptionsJsonError),
120
121    #[error("Failed to load settings: {0}")]
122    Settings(#[from] SettingsError),
123
124    #[error("Failed to initialize OAuth credential store: {0}")]
125    CredentialStore(#[from] OAuthError),
126
127    #[error("Failed to initialize session store: {0}")]
128    SessionStore(#[source] aether_sessions::SessionStoreError),
129
130    #[error("Failed to initialize workspace manager: {0}")]
131    WorkspaceManager(#[source] io::Error),
132
133    #[error("Failed to initialize telemetry: {0}")]
134    Telemetry(#[source] TelemetryInitError),
135}
136
137#[derive(Debug, Error)]
138pub enum AcpOptionsJsonError {
139    #[error("{0}")]
140    Parse(#[from] serde_json::Error),
141    #[error(transparent)]
142    ConflictingSettingsSources(#[from] ConflictingSettingsSources),
143    #[error("agent and model cannot both be supplied")]
144    ConflictingAgentSelection,
145    #[error("reasoningEffort requires model")]
146    ReasoningEffortWithoutModel,
147    #[error("invalid reasoningEffort: {0}")]
148    UnsupportedReasoningEffort(#[from] ReasoningEffortError),
149}
150
151pub async fn run_acp(args: AcpArgs) -> Result<AcpRunOutcome, AcpRunError> {
152    info!("Starting Aether ACP server");
153
154    let cwd = current_dir().unwrap_or_else(|_| PathBuf::from("."));
155    let state = Arc::new(create_acp_state(args, &cwd, SessionHooks::default())?);
156    let connect_result = state.serve(Stdio::new(), state.stop_token()).await;
157    state.shutdown_all().await;
158
159    match connect_result {
160        Ok(()) => Ok(AcpRunOutcome::CleanDisconnect),
161        Err(err) => Err(AcpRunError::Protocol(err)),
162    }
163}
164
165#[derive(Debug)]
166struct AcpRunConfig {
167    log_dir: PathBuf,
168    agent: Option<String>,
169    model: Option<String>,
170    reasoning_effort: Option<ReasoningEffort>,
171    trace_context: Option<AgentTraceContext>,
172    provider_connections: ProviderConnectionOverrides,
173    settings_source: SettingsSourceArgs,
174}
175
176impl AcpRunConfig {
177    fn from_args(args: AcpArgs) -> Result<Self, AcpOptionsJsonError> {
178        if let Some(json) = args.options_json {
179            return Self::from_options(serde_json::from_str(&json)?);
180        }
181
182        let config = Self {
183            log_dir: args.log_dir.unwrap_or_else(default_log_dir),
184            agent: args.agent,
185            model: args.model,
186            reasoning_effort: args.reasoning_effort,
187            trace_context: None,
188            provider_connections: args.provider_connection.into_overrides(),
189            settings_source: args.settings_source,
190        };
191        config.validate_reasoning_effort()?;
192        Ok(config)
193    }
194
195    fn from_options(options: AcpOptions) -> Result<Self, AcpOptionsJsonError> {
196        if options.agent.is_some() && options.model.is_some() {
197            return Err(AcpOptionsJsonError::ConflictingAgentSelection);
198        }
199        if options.reasoning_effort.is_some() && options.model.is_none() {
200            return Err(AcpOptionsJsonError::ReasoningEffortWithoutModel);
201        }
202
203        let settings_source = SettingsSourceArgs::from_json_options(options.settings, options.settings_file)?;
204
205        let config = Self {
206            log_dir: options.log_dir.unwrap_or_else(default_log_dir),
207            agent: options.agent,
208            model: options.model,
209            reasoning_effort: options.reasoning_effort,
210            trace_context: options.trace_context,
211            provider_connections: ProviderConnectionOverrides::new(options.providers.unwrap_or_default()),
212            settings_source,
213        };
214        config.validate_reasoning_effort()?;
215        Ok(config)
216    }
217
218    fn validate_reasoning_effort(&self) -> Result<(), AcpOptionsJsonError> {
219        if let Some(model) = self.model.as_deref() {
220            validate_reasoning_effort(model, self.reasoning_effort)?;
221        }
222        Ok(())
223    }
224}
225
226fn create_acp_state(args: AcpArgs, cwd: &Path, hooks: SessionHooks) -> Result<AcpState, AcpRunError> {
227    let config = AcpRunConfig::from_args(args)?;
228    setup_logging(&config.log_dir);
229
230    let initial_selection = if let Some(agent) = config.agent.clone() {
231        InitialSessionSelection::agent(agent)
232    } else if let Some(model) = config.model.clone() {
233        InitialSessionSelection::model(model, config.reasoning_effort)
234    } else {
235        InitialSessionSelection::default()
236    };
237
238    let settings = config.settings_source.load_settings(cwd)?;
239    let telemetry = match build_telemetry_runtime(settings.telemetry.as_ref(), config.trace_context) {
240        Ok(telemetry) => telemetry,
241        Err(error @ TelemetryInitError::InvalidTraceContext(_)) => return Err(AcpRunError::Telemetry(error)),
242        Err(error) => {
243            warn!("Telemetry disabled: {error}");
244            None
245        }
246    };
247
248    Ok(AcpState::new(AcpStateConfig {
249        session_store: Arc::new(SessionStore::new().map_err(AcpRunError::SessionStore)?),
250        workspace_manager: Arc::new(WorkspaceManager::new().map_err(AcpRunError::WorkspaceManager)?),
251        oauth_credential_store: oauth_credential_store_from_config(settings.credentials_store)?,
252        initial_selection,
253        settings_source: config.settings_source,
254        provider_connections: config.provider_connections,
255        telemetry,
256        runtime_factory: None,
257        cwd: cwd.to_path_buf(),
258        hooks,
259    }))
260}
261
262fn setup_logging(log_dir: &Path) {
263    create_dir_all(log_dir).ok();
264    tracing_subscriber::fmt()
265        .with_writer(daily(log_dir, "aether-acp.log"))
266        .with_ansi(false) // No ANSI colors in log files
267        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")))
268        .pretty()
269        .init();
270}
271
272fn default_log_dir() -> PathBuf {
273    PathBuf::from("/tmp/aether-acp-logs")
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use clap::Parser;
280
281    #[derive(Debug, Parser)]
282    struct TestCli {
283        #[command(flatten)]
284        args: AcpArgs,
285    }
286
287    #[test]
288    fn agent_conflicts_with_model() {
289        let err = TestCli::try_parse_from(["test", "--agent", "planner", "--model", "anthropic:claude-sonnet-4-5"])
290            .expect_err("agent and model should conflict");
291        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
292    }
293
294    #[test]
295    fn agent_conflicts_with_reasoning_effort() {
296        let err = TestCli::try_parse_from(["test", "--agent", "planner", "--reasoning-effort", "high"])
297            .expect_err("agent and reasoning effort should conflict");
298        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
299    }
300
301    #[test]
302    fn reasoning_effort_requires_model() {
303        let err = TestCli::try_parse_from(["test", "--reasoning-effort", "high"])
304            .expect_err("reasoning effort should require model");
305        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
306    }
307
308    #[test]
309    fn reasoning_effort_with_model_is_allowed() {
310        let cli =
311            TestCli::try_parse_from(["test", "--model", "anthropic:claude-sonnet-4-5", "--reasoning-effort", "high"])
312                .expect("reasoning effort can configure an explicit model session");
313        assert_eq!(cli.args.reasoning_effort, Some(ReasoningEffort::High));
314    }
315
316    #[test]
317    fn options_json_conflicts_with_individual_flags() {
318        let err = TestCli::try_parse_from([
319            "test",
320            "--options-json",
321            r#"{"model":"anthropic:claude-sonnet-4-5"}"#,
322            "--model",
323            "anthropic:claude-sonnet-4-5",
324        ])
325        .expect_err("options JSON should conflict with individual ACP flags");
326        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
327    }
328
329    #[test]
330    fn resolves_options_json() {
331        let cli = TestCli::try_parse_from([
332            "test",
333            "--options-json",
334            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"}}}"#,
335        ])
336        .unwrap();
337
338        let config = AcpRunConfig::from_args(cli.args).unwrap();
339
340        assert_eq!(config.log_dir, PathBuf::from("/tmp/custom-aether-logs"));
341        assert_eq!(config.model.as_deref(), Some("anthropic:claude-sonnet-4-5"));
342        assert_eq!(config.reasoning_effort, Some(ReasoningEffort::High));
343        assert!(config.trace_context.is_none());
344        assert!(config.settings_source.settings_json.as_deref().unwrap().contains(r#""agents":[]"#));
345        let bedrock = config.provider_connections.config_for("bedrock");
346        assert_eq!(bedrock.base_url.as_deref(), Some("http://127.0.0.1:8787"));
347        assert_eq!(bedrock.auth_mode, llm::ProviderAuthMode::None);
348    }
349
350    #[test]
351    fn options_json_rejects_unsupported_reasoning_effort() {
352        let error = AcpRunConfig::from_options(AcpOptions {
353            model: Some("anthropic:claude-opus-4-6".to_string()),
354            reasoning_effort: Some(ReasoningEffort::Xhigh),
355            ..AcpOptions::default()
356        })
357        .unwrap_err();
358
359        assert!(matches!(error, AcpOptionsJsonError::UnsupportedReasoningEffort(_)));
360    }
361
362    #[test]
363    fn options_json_validates_selection_rules() {
364        let err = AcpRunConfig::from_options(AcpOptions {
365            reasoning_effort: Some(ReasoningEffort::High),
366            ..AcpOptions::default()
367        })
368        .unwrap_err();
369
370        assert!(matches!(err, AcpOptionsJsonError::ReasoningEffortWithoutModel));
371    }
372}