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