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}