agent-registry-contract 0.2.0

Transport-neutral Agent Registry and resource relation contracts for Agent Infra
Documentation
//! Stable contracts for the Agent Registry and cross-domain resource relations.

use std::{collections::BTreeSet, fmt};

use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};

pub const SERVICE_NAME: &str = "agent-registry";
pub const AUDIENCE: &str = "agent-registry";
pub const DEFAULT_PORT: u16 = 5100;
pub const HEALTH_PATH: &str = "/health";
pub const AGENTS_PATH: &str = "/internal/v1/agents";
pub const AGENT_PATH: &str = "/internal/v1/agents/{agent_id}";
pub const PUBLIC_AGENTS_PATH: &str = "/v1/agents";
pub const PUBLIC_AGENT_PATH: &str = "/v1/agents/{agent_id}";

const MAX_IDENTIFIER_BYTES: usize = 255;
const MAX_RELATIONS_PER_KIND: usize = 256;
const MAX_SYSTEM_PROMPT_CHARS: usize = 16_383;
const MAX_AGENT_SKILLS: usize = 32;

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct AgentId(String);

impl AgentId {
    pub fn new(value: impl Into<String>) -> Result<Self, String> {
        let value = value.into();
        if !value.starts_with("agt_") {
            return Err("agent_id must start with 'agt_'".into());
        }
        validate_identifier("agent_id", &value)?;
        Ok(Self(value))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl<'de> Deserialize<'de> for AgentId {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let value = String::deserialize(deserializer)?;
        Self::new(value).map_err(D::Error::custom)
    }
}

impl fmt::Display for AgentId {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(formatter)
    }
}

/// Domain-neutral many-to-many associations for an Infra-owned resource.
///
/// The containing domain decides what each relation means. This type records
/// associations only; it deliberately does not imply ownership or permission.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ResourceRelations {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub agent_ids: Vec<AgentId>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub runtime_ids: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub user_ids: Vec<String>,
}

impl ResourceRelations {
    pub fn normalize(mut self) -> Result<Self, String> {
        normalize_values("agent_ids", &mut self.agent_ids)?;
        normalize_strings("runtime_ids", &mut self.runtime_ids)?;
        normalize_strings("user_ids", &mut self.user_ids)?;
        Ok(self)
    }

    pub fn contains_user(&self, user_id: &str) -> bool {
        self.user_ids.iter().any(|candidate| candidate == user_id)
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentStatus {
    #[default]
    Active,
    Archived,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentSkillRef {
    pub group: String,
    pub name: String,
    /// Omitted or `latest` means the Skills Hub default version selector.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
}

impl AgentSkillRef {
    pub fn parse(value: &str) -> Result<Self, String> {
        let value = value.trim();
        if value.is_empty() {
            return Err("skill ref must not be empty".into());
        }
        let (identity, version) = match value.rsplit_once('@') {
            Some((identity, version)) if identity.contains('/') => (identity, Some(version.trim())),
            _ => (value, None),
        };
        let identity = identity.strip_prefix('@').unwrap_or(identity);
        let Some((group, name)) = identity.split_once('/') else {
            return Err("skill ref must be group/name or @group/name[@version]".into());
        };
        let version = version
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(str::to_string);
        let skill = Self {
            group: group.trim().to_string(),
            name: name.trim().to_string(),
            version,
        };
        validate_skill_ref(&skill)?;
        Ok(skill)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Agent {
    pub id: AgentId,
    pub owner_user_id: String,
    pub name: String,
    pub description: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub system_prompt: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub skills: Vec<AgentSkillRef>,
    pub status: AgentStatus,
    pub revision: u64,
    #[serde(default)]
    pub relations: ResourceRelations,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateAgentRequest {
    pub name: String,
    #[serde(default)]
    pub description: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub system_prompt: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub skills: Vec<AgentSkillRef>,
    #[serde(default)]
    pub relations: ResourceRelations,
    /// Empty or omitted: Registry derives a stable key from the normalized name.
    #[serde(default)]
    pub idempotency_key: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateAgentRequest {
    pub expected_revision: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub system_prompt: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub skills: Option<Vec<AgentSkillRef>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<AgentStatus>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub relations: Option<ResourceRelations>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ListAgentsRequest {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<AgentStatus>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub related_agent_id: Option<AgentId>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub related_runtime_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub related_user_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cursor: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub limit: Option<u32>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentPage {
    pub items: Vec<Agent>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub next_cursor: Option<String>,
}

pub fn validate_name(value: &str) -> Result<String, String> {
    let value = value.trim();
    if value.is_empty() || value.chars().count() > 128 {
        return Err("agent name must contain 1..=128 characters".into());
    }
    if value.chars().any(char::is_control) {
        return Err("agent name must not contain control characters".into());
    }
    Ok(value.to_string())
}

pub fn validate_description(value: &str) -> Result<String, String> {
    let value = value.trim();
    if value.chars().count() > 4096 {
        return Err("agent description must contain at most 4096 characters".into());
    }
    if value.chars().any(|character| character == '\0') {
        return Err("agent description must not contain NUL".into());
    }
    Ok(value.to_string())
}

pub fn validate_system_prompt(value: &str) -> Result<String, String> {
    if value.chars().count() > MAX_SYSTEM_PROMPT_CHARS {
        return Err(format!(
            "agent systemPrompt must contain at most {MAX_SYSTEM_PROMPT_CHARS} characters"
        ));
    }
    if value.chars().any(|character| character == '\0') {
        return Err("agent systemPrompt must not contain NUL".into());
    }
    Ok(value.to_string())
}

pub fn validate_skills(skills: Vec<AgentSkillRef>) -> Result<Vec<AgentSkillRef>, String> {
    if skills.len() > MAX_AGENT_SKILLS {
        return Err(format!(
            "agent skills must contain at most {MAX_AGENT_SKILLS} entries"
        ));
    }
    let mut seen = BTreeSet::new();
    let mut normalized = Vec::with_capacity(skills.len());
    for skill in skills {
        validate_skill_ref(&skill)?;
        let key = format!("{}/{}", skill.group, skill.name);
        if !seen.insert(key) {
            return Err(format!(
                "duplicate agent skill {}/{}",
                skill.group, skill.name
            ));
        }
        normalized.push(AgentSkillRef {
            group: skill.group,
            name: skill.name,
            version: skill
                .version
                .map(|value| value.trim().to_string())
                .filter(|value| !value.is_empty()),
        });
    }
    Ok(normalized)
}

fn validate_skill_ref(skill: &AgentSkillRef) -> Result<(), String> {
    validate_skill_part("skill group", &skill.group)?;
    validate_skill_part("skill name", &skill.name)?;
    if let Some(version) = skill.version.as_deref() {
        validate_skill_part("skill version", version)?;
    }
    Ok(())
}

fn validate_skill_part(field: &str, value: &str) -> Result<(), String> {
    if value.is_empty() || value.len() > 191 {
        return Err(format!("{field} must contain 1..=191 bytes"));
    }
    if !value
        .bytes()
        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
    {
        return Err(format!(
            "{field} may contain only ASCII letters, digits, '-', '_' and '.'"
        ));
    }
    Ok(())
}

pub fn validate_identifier(field: &str, value: &str) -> Result<(), String> {
    if value.is_empty() || value.len() > MAX_IDENTIFIER_BYTES {
        return Err(format!(
            "{field} must contain 1..={MAX_IDENTIFIER_BYTES} bytes"
        ));
    }
    if !value.bytes().all(|byte| {
        byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'@')
    }) {
        return Err(format!("{field} contains unsupported characters"));
    }
    Ok(())
}

fn normalize_strings(field: &str, values: &mut Vec<String>) -> Result<(), String> {
    if values.len() > MAX_RELATIONS_PER_KIND {
        return Err(format!("{field} contains too many relations"));
    }
    for value in values.iter_mut() {
        *value = value.trim().to_string();
        validate_identifier(field, value)?;
    }
    values.sort();
    values.dedup();
    Ok(())
}

fn normalize_values(field: &str, values: &mut Vec<AgentId>) -> Result<(), String> {
    if values.len() > MAX_RELATIONS_PER_KIND {
        return Err(format!("{field} contains too many relations"));
    }
    let unique = values.iter().cloned().collect::<BTreeSet<_>>();
    *values = unique.into_iter().collect();
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn relations_are_sets_not_singular_scope_fields() {
        let relations = ResourceRelations {
            agent_ids: vec![
                AgentId::new("agt_b").unwrap(),
                AgentId::new("agt_a").unwrap(),
            ],
            runtime_ids: vec!["runtime-1".into(), "runtime-1".into()],
            user_ids: vec!["user-2".into(), "user-1".into()],
        }
        .normalize()
        .unwrap();
        assert_eq!(relations.agent_ids[0].as_str(), "agt_a");
        assert_eq!(relations.runtime_ids, ["runtime-1"]);
        assert_eq!(relations.user_ids, ["user-1", "user-2"]);
    }

    #[test]
    fn agent_id_validation_cannot_be_bypassed_by_json() {
        let error = serde_json::from_str::<AgentId>(r#""agent-legacy""#).unwrap_err();
        assert!(error.to_string().contains("must start with 'agt_'"));
    }

    #[test]
    fn create_request_defaults_missing_idempotency_key() {
        let request: CreateAgentRequest = serde_json::from_value(serde_json::json!({
            "name": "Weather",
            "relations": { "runtimeIds": ["rti_1"] }
        }))
        .unwrap();
        assert_eq!(request.name, "Weather");
        assert_eq!(request.idempotency_key, "");
        assert_eq!(request.relations.runtime_ids, ["rti_1"]);
        assert!(request.system_prompt.is_empty());
        assert!(request.skills.is_empty());
    }

    #[test]
    fn skill_ref_parses_group_name_and_optional_version() {
        let latest = AgentSkillRef::parse("@customer-service/customer-greeter").unwrap();
        assert_eq!(latest.group, "customer-service");
        assert_eq!(latest.name, "customer-greeter");
        assert_eq!(latest.version, None);
        let pinned = AgentSkillRef::parse("customer-service/customer-greeter@1.2.0").unwrap();
        assert_eq!(pinned.version.as_deref(), Some("1.2.0"));
    }
}