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}