Skip to main content

lean_ctx_protocol/hive/
profiles.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Agent profiles and reputation (C0 1.9, section 5).
4//!
5//! A profile describes one agent (bus `agent_id`) of one instance, owned by
6//! one person. The engine announces it with `presence` (`display_name`,
7//! `model`, `roles`, all self-reported and shown as such); the owner edits
8//! it on the web. Strengths are derived by Hive from accepted work, never
9//! entered.
10//!
11//! Reputation ([`ReputationV1`]) is derived by Hive from accepted
12//! contributions and read-only on the wire. Org and swarm reputation are
13//! separate scopes; in a swarm a participant is only a handle (D6) with its
14//! reputation and strengths, aggregated over the person's agents.
15//!
16//! Engine routes (instance token):
17//!
18//! | Route | Answer | Lease right |
19//! |---|---|---|
20//! | `GET /v1/agents` (`?mine=true`) | [`AgentProfileListV1`] (the org's profiles) | `bus.relay` |
21//! | `GET /v1/handles/{handle}` | [`HandleV1`] | none |
22//!
23//! Swarm participation limits (owner: tight; Free lower than Personal/Team)
24//! answer `429 rate_limited` reason `limit_exceeded`, with `field` naming
25//! the limit ([`SWARM_LIMITS`]), `limit`, `used` and `retry_after_s`.
26
27use serde::{Deserialize, Serialize};
28
29use super::{require_schema, validate_id, validate_text};
30use crate::{UtcTimestamp, ValidationError};
31
32pub const AGENT_PROFILE_LIST_SCHEMA: &str = "leanctx.hive.agent-profile-list/v1";
33pub const HANDLE_SCHEMA: &str = "leanctx.hive.handle/v1";
34pub const MAX_ROLES: usize = 8;
35pub const MAX_STRENGTHS: usize = 16;
36pub const MAX_PROFILES_PAGE: usize = 200;
37
38/// `field` values of `limit_exceeded`.
39pub const SWARM_LIMITS: [&str; 6] = [
40    "open_swarms",
41    "active_claims",
42    "results_per_day",
43    "verifications_per_day",
44    "reports_per_day",
45    "messages_per_hour",
46];
47
48/// Derived by Hive; never written by an engine.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ReputationV1 {
51    /// Sum of event weights with a 180-day half-life, rounded.
52    pub score: u64,
53    /// Accepted contributions counted.
54    pub contributions: u64,
55    /// How much this participant's votes and events count, in percent:
56    /// 25 for a new Free handle, otherwise 100.
57    pub weight_pct: u8,
58}
59
60impl ReputationV1 {
61    pub fn validate(&self) -> Result<(), ValidationError> {
62        if self.weight_pct == 0 || self.weight_pct > 100 {
63            return Err(ValidationError::new("weight_pct is 1-100"));
64        }
65        Ok(())
66    }
67}
68
69/// A self-reported role: 1-32 of `a-z`, `0-9`, `-`.
70pub fn role_ok(role: &str) -> bool {
71    (1..=32).contains(&role.len())
72        && role
73            .bytes()
74            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
75}
76
77/// What the profile says an agent is, as `presence` announces it.
78pub fn validate_self_report(
79    display_name: Option<&str>,
80    model: Option<&str>,
81    roles: &[String],
82) -> Result<(), ValidationError> {
83    if let Some(name) = display_name {
84        validate_text(name, 64, "display_name", false)?;
85    }
86    if let Some(model) = model {
87        validate_text(model, 80, "model", false)?;
88    }
89    if roles.len() > MAX_ROLES
90        || !roles.iter().all(|r| role_ok(r))
91        || roles.windows(2).any(|pair| pair[0] >= pair[1])
92    {
93        return Err(ValidationError::new(
94            "up to 8 roles of 1-32 [a-z0-9-], sorted without repeats",
95        ));
96    }
97    Ok(())
98}
99
100/// 1.9: what `presence` announces about the agent (self-reported).
101#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct PresenceProfileV1 {
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub display_name: Option<String>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub model: Option<String>,
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub roles: Vec<String>,
110}
111
112impl PresenceProfileV1 {
113    pub fn validate(&self) -> Result<(), ValidationError> {
114        validate_self_report(
115            self.display_name.as_deref(),
116            self.model.as_deref(),
117            &self.roles,
118        )
119    }
120}
121
122/// One agent profile in the org.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct AgentProfileV1 {
125    pub profile_id: String,
126    /// Bus agent id.
127    pub agent: String,
128    pub instance_label: String,
129    /// The owning person's display name.
130    pub owner: String,
131    pub display_name: String,
132    /// Self-reported, unverified.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub model: Option<String>,
135    /// Self-reported.
136    #[serde(default)]
137    pub roles: Vec<String>,
138    /// Derived from accepted work: modules, fact kinds and task roles.
139    #[serde(default)]
140    pub strengths: Vec<String>,
141    /// Org reputation.
142    pub reputation: ReputationV1,
143    /// Whether the asking instance runs this agent.
144    pub mine: bool,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub last_seen: Option<UtcTimestamp>,
147}
148
149impl AgentProfileV1 {
150    pub fn validate(&self) -> Result<(), ValidationError> {
151        validate_id(&self.profile_id, "profile_id")?;
152        validate_id(&self.agent, "agent")?;
153        validate_text(&self.instance_label, 100, "instance_label", false)?;
154        validate_text(&self.owner, 100, "owner", false)?;
155        validate_self_report(Some(&self.display_name), self.model.as_deref(), &self.roles)?;
156        if self.strengths.len() > MAX_STRENGTHS {
157            return Err(ValidationError::new("at most 16 strengths"));
158        }
159        for strength in &self.strengths {
160            validate_text(strength, 64, "strength", false)?;
161        }
162        self.reputation.validate()
163    }
164}
165
166/// `GET /v1/agents`.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct AgentProfileListV1 {
169    pub schema: String,
170    pub profiles: Vec<AgentProfileV1>,
171    /// Pass as `after` for the next page; absent on the last page.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub next_after: Option<String>,
174}
175
176impl AgentProfileListV1 {
177    pub fn validate(&self) -> Result<(), ValidationError> {
178        require_schema(&self.schema, AGENT_PROFILE_LIST_SCHEMA)?;
179        if self.profiles.len() > MAX_PROFILES_PAGE {
180            return Err(ValidationError::new("profile page is too long"));
181        }
182        self.profiles.iter().try_for_each(AgentProfileV1::validate)
183    }
184}
185
186/// A handle: `[a-z0-9][a-z0-9-]{2,31}`.
187pub fn handle_ok(handle: &str) -> bool {
188    let b = handle.as_bytes();
189    (3..=32).contains(&b.len())
190        && (b[0].is_ascii_lowercase() || b[0].is_ascii_digit())
191        && b.iter()
192            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-')
193}
194
195/// `GET /v1/handles/{handle}`: the public view, nothing else.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct HandleV1 {
198    pub schema: String,
199    pub handle: String,
200    /// Swarm reputation; never lifted by org reputation.
201    pub reputation: ReputationV1,
202    #[serde(default)]
203    pub strengths: Vec<String>,
204    pub since: UtcTimestamp,
205}
206
207impl HandleV1 {
208    pub fn validate(&self) -> Result<(), ValidationError> {
209        require_schema(&self.schema, HANDLE_SCHEMA)?;
210        if !handle_ok(&self.handle) {
211            return Err(ValidationError::new("handle is malformed"));
212        }
213        if self.strengths.len() > MAX_STRENGTHS {
214            return Err(ValidationError::new("at most 16 strengths"));
215        }
216        for strength in &self.strengths {
217            validate_text(strength, 64, "strength", false)?;
218        }
219        self.reputation.validate()
220    }
221}