agent_servers.rs

  1mod acp;
  2mod claude;
  3mod custom;
  4mod gemini;
  5mod settings;
  6
  7#[cfg(any(test, feature = "test-support"))]
  8pub mod e2e_tests;
  9
 10pub use claude::*;
 11pub use custom::*;
 12pub use gemini::*;
 13pub use settings::*;
 14
 15use acp_thread::AgentConnection;
 16use acp_thread::LoadError;
 17use anyhow::Result;
 18use anyhow::anyhow;
 19use anyhow::bail;
 20use collections::HashMap;
 21use gpui::AppContext as _;
 22use gpui::{App, AsyncApp, Entity, SharedString, Task};
 23use node_runtime::VersionStrategy;
 24use project::Project;
 25use schemars::JsonSchema;
 26use semver::Version;
 27use serde::{Deserialize, Serialize};
 28use std::str::FromStr as _;
 29use std::{
 30    any::Any,
 31    path::{Path, PathBuf},
 32    rc::Rc,
 33    sync::Arc,
 34};
 35use util::ResultExt as _;
 36
 37pub fn init(cx: &mut App) {
 38    settings::init(cx);
 39}
 40
 41pub struct AgentServerDelegate {
 42    project: Entity<Project>,
 43    status_tx: watch::Sender<SharedString>,
 44}
 45
 46impl AgentServerDelegate {
 47    pub fn new(project: Entity<Project>, status_tx: watch::Sender<SharedString>) -> Self {
 48        Self { project, status_tx }
 49    }
 50
 51    pub fn project(&self) -> &Entity<Project> {
 52        &self.project
 53    }
 54
 55    fn get_or_npm_install_builtin_agent(
 56        self,
 57        binary_name: SharedString,
 58        package_name: SharedString,
 59        entrypoint_path: PathBuf,
 60        ignore_system_version: bool,
 61        minimum_version: Option<Version>,
 62        cx: &mut App,
 63    ) -> Task<Result<AgentServerCommand>> {
 64        let project = self.project;
 65        let fs = project.read(cx).fs().clone();
 66        let Some(node_runtime) = project.read(cx).node_runtime().cloned() else {
 67            return Task::ready(Err(anyhow!("Missing node runtime")));
 68        };
 69        let mut status_tx = self.status_tx;
 70
 71        cx.spawn(async move |cx| {
 72            if !ignore_system_version {
 73                if let Some(bin) = find_bin_in_path(binary_name.clone(), &project, cx).await {
 74                    return Ok(AgentServerCommand { path: bin, args: Vec::new(), env: Default::default() })
 75                }
 76            }
 77
 78            cx.background_spawn(async move {
 79                let node_path = node_runtime.binary_path().await?;
 80                let dir = paths::data_dir().join("external_agents").join(binary_name.as_str());
 81                fs.create_dir(&dir).await?;
 82                let local_executable_path = dir.join(entrypoint_path);
 83                let command = AgentServerCommand {
 84                    path: node_path,
 85                    args: vec![local_executable_path.to_string_lossy().to_string()],
 86                    env: Default::default(),
 87                };
 88
 89                let installed_version = node_runtime
 90                    .npm_package_installed_version(&dir, &package_name)
 91                    .await?
 92                    .filter(|version| {
 93                        Version::from_str(&version)
 94                            .is_ok_and(|version| Some(version) >= minimum_version)
 95                    });
 96
 97                status_tx.send("Checking for latest version…".into())?;
 98                let latest_version = match node_runtime.npm_package_latest_version(&package_name).await
 99                {
100                    Ok(latest_version) => latest_version,
101                    Err(e) => {
102                        if let Some(installed_version) = installed_version {
103                            log::error!("{e}");
104                            log::warn!("failed to fetch latest version of {package_name}, falling back to cached version {installed_version}");
105                            return Ok(command);
106                        } else {
107                            bail!(e);
108                        }
109                    }
110                };
111
112                let should_install = node_runtime
113                    .should_install_npm_package(
114                        &package_name,
115                        &local_executable_path,
116                        &dir,
117                        VersionStrategy::Latest(&latest_version),
118                    )
119                    .await;
120
121                if should_install {
122                    status_tx.send("Installing latest version…".into())?;
123                    node_runtime
124                        .npm_install_packages(&dir, &[(&package_name, &latest_version)])
125                        .await?;
126                }
127
128                Ok(command)
129            }).await.map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into())
130        })
131    }
132}
133
134pub trait AgentServer: Send {
135    fn logo(&self) -> ui::IconName;
136    fn name(&self) -> SharedString;
137    fn telemetry_id(&self) -> &'static str;
138
139    fn connect(
140        &self,
141        root_dir: &Path,
142        delegate: AgentServerDelegate,
143        cx: &mut App,
144    ) -> Task<Result<Rc<dyn AgentConnection>>>;
145
146    fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
147}
148
149impl dyn AgentServer {
150    pub fn downcast<T: 'static + AgentServer + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
151        self.into_any().downcast().ok()
152    }
153}
154
155impl std::fmt::Debug for AgentServerCommand {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        let filtered_env = self.env.as_ref().map(|env| {
158            env.iter()
159                .map(|(k, v)| {
160                    (
161                        k,
162                        if util::redact::should_redact(k) {
163                            "[REDACTED]"
164                        } else {
165                            v
166                        },
167                    )
168                })
169                .collect::<Vec<_>>()
170        });
171
172        f.debug_struct("AgentServerCommand")
173            .field("path", &self.path)
174            .field("args", &self.args)
175            .field("env", &filtered_env)
176            .finish()
177    }
178}
179
180#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
181pub struct AgentServerCommand {
182    #[serde(rename = "command")]
183    pub path: PathBuf,
184    #[serde(default)]
185    pub args: Vec<String>,
186    pub env: Option<HashMap<String, String>>,
187}
188
189impl AgentServerCommand {
190    pub async fn resolve(
191        path_bin_name: &'static str,
192        extra_args: &[&'static str],
193        fallback_path: Option<&Path>,
194        settings: Option<BuiltinAgentServerSettings>,
195        project: &Entity<Project>,
196        cx: &mut AsyncApp,
197    ) -> Option<Self> {
198        if let Some(settings) = settings
199            && let Some(command) = settings.custom_command()
200        {
201            Some(command)
202        } else {
203            match find_bin_in_path(path_bin_name.into(), project, cx).await {
204                Some(path) => Some(Self {
205                    path,
206                    args: extra_args.iter().map(|arg| arg.to_string()).collect(),
207                    env: None,
208                }),
209                None => fallback_path.and_then(|path| {
210                    if path.exists() {
211                        Some(Self {
212                            path: path.to_path_buf(),
213                            args: extra_args.iter().map(|arg| arg.to_string()).collect(),
214                            env: None,
215                        })
216                    } else {
217                        None
218                    }
219                }),
220            }
221        }
222    }
223}
224
225async fn find_bin_in_path(
226    bin_name: SharedString,
227    project: &Entity<Project>,
228    cx: &mut AsyncApp,
229) -> Option<PathBuf> {
230    let (env_task, root_dir) = project
231        .update(cx, |project, cx| {
232            let worktree = project.visible_worktrees(cx).next();
233            match worktree {
234                Some(worktree) => {
235                    let env_task = project.environment().update(cx, |env, cx| {
236                        env.get_worktree_environment(worktree.clone(), cx)
237                    });
238
239                    let path = worktree.read(cx).abs_path();
240                    (env_task, path)
241                }
242                None => {
243                    let path: Arc<Path> = paths::home_dir().as_path().into();
244                    let env_task = project.environment().update(cx, |env, cx| {
245                        env.get_directory_environment(path.clone(), cx)
246                    });
247                    (env_task, path)
248                }
249            }
250        })
251        .log_err()?;
252
253    cx.background_executor()
254        .spawn(async move {
255            let which_result = if cfg!(windows) {
256                which::which(bin_name.as_str())
257            } else {
258                let env = env_task.await.unwrap_or_default();
259                let shell_path = env.get("PATH").cloned();
260                which::which_in(bin_name.as_str(), shell_path.as_ref(), root_dir.as_ref())
261            };
262
263            if let Err(which::Error::CannotFindBinaryPath) = which_result {
264                return None;
265            }
266
267            which_result.log_err()
268        })
269        .await
270}