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;
40use tracing_appender::rolling::daily;
41use tracing_subscriber::EnvFilter;
42
43use crate::credentials::build_oauth_credential_store;
44use crate::workspace::WorkspaceManager;
45use aether_auth::OAuthError;
46use session_factory::InitialSessionSelection;
47use session_store::SessionStore;
48
49#[derive(clap::Args, Debug)]
50pub struct AcpArgs {
51 #[clap(
53 long = "options-json",
54 hide = true,
55 conflicts_with_all = ["log_dir", "agent", "model", "reasoning_effort", "providers", "settings_json", "settings_file"]
56 )]
57 pub options_json: Option<String>,
58
59 #[clap(long)]
61 pub log_dir: Option<PathBuf>,
62
63 #[clap(long, conflicts_with_all = ["model", "reasoning_effort"])]
65 pub agent: Option<String>,
66
67 #[clap(long, conflicts_with = "agent")]
70 pub model: Option<String>,
71
72 #[clap(long, value_name = "minimal|low|medium|high|xhigh|max", requires = "model", conflicts_with = "agent")]
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)]
85pub struct AcpOptions {
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub log_dir: Option<PathBuf>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub providers: Option<BTreeMap<String, ProviderConnectionOverride>>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub settings: Option<AetherSettings>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub settings_file: Option<PathBuf>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub agent: Option<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub model: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub reasoning_effort: Option<ReasoningEffort>,
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 initialize OAuth credential store: {0}")]
119 CredentialStore(#[from] OAuthError),
120
121 #[error("Failed to initialize session store: {0}")]
122 SessionStore(#[source] io::Error),
123
124 #[error("Failed to initialize workspace manager: {0}")]
125 WorkspaceManager(#[source] io::Error),
126}
127
128#[derive(Debug, Error)]
129pub enum AcpOptionsJsonError {
130 #[error("{0}")]
131 Parse(#[from] serde_json::Error),
132 #[error(transparent)]
133 ConflictingSettingsSources(#[from] ConflictingSettingsSources),
134 #[error("agent and model cannot both be supplied")]
135 ConflictingAgentSelection,
136 #[error("reasoningEffort requires model")]
137 ReasoningEffortWithoutModel,
138 #[error("invalid reasoningEffort: {0}")]
139 UnsupportedReasoningEffort(#[from] ReasoningEffortError),
140}
141
142pub async fn run_acp(args: AcpArgs) -> Result<AcpRunOutcome, AcpRunError> {
143 info!("Starting Aether ACP server");
144
145 let config = AcpRunConfig::from_args(args)?;
146 setup_logging(&config.log_dir);
147
148 let initial_selection = if let Some(agent) = config.agent.clone() {
149 InitialSessionSelection::agent(agent)
150 } else if let Some(model) = config.model.clone() {
151 InitialSessionSelection::model(model, config.reasoning_effort)
152 } else {
153 InitialSessionSelection::default()
154 };
155 let session_store = Arc::new(SessionStore::new().map_err(AcpRunError::SessionStore)?);
156 let workspace_manager = Arc::new(WorkspaceManager::new().map_err(AcpRunError::WorkspaceManager)?);
157 let cwd = current_dir().unwrap_or_else(|_| PathBuf::from("."));
158 let oauth_credential_store = build_oauth_credential_store(&config.settings_source, &cwd)?;
159 let state = Arc::new(AcpState::new(AcpStateConfig {
160 session_store,
161 workspace_manager,
162 oauth_credential_store,
163 initial_selection,
164 settings_source: config.settings_source,
165 provider_connections: config.provider_connections,
166 }));
167
168 let connect_result = acp_agent_builder(state.clone()).connect_to(Stdio::new()).await;
169 state.shutdown_all().await;
170
171 match connect_result {
172 Ok(()) => Ok(AcpRunOutcome::CleanDisconnect),
173 Err(err) => Err(AcpRunError::Protocol(err)),
174 }
175}
176
177#[derive(Debug)]
178struct AcpRunConfig {
179 log_dir: PathBuf,
180 agent: Option<String>,
181 model: Option<String>,
182 reasoning_effort: Option<ReasoningEffort>,
183 provider_connections: ProviderConnectionOverrides,
184 settings_source: SettingsSourceArgs,
185}
186
187impl AcpRunConfig {
188 fn from_args(args: AcpArgs) -> Result<Self, AcpOptionsJsonError> {
189 if let Some(json) = args.options_json {
190 return Self::from_options(serde_json::from_str(&json)?);
191 }
192
193 let config = Self {
194 log_dir: args.log_dir.unwrap_or_else(default_log_dir),
195 agent: args.agent,
196 model: args.model,
197 reasoning_effort: args.reasoning_effort,
198 provider_connections: args.provider_connection.into_overrides(),
199 settings_source: args.settings_source,
200 };
201 config.validate_reasoning_effort()?;
202 Ok(config)
203 }
204
205 fn from_options(options: AcpOptions) -> Result<Self, AcpOptionsJsonError> {
206 if options.agent.is_some() && options.model.is_some() {
207 return Err(AcpOptionsJsonError::ConflictingAgentSelection);
208 }
209 if options.reasoning_effort.is_some() && options.model.is_none() {
210 return Err(AcpOptionsJsonError::ReasoningEffortWithoutModel);
211 }
212
213 let settings_source = SettingsSourceArgs::from_json_options(options.settings, options.settings_file)?;
214
215 let config = Self {
216 log_dir: options.log_dir.unwrap_or_else(default_log_dir),
217 agent: options.agent,
218 model: options.model,
219 reasoning_effort: options.reasoning_effort,
220 provider_connections: ProviderConnectionOverrides::new(options.providers.unwrap_or_default()),
221 settings_source,
222 };
223 config.validate_reasoning_effort()?;
224 Ok(config)
225 }
226
227 fn validate_reasoning_effort(&self) -> Result<(), AcpOptionsJsonError> {
228 if let Some(model) = self.model.as_deref() {
229 validate_reasoning_effort(model, self.reasoning_effort)?;
230 }
231 Ok(())
232 }
233}
234
235fn setup_logging(log_dir: &Path) {
236 create_dir_all(log_dir).ok();
237 tracing_subscriber::fmt()
238 .with_writer(daily(log_dir, "aether-acp.log"))
239 .with_ansi(false) .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")))
241 .pretty()
242 .init();
243}
244
245fn default_log_dir() -> PathBuf {
246 PathBuf::from("/tmp/aether-acp-logs")
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use clap::Parser;
253
254 #[derive(Debug, Parser)]
255 struct TestCli {
256 #[command(flatten)]
257 args: AcpArgs,
258 }
259
260 #[test]
261 fn agent_conflicts_with_model() {
262 let err = TestCli::try_parse_from(["test", "--agent", "planner", "--model", "anthropic:claude-sonnet-4-5"])
263 .expect_err("agent and model should conflict");
264 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
265 }
266
267 #[test]
268 fn agent_conflicts_with_reasoning_effort() {
269 let err = TestCli::try_parse_from(["test", "--agent", "planner", "--reasoning-effort", "high"])
270 .expect_err("agent and reasoning effort should conflict");
271 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
272 }
273
274 #[test]
275 fn reasoning_effort_requires_model() {
276 let err = TestCli::try_parse_from(["test", "--reasoning-effort", "high"])
277 .expect_err("reasoning effort should require model");
278 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
279 }
280
281 #[test]
282 fn reasoning_effort_with_model_is_allowed() {
283 let cli =
284 TestCli::try_parse_from(["test", "--model", "anthropic:claude-sonnet-4-5", "--reasoning-effort", "high"])
285 .expect("reasoning effort can configure an explicit model session");
286 assert_eq!(cli.args.reasoning_effort, Some(ReasoningEffort::High));
287 }
288
289 #[test]
290 fn options_json_conflicts_with_individual_flags() {
291 let err = TestCli::try_parse_from([
292 "test",
293 "--options-json",
294 r#"{"model":"anthropic:claude-sonnet-4-5"}"#,
295 "--model",
296 "anthropic:claude-sonnet-4-5",
297 ])
298 .expect_err("options JSON should conflict with individual ACP flags");
299 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
300 }
301
302 #[test]
303 fn resolves_options_json() {
304 let cli = TestCli::try_parse_from([
305 "test",
306 "--options-json",
307 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"}}}"#,
308 ])
309 .unwrap();
310
311 let config = AcpRunConfig::from_args(cli.args).unwrap();
312
313 assert_eq!(config.log_dir, PathBuf::from("/tmp/custom-aether-logs"));
314 assert_eq!(config.model.as_deref(), Some("anthropic:claude-sonnet-4-5"));
315 assert_eq!(config.reasoning_effort, Some(ReasoningEffort::High));
316 assert!(config.settings_source.settings_json.as_deref().unwrap().contains(r#""agents":[]"#));
317 let bedrock = config.provider_connections.config_for("bedrock");
318 assert_eq!(bedrock.base_url.as_deref(), Some("http://127.0.0.1:8787"));
319 assert_eq!(bedrock.auth_mode, llm::ProviderAuthMode::None);
320 }
321
322 #[test]
323 fn options_json_rejects_unsupported_reasoning_effort() {
324 let error = AcpRunConfig::from_options(AcpOptions {
325 model: Some("anthropic:claude-opus-4-6".to_string()),
326 reasoning_effort: Some(ReasoningEffort::Xhigh),
327 ..AcpOptions::default()
328 })
329 .unwrap_err();
330
331 assert!(matches!(error, AcpOptionsJsonError::UnsupportedReasoningEffort(_)));
332 }
333
334 #[test]
335 fn options_json_validates_selection_rules() {
336 let err = AcpRunConfig::from_options(AcpOptions {
337 reasoning_effort: Some(ReasoningEffort::High),
338 ..AcpOptions::default()
339 })
340 .unwrap_err();
341
342 assert!(matches!(err, AcpOptionsJsonError::ReasoningEffortWithoutModel));
343 }
344}