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 AGENT_COMPOSITION_PATH: &str = "/internal/v1/agents/{agent_id}/composition";
pub const PUBLIC_AGENTS_PATH: &str = "/v1/agents";
pub const PUBLIC_AGENT_PATH: &str = "/v1/agents/{agent_id}";
pub const PUBLIC_AGENT_COMPOSITION_PATH: &str = "/v1/agents/{agent_id}/composition";
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;
const MAX_AGENT_PLUGINS: usize = 64;
const MAX_PLUGIN_SETTINGS_BYTES: usize = 16_384;
#[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)
}
}
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentPluginRef {
pub package: String,
pub version: String,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub settings: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AgentWorkspaceType {
AgentInfraSpace,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentWorkspaceRef {
#[serde(rename = "type")]
pub workspace_type: AgentWorkspaceType,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub computer_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentComposition {
pub agent_id: AgentId,
pub agent_revision: u64,
#[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, skip_serializing_if = "Vec::is_empty")]
pub plugins: Vec<AgentPluginRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<AgentWorkspaceRef>,
}
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>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub plugins: Vec<AgentPluginRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<AgentWorkspaceRef>,
pub status: AgentStatus,
pub revision: u64,
#[serde(default)]
pub relations: ResourceRelations,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Agent {
pub fn composition(&self) -> AgentComposition {
AgentComposition {
agent_id: self.id.clone(),
agent_revision: self.revision,
system_prompt: self.system_prompt.clone(),
skills: self.skills.clone(),
plugins: self.plugins.clone(),
workspace: self.workspace.clone(),
}
}
}
#[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, skip_serializing_if = "Vec::is_empty")]
pub plugins: Vec<AgentPluginRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<AgentWorkspaceRef>,
#[serde(default)]
pub relations: ResourceRelations,
#[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 plugins: Option<Vec<AgentPluginRef>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<Option<AgentWorkspaceRef>>,
#[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)
}
pub fn validate_plugins(plugins: Vec<AgentPluginRef>) -> Result<Vec<AgentPluginRef>, String> {
if plugins.len() > MAX_AGENT_PLUGINS {
return Err(format!(
"agent plugins must contain at most {MAX_AGENT_PLUGINS} entries"
));
}
let mut seen = BTreeSet::new();
let mut normalized = Vec::with_capacity(plugins.len());
for plugin in plugins {
let package = plugin.package.trim().to_string();
let version = plugin.version.trim().to_string();
validate_plugin_package(&package)?;
validate_plugin_version(&version)?;
if !plugin.settings.is_null() && !plugin.settings.is_object() {
return Err("plugin settings must be an object or null".into());
}
let settings_size = serde_json::to_vec(&plugin.settings)
.map_err(|error| format!("plugin settings are not serializable: {error}"))?
.len();
if settings_size > MAX_PLUGIN_SETTINGS_BYTES {
return Err(format!(
"plugin settings must contain at most {MAX_PLUGIN_SETTINGS_BYTES} bytes"
));
}
if !seen.insert(package.clone()) {
return Err(format!("duplicate agent plugin {package}"));
}
normalized.push(AgentPluginRef {
package,
version,
settings: plugin.settings,
});
}
Ok(normalized)
}
pub fn validate_workspace(
workspace: Option<AgentWorkspaceRef>,
) -> Result<Option<AgentWorkspaceRef>, String> {
let Some(mut workspace) = workspace else {
return Ok(None);
};
workspace.id = workspace.id.trim().to_string();
validate_identifier("workspace.id", &workspace.id)?;
workspace.computer_id = workspace
.computer_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if let Some(computer_id) = workspace.computer_id.as_deref() {
validate_identifier("workspace.computer_id", computer_id)?;
}
Ok(Some(workspace))
}
fn validate_plugin_package(value: &str) -> Result<(), String> {
if value.is_empty() || value.len() > 214 || value.bytes().any(|byte| byte.is_ascii_whitespace())
{
return Err(
"plugin package must be a non-empty npm package name of at most 214 bytes".into(),
);
}
if value.bytes().any(|byte| byte.is_ascii_control()) {
return Err("plugin package must not contain control characters".into());
}
Ok(())
}
fn validate_plugin_version(value: &str) -> Result<(), String> {
if value.is_empty() || value.len() > 191 || value.bytes().any(|byte| byte.is_ascii_whitespace())
{
return Err("plugin version must contain 1..=191 non-whitespace bytes".into());
}
if value.bytes().any(|byte| byte.is_ascii_control()) {
return Err("plugin version must not contain control characters".into());
}
Ok(())
}
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());
assert!(request.plugins.is_empty());
assert!(request.workspace.is_none());
}
#[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"));
}
#[test]
fn composition_is_a_revisioned_projection_without_deploy_concepts() {
let composition: AgentComposition = serde_json::from_value(serde_json::json!({
"agentId": "agt_weather",
"agentRevision": 7,
"systemPrompt": "Answer with evidence.",
"skills": [{ "group": "research", "name": "web", "version": "1.2.0" }],
"plugins": [{
"package": "@nio/dsh-observability",
"version": "2.0.0",
"settings": { "trace": true }
}],
"workspace": {
"type": "agent-infra-space",
"id": "spc_weather",
"computerId": "cmp_weather"
}
}))
.unwrap();
assert_eq!(composition.agent_id.as_str(), "agt_weather");
assert_eq!(composition.agent_revision, 7);
assert_eq!(composition.plugins[0].package, "@nio/dsh-observability");
assert_eq!(composition.workspace.as_ref().unwrap().id, "spc_weather");
let json = serde_json::to_value(composition).unwrap();
assert!(json.get("appId").is_none());
assert!(json.get("environment").is_none());
assert!(json.get("releaseId").is_none());
}
}