Skip to main content

agent_protocols/
profile.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::BTreeMap;
4
5use crate::delegation::PrincipalDescriptor;
6use crate::error::{Result, SdkError};
7use crate::identity::{verify_envelope, AgentId, Envelope, Event};
8
9pub const PROTOCOL: &str = "agent-profile/1.0";
10pub const PROFILE_UPDATE: &str = "profile.update";
11
12#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
13pub struct ServiceEndpoint {
14    #[serde(rename = "type")]
15    pub kind: String,
16    pub url: String,
17    #[serde(default, skip_serializing_if = "Vec::is_empty")]
18    pub protocols: Vec<String>,
19}
20
21#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "snake_case")]
23pub enum ProfileLinkRel {
24    Homepage,
25    Documentation,
26    SourceCode,
27    Social,
28    Browser,
29}
30
31#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
32pub struct ProfileLink {
33    pub name: String,
34    pub url: String,
35    pub rel: ProfileLinkRel,
36}
37
38/// Discovery hint only. It carries no service URLs: the publishing agent is the
39/// party whose claim is checked, so clients resolve the principal document at
40/// `principal.id` and query the service it names.
41#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
42pub struct ProfileDelegationHint {
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub id: Option<String>,
45    pub principal: PrincipalDescriptor,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub relationship: Option<String>,
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub scopes: Vec<String>,
50}
51
52#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
53pub struct ProfileUpdatePayload {
54    pub id: AgentId,
55    pub name: String,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub description: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub avatar_url: Option<String>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub provider: Option<String>,
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub capabilities: Vec<String>,
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub service_endpoints: Vec<ServiceEndpoint>,
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub links: Vec<ProfileLink>,
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub delegations: Vec<ProfileDelegationHint>,
70    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
71    pub extra: BTreeMap<String, Value>,
72}
73
74impl ProfileUpdatePayload {
75    pub fn new(id: AgentId, name: impl Into<String>) -> Self {
76        Self {
77            id,
78            name: name.into(),
79            description: None,
80            avatar_url: None,
81            provider: None,
82            capabilities: Vec::new(),
83            service_endpoints: Vec::new(),
84            links: Vec::new(),
85            delegations: Vec::new(),
86            extra: BTreeMap::new(),
87        }
88    }
89}
90
91#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
92pub struct AgentProfile {
93    pub id: AgentId,
94    pub name: String,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub description: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub avatar_url: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub provider: Option<String>,
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub capabilities: Vec<String>,
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub service_endpoints: Vec<ServiceEndpoint>,
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub links: Vec<ProfileLink>,
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub delegations: Vec<ProfileDelegationHint>,
109    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
110    pub extra: BTreeMap<String, Value>,
111    pub updated_at: i64,
112    pub event_id: String,
113}
114
115#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
116pub struct ProfileBatchReadRequest {
117    pub ids: Vec<AgentId>,
118}
119
120#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
121pub struct ProfileBatchReadResponse {
122    pub result: Vec<AgentProfile>,
123}
124
125#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
126pub struct ProfileEventsResponse {
127    pub result: Vec<Envelope<ProfileUpdatePayload>>,
128}
129
130#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
131pub struct ProfileServiceDiscovery {
132    pub protocol: String,
133    pub service: String,
134    pub endpoints: ProfileServiceEndpoints,
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub features: Vec<String>,
137}
138
139#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
140pub struct ProfileServiceEndpoints {
141    pub profiles: String,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub profile_batch: Option<String>,
144}
145
146pub fn profile_update_event(
147    actor: AgentId,
148    created_at: i64,
149    nonce: u64,
150    payload: ProfileUpdatePayload,
151) -> Event<ProfileUpdatePayload> {
152    Event::new(PROTOCOL, PROFILE_UPDATE, actor, created_at, nonce, payload)
153}
154
155pub fn validate_profile_update(envelope: &Envelope<ProfileUpdatePayload>) -> Result<()> {
156    verify_envelope(envelope)?;
157    if envelope.event.protocol != PROTOCOL {
158        return Err(SdkError::InvalidEventProtocol {
159            expected: PROTOCOL.to_owned(),
160            actual: envelope.event.protocol.clone(),
161        });
162    }
163    if envelope.event.kind != PROFILE_UPDATE {
164        return Err(SdkError::InvalidEventType {
165            expected: PROFILE_UPDATE.to_owned(),
166            actual: envelope.event.kind.clone(),
167        });
168    }
169    if envelope.event.actor != envelope.event.payload.id {
170        return Err(SdkError::InvalidActor(
171            "profile update actor must match payload.id".to_owned(),
172        ));
173    }
174    Ok(())
175}
176
177pub fn materialize_profile(envelope: &Envelope<ProfileUpdatePayload>) -> Result<AgentProfile> {
178    validate_profile_update(envelope)?;
179    let payload = &envelope.event.payload;
180    Ok(AgentProfile {
181        id: payload.id.clone(),
182        name: payload.name.clone(),
183        description: payload.description.clone(),
184        avatar_url: payload.avatar_url.clone(),
185        provider: payload.provider.clone(),
186        capabilities: payload.capabilities.clone(),
187        service_endpoints: payload.service_endpoints.clone(),
188        links: payload.links.clone(),
189        delegations: payload.delegations.clone(),
190        extra: payload.extra.clone(),
191        updated_at: envelope.event.created_at,
192        event_id: envelope.hash.clone(),
193    })
194}
195
196/// Selects the latest profile state from accepted update envelopes. Nonces
197/// are strictly monotonic per Agent ID, so the latest profile is defined as
198/// the accepted `profile.update` with the greatest `nonce` — deterministic and
199/// independently checkable from event history alone.
200pub fn latest_profile_update(
201    envelopes: &[Envelope<ProfileUpdatePayload>],
202) -> Option<&Envelope<ProfileUpdatePayload>> {
203    envelopes.iter().max_by_key(|envelope| envelope.event.nonce)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::delegation::PrincipalDescriptor;
210    use crate::identity::AgentSigner;
211
212    #[test]
213    fn materializes_valid_profile_update() {
214        let signer = AgentSigner::from_seed([11; 32]);
215        let mut payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
216        payload.capabilities.push("research".to_owned());
217        payload
218            .extra
219            .insert("domain".to_owned(), Value::String("research".to_owned()));
220        payload.links.push(ProfileLink {
221            name: "Homepage".to_owned(),
222            url: "https://example.com".to_owned(),
223            rel: ProfileLinkRel::Homepage,
224        });
225        payload.delegations.push(ProfileDelegationHint {
226            id: Some("del_1".to_owned()),
227            principal: PrincipalDescriptor {
228                id: "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned(),
229                kind: Some("person".to_owned()),
230                name: Some("Yan".to_owned()),
231            },
232            relationship: Some("primary_delegate".to_owned()),
233            scopes: vec!["inbox.screen".to_owned()],
234        });
235        let expected_extra = payload.extra.clone();
236        let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
237        let envelope = signer.sign_event(event).unwrap();
238
239        let profile = materialize_profile(&envelope).unwrap();
240
241        assert_eq!(profile.id, signer.agent_id());
242        assert_eq!(profile.name, "ResearchAgent-v3");
243        assert_eq!(profile.links.len(), 1);
244        assert_eq!(profile.links[0].rel, ProfileLinkRel::Homepage);
245        assert_eq!(profile.delegations.len(), 1);
246        assert_eq!(profile.extra, expected_extra);
247        assert_eq!(profile.updated_at, 1_779_753_600_000);
248        assert_eq!(profile.event_id, envelope.hash);
249    }
250
251    #[test]
252    fn materialized_profile_has_no_username_field() {
253        let signer = AgentSigner::from_seed([15; 32]);
254        let payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
255        let event = profile_update_event(signer.agent_id(), 1_779_753_600_002, 1, payload);
256        let envelope = signer.sign_event(event).unwrap();
257
258        let profile = materialize_profile(&envelope).unwrap();
259
260        assert_eq!(profile.id, signer.agent_id());
261        let value = serde_json::to_value(&profile).unwrap();
262        assert!(value.get("username").is_none());
263    }
264
265    #[test]
266    fn latest_profile_update_picks_greatest_nonce() {
267        let signer = AgentSigner::from_seed([16; 32]);
268        let envelopes: Vec<_> = [3_u64, 1, 2]
269            .into_iter()
270            .map(|nonce| {
271                let payload =
272                    ProfileUpdatePayload::new(signer.agent_id(), format!("Agent-v{nonce}"));
273                signer
274                    .sign_event(profile_update_event(
275                        signer.agent_id(),
276                        1_779_753_600_000 + nonce as i64,
277                        nonce,
278                        payload,
279                    ))
280                    .unwrap()
281            })
282            .collect();
283
284        assert!(latest_profile_update(&[]).is_none());
285        let latest = latest_profile_update(&envelopes).unwrap();
286        assert_eq!(latest.event.nonce, 3);
287        assert_eq!(materialize_profile(latest).unwrap().name, "Agent-v3");
288    }
289
290    #[test]
291    fn rejects_actor_payload_mismatch() {
292        let signer = AgentSigner::from_seed([12; 32]);
293        let other = AgentSigner::from_seed([13; 32]);
294        let payload = ProfileUpdatePayload::new(other.agent_id(), "Imposter");
295        let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
296        let envelope = signer.sign_event(event).unwrap();
297
298        assert!(matches!(
299            validate_profile_update(&envelope),
300            Err(SdkError::InvalidActor(_))
301        ));
302    }
303
304    #[test]
305    fn rejects_legacy_agent_id_payload_without_id() {
306        let signer = AgentSigner::from_seed([14; 32]);
307        let legacy_event = crate::identity::Event::new(
308            PROTOCOL,
309            PROFILE_UPDATE,
310            signer.agent_id(),
311            1_779_753_600_001,
312            1,
313            serde_json::json!({
314                "agent_id": signer.agent_id(),
315                "name": "LegacyAgent"
316            }),
317        );
318        let signed = signer.sign_event(legacy_event).unwrap();
319
320        assert!(serde_json::from_value::<Envelope<ProfileUpdatePayload>>(
321            serde_json::to_value(signed).unwrap()
322        )
323        .is_err());
324    }
325
326    #[test]
327    fn rejects_wrong_protocol_and_type() {
328        let signer = AgentSigner::from_seed([19; 32]);
329        let payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent");
330
331        let wrong_protocol = signer
332            .sign_event(crate::identity::Event::new(
333                "agent-discourse/1.0",
334                PROFILE_UPDATE,
335                signer.agent_id(),
336                1_779_753_600_000,
337                1,
338                payload.clone(),
339            ))
340            .unwrap();
341        assert!(matches!(
342            validate_profile_update(&wrong_protocol),
343            Err(SdkError::InvalidEventProtocol { .. })
344        ));
345
346        let wrong_type = signer
347            .sign_event(crate::identity::Event::new(
348                PROTOCOL,
349                "profile.delete",
350                signer.agent_id(),
351                1_779_753_600_000,
352                1,
353                payload,
354            ))
355            .unwrap();
356        assert!(matches!(
357            validate_profile_update(&wrong_type),
358            Err(SdkError::InvalidEventType { .. })
359        ));
360    }
361}