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 AGENT_COMPOSITION_PATH: &str = "/internal/v1/agents/{agent_id}/composition";
15pub const PUBLIC_AGENTS_PATH: &str = "/v1/agents";
16pub const PUBLIC_AGENT_PATH: &str = "/v1/agents/{agent_id}";
17pub const PUBLIC_AGENT_COMPOSITION_PATH: &str = "/v1/agents/{agent_id}/composition";
18
19const MAX_IDENTIFIER_BYTES: usize = 255;
20const MAX_RELATIONS_PER_KIND: usize = 256;
21const MAX_SYSTEM_PROMPT_CHARS: usize = 16_383;
22const MAX_AGENT_SKILLS: usize = 32;
23const MAX_AGENT_PLUGINS: usize = 64;
24const MAX_PLUGIN_SETTINGS_BYTES: usize = 16_384;
25
26#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
27#[serde(transparent)]
28pub struct AgentId(String);
29
30impl AgentId {
31 pub fn new(value: impl Into<String>) -> Result<Self, String> {
32 let value = value.into();
33 if !value.starts_with("agt_") {
34 return Err("agent_id must start with 'agt_'".into());
35 }
36 validate_identifier("agent_id", &value)?;
37 Ok(Self(value))
38 }
39
40 pub fn as_str(&self) -> &str {
41 &self.0
42 }
43}
44
45impl<'de> Deserialize<'de> for AgentId {
46 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
47 where
48 D: Deserializer<'de>,
49 {
50 let value = String::deserialize(deserializer)?;
51 Self::new(value).map_err(D::Error::custom)
52 }
53}
54
55impl fmt::Display for AgentId {
56 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57 self.0.fmt(formatter)
58 }
59}
60
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase", deny_unknown_fields)]
67pub struct ResourceRelations {
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub agent_ids: Vec<AgentId>,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub runtime_ids: Vec<String>,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub user_ids: Vec<String>,
74}
75
76impl ResourceRelations {
77 pub fn normalize(mut self) -> Result<Self, String> {
78 normalize_values("agent_ids", &mut self.agent_ids)?;
79 normalize_strings("runtime_ids", &mut self.runtime_ids)?;
80 normalize_strings("user_ids", &mut self.user_ids)?;
81 Ok(self)
82 }
83
84 pub fn contains_user(&self, user_id: &str) -> bool {
85 self.user_ids.iter().any(|candidate| candidate == user_id)
86 }
87}
88
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum AgentStatus {
92 #[default]
93 Active,
94 Archived,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase", deny_unknown_fields)]
99pub struct AgentSkillRef {
100 pub group: String,
101 pub name: String,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub version: Option<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase", deny_unknown_fields)]
113pub struct AgentPluginRef {
114 pub package: String,
115 pub version: String,
116 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
117 pub settings: serde_json::Value,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "kebab-case")]
123pub enum AgentWorkspaceType {
124 AgentInfraSpace,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase", deny_unknown_fields)]
129pub struct AgentWorkspaceRef {
130 #[serde(rename = "type")]
131 pub workspace_type: AgentWorkspaceType,
132 pub id: String,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub computer_id: Option<String>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase", deny_unknown_fields)]
140pub struct AgentComposition {
141 pub agent_id: AgentId,
142 pub agent_revision: u64,
143 #[serde(default, skip_serializing_if = "String::is_empty")]
144 pub system_prompt: String,
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub skills: Vec<AgentSkillRef>,
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub plugins: Vec<AgentPluginRef>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub workspace: Option<AgentWorkspaceRef>,
151}
152
153impl AgentSkillRef {
154 pub fn parse(value: &str) -> Result<Self, String> {
155 let value = value.trim();
156 if value.is_empty() {
157 return Err("skill ref must not be empty".into());
158 }
159 let (identity, version) = match value.rsplit_once('@') {
160 Some((identity, version)) if identity.contains('/') => (identity, Some(version.trim())),
161 _ => (value, None),
162 };
163 let identity = identity.strip_prefix('@').unwrap_or(identity);
164 let Some((group, name)) = identity.split_once('/') else {
165 return Err("skill ref must be group/name or @group/name[@version]".into());
166 };
167 let version = version
168 .map(str::trim)
169 .filter(|value| !value.is_empty())
170 .map(str::to_string);
171 let skill = Self {
172 group: group.trim().to_string(),
173 name: name.trim().to_string(),
174 version,
175 };
176 validate_skill_ref(&skill)?;
177 Ok(skill)
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase", deny_unknown_fields)]
183pub struct Agent {
184 pub id: AgentId,
185 pub owner_user_id: String,
186 pub name: String,
187 pub description: String,
188 #[serde(default, skip_serializing_if = "String::is_empty")]
189 pub system_prompt: String,
190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub skills: Vec<AgentSkillRef>,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 pub plugins: Vec<AgentPluginRef>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub workspace: Option<AgentWorkspaceRef>,
196 pub status: AgentStatus,
197 pub revision: u64,
198 #[serde(default)]
199 pub relations: ResourceRelations,
200 pub created_at: DateTime<Utc>,
201 pub updated_at: DateTime<Utc>,
202}
203
204impl Agent {
205 pub fn composition(&self) -> AgentComposition {
206 AgentComposition {
207 agent_id: self.id.clone(),
208 agent_revision: self.revision,
209 system_prompt: self.system_prompt.clone(),
210 skills: self.skills.clone(),
211 plugins: self.plugins.clone(),
212 workspace: self.workspace.clone(),
213 }
214 }
215}
216
217#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase", deny_unknown_fields)]
219pub struct CreateAgentRequest {
220 pub name: String,
221 #[serde(default)]
222 pub description: String,
223 #[serde(default, skip_serializing_if = "String::is_empty")]
224 pub system_prompt: String,
225 #[serde(default, skip_serializing_if = "Vec::is_empty")]
226 pub skills: Vec<AgentSkillRef>,
227 #[serde(default, skip_serializing_if = "Vec::is_empty")]
228 pub plugins: Vec<AgentPluginRef>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub workspace: Option<AgentWorkspaceRef>,
231 #[serde(default)]
232 pub relations: ResourceRelations,
233 #[serde(default)]
235 pub idempotency_key: String,
236}
237
238#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(rename_all = "camelCase", deny_unknown_fields)]
240pub struct UpdateAgentRequest {
241 pub expected_revision: u64,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub name: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub description: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub system_prompt: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub skills: Option<Vec<AgentSkillRef>>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub plugins: Option<Vec<AgentPluginRef>>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub workspace: Option<Option<AgentWorkspaceRef>>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub status: Option<AgentStatus>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub relations: Option<ResourceRelations>,
258}
259
260#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase", deny_unknown_fields)]
262pub struct ListAgentsRequest {
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub status: Option<AgentStatus>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub related_agent_id: Option<AgentId>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub related_runtime_id: Option<String>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub related_user_id: Option<String>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub cursor: Option<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub limit: Option<u32>,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase", deny_unknown_fields)]
279pub struct AgentPage {
280 pub items: Vec<Agent>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub next_cursor: Option<String>,
283}
284
285pub fn validate_name(value: &str) -> Result<String, String> {
286 let value = value.trim();
287 if value.is_empty() || value.chars().count() > 128 {
288 return Err("agent name must contain 1..=128 characters".into());
289 }
290 if value.chars().any(char::is_control) {
291 return Err("agent name must not contain control characters".into());
292 }
293 Ok(value.to_string())
294}
295
296pub fn validate_description(value: &str) -> Result<String, String> {
297 let value = value.trim();
298 if value.chars().count() > 4096 {
299 return Err("agent description must contain at most 4096 characters".into());
300 }
301 if value.chars().any(|character| character == '\0') {
302 return Err("agent description must not contain NUL".into());
303 }
304 Ok(value.to_string())
305}
306
307pub fn validate_system_prompt(value: &str) -> Result<String, String> {
308 if value.chars().count() > MAX_SYSTEM_PROMPT_CHARS {
309 return Err(format!(
310 "agent systemPrompt must contain at most {MAX_SYSTEM_PROMPT_CHARS} characters"
311 ));
312 }
313 if value.chars().any(|character| character == '\0') {
314 return Err("agent systemPrompt must not contain NUL".into());
315 }
316 Ok(value.to_string())
317}
318
319pub fn validate_skills(skills: Vec<AgentSkillRef>) -> Result<Vec<AgentSkillRef>, String> {
320 if skills.len() > MAX_AGENT_SKILLS {
321 return Err(format!(
322 "agent skills must contain at most {MAX_AGENT_SKILLS} entries"
323 ));
324 }
325 let mut seen = BTreeSet::new();
326 let mut normalized = Vec::with_capacity(skills.len());
327 for skill in skills {
328 validate_skill_ref(&skill)?;
329 let key = format!("{}/{}", skill.group, skill.name);
330 if !seen.insert(key) {
331 return Err(format!(
332 "duplicate agent skill {}/{}",
333 skill.group, skill.name
334 ));
335 }
336 normalized.push(AgentSkillRef {
337 group: skill.group,
338 name: skill.name,
339 version: skill
340 .version
341 .map(|value| value.trim().to_string())
342 .filter(|value| !value.is_empty()),
343 });
344 }
345 Ok(normalized)
346}
347
348pub fn validate_plugins(plugins: Vec<AgentPluginRef>) -> Result<Vec<AgentPluginRef>, String> {
349 if plugins.len() > MAX_AGENT_PLUGINS {
350 return Err(format!(
351 "agent plugins must contain at most {MAX_AGENT_PLUGINS} entries"
352 ));
353 }
354 let mut seen = BTreeSet::new();
355 let mut normalized = Vec::with_capacity(plugins.len());
356 for plugin in plugins {
357 let package = plugin.package.trim().to_string();
358 let version = plugin.version.trim().to_string();
359 validate_plugin_package(&package)?;
360 validate_plugin_version(&version)?;
361 if !plugin.settings.is_null() && !plugin.settings.is_object() {
362 return Err("plugin settings must be an object or null".into());
363 }
364 let settings_size = serde_json::to_vec(&plugin.settings)
365 .map_err(|error| format!("plugin settings are not serializable: {error}"))?
366 .len();
367 if settings_size > MAX_PLUGIN_SETTINGS_BYTES {
368 return Err(format!(
369 "plugin settings must contain at most {MAX_PLUGIN_SETTINGS_BYTES} bytes"
370 ));
371 }
372 if !seen.insert(package.clone()) {
373 return Err(format!("duplicate agent plugin {package}"));
374 }
375 normalized.push(AgentPluginRef {
376 package,
377 version,
378 settings: plugin.settings,
379 });
380 }
381 Ok(normalized)
382}
383
384pub fn validate_workspace(
385 workspace: Option<AgentWorkspaceRef>,
386) -> Result<Option<AgentWorkspaceRef>, String> {
387 let Some(mut workspace) = workspace else {
388 return Ok(None);
389 };
390 workspace.id = workspace.id.trim().to_string();
391 validate_identifier("workspace.id", &workspace.id)?;
392 workspace.computer_id = workspace
393 .computer_id
394 .map(|value| value.trim().to_string())
395 .filter(|value| !value.is_empty());
396 if let Some(computer_id) = workspace.computer_id.as_deref() {
397 validate_identifier("workspace.computer_id", computer_id)?;
398 }
399 Ok(Some(workspace))
400}
401
402fn validate_plugin_package(value: &str) -> Result<(), String> {
403 if value.is_empty() || value.len() > 214 || value.bytes().any(|byte| byte.is_ascii_whitespace())
404 {
405 return Err(
406 "plugin package must be a non-empty npm package name of at most 214 bytes".into(),
407 );
408 }
409 if value.bytes().any(|byte| byte.is_ascii_control()) {
410 return Err("plugin package must not contain control characters".into());
411 }
412 Ok(())
413}
414
415fn validate_plugin_version(value: &str) -> Result<(), String> {
416 if value.is_empty() || value.len() > 191 || value.bytes().any(|byte| byte.is_ascii_whitespace())
417 {
418 return Err("plugin version must contain 1..=191 non-whitespace bytes".into());
419 }
420 if value.bytes().any(|byte| byte.is_ascii_control()) {
421 return Err("plugin version must not contain control characters".into());
422 }
423 Ok(())
424}
425
426fn validate_skill_ref(skill: &AgentSkillRef) -> Result<(), String> {
427 validate_skill_part("skill group", &skill.group)?;
428 validate_skill_part("skill name", &skill.name)?;
429 if let Some(version) = skill.version.as_deref() {
430 validate_skill_part("skill version", version)?;
431 }
432 Ok(())
433}
434
435fn validate_skill_part(field: &str, value: &str) -> Result<(), String> {
436 if value.is_empty() || value.len() > 191 {
437 return Err(format!("{field} must contain 1..=191 bytes"));
438 }
439 if !value
440 .bytes()
441 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
442 {
443 return Err(format!(
444 "{field} may contain only ASCII letters, digits, '-', '_' and '.'"
445 ));
446 }
447 Ok(())
448}
449
450pub fn validate_identifier(field: &str, value: &str) -> Result<(), String> {
451 if value.is_empty() || value.len() > MAX_IDENTIFIER_BYTES {
452 return Err(format!(
453 "{field} must contain 1..={MAX_IDENTIFIER_BYTES} bytes"
454 ));
455 }
456 if !value.bytes().all(|byte| {
457 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'@')
458 }) {
459 return Err(format!("{field} contains unsupported characters"));
460 }
461 Ok(())
462}
463
464fn normalize_strings(field: &str, values: &mut Vec<String>) -> Result<(), String> {
465 if values.len() > MAX_RELATIONS_PER_KIND {
466 return Err(format!("{field} contains too many relations"));
467 }
468 for value in values.iter_mut() {
469 *value = value.trim().to_string();
470 validate_identifier(field, value)?;
471 }
472 values.sort();
473 values.dedup();
474 Ok(())
475}
476
477fn normalize_values(field: &str, values: &mut Vec<AgentId>) -> Result<(), String> {
478 if values.len() > MAX_RELATIONS_PER_KIND {
479 return Err(format!("{field} contains too many relations"));
480 }
481 let unique = values.iter().cloned().collect::<BTreeSet<_>>();
482 *values = unique.into_iter().collect();
483 Ok(())
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 #[test]
491 fn relations_are_sets_not_singular_scope_fields() {
492 let relations = ResourceRelations {
493 agent_ids: vec![
494 AgentId::new("agt_b").unwrap(),
495 AgentId::new("agt_a").unwrap(),
496 ],
497 runtime_ids: vec!["runtime-1".into(), "runtime-1".into()],
498 user_ids: vec!["user-2".into(), "user-1".into()],
499 }
500 .normalize()
501 .unwrap();
502 assert_eq!(relations.agent_ids[0].as_str(), "agt_a");
503 assert_eq!(relations.runtime_ids, ["runtime-1"]);
504 assert_eq!(relations.user_ids, ["user-1", "user-2"]);
505 }
506
507 #[test]
508 fn agent_id_validation_cannot_be_bypassed_by_json() {
509 let error = serde_json::from_str::<AgentId>(r#""agent-legacy""#).unwrap_err();
510 assert!(error.to_string().contains("must start with 'agt_'"));
511 }
512
513 #[test]
514 fn create_request_defaults_missing_idempotency_key() {
515 let request: CreateAgentRequest = serde_json::from_value(serde_json::json!({
516 "name": "Weather",
517 "relations": { "runtimeIds": ["rti_1"] }
518 }))
519 .unwrap();
520 assert_eq!(request.name, "Weather");
521 assert_eq!(request.idempotency_key, "");
522 assert_eq!(request.relations.runtime_ids, ["rti_1"]);
523 assert!(request.system_prompt.is_empty());
524 assert!(request.skills.is_empty());
525 assert!(request.plugins.is_empty());
526 assert!(request.workspace.is_none());
527 }
528
529 #[test]
530 fn skill_ref_parses_group_name_and_optional_version() {
531 let latest = AgentSkillRef::parse("@customer-service/customer-greeter").unwrap();
532 assert_eq!(latest.group, "customer-service");
533 assert_eq!(latest.name, "customer-greeter");
534 assert_eq!(latest.version, None);
535 let pinned = AgentSkillRef::parse("customer-service/customer-greeter@1.2.0").unwrap();
536 assert_eq!(pinned.version.as_deref(), Some("1.2.0"));
537 }
538
539 #[test]
540 fn composition_is_a_revisioned_projection_without_deploy_concepts() {
541 let composition: AgentComposition = serde_json::from_value(serde_json::json!({
542 "agentId": "agt_weather",
543 "agentRevision": 7,
544 "systemPrompt": "Answer with evidence.",
545 "skills": [{ "group": "research", "name": "web", "version": "1.2.0" }],
546 "plugins": [{
547 "package": "@nio/dsh-observability",
548 "version": "2.0.0",
549 "settings": { "trace": true }
550 }],
551 "workspace": {
552 "type": "agent-infra-space",
553 "id": "spc_weather",
554 "computerId": "cmp_weather"
555 }
556 }))
557 .unwrap();
558
559 assert_eq!(composition.agent_id.as_str(), "agt_weather");
560 assert_eq!(composition.agent_revision, 7);
561 assert_eq!(composition.plugins[0].package, "@nio/dsh-observability");
562 assert_eq!(composition.workspace.as_ref().unwrap().id, "spc_weather");
563 let json = serde_json::to_value(composition).unwrap();
564 assert!(json.get("appId").is_none());
565 assert!(json.get("environment").is_none());
566 assert!(json.get("releaseId").is_none());
567 }
568}