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