use serde::{Deserialize, Serialize};
use crate::manifest::Manifest;
pub const WELL_KNOWN_PATH: &str = "/.well-known/agent-card.json";
pub const BINDING: &str = "JSONRPC";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CardSkill {
pub id: String,
pub name: String,
pub description: String,
pub tags: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardCapabilities {
pub streaming: bool,
pub push_notifications: bool,
pub extended_agent_card: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<AgentExtension>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentExtension {
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
}
pub const EXT_MANIFEST_PROVENANCE: &str =
"https://hupe1980.github.io/agentplane/a2a/ext/manifest-provenance/v1";
pub const EXT_GOVERNANCE: &str = "https://hupe1980.github.io/agentplane/a2a/ext/governance/v1";
pub const EXT_AGENT_DIRECTORY: &str =
"https://hupe1980.github.io/agentplane/a2a/ext/agent-directory/v1";
#[must_use]
pub fn agent_card_path(agent: &str) -> String {
format!("/agents/{agent}/agent-card.json")
}
impl CardCapabilities {
const fn implemented() -> Self {
Self {
streaming: true,
push_notifications: false,
extended_agent_card: true,
extensions: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardInterface {
pub url: String,
pub protocol_binding: String,
pub protocol_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tenant: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpAuthSecurityScheme {
pub scheme: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bearer_format: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardSecurityScheme {
pub http_auth_security_scheme: HttpAuthSecurityScheme,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecurityScopeList {
pub list: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CardSecurityRequirement {
pub schemes: std::collections::BTreeMap<String, SecurityScopeList>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CardSecurity {
name: String,
scheme: CardSecurityScheme,
scopes: Vec<String>,
}
impl CardSecurity {
#[must_use]
pub fn bearer(
name: impl Into<String>,
scopes: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
name: name.into(),
scheme: CardSecurityScheme {
http_auth_security_scheme: HttpAuthSecurityScheme {
scheme: "Bearer".to_owned(),
bearer_format: None,
},
},
scopes: scopes.into_iter().map(Into::into).collect(),
}
}
#[cfg(feature = "a2a-server")]
pub(crate) fn apply(&self, card: &mut AgentCard) {
card.security_schemes
.insert(self.name.clone(), self.scheme.clone());
card.security_requirements.push(CardSecurityRequirement {
schemes: std::collections::BTreeMap::from([(
self.name.clone(),
SecurityScopeList {
list: self.scopes.clone(),
},
)]),
});
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCard {
pub name: String,
pub description: String,
pub version: String,
pub capabilities: CardCapabilities,
pub supported_interfaces: Vec<CardInterface>,
pub default_input_modes: Vec<String>,
pub default_output_modes: Vec<String>,
pub skills: Vec<CardSkill>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub security_schemes: std::collections::BTreeMap<String, CardSecurityScheme>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub security_requirements: Vec<CardSecurityRequirement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub signatures: Vec<super::card_sig::CardSignature>,
}
impl AgentCard {
pub fn derive(
manifest: &Manifest,
url: impl Into<String>,
) -> Result<Self, crate::manifest::ManifestError> {
let description = manifest.spec.identity.as_ref().map_or_else(
|| format!("The {} agent.", manifest.metadata.name),
|identity| identity.role.clone(),
);
let skills = manifest
.spec
.capabilities
.provides
.iter()
.map(|capability| CardSkill {
id: capability.clone(),
name: capability.clone(),
description: description.clone(),
tags: Vec::new(),
})
.collect();
Ok(Self {
name: manifest.metadata.name.clone(),
description,
version: manifest.metadata.version.clone(),
capabilities: {
let mut capabilities = CardCapabilities::implemented();
capabilities.extensions.push(AgentExtension {
uri: EXT_MANIFEST_PROVENANCE.to_owned(),
description: Some(
"The digest of the manifest this card was derived from.".to_owned(),
),
required: false,
params: Some(serde_json::json!({
"manifestDigest": manifest.digest()?.to_hex(),
})),
});
capabilities
},
supported_interfaces: vec![CardInterface {
url: url.into(),
protocol_binding: BINDING.to_owned(),
protocol_version: crate::peers::PROTOCOL_VERSION.to_owned(),
tenant: None,
}],
default_input_modes: vec!["text/plain".to_owned(), "application/json".to_owned()],
default_output_modes: vec!["text/plain".to_owned(), "application/json".to_owned()],
skills,
security_schemes: std::collections::BTreeMap::new(),
security_requirements: Vec::new(),
signatures: Vec::new(),
})
}
#[must_use]
pub fn manifest_digest(&self) -> Option<&str> {
self.capabilities
.extensions
.iter()
.find(|e| e.uri == EXT_MANIFEST_PROVENANCE)?
.params
.as_ref()?
.get("manifestDigest")?
.as_str()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ExtendedAgentCard {
pub public: AgentCard,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExtendedTool {
pub reference: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub mutates: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtendedBudget {
#[serde(default, with = "stringly", skip_serializing_if = "Option::is_none")]
pub max_steps: Option<u64>,
#[serde(default, with = "stringly", skip_serializing_if = "Option::is_none")]
pub max_effects: Option<u64>,
#[serde(default, with = "stringly", skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
}
mod stringly {
use serde::{Deserialize, Deserializer, Serializer, de::Error as _};
#[allow(clippy::ref_option)]
pub fn serialize<S: Serializer>(v: &Option<u64>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(n) => s.serialize_str(&n.to_string()),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
let raw: Option<String> = Option::deserialize(d)?;
raw.map(|v| v.parse().map_err(D::Error::custom)).transpose()
}
}
impl ExtendedAgentCard {
pub fn derive(
manifest: &Manifest,
url: impl Into<String>,
) -> Result<Self, crate::manifest::ManifestError> {
let tools: Vec<ExtendedTool> = manifest
.spec
.tools
.iter()
.map(|g| ExtendedTool {
reference: g.reference.clone(),
description: g.description.clone(),
mutates: g.mutates,
})
.collect();
let budget = manifest.spec.budgets.as_ref().map(|b| ExtendedBudget {
max_steps: b.max_steps.map(|n| n as u64),
max_effects: b.max_effects.map(|n| n as u64),
max_tokens: b.max_tokens,
});
let topology = manifest
.spec
.topology
.as_ref()
.map(|t| format!("{:?}/{:?}", t.mode, t.role));
let mut public = AgentCard::derive(manifest, url)?;
let mut params = serde_json::Map::new();
params.insert(
"tools".to_owned(),
serde_json::to_value(&tools).unwrap_or(serde_json::Value::Null),
);
if let Some(budget) = budget {
params.insert(
"budget".to_owned(),
serde_json::to_value(budget).unwrap_or(serde_json::Value::Null),
);
}
if let Some(topology) = topology {
params.insert("topology".to_owned(), serde_json::Value::String(topology));
}
public.capabilities.extensions.push(AgentExtension {
uri: EXT_GOVERNANCE.to_owned(),
description: Some(
"What this agent may reach and spend, for a peer deciding whether to delegate."
.to_owned(),
),
required: false,
params: Some(serde_json::Value::Object(params)),
});
Ok(Self { public })
}
#[must_use]
pub fn tools(&self) -> Vec<ExtendedTool> {
self.governance("tools")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default()
}
#[must_use]
pub fn budget(&self) -> Option<ExtendedBudget> {
self.governance("budget")
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
#[must_use]
pub fn topology(&self) -> Option<String> {
self.governance("topology")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned)
}
fn governance(&self, key: &str) -> Option<&serde_json::Value> {
self.public
.capabilities
.extensions
.iter()
.find(|e| e.uri == EXT_GOVERNANCE)?
.params
.as_ref()?
.get(key)
}
}