mod binding;
mod card;
mod http;
mod jsonrpc;
mod push;
mod rest;
mod sse;
#[cfg(test)]
mod tests;
use std::sync::atomic::{AtomicI64, Ordering};
use a2a_lf::{
AgentCard, CancelTaskRequest, GetExtendedAgentCardRequest, GetTaskRequest, ListTasksRequest,
ListTasksResponse, Message, SendMessageConfiguration, SendMessageRequest, SendMessageResponse,
SubscribeToTaskRequest, Task, methods,
};
use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderName};
use reqwest::{Method, StatusCode};
use serde::de::DeserializeOwned;
use serde_json::Value;
use url::Url;
use super::config::{A2aAgentConfig, A2aLimits, A2aUrlError, check_url};
use super::error::A2aError;
use binding::{Operation, checked_id, is_dot_segment, select_interface};
use sse::{Decoder, EVENT_STREAM, EventFormat};
pub use binding::A2aBinding;
pub use sse::A2aEventStream;
pub const A2A_PROTOCOL_VERSION: &str = "1.0";
const EXTENSIONS_HEADER: &str = "a2a-extensions";
const JSON: &str = "application/json";
pub struct A2aClient {
name: String,
card_url: Url,
card: AgentCard,
extended_card: A2aExtendedCardStatus,
transport: Transport,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum A2aExtendedCardStatus {
NotDeclared,
NoCredentials,
InUse,
Unavailable(A2aError),
}
struct Transport {
http: reqwest::Client,
binding: A2aBinding,
endpoint: Url,
tenant: Option<String>,
refuses_credentials_in_clear: bool,
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 = http::build_headers(config)?;
let http = http::build_http_client(&config.limits)?;
let request = http
.get(card_url.clone())
.headers(headers.clone())
.header(ACCEPT, JSON);
let (status, body) = http::exchange(request, &config.limits).await?;
if !status.is_success() {
return Err(A2aError::HttpStatus {
operation: "the agent card request",
status,
});
}
let card = serde_json::from_slice(&body)
.map_err(|_| A2aError::InvalidCard("it is not a JSON Agent Card"))
.and_then(card::decode)?;
let (binding, 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());
if tenant.as_deref().is_some_and(is_dot_segment) {
return Err(A2aError::InvalidEndpoint(A2aUrlError::Malformed));
}
let refuses_credentials_in_clear = config.refuses_credentials_in_clear_to(&endpoint);
let declares_extended = card.capabilities.extended_agent_card == Some(true);
let mut client = Self {
name: config.name.clone(),
card_url,
card,
extended_card: A2aExtendedCardStatus::NotDeclared,
transport: Transport {
http,
binding,
endpoint,
tenant,
refuses_credentials_in_clear,
headers,
limits: config.limits.clone(),
next_id: AtomicI64::new(1),
},
};
if declares_extended {
client.extended_card = client.adopt_extended_card(config).await;
}
Ok(client)
}
async fn adopt_extended_card(&mut self, config: &A2aAgentConfig) -> A2aExtendedCardStatus {
if config.headers.is_empty() {
return A2aExtendedCardStatus::NoCredentials;
}
match self.get_extended_card().await {
Ok(card) => {
self.card = card;
A2aExtendedCardStatus::InUse
}
Err(error) => A2aExtendedCardStatus::Unavailable(error),
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn card(&self) -> &AgentCard {
&self.card
}
pub fn extended_card_status(&self) -> &A2aExtendedCardStatus {
&self.extended_card
}
pub fn card_url(&self) -> &Url {
&self.card_url
}
pub fn binding(&self) -> A2aBinding {
self.transport.binding
}
pub fn endpoint(&self) -> &Url {
&self.transport.endpoint
}
pub fn tenant(&self) -> Option<&str> {
self.transport.tenant.as_deref()
}
pub fn limits(&self) -> &A2aLimits {
&self.transport.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.transport.tenant.clone(),
};
let operation = Operation::new(
methods::SEND_MESSAGE,
&request,
Method::POST,
segments(["message:send"]),
)?
.activating(request.message.extensions.as_deref().unwrap_or_default())?;
let response: SendMessageResponse =
self.transport.call(operation).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 send_streaming_message(
&self,
message: Message,
configuration: SendMessageConfiguration,
) -> Result<A2aEventStream, A2aError> {
let task_id = message.task_id.clone();
let request = SendMessageRequest {
message,
configuration: Some(configuration),
metadata: None,
tenant: self.transport.tenant.clone(),
};
let operation = Operation::new(
methods::SEND_STREAMING_MESSAGE,
&request,
Method::POST,
segments(["message:stream"]),
)?
.activating(request.message.extensions.as_deref().unwrap_or_default())?;
self.transport
.open_stream(operation, task_id)
.await
.map_err(send_failure)
}
pub async fn subscribe_to_task(&self, task_id: &str) -> Result<A2aEventStream, A2aError> {
let method = methods::SUBSCRIBE_TO_TASK;
checked_id(method, task_id)?;
let request = SubscribeToTaskRequest {
id: task_id.to_string(),
tenant: self.transport.tenant.clone(),
};
let subscribe = |verb| {
let path = segments(["tasks", &format!("{task_id}:subscribe")]);
Operation::new(method, &request, verb, path)
};
let expected = Some(task_id.to_string());
match self
.transport
.open_stream(subscribe(Method::GET)?, expected.clone())
.await
{
Err(A2aError::HttpStatus { status, .. })
if status == StatusCode::METHOD_NOT_ALLOWED
&& self.transport.binding == A2aBinding::HttpJson =>
{
self.transport
.open_stream(subscribe(Method::POST)?, expected)
.await
}
opened => opened,
}
}
pub async fn get_task(
&self,
task_id: &str,
history_length: Option<i32>,
) -> Result<Task, A2aError> {
checked_id(methods::GET_TASK, task_id)?;
let request = GetTaskRequest {
id: task_id.to_string(),
history_length,
tenant: self.transport.tenant.clone(),
};
let operation = Operation::new(
methods::GET_TASK,
&request,
Method::GET,
segments(["tasks", task_id]),
)?
.query("historyLength", history_length);
let task: Task = self.transport.call(operation).await?;
expect_task(methods::GET_TASK, task_id, task)
}
pub async fn list_tasks(
&self,
mut request: ListTasksRequest,
) -> Result<ListTasksResponse, A2aError> {
request.tenant = self.transport.tenant.clone();
let status = request
.status
.as_ref()
.and_then(|status| serde_json::to_value(status).ok())
.and_then(|status| status.as_str().map(str::to_string));
let operation = Operation::new(
methods::LIST_TASKS,
&request,
Method::GET,
segments(["tasks"]),
)?
.query("contextId", request.context_id.as_ref())
.query("status", status)
.query("pageSize", request.page_size)
.query("pageToken", request.page_token.as_ref())
.query("historyLength", request.history_length)
.query(
"statusTimestampAfter",
request
.status_timestamp_after
.map(|after| after.to_rfc3339()),
)
.query("includeArtifacts", request.include_artifacts);
let mut listed = self.transport.call_value(operation).await?;
if let Value::Object(fields) = &mut listed {
for (name, default) in [
("tasks", Value::Array(Vec::new())),
("nextPageToken", Value::String(String::new())),
("pageSize", Value::from(0)),
("totalSize", Value::from(0)),
] {
fields.entry(name).or_insert(default);
}
}
serde_json::from_value(listed).map_err(|_| A2aError::Decode {
method: methods::LIST_TASKS,
})
}
pub async fn get_extended_card(&self) -> Result<AgentCard, A2aError> {
let request = GetExtendedAgentCardRequest {
tenant: self.transport.tenant.clone(),
};
let operation = Operation::new(
methods::GET_EXTENDED_AGENT_CARD,
&request,
Method::GET,
segments(["extendedAgentCard"]),
)?;
let card: Value = self.transport.call(operation).await?;
card::decode(card)
}
pub async fn cancel_task(&self, task_id: &str) -> Result<Task, A2aError> {
checked_id(methods::CANCEL_TASK, task_id)?;
let request = CancelTaskRequest {
id: task_id.to_string(),
metadata: None,
tenant: self.transport.tenant.clone(),
};
let operation = Operation::new(
methods::CANCEL_TASK,
&request,
Method::POST,
segments(["tasks", &format!("{task_id}:cancel")]),
)?;
let task: Task = self.transport.call(operation).await?;
expect_task(methods::CANCEL_TASK, task_id, task)
}
}
impl Transport {
fn operation_headers(&self, operation: &Operation) -> HeaderMap {
let mut headers = self.headers.clone();
if let Some(extensions) = &operation.extensions {
headers.insert(
HeaderName::from_static(EXTENSIONS_HEADER),
extensions.clone(),
);
}
headers
}
async fn call<R: DeserializeOwned>(&self, operation: Operation) -> Result<R, A2aError> {
let method = operation.method;
let value = self.call_value(operation).await?;
serde_json::from_value(value).map_err(|_| A2aError::Decode { method })
}
async fn call_value(&self, operation: Operation) -> Result<Value, A2aError> {
match self.binding {
A2aBinding::JsonRpc => {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let request = self.jsonrpc_request(&operation, id, JSON);
let (status, body) = http::exchange(request, &self.limits).await?;
jsonrpc::decode_reply(operation.method, id, status, &body)
}
A2aBinding::HttpJson => {
let request = self.rest_request(&operation, JSON)?;
let (status, body) = http::exchange(request, &self.limits).await?;
rest::decode_reply(operation.method, status, &body)
}
}
}
}
impl Transport {
async fn open_stream(
&self,
operation: Operation,
expected_task: Option<String>,
) -> Result<A2aEventStream, A2aError> {
let method = operation.method;
let (request, format) = match self.binding {
A2aBinding::JsonRpc => {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let request = self.jsonrpc_request(&operation, id, EVENT_STREAM);
(request, EventFormat::JsonRpc { id })
}
A2aBinding::HttpJson => (
self.rest_request(&operation, EVENT_STREAM)?,
EventFormat::HttpJson,
),
};
let timeout = self.limits.request_timeout;
let deadline = tokio::time::Instant::now() + timeout;
let response = tokio::time::timeout_at(deadline, http::send(request))
.await
.map_err(|_| A2aError::Timeout(timeout))??;
let status = response.status();
if status.is_success() && is_event_stream(&response) {
let decoder = Decoder { method, format };
return Ok(A2aEventStream::new(
response,
decoder,
expected_task,
&self.limits,
));
}
let read = http::read_bounded(response, self.limits.max_response_bytes);
let body = tokio::time::timeout_at(deadline, read)
.await
.map_err(|_| A2aError::Timeout(timeout))??;
match format {
EventFormat::JsonRpc { id } => jsonrpc::decode_reply(method, id, status, &body),
EventFormat::HttpJson => rest::decode_reply(method, status, &body),
}?;
Err(A2aError::NotAStream { 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("binding", &self.transport.binding)
.field("endpoint", &self.transport.endpoint.as_str())
.finish_non_exhaustive()
}
}
fn is_event_stream(response: &reqwest::Response) -> bool {
response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.trim_start()
.get(..EVENT_STREAM.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(EVENT_STREAM))
})
}
fn segments<const N: usize>(parts: [&str; N]) -> Vec<String> {
parts.into_iter().map(str::to_string).collect()
}
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 { .. }
| A2aError::InvalidExtensionUri => error,
_ => A2aError::SendIndeterminate,
}
}