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