From 99102a84fa30b235f6e383b20246a96f5a6a103f Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 8 Sep 2025 14:19:41 -0400 Subject: [PATCH] ACP over SSH (#37725) This PR adds support for using external agents in SSH projects via ACP, including automatic installation of Gemini CLI and Claude Code, authentication with API keys (for Gemini) and CLI login, and custom agents from user configuration. Co-authored-by: maan2003 Release Notes: - agent: Gemini CLI, Claude Code, and custom external agents can now be used in SSH projects. --------- Co-authored-by: maan2003 --- Cargo.lock | 9 +- crates/acp_thread/src/acp_thread.rs | 2 +- crates/agent2/src/native_agent_server.rs | 14 +- crates/agent_servers/Cargo.toml | 6 +- crates/agent_servers/src/acp.rs | 75 +- crates/agent_servers/src/agent_servers.rs | 337 +----- crates/agent_servers/src/claude.rs | 117 +- crates/agent_servers/src/custom.rs | 46 +- crates/agent_servers/src/e2e_tests.rs | 20 +- crates/agent_servers/src/gemini.rs | 164 +-- crates/agent_servers/src/settings.rs | 110 -- crates/agent_ui/src/acp/message_editor.rs | 11 +- crates/agent_ui/src/acp/thread_view.rs | 155 +-- crates/agent_ui/src/agent_configuration.rs | 34 +- crates/agent_ui/src/agent_panel.rs | 52 +- crates/agent_ui/src/agent_ui.rs | 17 +- crates/project/Cargo.toml | 2 + crates/project/src/agent_server_store.rs | 1091 ++++++++++++++++++ crates/project/src/project.rs | 27 +- crates/proto/proto/ai.proto | 33 + crates/proto/proto/debugger.proto | 9 +- crates/proto/proto/task.proto | 8 + crates/proto/proto/zed.proto | 10 +- crates/proto/src/proto.rs | 12 +- crates/remote_server/src/headless_project.rs | 14 +- crates/zed/Cargo.toml | 1 - crates/zed/src/main.rs | 1 - 27 files changed, 1538 insertions(+), 839 deletions(-) delete mode 100644 crates/agent_servers/src/settings.rs create mode 100644 crates/project/src/agent_server_store.rs diff --git a/Cargo.lock b/Cargo.lock index 49dcb57ef133612ab807ab7c84a25b070c9e1241..09106277c77288f56d1bc348e7d42cfde9366355 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -308,22 +308,18 @@ dependencies = [ "libc", "log", "nix 0.29.0", - "node_runtime", - "paths", "project", "reqwest_client", - "schemars", - "semver", "serde", "serde_json", "settings", "smol", + "task", "tempfile", "thiserror 2.0.12", "ui", "util", "watch", - "which 6.0.3", "workspace-hack", ] @@ -12605,6 +12601,7 @@ dependencies = [ "remote", "rpc", "schemars", + "semver", "serde", "serde_json", "settings", @@ -12624,6 +12621,7 @@ dependencies = [ "unindent", "url", "util", + "watch", "which 6.0.3", "workspace-hack", "worktree", @@ -20367,7 +20365,6 @@ dependencies = [ "acp_tools", "activity_indicator", "agent", - "agent_servers", "agent_settings", "agent_ui", "anyhow", diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index a3a8e31230b749b7b774a380030aab4600d78a07..8afa466bb607c02b7cdfe795b3168c2e20a0ba10 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -2758,7 +2758,7 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_thread(project, Path::new("/test"), cx)) + .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx)) .await .unwrap(); diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index 030d2cce746970bd9c8a0c7f0f5e1516eb68fcaf..0dde0ff98552d4292a4391d2aec4f36419228a25 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -35,10 +35,15 @@ impl AgentServer for NativeAgentServer { fn connect( &self, - _root_dir: &Path, + _root_dir: Option<&Path>, delegate: AgentServerDelegate, cx: &mut App, - ) -> Task>> { + ) -> Task< + Result<( + Rc, + Option, + )>, + > { log::debug!( "NativeAgentServer::connect called for path: {:?}", _root_dir @@ -60,7 +65,10 @@ impl AgentServer for NativeAgentServer { let connection = NativeAgentConnection(agent); log::debug!("NativeAgentServer connection established successfully"); - Ok(Rc::new(connection) as Rc) + Ok(( + Rc::new(connection) as Rc, + None, + )) }) } diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 222feb9aaa31a6ace1e13ba8943f416942e8918c..2a601dcb161b8220870f53e80890fd2bdbc91c13 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -35,22 +35,18 @@ language.workspace = true language_model.workspace = true language_models.workspace = true log.workspace = true -node_runtime.workspace = true -paths.workspace = true project.workspace = true reqwest_client = { workspace = true, optional = true } -schemars.workspace = true -semver.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true +task.workspace = true tempfile.workspace = true thiserror.workspace = true ui.workspace = true util.workspace = true watch.workspace = true -which.workspace = true workspace-hack.workspace = true [target.'cfg(unix)'.dependencies] diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 7991c1e3ccedafe8891ef80c57c4939bb19d2fb1..191bae066ce255ca0e88da215c7513703f7ace0b 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -1,4 +1,3 @@ -use crate::AgentServerCommand; use acp_thread::AgentConnection; use acp_tools::AcpConnectionRegistry; use action_log::ActionLog; @@ -8,8 +7,10 @@ use collections::HashMap; use futures::AsyncBufReadExt as _; use futures::io::BufReader; use project::Project; +use project::agent_server_store::AgentServerCommand; use serde::Deserialize; +use std::path::PathBuf; use std::{any::Any, cell::RefCell}; use std::{path::Path, rc::Rc}; use thiserror::Error; @@ -29,6 +30,7 @@ pub struct AcpConnection { sessions: Rc>>, auth_methods: Vec, agent_capabilities: acp::AgentCapabilities, + root_dir: PathBuf, _io_task: Task>, _wait_task: Task>, _stderr_task: Task>, @@ -43,9 +45,10 @@ pub async fn connect( server_name: SharedString, command: AgentServerCommand, root_dir: &Path, + is_remote: bool, cx: &mut AsyncApp, ) -> Result> { - let conn = AcpConnection::stdio(server_name, command.clone(), root_dir, cx).await?; + let conn = AcpConnection::stdio(server_name, command.clone(), root_dir, is_remote, cx).await?; Ok(Rc::new(conn) as _) } @@ -56,17 +59,21 @@ impl AcpConnection { server_name: SharedString, command: AgentServerCommand, root_dir: &Path, + is_remote: bool, cx: &mut AsyncApp, ) -> Result { - let mut child = util::command::new_smol_command(command.path) + let mut child = util::command::new_smol_command(command.path); + child .args(command.args.iter().map(|arg| arg.as_str())) .envs(command.env.iter().flatten()) - .current_dir(root_dir) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn()?; + .kill_on_drop(true); + if !is_remote { + child.current_dir(root_dir); + } + let mut child = child.spawn()?; let stdout = child.stdout.take().context("Failed to take stdout")?; let stdin = child.stdin.take().context("Failed to take stdin")?; @@ -145,6 +152,7 @@ impl AcpConnection { Ok(Self { auth_methods: response.auth_methods, + root_dir: root_dir.to_owned(), connection, server_name, sessions, @@ -158,6 +166,10 @@ impl AcpConnection { pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities { &self.agent_capabilities.prompt_capabilities } + + pub fn root_dir(&self) -> &Path { + &self.root_dir + } } impl AgentConnection for AcpConnection { @@ -171,29 +183,36 @@ impl AgentConnection for AcpConnection { let sessions = self.sessions.clone(); let cwd = cwd.to_path_buf(); let context_server_store = project.read(cx).context_server_store().read(cx); - let mcp_servers = context_server_store - .configured_server_ids() - .iter() - .filter_map(|id| { - let configuration = context_server_store.configuration_for_server(id)?; - let command = configuration.command(); - Some(acp::McpServer { - name: id.0.to_string(), - command: command.path.clone(), - args: command.args.clone(), - env: if let Some(env) = command.env.as_ref() { - env.iter() - .map(|(name, value)| acp::EnvVariable { - name: name.clone(), - value: value.clone(), - }) - .collect() - } else { - vec![] - }, + let mcp_servers = if project.read(cx).is_local() { + context_server_store + .configured_server_ids() + .iter() + .filter_map(|id| { + let configuration = context_server_store.configuration_for_server(id)?; + let command = configuration.command(); + Some(acp::McpServer { + name: id.0.to_string(), + command: command.path.clone(), + args: command.args.clone(), + env: if let Some(env) = command.env.as_ref() { + env.iter() + .map(|(name, value)| acp::EnvVariable { + name: name.clone(), + value: value.clone(), + }) + .collect() + } else { + vec![] + }, + }) }) - }) - .collect(); + .collect() + } else { + // In SSH projects, the external agent is running on the remote + // machine, and currently we only run MCP servers on the local + // machine. So don't pass any MCP servers to the agent in that case. + Vec::new() + }; cx.spawn(async move |cx| { let response = conn diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index e214dabfc763c2a46f1f4665c3d1f881d5ce406e..7f11d8ce93e9b34d5bb03e1c6306b57bad450efc 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -2,47 +2,25 @@ mod acp; mod claude; mod custom; mod gemini; -mod settings; #[cfg(any(test, feature = "test-support"))] pub mod e2e_tests; -use anyhow::Context as _; pub use claude::*; pub use custom::*; -use fs::Fs; -use fs::RemoveOptions; -use fs::RenameOptions; -use futures::StreamExt as _; pub use gemini::*; -use gpui::AppContext; -use node_runtime::NodeRuntime; -pub use settings::*; +use project::agent_server_store::AgentServerStore; use acp_thread::AgentConnection; -use acp_thread::LoadError; use anyhow::Result; -use anyhow::anyhow; -use collections::HashMap; -use gpui::{App, AsyncApp, Entity, SharedString, Task}; +use gpui::{App, Entity, SharedString, Task}; use project::Project; -use schemars::JsonSchema; -use semver::Version; -use serde::{Deserialize, Serialize}; -use std::str::FromStr as _; -use std::{ - any::Any, - path::{Path, PathBuf}, - rc::Rc, - sync::Arc, -}; -use util::ResultExt as _; +use std::{any::Any, path::Path, rc::Rc}; -pub fn init(cx: &mut App) { - settings::init(cx); -} +pub use acp::AcpConnection; pub struct AgentServerDelegate { + store: Entity, project: Entity, status_tx: Option>, new_version_available: Option>>, @@ -50,11 +28,13 @@ pub struct AgentServerDelegate { impl AgentServerDelegate { pub fn new( + store: Entity, project: Entity, status_tx: Option>, new_version_tx: Option>>, ) -> Self { Self { + store, project, status_tx, new_version_available: new_version_tx, @@ -64,188 +44,6 @@ impl AgentServerDelegate { pub fn project(&self) -> &Entity { &self.project } - - fn get_or_npm_install_builtin_agent( - self, - binary_name: SharedString, - package_name: SharedString, - entrypoint_path: PathBuf, - ignore_system_version: bool, - minimum_version: Option, - cx: &mut App, - ) -> Task> { - let project = self.project; - let fs = project.read(cx).fs().clone(); - let Some(node_runtime) = project.read(cx).node_runtime().cloned() else { - return Task::ready(Err(anyhow!( - "External agents are not yet available in remote projects." - ))); - }; - let status_tx = self.status_tx; - let new_version_available = self.new_version_available; - - cx.spawn(async move |cx| { - if !ignore_system_version { - if let Some(bin) = find_bin_in_path(binary_name.clone(), &project, cx).await { - return Ok(AgentServerCommand { - path: bin, - args: Vec::new(), - env: Default::default(), - }); - } - } - - cx.spawn(async move |cx| { - let node_path = node_runtime.binary_path().await?; - let dir = paths::data_dir() - .join("external_agents") - .join(binary_name.as_str()); - fs.create_dir(&dir).await?; - - let mut stream = fs.read_dir(&dir).await?; - let mut versions = Vec::new(); - let mut to_delete = Vec::new(); - while let Some(entry) = stream.next().await { - let Ok(entry) = entry else { continue }; - let Some(file_name) = entry.file_name() else { - continue; - }; - - if let Some(name) = file_name.to_str() - && let Some(version) = semver::Version::from_str(name).ok() - && fs - .is_file(&dir.join(file_name).join(&entrypoint_path)) - .await - { - versions.push((version, file_name.to_owned())); - } else { - to_delete.push(file_name.to_owned()) - } - } - - versions.sort(); - let newest_version = if let Some((version, file_name)) = versions.last().cloned() - && minimum_version.is_none_or(|minimum_version| version >= minimum_version) - { - versions.pop(); - Some(file_name) - } else { - None - }; - log::debug!("existing version of {package_name}: {newest_version:?}"); - to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name)); - - cx.background_spawn({ - let fs = fs.clone(); - let dir = dir.clone(); - async move { - for file_name in to_delete { - fs.remove_dir( - &dir.join(file_name), - RemoveOptions { - recursive: true, - ignore_if_not_exists: false, - }, - ) - .await - .ok(); - } - } - }) - .detach(); - - let version = if let Some(file_name) = newest_version { - cx.background_spawn({ - let file_name = file_name.clone(); - let dir = dir.clone(); - let fs = fs.clone(); - async move { - let latest_version = - node_runtime.npm_package_latest_version(&package_name).await; - if let Ok(latest_version) = latest_version - && &latest_version != &file_name.to_string_lossy() - { - Self::download_latest_version( - fs, - dir.clone(), - node_runtime, - package_name, - ) - .await - .log_err(); - if let Some(mut new_version_available) = new_version_available { - new_version_available.send(Some(latest_version)).ok(); - } - } - } - }) - .detach(); - file_name - } else { - if let Some(mut status_tx) = status_tx { - status_tx.send("Installing…".into()).ok(); - } - let dir = dir.clone(); - cx.background_spawn(Self::download_latest_version( - fs.clone(), - dir.clone(), - node_runtime, - package_name, - )) - .await? - .into() - }; - - let agent_server_path = dir.join(version).join(entrypoint_path); - let agent_server_path_exists = fs.is_file(&agent_server_path).await; - anyhow::ensure!( - agent_server_path_exists, - "Missing entrypoint path {} after installation", - agent_server_path.to_string_lossy() - ); - - anyhow::Ok(AgentServerCommand { - path: node_path, - args: vec![agent_server_path.to_string_lossy().to_string()], - env: Default::default(), - }) - }) - .await - .map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into()) - }) - } - - async fn download_latest_version( - fs: Arc, - dir: PathBuf, - node_runtime: NodeRuntime, - package_name: SharedString, - ) -> Result { - log::debug!("downloading latest version of {package_name}"); - - let tmp_dir = tempfile::tempdir_in(&dir)?; - - node_runtime - .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")]) - .await?; - - let version = node_runtime - .npm_package_installed_version(tmp_dir.path(), &package_name) - .await? - .context("expected package to be installed")?; - - fs.rename( - &tmp_dir.keep(), - &dir.join(&version), - RenameOptions { - ignore_if_exists: true, - overwrite: false, - }, - ) - .await?; - - anyhow::Ok(version) - } } pub trait AgentServer: Send { @@ -255,10 +53,10 @@ pub trait AgentServer: Send { fn connect( &self, - root_dir: &Path, + root_dir: Option<&Path>, delegate: AgentServerDelegate, cx: &mut App, - ) -> Task>>; + ) -> Task, Option)>>; fn into_any(self: Rc) -> Rc; } @@ -268,120 +66,3 @@ impl dyn AgentServer { self.into_any().downcast().ok() } } - -impl std::fmt::Debug for AgentServerCommand { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let filtered_env = self.env.as_ref().map(|env| { - env.iter() - .map(|(k, v)| { - ( - k, - if util::redact::should_redact(k) { - "[REDACTED]" - } else { - v - }, - ) - }) - .collect::>() - }); - - f.debug_struct("AgentServerCommand") - .field("path", &self.path) - .field("args", &self.args) - .field("env", &filtered_env) - .finish() - } -} - -#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] -pub struct AgentServerCommand { - #[serde(rename = "command")] - pub path: PathBuf, - #[serde(default)] - pub args: Vec, - pub env: Option>, -} - -impl AgentServerCommand { - pub async fn resolve( - path_bin_name: &'static str, - extra_args: &[&'static str], - fallback_path: Option<&Path>, - settings: Option, - project: &Entity, - cx: &mut AsyncApp, - ) -> Option { - if let Some(settings) = settings - && let Some(command) = settings.custom_command() - { - Some(command) - } else { - match find_bin_in_path(path_bin_name.into(), project, cx).await { - Some(path) => Some(Self { - path, - args: extra_args.iter().map(|arg| arg.to_string()).collect(), - env: None, - }), - None => fallback_path.and_then(|path| { - if path.exists() { - Some(Self { - path: path.to_path_buf(), - args: extra_args.iter().map(|arg| arg.to_string()).collect(), - env: None, - }) - } else { - None - } - }), - } - } - } -} - -async fn find_bin_in_path( - bin_name: SharedString, - project: &Entity, - cx: &mut AsyncApp, -) -> Option { - let (env_task, root_dir) = project - .update(cx, |project, cx| { - let worktree = project.visible_worktrees(cx).next(); - match worktree { - Some(worktree) => { - let env_task = project.environment().update(cx, |env, cx| { - env.get_worktree_environment(worktree.clone(), cx) - }); - - let path = worktree.read(cx).abs_path(); - (env_task, path) - } - None => { - let path: Arc = paths::home_dir().as_path().into(); - let env_task = project.environment().update(cx, |env, cx| { - env.get_directory_environment(path.clone(), cx) - }); - (env_task, path) - } - } - }) - .log_err()?; - - cx.background_executor() - .spawn(async move { - let which_result = if cfg!(windows) { - which::which(bin_name.as_str()) - } else { - let env = env_task.await.unwrap_or_default(); - let shell_path = env.get("PATH").cloned(); - which::which_in(bin_name.as_str(), shell_path.as_ref(), root_dir.as_ref()) - }; - - if let Err(which::Error::CannotFindBinaryPath) = which_result { - return None; - } - - which_result.log_err() - }) - .await -} diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 15352ce216f52dfd7a9f372a43c0ec401eb540af..0cb2dead3b342803f3becc8d1567b614cc375842 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -1,60 +1,22 @@ -use settings::SettingsStore; use std::path::Path; use std::rc::Rc; use std::{any::Any, path::PathBuf}; -use anyhow::Result; -use gpui::{App, AppContext as _, SharedString, Task}; +use anyhow::{Context as _, Result}; +use gpui::{App, SharedString, Task}; +use project::agent_server_store::CLAUDE_CODE_NAME; -use crate::{AgentServer, AgentServerDelegate, AllAgentServersSettings}; +use crate::{AgentServer, AgentServerDelegate}; use acp_thread::AgentConnection; #[derive(Clone)] pub struct ClaudeCode; -pub struct ClaudeCodeLoginCommand { +pub struct AgentServerLoginCommand { pub path: PathBuf, pub arguments: Vec, } -impl ClaudeCode { - const BINARY_NAME: &'static str = "claude-code-acp"; - const PACKAGE_NAME: &'static str = "@zed-industries/claude-code-acp"; - - pub fn login_command( - delegate: AgentServerDelegate, - cx: &mut App, - ) -> Task> { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).claude.clone() - }); - - cx.spawn(async move |cx| { - let mut command = if let Some(settings) = settings { - settings.command - } else { - cx.update(|cx| { - delegate.get_or_npm_install_builtin_agent( - Self::BINARY_NAME.into(), - Self::PACKAGE_NAME.into(), - "node_modules/@anthropic-ai/claude-code/cli.js".into(), - true, - Some("0.2.5".parse().unwrap()), - cx, - ) - })? - .await? - }; - command.args.push("/login".into()); - - Ok(ClaudeCodeLoginCommand { - path: command.path, - arguments: command.args, - }) - }) - } -} - impl AgentServer for ClaudeCode { fn telemetry_id(&self) -> &'static str { "claude-code" @@ -70,56 +32,33 @@ impl AgentServer for ClaudeCode { fn connect( &self, - root_dir: &Path, + root_dir: Option<&Path>, delegate: AgentServerDelegate, cx: &mut App, - ) -> Task>> { - let root_dir = root_dir.to_path_buf(); - let fs = delegate.project().read(cx).fs().clone(); - let server_name = self.name(); - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).claude.clone() - }); - let project = delegate.project().clone(); + ) -> Task, Option)>> { + let name = self.name(); + let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().to_string()); + let is_remote = delegate.project.read(cx).is_via_remote_server(); + let store = delegate.store.downgrade(); cx.spawn(async move |cx| { - let mut project_env = project - .update(cx, |project, cx| { - project.directory_environment(root_dir.as_path().into(), cx) - })? - .await - .unwrap_or_default(); - let mut command = if let Some(settings) = settings { - settings.command - } else { - cx.update(|cx| { - delegate.get_or_npm_install_builtin_agent( - Self::BINARY_NAME.into(), - Self::PACKAGE_NAME.into(), - format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(), - true, - None, - cx, - ) - })? - .await? - }; - project_env.extend(command.env.take().unwrap_or_default()); - command.env = Some(project_env); - - command - .env - .get_or_insert_default() - .insert("ANTHROPIC_API_KEY".to_owned(), "".to_owned()); - - let root_dir_exists = fs.is_dir(&root_dir).await; - anyhow::ensure!( - root_dir_exists, - "Session root {} does not exist or is not a directory", - root_dir.to_string_lossy() - ); - - crate::acp::connect(server_name, command.clone(), &root_dir, cx).await + let (command, root_dir, login) = store + .update(cx, |store, cx| { + let agent = store + .get_external_agent(&CLAUDE_CODE_NAME.into()) + .context("Claude Code is not registered")?; + anyhow::Ok(agent.get_command( + root_dir.as_deref(), + Default::default(), + delegate.status_tx, + delegate.new_version_available, + &mut cx.to_async(), + )) + })?? + .await?; + let connection = + crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?; + Ok((connection, login)) }) } diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 8d9670473a619eea9dc6730d04a4c807937aa393..0fb595ff02cda53ee5ffe4e778417e35d86a8805 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -1,19 +1,19 @@ -use crate::{AgentServerCommand, AgentServerDelegate}; +use crate::AgentServerDelegate; use acp_thread::AgentConnection; -use anyhow::Result; +use anyhow::{Context as _, Result}; use gpui::{App, SharedString, Task}; +use project::agent_server_store::ExternalAgentServerName; 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, command: AgentServerCommand) -> Self { - Self { name, command } + pub fn new(name: SharedString) -> Self { + Self { name } } } @@ -32,14 +32,36 @@ impl crate::AgentServer for CustomAgentServer { fn connect( &self, - root_dir: &Path, - _delegate: AgentServerDelegate, + root_dir: Option<&Path>, + delegate: AgentServerDelegate, cx: &mut App, - ) -> Task>> { - let server_name = self.name(); - let command = self.command.clone(); - let root_dir = root_dir.to_path_buf(); - cx.spawn(async move |cx| crate::acp::connect(server_name, command, &root_dir, cx).await) + ) -> Task, Option)>> { + let name = self.name(); + let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().to_string()); + let is_remote = delegate.project.read(cx).is_via_remote_server(); + let store = delegate.store.downgrade(); + + cx.spawn(async move |cx| { + let (command, root_dir, login) = store + .update(cx, |store, cx| { + let agent = store + .get_external_agent(&ExternalAgentServerName(name.clone())) + .with_context(|| { + format!("Custom agent server `{}` is not registered", name) + })?; + anyhow::Ok(agent.get_command( + root_dir.as_deref(), + Default::default(), + delegate.status_tx, + delegate.new_version_available, + &mut cx.to_async(), + )) + })?? + .await?; + let connection = + crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?; + Ok((connection, login)) + }) } fn into_any(self: Rc) -> Rc { diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index f801ef246807f93c4bbdc26a1ff3bd478cc476d0..eda55a596a2fbfd20024ea9f15157d6d9dd7c2b3 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -1,12 +1,12 @@ use crate::{AgentServer, AgentServerDelegate}; -#[cfg(test)] -use crate::{AgentServerCommand, CustomAgentServerSettings}; 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}; +#[cfg(test)] +use project::agent_server_store::{AgentServerCommand, CustomAgentServerSettings}; +use project::{FakeFs, Project, agent_server_store::AllAgentServersSettings}; use std::{ path::{Path, PathBuf}, sync::Arc, @@ -449,7 +449,6 @@ pub use common_e2e_tests; // Helpers pub async fn init_test(cx: &mut TestAppContext) -> Arc { - #[cfg(test)] use settings::Settings; env_logger::try_init().ok(); @@ -468,11 +467,11 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { language_model::init(client.clone(), cx); language_models::init(user_store, client, cx); agent_settings::init(cx); - crate::settings::init(cx); + AllAgentServersSettings::register(cx); #[cfg(test)] - crate::AllAgentServersSettings::override_global( - crate::AllAgentServersSettings { + AllAgentServersSettings::override_global( + AllAgentServersSettings { claude: Some(CustomAgentServerSettings { command: AgentServerCommand { path: "claude-code-acp".into(), @@ -498,10 +497,11 @@ pub async fn new_test_thread( current_dir: impl AsRef, cx: &mut TestAppContext, ) -> Entity { - let delegate = AgentServerDelegate::new(project.clone(), None, None); + let store = project.read_with(cx, |project, _| project.agent_server_store().clone()); + let delegate = AgentServerDelegate::new(store, project.clone(), None, None); - let connection = cx - .update(|cx| server.connect(current_dir.as_ref(), delegate, cx)) + let (connection, _) = cx + .update(|cx| server.connect(Some(current_dir.as_ref()), delegate, cx)) .await .unwrap(); diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 7e40d85767b7ed407a22ece55580bee7317a5e6d..20a18dfd332d25a828846bcbc04fd37c03c81dad 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -1,21 +1,17 @@ use std::rc::Rc; use std::{any::Any, path::Path}; -use crate::acp::AcpConnection; use crate::{AgentServer, AgentServerDelegate}; -use acp_thread::{AgentConnection, LoadError}; -use anyhow::Result; -use gpui::{App, AppContext as _, SharedString, Task}; +use acp_thread::AgentConnection; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use gpui::{App, SharedString, Task}; use language_models::provider::google::GoogleLanguageModelProvider; -use settings::SettingsStore; - -use crate::AllAgentServersSettings; +use project::agent_server_store::GEMINI_NAME; #[derive(Clone)] pub struct Gemini; -const ACP_ARG: &str = "--experimental-acp"; - impl AgentServer for Gemini { fn telemetry_id(&self) -> &'static str { "gemini-cli" @@ -31,126 +27,37 @@ impl AgentServer for Gemini { fn connect( &self, - root_dir: &Path, + root_dir: Option<&Path>, delegate: AgentServerDelegate, cx: &mut App, - ) -> Task>> { - let root_dir = root_dir.to_path_buf(); - let fs = delegate.project().read(cx).fs().clone(); - let server_name = self.name(); - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).gemini.clone() - }); - let project = delegate.project().clone(); + ) -> Task, Option)>> { + let name = self.name(); + let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().to_string()); + let is_remote = delegate.project.read(cx).is_via_remote_server(); + let store = delegate.store.downgrade(); cx.spawn(async move |cx| { - let ignore_system_version = settings - .as_ref() - .and_then(|settings| settings.ignore_system_version) - .unwrap_or(true); - let mut project_env = project - .update(cx, |project, cx| { - project.directory_environment(root_dir.as_path().into(), cx) - })? - .await - .unwrap_or_default(); - let mut command = if let Some(settings) = settings - && let Some(command) = settings.custom_command() - { - command - } else { - cx.update(|cx| { - delegate.get_or_npm_install_builtin_agent( - Self::BINARY_NAME.into(), - Self::PACKAGE_NAME.into(), - format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(), - ignore_system_version, - Some(Self::MINIMUM_VERSION.parse().unwrap()), - cx, - ) - })? - .await? - }; - if !command.args.contains(&ACP_ARG.into()) { - command.args.push(ACP_ARG.into()); - } + let mut extra_env = HashMap::default(); if let Some(api_key) = cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() { - project_env - .insert("GEMINI_API_KEY".to_owned(), api_key.key); - } - project_env.extend(command.env.take().unwrap_or_default()); - command.env = Some(project_env); - - let root_dir_exists = fs.is_dir(&root_dir).await; - anyhow::ensure!( - root_dir_exists, - "Session root {} does not exist or is not a directory", - root_dir.to_string_lossy() - ); - - let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await; - match &result { - Ok(connection) => { - if let Some(connection) = connection.clone().downcast::() - && !connection.prompt_capabilities().image - { - let version_output = util::command::new_smol_command(&command.path) - .args(command.args.iter()) - .arg("--version") - .kill_on_drop(true) - .output() - .await; - let current_version = - String::from_utf8(version_output?.stdout)?.trim().to_owned(); - - log::error!("connected to gemini, but missing prompt_capabilities.image (version is {current_version})"); - return Err(LoadError::Unsupported { - current_version: current_version.into(), - command: (command.path.to_string_lossy().to_string() + " " + &command.args.join(" ")).into(), - minimum_version: Self::MINIMUM_VERSION.into(), - } - .into()); - } - } - Err(e) => { - let version_fut = util::command::new_smol_command(&command.path) - .args(command.args.iter()) - .arg("--version") - .kill_on_drop(true) - .output(); - - let help_fut = util::command::new_smol_command(&command.path) - .args(command.args.iter()) - .arg("--help") - .kill_on_drop(true) - .output(); - - let (version_output, help_output) = - futures::future::join(version_fut, help_fut).await; - let Some(version_output) = version_output.ok().and_then(|output| String::from_utf8(output.stdout).ok()) else { - return result; - }; - let Some((help_stdout, help_stderr)) = help_output.ok().and_then(|output| String::from_utf8(output.stdout).ok().zip(String::from_utf8(output.stderr).ok())) else { - return result; - }; - - let current_version = version_output.trim().to_string(); - let supported = help_stdout.contains(ACP_ARG) || current_version.parse::().is_ok_and(|version| version >= Self::MINIMUM_VERSION.parse::().unwrap()); - - log::error!("failed to create ACP connection to gemini (version is {current_version}, supported: {supported}): {e}"); - log::debug!("gemini --help stdout: {help_stdout:?}"); - log::debug!("gemini --help stderr: {help_stderr:?}"); - if !supported { - return Err(LoadError::Unsupported { - current_version: current_version.into(), - command: (command.path.to_string_lossy().to_string() + " " + &command.args.join(" ")).into(), - minimum_version: Self::MINIMUM_VERSION.into(), - } - .into()); - } - } + extra_env.insert("GEMINI_API_KEY".into(), api_key.key); } - result + let (command, root_dir, login) = store + .update(cx, |store, cx| { + let agent = store + .get_external_agent(&GEMINI_NAME.into()) + .context("Gemini CLI is not registered")?; + anyhow::Ok(agent.get_command( + root_dir.as_deref(), + extra_env, + delegate.status_tx, + delegate.new_version_available, + &mut cx.to_async(), + )) + })?? + .await?; + let connection = + crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?; + Ok((connection, login)) }) } @@ -159,18 +66,11 @@ impl AgentServer for Gemini { } } -impl Gemini { - const PACKAGE_NAME: &str = "@google/gemini-cli"; - - const MINIMUM_VERSION: &str = "0.2.1"; - - const BINARY_NAME: &str = "gemini"; -} - #[cfg(test)] pub(crate) mod tests { + use project::agent_server_store::AgentServerCommand; + use super::*; - use crate::AgentServerCommand; use std::path::Path; crate::common_e2e_tests!(async |_, _, _| Gemini, allow_option_id = "proceed_once"); diff --git a/crates/agent_servers/src/settings.rs b/crates/agent_servers/src/settings.rs deleted file mode 100644 index 167753296a1a489128ba916f114f4895c15afcf9..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/settings.rs +++ /dev/null @@ -1,110 +0,0 @@ -use std::path::PathBuf; - -use crate::AgentServerCommand; -use anyhow::Result; -use collections::HashMap; -use gpui::{App, SharedString}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsKey, SettingsSources, SettingsUi}; - -pub fn init(cx: &mut App) { - AllAgentServersSettings::register(cx); -} - -#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, SettingsUi, SettingsKey)] -#[settings_key(key = "agent_servers")] -pub struct AllAgentServersSettings { - pub gemini: Option, - pub claude: Option, - - /// Custom agent servers configured by the user - #[serde(flatten)] - pub custom: HashMap, -} - -#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)] -pub struct BuiltinAgentServerSettings { - /// Absolute path to a binary to be used when launching this agent. - /// - /// This can be used to run a specific binary without automatic downloads or searching `$PATH`. - #[serde(rename = "command")] - pub path: Option, - /// If a binary is specified in `command`, it will be passed these arguments. - pub args: Option>, - /// If a binary is specified in `command`, it will be passed these environment variables. - pub env: Option>, - /// Whether to skip searching `$PATH` for an agent server binary when - /// launching this agent. - /// - /// This has no effect if a `command` is specified. Otherwise, when this is - /// `false`, Zed will search `$PATH` for an agent server binary and, if one - /// is found, use it for threads with this agent. If no agent binary is - /// found on `$PATH`, Zed will automatically install and use its own binary. - /// When this is `true`, Zed will not search `$PATH`, and will always use - /// its own binary. - /// - /// Default: true - pub ignore_system_version: Option, -} - -impl BuiltinAgentServerSettings { - pub(crate) fn custom_command(self) -> Option { - self.path.map(|path| AgentServerCommand { - path, - args: self.args.unwrap_or_default(), - env: self.env, - }) - } -} - -impl From for BuiltinAgentServerSettings { - fn from(value: AgentServerCommand) -> Self { - BuiltinAgentServerSettings { - path: Some(value.path), - args: Some(value.args), - env: value.env, - ..Default::default() - } - } -} - -#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)] -pub struct CustomAgentServerSettings { - #[serde(flatten)] - pub command: AgentServerCommand, -} - -impl settings::Settings for AllAgentServersSettings { - type FileContent = Self; - - fn load(sources: SettingsSources, _: &mut App) -> Result { - let mut settings = AllAgentServersSettings::default(); - - 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) - } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} -} diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 4f57c6161d8f5fae3aa0b5762ed85e49dfd20b43..de5421c0907674cc9c62c6e326239aa9ffc726f2 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -699,10 +699,15 @@ impl MessageEditor { self.project.read(cx).fs().clone(), self.history_store.clone(), )); - let delegate = AgentServerDelegate::new(self.project.clone(), None, None); - let connection = server.connect(Path::new(""), delegate, cx); + let delegate = AgentServerDelegate::new( + self.project.read(cx).agent_server_store().clone(), + self.project.clone(), + None, + None, + ); + let connection = server.connect(None, delegate, cx); cx.spawn(async move |_, cx| { - let agent = connection.await?; + let (agent, _) = connection.await?; let agent = agent.downcast::().unwrap(); let summary = agent .0 diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index bd7a9a1479e763bd42b825a562f7ee4a7c85e57e..8e8d5493cfd3408ba6fe0338b97f16bb8dbf15ba 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -6,7 +6,7 @@ use acp_thread::{ use acp_thread::{AgentConnection, Plan}; use action_log::ActionLog; use agent_client_protocol::{self as acp, PromptCapabilities}; -use agent_servers::{AgentServer, AgentServerDelegate, ClaudeCode}; +use agent_servers::{AgentServer, AgentServerDelegate}; use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting}; use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer}; use anyhow::{Context as _, Result, anyhow, bail}; @@ -40,7 +40,6 @@ use std::path::Path; use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; -use task::SpawnInTerminal; use terminal_view::terminal_panel::TerminalPanel; use text::Anchor; use theme::{AgentFontSize, ThemeSettings}; @@ -263,6 +262,7 @@ pub struct AcpThreadView { workspace: WeakEntity, project: Entity, thread_state: ThreadState, + login: Option, history_store: Entity, hovered_recent_history_item: Option, entry_view_state: Entity, @@ -392,6 +392,7 @@ impl AcpThreadView { project: project.clone(), entry_view_state, thread_state: Self::initial_state(agent, resume_thread, workspace, project, window, cx), + login: None, message_editor, model_selector: None, profile_selector: None, @@ -444,9 +445,11 @@ impl AcpThreadView { window: &mut Window, cx: &mut Context, ) -> ThreadState { - if !project.read(cx).is_local() && agent.clone().downcast::().is_none() { + if project.read(cx).is_via_collab() + && agent.clone().downcast::().is_none() + { return ThreadState::LoadError(LoadError::Other( - "External agents are not yet supported for remote projects.".into(), + "External agents are not yet supported in shared projects.".into(), )); } let mut worktrees = project.read(cx).visible_worktrees(cx).collect::>(); @@ -466,20 +469,23 @@ impl AcpThreadView { Some(worktree.read(cx).abs_path()) } }) - .next() - .unwrap_or_else(|| paths::home_dir().as_path().into()); + .next(); let (status_tx, mut status_rx) = watch::channel("Loading…".into()); let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None); let delegate = AgentServerDelegate::new( + project.read(cx).agent_server_store().clone(), project.clone(), Some(status_tx), Some(new_version_available_tx), ); - let connect_task = agent.connect(&root_dir, delegate, cx); + let connect_task = agent.connect(root_dir.as_deref(), delegate, cx); let load_task = cx.spawn_in(window, async move |this, cx| { let connection = match connect_task.await { - Ok(connection) => connection, + Ok((connection, login)) => { + this.update(cx, |this, _| this.login = login).ok(); + connection + } Err(err) => { this.update_in(cx, |this, window, cx| { if err.downcast_ref::().is_some() { @@ -506,6 +512,14 @@ impl AcpThreadView { }) .log_err() } else { + let root_dir = if let Some(acp_agent) = connection + .clone() + .downcast::() + { + acp_agent.root_dir().into() + } else { + root_dir.unwrap_or(paths::home_dir().as_path().into()) + }; cx.update(|_, cx| { connection .clone() @@ -1462,9 +1476,12 @@ impl AcpThreadView { self.thread_error.take(); configuration_view.take(); pending_auth_method.replace(method.clone()); - let authenticate = if method.0.as_ref() == "claude-login" { + let authenticate = if (method.0.as_ref() == "claude-login" + || method.0.as_ref() == "spawn-gemini-cli") + && let Some(login) = self.login.clone() + { if let Some(workspace) = self.workspace.upgrade() { - Self::spawn_claude_login(&workspace, window, cx) + Self::spawn_external_agent_login(login, workspace, false, window, cx) } else { Task::ready(Ok(())) } @@ -1511,31 +1528,28 @@ impl AcpThreadView { })); } - fn spawn_claude_login( - workspace: &Entity, + fn spawn_external_agent_login( + login: task::SpawnInTerminal, + workspace: Entity, + previous_attempt: bool, window: &mut Window, cx: &mut App, ) -> Task> { let Some(terminal_panel) = workspace.read(cx).panel::(cx) else { return Task::ready(Ok(())); }; - let project_entity = workspace.read(cx).project(); - let project = project_entity.read(cx); - let cwd = project.first_project_directory(cx); - let shell = project.terminal_settings(&cwd, cx).shell.clone(); - - let delegate = AgentServerDelegate::new(project_entity.clone(), None, None); - let command = ClaudeCode::login_command(delegate, cx); + let project = workspace.read(cx).project().clone(); + let cwd = project.read(cx).first_project_directory(cx); + let shell = project.read(cx).terminal_settings(&cwd, cx).shell.clone(); window.spawn(cx, async move |cx| { - let login_command = command.await?; - let command = login_command - .path - .to_str() - .with_context(|| format!("invalid login command: {:?}", login_command.path))?; - let command = shlex::try_quote(command)?; - let args = login_command - .arguments + let mut task = login.clone(); + task.command = task + .command + .map(|command| anyhow::Ok(shlex::try_quote(&command)?.to_string())) + .transpose()?; + task.args = task + .args .iter() .map(|arg| { Ok(shlex::try_quote(arg) @@ -1543,26 +1557,16 @@ impl AcpThreadView { .to_string()) }) .collect::>>()?; + task.full_label = task.label.clone(); + task.id = task::TaskId(format!("external-agent-{}-login", task.label)); + task.command_label = task.label.clone(); + task.use_new_terminal = true; + task.allow_concurrent_runs = true; + task.hide = task::HideStrategy::Always; + task.shell = shell; let terminal = terminal_panel.update_in(cx, |terminal_panel, window, cx| { - terminal_panel.spawn_task( - &SpawnInTerminal { - id: task::TaskId("claude-login".into()), - full_label: "claude /login".to_owned(), - label: "claude /login".to_owned(), - command: Some(command.into()), - args, - command_label: "claude /login".to_owned(), - cwd, - use_new_terminal: true, - allow_concurrent_runs: true, - hide: task::HideStrategy::Always, - shell, - ..Default::default() - }, - window, - cx, - ) + terminal_panel.spawn_task(&login, window, cx) })?; let terminal = terminal.await?; @@ -1578,7 +1582,9 @@ impl AcpThreadView { cx.background_executor().timer(Duration::from_secs(1)).await; let content = terminal.update(cx, |terminal, _cx| terminal.get_content())?; - if content.contains("Login successful") { + if content.contains("Login successful") + || content.contains("Type your message") + { return anyhow::Ok(()); } } @@ -1594,6 +1600,9 @@ impl AcpThreadView { } } _ = exit_status => { + if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server())? && login.label.contains("gemini") { + return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, true, window, cx))?.await + } return Err(anyhow!("exited before logging in")); } } @@ -3088,26 +3097,38 @@ impl AcpThreadView { }) .children(connection.auth_methods().iter().enumerate().rev().map( |(ix, method)| { - Button::new( - SharedString::from(method.id.0.clone()), - method.name.clone(), - ) - .when(ix == 0, |el| { - el.style(ButtonStyle::Tinted(ui::TintColor::Warning)) - }) - .label_size(LabelSize::Small) - .on_click({ - let method_id = method.id.clone(); - cx.listener(move |this, _, window, cx| { - telemetry::event!( - "Authenticate Agent Started", - agent = this.agent.telemetry_id(), - method = method_id - ); + let (method_id, name) = if self + .project + .read(cx) + .is_via_remote_server() + && method.id.0.as_ref() == "oauth-personal" + && method.name == "Log in with Google" + { + ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into()) + } else { + (method.id.0.clone(), method.name.clone()) + }; - this.authenticate(method_id.clone(), window, cx) + Button::new(SharedString::from(method_id.clone()), name) + .when(ix == 0, |el| { + el.style(ButtonStyle::Tinted(ui::TintColor::Warning)) + }) + .label_size(LabelSize::Small) + .on_click({ + cx.listener(move |this, _, window, cx| { + telemetry::event!( + "Authenticate Agent Started", + agent = this.agent.telemetry_id(), + method = method_id + ); + + this.authenticate( + acp::AuthMethodId(method_id.clone()), + window, + cx, + ) + }) }) - }) }, )), ) @@ -5710,11 +5731,11 @@ pub(crate) mod tests { fn connect( &self, - _root_dir: &Path, + _root_dir: Option<&Path>, _delegate: AgentServerDelegate, _cx: &mut App, - ) -> Task>> { - Task::ready(Ok(Rc::new(self.connection.clone()))) + ) -> Task, Option)>> { + Task::ready(Ok((Rc::new(self.connection.clone()), None))) } fn into_any(self: Rc) -> Rc { diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 5981a3c52bf52ff4549b2f73a6322e308725750d..8ae21841fcfd8ac834541f1060ac1fc5dc04b7c2 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -5,7 +5,6 @@ mod tool_picker; use std::{ops::Range, sync::Arc}; -use agent_servers::{AgentServerCommand, AllAgentServersSettings, CustomAgentServerSettings}; use agent_settings::AgentSettings; use anyhow::Result; use assistant_tool::{ToolSource, ToolWorkingSet}; @@ -26,6 +25,10 @@ use language_model::{ }; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ + agent_server_store::{ + AgentServerCommand, AgentServerStore, AllAgentServersSettings, CLAUDE_CODE_NAME, + CustomAgentServerSettings, GEMINI_NAME, + }, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, project_settings::{ContextServerSettings, ProjectSettings}, }; @@ -45,11 +48,13 @@ pub(crate) use manage_profiles_modal::ManageProfilesModal; use crate::{ AddContextServer, ExternalAgent, NewExternalAgentThread, agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider}, + placeholder_command, }; pub struct AgentConfiguration { fs: Arc, language_registry: Arc, + agent_server_store: Entity, workspace: WeakEntity, focus_handle: FocusHandle, configuration_views_by_provider: HashMap, @@ -66,6 +71,7 @@ pub struct AgentConfiguration { impl AgentConfiguration { pub fn new( fs: Arc, + agent_server_store: Entity, context_server_store: Entity, tools: Entity, language_registry: Arc, @@ -104,6 +110,7 @@ impl AgentConfiguration { workspace, focus_handle, configuration_views_by_provider: HashMap::default(), + agent_server_store, context_server_store, expanded_context_server_tools: HashMap::default(), expanded_provider_configurations: HashMap::default(), @@ -991,17 +998,30 @@ impl AgentConfiguration { } fn render_agent_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { - let settings = AllAgentServersSettings::get_global(cx).clone(); - let user_defined_agents = settings + let custom_settings = cx + .global::() + .get::(None) .custom - .iter() - .map(|(name, settings)| { + .clone(); + let user_defined_agents = self + .agent_server_store + .read(cx) + .external_agents() + .filter(|name| name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME) + .cloned() + .collect::>(); + let user_defined_agents = user_defined_agents + .into_iter() + .map(|name| { self.render_agent_server( IconName::Ai, name.clone(), ExternalAgent::Custom { - name: name.clone(), - command: settings.command.clone(), + name: name.clone().into(), + command: custom_settings + .get(&name.0) + .map(|settings| settings.command.clone()) + .unwrap_or(placeholder_command()), }, cx, ) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index d021eaefb5ebff43fad1fe4822b3758550a0179f..ad42b0001d60fb84cd879aeed85de35c6364eea5 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -5,9 +5,11 @@ use std::sync::Arc; use std::time::Duration; use acp_thread::AcpThread; -use agent_servers::AgentServerCommand; use agent2::{DbThreadMetadata, HistoryEntry}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; +use project::agent_server_store::{ + AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, GEMINI_NAME, +}; use serde::{Deserialize, Serialize}; use zed_actions::OpenBrowser; use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent}; @@ -33,7 +35,9 @@ use crate::{ thread_history::{HistoryEntryElement, ThreadHistory}, ui::{AgentOnboardingModal, EndTrialUpsell}, }; -use crate::{ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary}; +use crate::{ + ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary, placeholder_command, +}; use agent::{ Thread, ThreadError, ThreadEvent, ThreadId, ThreadSummary, TokenUsageRatio, context_store::ContextStore, @@ -62,7 +66,7 @@ use project::{DisableAiSettings, Project, ProjectPath, Worktree}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; use search::{BufferSearchBar, buffer_search}; -use settings::{Settings, update_settings_file}; +use settings::{Settings, SettingsStore, update_settings_file}; use theme::ThemeSettings; use time::UtcOffset; use ui::utils::WithRemSize; @@ -1094,7 +1098,7 @@ impl AgentPanel { let workspace = self.workspace.clone(); let project = self.project.clone(); let fs = self.fs.clone(); - let is_not_local = !self.project.read(cx).is_local(); + let is_via_collab = self.project.read(cx).is_via_collab(); const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; @@ -1126,7 +1130,7 @@ impl AgentPanel { agent } None => { - if is_not_local { + if is_via_collab { ExternalAgent::NativeAgent } else { cx.background_spawn(async move { @@ -1503,6 +1507,7 @@ impl AgentPanel { } pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context) { + let agent_server_store = self.project.read(cx).agent_server_store().clone(); let context_server_store = self.project.read(cx).context_server_store(); let tools = self.thread_store.read(cx).tools(); let fs = self.fs.clone(); @@ -1511,6 +1516,7 @@ impl AgentPanel { self.configuration = Some(cx.new(|cx| { AgentConfiguration::new( fs, + agent_server_store, context_server_store, tools, self.language_registry.clone(), @@ -2503,6 +2509,7 @@ impl AgentPanel { } fn render_toolbar_new(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); let active_thread = match &self.active_view { @@ -2535,8 +2542,10 @@ impl AgentPanel { .with_handle(self.new_thread_menu_handle.clone()) .menu({ let workspace = self.workspace.clone(); - let is_not_local = workspace - .update(cx, |workspace, cx| !workspace.project().read(cx).is_local()) + let is_via_collab = workspace + .update(cx, |workspace, cx| { + workspace.project().read(cx).is_via_collab() + }) .unwrap_or_default(); move |window, cx| { @@ -2628,7 +2637,7 @@ impl AgentPanel { ContextMenuEntry::new("New Gemini CLI Thread") .icon(IconName::AiGemini) .icon_color(Color::Muted) - .disabled(is_not_local) + .disabled(is_via_collab) .handler({ let workspace = workspace.clone(); move |window, cx| { @@ -2655,7 +2664,7 @@ impl AgentPanel { menu.item( ContextMenuEntry::new("New Claude Code Thread") .icon(IconName::AiClaude) - .disabled(is_not_local) + .disabled(is_via_collab) .icon_color(Color::Muted) .handler({ let workspace = workspace.clone(); @@ -2680,19 +2689,25 @@ impl AgentPanel { ) }) .when(cx.has_flag::(), |mut menu| { - // Add custom agents from settings - let settings = - agent_servers::AllAgentServersSettings::get_global(cx); - for (agent_name, agent_settings) in &settings.custom { + let agent_names = agent_server_store + .read(cx) + .external_agents() + .filter(|name| { + name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME + }) + .cloned() + .collect::>(); + let custom_settings = cx.global::().get::(None).custom.clone(); + for agent_name in agent_names { menu = menu.item( ContextMenuEntry::new(format!("New {} Thread", agent_name)) .icon(IconName::Terminal) .icon_color(Color::Muted) - .disabled(is_not_local) + .disabled(is_via_collab) .handler({ let workspace = workspace.clone(); let agent_name = agent_name.clone(); - let agent_settings = agent_settings.clone(); + let custom_settings = custom_settings.clone(); move |window, cx| { if let Some(workspace) = workspace.upgrade() { workspace.update(cx, |workspace, cx| { @@ -2703,10 +2718,9 @@ impl AgentPanel { panel.new_agent_thread( AgentType::Custom { name: agent_name - .clone(), - command: agent_settings - .command - .clone(), + .clone() + .into(), + command: custom_settings.get(&agent_name.0).map(|settings| settings.command.clone()).unwrap_or(placeholder_command()) }, window, cx, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index e60c0baff99d1f615cbe439aed754a35f2a5c8db..b16643854ee213c9d0f4370e422b012c1deebd9d 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -28,7 +28,6 @@ use std::rc::Rc; use std::sync::Arc; use agent::{Thread, ThreadId}; -use agent_servers::AgentServerCommand; use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection}; use assistant_slash_command::SlashCommandRegistry; use client::Client; @@ -41,6 +40,7 @@ use language_model::{ ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, }; use project::DisableAiSettings; +use project::agent_server_store::AgentServerCommand; use prompt_store::PromptBuilder; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -174,6 +174,14 @@ enum ExternalAgent { }, } +fn placeholder_command() -> AgentServerCommand { + AgentServerCommand { + path: "/placeholder".into(), + args: vec![], + env: None, + } +} + impl ExternalAgent { fn name(&self) -> &'static str { match self { @@ -193,10 +201,9 @@ impl ExternalAgent { 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, command } => Rc::new(agent_servers::CustomAgentServer::new( - name.clone(), - command.clone(), - )), + Self::Custom { name, command: _ } => { + Rc::new(agent_servers::CustomAgentServer::new(name.clone())) + } } } } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 57d6d6ca283af0fd51ed10622f55edc9fb086e7e..3d46a44770ec2504991899e98c1504116611c20b 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -67,6 +67,7 @@ regex.workspace = true remote.workspace = true rpc.workspace = true schemars.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true @@ -85,6 +86,7 @@ text.workspace = true toml.workspace = true url.workspace = true util.workspace = true +watch.workspace = true which.workspace = true worktree.workspace = true zlog.workspace = true diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..5f9342c8933d43da9bab6d63bc455ea0496d4712 --- /dev/null +++ b/crates/project/src/agent_server_store.rs @@ -0,0 +1,1091 @@ +use std::{ + any::Any, + borrow::Borrow, + path::{Path, PathBuf}, + str::FromStr as _, + sync::Arc, + time::Duration, +}; + +use anyhow::{Context as _, Result, bail}; +use collections::HashMap; +use fs::{Fs, RemoveOptions, RenameOptions}; +use futures::StreamExt as _; +use gpui::{ + App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, +}; +use node_runtime::NodeRuntime; +use remote::RemoteClient; +use rpc::{ + AnyProtoClient, TypedEnvelope, + proto::{self, ToProto}, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{SettingsKey, SettingsSources, SettingsStore, SettingsUi}; +use util::{ResultExt as _, debug_panic}; + +use crate::ProjectEnvironment; + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] +pub struct AgentServerCommand { + #[serde(rename = "command")] + pub path: PathBuf, + #[serde(default)] + pub args: Vec, + pub env: Option>, +} + +impl std::fmt::Debug for AgentServerCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let filtered_env = self.env.as_ref().map(|env| { + env.iter() + .map(|(k, v)| { + ( + k, + if util::redact::should_redact(k) { + "[REDACTED]" + } else { + v + }, + ) + }) + .collect::>() + }); + + f.debug_struct("AgentServerCommand") + .field("path", &self.path) + .field("args", &self.args) + .field("env", &filtered_env) + .finish() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ExternalAgentServerName(pub SharedString); + +impl std::fmt::Display for ExternalAgentServerName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From<&'static str> for ExternalAgentServerName { + fn from(value: &'static str) -> Self { + ExternalAgentServerName(value.into()) + } +} + +impl From for SharedString { + fn from(value: ExternalAgentServerName) -> Self { + value.0 + } +} + +impl Borrow for ExternalAgentServerName { + fn borrow(&self) -> &str { + &self.0 + } +} + +pub trait ExternalAgentServer { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + status_tx: Option>, + new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>>; + + fn as_any_mut(&mut self) -> &mut dyn Any; +} + +impl dyn ExternalAgentServer { + fn downcast_mut(&mut self) -> Option<&mut T> { + self.as_any_mut().downcast_mut() + } +} + +enum AgentServerStoreState { + Local { + node_runtime: NodeRuntime, + fs: Arc, + project_environment: Entity, + downstream_client: Option<(u64, AnyProtoClient)>, + settings: Option, + _subscriptions: [Subscription; 1], + }, + Remote { + project_id: u64, + upstream_client: Entity, + }, + Collab, +} + +pub struct AgentServerStore { + state: AgentServerStoreState, + external_agents: HashMap>, +} + +pub struct AgentServersUpdated; + +impl EventEmitter for AgentServerStore {} + +impl AgentServerStore { + pub fn init_remote(session: &AnyProtoClient) { + session.add_entity_message_handler(Self::handle_external_agents_updated); + session.add_entity_message_handler(Self::handle_loading_status_updated); + session.add_entity_message_handler(Self::handle_new_version_available); + } + + pub fn init_headless(session: &AnyProtoClient) { + session.add_entity_request_handler(Self::handle_get_agent_server_command); + } + + fn agent_servers_settings_changed(&mut self, cx: &mut Context) { + let AgentServerStoreState::Local { + node_runtime, + fs, + project_environment, + downstream_client, + settings: old_settings, + .. + } = &mut self.state + else { + debug_panic!( + "should not be subscribed to agent server settings changes in non-local project" + ); + return; + }; + + let new_settings = cx + .global::() + .get::(None) + .clone(); + if Some(&new_settings) == old_settings.as_ref() { + return; + } + + self.external_agents.clear(); + self.external_agents.insert( + GEMINI_NAME.into(), + Box::new(LocalGemini { + fs: fs.clone(), + node_runtime: node_runtime.clone(), + project_environment: project_environment.clone(), + custom_command: new_settings + .gemini + .clone() + .and_then(|settings| settings.custom_command()), + ignore_system_version: new_settings + .gemini + .as_ref() + .and_then(|settings| settings.ignore_system_version) + .unwrap_or(true), + }), + ); + self.external_agents.insert( + CLAUDE_CODE_NAME.into(), + Box::new(LocalClaudeCode { + fs: fs.clone(), + node_runtime: node_runtime.clone(), + project_environment: project_environment.clone(), + custom_command: new_settings.claude.clone().map(|settings| settings.command), + }), + ); + self.external_agents + .extend(new_settings.custom.iter().map(|(name, settings)| { + ( + ExternalAgentServerName(name.clone()), + Box::new(LocalCustomAgent { + command: settings.command.clone(), + project_environment: project_environment.clone(), + }) as Box, + ) + })); + + *old_settings = Some(new_settings.clone()); + + if let Some((project_id, downstream_client)) = downstream_client { + downstream_client + .send(proto::ExternalAgentsUpdated { + project_id: *project_id, + names: self + .external_agents + .keys() + .map(|name| name.to_string()) + .collect(), + }) + .log_err(); + } + cx.emit(AgentServersUpdated); + } + + pub fn local( + node_runtime: NodeRuntime, + fs: Arc, + project_environment: Entity, + cx: &mut Context, + ) -> Self { + let subscription = cx.observe_global::(|this, cx| { + this.agent_servers_settings_changed(cx); + }); + let this = Self { + state: AgentServerStoreState::Local { + node_runtime, + fs, + project_environment, + downstream_client: None, + settings: None, + _subscriptions: [subscription], + }, + external_agents: Default::default(), + }; + cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(1)).await; + this.update(cx, |this, cx| { + this.agent_servers_settings_changed(cx); + }) + .ok(); + }) + .detach(); + this + } + + pub(crate) fn remote( + project_id: u64, + upstream_client: Entity, + _cx: &mut Context, + ) -> Self { + // Set up the builtin agents here so they're immediately available in + // remote projects--we know that the HeadlessProject on the other end + // will have them. + let external_agents = [ + ( + GEMINI_NAME.into(), + Box::new(RemoteExternalAgentServer { + project_id, + upstream_client: upstream_client.clone(), + name: GEMINI_NAME.into(), + status_tx: None, + new_version_available_tx: None, + }) as Box, + ), + ( + CLAUDE_CODE_NAME.into(), + Box::new(RemoteExternalAgentServer { + project_id, + upstream_client: upstream_client.clone(), + name: CLAUDE_CODE_NAME.into(), + status_tx: None, + new_version_available_tx: None, + }) as Box, + ), + ] + .into_iter() + .collect(); + + Self { + state: AgentServerStoreState::Remote { + project_id, + upstream_client, + }, + external_agents, + } + } + + pub(crate) fn collab(_cx: &mut Context) -> Self { + Self { + state: AgentServerStoreState::Collab, + external_agents: Default::default(), + } + } + + pub fn shared(&mut self, project_id: u64, client: AnyProtoClient) { + match &mut self.state { + AgentServerStoreState::Local { + downstream_client, .. + } => { + client + .send(proto::ExternalAgentsUpdated { + project_id, + names: self + .external_agents + .keys() + .map(|name| name.to_string()) + .collect(), + }) + .log_err(); + *downstream_client = Some((project_id, client)); + } + AgentServerStoreState::Remote { .. } => { + debug_panic!( + "external agents over collab not implemented, remote project should not be shared" + ); + } + AgentServerStoreState::Collab => { + debug_panic!("external agents over collab not implemented, should not be shared"); + } + } + } + + pub fn get_external_agent( + &mut self, + name: &ExternalAgentServerName, + ) -> Option<&mut (dyn ExternalAgentServer + 'static)> { + self.external_agents + .get_mut(name) + .map(|agent| agent.as_mut()) + } + + pub fn external_agents(&self) -> impl Iterator { + self.external_agents.keys() + } + + async fn handle_get_agent_server_command( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let (command, root_dir, login) = this + .update(&mut cx, |this, cx| { + let AgentServerStoreState::Local { + downstream_client, .. + } = &this.state + else { + debug_panic!("should not receive GetAgentServerCommand in a non-local project"); + bail!("unexpected GetAgentServerCommand request in a non-local project"); + }; + let agent = this + .external_agents + .get_mut(&*envelope.payload.name) + .with_context(|| format!("agent `{}` not found", envelope.payload.name))?; + let (status_tx, new_version_available_tx) = downstream_client + .clone() + .map(|(project_id, downstream_client)| { + let (status_tx, mut status_rx) = watch::channel(SharedString::from("")); + let (new_version_available_tx, mut new_version_available_rx) = + watch::channel(None); + cx.spawn({ + let downstream_client = downstream_client.clone(); + let name = envelope.payload.name.clone(); + async move |_, _| { + while let Some(status) = status_rx.recv().await.ok() { + downstream_client.send( + proto::ExternalAgentLoadingStatusUpdated { + project_id, + name: name.clone(), + status: status.to_string(), + }, + )?; + } + anyhow::Ok(()) + } + }) + .detach_and_log_err(cx); + cx.spawn({ + let name = envelope.payload.name.clone(); + async move |_, _| { + if let Some(version) = + new_version_available_rx.recv().await.ok().flatten() + { + downstream_client.send( + proto::NewExternalAgentVersionAvailable { + project_id, + name: name.clone(), + version, + }, + )?; + } + anyhow::Ok(()) + } + }) + .detach_and_log_err(cx); + (status_tx, new_version_available_tx) + }) + .unzip(); + anyhow::Ok(agent.get_command( + envelope.payload.root_dir.as_deref(), + HashMap::default(), + status_tx, + new_version_available_tx, + &mut cx.to_async(), + )) + })?? + .await?; + Ok(proto::AgentServerCommand { + path: command.path.to_string_lossy().to_string(), + args: command.args, + env: command + .env + .map(|env| env.into_iter().collect()) + .unwrap_or_default(), + root_dir: root_dir, + login: login.map(|login| login.to_proto()), + }) + } + + async fn handle_external_agents_updated( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + let AgentServerStoreState::Remote { + project_id, + upstream_client, + } = &this.state + else { + debug_panic!( + "handle_external_agents_updated should not be called for a non-remote project" + ); + bail!("unexpected ExternalAgentsUpdated message") + }; + + let mut status_txs = this + .external_agents + .iter_mut() + .filter_map(|(name, agent)| { + Some(( + name.clone(), + agent + .downcast_mut::()? + .status_tx + .take(), + )) + }) + .collect::>(); + let mut new_version_available_txs = this + .external_agents + .iter_mut() + .filter_map(|(name, agent)| { + Some(( + name.clone(), + agent + .downcast_mut::()? + .new_version_available_tx + .take(), + )) + }) + .collect::>(); + + this.external_agents = envelope + .payload + .names + .into_iter() + .map(|name| { + let agent = RemoteExternalAgentServer { + project_id: *project_id, + upstream_client: upstream_client.clone(), + name: ExternalAgentServerName(name.clone().into()), + status_tx: status_txs.remove(&*name).flatten(), + new_version_available_tx: new_version_available_txs + .remove(&*name) + .flatten(), + }; + ( + ExternalAgentServerName(name.into()), + Box::new(agent) as Box, + ) + }) + .collect(); + cx.emit(AgentServersUpdated); + Ok(()) + })? + } + + async fn handle_loading_status_updated( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + this.update(&mut cx, |this, _| { + if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name) + && let Some(agent) = agent.downcast_mut::() + && let Some(status_tx) = &mut agent.status_tx + { + status_tx.send(envelope.payload.status.into()).ok(); + } + }) + } + + async fn handle_new_version_available( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + this.update(&mut cx, |this, _| { + if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name) + && let Some(agent) = agent.downcast_mut::() + && let Some(new_version_available_tx) = &mut agent.new_version_available_tx + { + new_version_available_tx + .send(Some(envelope.payload.version)) + .ok(); + } + }) + } +} + +fn get_or_npm_install_builtin_agent( + binary_name: SharedString, + package_name: SharedString, + entrypoint_path: PathBuf, + minimum_version: Option, + status_tx: Option>, + new_version_available: Option>>, + fs: Arc, + node_runtime: NodeRuntime, + cx: &mut AsyncApp, +) -> Task> { + cx.spawn(async move |cx| { + let node_path = node_runtime.binary_path().await?; + let dir = paths::data_dir() + .join("external_agents") + .join(binary_name.as_str()); + fs.create_dir(&dir).await?; + + let mut stream = fs.read_dir(&dir).await?; + let mut versions = Vec::new(); + let mut to_delete = Vec::new(); + while let Some(entry) = stream.next().await { + let Ok(entry) = entry else { continue }; + let Some(file_name) = entry.file_name() else { + continue; + }; + + if let Some(name) = file_name.to_str() + && let Some(version) = semver::Version::from_str(name).ok() + && fs + .is_file(&dir.join(file_name).join(&entrypoint_path)) + .await + { + versions.push((version, file_name.to_owned())); + } else { + to_delete.push(file_name.to_owned()) + } + } + + versions.sort(); + let newest_version = if let Some((version, file_name)) = versions.last().cloned() + && minimum_version.is_none_or(|minimum_version| version >= minimum_version) + { + versions.pop(); + Some(file_name) + } else { + None + }; + log::debug!("existing version of {package_name}: {newest_version:?}"); + to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name)); + + cx.background_spawn({ + let fs = fs.clone(); + let dir = dir.clone(); + async move { + for file_name in to_delete { + fs.remove_dir( + &dir.join(file_name), + RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .ok(); + } + } + }) + .detach(); + + let version = if let Some(file_name) = newest_version { + cx.background_spawn({ + let file_name = file_name.clone(); + let dir = dir.clone(); + let fs = fs.clone(); + async move { + let latest_version = + node_runtime.npm_package_latest_version(&package_name).await; + if let Ok(latest_version) = latest_version + && &latest_version != &file_name.to_string_lossy() + { + download_latest_version( + fs, + dir.clone(), + node_runtime, + package_name.clone(), + ) + .await + .log_err(); + if let Some(mut new_version_available) = new_version_available { + new_version_available.send(Some(latest_version)).ok(); + } + } + } + }) + .detach(); + file_name + } else { + if let Some(mut status_tx) = status_tx { + status_tx.send("Installing…".into()).ok(); + } + let dir = dir.clone(); + cx.background_spawn(download_latest_version( + fs.clone(), + dir.clone(), + node_runtime, + package_name.clone(), + )) + .await? + .into() + }; + + let agent_server_path = dir.join(version).join(entrypoint_path); + let agent_server_path_exists = fs.is_file(&agent_server_path).await; + anyhow::ensure!( + agent_server_path_exists, + "Missing entrypoint path {} after installation", + agent_server_path.to_string_lossy() + ); + + anyhow::Ok(AgentServerCommand { + path: node_path, + args: vec![agent_server_path.to_string_lossy().to_string()], + env: None, + }) + }) +} + +fn find_bin_in_path( + bin_name: SharedString, + root_dir: PathBuf, + env: HashMap, + cx: &mut AsyncApp, +) -> Task> { + cx.background_executor().spawn(async move { + let which_result = if cfg!(windows) { + which::which(bin_name.as_str()) + } else { + let shell_path = env.get("PATH").cloned(); + which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir) + }; + + if let Err(which::Error::CannotFindBinaryPath) = which_result { + return None; + } + + which_result.log_err() + }) +} + +async fn download_latest_version( + fs: Arc, + dir: PathBuf, + node_runtime: NodeRuntime, + package_name: SharedString, +) -> Result { + log::debug!("downloading latest version of {package_name}"); + + let tmp_dir = tempfile::tempdir_in(&dir)?; + + node_runtime + .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")]) + .await?; + + let version = node_runtime + .npm_package_installed_version(tmp_dir.path(), &package_name) + .await? + .context("expected package to be installed")?; + + fs.rename( + &tmp_dir.keep(), + &dir.join(&version), + RenameOptions { + ignore_if_exists: true, + overwrite: false, + }, + ) + .await?; + + anyhow::Ok(version) +} + +struct RemoteExternalAgentServer { + project_id: u64, + upstream_client: Entity, + name: ExternalAgentServerName, + status_tx: Option>, + new_version_available_tx: Option>>, +} + +// new method: status_updated +// does nothing in the all-local case +// for RemoteExternalAgentServer, sends on the stored tx +// etc. + +impl ExternalAgentServer for RemoteExternalAgentServer { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + status_tx: Option>, + new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>> { + let project_id = self.project_id; + let name = self.name.to_string(); + let upstream_client = self.upstream_client.downgrade(); + let root_dir = root_dir.map(|root_dir| root_dir.to_owned()); + self.status_tx = status_tx; + self.new_version_available_tx = new_version_available_tx; + cx.spawn(async move |cx| { + let mut response = upstream_client + .update(cx, |upstream_client, _| { + upstream_client + .proto_client() + .request(proto::GetAgentServerCommand { + project_id, + name, + root_dir: root_dir.clone(), + }) + })? + .await?; + let root_dir = response.root_dir; + response.env.extend(extra_env); + let command = upstream_client.update(cx, |client, _| { + client.build_command( + Some(response.path), + &response.args, + &response.env.into_iter().collect(), + Some(root_dir.clone()), + None, + ) + })??; + Ok(( + AgentServerCommand { + path: command.program.into(), + args: command.args, + env: Some(command.env), + }, + root_dir, + response + .login + .map(|login| task::SpawnInTerminal::from_proto(login)), + )) + }) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +struct LocalGemini { + fs: Arc, + node_runtime: NodeRuntime, + project_environment: Entity, + custom_command: Option, + ignore_system_version: bool, +} + +impl ExternalAgentServer for LocalGemini { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + status_tx: Option>, + new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>> { + let fs = self.fs.clone(); + let node_runtime = self.node_runtime.clone(); + let project_environment = self.project_environment.downgrade(); + let custom_command = self.custom_command.clone(); + let ignore_system_version = self.ignore_system_version; + let root_dir: Arc = root_dir + .map(|root_dir| Path::new(root_dir)) + .unwrap_or(paths::home_dir()) + .into(); + + cx.spawn(async move |cx| { + let mut env = project_environment + .update(cx, |project_environment, cx| { + project_environment.get_directory_environment(root_dir.clone(), cx) + })? + .await + .unwrap_or_default(); + + let mut command = if let Some(mut custom_command) = custom_command { + env.extend(custom_command.env.unwrap_or_default()); + custom_command.env = Some(env); + custom_command + } else if !ignore_system_version + && let Some(bin) = + find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await + { + AgentServerCommand { + path: bin, + args: Vec::new(), + env: Some(env), + } + } else { + let mut command = get_or_npm_install_builtin_agent( + GEMINI_NAME.into(), + "@google/gemini-cli".into(), + "node_modules/@google/gemini-cli/dist/index.js".into(), + Some("0.2.1".parse().unwrap()), + status_tx, + new_version_available_tx, + fs, + node_runtime, + cx, + ) + .await?; + command.env = Some(env); + command + }; + + // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments. + let login = task::SpawnInTerminal { + command: Some(command.path.clone().to_proto()), + args: command.args.clone(), + env: command.env.clone().unwrap_or_default(), + label: "gemini /auth".into(), + ..Default::default() + }; + + command.env.get_or_insert_default().extend(extra_env); + command.args.push("--experimental-acp".into()); + Ok((command, root_dir.to_proto(), Some(login))) + }) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +struct LocalClaudeCode { + fs: Arc, + node_runtime: NodeRuntime, + project_environment: Entity, + custom_command: Option, +} + +impl ExternalAgentServer for LocalClaudeCode { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + status_tx: Option>, + new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>> { + let fs = self.fs.clone(); + let node_runtime = self.node_runtime.clone(); + let project_environment = self.project_environment.downgrade(); + let custom_command = self.custom_command.clone(); + let root_dir: Arc = root_dir + .map(|root_dir| Path::new(root_dir)) + .unwrap_or(paths::home_dir()) + .into(); + + cx.spawn(async move |cx| { + let mut env = project_environment + .update(cx, |project_environment, cx| { + project_environment.get_directory_environment(root_dir.clone(), cx) + })? + .await + .unwrap_or_default(); + env.insert("ANTHROPIC_API_KEY".into(), "".into()); + + let (mut command, login) = if let Some(mut custom_command) = custom_command { + env.extend(custom_command.env.unwrap_or_default()); + custom_command.env = Some(env); + (custom_command, None) + } else { + let mut command = get_or_npm_install_builtin_agent( + "claude-code-acp".into(), + "@zed-industries/claude-code-acp".into(), + "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(), + Some("0.2.5".parse().unwrap()), + status_tx, + new_version_available_tx, + fs, + node_runtime, + cx, + ) + .await?; + command.env = Some(env); + let login = command + .args + .first() + .and_then(|path| { + path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js") + }) + .map(|path_prefix| task::SpawnInTerminal { + command: Some(command.path.clone().to_proto()), + args: vec![ + Path::new(path_prefix) + .join("@anthropic-ai/claude-code/cli.js") + .to_string_lossy() + .to_string(), + "/login".into(), + ], + env: command.env.clone().unwrap_or_default(), + label: "claude /login".into(), + ..Default::default() + }); + (command, login) + }; + + command.env.get_or_insert_default().extend(extra_env); + Ok((command, root_dir.to_proto(), login)) + }) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +struct LocalCustomAgent { + project_environment: Entity, + command: AgentServerCommand, +} + +impl ExternalAgentServer for LocalCustomAgent { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + _status_tx: Option>, + _new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>> { + let mut command = self.command.clone(); + let root_dir: Arc = root_dir + .map(|root_dir| Path::new(root_dir)) + .unwrap_or(paths::home_dir()) + .into(); + let project_environment = self.project_environment.downgrade(); + cx.spawn(async move |cx| { + let mut env = project_environment + .update(cx, |project_environment, cx| { + project_environment.get_directory_environment(root_dir.clone(), cx) + })? + .await + .unwrap_or_default(); + env.extend(command.env.unwrap_or_default()); + env.extend(extra_env); + command.env = Some(env); + Ok((command, root_dir.to_proto(), None)) + }) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +pub const GEMINI_NAME: &'static str = "gemini"; +pub const CLAUDE_CODE_NAME: &'static str = "claude"; + +#[derive( + Default, Deserialize, Serialize, Clone, JsonSchema, Debug, SettingsUi, SettingsKey, PartialEq, +)] +#[settings_key(key = "agent_servers")] +pub struct AllAgentServersSettings { + pub gemini: Option, + pub claude: Option, + + /// Custom agent servers configured by the user + #[serde(flatten)] + pub custom: HashMap, +} + +#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)] +pub struct BuiltinAgentServerSettings { + /// Absolute path to a binary to be used when launching this agent. + /// + /// This can be used to run a specific binary without automatic downloads or searching `$PATH`. + #[serde(rename = "command")] + pub path: Option, + /// If a binary is specified in `command`, it will be passed these arguments. + pub args: Option>, + /// If a binary is specified in `command`, it will be passed these environment variables. + pub env: Option>, + /// Whether to skip searching `$PATH` for an agent server binary when + /// launching this agent. + /// + /// This has no effect if a `command` is specified. Otherwise, when this is + /// `false`, Zed will search `$PATH` for an agent server binary and, if one + /// is found, use it for threads with this agent. If no agent binary is + /// found on `$PATH`, Zed will automatically install and use its own binary. + /// When this is `true`, Zed will not search `$PATH`, and will always use + /// its own binary. + /// + /// Default: true + pub ignore_system_version: Option, +} + +impl BuiltinAgentServerSettings { + pub(crate) fn custom_command(self) -> Option { + self.path.map(|path| AgentServerCommand { + path, + args: self.args.unwrap_or_default(), + env: self.env, + }) + } +} + +impl From for BuiltinAgentServerSettings { + fn from(value: AgentServerCommand) -> Self { + BuiltinAgentServerSettings { + path: Some(value.path), + args: Some(value.args), + env: value.env, + ..Default::default() + } + } +} + +#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)] +pub struct CustomAgentServerSettings { + #[serde(flatten)] + pub command: AgentServerCommand, +} + +impl settings::Settings for AllAgentServersSettings { + type FileContent = Self; + + fn load(sources: SettingsSources, _: &mut App) -> Result { + let mut settings = AllAgentServersSettings::default(); + + 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 && name != CLAUDE_CODE_NAME { + settings.custom.insert(name.clone(), config.clone()); + } + } + } + + Ok(settings) + } + + fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index a2bd4ee7b7b5e69586f05a72727a23339b61c26b..1ef3de7a166b785de7799269548cbddf7202ad0d 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1,3 +1,4 @@ +pub mod agent_server_store; pub mod buffer_store; mod color_extractor; pub mod connection_manager; @@ -34,7 +35,11 @@ mod yarn; use dap::inline_value::{InlineValueLocation, VariableLookupKind, VariableScope}; -use crate::{git_store::GitStore, lsp_store::log_store::LogKind}; +use crate::{ + agent_server_store::{AgentServerStore, AllAgentServersSettings}, + git_store::GitStore, + lsp_store::log_store::LogKind, +}; pub use git_store::{ ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate, git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal}, @@ -179,6 +184,7 @@ pub struct Project { buffer_ordered_messages_tx: mpsc::UnboundedSender, languages: Arc, dap_store: Entity, + agent_server_store: Entity, breakpoint_store: Entity, collab_client: Arc, @@ -1019,6 +1025,7 @@ impl Project { WorktreeSettings::register(cx); ProjectSettings::register(cx); DisableAiSettings::register(cx); + AllAgentServersSettings::register(cx); } pub fn init(client: &Arc, cx: &mut App) { @@ -1174,6 +1181,10 @@ impl Project { ) }); + let agent_server_store = cx.new(|cx| { + AgentServerStore::local(node.clone(), fs.clone(), environment.clone(), cx) + }); + cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach(); Self { @@ -1200,6 +1211,7 @@ impl Project { remote_client: None, breakpoint_store, dap_store, + agent_server_store, buffers_needing_diff: Default::default(), git_diff_debouncer: DebouncedDelay::new(), @@ -1338,6 +1350,9 @@ impl Project { ) }); + let agent_server_store = + cx.new(|cx| AgentServerStore::remote(REMOTE_SERVER_PROJECT_ID, remote.clone(), cx)); + cx.subscribe(&remote, Self::on_remote_client_event).detach(); let this = Self { @@ -1353,6 +1368,7 @@ impl Project { join_project_response_message_id: 0, client_state: ProjectClientState::Local, git_store, + agent_server_store, client_subscriptions: Vec::new(), _subscriptions: vec![ cx.on_release(Self::release), @@ -1407,6 +1423,7 @@ impl Project { remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.dap_store); remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.settings_observer); remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.git_store); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.agent_server_store); remote_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer); remote_proto.add_entity_message_handler(Self::handle_update_worktree); @@ -1422,6 +1439,7 @@ impl Project { ToolchainStore::init(&remote_proto); DapStore::init(&remote_proto, cx); GitStore::init(&remote_proto); + AgentServerStore::init_remote(&remote_proto); this }) @@ -1564,6 +1582,8 @@ impl Project { ) })?; + let agent_server_store = cx.new(|cx| AgentServerStore::collab(cx))?; + let project = cx.new(|cx| { let replica_id = response.payload.replica_id as ReplicaId; @@ -1624,6 +1644,7 @@ impl Project { breakpoint_store, dap_store: dap_store.clone(), git_store: git_store.clone(), + agent_server_store, buffers_needing_diff: Default::default(), git_diff_debouncer: DebouncedDelay::new(), terminals: Terminals { @@ -5199,6 +5220,10 @@ impl Project { &self.git_store } + pub fn agent_server_store(&self) -> &Entity { + &self.agent_server_store + } + #[cfg(test)] fn git_scans_complete(&self, cx: &Context) -> Task<()> { cx.spawn(async move |this, cx| { diff --git a/crates/proto/proto/ai.proto b/crates/proto/proto/ai.proto index 1064ed2f8d301a6cc80170ce33fcca33310c2f1d..9b4cc27dcb9755f5205907cc5fd93687aa76bc4f 100644 --- a/crates/proto/proto/ai.proto +++ b/crates/proto/proto/ai.proto @@ -2,6 +2,7 @@ syntax = "proto3"; package zed.messages; import "buffer.proto"; +import "task.proto"; message Context { repeated ContextOperation operations = 1; @@ -164,3 +165,35 @@ enum LanguageModelRole { LanguageModelSystem = 2; reserved 3; } + +message GetAgentServerCommand { + uint64 project_id = 1; + string name = 2; + optional string root_dir = 3; +} + +message AgentServerCommand { + string path = 1; + repeated string args = 2; + map env = 3; + string root_dir = 4; + + optional SpawnInTerminal login = 5; +} + +message ExternalAgentsUpdated { + uint64 project_id = 1; + repeated string names = 2; +} + +message ExternalAgentLoadingStatusUpdated { + uint64 project_id = 1; + string name = 2; + string status = 3; +} + +message NewExternalAgentVersionAvailable { + uint64 project_id = 1; + string name = 2; + string version = 3; +} diff --git a/crates/proto/proto/debugger.proto b/crates/proto/proto/debugger.proto index e3cb5ebbce0ceb87a7197f19a133bbb92a572085..dcfb91c77dd0004bfb248d4e4c23dcf269b7bc11 100644 --- a/crates/proto/proto/debugger.proto +++ b/crates/proto/proto/debugger.proto @@ -3,6 +3,7 @@ package zed.messages; import "core.proto"; import "buffer.proto"; +import "task.proto"; enum BreakpointState { Enabled = 0; @@ -533,14 +534,6 @@ message DebugScenario { optional string configuration = 7; } -message SpawnInTerminal { - string label = 1; - optional string command = 2; - repeated string args = 3; - map env = 4; - optional string cwd = 5; -} - message LogToDebugConsole { uint64 project_id = 1; uint64 session_id = 2; diff --git a/crates/proto/proto/task.proto b/crates/proto/proto/task.proto index e6fa192ab5836371c5a2f1fb992b4b07f843655b..8fc3a6d18e1398d8647ba3daaa419829177e55f8 100644 --- a/crates/proto/proto/task.proto +++ b/crates/proto/proto/task.proto @@ -40,3 +40,11 @@ enum HideStrategy { HideNever = 1; HideOnSuccess = 2; } + +message SpawnInTerminal { + string label = 1; + optional string command = 2; + repeated string args = 3; + map env = 4; + optional string cwd = 5; +} diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 39fa1fdd53d140cb5d88da751d843e6a7ad1db70..3286b9e752597a56cf39f24557a869a3f6fb5ffe 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -405,7 +405,15 @@ message Envelope { GetProcessesResponse get_processes_response = 370; ResolveToolchain resolve_toolchain = 371; - ResolveToolchainResponse resolve_toolchain_response = 372; // current max + ResolveToolchainResponse resolve_toolchain_response = 372; + + GetAgentServerCommand get_agent_server_command = 373; + AgentServerCommand agent_server_command = 374; + + ExternalAgentsUpdated external_agents_updated = 375; + + ExternalAgentLoadingStatusUpdated external_agent_loading_status_updated = 376; + NewExternalAgentVersionAvailable new_external_agent_version_available = 377; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 4c0fc3dc98e22029cf167c0506916d71f3e93602..79e6b414ef516372eca3ec06b72de507ee2b8711 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -319,6 +319,11 @@ messages!( (GitClone, Background), (GitCloneResponse, Background), (ToggleLspLogs, Background), + (GetAgentServerCommand, Background), + (AgentServerCommand, Background), + (ExternalAgentsUpdated, Background), + (ExternalAgentLoadingStatusUpdated, Background), + (NewExternalAgentVersionAvailable, Background), ); request_messages!( @@ -491,6 +496,7 @@ request_messages!( (GitClone, GitCloneResponse), (ToggleLspLogs, Ack), (GetProcesses, GetProcessesResponse), + (GetAgentServerCommand, AgentServerCommand) ); lsp_messages!( @@ -644,7 +650,11 @@ entity_messages!( GetDocumentDiagnostics, PullWorkspaceDiagnostics, GetDefaultBranch, - GitClone + GitClone, + GetAgentServerCommand, + ExternalAgentsUpdated, + ExternalAgentLoadingStatusUpdated, + NewExternalAgentVersionAvailable, ); entity_messages!( diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 2714e9ff79a84f613f9eb4b10800b968d8a1aedf..bdfd46002e63069b68b5661f1733060818a291e7 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -12,6 +12,7 @@ use node_runtime::NodeRuntime; use project::{ LspStore, LspStoreEvent, ManifestTree, PrettierStore, ProjectEnvironment, ProjectPath, ToolchainStore, WorktreeId, + agent_server_store::AgentServerStore, buffer_store::{BufferStore, BufferStoreEvent}, debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore}, git_store::GitStore, @@ -44,6 +45,7 @@ pub struct HeadlessProject { pub lsp_store: Entity, pub task_store: Entity, pub dap_store: Entity, + pub agent_server_store: Entity, pub settings_observer: Entity, pub next_entry_id: Arc, pub languages: Arc, @@ -182,7 +184,7 @@ impl HeadlessProject { .as_local_store() .expect("Toolchain store to be local") .clone(), - environment, + environment.clone(), manifest_tree, languages.clone(), http_client.clone(), @@ -193,6 +195,13 @@ impl HeadlessProject { lsp_store }); + let agent_server_store = cx.new(|cx| { + let mut agent_server_store = + AgentServerStore::local(node_runtime.clone(), fs.clone(), environment, cx); + agent_server_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone()); + agent_server_store + }); + cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach(); language_extension::init( language_extension::LspAccess::ViaLspStore(lsp_store.clone()), @@ -226,6 +235,7 @@ impl HeadlessProject { session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &dap_store); session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &settings_observer); session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &git_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &agent_server_store); session.add_request_handler(cx.weak_entity(), Self::handle_list_remote_directory); session.add_request_handler(cx.weak_entity(), Self::handle_get_path_metadata); @@ -264,6 +274,7 @@ impl HeadlessProject { // todo(debugger): Re init breakpoint store when we set it up for collab // BreakpointStore::init(&client); GitStore::init(&session); + AgentServerStore::init_headless(&session); HeadlessProject { next_entry_id: Default::default(), @@ -275,6 +286,7 @@ impl HeadlessProject { lsp_store, task_store, dap_store, + agent_server_store, languages, extensions, git_store, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index bee6c87670c87a08945918a3dd49b26463a3a3ef..6f1934456d747de7c27e0a0903fabbb083549fba 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -24,7 +24,6 @@ acp_tools.workspace = true agent.workspace = true agent_ui.workspace = true agent_settings.workspace = true -agent_servers.workspace = true anyhow.workspace = true askpass.workspace = true assets.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 3287e866e48058a763c7db6633c1db4252fc0bec..ab24224119296c90739cacb4e44f878f8ac06cb3 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -567,7 +567,6 @@ pub fn main() { language_model::init(app_state.client.clone(), cx); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); agent_settings::init(cx); - agent_servers::init(cx); acp_tools::init(cx); web_search::init(cx); web_search_providers::init(app_state.client.clone(), cx);