1pub(crate) mod agent;
2pub(crate) mod protocol;
3pub mod server;
4pub(crate) mod session;
5pub(crate) mod state;
6#[cfg(any(test, feature = "testing"))]
7pub mod testing;
8
9pub use session::hooks::{IdleHook, SessionHooks};
10
11use crate::acp::state::{AcpState, AcpStateConfig};
12use crate::credentials::oauth_credential_store_from_config;
13use crate::provider_connection_args::ProviderConnectionArgs;
14use crate::resolve::InitialSessionSelection;
15use crate::settings_args::{ConflictingSettingsSources, SettingsSourceArgs};
16use crate::telemetry::build_telemetry_runtime;
17use crate::workspace::WorkspaceManager;
18use aether_project::AetherSettings;
19use aether_sessions::SessionStore;
20use aether_telemetry::{AgentTraceContext, TelemetryInitError};
21use agent_client_protocol as acp;
22use agent_client_protocol::Stdio;
23use llm::catalog::{ReasoningEffortError, validate_reasoning_effort};
24use llm::{ProviderConnectionOverride, ProviderConnectionOverrides, ReasoningEffort};
25use std::collections::BTreeMap;
26use std::env::current_dir;
27use std::io;
28use std::sync::Arc;
29use std::{
30 fs::create_dir_all,
31 path::{Path, PathBuf},
32};
33use thiserror::Error;
34use tracing::{info, warn};
35use tracing_appender::rolling::daily;
36use tracing_subscriber::EnvFilter;
37
38use aether_auth::OAuthError;
39use aether_project::SettingsError;
40
41#[derive(clap::Args, Debug)]
42pub struct AcpArgs {
43 #[clap(
45 long = "options-json",
46 hide = true,
47 conflicts_with_all = ["log_dir", "agent", "model", "reasoning_effort", "providers", "settings_json", "settings_file"]
48 )]
49 pub options_json: Option<String>,
50
51 #[clap(long)]
53 pub log_dir: Option<PathBuf>,
54
55 #[clap(long, conflicts_with_all = ["model", "reasoning_effort"])]
57 pub agent: Option<String>,
58
59 #[clap(long, conflicts_with = "agent")]
62 pub model: Option<String>,
63
64 #[clap(
66 long,
67 value_name = "default|disabled|minimal|low|medium|high|xhigh|max",
68 requires = "model",
69 conflicts_with = "agent"
70 )]
71 pub reasoning_effort: Option<ReasoningEffort>,
72
73 #[command(flatten)]
74 pub provider_connection: ProviderConnectionArgs,
75
76 #[command(flatten)]
77 pub settings_source: SettingsSourceArgs,
78}
79
80#[derive(Clone, Debug, Default, serde::Deserialize, schemars::JsonSchema)]
81#[serde(rename_all = "camelCase", deny_unknown_fields)]
82#[schemars(rename = "AetherAcpOptions")]
83pub struct AcpOptions {
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub log_dir: Option<PathBuf>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub providers: Option<BTreeMap<String, ProviderConnectionOverride>>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub settings: Option<AetherSettings>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub settings_file: Option<PathBuf>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub agent: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub model: Option<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub reasoning_effort: Option<ReasoningEffort>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub trace_context: Option<AgentTraceContext>,
100}
101
102#[derive(Debug)]
104pub enum AcpRunOutcome {
105 CleanDisconnect,
107}
108
109#[derive(Debug, Error)]
111pub enum AcpRunError {
112 #[error("ACP protocol error: {0}")]
113 Protocol(#[from] acp::Error),
114
115 #[error("Invalid --options-json: {0}")]
116 OptionsJson(#[from] AcpOptionsJsonError),
117
118 #[error("Failed to load settings: {0}")]
119 Settings(#[from] SettingsError),
120
121 #[error("Failed to initialize OAuth credential store: {0}")]
122 CredentialStore(#[from] OAuthError),
123
124 #[error("Failed to initialize session store: {0}")]
125 SessionStore(#[source] aether_sessions::SessionStoreError),
126
127 #[error("Failed to initialize workspace manager: {0}")]
128 WorkspaceManager(#[source] io::Error),
129
130 #[error("Failed to initialize telemetry: {0}")]
131 Telemetry(#[source] TelemetryInitError),
132}
133
134#[derive(Debug, Error)]
135pub enum AcpOptionsJsonError {
136 #[error("{0}")]
137 Parse(#[from] serde_json::Error),
138 #[error(transparent)]
139 ConflictingSettingsSources(#[from] ConflictingSettingsSources),
140 #[error("agent and model cannot both be supplied")]
141 ConflictingAgentSelection,
142 #[error("reasoningEffort requires model")]
143 ReasoningEffortWithoutModel,
144 #[error("invalid reasoningEffort: {0}")]
145 UnsupportedReasoningEffort(#[from] ReasoningEffortError),
146}
147
148pub async fn run_acp(args: AcpArgs) -> Result<AcpRunOutcome, AcpRunError> {
149 info!("Starting Aether ACP server");
150
151 let cwd = current_dir().unwrap_or_else(|_| PathBuf::from("."));
152 let state = Arc::new(create_acp_state(args, &cwd, SessionHooks::default())?);
153 let connect_result = state.serve(Stdio::new(), state.stop_token()).await;
154 state.shutdown_all().await;
155
156 match connect_result {
157 Ok(()) => Ok(AcpRunOutcome::CleanDisconnect),
158 Err(err) => Err(AcpRunError::Protocol(err)),
159 }
160}
161
162#[derive(Debug)]
163struct AcpRunConfig {
164 log_dir: PathBuf,
165 agent: Option<String>,
166 model: Option<String>,
167 reasoning_effort: Option<ReasoningEffort>,
168 trace_context: Option<AgentTraceContext>,
169 provider_connections: ProviderConnectionOverrides,
170 settings_source: SettingsSourceArgs,
171}
172
173impl AcpRunConfig {
174 fn from_args(args: AcpArgs) -> Result<Self, AcpOptionsJsonError> {
175 if let Some(json) = args.options_json {
176 return Self::from_options(serde_json::from_str(&json)?);
177 }
178
179 let config = Self {
180 log_dir: args.log_dir.unwrap_or_else(default_log_dir),
181 agent: args.agent,
182 model: args.model,
183 reasoning_effort: args.reasoning_effort,
184 trace_context: None,
185 provider_connections: args.provider_connection.into_overrides(),
186 settings_source: args.settings_source,
187 };
188 config.validate_reasoning_effort()?;
189 Ok(config)
190 }
191
192 fn from_options(options: AcpOptions) -> Result<Self, AcpOptionsJsonError> {
193 if options.agent.is_some() && options.model.is_some() {
194 return Err(AcpOptionsJsonError::ConflictingAgentSelection);
195 }
196 if options.reasoning_effort.is_some() && options.model.is_none() {
197 return Err(AcpOptionsJsonError::ReasoningEffortWithoutModel);
198 }
199
200 let settings_source = SettingsSourceArgs::from_json_options(options.settings, options.settings_file)?;
201
202 let config = Self {
203 log_dir: options.log_dir.unwrap_or_else(default_log_dir),
204 agent: options.agent,
205 model: options.model,
206 reasoning_effort: options.reasoning_effort,
207 trace_context: options.trace_context,
208 provider_connections: ProviderConnectionOverrides::new(options.providers.unwrap_or_default()),
209 settings_source,
210 };
211 config.validate_reasoning_effort()?;
212 Ok(config)
213 }
214
215 fn validate_reasoning_effort(&self) -> Result<(), AcpOptionsJsonError> {
216 if let Some(model) = self.model.as_deref() {
217 validate_reasoning_effort(model, self.reasoning_effort)?;
218 }
219 Ok(())
220 }
221}
222
223fn create_acp_state(args: AcpArgs, cwd: &Path, hooks: SessionHooks) -> Result<AcpState, AcpRunError> {
224 let config = AcpRunConfig::from_args(args)?;
225 setup_logging(&config.log_dir);
226
227 let initial_selection = if let Some(agent) = config.agent.clone() {
228 InitialSessionSelection::agent(agent)
229 } else if let Some(model) = config.model.clone() {
230 InitialSessionSelection::model(model, config.reasoning_effort)
231 } else {
232 InitialSessionSelection::default()
233 };
234
235 let settings = config.settings_source.load_settings(cwd)?;
236 let telemetry = match build_telemetry_runtime(settings.telemetry.as_ref(), config.trace_context) {
237 Ok(telemetry) => telemetry,
238 Err(error @ TelemetryInitError::InvalidTraceContext(_)) => return Err(AcpRunError::Telemetry(error)),
239 Err(error) => {
240 warn!("Telemetry disabled: {error}");
241 None
242 }
243 };
244
245 Ok(AcpState::new(AcpStateConfig {
246 session_store: Arc::new(SessionStore::new().map_err(AcpRunError::SessionStore)?),
247 workspace_manager: Arc::new(WorkspaceManager::new().map_err(AcpRunError::WorkspaceManager)?),
248 oauth_credential_store: oauth_credential_store_from_config(settings.credentials_store)?,
249 initial_selection,
250 settings_source: config.settings_source,
251 provider_connections: config.provider_connections,
252 telemetry,
253 runtime_factory: None,
254 cwd: cwd.to_path_buf(),
255 hooks,
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) .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}