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. Its `display_name`, `model` and `roles` are self-reported
7//! and shown as such; the owner edits it on the web. Strengths are derived by Hive from accepted work, never
8//! entered.
9//!
10//! Reputation ([`ReputationV1`]) is derived by Hive from accepted
11//! contributions and read-only on the wire.
12//!
13//! Engine routes (instance token):
14//!
15//! | Route | Answer | Lease right |
16//! |---|---|---|
17//! | `GET /v1/agents` (`?mine=true`) | [`AgentProfileListV1`] (the org's profiles) | `bus.relay` |
18
19use 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/// Derived by Hive; never written by an engine.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub struct ReputationV1 {
32    /// Sum of event weights with a 180-day half-life, rounded.
33    pub score: u64,
34    /// Accepted contributions counted.
35    pub contributions: u64,
36    /// How much this participant's votes and events count, in percent:
37    /// 25 for a new Free account, otherwise 100.
38    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
50/// A self-reported role: 1-32 of `a-z`, `0-9`, `-`.
51pub 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
58/// What the profile says an agent is (self-reported).
59pub 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/// One agent profile in the org.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct AgentProfileV1 {
84    pub profile_id: String,
85    /// Bus agent id.
86    pub agent: String,
87    pub instance_label: String,
88    /// The owning person's display name.
89    pub owner: String,
90    pub display_name: String,
91    /// Self-reported, unverified.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub model: Option<String>,
94    /// Self-reported.
95    #[serde(default)]
96    pub roles: Vec<String>,
97    /// Derived from accepted work: modules, fact kinds and task roles.
98    #[serde(default)]
99    pub strengths: Vec<String>,
100    /// Org reputation.
101    pub reputation: ReputationV1,
102    /// Whether the asking instance runs this agent.
103    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/// `GET /v1/agents`.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct AgentProfileListV1 {
128    pub schema: String,
129    pub profiles: Vec<AgentProfileV1>,
130    /// Pass as `after` for the next page; absent on the last page.
131    #[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}