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 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
196pub 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: None,
227 principal: PrincipalDescriptor {
228 id: "https://al.ink/yan".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 credential_url: "https://al.ink/v1/delegations/del_1".to_owned(),
235 status_url: None,
236 });
237 let expected_extra = payload.extra.clone();
238 let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
239 let envelope = signer.sign_event(event).unwrap();
240
241 let profile = materialize_profile(&envelope).unwrap();
242
243 assert_eq!(profile.id, signer.agent_id());
244 assert_eq!(profile.name, "ResearchAgent-v3");
245 assert_eq!(profile.links.len(), 1);
246 assert_eq!(profile.links[0].rel, ProfileLinkRel::Homepage);
247 assert_eq!(profile.delegations.len(), 1);
248 assert_eq!(profile.extra, expected_extra);
249 assert_eq!(profile.updated_at, 1_779_753_600_000);
250 assert_eq!(profile.event_id, envelope.hash);
251 }
252
253 #[test]
254 fn materialized_profile_has_no_username_field() {
255 let signer = AgentSigner::from_seed([15; 32]);
256 let payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
257 let event = profile_update_event(signer.agent_id(), 1_779_753_600_002, 1, payload);
258 let envelope = signer.sign_event(event).unwrap();
259
260 let profile = materialize_profile(&envelope).unwrap();
261
262 assert_eq!(profile.id, signer.agent_id());
263 let value = serde_json::to_value(&profile).unwrap();
264 assert!(value.get("username").is_none());
265 }
266
267 #[test]
268 fn latest_profile_update_picks_greatest_nonce() {
269 let signer = AgentSigner::from_seed([16; 32]);
270 let envelopes: Vec<_> = [3_u64, 1, 2]
271 .into_iter()
272 .map(|nonce| {
273 let payload =
274 ProfileUpdatePayload::new(signer.agent_id(), format!("Agent-v{nonce}"));
275 signer
276 .sign_event(profile_update_event(
277 signer.agent_id(),
278 1_779_753_600_000 + nonce as i64,
279 nonce,
280 payload,
281 ))
282 .unwrap()
283 })
284 .collect();
285
286 assert!(latest_profile_update(&[]).is_none());
287 let latest = latest_profile_update(&envelopes).unwrap();
288 assert_eq!(latest.event.nonce, 3);
289 assert_eq!(materialize_profile(latest).unwrap().name, "Agent-v3");
290 }
291
292 #[test]
293 fn rejects_actor_payload_mismatch() {
294 let signer = AgentSigner::from_seed([12; 32]);
295 let other = AgentSigner::from_seed([13; 32]);
296 let payload = ProfileUpdatePayload::new(other.agent_id(), "Imposter");
297 let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
298 let envelope = signer.sign_event(event).unwrap();
299
300 assert!(matches!(
301 validate_profile_update(&envelope),
302 Err(SdkError::InvalidActor(_))
303 ));
304 }
305
306 #[test]
307 fn rejects_legacy_agent_id_payload_without_id() {
308 let signer = AgentSigner::from_seed([14; 32]);
309 let legacy_event = crate::identity::Event::new(
310 PROTOCOL,
311 PROFILE_UPDATE,
312 signer.agent_id(),
313 1_779_753_600_001,
314 1,
315 serde_json::json!({
316 "agent_id": signer.agent_id(),
317 "name": "LegacyAgent"
318 }),
319 );
320 let signed = signer.sign_event(legacy_event).unwrap();
321
322 assert!(serde_json::from_value::<Envelope<ProfileUpdatePayload>>(
323 serde_json::to_value(signed).unwrap()
324 )
325 .is_err());
326 }
327
328 #[test]
329 fn rejects_wrong_protocol_and_type() {
330 let signer = AgentSigner::from_seed([19; 32]);
331 let payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent");
332
333 let wrong_protocol = signer
334 .sign_event(crate::identity::Event::new(
335 "agent-discourse/1.0",
336 PROFILE_UPDATE,
337 signer.agent_id(),
338 1_779_753_600_000,
339 1,
340 payload.clone(),
341 ))
342 .unwrap();
343 assert!(matches!(
344 validate_profile_update(&wrong_protocol),
345 Err(SdkError::InvalidEventProtocol { .. })
346 ));
347
348 let wrong_type = signer
349 .sign_event(crate::identity::Event::new(
350 PROTOCOL,
351 "profile.delete",
352 signer.agent_id(),
353 1_779_753_600_000,
354 1,
355 payload,
356 ))
357 .unwrap();
358 assert!(matches!(
359 validate_profile_update(&wrong_type),
360 Err(SdkError::InvalidEventType { .. })
361 ));
362 }
363}