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