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 #[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 #[clap(long)]
54 pub log_dir: Option<PathBuf>,
55
56 #[clap(long, conflicts_with_all = ["model", "reasoning_effort"])]
58 pub agent: Option<String>,
59
60 #[clap(long, conflicts_with = "agent")]
63 pub model: Option<String>,
64
65 #[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#[derive(Debug)]
99pub enum AcpRunOutcome {
100 CleanDisconnect,
102}
103
104#[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) .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}