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)]
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
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: 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}