From afe0546d7303d631c1a0f14e3b7b307366f3c577 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 11 May 2026 10:30:46 -0400 Subject: [PATCH] feat: add ACP backend crate skeleton --- Cargo.lock | 188 +++++++++++++++++----- Cargo.toml | 2 + lib/crates/fabro-acp/Cargo.toml | 36 +++++ lib/crates/fabro-acp/src/command.rs | 235 ++++++++++++++++++++++++++++ lib/crates/fabro-acp/src/lib.rs | 3 + 5 files changed, 424 insertions(+), 40 deletions(-) create mode 100644 lib/crates/fabro-acp/Cargo.toml create mode 100644 lib/crates/fabro-acp/src/command.rs create mode 100644 lib/crates/fabro-acp/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 655be86cb..92854b102 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,72 @@ dependencies = [ "subtle", ] +[[package]] +name = "agent-client-protocol" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af62fb84df2af0f933d8f5fd78b843fa5eb0ec5a48fa1b528c41951d0bbe36c" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "anyhow", + "futures", + "futures-concurrency", + "jsonrpcmsg", + "rmcp", + "rustc-hash", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce42c2d3c048c12897eef2e577dfff1e3355c632c9f1625cc953b9df48b44631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49bae57dad1c28a362fbdcf7bab0583316a02b45a70792109fced55780a3b63c" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_with", + "strum", + "tracing", +] + +[[package]] +name = "agent-client-protocol-tokio" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1572b219f22c4b3be0f20f934c8b6f1d1457126ce72923c4f6608f96153b65" +dependencies = [ + "agent-client-protocol", + "futures", + "serde", + "serde_json", + "shell-words", + "tokio", + "tokio-util", +] + [[package]] name = "ahash" version = "0.8.12" @@ -594,6 +660,15 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -1088,16 +1163,6 @@ dependencies = [ "darling_macro 0.14.4", ] -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - [[package]] name = "darling" version = "0.23.0" @@ -1122,20 +1187,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.117", -] - [[package]] name = "darling_core" version = "0.23.0" @@ -1160,17 +1211,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.23.0" @@ -1309,6 +1349,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -1537,6 +1578,28 @@ dependencies = [ "libc", ] +[[package]] +name = "fabro-acp" +version = "0.230.0-nightly.0" +dependencies = [ + "agent-client-protocol", + "agent-client-protocol-tokio", + "bytes", + "fabro-model", + "fabro-sandbox", + "fabro-types", + "fabro-util", + "futures", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "fabro-agent" version = "0.230.0-nightly.0" @@ -2530,6 +2593,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flatbuffers" version = "25.12.19" @@ -2800,6 +2869,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -2823,6 +2905,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -3689,6 +3784,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonrpcmsg" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "jsonschema" version = "0.42.2" @@ -5587,6 +5692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" dependencies = [ "async-trait", + "base64", "chrono", "futures", "http", @@ -6134,11 +6240,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.17.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -6153,11 +6260,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.17.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6888,6 +6995,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "futures-util", "hashbrown 0.15.5", diff --git a/Cargo.toml b/Cargo.toml index b0892c9bd..008bf5855 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ version = "0.230.0-nightly.0" license = "MIT" [workspace.dependencies] +agent-client-protocol = "0.11.1" +agent-client-protocol-tokio = "0.11.1" anyhow = "1" axum = { version = "0.8" } axum-extra = { version = "0.10", features = ["cookie-private"] } diff --git a/lib/crates/fabro-acp/Cargo.toml b/lib/crates/fabro-acp/Cargo.toml new file mode 100644 index 000000000..408b8a7d9 --- /dev/null +++ b/lib/crates/fabro-acp/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "fabro-acp" +edition.workspace = true +version.workspace = true +publish = false +license.workspace = true +description = "Agent Client Protocol backend support for Fabro" + +[features] +test-support = [] + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +agent-client-protocol.workspace = true +agent-client-protocol-tokio.workspace = true +fabro-model = { path = "../fabro-model" } +fabro-sandbox = { path = "../fabro-sandbox" } +fabro-types = { path = "../fabro-types" } +fabro-util = { path = "../fabro-util" } +bytes.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-util = { workspace = true, features = ["compat", "io"] } +futures.workspace = true +uuid.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/lib/crates/fabro-acp/src/command.rs b/lib/crates/fabro-acp/src/command.rs new file mode 100644 index 000000000..9fb85cfb9 --- /dev/null +++ b/lib/crates/fabro-acp/src/command.rs @@ -0,0 +1,235 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + str::FromStr, +}; + +use agent_client_protocol::schema::McpServer; +use agent_client_protocol_tokio::AcpAgent; +use fabro_model::Provider; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcpCommand { + display: String, + program: PathBuf, + args: Vec, + env: HashMap, +} + +impl AcpCommand { + #[must_use] + pub fn program(&self) -> &Path { + &self.program + } + + #[must_use] + pub fn args(&self) -> &[String] { + &self.args + } + + #[must_use] + pub fn env(&self) -> &HashMap { + &self.env + } + + #[must_use] + pub fn display(&self) -> &str { + &self.display + } + + #[must_use] + pub fn to_shell_command(&self) -> String { + std::iter::once(self.program.to_string_lossy().into_owned()) + .chain(self.args.iter().cloned()) + .map(|part| fabro_sandbox::shell_quote(&part)) + .collect::>() + .join(" ") + } +} + +impl std::fmt::Display for AcpCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.display) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AcpCommandError { + #[error("acp_command must not be empty")] + EmptyOverride, + #[error("only stdio ACP commands are supported")] + UnsupportedTransport, + #[error("failed to parse acp_command")] + Parse(#[source] agent_client_protocol::Error), +} + +impl From for AcpCommandError { + fn from(error: agent_client_protocol::Error) -> Self { + Self::Parse(error) + } +} + +#[must_use] +pub fn default_acp_command(provider: Provider) -> AcpCommand { + match provider { + Provider::Anthropic => command_from_parts( + "npx -y @zed-industries/claude-code-acp@latest", + "npx", + ["-y", "@zed-industries/claude-code-acp@latest"], + ), + Provider::Gemini => command_from_parts( + "npx -y -- @google/gemini-cli@latest --experimental-acp", + "npx", + ["-y", "--", "@google/gemini-cli@latest", "--experimental-acp"], + ), + Provider::OpenAi + | Provider::Kimi + | Provider::Zai + | Provider::Minimax + | Provider::Inception + | Provider::OpenAiCompatible => command_from_parts( + "npx -y @zed-industries/codex-acp@latest", + "npx", + ["-y", "@zed-industries/codex-acp@latest"], + ), + } +} + +pub fn resolve_acp_command( + provider: Provider, + override_command: Option<&str>, +) -> Result { + if let Some(raw) = override_command { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(AcpCommandError::EmptyOverride); + } + return parse_acp_command(trimmed); + } + + Ok(default_acp_command(provider)) +} + +fn parse_acp_command(raw: &str) -> Result { + reject_non_stdio_json_transport(raw)?; + + let agent = AcpAgent::from_str(raw)?; + let McpServer::Stdio(stdio) = agent.into_server() else { + return Err(AcpCommandError::UnsupportedTransport); + }; + + Ok(AcpCommand { + display: raw.to_string(), + program: stdio.command, + args: stdio.args, + env: stdio + .env + .into_iter() + .map(|env| (env.name, env.value)) + .collect(), + }) +} + +fn reject_non_stdio_json_transport(raw: &str) -> Result<(), AcpCommandError> { + let trimmed = raw.trim_start(); + if !trimmed.starts_with('{') { + return Ok(()); + } + + let Ok(value) = serde_json::from_str::(trimmed) else { + return Ok(()); + }; + + match value.get("type").and_then(serde_json::Value::as_str) { + Some("stdio") | None => Ok(()), + Some(_) => Err(AcpCommandError::UnsupportedTransport), + } +} + +fn command_from_parts( + display: impl Into, + program: impl Into, + args: [&str; N], +) -> AcpCommand { + AcpCommand { + display: display.into(), + program: program.into(), + args: args.into_iter().map(str::to_string).collect(), + env: HashMap::new(), + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use fabro_model::Provider; + + use super::*; + + #[test] + fn default_command_for_anthropic_uses_zed_claude_acp() { + assert_eq!( + default_acp_command(Provider::Anthropic).to_string(), + "npx -y @zed-industries/claude-code-acp@latest" + ); + } + + #[test] + fn default_command_for_openai_compatible_family_uses_zed_codex_acp() { + for provider in [ + Provider::OpenAi, + Provider::Kimi, + Provider::Zai, + Provider::Minimax, + Provider::Inception, + Provider::OpenAiCompatible, + ] { + assert_eq!( + default_acp_command(provider).to_string(), + "npx -y @zed-industries/codex-acp@latest" + ); + } + } + + #[test] + fn default_command_for_gemini_uses_experimental_acp() { + assert_eq!( + default_acp_command(Provider::Gemini).to_string(), + "npx -y -- @google/gemini-cli@latest --experimental-acp" + ); + } + + #[test] + fn explicit_acp_command_overrides_provider_default() { + let command = resolve_acp_command(Provider::OpenAi, Some("python fake_agent.py")).unwrap(); + assert_eq!(command.to_string(), "python fake_agent.py"); + assert_eq!(command.program(), Path::new("python")); + assert_eq!(command.args(), &["fake_agent.py".to_string()]); + } + + #[test] + fn blank_acp_command_is_rejected() { + let err = resolve_acp_command(Provider::OpenAi, Some(" ")).unwrap_err(); + assert!(err.to_string().contains("acp_command must not be empty")); + } + + #[test] + fn json_stdio_acp_command_is_supported() { + let raw = r#"{"type":"stdio","name":"fake","command":"python","args":["fake agent.py"],"env":[{"name":"MODE","value":"test"}]}"#; + let command = resolve_acp_command(Provider::OpenAi, Some(raw)).unwrap(); + assert_eq!(command.program(), Path::new("python")); + assert_eq!(command.args(), &["fake agent.py".to_string()]); + assert_eq!(command.env().get("MODE").map(String::as_str), Some("test")); + } + + #[test] + fn non_stdio_acp_command_is_rejected() { + let raw = r#"{"type":"http","name":"remote","url":"https://example.test/acp"}"#; + let err = resolve_acp_command(Provider::OpenAi, Some(raw)).unwrap_err(); + assert!( + err.to_string() + .contains("only stdio ACP commands are supported") + ); + } +} diff --git a/lib/crates/fabro-acp/src/lib.rs b/lib/crates/fabro-acp/src/lib.rs new file mode 100644 index 000000000..cb6725e7c --- /dev/null +++ b/lib/crates/fabro-acp/src/lib.rs @@ -0,0 +1,3 @@ +pub mod command; + +pub use command::{AcpCommand, AcpCommandError, default_acp_command, resolve_acp_command};