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