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    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub id: Option<AgentId>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub agent_id: Option<AgentId>,
43    pub name: String,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub description: Option<String>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub avatar_url: Option<String>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub provider: Option<String>,
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub capabilities: Vec<String>,
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub service_endpoints: Vec<ServiceEndpoint>,
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub links: Vec<ProfileLink>,
56    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
57    pub extra: BTreeMap<String, Value>,
58}
59
60impl ProfileUpdatePayload {
61    pub fn new(id: AgentId, name: impl Into<String>) -> Self {
62        Self {
63            id: Some(id),
64            agent_id: None,
65            name: name.into(),
66            description: None,
67            avatar_url: None,
68            provider: None,
69            capabilities: Vec::new(),
70            service_endpoints: Vec::new(),
71            links: Vec::new(),
72            extra: BTreeMap::new(),
73        }
74    }
75
76    pub fn effective_id(&self) -> Option<&AgentId> {
77        self.id.as_ref().or(self.agent_id.as_ref())
78    }
79}
80
81#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
82pub struct AgentProfile {
83    pub id: AgentId,
84    pub name: String,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub description: Option<String>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub avatar_url: Option<String>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub provider: Option<String>,
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub capabilities: Vec<String>,
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub service_endpoints: Vec<ServiceEndpoint>,
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub links: Vec<ProfileLink>,
97    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
98    pub extra: BTreeMap<String, Value>,
99    pub updated_at: i64,
100    pub event_id: String,
101}
102
103#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
104pub struct ProfileBatchReadRequest {
105    pub ids: Vec<AgentId>,
106}
107
108#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
109pub struct ProfileBatchReadResponse {
110    pub result: Vec<AgentProfile>,
111}
112
113#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
114pub struct ProfileEventsResponse {
115    pub result: Vec<Envelope<ProfileUpdatePayload>>,
116}
117
118#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
119pub struct ProfileServiceDiscovery {
120    pub protocol: String,
121    pub service: String,
122    pub endpoints: ProfileServiceEndpoints,
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub features: Vec<String>,
125}
126
127#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
128pub struct ProfileServiceEndpoints {
129    pub profiles: String,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub profile_batch: Option<String>,
132}
133
134pub fn profile_update_event(
135    actor: AgentId,
136    created_at: i64,
137    nonce: u64,
138    payload: ProfileUpdatePayload,
139) -> Event<ProfileUpdatePayload> {
140    Event::new(PROTOCOL, PROFILE_UPDATE, actor, created_at, nonce, payload)
141}
142
143pub fn validate_profile_update(envelope: &Envelope<ProfileUpdatePayload>) -> Result<()> {
144    verify_envelope(envelope)?;
145    if envelope.event.protocol != PROTOCOL {
146        return Err(SdkError::InvalidEventProtocol {
147            expected: PROTOCOL.to_owned(),
148            actual: envelope.event.protocol.clone(),
149        });
150    }
151    if envelope.event.kind != PROFILE_UPDATE {
152        return Err(SdkError::InvalidEventType {
153            expected: PROFILE_UPDATE.to_owned(),
154            actual: envelope.event.kind.clone(),
155        });
156    }
157    let payload_id = envelope.event.payload.effective_id().ok_or_else(|| {
158        SdkError::InvalidActor("profile update actor must match payload.id".to_owned())
159    })?;
160    if let (Some(id), Some(agent_id)) = (
161        envelope.event.payload.id.as_ref(),
162        envelope.event.payload.agent_id.as_ref(),
163    ) {
164        if id != agent_id {
165            return Err(SdkError::InvalidActor(
166                "profile update payload.id and payload.agent_id must be equal".to_owned(),
167            ));
168        }
169    }
170    if envelope.event.actor != *payload_id {
171        return Err(SdkError::InvalidActor(
172            "profile update actor must match payload.id".to_owned(),
173        ));
174    }
175    Ok(())
176}
177
178pub fn materialize_profile(envelope: &Envelope<ProfileUpdatePayload>) -> Result<AgentProfile> {
179    validate_profile_update(envelope)?;
180    let payload = &envelope.event.payload;
181    let payload_id = payload.effective_id().ok_or_else(|| {
182        SdkError::InvalidActor("profile update actor must match payload.id".to_owned())
183    })?;
184    Ok(AgentProfile {
185        id: payload_id.clone(),
186        name: payload.name.clone(),
187        description: payload.description.clone(),
188        avatar_url: payload.avatar_url.clone(),
189        provider: payload.provider.clone(),
190        capabilities: payload.capabilities.clone(),
191        service_endpoints: payload.service_endpoints.clone(),
192        links: payload.links.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::identity::AgentSigner;
203
204    #[test]
205    fn materializes_valid_profile_update() {
206        let signer = AgentSigner::from_seed([11; 32]);
207        let mut payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
208        payload.capabilities.push("research".to_owned());
209        payload
210            .extra
211            .insert("domain".to_owned(), Value::String("research".to_owned()));
212        payload.links.push(ProfileLink {
213            name: "Homepage".to_owned(),
214            url: "https://example.com".to_owned(),
215            rel: ProfileLinkRel::Homepage,
216        });
217        let expected_extra = payload.extra.clone();
218        let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
219        let envelope = signer.sign_event(event).unwrap();
220
221        let profile = materialize_profile(&envelope).unwrap();
222
223        assert_eq!(profile.id, signer.agent_id());
224        assert_eq!(profile.name, "ResearchAgent-v3");
225        assert_eq!(profile.links.len(), 1);
226        assert_eq!(profile.links[0].rel, ProfileLinkRel::Homepage);
227        assert_eq!(profile.extra, expected_extra);
228        assert_eq!(profile.updated_at, 1_779_753_600_000);
229        assert_eq!(profile.event_id, envelope.hash);
230    }
231
232    #[test]
233    fn rejects_actor_payload_mismatch() {
234        let signer = AgentSigner::from_seed([12; 32]);
235        let other = AgentSigner::from_seed([13; 32]);
236        let payload = ProfileUpdatePayload::new(other.agent_id(), "Imposter");
237        let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
238        let envelope = signer.sign_event(event).unwrap();
239
240        assert!(matches!(
241            validate_profile_update(&envelope),
242            Err(SdkError::InvalidActor(_))
243        ));
244    }
245
246    #[test]
247    fn materializes_legacy_agent_id_payload() {
248        let signer = AgentSigner::from_seed([14; 32]);
249        let legacy_event = crate::identity::Event::new(
250            PROTOCOL,
251            PROFILE_UPDATE,
252            signer.agent_id(),
253            1_779_753_600_001,
254            1,
255            serde_json::json!({
256                "agent_id": signer.agent_id(),
257                "name": "LegacyAgent"
258            }),
259        );
260        let signed = signer.sign_event(legacy_event).unwrap();
261        let envelope: Envelope<ProfileUpdatePayload> =
262            serde_json::from_value(serde_json::to_value(signed).unwrap()).unwrap();
263
264        let profile = materialize_profile(&envelope).unwrap();
265
266        assert_eq!(profile.id, signer.agent_id());
267        assert_eq!(profile.name, "LegacyAgent");
268    }
269}