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