1pub(crate) mod config_setting;
2pub(crate) mod handlers;
3pub(crate) mod mappers;
4pub(crate) mod model_config;
5pub(crate) mod relay;
6pub(crate) mod session;
7pub(crate) mod session_manager;
8pub(crate) mod session_registry;
9pub(crate) mod session_store;
10pub(crate) mod stdio;
11pub mod testing;
12
13pub use mappers::map_mcp_prompt_to_available_command;
14pub use session_manager::SessionManager;
15
16use crate::acp::handlers::acp_agent_builder;
17use crate::acp::stdio::Stdio;
18use crate::provider_connection_args::ProviderConnectionArgs;
19use crate::settings_args::SettingsSourceArgs;
20use agent_client_protocol as acp;
21use llm::ReasoningEffort;
22use std::sync::Arc;
23use std::{fs::create_dir_all, path::PathBuf};
24use tracing::info;
25use tracing_appender::rolling::daily;
26use tracing_subscriber::EnvFilter;
27
28use aether_auth::OAuthCredentialStorage;
29use session_manager::{InitialSessionSelection, SessionManagerConfig};
30use session_registry::SessionRegistry;
31use session_store::SessionStore;
32
33#[derive(clap::Args, Debug)]
34pub struct AcpArgs {
35 #[clap(long, default_value = "/tmp/aether-acp-logs")]
37 pub log_dir: PathBuf,
38
39 #[clap(long, conflicts_with_all = ["model", "reasoning_effort"])]
41 pub agent: Option<String>,
42
43 #[clap(long, conflicts_with = "agent")]
46 pub model: Option<String>,
47
48 #[clap(long, value_name = "low|medium|high|xhigh", requires = "model", conflicts_with = "agent")]
50 pub reasoning_effort: Option<ReasoningEffort>,
51
52 #[command(flatten)]
53 pub provider_connection: ProviderConnectionArgs,
54
55 #[command(flatten)]
56 pub settings_source: SettingsSourceArgs,
57}
58
59#[derive(Debug)]
61pub enum AcpRunOutcome {
62 CleanDisconnect,
64}
65
66#[derive(Debug)]
68pub enum AcpRunError {
69 Protocol(acp::Error),
70}
71
72impl std::fmt::Display for AcpRunError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 AcpRunError::Protocol(e) => write!(f, "ACP protocol error: {e}"),
76 }
77 }
78}
79
80impl std::error::Error for AcpRunError {
81 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
82 match self {
83 AcpRunError::Protocol(e) => Some(e),
84 }
85 }
86}
87
88pub async fn run_acp(args: AcpArgs) -> Result<AcpRunOutcome, AcpRunError> {
89 info!("Starting Aether ACP server");
90
91 setup_logging(&args);
92
93 let initial_selection = if let Some(agent) = args.agent.clone() {
94 InitialSessionSelection::agent(agent)
95 } else if let Some(model) = args.model.clone() {
96 InitialSessionSelection::model(model, args.reasoning_effort)
97 } else {
98 InitialSessionSelection::default()
99 };
100 let session_store =
101 SessionStore::new().map_or_else(|e| panic!("Failed to initialize session store: {e}"), Arc::new);
102 let oauth_credential_store: Arc<dyn OAuthCredentialStorage> =
103 Arc::new(aether_auth::OsKeyringStore::with_platform_store());
104 let provider_connections = args.provider_connection.into_overrides();
105 let manager = Arc::new(SessionManager::new(SessionManagerConfig {
106 registry: Arc::new(SessionRegistry::new()),
107 session_store,
108 oauth_credential_store,
109 initial_selection,
110 settings_source: args.settings_source,
111 provider_connections,
112 }));
113
114 let connect_result = acp_agent_builder(manager.clone()).connect_to(Stdio::new()).await;
115 manager.shutdown_all_sessions().await;
116
117 match connect_result {
118 Ok(()) => Ok(AcpRunOutcome::CleanDisconnect),
119 Err(err) => Err(AcpRunError::Protocol(err)),
120 }
121}
122
123fn setup_logging(args: &AcpArgs) {
124 create_dir_all(&args.log_dir).ok();
125 tracing_subscriber::fmt()
126 .with_writer(daily(&args.log_dir, "aether-acp.log"))
127 .with_ansi(false) .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")))
129 .pretty()
130 .init();
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use clap::Parser;
137
138 #[derive(Debug, Parser)]
139 struct TestCli {
140 #[command(flatten)]
141 args: AcpArgs,
142 }
143
144 #[test]
145 fn agent_conflicts_with_model() {
146 let err = TestCli::try_parse_from(["test", "--agent", "planner", "--model", "anthropic:claude-sonnet-4-5"])
147 .expect_err("agent and model should conflict");
148 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
149 }
150
151 #[test]
152 fn agent_conflicts_with_reasoning_effort() {
153 let err = TestCli::try_parse_from(["test", "--agent", "planner", "--reasoning-effort", "high"])
154 .expect_err("agent and reasoning effort should conflict");
155 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
156 }
157
158 #[test]
159 fn reasoning_effort_requires_model() {
160 let err = TestCli::try_parse_from(["test", "--reasoning-effort", "high"])
161 .expect_err("reasoning effort should require model");
162 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
163 }
164
165 #[test]
166 fn reasoning_effort_with_model_is_allowed() {
167 let cli =
168 TestCli::try_parse_from(["test", "--model", "anthropic:claude-sonnet-4-5", "--reasoning-effort", "high"])
169 .expect("reasoning effort can configure an explicit model session");
170 assert_eq!(cli.args.reasoning_effort, Some(ReasoningEffort::High));
171 }
172}