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 #[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 #[clap(long)]
56 pub log_dir: Option<PathBuf>,
57
58 #[clap(long, conflicts_with_all = ["model", "reasoning_effort"])]
60 pub agent: Option<String>,
61
62 #[clap(long, conflicts_with = "agent")]
65 pub model: Option<String>,
66
67 #[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#[derive(Debug)]
107pub enum AcpRunOutcome {
108 CleanDisconnect,
110}
111
112#[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) .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}