1use std::{collections::BTreeSet, fmt};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
7
8pub const SERVICE_NAME: &str = "agent-registry";
9pub const AUDIENCE: &str = "agent-registry";
10pub const DEFAULT_PORT: u16 = 5100;
11pub const HEALTH_PATH: &str = "/health";
12pub const AGENTS_PATH: &str = "/internal/v1/agents";
13pub const AGENT_PATH: &str = "/internal/v1/agents/{agent_id}";
14pub const PUBLIC_AGENTS_PATH: &str = "/v1/agents";
15pub const PUBLIC_AGENT_PATH: &str = "/v1/agents/{agent_id}";
16
17const MAX_IDENTIFIER_BYTES: usize = 255;
18const MAX_RELATIONS_PER_KIND: usize = 256;
19const MAX_SYSTEM_PROMPT_CHARS: usize = 16_383;
20const MAX_AGENT_SKILLS: usize = 32;
21
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
23#[serde(transparent)]
24pub struct AgentId(String);
25
26impl AgentId {
27 pub fn new(value: impl Into<String>) -> Result<Self, String> {
28 let value = value.into();
29 if !value.starts_with("agt_") {
30 return Err("agent_id must start with 'agt_'".into());
31 }
32 validate_identifier("agent_id", &value)?;
33 Ok(Self(value))
34 }
35
36 pub fn as_str(&self) -> &str {
37 &self.0
38 }
39}
40
41impl<'de> Deserialize<'de> for AgentId {
42 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
43 where
44 D: Deserializer<'de>,
45 {
46 let value = String::deserialize(deserializer)?;
47 Self::new(value).map_err(D::Error::custom)
48 }
49}
50
51impl fmt::Display for AgentId {
52 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53 self.0.fmt(formatter)
54 }
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase", deny_unknown_fields)]
63pub struct ResourceRelations {
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub agent_ids: Vec<AgentId>,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub runtime_ids: Vec<String>,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub user_ids: Vec<String>,
70}
71
72impl ResourceRelations {
73 pub fn normalize(mut self) -> Result<Self, String> {
74 normalize_values("agent_ids", &mut self.agent_ids)?;
75 normalize_strings("runtime_ids", &mut self.runtime_ids)?;
76 normalize_strings("user_ids", &mut self.user_ids)?;
77 Ok(self)
78 }
79
80 pub fn contains_user(&self, user_id: &str) -> bool {
81 self.user_ids.iter().any(|candidate| candidate == user_id)
82 }
83}
84
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum AgentStatus {
88 #[default]
89 Active,
90 Archived,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95pub struct AgentSkillRef {
96 pub group: String,
97 pub name: String,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub version: Option<String>,
101}
102
103impl AgentSkillRef {
104 pub fn parse(value: &str) -> Result<Self, String> {
105 let value = value.trim();
106 if value.is_empty() {
107 return Err("skill ref must not be empty".into());
108 }
109 let (identity, version) = match value.rsplit_once('@') {
110 Some((identity, version)) if identity.contains('/') => (identity, Some(version.trim())),
111 _ => (value, None),
112 };
113 let identity = identity.strip_prefix('@').unwrap_or(identity);
114 let Some((group, name)) = identity.split_once('/') else {
115 return Err("skill ref must be group/name or @group/name[@version]".into());
116 };
117 let version = version
118 .map(str::trim)
119 .filter(|value| !value.is_empty())
120 .map(str::to_string);
121 let skill = Self {
122 group: group.trim().to_string(),
123 name: name.trim().to_string(),
124 version,
125 };
126 validate_skill_ref(&skill)?;
127 Ok(skill)
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct Agent {
134 pub id: AgentId,
135 pub owner_user_id: String,
136 pub name: String,
137 pub description: String,
138 #[serde(default, skip_serializing_if = "String::is_empty")]
139 pub system_prompt: String,
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub skills: Vec<AgentSkillRef>,
142 pub status: AgentStatus,
143 pub revision: u64,
144 #[serde(default)]
145 pub relations: ResourceRelations,
146 pub created_at: DateTime<Utc>,
147 pub updated_at: DateTime<Utc>,
148}
149
150#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase", deny_unknown_fields)]
152pub struct CreateAgentRequest {
153 pub name: String,
154 #[serde(default)]
155 pub description: String,
156 #[serde(default, skip_serializing_if = "String::is_empty")]
157 pub system_prompt: String,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub skills: Vec<AgentSkillRef>,
160 #[serde(default)]
161 pub relations: ResourceRelations,
162 #[serde(default)]
164 pub idempotency_key: String,
165}
166
167#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase", deny_unknown_fields)]
169pub struct UpdateAgentRequest {
170 pub expected_revision: u64,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub name: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub description: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub system_prompt: Option<String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub skills: Option<Vec<AgentSkillRef>>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub status: Option<AgentStatus>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub relations: Option<ResourceRelations>,
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase", deny_unknown_fields)]
187pub struct ListAgentsRequest {
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub status: Option<AgentStatus>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub related_agent_id: Option<AgentId>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub related_runtime_id: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub related_user_id: Option<String>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub cursor: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub limit: Option<u32>,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase", deny_unknown_fields)]
204pub struct AgentPage {
205 pub items: Vec<Agent>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub next_cursor: Option<String>,
208}
209
210pub fn validate_name(value: &str) -> Result<String, String> {
211 let value = value.trim();
212 if value.is_empty() || value.chars().count() > 128 {
213 return Err("agent name must contain 1..=128 characters".into());
214 }
215 if value.chars().any(char::is_control) {
216 return Err("agent name must not contain control characters".into());
217 }
218 Ok(value.to_string())
219}
220
221pub fn validate_description(value: &str) -> Result<String, String> {
222 let value = value.trim();
223 if value.chars().count() > 4096 {
224 return Err("agent description must contain at most 4096 characters".into());
225 }
226 if value.chars().any(|character| character == '\0') {
227 return Err("agent description must not contain NUL".into());
228 }
229 Ok(value.to_string())
230}
231
232pub fn validate_system_prompt(value: &str) -> Result<String, String> {
233 if value.chars().count() > MAX_SYSTEM_PROMPT_CHARS {
234 return Err(format!(
235 "agent systemPrompt must contain at most {MAX_SYSTEM_PROMPT_CHARS} characters"
236 ));
237 }
238 if value.chars().any(|character| character == '\0') {
239 return Err("agent systemPrompt must not contain NUL".into());
240 }
241 Ok(value.to_string())
242}
243
244pub fn validate_skills(skills: Vec<AgentSkillRef>) -> Result<Vec<AgentSkillRef>, String> {
245 if skills.len() > MAX_AGENT_SKILLS {
246 return Err(format!(
247 "agent skills must contain at most {MAX_AGENT_SKILLS} entries"
248 ));
249 }
250 let mut seen = BTreeSet::new();
251 let mut normalized = Vec::with_capacity(skills.len());
252 for skill in skills {
253 validate_skill_ref(&skill)?;
254 let key = format!("{}/{}", skill.group, skill.name);
255 if !seen.insert(key) {
256 return Err(format!(
257 "duplicate agent skill {}/{}",
258 skill.group, skill.name
259 ));
260 }
261 normalized.push(AgentSkillRef {
262 group: skill.group,
263 name: skill.name,
264 version: skill
265 .version
266 .map(|value| value.trim().to_string())
267 .filter(|value| !value.is_empty()),
268 });
269 }
270 Ok(normalized)
271}
272
273fn validate_skill_ref(skill: &AgentSkillRef) -> Result<(), String> {
274 validate_skill_part("skill group", &skill.group)?;
275 validate_skill_part("skill name", &skill.name)?;
276 if let Some(version) = skill.version.as_deref() {
277 validate_skill_part("skill version", version)?;
278 }
279 Ok(())
280}
281
282fn validate_skill_part(field: &str, value: &str) -> Result<(), String> {
283 if value.is_empty() || value.len() > 191 {
284 return Err(format!("{field} must contain 1..=191 bytes"));
285 }
286 if !value
287 .bytes()
288 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
289 {
290 return Err(format!(
291 "{field} may contain only ASCII letters, digits, '-', '_' and '.'"
292 ));
293 }
294 Ok(())
295}
296
297pub fn validate_identifier(field: &str, value: &str) -> Result<(), String> {
298 if value.is_empty() || value.len() > MAX_IDENTIFIER_BYTES {
299 return Err(format!(
300 "{field} must contain 1..={MAX_IDENTIFIER_BYTES} bytes"
301 ));
302 }
303 if !value.bytes().all(|byte| {
304 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'@')
305 }) {
306 return Err(format!("{field} contains unsupported characters"));
307 }
308 Ok(())
309}
310
311fn normalize_strings(field: &str, values: &mut Vec<String>) -> Result<(), String> {
312 if values.len() > MAX_RELATIONS_PER_KIND {
313 return Err(format!("{field} contains too many relations"));
314 }
315 for value in values.iter_mut() {
316 *value = value.trim().to_string();
317 validate_identifier(field, value)?;
318 }
319 values.sort();
320 values.dedup();
321 Ok(())
322}
323
324fn normalize_values(field: &str, values: &mut Vec<AgentId>) -> Result<(), String> {
325 if values.len() > MAX_RELATIONS_PER_KIND {
326 return Err(format!("{field} contains too many relations"));
327 }
328 let unique = values.iter().cloned().collect::<BTreeSet<_>>();
329 *values = unique.into_iter().collect();
330 Ok(())
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn relations_are_sets_not_singular_scope_fields() {
339 let relations = ResourceRelations {
340 agent_ids: vec![
341 AgentId::new("agt_b").unwrap(),
342 AgentId::new("agt_a").unwrap(),
343 ],
344 runtime_ids: vec!["runtime-1".into(), "runtime-1".into()],
345 user_ids: vec!["user-2".into(), "user-1".into()],
346 }
347 .normalize()
348 .unwrap();
349 assert_eq!(relations.agent_ids[0].as_str(), "agt_a");
350 assert_eq!(relations.runtime_ids, ["runtime-1"]);
351 assert_eq!(relations.user_ids, ["user-1", "user-2"]);
352 }
353
354 #[test]
355 fn agent_id_validation_cannot_be_bypassed_by_json() {
356 let error = serde_json::from_str::<AgentId>(r#""agent-legacy""#).unwrap_err();
357 assert!(error.to_string().contains("must start with 'agt_'"));
358 }
359
360 #[test]
361 fn create_request_defaults_missing_idempotency_key() {
362 let request: CreateAgentRequest = serde_json::from_value(serde_json::json!({
363 "name": "Weather",
364 "relations": { "runtimeIds": ["rti_1"] }
365 }))
366 .unwrap();
367 assert_eq!(request.name, "Weather");
368 assert_eq!(request.idempotency_key, "");
369 assert_eq!(request.relations.runtime_ids, ["rti_1"]);
370 assert!(request.system_prompt.is_empty());
371 assert!(request.skills.is_empty());
372 }
373
374 #[test]
375 fn skill_ref_parses_group_name_and_optional_version() {
376 let latest = AgentSkillRef::parse("@customer-service/customer-greeter").unwrap();
377 assert_eq!(latest.group, "customer-service");
378 assert_eq!(latest.name, "customer-greeter");
379 assert_eq!(latest.version, None);
380 let pinned = AgentSkillRef::parse("customer-service/customer-greeter@1.2.0").unwrap();
381 assert_eq!(pinned.version.as_deref(), Some("1.2.0"));
382 }
383}