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

@ -46,7 +46,7 @@ pub struct AcpConnectionRegistry {
}
struct ActiveConnection {
server_name: &'static str,
server_name: SharedString,
connection: Weak<acp::ClientSideConnection>,
}
@ -63,12 +63,12 @@ impl AcpConnectionRegistry {
pub fn set_active_connection(
&self,
server_name: &'static str,
server_name: impl Into<SharedString>,
connection: &Rc<acp::ClientSideConnection>,
cx: &mut Context<Self>,
) {
self.active_connection.replace(Some(ActiveConnection {
server_name,
server_name: server_name.into(),
connection: Rc::downgrade(connection),
}));
cx.notify();
@ -85,7 +85,7 @@ struct AcpTools {
}
struct WatchedConnection {
server_name: &'static str,
server_name: SharedString,
messages: Vec<WatchedConnectionMessage>,
list_state: ListState,
connection: Weak<acp::ClientSideConnection>,
@ -142,7 +142,7 @@ impl AcpTools {
});
self.watched_connection = Some(WatchedConnection {
server_name: active_connection.server_name,
server_name: active_connection.server_name.clone(),
messages: vec![],
list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)),
connection: active_connection.connection.clone(),
@ -442,7 +442,7 @@ impl Item for AcpTools {
"ACP: {}",
self.watched_connection
.as_ref()
.map_or("Disconnected", |connection| connection.server_name)
.map_or("Disconnected", |connection| &connection.server_name)
)
.into()
}

View file

@ -3,7 +3,7 @@ use std::{any::Any, path::Path, rc::Rc, sync::Arc};
use agent_servers::AgentServer;
use anyhow::Result;
use fs::Fs;
use gpui::{App, Entity, Task};
use gpui::{App, Entity, SharedString, Task};
use project::Project;
use prompt_store::PromptStore;
@ -22,16 +22,16 @@ impl NativeAgentServer {
}
impl AgentServer for NativeAgentServer {
fn name(&self) -> &'static str {
"Zed Agent"
fn name(&self) -> SharedString {
"Zed Agent".into()
}
fn empty_state_headline(&self) -> &'static str {
fn empty_state_headline(&self) -> SharedString {
self.name()
}
fn empty_state_message(&self) -> &'static str {
""
fn empty_state_message(&self) -> SharedString {
"".into()
}
fn logo(&self) -> ui::IconName {

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)

View file

@ -600,7 +600,7 @@ impl AcpThreadView {
let view = registry.read(cx).provider(&provider_id).map(|provider| {
provider.configuration_view(
language_model::ConfigurationViewTargetAgent::Other(agent_name),
language_model::ConfigurationViewTargetAgent::Other(agent_name.clone()),
window,
cx,
)
@ -1372,7 +1372,7 @@ impl AcpThreadView {
.icon_color(Color::Muted)
.style(ButtonStyle::Transparent)
.tooltip(move |_window, cx| {
cx.new(|_| UnavailableEditingTooltip::new(agent_name.into()))
cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone()))
.into()
})
)
@ -3911,13 +3911,13 @@ impl AcpThreadView {
match AgentSettings::get_global(cx).notify_when_agent_waiting {
NotifyWhenAgentWaiting::PrimaryScreen => {
if let Some(primary) = cx.primary_display() {
self.pop_up(icon, caption.into(), title.into(), window, primary, cx);
self.pop_up(icon, caption.into(), title, window, primary, cx);
}
}
NotifyWhenAgentWaiting::AllScreens => {
let caption = caption.into();
for screen in cx.displays() {
self.pop_up(icon, caption.clone(), title.into(), window, screen, cx);
self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
}
}
NotifyWhenAgentWaiting::Never => {
@ -5153,16 +5153,16 @@ pub(crate) mod tests {
ui::IconName::Ai
}
fn name(&self) -> &'static str {
"Test"
fn name(&self) -> SharedString {
"Test".into()
}
fn empty_state_headline(&self) -> &'static str {
"Test"
fn empty_state_headline(&self) -> SharedString {
"Test".into()
}
fn empty_state_message(&self) -> &'static str {
"Test"
fn empty_state_message(&self) -> SharedString {
"Test".into()
}
fn connect(

View file

@ -5,6 +5,7 @@ use std::sync::Arc;
use std::time::Duration;
use acp_thread::AcpThread;
use agent_servers::AgentServerSettings;
use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize};
@ -128,7 +129,7 @@ pub fn init(cx: &mut App) {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.external_thread(action.agent, None, None, window, cx)
panel.external_thread(action.agent.clone(), None, None, window, cx)
});
}
})
@ -239,7 +240,7 @@ enum WhichFontSize {
None,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub enum AgentType {
#[default]
Zed,
@ -247,23 +248,29 @@ pub enum AgentType {
Gemini,
ClaudeCode,
NativeAgent,
Custom {
name: SharedString,
settings: AgentServerSettings,
},
}
impl AgentType {
fn label(self) -> impl Into<SharedString> {
fn label(&self) -> SharedString {
match self {
Self::Zed | Self::TextThread => "Zed Agent",
Self::NativeAgent => "Agent 2",
Self::Gemini => "Gemini CLI",
Self::ClaudeCode => "Claude Code",
Self::Zed | Self::TextThread => "Zed Agent".into(),
Self::NativeAgent => "Agent 2".into(),
Self::Gemini => "Gemini CLI".into(),
Self::ClaudeCode => "Claude Code".into(),
Self::Custom { name, .. } => name.into(),
}
}
fn icon(self) -> Option<IconName> {
fn icon(&self) -> Option<IconName> {
match self {
Self::Zed | Self::NativeAgent | Self::TextThread => None,
Self::Gemini => Some(IconName::AiGemini),
Self::ClaudeCode => Some(IconName::AiClaude),
Self::Custom { .. } => Some(IconName::Terminal),
}
}
}
@ -517,7 +524,7 @@ pub struct AgentPanel {
impl AgentPanel {
fn serialize(&mut self, cx: &mut Context<Self>) {
let width = self.width;
let selected_agent = self.selected_agent;
let selected_agent = self.selected_agent.clone();
self.pending_serialization = Some(cx.background_spawn(async move {
KEY_VALUE_STORE
.write_kvp(
@ -607,7 +614,7 @@ impl AgentPanel {
panel.update(cx, |panel, cx| {
panel.width = serialized_panel.width.map(|w| w.round());
if let Some(selected_agent) = serialized_panel.selected_agent {
panel.selected_agent = selected_agent;
panel.selected_agent = selected_agent.clone();
panel.new_agent_thread(selected_agent, window, cx);
}
cx.notify();
@ -1077,14 +1084,17 @@ impl AgentPanel {
cx.spawn_in(window, async move |this, cx| {
let ext_agent = match agent_choice {
Some(agent) => {
cx.background_spawn(async move {
if let Some(serialized) =
serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
{
KEY_VALUE_STORE
.write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
.await
.log_err();
cx.background_spawn({
let agent = agent.clone();
async move {
if let Some(serialized) =
serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
{
KEY_VALUE_STORE
.write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
.await
.log_err();
}
}
})
.detach();
@ -1110,7 +1120,9 @@ impl AgentPanel {
this.update_in(cx, |this, window, cx| {
match ext_agent {
crate::ExternalAgent::Gemini | crate::ExternalAgent::NativeAgent => {
crate::ExternalAgent::Gemini
| crate::ExternalAgent::NativeAgent
| crate::ExternalAgent::Custom { .. } => {
if !cx.has_flag::<GeminiAndNativeFeatureFlag>() {
return;
}
@ -1839,14 +1851,14 @@ impl AgentPanel {
cx: &mut Context<Self>,
) {
if self.selected_agent != agent {
self.selected_agent = agent;
self.selected_agent = agent.clone();
self.serialize(cx);
}
self.new_agent_thread(agent, window, cx);
}
pub fn selected_agent(&self) -> AgentType {
self.selected_agent
self.selected_agent.clone()
}
pub fn new_agent_thread(
@ -1885,6 +1897,13 @@ impl AgentPanel {
window,
cx,
),
AgentType::Custom { name, settings } => self.external_thread(
Some(crate::ExternalAgent::Custom { name, settings }),
None,
None,
window,
cx,
),
}
}
@ -2610,13 +2629,55 @@ impl AgentPanel {
}
}),
)
})
.when(cx.has_flag::<GeminiAndNativeFeatureFlag>(), |mut menu| {
// Add custom agents from settings
let settings =
agent_servers::AllAgentServersSettings::get_global(cx);
for (agent_name, agent_settings) in &settings.custom {
menu = menu.item(
ContextMenuEntry::new(format!("New {} Thread", agent_name))
.icon(IconName::Terminal)
.icon_color(Color::Muted)
.handler({
let workspace = workspace.clone();
let agent_name = agent_name.clone();
let agent_settings = agent_settings.clone();
move |window, cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
if let Some(panel) =
workspace.panel::<AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
panel.set_selected_agent(
AgentType::Custom {
name: agent_name
.clone(),
settings:
agent_settings
.clone(),
},
window,
cx,
);
});
}
});
}
}
}),
);
}
menu
});
menu
}))
}
});
let selected_agent_label = self.selected_agent.label().into();
let selected_agent_label = self.selected_agent.label();
let selected_agent = div()
.id("selected_agent_icon")
.when_some(self.selected_agent.icon(), |this, icon| {

View file

@ -28,13 +28,14 @@ use std::rc::Rc;
use std::sync::Arc;
use agent::{Thread, ThreadId};
use agent_servers::AgentServerSettings;
use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection};
use assistant_slash_command::SlashCommandRegistry;
use client::Client;
use command_palette_hooks::CommandPaletteFilter;
use feature_flags::FeatureFlagAppExt as _;
use fs::Fs;
use gpui::{Action, App, Entity, actions};
use gpui::{Action, App, Entity, SharedString, actions};
use language::LanguageRegistry;
use language_model::{
ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
@ -159,13 +160,17 @@ pub struct NewNativeAgentThreadFromSummary {
from_session_id: agent_client_protocol::SessionId,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum ExternalAgent {
#[default]
Gemini,
ClaudeCode,
NativeAgent,
Custom {
name: SharedString,
settings: AgentServerSettings,
},
}
impl ExternalAgent {
@ -175,9 +180,13 @@ impl ExternalAgent {
history: Entity<agent2::HistoryStore>,
) -> Rc<dyn agent_servers::AgentServer> {
match self {
ExternalAgent::Gemini => Rc::new(agent_servers::Gemini),
ExternalAgent::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
ExternalAgent::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)),
Self::Gemini => Rc::new(agent_servers::Gemini),
Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)),
Self::Custom { name, settings } => Rc::new(agent_servers::CustomAgentServer::new(
name.clone(),
settings,
)),
}
}
}

View file

@ -643,11 +643,11 @@ pub trait LanguageModelProvider: 'static {
fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>>;
}
#[derive(Default, Clone, Copy)]
#[derive(Default, Clone)]
pub enum ConfigurationViewTargetAgent {
#[default]
ZedAgent,
Other(&'static str),
Other(SharedString),
}
#[derive(PartialEq, Eq)]

View file

@ -1041,9 +1041,9 @@ impl Render for ConfigurationView {
v_flex()
.size_full()
.on_action(cx.listener(Self::save_api_key))
.child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match self.target_agent {
ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Anthropic",
ConfigurationViewTargetAgent::Other(agent) => agent,
.child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent {
ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Anthropic".into(),
ConfigurationViewTargetAgent::Other(agent) => agent.clone(),
})))
.child(
List::new()

View file

@ -921,9 +921,9 @@ impl Render for ConfigurationView {
v_flex()
.size_full()
.on_action(cx.listener(Self::save_api_key))
.child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match self.target_agent {
ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Google AI",
ConfigurationViewTargetAgent::Other(agent) => agent,
.child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent {
ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Google AI".into(),
ConfigurationViewTargetAgent::Other(agent) => agent.clone(),
})))
.child(
List::new()