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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
39pub struct ProfileDelegationHint {
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub id: Option<String>,
42    pub principal: PrincipalDescriptor,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub relationship: Option<String>,
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub scopes: Vec<String>,
47    pub credential_url: String,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub status_url: Option<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 username: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub description: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub avatar_url: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub provider: Option<String>,
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub capabilities: Vec<String>,
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub service_endpoints: Vec<ServiceEndpoint>,
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub links: Vec<ProfileLink>,
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub delegations: Vec<ProfileDelegationHint>,
111    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
112    pub extra: BTreeMap<String, Value>,
113    pub updated_at: i64,
114    pub event_id: String,
115}
116
117#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
118pub struct ProfileBatchReadRequest {
119    pub ids: Vec<AgentId>,
120}
121
122#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
123pub struct ProfileBatchReadResponse {
124    pub result: Vec<AgentProfile>,
125}
126
127#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
128pub struct ProfileEventsResponse {
129    pub result: Vec<Envelope<ProfileUpdatePayload>>,
130}
131
132#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
133pub struct ProfileServiceDiscovery {
134    pub protocol: String,
135    pub service: String,
136    pub endpoints: ProfileServiceEndpoints,
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub features: Vec<String>,
139}
140
141#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
142pub struct ProfileServiceEndpoints {
143    pub profiles: String,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub profile_batch: Option<String>,
146}
147
148pub fn profile_update_event(
149    actor: AgentId,
150    created_at: i64,
151    nonce: u64,
152    payload: ProfileUpdatePayload,
153) -> Event<ProfileUpdatePayload> {
154    Event::new(PROTOCOL, PROFILE_UPDATE, actor, created_at, nonce, payload)
155}
156
157pub fn validate_profile_update(envelope: &Envelope<ProfileUpdatePayload>) -> Result<()> {
158    verify_envelope(envelope)?;
159    if envelope.event.protocol != PROTOCOL {
160        return Err(SdkError::InvalidEventProtocol {
161            expected: PROTOCOL.to_owned(),
162            actual: envelope.event.protocol.clone(),
163        });
164    }
165    if envelope.event.kind != PROFILE_UPDATE {
166        return Err(SdkError::InvalidEventType {
167            expected: PROFILE_UPDATE.to_owned(),
168            actual: envelope.event.kind.clone(),
169        });
170    }
171    if envelope.event.actor != envelope.event.payload.id {
172        return Err(SdkError::InvalidActor(
173            "profile update actor must match payload.id".to_owned(),
174        ));
175    }
176    Ok(())
177}
178
179pub fn materialize_profile(envelope: &Envelope<ProfileUpdatePayload>) -> Result<AgentProfile> {
180    validate_profile_update(envelope)?;
181    let payload = &envelope.event.payload;
182    Ok(AgentProfile {
183        id: payload.id.clone(),
184        name: payload.name.clone(),
185        username: None,
186        description: payload.description.clone(),
187        avatar_url: payload.avatar_url.clone(),
188        provider: payload.provider.clone(),
189        capabilities: payload.capabilities.clone(),
190        service_endpoints: payload.service_endpoints.clone(),
191        links: payload.links.clone(),
192        delegations: payload.delegations.clone(),
193        extra: payload.extra.clone(),
194        updated_at: envelope.event.created_at,
195        event_id: envelope.hash.clone(),
196    })
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::delegation::PrincipalDescriptor;
203    use crate::identity::AgentSigner;
204
205    #[test]
206    fn materializes_valid_profile_update() {
207        let signer = AgentSigner::from_seed([11; 32]);
208        let mut payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
209        payload.capabilities.push("research".to_owned());
210        payload
211            .extra
212            .insert("domain".to_owned(), Value::String("research".to_owned()));
213        payload.links.push(ProfileLink {
214            name: "Homepage".to_owned(),
215            url: "https://example.com".to_owned(),
216            rel: ProfileLinkRel::Homepage,
217        });
218        payload.delegations.push(ProfileDelegationHint {
219            id: None,
220            principal: PrincipalDescriptor {
221                id: "https://al.ink/yan".to_owned(),
222                kind: Some("person".to_owned()),
223                name: Some("Yan".to_owned()),
224            },
225            relationship: Some("primary_delegate".to_owned()),
226            scopes: vec!["inbox.screen".to_owned()],
227            credential_url: "https://al.ink/v1/delegations/del_1".to_owned(),
228            status_url: None,
229        });
230        let expected_extra = payload.extra.clone();
231        let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
232        let envelope = signer.sign_event(event).unwrap();
233
234        let profile = materialize_profile(&envelope).unwrap();
235
236        assert_eq!(profile.id, signer.agent_id());
237        assert_eq!(profile.name, "ResearchAgent-v3");
238        assert_eq!(profile.username, None);
239        assert_eq!(profile.links.len(), 1);
240        assert_eq!(profile.links[0].rel, ProfileLinkRel::Homepage);
241        assert_eq!(profile.delegations.len(), 1);
242        assert_eq!(profile.extra, expected_extra);
243        assert_eq!(profile.updated_at, 1_779_753_600_000);
244        assert_eq!(profile.event_id, envelope.hash);
245    }
246
247    #[test]
248    fn materializes_profile_update_without_username() {
249        let signer = AgentSigner::from_seed([15; 32]);
250        let payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
251        let event = profile_update_event(signer.agent_id(), 1_779_753_600_002, 1, payload);
252        let envelope = signer.sign_event(event).unwrap();
253
254        let profile = materialize_profile(&envelope).unwrap();
255
256        assert_eq!(profile.id, signer.agent_id());
257        assert_eq!(profile.username, None);
258    }
259
260    #[test]
261    fn rejects_actor_payload_mismatch() {
262        let signer = AgentSigner::from_seed([12; 32]);
263        let other = AgentSigner::from_seed([13; 32]);
264        let payload = ProfileUpdatePayload::new(other.agent_id(), "Imposter");
265        let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
266        let envelope = signer.sign_event(event).unwrap();
267
268        assert!(matches!(
269            validate_profile_update(&envelope),
270            Err(SdkError::InvalidActor(_))
271        ));
272    }
273
274    #[test]
275    fn rejects_legacy_agent_id_payload_without_id() {
276        let signer = AgentSigner::from_seed([14; 32]);
277        let legacy_event = crate::identity::Event::new(
278            PROTOCOL,
279            PROFILE_UPDATE,
280            signer.agent_id(),
281            1_779_753_600_001,
282            1,
283            serde_json::json!({
284                "agent_id": signer.agent_id(),
285                "name": "LegacyAgent"
286            }),
287        );
288        let signed = signer.sign_event(legacy_event).unwrap();
289
290        assert!(serde_json::from_value::<Envelope<ProfileUpdatePayload>>(
291            serde_json::to_value(signed).unwrap()
292        )
293        .is_err());
294    }
295
296    #[test]
297    fn rejects_wrong_protocol_and_type() {
298        let signer = AgentSigner::from_seed([19; 32]);
299        let payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent");
300
301        let wrong_protocol = signer
302            .sign_event(crate::identity::Event::new(
303                "agent-discourse/1.0",
304                PROFILE_UPDATE,
305                signer.agent_id(),
306                1_779_753_600_000,
307                1,
308                payload.clone(),
309            ))
310            .unwrap();
311        assert!(matches!(
312            validate_profile_update(&wrong_protocol),
313            Err(SdkError::InvalidEventProtocol { .. })
314        ));
315
316        let wrong_type = signer
317            .sign_event(crate::identity::Event::new(
318                PROTOCOL,
319                "profile.delete",
320                signer.agent_id(),
321                1_779_753_600_000,
322                1,
323                payload,
324            ))
325            .unwrap();
326        assert!(matches!(
327            validate_profile_update(&wrong_type),
328            Err(SdkError::InvalidEventType { .. })
329        ));
330    }
331}