systemprompt_cli/commands/admin/agents/
registry.rs1use 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>,
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(®istry_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}