1use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use url::Url;
12
13use crate::error::{A2AError, A2AResult};
14
15#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
19#[serde(rename_all = "camelCase")]
20pub struct AgentCard {
21 pub name: String,
23
24 pub description: String,
26
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub version: Option<String>,
30
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub provider: Option<AgentProvider>,
34
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub icon_url: Option<Url>,
38
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub documentation_url: Option<Url>,
42
43 pub supported_interfaces: Vec<AgentInterface>,
45
46 #[serde(default)]
48 pub capabilities: AgentCapabilities,
49
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub security_schemes: Vec<SecurityScheme>,
53
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub security: Vec<SecurityRequirement>,
57
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub default_input_modes: Vec<ContentType>,
61
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub default_output_modes: Vec<ContentType>,
65
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub skills: Vec<AgentSkill>,
69}
70
71impl AgentCard {
72 pub async fn discover(base_url: &str) -> A2AResult<Self> {
76 let url = format!(
77 "{}/.well-known/agent-card.json",
78 base_url.trim_end_matches('/')
79 );
80
81 tracing::info!(url = %url, "Discovering A2A agent");
82
83 let response = reqwest::get(&url)
84 .await
85 .map_err(|e| A2AError::DiscoveryFailed(format!("Failed to fetch agent card: {e}")))?;
86
87 if !response.status().is_success() {
88 return Err(A2AError::DiscoveryFailed(format!(
89 "Agent card endpoint returned {}",
90 response.status()
91 )));
92 }
93
94 let card: AgentCard = response
95 .json()
96 .await
97 .map_err(|e| A2AError::InvalidAgentCard(format!("Failed to parse agent card: {e}")))?;
98
99 card.validate()?;
100
101 tracing::info!(
102 name = %card.name,
103 skills = card.skills.len(),
104 "Discovered A2A agent"
105 );
106
107 Ok(card)
108 }
109
110 pub fn validate(&self) -> A2AResult<()> {
112 if self.name.is_empty() {
113 return Err(A2AError::InvalidAgentCard("name is required".into()));
114 }
115 if self.description.is_empty() {
116 return Err(A2AError::InvalidAgentCard("description is required".into()));
117 }
118 if self.supported_interfaces.is_empty() {
119 return Err(A2AError::InvalidAgentCard(
120 "at least one supported interface is required".into(),
121 ));
122 }
123 Ok(())
124 }
125
126 pub fn supports_streaming(&self) -> bool {
128 self.capabilities.streaming
129 }
130
131 pub fn supports_push_notifications(&self) -> bool {
133 self.capabilities.push_notifications
134 }
135
136 pub fn find_skill(&self, skill_id: &str) -> Option<&AgentSkill> {
138 self.skills.iter().find(|s| s.id == skill_id)
139 }
140
141 pub fn primary_url(&self) -> Option<&Url> {
143 self.supported_interfaces.first().map(|i| &i.url)
144 }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
149#[serde(rename_all = "camelCase")]
150pub struct AgentProvider {
151 pub organization: String,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub url: Option<Url>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
161#[serde(rename_all = "camelCase")]
162pub struct AgentInterface {
163 pub url: Url,
165
166 pub protocol_binding: ProtocolBinding,
168
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub protocol_version: Option<String>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
176#[serde(rename_all = "kebab-case")]
177pub enum ProtocolBinding {
178 JsonrpcHttp,
180 Grpc,
182 HttpJson,
184 #[serde(untagged)]
186 Custom(String),
187}
188
189#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
191#[serde(rename_all = "camelCase")]
192pub struct AgentCapabilities {
193 #[serde(default)]
195 pub streaming: bool,
196
197 #[serde(default)]
199 pub push_notifications: bool,
200
201 #[serde(default)]
203 pub extended_agent_card: bool,
204
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub extensions: Vec<AgentExtension>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
212#[serde(rename_all = "camelCase")]
213pub struct AgentExtension {
214 pub uri: String,
216
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub description: Option<String>,
220
221 #[serde(default)]
223 pub required: bool,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
228#[serde(rename_all = "camelCase")]
229pub struct AgentSkill {
230 pub id: String,
232
233 pub name: String,
235
236 pub description: String,
238
239 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub tags: Vec<String>,
242
243 #[serde(default, skip_serializing_if = "Vec::is_empty")]
245 pub examples: Vec<String>,
246
247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
249 pub input_modes: Vec<ContentType>,
250
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
253 pub output_modes: Vec<ContentType>,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
258#[serde(rename_all = "camelCase")]
259pub struct ContentType {
260 pub media_type: String,
262}
263
264impl ContentType {
265 pub fn text() -> Self {
266 Self {
267 media_type: "text/plain".into(),
268 }
269 }
270 pub fn json() -> Self {
271 Self {
272 media_type: "application/json".into(),
273 }
274 }
275 pub fn a2a_json() -> Self {
276 Self {
277 media_type: "application/a2a+json".into(),
278 }
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
284#[serde(rename_all = "camelCase", tag = "type")]
285pub enum SecurityScheme {
286 #[serde(rename = "apiKey")]
288 ApiKey {
289 name: String,
290 #[serde(rename = "in")]
291 location: ApiKeyLocation,
292 #[serde(skip_serializing_if = "Option::is_none")]
293 description: Option<String>,
294 },
295
296 Http {
298 scheme: String,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 bearer_format: Option<String>,
301 #[serde(skip_serializing_if = "Option::is_none")]
302 description: Option<String>,
303 },
304
305 #[serde(rename = "oauth2")]
307 OAuth2 {
308 flows: serde_json::Value,
309 #[serde(skip_serializing_if = "Option::is_none")]
310 description: Option<String>,
311 },
312
313 OpenIdConnect {
315 open_id_connect_url: Url,
316 #[serde(skip_serializing_if = "Option::is_none")]
317 description: Option<String>,
318 },
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
323#[serde(rename_all = "lowercase")]
324pub enum ApiKeyLocation {
325 Header,
326 Query,
327 Cookie,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
332pub struct SecurityRequirement {
333 pub scheme: String,
335 #[serde(default, skip_serializing_if = "Vec::is_empty")]
337 pub scopes: Vec<String>,
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn test_serialize_agent_card() {
346 let card = AgentCard {
347 name: "summarizer".into(),
348 description: "Summarizes documents with citations".into(),
349 version: Some("1.0.0".into()),
350 provider: Some(AgentProvider {
351 organization: "AgentOven".into(),
352 url: Some(Url::parse("https://agentoven.dev").unwrap()),
353 }),
354 icon_url: None,
355 documentation_url: None,
356 supported_interfaces: vec![AgentInterface {
357 url: Url::parse("https://agent.example.com/a2a").unwrap(),
358 protocol_binding: ProtocolBinding::JsonrpcHttp,
359 protocol_version: Some("1.0".into()),
360 }],
361 capabilities: AgentCapabilities {
362 streaming: true,
363 push_notifications: true,
364 ..Default::default()
365 },
366 security_schemes: vec![SecurityScheme::Http {
367 scheme: "bearer".into(),
368 bearer_format: Some("JWT".into()),
369 description: None,
370 }],
371 security: vec![],
372 default_input_modes: vec![ContentType::text()],
373 default_output_modes: vec![ContentType::text(), ContentType::json()],
374 skills: vec![AgentSkill {
375 id: "summarize".into(),
376 name: "Document Summarization".into(),
377 description: "Summarizes long documents into concise summaries".into(),
378 tags: vec!["summarization".into(), "nlp".into()],
379 examples: vec!["Summarize this quarterly report".into()],
380 input_modes: vec![],
381 output_modes: vec![],
382 }],
383 };
384
385 let json = serde_json::to_string_pretty(&card).unwrap();
386 assert!(json.contains("summarizer"));
387 assert!(json.contains("agentoven.dev"));
388
389 let parsed: AgentCard = serde_json::from_str(&json).unwrap();
391 assert_eq!(parsed.name, "summarizer");
392 assert!(parsed.capabilities.streaming);
393 }
394
395 #[test]
396 fn test_validate_agent_card() {
397 let mut card = AgentCard {
398 name: "".into(),
399 description: "test".into(),
400 version: None,
401 provider: None,
402 icon_url: None,
403 documentation_url: None,
404 supported_interfaces: vec![],
405 capabilities: Default::default(),
406 security_schemes: vec![],
407 security: vec![],
408 default_input_modes: vec![],
409 default_output_modes: vec![],
410 skills: vec![],
411 };
412
413 assert!(card.validate().is_err());
414
415 card.name = "test-agent".into();
416 assert!(card.validate().is_err()); card.supported_interfaces.push(AgentInterface {
419 url: Url::parse("https://example.com").unwrap(),
420 protocol_binding: ProtocolBinding::JsonrpcHttp,
421 protocol_version: None,
422 });
423 assert!(card.validate().is_ok());
424 }
425}