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
 10use anyhow::Context as _;
 11pub use claude::*;
 12pub use custom::*;
 13use fs::Fs;
 14use fs::RemoveOptions;
 15use fs::RenameOptions;
 16use futures::StreamExt as _;
 17pub use gemini::*;
 18use gpui::AppContext;
 19use node_runtime::NodeRuntime;
 20pub use settings::*;
 21
 22use acp_thread::AgentConnection;
 23use acp_thread::LoadError;
 24use anyhow::Result;
 25use anyhow::anyhow;
 26use collections::HashMap;
 27use gpui::{App, AsyncApp, Entity, SharedString, Task};
 28use project::Project;
 29use schemars::JsonSchema;
 30use semver::Version;
 31use serde::{Deserialize, Serialize};
 32use std::str::FromStr as _;
 33use std::{
 34    any::Any,
 35    path::{Path, PathBuf},
 36    rc::Rc,
 37    sync::Arc,
 38};
 39use util::ResultExt as _;
 40
 41pub fn init(cx: &mut App) {
 42    settings::init(cx);
 43}
 44
 45pub struct AgentServerDelegate {
 46    project: Entity<Project>,
 47    status_tx: Option<watch::Sender<SharedString>>,
 48}
 49
 50impl AgentServerDelegate {
 51    pub fn new(project: Entity<Project>, status_tx: Option<watch::Sender<SharedString>>) -> Self {
 52        Self { project, status_tx }
 53    }
 54
 55    pub fn project(&self) -> &Entity<Project> {
 56        &self.project
 57    }
 58
 59    fn get_or_npm_install_builtin_agent(
 60        self,
 61        binary_name: SharedString,
 62        package_name: SharedString,
 63        entrypoint_path: PathBuf,
 64        ignore_system_version: bool,
 65        minimum_version: Option<Version>,
 66        cx: &mut App,
 67    ) -> Task<Result<AgentServerCommand>> {
 68        let project = self.project;
 69        let fs = project.read(cx).fs().clone();
 70        let Some(node_runtime) = project.read(cx).node_runtime().cloned() else {
 71            return Task::ready(Err(anyhow!(
 72                "External agents are not yet available in remote projects."
 73            )));
 74        };
 75        let status_tx = self.status_tx;
 76
 77        cx.spawn(async move |cx| {
 78            if !ignore_system_version {
 79                if let Some(bin) = find_bin_in_path(binary_name.clone(), &project, cx).await {
 80                    return Ok(AgentServerCommand {
 81                        path: bin,
 82                        args: Vec::new(),
 83                        env: Default::default(),
 84                    });
 85                }
 86            }
 87
 88            cx.spawn(async move |cx| {
 89                let node_path = node_runtime.binary_path().await?;
 90                let dir = paths::data_dir()
 91                    .join("external_agents")
 92                    .join(binary_name.as_str());
 93                fs.create_dir(&dir).await?;
 94
 95                let mut stream = fs.read_dir(&dir).await?;
 96                let mut versions = Vec::new();
 97                let mut to_delete = Vec::new();
 98                while let Some(entry) = stream.next().await {
 99                    let Ok(entry) = entry else { continue };
100                    let Some(file_name) = entry.file_name() else {
101                        continue;
102                    };
103
104                    if let Some(version) = file_name
105                        .to_str()
106                        .and_then(|name| semver::Version::from_str(&name).ok())
107                    {
108                        versions.push((version, file_name.to_owned()));
109                    } else {
110                        to_delete.push(file_name.to_owned())
111                    }
112                }
113
114                versions.sort();
115                let newest_version = if let Some((version, file_name)) = versions.last().cloned()
116                    && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
117                {
118                    versions.pop();
119                    Some(file_name)
120                } else {
121                    None
122                };
123                log::debug!("existing version of {package_name}: {newest_version:?}");
124                to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
125
126                cx.background_spawn({
127                    let fs = fs.clone();
128                    let dir = dir.clone();
129                    async move {
130                        for file_name in to_delete {
131                            fs.remove_dir(
132                                &dir.join(file_name),
133                                RemoveOptions {
134                                    recursive: true,
135                                    ignore_if_not_exists: false,
136                                },
137                            )
138                            .await
139                            .ok();
140                        }
141                    }
142                })
143                .detach();
144
145                let version = if let Some(file_name) = newest_version {
146                    cx.background_spawn({
147                        let file_name = file_name.clone();
148                        let dir = dir.clone();
149                        async move {
150                            let latest_version =
151                                node_runtime.npm_package_latest_version(&package_name).await;
152                            if let Ok(latest_version) = latest_version
153                                && &latest_version != &file_name.to_string_lossy()
154                            {
155                                Self::download_latest_version(
156                                    fs,
157                                    dir.clone(),
158                                    node_runtime,
159                                    package_name,
160                                )
161                                .await
162                                .log_err();
163                            }
164                        }
165                    })
166                    .detach();
167                    file_name
168                } else {
169                    if let Some(mut status_tx) = status_tx {
170                        status_tx.send("Installing…".into()).ok();
171                    }
172                    let dir = dir.clone();
173                    cx.background_spawn(Self::download_latest_version(
174                        fs,
175                        dir.clone(),
176                        node_runtime,
177                        package_name,
178                    ))
179                    .await?
180                    .into()
181                };
182                anyhow::Ok(AgentServerCommand {
183                    path: node_path,
184                    args: vec![
185                        dir.join(version)
186                            .join(entrypoint_path)
187                            .to_string_lossy()
188                            .to_string(),
189                    ],
190                    env: Default::default(),
191                })
192            })
193            .await
194            .map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into())
195        })
196    }
197
198    async fn download_latest_version(
199        fs: Arc<dyn Fs>,
200        dir: PathBuf,
201        node_runtime: NodeRuntime,
202        package_name: SharedString,
203    ) -> Result<String> {
204        log::debug!("downloading latest version of {package_name}");
205
206        let tmp_dir = tempfile::tempdir_in(&dir)?;
207
208        node_runtime
209            .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
210            .await?;
211
212        let version = node_runtime
213            .npm_package_installed_version(tmp_dir.path(), &package_name)
214            .await?
215            .context("expected package to be installed")?;
216
217        fs.rename(
218            &tmp_dir.keep(),
219            &dir.join(&version),
220            RenameOptions {
221                ignore_if_exists: true,
222                overwrite: false,
223            },
224        )
225        .await?;
226
227        anyhow::Ok(version)
228    }
229}
230
231pub trait AgentServer: Send {
232    fn logo(&self) -> ui::IconName;
233    fn name(&self) -> SharedString;
234    fn telemetry_id(&self) -> &'static str;
235
236    fn connect(
237        &self,
238        root_dir: &Path,
239        delegate: AgentServerDelegate,
240        cx: &mut App,
241    ) -> Task<Result<Rc<dyn AgentConnection>>>;
242
243    fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
244}
245
246impl dyn AgentServer {
247    pub fn downcast<T: 'static + AgentServer + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
248        self.into_any().downcast().ok()
249    }
250}
251
252impl std::fmt::Debug for AgentServerCommand {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        let filtered_env = self.env.as_ref().map(|env| {
255            env.iter()
256                .map(|(k, v)| {
257                    (
258                        k,
259                        if util::redact::should_redact(k) {
260                            "[REDACTED]"
261                        } else {
262                            v
263                        },
264                    )
265                })
266                .collect::<Vec<_>>()
267        });
268
269        f.debug_struct("AgentServerCommand")
270            .field("path", &self.path)
271            .field("args", &self.args)
272            .field("env", &filtered_env)
273            .finish()
274    }
275}
276
277#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
278pub struct AgentServerCommand {
279    #[serde(rename = "command")]
280    pub path: PathBuf,
281    #[serde(default)]
282    pub args: Vec<String>,
283    pub env: Option<HashMap<String, String>>,
284}
285
286impl AgentServerCommand {
287    pub async fn resolve(
288        path_bin_name: &'static str,
289        extra_args: &[&'static str],
290        fallback_path: Option<&Path>,
291        settings: Option<BuiltinAgentServerSettings>,
292        project: &Entity<Project>,
293        cx: &mut AsyncApp,
294    ) -> Option<Self> {
295        if let Some(settings) = settings
296            && let Some(command) = settings.custom_command()
297        {
298            Some(command)
299        } else {
300            match find_bin_in_path(path_bin_name.into(), project, cx).await {
301                Some(path) => Some(Self {
302                    path,
303                    args: extra_args.iter().map(|arg| arg.to_string()).collect(),
304                    env: None,
305                }),
306                None => fallback_path.and_then(|path| {
307                    if path.exists() {
308                        Some(Self {
309                            path: path.to_path_buf(),
310                            args: extra_args.iter().map(|arg| arg.to_string()).collect(),
311                            env: None,
312                        })
313                    } else {
314                        None
315                    }
316                }),
317            }
318        }
319    }
320}
321
322async fn find_bin_in_path(
323    bin_name: SharedString,
324    project: &Entity<Project>,
325    cx: &mut AsyncApp,
326) -> Option<PathBuf> {
327    let (env_task, root_dir) = project
328        .update(cx, |project, cx| {
329            let worktree = project.visible_worktrees(cx).next();
330            match worktree {
331                Some(worktree) => {
332                    let env_task = project.environment().update(cx, |env, cx| {
333                        env.get_worktree_environment(worktree.clone(), cx)
334                    });
335
336                    let path = worktree.read(cx).abs_path();
337                    (env_task, path)
338                }
339                None => {
340                    let path: Arc<Path> = paths::home_dir().as_path().into();
341                    let env_task = project.environment().update(cx, |env, cx| {
342                        env.get_directory_environment(path.clone(), cx)
343                    });
344                    (env_task, path)
345                }
346            }
347        })
348        .log_err()?;
349
350    cx.background_executor()
351        .spawn(async move {
352            let which_result = if cfg!(windows) {
353                which::which(bin_name.as_str())
354            } else {
355                let env = env_task.await.unwrap_or_default();
356                let shell_path = env.get("PATH").cloned();
357                which::which_in(bin_name.as_str(), shell_path.as_ref(), root_dir.as_ref())
358            };
359
360            if let Err(which::Error::CannotFindBinaryPath) = which_result {
361                return None;
362            }
363
364            which_result.log_err()
365        })
366        .await
367}