agent_registry_store.rs

  1use std::path::{Path, PathBuf};
  2use std::sync::Arc;
  3use std::time::Duration;
  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;
 12
 13const REGISTRY_URL: &str =
 14    "https://github.com/agentclientprotocol/registry/releases/latest/download/registry.json";
 15const REGISTRY_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60);
 16
 17#[derive(Clone, Debug)]
 18pub struct RegistryAgent {
 19    pub id: SharedString,
 20    pub name: SharedString,
 21    pub description: SharedString,
 22    pub version: SharedString,
 23    pub repository: Option<SharedString>,
 24    pub icon_path: Option<SharedString>,
 25    pub targets: HashMap<String, RegistryTargetConfig>,
 26    pub supports_current_platform: bool,
 27}
 28
 29#[derive(Clone, Debug)]
 30pub struct RegistryTargetConfig {
 31    pub archive: String,
 32    pub cmd: String,
 33    pub args: Vec<String>,
 34    pub sha256: Option<String>,
 35    pub env: HashMap<String, String>,
 36}
 37
 38struct GlobalAgentRegistryStore(Entity<AgentRegistryStore>);
 39
 40impl Global for GlobalAgentRegistryStore {}
 41
 42pub struct AgentRegistryStore {
 43    fs: Arc<dyn Fs>,
 44    http_client: Arc<dyn HttpClient>,
 45    agents: Vec<RegistryAgent>,
 46    is_fetching: bool,
 47    fetch_error: Option<SharedString>,
 48    pending_refresh: Option<Task<()>>,
 49    _poll_task: Task<Result<()>>,
 50}
 51
 52impl AgentRegistryStore {
 53    pub fn init_global(cx: &mut App) -> Entity<Self> {
 54        if let Some(store) = Self::try_global(cx) {
 55            return store;
 56        }
 57
 58        let fs = <dyn Fs>::global(cx);
 59        let http_client: Arc<dyn HttpClient> = cx.http_client();
 60
 61        let store = cx.new(|cx| Self::new(fs, http_client, cx));
 62        store.update(cx, |store, cx| {
 63            store.refresh(cx);
 64            store.start_polling(cx);
 65        });
 66        cx.set_global(GlobalAgentRegistryStore(store.clone()));
 67        store
 68    }
 69
 70    pub fn global(cx: &App) -> Entity<Self> {
 71        cx.global::<GlobalAgentRegistryStore>().0.clone()
 72    }
 73
 74    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
 75        cx.try_global::<GlobalAgentRegistryStore>()
 76            .map(|store| store.0.clone())
 77    }
 78
 79    pub fn agents(&self) -> &[RegistryAgent] {
 80        &self.agents
 81    }
 82
 83    pub fn agent(&self, id: &str) -> Option<&RegistryAgent> {
 84        self.agents.iter().find(|agent| agent.id == id)
 85    }
 86
 87    pub fn is_fetching(&self) -> bool {
 88        self.is_fetching
 89    }
 90
 91    pub fn fetch_error(&self) -> Option<SharedString> {
 92        self.fetch_error.clone()
 93    }
 94
 95    pub fn refresh(&mut self, cx: &mut Context<Self>) {
 96        if self.pending_refresh.is_some() {
 97            return;
 98        }
 99
100        self.is_fetching = true;
101        self.fetch_error = None;
102        cx.notify();
103
104        let fs = self.fs.clone();
105        let http_client = self.http_client.clone();
106
107        self.pending_refresh = Some(cx.spawn(async move |this, cx| {
108            let result = match fetch_registry_index(http_client.clone()).await {
109                Ok(data) => {
110                    build_registry_agents(fs.clone(), http_client, data.index, data.raw_body, true)
111                        .await
112                }
113                Err(error) => Err(error),
114            };
115
116            this.update(cx, |this, cx| {
117                this.pending_refresh = None;
118                this.is_fetching = false;
119                match result {
120                    Ok(agents) => {
121                        this.agents = agents;
122                        this.fetch_error = None;
123                    }
124                    Err(error) => {
125                        this.fetch_error = Some(SharedString::from(error.to_string()));
126                    }
127                }
128                cx.notify();
129            })
130            .ok();
131        }));
132    }
133
134    fn new(fs: Arc<dyn Fs>, http_client: Arc<dyn HttpClient>, cx: &mut Context<Self>) -> Self {
135        let mut store = Self {
136            fs: fs.clone(),
137            http_client,
138            agents: Vec::new(),
139            is_fetching: false,
140            fetch_error: None,
141            pending_refresh: None,
142            _poll_task: Task::ready(Ok(())),
143        };
144
145        store.load_cached_registry(fs, store.http_client.clone(), cx);
146
147        store
148    }
149
150    fn load_cached_registry(
151        &mut self,
152        fs: Arc<dyn Fs>,
153        http_client: Arc<dyn HttpClient>,
154        cx: &mut Context<Self>,
155    ) {
156        cx.spawn(async move |this, cx| -> Result<()> {
157            let cache_path = registry_cache_path();
158            if !fs.is_file(&cache_path).await {
159                return Ok(());
160            }
161
162            let bytes = fs
163                .load_bytes(&cache_path)
164                .await
165                .context("reading cached registry")?;
166            let index: RegistryIndex =
167                serde_json::from_slice(&bytes).context("parsing cached registry")?;
168
169            let agents = build_registry_agents(fs, http_client, index, bytes, false).await?;
170
171            this.update(cx, |this, cx| {
172                this.agents = agents;
173                cx.notify();
174            })?;
175
176            Ok(())
177        })
178        .detach_and_log_err(cx);
179    }
180
181    fn start_polling(&mut self, cx: &mut Context<Self>) {
182        self._poll_task = cx.spawn(async move |this, cx| -> Result<()> {
183            loop {
184                this.update(cx, |this, cx| this.refresh(cx))?;
185                cx.background_executor()
186                    .timer(REGISTRY_REFRESH_INTERVAL)
187                    .await;
188            }
189        });
190    }
191}
192
193struct RegistryFetchResult {
194    index: RegistryIndex,
195    raw_body: Vec<u8>,
196}
197
198async fn fetch_registry_index(http_client: Arc<dyn HttpClient>) -> Result<RegistryFetchResult> {
199    let mut response = http_client
200        .get(REGISTRY_URL, AsyncBody::default(), true)
201        .await
202        .context("requesting ACP registry")?;
203
204    let mut body = Vec::new();
205    response
206        .body_mut()
207        .read_to_end(&mut body)
208        .await
209        .context("reading ACP registry response")?;
210
211    if response.status().is_client_error() {
212        let text = String::from_utf8_lossy(body.as_slice());
213        bail!(
214            "registry status error {}, response: {text:?}",
215            response.status().as_u16()
216        );
217    }
218
219    let index: RegistryIndex = serde_json::from_slice(&body).context("parsing ACP registry")?;
220    Ok(RegistryFetchResult {
221        index,
222        raw_body: body,
223    })
224}
225
226async fn build_registry_agents(
227    fs: Arc<dyn Fs>,
228    http_client: Arc<dyn HttpClient>,
229    index: RegistryIndex,
230    raw_body: Vec<u8>,
231    update_cache: bool,
232) -> Result<Vec<RegistryAgent>> {
233    let cache_dir = registry_cache_dir();
234    fs.create_dir(&cache_dir).await?;
235
236    let cache_path = cache_dir.join("registry.json");
237    if update_cache {
238        fs.write(&cache_path, &raw_body).await?;
239    }
240
241    let icons_dir = cache_dir.join("icons");
242    if update_cache {
243        fs.create_dir(&icons_dir).await?;
244    }
245
246    let current_platform = current_platform_key();
247
248    let mut agents = Vec::new();
249    for entry in index.agents {
250        let Some(binary) = entry.distribution.binary.as_ref() else {
251            continue;
252        };
253
254        if binary.is_empty() {
255            continue;
256        }
257
258        let mut targets = HashMap::default();
259        for (platform, target) in binary.iter() {
260            targets.insert(
261                platform.clone(),
262                RegistryTargetConfig {
263                    archive: target.archive.clone(),
264                    cmd: target.cmd.clone(),
265                    args: target.args.clone(),
266                    sha256: None,
267                    env: target.env.clone(),
268                },
269            );
270        }
271
272        let supports_current_platform = current_platform
273            .as_ref()
274            .is_some_and(|platform| targets.contains_key(*platform));
275
276        let icon_path = resolve_icon_path(
277            &entry,
278            &icons_dir,
279            update_cache,
280            fs.clone(),
281            http_client.clone(),
282        )
283        .await?;
284
285        agents.push(RegistryAgent {
286            id: entry.id.into(),
287            name: entry.name.into(),
288            description: entry.description.into(),
289            version: entry.version.into(),
290            repository: entry.repository.map(Into::into),
291            icon_path,
292            targets,
293            supports_current_platform,
294        });
295    }
296
297    Ok(agents)
298}
299
300async fn resolve_icon_path(
301    entry: &RegistryEntry,
302    icons_dir: &Path,
303    update_cache: bool,
304    fs: Arc<dyn Fs>,
305    http_client: Arc<dyn HttpClient>,
306) -> Result<Option<SharedString>> {
307    let icon_url = resolve_icon_url(entry);
308    let Some(icon_url) = icon_url else {
309        return Ok(None);
310    };
311
312    let icon_path = icons_dir.join(format!("{}.svg", entry.id));
313    if update_cache && !fs.is_file(&icon_path).await {
314        if let Err(error) = download_icon(fs.clone(), http_client, &icon_url, entry).await {
315            log::warn!(
316                "Failed to download ACP registry icon for {}: {error:#}",
317                entry.id
318            );
319        }
320    }
321
322    if fs.is_file(&icon_path).await {
323        Ok(Some(SharedString::from(
324            icon_path.to_string_lossy().into_owned(),
325        )))
326    } else {
327        Ok(None)
328    }
329}
330
331async fn download_icon(
332    fs: Arc<dyn Fs>,
333    http_client: Arc<dyn HttpClient>,
334    icon_url: &str,
335    entry: &RegistryEntry,
336) -> Result<()> {
337    let mut response = http_client
338        .get(icon_url, AsyncBody::default(), true)
339        .await
340        .with_context(|| format!("requesting icon for {}", entry.id))?;
341
342    let mut body = Vec::new();
343    response
344        .body_mut()
345        .read_to_end(&mut body)
346        .await
347        .with_context(|| format!("reading icon for {}", entry.id))?;
348
349    if response.status().is_client_error() {
350        let text = String::from_utf8_lossy(body.as_slice());
351        bail!(
352            "icon status error {}, response: {text:?}",
353            response.status().as_u16()
354        );
355    }
356
357    let icon_path = registry_cache_dir()
358        .join("icons")
359        .join(format!("{}.svg", entry.id));
360    fs.write(&icon_path, &body).await?;
361    Ok(())
362}
363
364fn resolve_icon_url(entry: &RegistryEntry) -> Option<String> {
365    let icon = entry.icon.as_ref()?;
366    if icon.starts_with("https://") || icon.starts_with("http://") {
367        return Some(icon.to_string());
368    }
369
370    let relative_icon = icon.trim_start_matches("./");
371    Some(format!(
372        "https://raw.githubusercontent.com/agentclientprotocol/registry/main/{}/{relative_icon}",
373        entry.id
374    ))
375}
376
377fn current_platform_key() -> Option<&'static str> {
378    let os = if cfg!(target_os = "macos") {
379        "darwin"
380    } else if cfg!(target_os = "linux") {
381        "linux"
382    } else if cfg!(target_os = "windows") {
383        "windows"
384    } else {
385        return None;
386    };
387
388    let arch = if cfg!(target_arch = "aarch64") {
389        "aarch64"
390    } else if cfg!(target_arch = "x86_64") {
391        "x86_64"
392    } else {
393        return None;
394    };
395
396    Some(match os {
397        "darwin" => match arch {
398            "aarch64" => "darwin-aarch64",
399            "x86_64" => "darwin-x86_64",
400            _ => return None,
401        },
402        "linux" => match arch {
403            "aarch64" => "linux-aarch64",
404            "x86_64" => "linux-x86_64",
405            _ => return None,
406        },
407        "windows" => match arch {
408            "aarch64" => "windows-aarch64",
409            "x86_64" => "windows-x86_64",
410            _ => return None,
411        },
412        _ => return None,
413    })
414}
415
416fn registry_cache_dir() -> PathBuf {
417    paths::external_agents_dir().join("registry")
418}
419
420fn registry_cache_path() -> PathBuf {
421    registry_cache_dir().join("registry.json")
422}
423
424#[derive(Deserialize)]
425struct RegistryIndex {
426    #[serde(rename = "version")]
427    _version: String,
428    agents: Vec<RegistryEntry>,
429    #[serde(rename = "extensions")]
430    _extensions: Vec<RegistryEntry>,
431}
432
433#[derive(Deserialize)]
434struct RegistryEntry {
435    id: String,
436    name: String,
437    version: String,
438    description: String,
439    #[serde(default)]
440    repository: Option<String>,
441    #[serde(default)]
442    icon: Option<String>,
443    distribution: RegistryDistribution,
444}
445
446#[derive(Deserialize)]
447struct RegistryDistribution {
448    #[serde(default)]
449    binary: Option<HashMap<String, RegistryBinaryTarget>>,
450}
451
452#[derive(Deserialize)]
453struct RegistryBinaryTarget {
454    archive: String,
455    cmd: String,
456    #[serde(default)]
457    args: Vec<String>,
458    #[serde(default)]
459    env: HashMap<String, String>,
460}