use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::client::HaiClient;
use crate::error::{HaiError, Result};
use crate::jacs::JacsProvider;
use crate::types::{
RegisterAgentOptions, RegistrationResult, SendEmailOptions, SendEmailResult, TransportType,
};
pub const A2A_PROTOCOL_VERSION_04: &str = "0.4.0";
pub const A2A_PROTOCOL_VERSION_10: &str = "1.0";
pub const A2A_JACS_EXTENSION_URI: &str = "urn:jacs:provenance-v1";
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum A2ATrustPolicy {
Open,
#[default]
Verified,
Strict,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct A2AAgentInterface {
pub url: String,
pub protocol_binding: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protocol_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct A2AAgentExtension {
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct A2AAgentCapabilities {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub streaming: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub push_notifications: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extended_agent_card: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<A2AAgentExtension>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct A2AAgentSkill {
pub id: String,
pub name: String,
pub description: String,
#[serde(default)]
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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub output_modes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct A2AAgentCard {
pub name: String,
pub description: String,
pub version: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub protocol_versions: Vec<String>,
pub supported_interfaces: Vec<A2AAgentInterface>,
pub default_input_modes: Vec<String>,
pub default_output_modes: Vec<String>,
pub capabilities: A2AAgentCapabilities,
pub skills: Vec<A2AAgentSkill>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub metadata: Map<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct A2AArtifactSignature {
#[serde(rename = "agentID")]
pub agent_id: String,
pub date: String,
pub signature: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct A2AWrappedArtifact {
pub jacs_id: String,
pub jacs_version: String,
pub jacs_type: String,
pub jacs_level: String,
pub jacs_version_date: String,
pub a2a_artifact: Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub jacs_parent_signatures: Vec<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jacs_signature: Option<A2AArtifactSignature>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct A2AArtifactVerificationResult {
pub valid: bool,
pub signer_id: String,
pub artifact_type: String,
pub timestamp: String,
pub original_artifact: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct A2ATrustAssessment {
pub allowed: bool,
pub trust_level: String,
pub jacs_registered: bool,
pub in_trust_store: bool,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct A2AChainEntry {
pub artifact_id: String,
pub artifact_type: String,
pub timestamp: String,
pub agent_id: String,
pub signature_present: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct A2AChainOfCustody {
pub chain_of_custody: Vec<A2AChainEntry>,
pub created: String,
pub total_artifacts: usize,
}
#[derive(Debug, Clone)]
pub struct A2AMediatedJobOptions {
pub transport: TransportType,
pub notify_email: Option<String>,
pub email_subject: Option<String>,
pub verify_inbound_artifact: bool,
pub enforce_trust_policy: bool,
pub max_reconnect_attempts: usize,
}
impl Default for A2AMediatedJobOptions {
fn default() -> Self {
Self {
transport: TransportType::Sse,
notify_email: None,
email_subject: None,
verify_inbound_artifact: false,
enforce_trust_policy: false,
max_reconnect_attempts: 0,
}
}
}
pub struct A2AIntegration<'a, P: JacsProvider> {
client: &'a HaiClient<P>,
trust_policy: A2ATrustPolicy,
trusted_cards: Arc<Mutex<HashMap<String, Value>>>,
}
impl<P: JacsProvider> HaiClient<P> {
pub fn get_a2a(&self, trust_policy: Option<A2ATrustPolicy>) -> A2AIntegration<'_, P> {
A2AIntegration {
client: self,
trust_policy: trust_policy.unwrap_or(A2ATrustPolicy::Verified),
trusted_cards: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl<'a, P: JacsProvider> A2AIntegration<'a, P> {
pub fn export_agent_card(&self, agent_data: &Value) -> Result<A2AAgentCard> {
let agent_id = value_string(agent_data, &["jacsId"]).if_empty_then(self.client.jacs_id());
let name = value_string(agent_data, &["jacsName"]).if_empty_then(self.client.jacs_id());
let default_description = format!("HAIAI agent {name}");
let description =
value_string(agent_data, &["jacsDescription"]).if_empty_then(&default_description);
let version = value_string(agent_data, &["jacsVersion"]).if_empty_then("1.0.0");
let profile =
value_string(agent_data, &["a2aProfile"]).if_empty_then(A2A_PROTOCOL_VERSION_04);
let mut base_url = format!("https://agent-{agent_id}.example.com");
if let Some(domain) = value_string_opt(agent_data, &["jacsAgentDomain"]) {
let cleaned = domain.trim_start_matches("https://");
base_url = format!("https://{cleaned}/agent/{agent_id}");
}
let mut supported = vec![A2AAgentInterface {
url: base_url,
protocol_binding: "jsonrpc".to_string(),
protocol_version: None,
tenant: None,
}];
if profile == A2A_PROTOCOL_VERSION_10 {
supported[0].protocol_version = Some(A2A_PROTOCOL_VERSION_10.to_string());
}
let mut metadata = Map::new();
metadata.insert("jacsId".to_string(), Value::String(agent_id.to_string()));
metadata.insert(
"jacsVersion".to_string(),
Value::String(version.to_string()),
);
metadata.insert("a2aProfile".to_string(), Value::String(profile.to_string()));
Ok(A2AAgentCard {
name: name.to_string(),
description: description.to_string(),
version: version.to_string(),
protocol_versions: if profile == A2A_PROTOCOL_VERSION_04 {
vec![A2A_PROTOCOL_VERSION_04.to_string()]
} else {
Vec::new()
},
supported_interfaces: supported,
default_input_modes: vec!["text/plain".to_string(), "application/json".to_string()],
default_output_modes: vec!["text/plain".to_string(), "application/json".to_string()],
capabilities: A2AAgentCapabilities {
extensions: vec![A2AAgentExtension {
uri: A2A_JACS_EXTENSION_URI.to_string(),
description: Some(
"JACS cryptographic document signing and verification".to_string(),
),
required: false,
}],
..A2AAgentCapabilities::default()
},
skills: convert_services_to_skills(agent_data.get("jacsServices")),
metadata,
})
}
pub fn sign_artifact(
&self,
artifact: Value,
artifact_type: &str,
parent_signatures: Option<Vec<Value>>,
) -> Result<A2AWrappedArtifact> {
let now = format_rfc3339_now()?;
let mut wrapped = A2AWrappedArtifact {
jacs_id: Uuid::new_v4().to_string(),
jacs_version: "1.0.0".to_string(),
jacs_type: format!("a2a-{artifact_type}"),
jacs_level: "artifact".to_string(),
jacs_version_date: now.clone(),
a2a_artifact: artifact,
jacs_parent_signatures: parent_signatures.unwrap_or_default(),
jacs_signature: None,
};
let canonical = canonical_artifact_json(self.client, &wrapped)?;
let signature = self.client.sign_message(&canonical)?;
wrapped.jacs_signature = Some(A2AArtifactSignature {
agent_id: self.client.jacs_id().to_string(),
date: now,
signature,
});
Ok(wrapped)
}
pub fn verify_artifact(
&self,
wrapped: &A2AWrappedArtifact,
) -> Result<A2AArtifactVerificationResult> {
let signer_id = wrapped
.jacs_signature
.as_ref()
.map(|s| s.agent_id.clone())
.unwrap_or_default();
let wrapped_json = serde_json::to_string(wrapped)?;
match self.client.verify_a2a_artifact(&wrapped_json) {
Ok(result_json) => {
let jacs_result: Value = serde_json::from_str(&result_json)?;
Ok(A2AArtifactVerificationResult {
valid: jacs_result
.get("valid")
.and_then(Value::as_bool)
.unwrap_or(false),
signer_id: jacs_result
.get("signerId")
.and_then(Value::as_str)
.unwrap_or(&signer_id)
.to_string(),
artifact_type: jacs_result
.get("artifactType")
.and_then(Value::as_str)
.unwrap_or(&wrapped.jacs_type)
.to_string(),
timestamp: jacs_result
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or(&wrapped.jacs_version_date)
.to_string(),
original_artifact: jacs_result
.get("originalArtifact")
.cloned()
.unwrap_or_else(|| wrapped.a2a_artifact.clone()),
error: jacs_result
.get("error")
.and_then(Value::as_str)
.map(ToString::to_string),
})
}
Err(e) => Ok(A2AArtifactVerificationResult {
valid: false,
signer_id,
artifact_type: wrapped.jacs_type.clone(),
timestamp: wrapped.jacs_version_date.clone(),
original_artifact: wrapped.a2a_artifact.clone(),
error: Some(format!("verification failed: {e}")),
}),
}
}
pub fn create_chain_of_custody(&self, artifacts: &[A2AWrappedArtifact]) -> A2AChainOfCustody {
let chain = artifacts
.iter()
.map(|artifact| {
let signature = artifact.jacs_signature.as_ref();
A2AChainEntry {
artifact_id: artifact.jacs_id.clone(),
artifact_type: artifact.jacs_type.clone(),
timestamp: artifact.jacs_version_date.clone(),
agent_id: signature.map(|s| s.agent_id.clone()).unwrap_or_default(),
signature_present: signature.map(|s| !s.signature.is_empty()).unwrap_or(false),
}
})
.collect::<Vec<_>>();
A2AChainOfCustody {
total_artifacts: chain.len(),
created: format_rfc3339_now().unwrap_or_default(),
chain_of_custody: chain,
}
}
pub fn generate_well_known_documents(
&self,
agent_card: &A2AAgentCard,
jws_signature: &str,
public_key_b64: &str,
agent_data: &Value,
) -> Result<Value> {
let mut card_value = serde_json::to_value(agent_card)?;
if !jws_signature.is_empty() {
if let Value::Object(ref mut card_obj) = card_value {
card_obj.insert("signatures".to_string(), json!([{ "jws": jws_signature }]));
}
}
let agent_id = value_string(agent_data, &["jacsId"]).if_empty_then(self.client.jacs_id());
let agent_version = value_string(agent_data, &["jacsVersion"]).if_empty_then("1.0.0");
let fallback_profile = resolve_card_profile(agent_card);
let profile = value_string(agent_data, &["a2aProfile"]).if_empty_then(&fallback_profile);
Ok(json!({
"/.well-known/agent-card.json": card_value,
"/.well-known/jwks.json": {
"keys": [{
"kty": "OKP",
"crv": "Ed25519",
"kid": agent_id,
"use": "sig",
"alg": "EdDSA",
"x": public_key_b64
}]
},
"/.well-known/jacs-agent.json": {
"jacsVersion": "1.0",
"agentId": agent_id,
"agentVersion": agent_version,
"capabilities": {
"signing": true,
"verification": true
},
"endpoints": {
"verify": "/jacs/verify",
"sign": "/jacs/sign",
"agent": "/jacs/agent"
}
},
"/.well-known/jacs-extension.json": {
"uri": A2A_JACS_EXTENSION_URI,
"name": "JACS Document Provenance",
"version": "1.0",
"a2aProtocolVersion": profile
}
}))
}
pub fn assess_remote_agent(
&self,
agent_card_json: &str,
policy: Option<A2ATrustPolicy>,
) -> Result<A2ATrustAssessment> {
let card: Value = serde_json::from_str(agent_card_json)?;
let resolved = policy.unwrap_or(self.trust_policy);
let jacs_registered = has_jacs_extension(&card);
let agent_id = extract_card_agent_id(&card).unwrap_or_default();
let in_trust_store = self.is_trusted_a2a_agent(&agent_id);
let trust_level = if in_trust_store {
"explicitly_trusted"
} else if jacs_registered {
"jacs_verified"
} else {
"untrusted"
};
let mut result = A2ATrustAssessment {
allowed: false,
trust_level: trust_level.to_string(),
jacs_registered,
in_trust_store,
reason: String::new(),
};
match resolved {
A2ATrustPolicy::Open => {
result.allowed = true;
result.reason = "open policy: all agents accepted".to_string();
}
A2ATrustPolicy::Verified => {
result.allowed = jacs_registered;
result.reason = if result.allowed {
"verified policy: card declares JACS extension".to_string()
} else {
"verified policy: card missing JACS extension".to_string()
};
}
A2ATrustPolicy::Strict => {
result.allowed = in_trust_store;
result.reason = if result.allowed {
"strict policy: agent is in local trust store".to_string()
} else {
"strict policy: agent is not in local trust store".to_string()
};
}
}
Ok(result)
}
pub fn trust_a2a_agent(&self, agent_card_json: &str) -> Result<String> {
let card: Value = serde_json::from_str(agent_card_json)?;
let Some(agent_id) = extract_card_agent_id(&card) else {
return Err(HaiError::Message(
"cannot trust card without metadata.jacsId".to_string(),
));
};
let mut trusted = self
.trusted_cards
.lock()
.map_err(|e| HaiError::Message(format!("failed to lock trust store: {e}")))?;
trusted.insert(agent_id.clone(), card);
Ok(agent_id)
}
pub fn is_trusted_a2a_agent(&self, agent_id: &str) -> bool {
let Ok(trusted) = self.trusted_cards.lock() else {
return false;
};
trusted.contains_key(agent_id)
}
pub fn list_trusted_a2a_agents(&self) -> Vec<String> {
let Ok(trusted) = self.trusted_cards.lock() else {
return Vec::new();
};
let mut ids = trusted.keys().cloned().collect::<Vec<_>>();
ids.sort();
ids
}
pub fn register_options_with_agent_card(
&self,
mut options: RegisterAgentOptions,
agent_card: &A2AAgentCard,
) -> Result<RegisterAgentOptions> {
let merged = merge_agent_json_with_card(&options.agent_json, agent_card)?;
options.agent_json = merged;
Ok(options)
}
pub async fn register_with_agent_card(
&self,
options: RegisterAgentOptions,
agent_card: &A2AAgentCard,
) -> Result<RegistrationResult> {
let merged = self.register_options_with_agent_card(options, agent_card)?;
self.client.register(&merged).await
}
pub async fn send_signed_artifact_email(
&self,
to: &str,
subject: &str,
artifact: &A2AWrappedArtifact,
) -> Result<SendEmailResult> {
let pretty = serde_json::to_string_pretty(artifact)?;
self.client
.send_email(&SendEmailOptions {
to: to.to_string(),
subject: subject.to_string(),
body: format!("Signed A2A artifact:\n\n{pretty}"),
cc: Vec::new(),
bcc: Vec::new(),
in_reply_to: None,
attachments: Vec::new(),
labels: Vec::new(),
append_footer: None,
})
.await
}
pub async fn on_mediated_benchmark_job<F, Fut>(
&self,
options: A2AMediatedJobOptions,
handler: F,
) -> Result<()>
where
F: FnMut(A2AWrappedArtifact) -> Fut + Send,
Fut: Future<Output = Result<Value>> + Send,
{
let handler = Arc::new(tokio::sync::Mutex::new(handler));
let notify_email = options.notify_email.clone();
let email_subject = options.email_subject.clone();
let verify_inbound_artifact = options.verify_inbound_artifact;
let enforce_trust_policy = options.enforce_trust_policy;
let max_reconnect_attempts = options.max_reconnect_attempts;
let transport = options.transport;
let mut reconnect_attempts = 0usize;
loop {
let handled_jobs = Arc::new(AtomicUsize::new(0));
let handled_jobs_for_handler = Arc::clone(&handled_jobs);
let handler = Arc::clone(&handler);
let notify_email = notify_email.clone();
let email_subject = email_subject.clone();
let result = self
.client
.on_benchmark_job(transport, move |event_data| {
let handler = Arc::clone(&handler);
let handled_jobs_for_handler = Arc::clone(&handled_jobs_for_handler);
let notify_email = notify_email.clone();
let email_subject = email_subject.clone();
async move {
handled_jobs_for_handler.fetch_add(1, Ordering::SeqCst);
let job_id = value_string(&event_data, &["jobId", "job_id"]);
if job_id.is_empty() {
return Err(HaiError::Message(
"benchmark job event missing jobId".to_string(),
));
}
if enforce_trust_policy {
let card_json = extract_remote_agent_card_json(&event_data)?.ok_or_else(|| {
HaiError::Message(
"remote agent card required when trust enforcement is enabled".to_string(),
)
})?;
let trust = self.assess_remote_agent(&card_json, None)?;
if !trust.allowed {
return Err(HaiError::Message(format!(
"trust policy rejected remote agent: {}",
trust.reason
)));
}
}
if verify_inbound_artifact {
let inbound = extract_inbound_a2a_task(&event_data)?.ok_or_else(|| {
HaiError::Message(
"inbound a2a task required when signature verification is enabled".to_string(),
)
})?;
let verify = self.verify_artifact(&inbound)?;
if !verify.valid {
return Err(HaiError::Message(format!(
"inbound a2a task signature invalid: {}",
verify.error.unwrap_or_else(|| "unknown verification failure".to_string())
)));
}
}
let task_payload = json!({
"type": event_data.get("type").cloned().unwrap_or(Value::String("benchmark_job".to_string())),
"jobId": job_id,
"scenarioId": event_data.get("scenarioId").cloned().unwrap_or(Value::Null),
"config": event_data.get("config").cloned().unwrap_or(Value::Null),
});
let task_artifact = self.sign_artifact(task_payload, "task", None)?;
let result_payload = {
let mut locked_handler = handler.lock().await;
(*locked_handler)(task_artifact.clone()).await?
};
let parent = serde_json::to_value(&task_artifact)?;
let result_artifact = self.sign_artifact(
result_payload.clone(),
"task-result",
Some(vec![parent]),
)?;
let message = result_payload
.get("message")
.and_then(Value::as_str)
.map(ToString::to_string)
.unwrap_or_else(|| result_payload.to_string());
self.client
.submit_response(
job_id,
&message,
Some(json!({
"a2aTask": task_artifact,
"a2aResult": result_artifact.clone()
})),
0,
)
.await?;
if let Some(email) = notify_email {
let subject = email_subject
.unwrap_or_else(|| format!("A2A mediated result for job {job_id}"));
self.send_signed_artifact_email(&email, &subject, &result_artifact)
.await?;
}
Ok(())
}
})
.await;
match result {
Ok(()) => {
if handled_jobs.load(Ordering::SeqCst) == 0
&& reconnect_attempts < max_reconnect_attempts
{
reconnect_attempts += 1;
continue;
}
return Ok(());
}
Err(err) => {
if reconnect_attempts < max_reconnect_attempts {
reconnect_attempts += 1;
continue;
}
return Err(err);
}
}
}
}
}
fn convert_services_to_skills(services: Option<&Value>) -> Vec<A2AAgentSkill> {
let Some(Value::Array(entries)) = services else {
return vec![A2AAgentSkill {
id: "verify-signature".to_string(),
name: "verify_signature".to_string(),
description: "Verify JACS document signatures".to_string(),
tags: vec![
"jacs".to_string(),
"verification".to_string(),
"cryptography".to_string(),
],
..A2AAgentSkill::default()
}];
};
let mut out = entries
.iter()
.filter_map(|entry| entry.as_object())
.map(|entry| {
let name = entry
.get("name")
.and_then(Value::as_str)
.or_else(|| entry.get("serviceDescription").and_then(Value::as_str))
.unwrap_or("service");
let description = entry
.get("serviceDescription")
.and_then(Value::as_str)
.unwrap_or("No description");
A2AAgentSkill {
id: slugify(name),
name: name.to_string(),
description: description.to_string(),
tags: vec!["jacs".to_string(), slugify(name)],
..A2AAgentSkill::default()
}
})
.collect::<Vec<_>>();
if out.is_empty() {
out.push(A2AAgentSkill {
id: "verify-signature".to_string(),
name: "verify_signature".to_string(),
description: "Verify JACS document signatures".to_string(),
tags: vec![
"jacs".to_string(),
"verification".to_string(),
"cryptography".to_string(),
],
..A2AAgentSkill::default()
});
}
out
}
fn canonical_artifact_json<P: JacsProvider>(
client: &HaiClient<P>,
wrapped: &A2AWrappedArtifact,
) -> Result<String> {
let mut clone = wrapped.clone();
clone.jacs_signature = None;
let value = serde_json::to_value(clone)?;
client.canonical_json(&value)
}
fn has_jacs_extension(card: &Value) -> bool {
card.get("capabilities")
.and_then(Value::as_object)
.and_then(|caps| caps.get("extensions"))
.and_then(Value::as_array)
.map(|extensions| {
extensions.iter().any(|ext| {
ext.get("uri")
.and_then(Value::as_str)
.map(|uri| uri == A2A_JACS_EXTENSION_URI)
.unwrap_or(false)
})
})
.unwrap_or(false)
}
fn extract_card_agent_id(card: &Value) -> Option<String> {
card.get("metadata")
.and_then(Value::as_object)
.and_then(|meta| meta.get("jacsId"))
.and_then(Value::as_str)
.map(ToString::to_string)
}
fn extract_remote_agent_card_json(event: &Value) -> Result<Option<String>> {
for key in ["remoteAgentCard", "agentCard"] {
let Some(card) = event.get(key) else {
continue;
};
return match card {
Value::String(raw) => Ok(Some(raw.clone())),
Value::Object(_) => Ok(Some(serde_json::to_string(card)?)),
_ => Err(HaiError::Message(format!(
"{key} must be a JSON object or string"
))),
};
}
Ok(None)
}
fn extract_inbound_a2a_task(event: &Value) -> Result<Option<A2AWrappedArtifact>> {
for key in ["a2aTask", "a2a_task"] {
let Some(task) = event.get(key) else {
continue;
};
return match task {
Value::String(raw) => Ok(Some(serde_json::from_str(raw)?)),
Value::Object(_) => Ok(Some(serde_json::from_value(task.clone())?)),
_ => Err(HaiError::Message(format!(
"{key} must be a JSON object or string"
))),
};
}
Ok(None)
}
fn merge_agent_json_with_card(agent_json: &str, card: &A2AAgentCard) -> Result<String> {
if agent_json.trim().is_empty() {
return Err(HaiError::Message("agent_json is required".to_string()));
}
let mut agent_doc: Value = serde_json::from_str(agent_json)?;
let Some(agent_obj) = agent_doc.as_object_mut() else {
return Err(HaiError::Message(
"agent_json must be a JSON object".to_string(),
));
};
let card_value = serde_json::to_value(card)?;
agent_obj.insert("a2aAgentCard".to_string(), card_value);
if !agent_obj.contains_key("skills") && !card.skills.is_empty() {
agent_obj.insert("skills".to_string(), serde_json::to_value(&card.skills)?);
}
if !agent_obj.contains_key("capabilities") {
agent_obj.insert(
"capabilities".to_string(),
serde_json::to_value(&card.capabilities)?,
);
}
let mut metadata = agent_obj
.remove("metadata")
.and_then(|m| m.as_object().cloned())
.unwrap_or_default();
metadata.insert(
"a2aProfile".to_string(),
Value::String(resolve_card_profile(card)),
);
metadata.insert(
"a2aSkillsCount".to_string(),
Value::Number(serde_json::Number::from(card.skills.len())),
);
agent_obj.insert("metadata".to_string(), Value::Object(metadata));
Ok(serde_json::to_string(&agent_doc)?)
}
fn resolve_card_profile(card: &A2AAgentCard) -> String {
if let Some(profile) = card
.metadata
.get("a2aProfile")
.and_then(Value::as_str)
.map(ToString::to_string)
{
return profile;
}
if let Some(profile) = card
.protocol_versions
.iter()
.find(|v| !v.trim().is_empty())
.cloned()
{
return profile;
}
if let Some(profile) = card
.supported_interfaces
.iter()
.find_map(|iface| iface.protocol_version.clone())
.filter(|v| !v.trim().is_empty())
{
return profile;
}
A2A_PROTOCOL_VERSION_04.to_string()
}
fn format_rfc3339_now() -> Result<String> {
OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.map_err(|e| HaiError::Message(format!("failed to format timestamp: {e}")))
}
fn slugify(input: &str) -> String {
let lowered = input.trim().to_lowercase();
let replaced = lowered.replace([' ', '_'], "-");
let mut out = String::new();
for ch in replaced.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' {
out.push(ch);
}
}
if out.is_empty() {
"skill".to_string()
} else {
out
}
}
fn value_string_opt<'a>(value: &'a Value, paths: &[&str]) -> Option<&'a str> {
for path in paths {
if let Some(found) = value.get(path).and_then(Value::as_str) {
if !found.is_empty() {
return Some(found);
}
}
}
None
}
fn value_string<'a>(value: &'a Value, paths: &[&str]) -> &'a str {
value_string_opt(value, paths).unwrap_or("")
}
trait IfEmpty {
fn if_empty_then<'a>(&'a self, fallback: &'a str) -> &'a str;
}
impl IfEmpty for str {
fn if_empty_then<'a>(&'a self, fallback: &'a str) -> &'a str {
if self.trim().is_empty() {
fallback
} else {
self
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_keeps_ascii_and_dashes() {
assert_eq!(slugify("Sign Response"), "sign-response");
assert_eq!(slugify("a_b$c"), "a-bc");
}
#[test]
fn resolve_profile_prefers_metadata() {
let mut metadata = Map::new();
metadata.insert("a2aProfile".to_string(), Value::String("1.0".to_string()));
let card = A2AAgentCard {
metadata,
protocol_versions: vec!["0.4.0".to_string()],
..A2AAgentCard::default()
};
assert_eq!(resolve_card_profile(&card), "1.0");
}
#[test]
fn merge_agent_json_with_card_adds_metadata() {
let card = A2AAgentCard {
skills: vec![A2AAgentSkill {
id: "k".to_string(),
name: "n".to_string(),
description: "d".to_string(),
tags: vec!["jacs".to_string()],
..A2AAgentSkill::default()
}],
supported_interfaces: vec![A2AAgentInterface {
url: "https://agent.example.com".to_string(),
protocol_binding: "jsonrpc".to_string(),
protocol_version: Some("1.0".to_string()),
tenant: None,
}],
..A2AAgentCard::default()
};
let merged =
merge_agent_json_with_card(r#"{"jacsId":"demo-agent"}"#, &card).expect("merged");
let merged_json: Value = serde_json::from_str(&merged).expect("decode");
assert!(merged_json.get("a2aAgentCard").is_some());
assert_eq!(merged_json["metadata"]["a2aProfile"], "1.0");
assert_eq!(merged_json["metadata"]["a2aSkillsCount"], 1);
}
#[test]
fn extract_inbound_task_from_object() {
let event = json!({
"a2aTask": {
"jacsId": "task-1",
"jacsVersion": "1.0.0",
"jacsType": "a2a-task",
"jacsLevel": "artifact",
"jacsVersionDate": "2026-02-24T00:00:00Z",
"a2aArtifact": {"k":"v"},
"jacsSignature": {
"agentID": "demo-agent",
"date": "2026-02-24T00:00:00Z",
"signature": "sig"
}
}
});
let task = extract_inbound_a2a_task(&event)
.expect("extract")
.expect("task");
assert_eq!(task.jacs_type, "a2a-task");
}
#[test]
fn extract_remote_card_from_object() {
let event = json!({
"remoteAgentCard": {
"name": "remote",
"metadata": {"jacsId":"remote-agent"}
}
});
let raw = extract_remote_agent_card_json(&event)
.expect("extract")
.expect("card");
let card: Value = serde_json::from_str(&raw).expect("decode");
assert_eq!(card["metadata"]["jacsId"], "remote-agent");
}
}