Skip to main content

aether_cli/acp/
mod.rs

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