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