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