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