feat: add ACP backend crate skeleton

This commit is contained in:
Bryan Helmkamp 2026-05-11 10:30:46 -04:00
parent b3096d2288
commit afe0546d73
No known key found for this signature in database
5 changed files with 424 additions and 40 deletions

188
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -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"

View file

@ -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<String>,
env: HashMap<String, String>,
}
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<String, String> {
&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::<Vec<_>>()
.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<agent_client_protocol::Error> 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<AcpCommand, AcpCommandError> {
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<AcpCommand, AcpCommandError> {
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::<serde_json::Value>(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<const N: usize>(
display: impl Into<String>,
program: impl Into<PathBuf>,
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")
);
}
}

View file

@ -0,0 +1,3 @@
pub mod command;
pub use command::{AcpCommand, AcpCommandError, default_acp_command, resolve_acp_command};