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}