use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::error::{A2AError, A2AResult};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentCard {
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<AgentProvider>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon_url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub documentation_url: Option<Url>,
pub supported_interfaces: Vec<AgentInterface>,
#[serde(default)]
pub capabilities: AgentCapabilities,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub security_schemes: Vec<SecurityScheme>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub security: Vec<SecurityRequirement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub default_input_modes: Vec<ContentType>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub default_output_modes: Vec<ContentType>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<AgentSkill>,
}
impl AgentCard {
pub async fn discover(base_url: &str) -> A2AResult<Self> {
let url = format!(
"{}/.well-known/agent-card.json",
base_url.trim_end_matches('/')
);
tracing::info!(url = %url, "Discovering A2A agent");
let response = reqwest::get(&url)
.await
.map_err(|e| A2AError::DiscoveryFailed(format!("Failed to fetch agent card: {e}")))?;
if !response.status().is_success() {
return Err(A2AError::DiscoveryFailed(format!(
"Agent card endpoint returned {}",
response.status()
)));
}
let card: AgentCard = response
.json()
.await
.map_err(|e| A2AError::InvalidAgentCard(format!("Failed to parse agent card: {e}")))?;
card.validate()?;
tracing::info!(
name = %card.name,
skills = card.skills.len(),
"Discovered A2A agent"
);
Ok(card)
}
pub fn validate(&self) -> A2AResult<()> {
if self.name.is_empty() {
return Err(A2AError::InvalidAgentCard("name is required".into()));
}
if self.description.is_empty() {
return Err(A2AError::InvalidAgentCard("description is required".into()));
}
if self.supported_interfaces.is_empty() {
return Err(A2AError::InvalidAgentCard(
"at least one supported interface is required".into(),
));
}
Ok(())
}
pub fn supports_streaming(&self) -> bool {
self.capabilities.streaming
}
pub fn supports_push_notifications(&self) -> bool {
self.capabilities.push_notifications
}
pub fn find_skill(&self, skill_id: &str) -> Option<&AgentSkill> {
self.skills.iter().find(|s| s.id == skill_id)
}
pub fn primary_url(&self) -> Option<&Url> {
self.supported_interfaces.first().map(|i| &i.url)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentProvider {
pub organization: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<Url>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentInterface {
pub url: Url,
pub protocol_binding: ProtocolBinding,
#[serde(skip_serializing_if = "Option::is_none")]
pub protocol_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum ProtocolBinding {
JsonrpcHttp,
Grpc,
HttpJson,
#[serde(untagged)]
Custom(String),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentCapabilities {
#[serde(default)]
pub streaming: bool,
#[serde(default)]
pub push_notifications: bool,
#[serde(default)]
pub extended_agent_card: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<AgentExtension>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentExtension {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentSkill {
pub id: String,
pub name: String,
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub input_modes: Vec<ContentType>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub output_modes: Vec<ContentType>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ContentType {
pub media_type: String,
}
impl ContentType {
pub fn text() -> Self {
Self {
media_type: "text/plain".into(),
}
}
pub fn json() -> Self {
Self {
media_type: "application/json".into(),
}
}
pub fn a2a_json() -> Self {
Self {
media_type: "application/a2a+json".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum SecurityScheme {
#[serde(rename = "apiKey")]
ApiKey {
name: String,
#[serde(rename = "in")]
location: ApiKeyLocation,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
Http {
scheme: String,
#[serde(skip_serializing_if = "Option::is_none")]
bearer_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
#[serde(rename = "oauth2")]
OAuth2 {
flows: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
OpenIdConnect {
open_id_connect_url: Url,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ApiKeyLocation {
Header,
Query,
Cookie,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SecurityRequirement {
pub scheme: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_agent_card() {
let card = AgentCard {
name: "summarizer".into(),
description: "Summarizes documents with citations".into(),
version: Some("1.0.0".into()),
provider: Some(AgentProvider {
organization: "AgentOven".into(),
url: Some(Url::parse("https://agentoven.dev").unwrap()),
}),
icon_url: None,
documentation_url: None,
supported_interfaces: vec![AgentInterface {
url: Url::parse("https://agent.example.com/a2a").unwrap(),
protocol_binding: ProtocolBinding::JsonrpcHttp,
protocol_version: Some("1.0".into()),
}],
capabilities: AgentCapabilities {
streaming: true,
push_notifications: true,
..Default::default()
},
security_schemes: vec![SecurityScheme::Http {
scheme: "bearer".into(),
bearer_format: Some("JWT".into()),
description: None,
}],
security: vec![],
default_input_modes: vec![ContentType::text()],
default_output_modes: vec![ContentType::text(), ContentType::json()],
skills: vec![AgentSkill {
id: "summarize".into(),
name: "Document Summarization".into(),
description: "Summarizes long documents into concise summaries".into(),
tags: vec!["summarization".into(), "nlp".into()],
examples: vec!["Summarize this quarterly report".into()],
input_modes: vec![],
output_modes: vec![],
}],
};
let json = serde_json::to_string_pretty(&card).unwrap();
assert!(json.contains("summarizer"));
assert!(json.contains("agentoven.dev"));
let parsed: AgentCard = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "summarizer");
assert!(parsed.capabilities.streaming);
}
#[test]
fn test_validate_agent_card() {
let mut card = AgentCard {
name: "".into(),
description: "test".into(),
version: None,
provider: None,
icon_url: None,
documentation_url: None,
supported_interfaces: vec![],
capabilities: Default::default(),
security_schemes: vec![],
security: vec![],
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
};
assert!(card.validate().is_err());
card.name = "test-agent".into();
assert!(card.validate().is_err());
card.supported_interfaces.push(AgentInterface {
url: Url::parse("https://example.com").unwrap(),
protocol_binding: ProtocolBinding::JsonrpcHttp,
protocol_version: None,
});
assert!(card.validate().is_ok());
}
}