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