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