Skip to main content

agent_protocols/
profile.rs

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, Debug, Serialize, Deserialize, PartialEq)]
21pub struct ProfileUpdatePayload {
22    pub agent_id: AgentId,
23    pub name: String,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub description: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub avatar_url: Option<String>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub provider: Option<String>,
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub capabilities: Vec<String>,
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub service_endpoints: Vec<ServiceEndpoint>,
34    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
35    pub links: BTreeMap<String, String>,
36    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
37    pub metadata: BTreeMap<String, Value>,
38}
39
40impl ProfileUpdatePayload {
41    pub fn new(agent_id: AgentId, name: impl Into<String>) -> Self {
42        Self {
43            agent_id,
44            name: name.into(),
45            description: None,
46            avatar_url: None,
47            provider: None,
48            capabilities: Vec::new(),
49            service_endpoints: Vec::new(),
50            links: BTreeMap::new(),
51            metadata: BTreeMap::new(),
52        }
53    }
54}
55
56#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
57pub struct AgentProfile {
58    pub agent_id: AgentId,
59    pub name: String,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub description: Option<String>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub avatar_url: Option<String>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub provider: Option<String>,
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub capabilities: Vec<String>,
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub service_endpoints: Vec<ServiceEndpoint>,
70    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
71    pub links: BTreeMap<String, String>,
72    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
73    pub metadata: BTreeMap<String, Value>,
74    pub updated_at: i64,
75    pub profile_event_id: String,
76}
77
78#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
79pub struct ProfileReadResponse {
80    pub profile: AgentProfile,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub profile_event: Option<Envelope<ProfileUpdatePayload>>,
83}
84
85#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
86pub struct ProfileServiceDiscovery {
87    pub protocol: String,
88    pub service: String,
89    pub endpoints: ProfileServiceEndpoints,
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub features: Vec<String>,
92}
93
94#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
95pub struct ProfileServiceEndpoints {
96    pub profiles: String,
97}
98
99pub fn profile_update_event(
100    actor: AgentId,
101    created_at: i64,
102    nonce: impl Into<String>,
103    payload: ProfileUpdatePayload,
104) -> Event<ProfileUpdatePayload> {
105    Event::new(PROTOCOL, PROFILE_UPDATE, actor, created_at, nonce, payload)
106}
107
108pub fn validate_profile_update(envelope: &Envelope<ProfileUpdatePayload>) -> Result<()> {
109    verify_envelope(envelope)?;
110    if envelope.event.protocol != PROTOCOL {
111        return Err(SdkError::InvalidEventProtocol {
112            expected: PROTOCOL.to_owned(),
113            actual: envelope.event.protocol.clone(),
114        });
115    }
116    if envelope.event.kind != PROFILE_UPDATE {
117        return Err(SdkError::InvalidEventType {
118            expected: PROFILE_UPDATE.to_owned(),
119            actual: envelope.event.kind.clone(),
120        });
121    }
122    if envelope.event.actor != envelope.event.payload.agent_id {
123        return Err(SdkError::InvalidActor(
124            "profile update actor must match payload.agent_id".to_owned(),
125        ));
126    }
127    Ok(())
128}
129
130pub fn materialize_profile(envelope: &Envelope<ProfileUpdatePayload>) -> Result<AgentProfile> {
131    validate_profile_update(envelope)?;
132    let payload = &envelope.event.payload;
133    Ok(AgentProfile {
134        agent_id: payload.agent_id.clone(),
135        name: payload.name.clone(),
136        description: payload.description.clone(),
137        avatar_url: payload.avatar_url.clone(),
138        provider: payload.provider.clone(),
139        capabilities: payload.capabilities.clone(),
140        service_endpoints: payload.service_endpoints.clone(),
141        links: payload.links.clone(),
142        metadata: payload.metadata.clone(),
143        updated_at: envelope.event.created_at,
144        profile_event_id: envelope.event_id.clone(),
145    })
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::identity::AgentSigner;
152
153    #[test]
154    fn materializes_valid_profile_update() {
155        let signer = AgentSigner::from_seed([11; 32]);
156        let mut payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
157        payload.capabilities.push("research".to_owned());
158        let event =
159            profile_update_event(signer.agent_id(), 1_779_753_600_000, "n_profile", payload);
160        let envelope = signer.sign_event(event).unwrap();
161
162        let profile = materialize_profile(&envelope).unwrap();
163
164        assert_eq!(profile.name, "ResearchAgent-v3");
165        assert_eq!(profile.updated_at, 1_779_753_600_000);
166        assert_eq!(profile.profile_event_id, envelope.event_id);
167    }
168
169    #[test]
170    fn rejects_actor_payload_mismatch() {
171        let signer = AgentSigner::from_seed([12; 32]);
172        let other = AgentSigner::from_seed([13; 32]);
173        let payload = ProfileUpdatePayload::new(other.agent_id(), "Imposter");
174        let event =
175            profile_update_event(signer.agent_id(), 1_779_753_600_000, "n_profile", payload);
176        let envelope = signer.sign_event(event).unwrap();
177
178        assert!(matches!(
179            validate_profile_update(&envelope),
180            Err(SdkError::InvalidActor(_))
181        ));
182    }
183}