Skip to main content

microclaw_engine/
a2a.rs

1use serde::{Deserialize, Serialize};
2
3use crate::config::Config;
4
5pub const A2A_PROTOCOL_VERSION: &str = "microclaw-a2a/v1";
6pub const A2A_AGENT_CARD_PATH: &str = "/api/a2a/agent-card";
7pub const A2A_MESSAGE_PATH: &str = "/api/a2a/message";
8
9#[derive(Clone, Debug, Serialize, Deserialize)]
10pub struct A2AAgentCard {
11    pub protocol_version: String,
12    pub agent_id: String,
13    pub agent_name: String,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub description: Option<String>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub public_base_url: Option<String>,
18    pub endpoints: A2AEndpoints,
19    pub capabilities: Vec<String>,
20}
21
22#[derive(Clone, Debug, Serialize, Deserialize)]
23pub struct A2AEndpoints {
24    pub agent_card: String,
25    pub message: String,
26}
27
28#[derive(Clone, Debug, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct A2AMessageRequest {
31    #[serde(default)]
32    pub session_key: Option<String>,
33    #[serde(default)]
34    pub sender_name: Option<String>,
35    #[serde(default)]
36    pub source_agent: Option<String>,
37    #[serde(default)]
38    pub source_url: Option<String>,
39    pub message: String,
40}
41
42#[derive(Clone, Debug, Serialize, Deserialize)]
43pub struct A2AMessageResponse {
44    pub ok: bool,
45    pub protocol_version: String,
46    pub agent_name: String,
47    pub session_key: String,
48    pub response: String,
49}
50
51pub fn normalize_peer_name(name: &str) -> Option<String> {
52    let trimmed = name.trim().to_ascii_lowercase();
53    if trimmed.is_empty() {
54        None
55    } else {
56        Some(trimmed)
57    }
58}
59
60pub fn normalize_base_url(raw: &str) -> Option<String> {
61    let trimmed = raw.trim().trim_end_matches('/');
62    if trimmed.is_empty() {
63        return None;
64    }
65    let parsed = reqwest::Url::parse(trimmed).ok()?;
66    match parsed.scheme() {
67        "http" | "https" => Some(trimmed.to_string()),
68        _ => None,
69    }
70}
71
72pub fn local_agent_name(config: &Config) -> String {
73    config
74        .a2a
75        .agent_name
76        .as_deref()
77        .map(str::trim)
78        .filter(|v| !v.is_empty())
79        .map(ToOwned::to_owned)
80        .or_else(|| {
81            let bot = config.bot_username.trim();
82            if bot.is_empty() {
83                None
84            } else {
85                Some(bot.to_string())
86            }
87        })
88        .unwrap_or_else(|| "MicroClaw".to_string())
89}
90
91pub fn default_session_key_for_source(source_agent: Option<&str>) -> String {
92    source_agent
93        .and_then(normalize_peer_name)
94        .map(|v| format!("a2a:{v}"))
95        .unwrap_or_else(|| "a2a:remote".to_string())
96}
97
98pub fn build_agent_card(config: &Config) -> A2AAgentCard {
99    let base_url = config
100        .a2a
101        .public_base_url
102        .as_deref()
103        .and_then(normalize_base_url);
104    let agent_name = local_agent_name(config);
105    let agent_id = agent_name
106        .chars()
107        .map(|ch| {
108            if ch.is_ascii_alphanumeric() {
109                ch.to_ascii_lowercase()
110            } else {
111                '-'
112            }
113        })
114        .collect::<String>()
115        .trim_matches('-')
116        .to_string();
117    let prefix = base_url.clone().unwrap_or_default();
118    A2AAgentCard {
119        protocol_version: A2A_PROTOCOL_VERSION.to_string(),
120        agent_id: if agent_id.is_empty() {
121            "microclaw".to_string()
122        } else {
123            agent_id
124        },
125        agent_name,
126        description: config.a2a.agent_description.clone(),
127        public_base_url: base_url,
128        endpoints: A2AEndpoints {
129            agent_card: format!("{prefix}{A2A_AGENT_CARD_PATH}"),
130            message: format!("{prefix}{A2A_MESSAGE_PATH}"),
131        },
132        capabilities: vec!["sync-message".to_string()],
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::config::{A2APeerConfig, Config};
140
141    #[test]
142    fn test_normalize_base_url() {
143        assert_eq!(
144            normalize_base_url(" https://example.com/base/ "),
145            Some("https://example.com/base".to_string())
146        );
147        assert_eq!(normalize_base_url("file:///tmp"), None);
148    }
149
150    #[test]
151    fn test_build_agent_card_uses_config() {
152        let mut cfg = Config::test_defaults();
153        cfg.a2a.enabled = true;
154        cfg.a2a.agent_name = Some("Planner".into());
155        cfg.a2a.agent_description = Some("Routes work".into());
156        cfg.a2a.public_base_url = Some("https://mc.example.com/".into());
157        cfg.a2a.peers.insert(
158            "worker".into(),
159            A2APeerConfig {
160                enabled: true,
161                base_url: "https://worker.example.com".into(),
162                bearer_token: None,
163                description: None,
164                default_session_key: None,
165                trust: Default::default(),
166            },
167        );
168
169        let card = build_agent_card(&cfg);
170        assert_eq!(card.agent_name, "Planner");
171        assert_eq!(card.agent_id, "planner");
172        assert_eq!(
173            card.endpoints.message,
174            "https://mc.example.com/api/a2a/message"
175        );
176    }
177}