lean_ctx_protocol/hive/
profiles.rs1use serde::{Deserialize, Serialize};
20
21use super::{require_schema, validate_id, validate_text};
22use crate::{UtcTimestamp, ValidationError};
23
24pub const AGENT_PROFILE_LIST_SCHEMA: &str = "leanctx.hive.agent-profile-list/v1";
25pub const MAX_ROLES: usize = 8;
26pub const MAX_STRENGTHS: usize = 16;
27pub const MAX_PROFILES_PAGE: usize = 200;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub struct ReputationV1 {
32 pub score: u64,
34 pub contributions: u64,
36 pub weight_pct: u8,
39}
40
41impl ReputationV1 {
42 pub fn validate(&self) -> Result<(), ValidationError> {
43 if self.weight_pct == 0 || self.weight_pct > 100 {
44 return Err(ValidationError::new("weight_pct is 1-100"));
45 }
46 Ok(())
47 }
48}
49
50pub fn role_ok(role: &str) -> bool {
52 (1..=32).contains(&role.len())
53 && role
54 .bytes()
55 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
56}
57
58pub fn validate_self_report(
60 display_name: Option<&str>,
61 model: Option<&str>,
62 roles: &[String],
63) -> Result<(), ValidationError> {
64 if let Some(name) = display_name {
65 validate_text(name, 64, "display_name", false)?;
66 }
67 if let Some(model) = model {
68 validate_text(model, 80, "model", false)?;
69 }
70 if roles.len() > MAX_ROLES
71 || !roles.iter().all(|r| role_ok(r))
72 || roles.windows(2).any(|pair| pair[0] >= pair[1])
73 {
74 return Err(ValidationError::new(
75 "up to 8 roles of 1-32 [a-z0-9-], sorted without repeats",
76 ));
77 }
78 Ok(())
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct AgentProfileV1 {
84 pub profile_id: String,
85 pub agent: String,
87 pub instance_label: String,
88 pub owner: String,
90 pub display_name: String,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub model: Option<String>,
94 #[serde(default)]
96 pub roles: Vec<String>,
97 #[serde(default)]
99 pub strengths: Vec<String>,
100 pub reputation: ReputationV1,
102 pub mine: bool,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub last_seen: Option<UtcTimestamp>,
106}
107
108impl AgentProfileV1 {
109 pub fn validate(&self) -> Result<(), ValidationError> {
110 validate_id(&self.profile_id, "profile_id")?;
111 validate_id(&self.agent, "agent")?;
112 validate_text(&self.instance_label, 100, "instance_label", false)?;
113 validate_text(&self.owner, 100, "owner", false)?;
114 validate_self_report(Some(&self.display_name), self.model.as_deref(), &self.roles)?;
115 if self.strengths.len() > MAX_STRENGTHS {
116 return Err(ValidationError::new("at most 16 strengths"));
117 }
118 for strength in &self.strengths {
119 validate_text(strength, 64, "strength", false)?;
120 }
121 self.reputation.validate()
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct AgentProfileListV1 {
128 pub schema: String,
129 pub profiles: Vec<AgentProfileV1>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub next_after: Option<String>,
133}
134
135impl AgentProfileListV1 {
136 pub fn validate(&self) -> Result<(), ValidationError> {
137 require_schema(&self.schema, AGENT_PROFILE_LIST_SCHEMA)?;
138 if self.profiles.len() > MAX_PROFILES_PAGE {
139 return Err(ValidationError::new("profile page is too long"));
140 }
141 self.profiles.iter().try_for_each(AgentProfileV1::validate)
142 }
143}