#[cfg(test)]
mod tests;
use std::sync::atomic::{AtomicI64, Ordering};
use a2a_lf::{
AgentCard, AgentInterface, CancelTaskRequest, GetTaskRequest, JsonRpcId, JsonRpcRequest,
JsonRpcResponse, Message, SendMessageConfiguration, SendMessageRequest, SendMessageResponse,
TRANSPORT_PROTOCOL_JSONRPC, Task, methods,
};
use futures_util::StreamExt;
use reqwest::StatusCode;
use reqwest::header::{ACCEPT, HeaderMap, HeaderName, HeaderValue};
use serde::Serialize;
use serde::de::DeserializeOwned;
use url::Url;
use super::config::{A2aAgentConfig, A2aConfigError, A2aLimits, check_url};
use super::error::{A2aError, A2aRpcError};
pub const A2A_PROTOCOL_VERSION: &str = "1.0";
const VERSION_HEADER: &str = "a2a-version";
pub struct A2aClient {
name: String,
http: reqwest::Client,
card_url: Url,
card: AgentCard,
endpoint: Url,
tenant: Option<String>,
headers: HeaderMap,
limits: A2aLimits,
next_id: AtomicI64,
}
impl A2aClient {
pub async fn connect(config: &A2aAgentConfig) -> Result<Self, A2aError> {
let card_url = config.validate()?;
let headers = build_headers(config)?;
let http = build_http_client(&config.limits)?;
let request = http
.get(card_url.clone())
.headers(headers.clone())
.header(ACCEPT, "application/json");
let (status, body) = exchange(request, &config.limits).await?;
if !status.is_success() {
return Err(A2aError::HttpStatus {
operation: "the agent card request",
status,
});
}
let card: AgentCard = serde_json::from_slice(&body)
.map_err(|_| A2aError::InvalidCard("it is not a JSON Agent Card"))?;
if card.name.trim().is_empty() {
return Err(A2aError::InvalidCard("it has no name"));
}
let interface = select_interface(&card).ok_or(A2aError::NoCompatibleInterface)?;
let endpoint = check_url(&interface.url).map_err(A2aError::InvalidEndpoint)?;
if config.refuses_plaintext_to(&endpoint) {
return Err(A2aError::PlaintextEndpoint);
}
let tenant = interface.tenant.clone().filter(|tenant| !tenant.is_empty());
Ok(Self {
name: config.name.clone(),
http,
card_url,
card,
endpoint,
tenant,
headers,
limits: config.limits.clone(),
next_id: AtomicI64::new(1),
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn card(&self) -> &AgentCard {
&self.card
}
pub fn card_url(&self) -> &Url {
&self.card_url
}
pub fn endpoint(&self) -> &Url {
&self.endpoint
}
pub fn limits(&self) -> &A2aLimits {
&self.limits
}
pub async fn send_message(
&self,
message: Message,
configuration: SendMessageConfiguration,
) -> Result<SendMessageResponse, A2aError> {
let task_id = message.task_id.clone();
let request = SendMessageRequest {
message,
configuration: Some(configuration),
metadata: None,
tenant: self.tenant.clone(),
};
let response: SendMessageResponse = self
.call(methods::SEND_MESSAGE, &request)
.await
.map_err(send_failure)?;
if let (Some(expected), SendMessageResponse::Task(task)) = (&task_id, &response)
&& task.id != *expected
{
return Err(A2aError::MismatchedTask {
method: methods::SEND_MESSAGE,
});
}
Ok(response)
}
pub async fn get_task(
&self,
task_id: &str,
history_length: Option<i32>,
) -> Result<Task, A2aError> {
let request = GetTaskRequest {
id: task_id.to_string(),
history_length,
tenant: self.tenant.clone(),
};
let task: Task = self.call(methods::GET_TASK, &request).await?;
expect_task(methods::GET_TASK, task_id, task)
}
pub async fn cancel_task(&self, task_id: &str) -> Result<Task, A2aError> {
let request = CancelTaskRequest {
id: task_id.to_string(),
metadata: None,
tenant: self.tenant.clone(),
};
let task: Task = self.call(methods::CANCEL_TASK, &request).await?;
expect_task(methods::CANCEL_TASK, task_id, task)
}
async fn call<P: Serialize, R: DeserializeOwned>(
&self,
method: &'static str,
params: &P,
) -> Result<R, A2aError> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let params = serde_json::to_value(params).map_err(|_| A2aError::Decode { method })?;
let request = JsonRpcRequest::new(JsonRpcId::Number(id), method, Some(params));
let builder = self
.http
.post(self.endpoint.clone())
.headers(self.headers.clone())
.header(ACCEPT, "application/json")
.json(&request);
let (status, body) = exchange(builder, &self.limits).await?;
let Ok(reply) = serde_json::from_slice::<JsonRpcResponse>(&body) else {
if !status.is_success() {
return Err(A2aError::HttpStatus {
operation: method,
status,
});
}
return Err(A2aError::Decode { method });
};
if let Some(error) = reply.error {
if reply.id != JsonRpcId::Number(id) && reply.id != JsonRpcId::Null {
return Err(A2aError::MismatchedReplyId { method });
}
return Err(A2aError::Rpc {
method,
error: A2aRpcError::from_code(error.code),
});
}
if reply.id != JsonRpcId::Number(id) {
return Err(A2aError::MismatchedReplyId { method });
}
if !status.is_success() {
return Err(A2aError::HttpStatus {
operation: method,
status,
});
}
let result = reply.result.ok_or(A2aError::Decode { method })?;
serde_json::from_value(result).map_err(|_| A2aError::Decode { method })
}
}
impl std::fmt::Debug for A2aClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("A2aClient")
.field("name", &self.name)
.field("card_url", &self.card_url.as_str())
.field("endpoint", &self.endpoint.as_str())
.finish_non_exhaustive()
}
}
fn select_interface(card: &AgentCard) -> Option<&AgentInterface> {
card.supported_interfaces.iter().find(|interface| {
interface
.protocol_binding
.eq_ignore_ascii_case(TRANSPORT_PROTOCOL_JSONRPC)
&& speaks_version(&interface.protocol_version)
})
}
fn speaks_version(declared: &str) -> bool {
let mut parts = declared.trim().split('.');
let major = parts.next().and_then(|part| part.parse::<u32>().ok());
let minor = match parts.next() {
None => Some(0),
Some(part) => part.parse::<u32>().ok(),
};
major == Some(1) && minor == Some(0)
}
fn expect_task(method: &'static str, task_id: &str, task: Task) -> Result<Task, A2aError> {
if task.id != task_id {
return Err(A2aError::MismatchedTask { method });
}
Ok(task)
}
fn send_failure(error: A2aError) -> A2aError {
match &error {
A2aError::HttpStatus { status, .. } if status.is_client_error() => error,
A2aError::Connect | A2aError::Config(_) | A2aError::Rpc { .. } => error,
_ => A2aError::SendIndeterminate,
}
}
async fn exchange(
request: reqwest::RequestBuilder,
limits: &A2aLimits,
) -> Result<(StatusCode, Vec<u8>), A2aError> {
let operation = async {
let response = request.send().await.map_err(transport_error)?;
let status = response.status();
if status.is_redirection() {
return Err(A2aError::RedirectRefused);
}
let body = read_bounded(response, limits.max_response_bytes).await?;
Ok((status, body))
};
tokio::time::timeout(limits.request_timeout, operation)
.await
.map_err(|_| A2aError::Timeout(limits.request_timeout))?
}
async fn read_bounded(response: reqwest::Response, limit: usize) -> Result<Vec<u8>, A2aError> {
if response
.content_length()
.is_some_and(|length| length > limit as u64)
{
return Err(A2aError::ResponseTooLarge { limit });
}
let mut body = response.bytes_stream();
let mut buffer = Vec::new();
while let Some(chunk) = body.next().await {
let chunk = chunk.map_err(|_| A2aError::Transport("the reply body could not be read"))?;
if buffer.len().saturating_add(chunk.len()) > limit {
return Err(A2aError::ResponseTooLarge { limit });
}
buffer.extend_from_slice(&chunk);
}
Ok(buffer)
}
fn build_http_client(limits: &A2aLimits) -> Result<reqwest::Client, A2aError> {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.retry(reqwest::retry::never())
.connect_timeout(limits.connect_timeout)
.build()
.map_err(|_| A2aError::Transport("the HTTP client could not be built"))
}
fn build_headers(config: &A2aAgentConfig) -> Result<HeaderMap, A2aError> {
let mut headers = HeaderMap::new();
for (name, value) in &config.headers {
let header_name = HeaderName::try_from(name.as_str())
.map_err(|_| A2aConfigError::InvalidHeaderName { name: name.clone() })?;
let mut header_value = HeaderValue::try_from(value.expose_secret())
.map_err(|_| A2aConfigError::InvalidHeaderValue { name: name.clone() })?;
header_value.set_sensitive(true);
headers.insert(header_name, header_value);
}
headers.insert(
HeaderName::from_static(VERSION_HEADER),
HeaderValue::from_static(A2A_PROTOCOL_VERSION),
);
Ok(headers)
}
fn transport_error(error: reqwest::Error) -> A2aError {
if error.is_connect() {
A2aError::Connect
} else if error.is_timeout() {
A2aError::Transport("the request timed out")
} else if error.is_request() {
A2aError::Transport("the request could not be sent")
} else {
A2aError::Transport("the connection failed")
}
}