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