use std::time::Duration;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::core::{Delegation, Digest, canon};
use super::{PeerClient, PeerCredential, PeerError, PeerId};
#[derive(Debug, Clone)]
pub struct Endpoint {
pub url: String,
pub timeout: Duration,
pub tenant: Option<String>,
}
impl Endpoint {
pub const DEFAULT_TIMEOUT: Duration = Duration::from_mins(2);
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
timeout: Self::DEFAULT_TIMEOUT,
tenant: None,
}
}
#[must_use]
pub fn for_tenant(mut self, tenant: impl Into<String>) -> Self {
self.tenant = Some(tenant.into());
self
}
#[must_use]
pub const fn timeout(mut self, d: Duration) -> Self {
self.timeout = d;
self
}
}
pub const EXTENSION_URI: &str = "https://hupe1980.github.io/agentplane/a2a/delegation/v1";
pub use super::PROTOCOL_VERSION;
#[derive(Debug)]
pub struct A2aClient {
egress: Option<crate::core::Egress>,
endpoint: Endpoint,
http: std::sync::OnceLock<reqwest::Client>,
#[cfg(feature = "testkit")]
loopback: bool,
}
impl A2aClient {
#[cfg(feature = "testkit")]
#[must_use]
pub const fn allow_loopback(mut self) -> Self {
self.loopback = true;
self
}
#[must_use]
pub fn egress(mut self, egress: crate::core::Egress) -> Self {
self.egress = Some(egress);
self
}
pub fn new(endpoint: Endpoint) -> Result<Self, PeerError> {
Ok(Self {
egress: None,
endpoint,
http: std::sync::OnceLock::new(),
#[cfg(feature = "testkit")]
loopback: false,
})
}
fn body(
capability: &str,
payload: &Value,
acting_as: &Delegation,
provenance: Option<&crate::core::Provenance>,
tenant: Option<&str>,
) -> Value {
let attested = provenance.map(|p| Value::Object(p.to_meta()));
let message_id = provenance.map_or_else(
|| {
let claim = json!({
"capability": capability,
"payload": payload,
"chain": acting_as,
});
format!("msg-{}", Digest::of(&canon::value_bytes(&claim)))
},
|p| p.dedupe_key().to_string(),
);
let mut governance = serde_json::Map::new();
governance.insert("capability".into(), json!(capability));
governance.insert("chain".into(), json!(acting_as));
if let Some(attested) = attested {
governance.insert("provenance".into(), json!(attested));
}
let mut params = serde_json::Map::new();
insert_tenant(&mut params, tenant);
params.insert(
"message".into(),
json!({
"role": "ROLE_USER",
"messageId": message_id,
"parts": [{ "data": payload, "mediaType": "application/json" }],
"extensions": [EXTENSION_URI],
"metadata": {
"skill": capability,
EXTENSION_URI: Value::Object(governance),
}
}),
);
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "SendMessage",
"params": Value::Object(params),
})
}
fn get_task_body(task_id: &str, tenant: Option<&str>) -> Value {
let mut params = serde_json::Map::new();
insert_tenant(&mut params, tenant);
params.insert("id".into(), json!(task_id));
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "GetTask",
"params": Value::Object(params),
})
}
fn cancel_task_body(task_id: &str, tenant: Option<&str>) -> Value {
let mut params = serde_json::Map::new();
insert_tenant(&mut params, tenant);
params.insert("id".into(), json!(task_id));
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "CancelTask",
"params": Value::Object(params),
})
}
fn reach(&self) -> crate::netguard::Reach {
if self.loopback_allowed() {
crate::netguard::Reach::PublicOrLoopbackName
} else {
crate::netguard::Reach::Public
}
}
async fn approve(
&self,
peer: &PeerId,
host: &str,
parsed: &reqwest::Url,
) -> Result<(), PeerError> {
let lookup = host
.strip_prefix('[')
.and_then(|inner| inner.strip_suffix(']'))
.unwrap_or(host)
.to_owned();
let port = parsed.port_or_known_default().unwrap_or(443);
let resolved = tokio::net::lookup_host((lookup.as_str(), port))
.await
.map_err(|error| PeerError::Unreachable {
peer: peer.clone(),
detail: format!("DNS for '{host}': {error}"),
})?;
crate::netguard::judge(self.reach(), host, resolved).map_err(|error| {
PeerError::Refused {
peer: peer.clone(),
detail: error.to_string(),
}
})?;
Ok(())
}
fn http(&self, peer: &PeerId) -> Result<&reqwest::Client, PeerError> {
if let Some(client) = self.http.get() {
return Ok(client);
}
let client = crate::netguard::guarded_client(self.reach())
.timeout(self.endpoint.timeout)
.build()
.map_err(|error| PeerError::Unreachable {
peer: peer.clone(),
detail: error.to_string(),
})?;
Ok(self.http.get_or_init(|| client))
}
#[allow(clippy::unused_self)]
const fn loopback_allowed(&self) -> bool {
#[cfg(feature = "testkit")]
{
self.loopback
}
#[cfg(not(feature = "testkit"))]
{
false
}
}
async fn rpc(
&self,
peer: &PeerId,
body: &Value,
credential: Option<&PeerCredential>,
) -> Result<Value, PeerError> {
let parsed =
reqwest::Url::parse(&self.endpoint.url).map_err(|error| PeerError::Refused {
peer: peer.clone(),
detail: format!("the peer endpoint is not a URL: {error}"),
})?;
let host = parsed
.host_str()
.ok_or_else(|| PeerError::Refused {
peer: peer.clone(),
detail: "the peer endpoint names no host".to_owned(),
})?
.to_owned();
if parsed.scheme() != "https"
&& !(self.loopback_allowed()
&& crate::netguard::is_loopback_name(&host.to_ascii_lowercase()))
{
return Err(PeerError::Refused {
peer: peer.clone(),
detail: format!(
"the peer endpoint '{}' is not https — a bearer credential and the \
run's payload must not cross the network in cleartext",
self.endpoint.url
),
});
}
if let Some(egress) = &self.egress
&& let Err(error) = egress.permits(Some(host.as_str()))
{
return Err(PeerError::Refused {
peer: peer.clone(),
detail: error.to_string(),
});
}
self.approve(peer, &host, &parsed).await?;
let client = self.http(peer)?;
let mut request = client
.post(&self.endpoint.url)
.header("A2A-Version", PROTOCOL_VERSION)
.header("A2A-Extensions", EXTENSION_URI)
.json(body);
if let Some(credential) = credential {
request = request.bearer_auth(credential.expose());
}
let response = request
.send()
.await
.map_err(|error| classify_transport(peer, &error))?;
let status = response.status();
let body = crate::netguard::intake::read(response, crate::netguard::intake::ANSWER).await;
let Ok(body) = body else {
return Err(classify_status(peer, status));
};
let Ok(rpc) = serde_json::from_slice::<RpcResponse>(&body) else {
return Err(classify_status(peer, status));
};
if !status.is_success() && rpc.jsonrpc.is_none() {
return Err(classify_status(peer, status));
}
if rpc.jsonrpc.as_deref() != Some("2.0") || rpc.id.as_ref() != Some(&json!(1)) {
return Err(invalid_response(
peer,
"JSON-RPC response has the wrong version or does not correlate to request id 1",
));
}
let result = match (rpc.result, rpc.error) {
(Some(_), Some(_)) | (None, None) => {
return Err(invalid_response(
peer,
"JSON-RPC response must contain exactly one of 'result' or 'error'",
));
}
(None, Some(error)) => return Err(classify_rpc(peer, &error)),
(Some(result), None) => result,
};
if !status.is_success() {
return Err(classify_status(peer, status));
}
Ok(result)
}
}
#[derive(Debug, Deserialize)]
struct RpcError {
code: i64,
message: String,
#[serde(default)]
data: Option<Value>,
}
impl RpcError {
fn names_reason(&self, domain: &str, reason: &str) -> bool {
let Some(entries) = self.data.as_ref().and_then(Value::as_array) else {
return false;
};
entries.iter().any(|entry| {
entry.get("domain").and_then(Value::as_str) == Some(domain)
&& entry.get("reason").and_then(Value::as_str) == Some(reason)
})
}
}
#[derive(Debug, Deserialize)]
struct RpcResponse {
#[serde(default)]
jsonrpc: Option<String>,
#[serde(default)]
id: Option<Value>,
#[serde(default)]
result: Option<Value>,
#[serde(default)]
error: Option<RpcError>,
}
fn classify_rpc(peer: &PeerId, e: &RpcError) -> PeerError {
let detail = format!("{} (code {})", e.message, e.code);
match e.code {
-32700 | -32600 | -32601 | -32602 | -32005..=-32001 | -32009..=-32007 => {
PeerError::Refused {
peer: peer.clone(),
detail,
}
}
-32006 => PeerError::InvalidResponse {
peer: peer.clone(),
detail: format!("{detail} — the peer did not say whether it acted"),
},
-32029 if e.names_reason(super::ERROR_DOMAIN, super::QUOTA_EXHAUSTED_REASON) => {
PeerError::Refused {
peer: peer.clone(),
detail: format!("{detail} — the peer is at a ceiling; come back"),
}
}
-32030 if e.names_reason(super::ERROR_DOMAIN, super::HALTED_REASON) => PeerError::Refused {
peer: peer.clone(),
detail: format!("{detail} — the peer is halted by its operator; do not retry"),
},
_ => PeerError::InDoubt {
peer: peer.clone(),
detail: format!(
"{detail} — the peer answered with a fault and did not say whether it acted"
),
},
}
}
fn invalid_response(peer: &PeerId, detail: impl Into<String>) -> PeerError {
PeerError::InvalidResponse {
peer: peer.clone(),
detail: detail.into(),
}
}
fn send_message_result(peer: &PeerId, result: &Value) -> Result<Value, PeerError> {
let Some(object) = result.as_object() else {
return Err(invalid_response(
peer,
"SendMessage result is not an object containing exactly one of 'task' or 'message'",
));
};
match (object.get("task"), object.get("message")) {
(Some(task), None) if task.is_object() => Ok(task.clone()),
(None, Some(message)) if message.is_object() => Ok(message.clone()),
_ => Err(invalid_response(
peer,
"SendMessage result must contain exactly one object member named 'task' or 'message'",
)),
}
}
fn classify_status(peer: &PeerId, status: reqwest::StatusCode) -> PeerError {
if status.is_server_error() {
return PeerError::InDoubt {
peer: peer.clone(),
detail: format!(
"the peer answered HTTP {status} — a fault, not a decline, and it did not \
say whether it acted"
),
};
}
PeerError::Refused {
peer: peer.clone(),
detail: format!("HTTP {status}"),
}
}
fn classify_transport(peer: &PeerId, e: &reqwest::Error) -> PeerError {
if e.is_connect() {
return PeerError::Unreachable {
peer: peer.clone(),
detail: format!("could not connect: {e}"),
};
}
if e.is_timeout() {
return PeerError::TimedOut {
peer: peer.clone(),
detail: format!("timed out: {e}"),
};
}
if e.is_body() || e.is_decode() {
return PeerError::InDoubt {
peer: peer.clone(),
detail: format!("the peer answered, and the response could not be read: {e}"),
};
}
if e.is_request() {
return PeerError::InDoubt {
peer: peer.clone(),
detail: format!("the request failed in flight: {e}"),
};
}
PeerError::InDoubt {
peer: peer.clone(),
detail: e.to_string(),
}
}
fn task_failure(peer: &PeerId, result: &Value) -> Option<PeerError> {
let state = result.get("status")?.get("state")?.as_str()?;
match state {
"TASK_STATE_FAILED" | "TASK_STATE_CANCELED" | "TASK_STATE_REJECTED" => {
Some(PeerError::Failed {
peer: peer.clone(),
detail: result
.get("status")
.and_then(|s| s.get("message"))
.map_or_else(
|| format!("the peer returned task state {state}"),
std::string::ToString::to_string,
),
})
}
_ => None,
}
}
#[async_trait]
impl PeerClient for A2aClient {
async fn send(
&self,
peer: &PeerId,
capability: &str,
payload: &Value,
acting_as: &Delegation,
credential: Option<&PeerCredential>,
provenance: Option<&crate::core::Provenance>,
) -> Result<Value, PeerError> {
let result = self
.rpc(
peer,
&Self::body(
capability,
payload,
acting_as,
provenance,
self.endpoint.tenant.as_deref(),
),
credential,
)
.await?;
let result = send_message_result(peer, &result)?;
if let Some(failure) = task_failure(peer, &result) {
return Err(failure);
}
Ok(result)
}
async fn get_task(
&self,
peer: &PeerId,
task_id: &str,
credential: Option<&PeerCredential>,
) -> Result<Value, PeerError> {
let result = self
.rpc(
peer,
&Self::get_task_body(task_id, self.endpoint.tenant.as_deref()),
credential,
)
.await?;
if !result.is_object()
|| result.get("id").and_then(Value::as_str) != Some(task_id)
|| result
.get("status")
.and_then(|status| status.get("state"))
.and_then(Value::as_str)
.is_none()
{
return Err(invalid_response(
peer,
"GetTask result is not the requested Task object",
));
}
Ok(result)
}
async fn cancel_task(
&self,
peer: &PeerId,
task_id: &str,
credential: Option<&PeerCredential>,
) -> Result<Value, PeerError> {
let result = self
.rpc(
peer,
&Self::cancel_task_body(task_id, self.endpoint.tenant.as_deref()),
credential,
)
.await?;
if !result.is_object()
|| result.get("id").and_then(Value::as_str) != Some(task_id)
|| result
.get("status")
.and_then(|status| status.get("state"))
.and_then(Value::as_str)
.is_none()
{
return Err(invalid_response(
peer,
"CancelTask result is not the requested Task object",
));
}
Ok(result)
}
}
fn insert_tenant(params: &mut serde_json::Map<String, Value>, tenant: Option<&str>) {
if let Some(tenant) = tenant {
params.insert("tenant".into(), json!(tenant));
}
}