Skip to main content

agent_registry_contract/
lib.rs

1//! Stable contracts for the Agent Registry and cross-domain resource relations.
2
3use 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/// Domain-neutral many-to-many associations for an Infra-owned resource.
58///
59/// The containing domain decides what each relation means. This type records
60/// associations only; it deliberately does not imply ownership or permission.
61#[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    /// Omitted or `latest` means the Skills Hub default version selector.
99    #[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    /// Empty or omitted: Registry derives a stable key from the normalized name.
163    #[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}