Skip to main content

agent_protocols/
profile.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::BTreeMap;
4
5use crate::error::{Result, SdkError};
6use crate::identity::{verify_envelope, AgentId, Envelope, Event};
7
8pub const PROTOCOL: &str = "agent-profile/1.0";
9pub const PROFILE_UPDATE: &str = "profile.update";
10
11#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
12pub struct ServiceEndpoint {
13    #[serde(rename = "type")]
14    pub kind: String,
15    pub url: String,
16    #[serde(default, skip_serializing_if = "Vec::is_empty")]
17    pub protocols: Vec<String>,
18}
19
20#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(rename_all = "snake_case")]
22pub enum ProfileLinkRel {
23    Homepage,
24    Documentation,
25    SourceCode,
26    Social,
27    Browser,
28}
29
30#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
31pub struct ProfileLink {
32    pub name: String,
33    pub url: String,
34    pub rel: ProfileLinkRel,
35}
36
37#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
38pub struct ProfileUpdatePayload {
39    pub id: AgentId,
40    pub name: String,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub description: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub avatar_url: Option<String>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub provider: Option<String>,
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub capabilities: Vec<String>,
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub service_endpoints: Vec<ServiceEndpoint>,
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub links: Vec<ProfileLink>,
53    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
54    pub extra: BTreeMap<String, Value>,
55}
56
57impl ProfileUpdatePayload {
58    pub fn new(id: AgentId, name: impl Into<String>) -> Self {
59        Self {
60            id,
61            name: name.into(),
62            description: None,
63            avatar_url: None,
64            provider: None,
65            capabilities: Vec::new(),
66            service_endpoints: Vec::new(),
67            links: Vec::new(),
68            extra: BTreeMap::new(),
69        }
70    }
71}
72
73#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
74pub struct AgentProfile {
75    pub id: AgentId,
76    pub name: String,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub description: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub avatar_url: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub provider: Option<String>,
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub capabilities: Vec<String>,
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub service_endpoints: Vec<ServiceEndpoint>,
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub links: Vec<ProfileLink>,
89    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90    pub extra: BTreeMap<String, Value>,
91    pub updated_at: i64,
92    pub event_id: String,
93}
94
95#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
96pub struct ProfileBatchReadRequest {
97    pub ids: Vec<AgentId>,
98}
99
100#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
101pub struct ProfileBatchReadResponse {
102    pub result: Vec<AgentProfile>,
103}
104
105#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
106pub struct ProfileEventsResponse {
107    pub result: Vec<Envelope<ProfileUpdatePayload>>,
108}
109
110#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
111pub struct ProfileServiceDiscovery {
112    pub protocol: String,
113    pub service: String,
114    pub endpoints: ProfileServiceEndpoints,
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub features: Vec<String>,
117}
118
119#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
120pub struct ProfileServiceEndpoints {
121    pub profiles: String,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub profile_batch: Option<String>,
124}
125
126pub fn profile_update_event(
127    actor: AgentId,
128    created_at: i64,
129    nonce: u64,
130    payload: ProfileUpdatePayload,
131) -> Event<ProfileUpdatePayload> {
132    Event::new(PROTOCOL, PROFILE_UPDATE, actor, created_at, nonce, payload)
133}
134
135pub fn validate_profile_update(envelope: &Envelope<ProfileUpdatePayload>) -> Result<()> {
136    verify_envelope(envelope)?;
137    if envelope.event.protocol != PROTOCOL {
138        return Err(SdkError::InvalidEventProtocol {
139            expected: PROTOCOL.to_owned(),
140            actual: envelope.event.protocol.clone(),
141        });
142    }
143    if envelope.event.kind != PROFILE_UPDATE {
144        return Err(SdkError::InvalidEventType {
145            expected: PROFILE_UPDATE.to_owned(),
146            actual: envelope.event.kind.clone(),
147        });
148    }
149    if envelope.event.actor != envelope.event.payload.id {
150        return Err(SdkError::InvalidActor(
151            "profile update actor must match payload.id".to_owned(),
152        ));
153    }
154    Ok(())
155}
156
157pub fn materialize_profile(envelope: &Envelope<ProfileUpdatePayload>) -> Result<AgentProfile> {
158    validate_profile_update(envelope)?;
159    let payload = &envelope.event.payload;
160    Ok(AgentProfile {
161        id: payload.id.clone(),
162        name: payload.name.clone(),
163        description: payload.description.clone(),
164        avatar_url: payload.avatar_url.clone(),
165        provider: payload.provider.clone(),
166        capabilities: payload.capabilities.clone(),
167        service_endpoints: payload.service_endpoints.clone(),
168        links: payload.links.clone(),
169        extra: payload.extra.clone(),
170        updated_at: envelope.event.created_at,
171        event_id: envelope.hash.clone(),
172    })
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::identity::AgentSigner;
179
180    #[test]
181    fn materializes_valid_profile_update() {
182        let signer = AgentSigner::from_seed([11; 32]);
183        let mut payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
184        payload.capabilities.push("research".to_owned());
185        payload
186            .extra
187            .insert("domain".to_owned(), Value::String("research".to_owned()));
188        payload.links.push(ProfileLink {
189            name: "Homepage".to_owned(),
190            url: "https://example.com".to_owned(),
191            rel: ProfileLinkRel::Homepage,
192        });
193        let expected_extra = payload.extra.clone();
194        let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
195        let envelope = signer.sign_event(event).unwrap();
196
197        let profile = materialize_profile(&envelope).unwrap();
198
199        assert_eq!(profile.id, signer.agent_id());
200        assert_eq!(profile.name, "ResearchAgent-v3");
201        assert_eq!(profile.links.len(), 1);
202        assert_eq!(profile.links[0].rel, ProfileLinkRel::Homepage);
203        assert_eq!(profile.extra, expected_extra);
204        assert_eq!(profile.updated_at, 1_779_753_600_000);
205        assert_eq!(profile.event_id, envelope.hash);
206    }
207
208    #[test]
209    fn rejects_actor_payload_mismatch() {
210        let signer = AgentSigner::from_seed([12; 32]);
211        let other = AgentSigner::from_seed([13; 32]);
212        let payload = ProfileUpdatePayload::new(other.agent_id(), "Imposter");
213        let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
214        let envelope = signer.sign_event(event).unwrap();
215
216        assert!(matches!(
217            validate_profile_update(&envelope),
218            Err(SdkError::InvalidActor(_))
219        ));
220    }
221
222    #[test]
223    fn rejects_legacy_agent_id_payload_without_id() {
224        let signer = AgentSigner::from_seed([14; 32]);
225        let legacy_event = crate::identity::Event::new(
226            PROTOCOL,
227            PROFILE_UPDATE,
228            signer.agent_id(),
229            1_779_753_600_001,
230            1,
231            serde_json::json!({
232                "agent_id": signer.agent_id(),
233                "name": "LegacyAgent"
234            }),
235        );
236        let signed = signer.sign_event(legacy_event).unwrap();
237
238        assert!(serde_json::from_value::<Envelope<ProfileUpdatePayload>>(
239            serde_json::to_value(signed).unwrap()
240        )
241        .is_err());
242    }
243}