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);