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>,
http: reqwest::Client,
endpoint: Endpoint,
}
impl A2aClient {
#[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> {
let http = reqwest::Client::builder()
.timeout(endpoint.timeout)
.build()
.map_err(|e| PeerError::Unreachable {
peer: PeerId::new("<local>"),
detail: format!("could not build an HTTP client: {e}"),
})?;
Ok(Self {
egress: None,
http,
endpoint,
})
}
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": { 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),
})
}
async fn rpc(
&self,
peer: &PeerId,
body: &Value,
credential: Option<&PeerCredential>,
) -> Result<Value, PeerError> {
if let Some(egress) = &self.egress {
let host = reqwest::Url::parse(&self.endpoint.url)
.ok()
.and_then(|url| url.host_str().map(ToOwned::to_owned));
if let Err(error) = egress.permits(host.as_deref()) {
return Err(PeerError::Refused {
peer: peer.clone(),
detail: error.to_string(),
});
}
}
let mut request = self
.http
.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 parsed: Result<RpcResponse, _> = response.json().await;
let Ok(rpc) = parsed 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,
}
#[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 => PeerError::Refused {
peer: peer.clone(),
detail: format!("{detail} — the peer is at a ceiling; come back"),
},
_ => 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)
}
}
fn insert_tenant(params: &mut serde_json::Map<String, Value>, tenant: Option<&str>) {
if let Some(tenant) = tenant {
params.insert("tenant".into(), json!(tenant));
}
}