mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
feat: add ACP backend crate skeleton
This commit is contained in:
parent
b3096d2288
commit
afe0546d73
5 changed files with 424 additions and 40 deletions
188
Cargo.lock
generated
188
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
36
lib/crates/fabro-acp/Cargo.toml
Normal file
36
lib/crates/fabro-acp/Cargo.toml
Normal 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"
|
||||
235
lib/crates/fabro-acp/src/command.rs
Normal file
235
lib/crates/fabro-acp/src/command.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
3
lib/crates/fabro-acp/src/lib.rs
Normal file
3
lib/crates/fabro-acp/src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod command;
|
||||
|
||||
pub use command::{AcpCommand, AcpCommandError, default_acp_command, resolve_acp_command};
|
||||
Loading…
Add table
Reference in a new issue