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