acp: Support launching custom agent servers (#36805)

It's enough to add this to your settings:

```json
{
    "agent_servers": {
        "Name Of Your Agent": {
            "command": "/path/to/custom/agent",
            "args": ["arguments", "that", "you", "want"],
        }
    }
}
```

Release Notes:

- N/A
This commit is contained in:
Antonio Scandurra 2025-08-23 16:30:54 +02:00 committed by GitHub
parent 70575d1115
commit 61bc1cc441
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 238 additions and 91 deletions

View file

@ -15,7 +15,7 @@ use std::{path::Path, rc::Rc};
use thiserror::Error;
use anyhow::{Context as _, Result};
use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity};
use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntity};
use acp_thread::{AcpThread, AuthRequired, LoadError};
@ -24,7 +24,7 @@ use acp_thread::{AcpThread, AuthRequired, LoadError};
pub struct UnsupportedVersion;
pub struct AcpConnection {
server_name: &'static str,
server_name: SharedString,
connection: Rc<acp::ClientSideConnection>,
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
auth_methods: Vec<acp::AuthMethod>,
@ -38,7 +38,7 @@ pub struct AcpSession {
}
pub async fn connect(
server_name: &'static str,
server_name: SharedString,
command: AgentServerCommand,
root_dir: &Path,
cx: &mut AsyncApp,
@ -51,7 +51,7 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1;
impl AcpConnection {
pub async fn stdio(
server_name: &'static str,
server_name: SharedString,
command: AgentServerCommand,
root_dir: &Path,
cx: &mut AsyncApp,
@ -121,7 +121,7 @@ impl AcpConnection {
cx.update(|cx| {
AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| {
registry.set_active_connection(server_name, &connection, cx)
registry.set_active_connection(server_name.clone(), &connection, cx)
});
})?;
@ -187,7 +187,7 @@ impl AgentConnection for AcpConnection {
let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
let thread = cx.new(|_cx| {
AcpThread::new(
self.server_name,
self.server_name.clone(),
self.clone(),
project,
action_log,

View file

@ -1,5 +1,6 @@
mod acp;
mod claude;
mod custom;
mod gemini;
mod settings;
@ -7,6 +8,7 @@ mod settings;
pub mod e2e_tests;
pub use claude::*;
pub use custom::*;
pub use gemini::*;
pub use settings::*;
@ -31,9 +33,9 @@ pub fn init(cx: &mut App) {
pub trait AgentServer: Send {
fn logo(&self) -> ui::IconName;
fn name(&self) -> &'static str;
fn empty_state_headline(&self) -> &'static str;
fn empty_state_message(&self) -> &'static str;
fn name(&self) -> SharedString;
fn empty_state_headline(&self) -> SharedString;
fn empty_state_message(&self) -> SharedString;
fn connect(
&self,

View file

@ -30,7 +30,7 @@ use futures::{
io::BufReader,
select_biased,
};
use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
use gpui::{App, AppContext, AsyncApp, Entity, SharedString, Task, WeakEntity};
use serde::{Deserialize, Serialize};
use util::{ResultExt, debug_panic};
@ -43,16 +43,16 @@ use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri
pub struct ClaudeCode;
impl AgentServer for ClaudeCode {
fn name(&self) -> &'static str {
"Claude Code"
fn name(&self) -> SharedString {
"Claude Code".into()
}
fn empty_state_headline(&self) -> &'static str {
fn empty_state_headline(&self) -> SharedString {
self.name()
}
fn empty_state_message(&self) -> &'static str {
"How can I help you today?"
fn empty_state_message(&self) -> SharedString {
"How can I help you today?".into()
}
fn logo(&self) -> ui::IconName {

View file

@ -0,0 +1,59 @@
use crate::{AgentServerCommand, AgentServerSettings};
use acp_thread::AgentConnection;
use anyhow::Result;
use gpui::{App, Entity, SharedString, Task};
use project::Project;
use std::{path::Path, rc::Rc};
use ui::IconName;
/// A generic agent server implementation for custom user-defined agents
pub struct CustomAgentServer {
name: SharedString,
command: AgentServerCommand,
}
impl CustomAgentServer {
pub fn new(name: SharedString, settings: &AgentServerSettings) -> Self {
Self {
name,
command: settings.command.clone(),
}
}
}
impl crate::AgentServer for CustomAgentServer {
fn name(&self) -> SharedString {
self.name.clone()
}
fn logo(&self) -> IconName {
IconName::Terminal
}
fn empty_state_headline(&self) -> SharedString {
"No conversations yet".into()
}
fn empty_state_message(&self) -> SharedString {
format!("Start a conversation with {}", self.name).into()
}
fn connect(
&self,
root_dir: &Path,
_project: &Entity<Project>,
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
let server_name = self.name();
let command = self.command.clone();
let root_dir = root_dir.to_path_buf();
cx.spawn(async move |mut cx| {
crate::acp::connect(server_name, command, &root_dir, &mut cx).await
})
}
fn into_any(self: Rc<Self>) -> Rc<dyn std::any::Any> {
self
}
}

View file

@ -1,17 +1,15 @@
use crate::AgentServer;
use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
use agent_client_protocol as acp;
use futures::{FutureExt, StreamExt, channel::mpsc, select};
use gpui::{AppContext, Entity, TestAppContext};
use indoc::indoc;
use project::{FakeFs, Project};
use std::{
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use crate::AgentServer;
use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
use agent_client_protocol as acp;
use futures::{FutureExt, StreamExt, channel::mpsc, select};
use gpui::{AppContext, Entity, TestAppContext};
use indoc::indoc;
use project::{FakeFs, Project};
use util::path;
pub async fn test_basic<T, F>(server: F, cx: &mut TestAppContext)
@ -479,6 +477,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
gemini: Some(crate::AgentServerSettings {
command: crate::gemini::tests::local_command(),
}),
custom: collections::HashMap::default(),
},
cx,
);

View file

@ -4,11 +4,10 @@ use std::{any::Any, path::Path};
use crate::{AgentServer, AgentServerCommand};
use acp_thread::{AgentConnection, LoadError};
use anyhow::Result;
use gpui::{Entity, Task};
use gpui::{App, Entity, SharedString, Task};
use language_models::provider::google::GoogleLanguageModelProvider;
use project::Project;
use settings::SettingsStore;
use ui::App;
use crate::AllAgentServersSettings;
@ -18,16 +17,16 @@ pub struct Gemini;
const ACP_ARG: &str = "--experimental-acp";
impl AgentServer for Gemini {
fn name(&self) -> &'static str {
"Gemini CLI"
fn name(&self) -> SharedString {
"Gemini CLI".into()
}
fn empty_state_headline(&self) -> &'static str {
fn empty_state_headline(&self) -> SharedString {
self.name()
}
fn empty_state_message(&self) -> &'static str {
"Ask questions, edit files, run commands"
fn empty_state_message(&self) -> SharedString {
"Ask questions, edit files, run commands".into()
}
fn logo(&self) -> ui::IconName {

View file

@ -1,6 +1,7 @@
use crate::AgentServerCommand;
use anyhow::Result;
use gpui::App;
use collections::HashMap;
use gpui::{App, SharedString};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources};
@ -13,9 +14,13 @@ pub fn init(cx: &mut App) {
pub struct AllAgentServersSettings {
pub gemini: Option<AgentServerSettings>,
pub claude: Option<AgentServerSettings>,
/// Custom agent servers configured by the user
#[serde(flatten)]
pub custom: HashMap<SharedString, AgentServerSettings>,
}
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
pub struct AgentServerSettings {
#[serde(flatten)]
pub command: AgentServerCommand,
@ -29,13 +34,26 @@ impl settings::Settings for AllAgentServersSettings {
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
let mut settings = AllAgentServersSettings::default();
for AllAgentServersSettings { gemini, claude } in sources.defaults_and_customizations() {
for AllAgentServersSettings {
gemini,
claude,
custom,
} in sources.defaults_and_customizations()
{
if gemini.is_some() {
settings.gemini = gemini.clone();
}
if claude.is_some() {
settings.claude = claude.clone();
}
// Merge custom agents
for (name, config) in custom {
// Skip built-in agent names to avoid conflicts
if name != "gemini" && name != "claude" {
settings.custom.insert(name.clone(), config.clone());
}
}
}
Ok(settings)