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>,
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(®istry_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}