Skip to main content

systemprompt_cli/commands/admin/agents/
registry.rs

1//! Agent-registry HTTP client for admin commands.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use anyhow::{Context, Result};
7use clap::Args;
8use reqwest::Client;
9use serde::Deserialize;
10use systemprompt_config::ProfileBootstrap;
11
12use super::types::{RegistryAgentInfo, RegistryOutput};
13use crate::CliConfig;
14use crate::shared::{CommandOutput, truncate_with_ellipsis};
15
16const FALLBACK_GATEWAY_URL: &str = "http://localhost:8080";
17
18#[derive(Debug, Args)]
19pub struct RegistryArgs {
20    #[arg(
21        long,
22        help = "Gateway URL (default: active profile's api_external_url)"
23    )]
24    pub url: Option<String>,
25
26    #[arg(long, help = "Show only running agents")]
27    pub running: bool,
28
29    #[arg(long, help = "Include full agent card details")]
30    pub verbose: bool,
31}
32
33#[derive(Debug, Deserialize)]
34struct RegistryResponse {
35    data: Vec<AgentCardResponse>,
36}
37
38#[derive(Debug, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct AgentCardResponse {
41    name: String,
42    description: String,
43    #[serde(default)]
44    supported_interfaces: Vec<AgentInterfaceResponse>,
45    version: String,
46    #[serde(default)]
47    capabilities: CapabilitiesResponse,
48    #[serde(default)]
49    skills: Vec<SkillResponse>,
50}
51
52#[derive(Debug, Deserialize)]
53#[serde(rename_all = "camelCase")]
54struct AgentInterfaceResponse {
55    url: String,
56}
57
58#[derive(Debug, Default, Deserialize)]
59#[serde(rename_all = "camelCase")]
60struct CapabilitiesResponse {
61    streaming: Option<bool>,
62    #[serde(default)]
63    extensions: Option<Vec<ExtensionResponse>>,
64}
65
66#[derive(Debug, Deserialize)]
67struct ExtensionResponse {
68    uri: String,
69    #[serde(default)]
70    // JSON: A2A AgentCard `AgentExtension.params` — free-form per the spec.
71    params: Option<serde_json::Value>,
72}
73
74#[derive(Debug, Deserialize)]
75struct SkillResponse {
76    name: String,
77}
78
79pub(super) async fn execute(args: RegistryArgs, _config: &CliConfig) -> Result<CommandOutput> {
80    let profile_url = ProfileBootstrap::get()
81        .ok()
82        .map(|p| p.server.api_external_url.clone());
83    let base_url = args
84        .url
85        .clone()
86        .or(profile_url)
87        .unwrap_or_else(|| FALLBACK_GATEWAY_URL.to_owned());
88    let registry_url = format!("{}/api/v1/agents/registry", base_url.trim_end_matches('/'));
89
90    let registry = fetch_registry(&registry_url).await?;
91
92    let agents: Vec<RegistryAgentInfo> = registry
93        .data
94        .into_iter()
95        .filter(|agent| !args.running || is_agent_running(agent))
96        .map(|agent| to_agent_info(agent, args.verbose))
97        .collect();
98
99    let output = RegistryOutput {
100        gateway_url: base_url.clone(),
101        agents_count: agents.len(),
102        agents,
103    };
104
105    Ok(CommandOutput::table_of(
106        vec![
107            "name",
108            "url",
109            "status",
110            "version",
111            "streaming",
112            "skills_count",
113        ],
114        &output.agents,
115    )
116    .with_title("Agent Registry"))
117}
118
119async fn fetch_registry(registry_url: &str) -> Result<RegistryResponse> {
120    let client = Client::new();
121    let response = client
122        .get(registry_url)
123        .send()
124        .await
125        .with_context(|| format!("Failed to connect to gateway at {}", registry_url))?;
126
127    if !response.status().is_success() {
128        let status = response.status();
129        let body = response.text().await.unwrap_or_else(|e| {
130            tracing::warn!(error = %e, "Failed to read error response body");
131            String::new()
132        });
133        anyhow::bail!("Registry request failed with status {}: {}", status, body);
134    }
135
136    response
137        .json()
138        .await
139        .context("Failed to parse registry response")
140}
141
142pub fn to_agent_info(agent: AgentCardResponse, verbose: bool) -> RegistryAgentInfo {
143    let status = extract_status(&agent);
144    let skills: Vec<String> = agent.skills.iter().map(|s| s.name.clone()).collect();
145
146    let url = agent
147        .supported_interfaces
148        .first()
149        .map_or_else(String::new, |i| i.url.clone());
150
151    RegistryAgentInfo {
152        name: agent.name,
153        description: if verbose {
154            agent.description
155        } else {
156            truncate_with_ellipsis(&agent.description, 50)
157        },
158        url,
159        version: agent.version,
160        status,
161        streaming: agent.capabilities.streaming.unwrap_or(false),
162        skills_count: skills.len(),
163        skills: if verbose { skills } else { vec![] },
164    }
165}
166
167pub fn is_agent_running(agent: &AgentCardResponse) -> bool {
168    agent.capabilities.extensions.as_ref().is_some_and(|exts| {
169        exts.iter().any(|ext| {
170            ext.uri == "systemprompt:service-status"
171                && ext
172                    .params
173                    .as_ref()
174                    .and_then(|p| p.get("status"))
175                    .and_then(|s| s.as_str())
176                    .is_some_and(|s| s == "running")
177        })
178    })
179}
180
181pub fn extract_status(agent: &AgentCardResponse) -> String {
182    agent
183        .capabilities
184        .extensions
185        .as_ref()
186        .and_then(|exts| {
187            exts.iter().find_map(|ext| {
188                if ext.uri == "systemprompt:service-status" {
189                    ext.params
190                        .as_ref()
191                        .and_then(|p| p.get("status"))
192                        .and_then(|s| s.as_str())
193                        .map(String::from)
194                } else {
195                    None
196                }
197            })
198        })
199        .unwrap_or_else(|| "unknown".to_owned())
200}