agent_registry_store.rs

  1use std::path::{Path, PathBuf};
  2use std::sync::Arc;
  3use std::time::{Duration, Instant};
  4
  5use anyhow::{Context as _, Result, bail};
  6use collections::HashMap;
  7use fs::Fs;
  8use futures::AsyncReadExt;
  9use gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task};
 10use http_client::{AsyncBody, HttpClient};
 11use serde::Deserialize;
 12use settings::Settings;
 13
 14use crate::agent_server_store::{AllAgentServersSettings, CustomAgentServerSettings};
 15
 16const REGISTRY_URL: &str =
 17    "https://github.com/agentclientprotocol/registry/releases/latest/download/registry.json";
 18const REFRESH_THROTTLE_DURATION: Duration = Duration::from_secs(60 * 60);
 19
 20#[derive(Clone, Debug)]
 21pub struct RegistryAgentMetadata {
 22    pub id: SharedString,
 23    pub name: SharedString,
 24    pub description: SharedString,
 25    pub version: SharedString,
 26    pub repository: Option<SharedString>,
 27    pub icon_path: Option<SharedString>,
 28}
 29
 30#[derive(Clone, Debug)]
 31pub struct RegistryBinaryAgent {
 32    pub metadata: RegistryAgentMetadata,
 33    pub targets: HashMap<String, RegistryTargetConfig>,
 34    pub supports_current_platform: bool,
 35}
 36
 37#[derive(Clone, Debug)]
 38pub struct RegistryNpxAgent {
 39    pub metadata: RegistryAgentMetadata,
 40    pub package: SharedString,
 41    pub args: Vec<String>,
 42    pub env: HashMap<String, String>,
 43}
 44
 45#[derive(Clone, Debug)]
 46pub enum RegistryAgent {
 47    Binary(RegistryBinaryAgent),
 48    Npx(RegistryNpxAgent),
 49}
 50
 51impl RegistryAgent {
 52    pub fn metadata(&self) -> &RegistryAgentMetadata {
 53        match self {
 54            RegistryAgent::Binary(agent) => &agent.metadata,
 55            RegistryAgent::Npx(agent) => &agent.metadata,
 56        }
 57    }
 58
 59    pub fn id(&self) -> &SharedString {
 60        &self.metadata().id
 61    }
 62
 63    pub fn name(&self) -> &SharedString {
 64        &self.metadata().name
 65    }
 66
 67    pub fn description(&self) -> &SharedString {
 68        &self.metadata().description
 69    }
 70
 71    pub fn version(&self) -> &SharedString {
 72        &self.metadata().version
 73    }
 74
 75    pub fn repository(&self) -> Option<&SharedString> {
 76        self.metadata().repository.as_ref()
 77    }
 78
 79    pub fn icon_path(&self) -> Option<&SharedString> {
 80        self.metadata().icon_path.as_ref()
 81    }
 82
 83    pub fn supports_current_platform(&self) -> bool {
 84        match self {
 85            RegistryAgent::Binary(agent) => agent.supports_current_platform,
 86            RegistryAgent::Npx(_) => true,
 87        }
 88    }
 89}
 90
 91#[derive(Clone, Debug)]
 92pub struct RegistryTargetConfig {
 93    pub archive: String,
 94    pub cmd: String,
 95    pub args: Vec<String>,
 96    pub sha256: Option<String>,
 97    pub env: HashMap<String, String>,
 98}
 99
100struct GlobalAgentRegistryStore(Entity<AgentRegistryStore>);
101
102impl Global for GlobalAgentRegistryStore {}
103
104pub struct AgentRegistryStore {
105    fs: Arc<dyn Fs>,
106    http_client: Arc<dyn HttpClient>,
107    agents: Vec<RegistryAgent>,
108    is_fetching: bool,
109    fetch_error: Option<SharedString>,
110    pending_refresh: Option<Task<()>>,
111    last_refresh: Option<Instant>,
112}
113
114impl AgentRegistryStore {
115    /// Initialize the global AgentRegistryStore.
116    ///
117    /// This loads the cached registry from disk. If the cache is empty but there
118    /// are registry agents configured in settings, it will trigger a network fetch.
119    /// Otherwise, call `refresh()` explicitly when you need fresh data
120    /// (e.g., when opening the Agent Registry page).
121    pub fn init_global(cx: &mut App) -> Entity<Self> {
122        if let Some(store) = Self::try_global(cx) {
123            return store;
124        }
125
126        let fs = <dyn Fs>::global(cx);
127        let http_client: Arc<dyn HttpClient> = cx.http_client();
128
129        let store = cx.new(|cx| Self::new(fs, http_client, cx));
130        cx.set_global(GlobalAgentRegistryStore(store.clone()));
131
132        let has_registry_agents_in_settings = AllAgentServersSettings::get_global(cx)
133            .custom
134            .values()
135            .any(|s| matches!(s, CustomAgentServerSettings::Registry { .. }));
136
137        if has_registry_agents_in_settings {
138            store.update(cx, |store, cx| {
139                if store.agents.is_empty() {
140                    store.refresh(cx);
141                }
142            });
143        }
144
145        store
146    }
147
148    pub fn global(cx: &App) -> Entity<Self> {
149        cx.global::<GlobalAgentRegistryStore>().0.clone()
150    }
151
152    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
153        cx.try_global::<GlobalAgentRegistryStore>()
154            .map(|store| store.0.clone())
155    }
156
157    pub fn agents(&self) -> &[RegistryAgent] {
158        &self.agents
159    }
160
161    pub fn agent(&self, id: &str) -> Option<&RegistryAgent> {
162        self.agents.iter().find(|agent| agent.id().as_ref() == id)
163    }
164
165    pub fn is_fetching(&self) -> bool {
166        self.is_fetching
167    }
168
169    pub fn fetch_error(&self) -> Option<SharedString> {
170        self.fetch_error.clone()
171    }
172
173    /// Refresh the registry from the network.
174    ///
175    /// This will fetch the latest registry data and update the cache.
176    pub fn refresh(&mut self, cx: &mut Context<Self>) {
177        if self.pending_refresh.is_some() {
178            return;
179        }
180
181        self.is_fetching = true;
182        self.fetch_error = None;
183        self.last_refresh = Some(Instant::now());
184        cx.notify();
185
186        let fs = self.fs.clone();
187        let http_client = self.http_client.clone();
188
189        self.pending_refresh = Some(cx.spawn(async move |this, cx| {
190            let result = match fetch_registry_index(http_client.clone()).await {
191                Ok(data) => {
192                    build_registry_agents(fs.clone(), http_client, data.index, data.raw_body, true)
193                        .await
194                }
195                Err(error) => Err(error),
196            };
197
198            this.update(cx, |this, cx| {
199                this.pending_refresh = None;
200                this.is_fetching = false;
201                match result {
202                    Ok(agents) => {
203                        this.agents = agents;
204                        this.fetch_error = None;
205                    }
206                    Err(error) => {
207                        this.fetch_error = Some(SharedString::from(error.to_string()));
208                    }
209                }
210                cx.notify();
211            })
212            .ok();
213        }));
214    }
215
216    /// Refresh the registry if it hasn't been refreshed recently.
217    ///
218    /// This is useful to call when using a registry-based agent to check for
219    /// updates without making too many network requests. The refresh is
220    /// throttled to at most once per hour.
221    pub fn refresh_if_stale(&mut self, cx: &mut Context<Self>) {
222        let should_refresh = self
223            .last_refresh
224            .map(|last| last.elapsed() >= REFRESH_THROTTLE_DURATION)
225            .unwrap_or(true);
226
227        if should_refresh {
228            self.refresh(cx);
229        }
230    }
231
232    fn new(fs: Arc<dyn Fs>, http_client: Arc<dyn HttpClient>, cx: &mut Context<Self>) -> Self {
233        let mut store = Self {
234            fs: fs.clone(),
235            http_client,
236            agents: Vec::new(),
237            is_fetching: false,
238            fetch_error: None,
239            pending_refresh: None,
240            last_refresh: None,
241        };
242
243        store.load_cached_registry(fs, store.http_client.clone(), cx);
244
245        store
246    }
247
248    fn load_cached_registry(
249        &mut self,
250        fs: Arc<dyn Fs>,
251        http_client: Arc<dyn HttpClient>,
252        cx: &mut Context<Self>,
253    ) {
254        cx.spawn(async move |this, cx| -> Result<()> {
255            let cache_path = registry_cache_path();
256            if !fs.is_file(&cache_path).await {
257                return Ok(());
258            }
259
260            let bytes = fs
261                .load_bytes(&cache_path)
262                .await
263                .context("reading cached registry")?;
264            let index: RegistryIndex =
265                serde_json::from_slice(&bytes).context("parsing cached registry")?;
266
267            let agents = build_registry_agents(fs, http_client, index, bytes, false).await?;
268
269            this.update(cx, |this, cx| {
270                this.agents = agents;
271                cx.notify();
272            })?;
273
274            Ok(())
275        })
276        .detach_and_log_err(cx);
277    }
278}
279
280struct RegistryFetchResult {
281    index: RegistryIndex,
282    raw_body: Vec<u8>,
283}
284
285async fn fetch_registry_index(http_client: Arc<dyn HttpClient>) -> Result<RegistryFetchResult> {
286    let mut response = http_client
287        .get(REGISTRY_URL, AsyncBody::default(), true)
288        .await
289        .context("requesting ACP registry")?;
290
291    let mut body = Vec::new();
292    response
293        .body_mut()
294        .read_to_end(&mut body)
295        .await
296        .context("reading ACP registry response")?;
297
298    if response.status().is_client_error() {
299        let text = String::from_utf8_lossy(body.as_slice());
300        bail!(
301            "registry status error {}, response: {text:?}",
302            response.status().as_u16()
303        );
304    }
305
306    let index: RegistryIndex = serde_json::from_slice(&body).context("parsing ACP registry")?;
307    Ok(RegistryFetchResult {
308        index,
309        raw_body: body,
310    })
311}
312
313async fn build_registry_agents(
314    fs: Arc<dyn Fs>,
315    http_client: Arc<dyn HttpClient>,
316    index: RegistryIndex,
317    raw_body: Vec<u8>,
318    update_cache: bool,
319) -> Result<Vec<RegistryAgent>> {
320    let cache_dir = registry_cache_dir();
321    fs.create_dir(&cache_dir).await?;
322
323    let cache_path = cache_dir.join("registry.json");
324    if update_cache {
325        fs.write(&cache_path, &raw_body).await?;
326    }
327
328    let icons_dir = cache_dir.join("icons");
329    if update_cache {
330        fs.create_dir(&icons_dir).await?;
331    }
332
333    let current_platform = current_platform_key();
334
335    let mut agents = Vec::new();
336    for entry in index.agents {
337        let icon_path = resolve_icon_path(
338            &entry,
339            &icons_dir,
340            update_cache,
341            fs.clone(),
342            http_client.clone(),
343        )
344        .await?;
345
346        let metadata = RegistryAgentMetadata {
347            id: entry.id.into(),
348            name: entry.name.into(),
349            description: entry.description.into(),
350            version: entry.version.into(),
351            repository: entry.repository.map(Into::into),
352            icon_path,
353        };
354
355        let binary_agent = entry.distribution.binary.as_ref().and_then(|binary| {
356            if binary.is_empty() {
357                return None;
358            }
359
360            let mut targets = HashMap::default();
361            for (platform, target) in binary.iter() {
362                targets.insert(
363                    platform.clone(),
364                    RegistryTargetConfig {
365                        archive: target.archive.clone(),
366                        cmd: target.cmd.clone(),
367                        args: target.args.clone(),
368                        sha256: None,
369                        env: target.env.clone(),
370                    },
371                );
372            }
373
374            let supports_current_platform = current_platform
375                .as_ref()
376                .is_some_and(|platform| targets.contains_key(*platform));
377
378            Some(RegistryBinaryAgent {
379                metadata: metadata.clone(),
380                targets,
381                supports_current_platform,
382            })
383        });
384
385        let npx_agent = entry.distribution.npx.as_ref().map(|npx| RegistryNpxAgent {
386            metadata: metadata.clone(),
387            package: npx.package.clone().into(),
388            args: npx.args.clone(),
389            env: npx.env.clone(),
390        });
391
392        let agent = match (binary_agent, npx_agent) {
393            (Some(binary_agent), Some(npx_agent)) => {
394                if binary_agent.supports_current_platform {
395                    RegistryAgent::Binary(binary_agent)
396                } else {
397                    RegistryAgent::Npx(npx_agent)
398                }
399            }
400            (Some(binary_agent), None) => RegistryAgent::Binary(binary_agent),
401            (None, Some(npx_agent)) => RegistryAgent::Npx(npx_agent),
402            (None, None) => continue,
403        };
404
405        agents.push(agent);
406    }
407
408    Ok(agents)
409}
410
411async fn resolve_icon_path(
412    entry: &RegistryEntry,
413    icons_dir: &Path,
414    update_cache: bool,
415    fs: Arc<dyn Fs>,
416    http_client: Arc<dyn HttpClient>,
417) -> Result<Option<SharedString>> {
418    let icon_url = resolve_icon_url(entry);
419    let Some(icon_url) = icon_url else {
420        return Ok(None);
421    };
422
423    let icon_path = icons_dir.join(format!("{}.svg", entry.id));
424    if update_cache && !fs.is_file(&icon_path).await {
425        if let Err(error) = download_icon(fs.clone(), http_client, &icon_url, entry).await {
426            log::warn!(
427                "Failed to download ACP registry icon for {}: {error:#}",
428                entry.id
429            );
430        }
431    }
432
433    if fs.is_file(&icon_path).await {
434        Ok(Some(SharedString::from(
435            icon_path.to_string_lossy().into_owned(),
436        )))
437    } else {
438        Ok(None)
439    }
440}
441
442async fn download_icon(
443    fs: Arc<dyn Fs>,
444    http_client: Arc<dyn HttpClient>,
445    icon_url: &str,
446    entry: &RegistryEntry,
447) -> Result<()> {
448    let mut response = http_client
449        .get(icon_url, AsyncBody::default(), true)
450        .await
451        .with_context(|| format!("requesting icon for {}", entry.id))?;
452
453    let mut body = Vec::new();
454    response
455        .body_mut()
456        .read_to_end(&mut body)
457        .await
458        .with_context(|| format!("reading icon for {}", entry.id))?;
459
460    if response.status().is_client_error() {
461        let text = String::from_utf8_lossy(body.as_slice());
462        bail!(
463            "icon status error {}, response: {text:?}",
464            response.status().as_u16()
465        );
466    }
467
468    let icon_path = registry_cache_dir()
469        .join("icons")
470        .join(format!("{}.svg", entry.id));
471    fs.write(&icon_path, &body).await?;
472    Ok(())
473}
474
475fn resolve_icon_url(entry: &RegistryEntry) -> Option<String> {
476    let icon = entry.icon.as_ref()?;
477    if icon.starts_with("https://") || icon.starts_with("http://") {
478        return Some(icon.to_string());
479    }
480
481    let relative_icon = icon.trim_start_matches("./");
482    Some(format!(
483        "https://raw.githubusercontent.com/agentclientprotocol/registry/main/{}/{relative_icon}",
484        entry.id
485    ))
486}
487
488fn current_platform_key() -> Option<&'static str> {
489    let os = if cfg!(target_os = "macos") {
490        "darwin"
491    } else if cfg!(target_os = "linux") {
492        "linux"
493    } else if cfg!(target_os = "windows") {
494        "windows"
495    } else {
496        return None;
497    };
498
499    let arch = if cfg!(target_arch = "aarch64") {
500        "aarch64"
501    } else if cfg!(target_arch = "x86_64") {
502        "x86_64"
503    } else {
504        return None;
505    };
506
507    Some(match os {
508        "darwin" => match arch {
509            "aarch64" => "darwin-aarch64",
510            "x86_64" => "darwin-x86_64",
511            _ => return None,
512        },
513        "linux" => match arch {
514            "aarch64" => "linux-aarch64",
515            "x86_64" => "linux-x86_64",
516            _ => return None,
517        },
518        "windows" => match arch {
519            "aarch64" => "windows-aarch64",
520            "x86_64" => "windows-x86_64",
521            _ => return None,
522        },
523        _ => return None,
524    })
525}
526
527fn registry_cache_dir() -> PathBuf {
528    paths::external_agents_dir().join("registry")
529}
530
531fn registry_cache_path() -> PathBuf {
532    registry_cache_dir().join("registry.json")
533}
534
535#[derive(Deserialize)]
536struct RegistryIndex {
537    #[serde(rename = "version")]
538    _version: String,
539    agents: Vec<RegistryEntry>,
540    #[serde(rename = "extensions")]
541    _extensions: Vec<RegistryEntry>,
542}
543
544#[derive(Deserialize)]
545struct RegistryEntry {
546    id: String,
547    name: String,
548    version: String,
549    description: String,
550    #[serde(default)]
551    repository: Option<String>,
552    #[serde(default)]
553    icon: Option<String>,
554    distribution: RegistryDistribution,
555}
556
557#[derive(Deserialize)]
558struct RegistryDistribution {
559    #[serde(default)]
560    binary: Option<HashMap<String, RegistryBinaryTarget>>,
561    #[serde(default)]
562    npx: Option<RegistryNpxDistribution>,
563}
564
565#[derive(Deserialize)]
566struct RegistryBinaryTarget {
567    archive: String,
568    cmd: String,
569    #[serde(default)]
570    args: Vec<String>,
571    #[serde(default)]
572    env: HashMap<String, String>,
573}
574
575#[derive(Deserialize)]
576struct RegistryNpxDistribution {
577    package: String,
578    #[serde(default)]
579    args: Vec<String>,
580    #[serde(default)]
581    env: HashMap<String, String>,
582}