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