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    params: Option<serde_json::Value>,
71}
72
73#[derive(Debug, Deserialize)]
74struct SkillResponse {
75    name: String,
76}
77
78pub(super) async fn execute(args: RegistryArgs, _config: &CliConfig) -> Result<CommandOutput> {
79    let profile_url = ProfileBootstrap::get()
80        .ok()
81        .map(|p| p.server.api_external_url.clone());
82    let base_url = args
83        .url
84        .clone()
85        .or(profile_url)
86        .unwrap_or_else(|| FALLBACK_GATEWAY_URL.to_owned());
87    let registry_url = format!("{}/api/v1/agents/registry", base_url.trim_end_matches('/'));
88
89    let registry = fetch_registry(&registry_url).await?;
90
91    let agents: Vec<RegistryAgentInfo> = registry
92        .data
93        .into_iter()
94        .filter(|agent| !args.running || is_agent_running(agent))
95        .map(|agent| to_agent_info(agent, args.verbose))
96        .collect();
97
98    let output = RegistryOutput {
99        gateway_url: base_url.clone(),
100        agents_count: agents.len(),
101        agents,
102    };
103
104    Ok(CommandOutput::table_of(
105        vec![
106            "name",
107            "url",
108            "status",
109            "version",
110            "streaming",
111            "skills_count",
112        ],
113        &output.agents,
114    )
115    .with_title("Agent Registry"))
116}
117
118async fn fetch_registry(registry_url: &str) -> Result<RegistryResponse> {
119    let client = Client::new();
120    let response = client
121        .get(registry_url)
122        .send()
123        .await
124        .with_context(|| format!("Failed to connect to gateway at {}", registry_url))?;
125
126    if !response.status().is_success() {
127        let status = response.status();
128        let body = response.text().await.unwrap_or_else(|e| {
129            tracing::warn!(error = %e, "Failed to read error response body");
130            String::new()
131        });
132        anyhow::bail!("Registry request failed with status {}: {}", status, body);
133    }
134
135    response
136        .json()
137        .await
138        .context("Failed to parse registry response")
139}
140
141pub fn to_agent_info(agent: AgentCardResponse, verbose: bool) -> RegistryAgentInfo {
142    let status = extract_status(&agent);
143    let skills: Vec<String> = agent.skills.iter().map(|s| s.name.clone()).collect();
144
145    let url = agent
146        .supported_interfaces
147        .first()
148        .map_or_else(String::new, |i| i.url.clone());
149
150    RegistryAgentInfo {
151        name: agent.name,
152        description: if verbose {
153            agent.description
154        } else {
155            truncate_with_ellipsis(&agent.description, 50)
156        },
157        url,
158        version: agent.version,
159        status,
160        streaming: agent.capabilities.streaming.unwrap_or(false),
161        skills_count: skills.len(),
162        skills: if verbose { skills } else { vec![] },
163    }
164}
165
166pub fn is_agent_running(agent: &AgentCardResponse) -> bool {
167    agent.capabilities.extensions.as_ref().is_some_and(|exts| {
168        exts.iter().any(|ext| {
169            ext.uri == "systemprompt:service-status"
170                && ext
171                    .params
172                    .as_ref()
173                    .and_then(|p| p.get("status"))
174                    .and_then(|s| s.as_str())
175                    .is_some_and(|s| s == "running")
176        })
177    })
178}
179
180pub fn extract_status(agent: &AgentCardResponse) -> String {
181    agent
182        .capabilities
183        .extensions
184        .as_ref()
185        .and_then(|exts| {
186            exts.iter().find_map(|ext| {
187                if ext.uri == "systemprompt:service-status" {
188                    ext.params
189                        .as_ref()
190                        .and_then(|p| p.get("status"))
191                        .and_then(|s| s.as_str())
192                        .map(String::from)
193                } else {
194                    None
195                }
196            })
197        })
198        .unwrap_or_else(|| "unknown".to_owned())
199}