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