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 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/// Domain-neutral many-to-many associations for an Infra-owned resource.
62///
63/// The containing domain decides what each relation means. This type records
64/// associations only; it deliberately does not imply ownership or permission.
65#[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    /// Omitted or `latest` means the Skills Hub default version selector.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub version: Option<String>,
105}
106
107/// A non-secret DSH plugin selection owned by Agent Registry.
108///
109/// Credentials are deliberately excluded and must be resolved from Runtime
110/// Identity when Harness Control prepares an execution.
111#[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/// Workspace selected for an Agent profile and resolved by Harness Control.
121#[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/// Immutable, revision-addressed desired composition consumed by Harness Control.
138#[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    /// Empty or omitted: Registry derives a stable key from the normalized name.
234    #[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}