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 username: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub description: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub avatar_url: Option<String>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub provider: Option<String>,
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub capabilities: Vec<String>,
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub service_endpoints: Vec<ServiceEndpoint>,
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub links: Vec<ProfileLink>,
91    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
92    pub extra: BTreeMap<String, Value>,
93    pub updated_at: i64,
94    pub event_id: String,
95}
96
97#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
98pub struct ProfileBatchReadRequest {
99    pub ids: Vec<AgentId>,
100}
101
102#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
103pub struct ProfileBatchReadResponse {
104    pub result: Vec<AgentProfile>,
105}
106
107#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
108pub struct ProfileEventsResponse {
109    pub result: Vec<Envelope<ProfileUpdatePayload>>,
110}
111
112#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
113pub struct ProfileServiceDiscovery {
114    pub protocol: String,
115    pub service: String,
116    pub endpoints: ProfileServiceEndpoints,
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub features: Vec<String>,
119}
120
121#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
122pub struct ProfileServiceEndpoints {
123    pub profiles: String,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub profile_batch: Option<String>,
126}
127
128pub fn profile_update_event(
129    actor: AgentId,
130    created_at: i64,
131    nonce: u64,
132    payload: ProfileUpdatePayload,
133) -> Event<ProfileUpdatePayload> {
134    Event::new(PROTOCOL, PROFILE_UPDATE, actor, created_at, nonce, payload)
135}
136
137pub fn validate_profile_update(envelope: &Envelope<ProfileUpdatePayload>) -> Result<()> {
138    verify_envelope(envelope)?;
139    if envelope.event.protocol != PROTOCOL {
140        return Err(SdkError::InvalidEventProtocol {
141            expected: PROTOCOL.to_owned(),
142            actual: envelope.event.protocol.clone(),
143        });
144    }
145    if envelope.event.kind != PROFILE_UPDATE {
146        return Err(SdkError::InvalidEventType {
147            expected: PROFILE_UPDATE.to_owned(),
148            actual: envelope.event.kind.clone(),
149        });
150    }
151    if envelope.event.actor != envelope.event.payload.id {
152        return Err(SdkError::InvalidActor(
153            "profile update actor must match payload.id".to_owned(),
154        ));
155    }
156    Ok(())
157}
158
159pub fn materialize_profile(envelope: &Envelope<ProfileUpdatePayload>) -> Result<AgentProfile> {
160    validate_profile_update(envelope)?;
161    let payload = &envelope.event.payload;
162    Ok(AgentProfile {
163        id: payload.id.clone(),
164        name: payload.name.clone(),
165        username: None,
166        description: payload.description.clone(),
167        avatar_url: payload.avatar_url.clone(),
168        provider: payload.provider.clone(),
169        capabilities: payload.capabilities.clone(),
170        service_endpoints: payload.service_endpoints.clone(),
171        links: payload.links.clone(),
172        extra: payload.extra.clone(),
173        updated_at: envelope.event.created_at,
174        event_id: envelope.hash.clone(),
175    })
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::identity::AgentSigner;
182
183    #[test]
184    fn materializes_valid_profile_update() {
185        let signer = AgentSigner::from_seed([11; 32]);
186        let mut payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
187        payload.capabilities.push("research".to_owned());
188        payload
189            .extra
190            .insert("domain".to_owned(), Value::String("research".to_owned()));
191        payload.links.push(ProfileLink {
192            name: "Homepage".to_owned(),
193            url: "https://example.com".to_owned(),
194            rel: ProfileLinkRel::Homepage,
195        });
196        let expected_extra = payload.extra.clone();
197        let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
198        let envelope = signer.sign_event(event).unwrap();
199
200        let profile = materialize_profile(&envelope).unwrap();
201
202        assert_eq!(profile.id, signer.agent_id());
203        assert_eq!(profile.name, "ResearchAgent-v3");
204        assert_eq!(profile.username, None);
205        assert_eq!(profile.links.len(), 1);
206        assert_eq!(profile.links[0].rel, ProfileLinkRel::Homepage);
207        assert_eq!(profile.extra, expected_extra);
208        assert_eq!(profile.updated_at, 1_779_753_600_000);
209        assert_eq!(profile.event_id, envelope.hash);
210    }
211
212    #[test]
213    fn materializes_profile_update_without_username() {
214        let signer = AgentSigner::from_seed([15; 32]);
215        let payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
216        let event = profile_update_event(signer.agent_id(), 1_779_753_600_002, 1, payload);
217        let envelope = signer.sign_event(event).unwrap();
218
219        let profile = materialize_profile(&envelope).unwrap();
220
221        assert_eq!(profile.id, signer.agent_id());
222        assert_eq!(profile.username, None);
223    }
224
225    #[test]
226    fn rejects_actor_payload_mismatch() {
227        let signer = AgentSigner::from_seed([12; 32]);
228        let other = AgentSigner::from_seed([13; 32]);
229        let payload = ProfileUpdatePayload::new(other.agent_id(), "Imposter");
230        let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
231        let envelope = signer.sign_event(event).unwrap();
232
233        assert!(matches!(
234            validate_profile_update(&envelope),
235            Err(SdkError::InvalidActor(_))
236        ));
237    }
238
239    #[test]
240    fn rejects_legacy_agent_id_payload_without_id() {
241        let signer = AgentSigner::from_seed([14; 32]);
242        let legacy_event = crate::identity::Event::new(
243            PROTOCOL,
244            PROFILE_UPDATE,
245            signer.agent_id(),
246            1_779_753_600_001,
247            1,
248            serde_json::json!({
249                "agent_id": signer.agent_id(),
250                "name": "LegacyAgent"
251            }),
252        );
253        let signed = signer.sign_event(legacy_event).unwrap();
254
255        assert!(serde_json::from_value::<Envelope<ProfileUpdatePayload>>(
256            serde_json::to_value(signed).unwrap()
257        )
258        .is_err());
259    }
260}