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