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