pub(crate) mod signing;
mod sse;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use super::protocol::{
A2AErrorData, A2AMessage, A2ARequest, A2AResponse, A2ATask, A2ATaskDetails, A2ATaskResult,
AgentCard, TaskStatus, TraceContext,
};
pub use signing::{
canonical_json, sign_agent_card, sign_card_jws, verify_card_jws, verify_card_signature,
};
pub use sse::A2ASseStream;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum A2AError {
#[error("HTTP error: {0}")]
Http(String),
#[error("Parse error: {0}")]
Parse(String),
#[error("API error [{code}]: {message}")]
Api {
code: i32,
message: String,
},
#[error("Timeout: {0}")]
Timeout(String),
#[error("Agent card signature: {0}")]
Signature(String),
#[error("Task {task_id} requires more input: {prompt}")]
InputRequired {
task_id: String,
prompt: String,
},
}
impl From<reqwest::Error> for A2AError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() {
A2AError::Timeout(err.to_string())
} else {
A2AError::Http(err.to_string())
}
}
}
impl From<A2AErrorData> for A2AError {
fn from(err: A2AErrorData) -> Self {
A2AError::Api {
code: err.code,
message: err.message,
}
}
}
pub struct A2AClient {
base_url: String,
http: reqwest::Client,
stream_http: reqwest::Client,
next_id: AtomicU64,
auth_token: Option<String>,
trace_id: Option<String>,
trace_context: Option<TraceContext>,
card_secret: Option<Vec<u8>>,
require_card_signature: bool,
}
impl A2AClient {
pub fn new(base_url: impl Into<String>) -> Result<Self, A2AError> {
let base_url = base_url.into();
if !base_url.starts_with("https://") {
log::warn!(
"A2A client connecting over non-HTTPS URL: {} (use TLS in production)",
base_url
);
}
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|e| A2AError::Http(format!("failed to build HTTP client: {e}")))?;
let stream_http = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.build()
.unwrap_or_else(|_| http.clone());
Ok(Self::with_http_client(base_url, http).with_stream_client(stream_http))
}
pub fn with_http_client(base_url: impl Into<String>, http: reqwest::Client) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
stream_http: http.clone(),
http,
next_id: AtomicU64::new(1),
auth_token: None,
trace_id: None,
trace_context: None,
card_secret: None,
require_card_signature: false,
}
}
fn with_stream_client(mut self, stream_http: reqwest::Client) -> Self {
self.stream_http = stream_http;
self
}
pub fn builder(base_url: impl Into<String>) -> A2AClientBuilder {
A2AClientBuilder::new(base_url)
}
fn alloc_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::SeqCst)
}
pub async fn get_agent_card(&self) -> Result<AgentCard, A2AError> {
let url = format!("{}/.well-known/agent-card.json", self.base_url);
let resp = self.with_traceparent(self.http.get(&url)).send().await?;
let status = resp.status();
if !status.is_success() {
return Err(A2AError::Http(format!(
"Agent card request failed with status {}",
status
)));
}
let card: AgentCard = resp
.json()
.await
.map_err(|e| A2AError::Parse(format!("Failed to parse agent card: {}", e)))?;
if !card.url.trim_end_matches('/').is_empty()
&& card.url.trim_end_matches('/') != self.base_url.trim_end_matches('/')
{
log::warn!(
"Agent card URL mismatch: card.url={}, base_url={}",
card.url,
self.base_url
);
}
if card.signature.is_some() {
match &self.card_secret {
Some(secret) => {
verify_card_signature(&card, secret)?;
}
None if self.require_card_signature => {
return Err(A2AError::Signature(
"agent card is signed but no verification secret is configured".to_string(),
));
}
None => {
log::warn!(
"agent card is signed but no verification secret is configured; \
skipping signature verification"
);
}
}
}
Ok(card)
}
pub async fn send_task(&self, message: A2AMessage) -> Result<A2ATask, A2AError> {
let id = self.alloc_id();
let req = self.with_context(A2ARequest::send_task(id, &message));
self.send_task_req(req).await
}
pub async fn send_task_with_message_id(
&self,
message: A2AMessage,
message_id: &str,
) -> Result<A2ATask, A2AError> {
let id = self.alloc_id();
let req = self.with_context(A2ARequest::send_task_with_message_id(
id, &message, message_id,
));
self.send_task_req(req).await
}
pub async fn resume_task(
&self,
task_id: &str,
message: A2AMessage,
) -> Result<A2ATask, A2AError> {
let id = self.alloc_id();
let req = self.with_context(A2ARequest::continue_task(id, task_id, &message));
self.send_task_req(req).await
}
pub async fn get_task(&self, task_id: &str) -> Result<A2ATask, A2AError> {
let id = self.alloc_id();
let req = self.with_context(A2ARequest::get_task(id, task_id));
let resp = self.post_request(req).await?;
self.task_from_response(resp)
}
pub async fn get_task_details(&self, task_id: &str) -> Result<A2ATaskDetails, A2AError> {
let id = self.alloc_id();
let req = self.with_context(A2ARequest::get_task(id, task_id));
let resp = self.post_request(req).await?;
let result = resp.into_result().map_err(A2AError::from)?;
let task: A2ATask = result
.get("task")
.ok_or_else(|| A2AError::Parse("Missing 'task' in response".to_string()))
.and_then(|v| {
serde_json::from_value(v.clone())
.map_err(|e| A2AError::Parse(format!("Failed to parse task: {}", e)))
})?;
let task_result: Option<A2ATaskResult> = result
.get("result")
.map(|v| {
serde_json::from_value(v.clone())
.map_err(|e| A2AError::Parse(format!("Failed to parse task result: {}", e)))
})
.transpose()?;
let error = result
.get("error")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(A2ATaskDetails {
task,
result: task_result,
error,
})
}
pub async fn cancel_task(&self, task_id: &str) -> Result<A2ATask, A2AError> {
let id = self.alloc_id();
let req = self.with_context(A2ARequest::cancel_task(id, task_id));
let resp = self.post_request(req).await?;
self.task_from_response(resp)
}
fn with_context(&self, req: A2ARequest) -> A2ARequest {
match &self.trace_id {
Some(tid) => req.with_trace_id(tid.as_str()),
None => req,
}
}
fn with_traceparent(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.trace_context {
Some(ctx) => request.header("traceparent", ctx.to_traceparent()),
None => request,
}
}
async fn send_task_req(&self, req: A2ARequest) -> Result<A2ATask, A2AError> {
let resp = self.post_request(req).await?;
self.task_from_response(resp)
}
fn task_from_response(&self, resp: A2AResponse) -> Result<A2ATask, A2AError> {
let result = resp.into_result().map_err(A2AError::from)?;
result
.get("task")
.ok_or_else(|| A2AError::Parse("Missing 'task' in response".to_string()))
.and_then(|v| {
serde_json::from_value(v.clone())
.map_err(|e| A2AError::Parse(format!("Failed to parse task: {}", e)))
})
}
pub async fn send_task_and_wait(
&self,
message: A2AMessage,
timeout: Duration,
) -> Result<A2ATaskResult, A2AError> {
let task = self.send_task(message).await?;
self.wait_for_task(&task.id, timeout).await
}
pub async fn send_task_and_wait_with_message_id(
&self,
message: A2AMessage,
message_id: &str,
timeout: Duration,
) -> Result<A2ATaskResult, A2AError> {
let task = self.send_task_with_message_id(message, message_id).await?;
self.wait_for_task(&task.id, timeout).await
}
async fn wait_for_task(
&self,
task_id: &str,
timeout: Duration,
) -> Result<A2ATaskResult, A2AError> {
let start = std::time::Instant::now();
let poll_interval = Duration::from_secs(1);
loop {
let mut details = None;
let mut last_err: Option<A2AError> = None;
for attempt in 0..3u32 {
match self.get_task_details(task_id).await {
Ok(d) => {
details = Some(d);
break;
}
Err(e) => {
last_err = Some(e);
if attempt < 2 {
tokio::time::sleep(Duration::from_millis(100 << attempt)).await;
}
}
}
}
let details = match details {
Some(d) => d,
None => {
return Err(last_err.unwrap_or_else(|| {
A2AError::Http("task poll failed without an error".to_string())
}))
}
};
match details.task.status {
TaskStatus::Completed => {
return details.result.ok_or_else(|| {
A2AError::Parse(format!("Task {} completed without a result", task_id))
})
}
TaskStatus::Failed => {
return Err(A2AError::Api {
code: -32000,
message: details.error.unwrap_or_else(|| "Task failed".to_string()),
})
}
TaskStatus::Cancelled => {
return Err(A2AError::Api {
code: -32000,
message: format!("Task {} was cancelled", task_id),
})
}
TaskStatus::Rejected => {
return Err(A2AError::Api {
code: -32000,
message: format!("Task {} was rejected", task_id),
})
}
TaskStatus::Expired => {
return Err(A2AError::Api {
code: -32000,
message: format!("Task {} expired", task_id),
})
}
TaskStatus::AuthRequired => {
return Err(A2AError::Api {
code: 401,
message: format!("Task {} requires authentication", task_id),
})
}
TaskStatus::InputRequired => {
return Err(A2AError::InputRequired {
task_id: task_id.to_string(),
prompt: details
.error
.unwrap_or_else(|| "Input required".to_string()),
});
}
TaskStatus::Submitted | TaskStatus::Working => {
if start.elapsed() > timeout {
return Err(A2AError::Timeout(format!(
"Task {} did not complete within {:?}",
task_id, timeout
)));
}
tokio::time::sleep(poll_interval).await;
}
}
}
}
pub async fn post_request(&self, req: A2ARequest) -> Result<A2AResponse, A2AError> {
let url = format!("{}/", self.base_url);
let mut request = self.with_traceparent(self.http.post(&url).json(&req));
if let Some(token) = &self.auth_token {
request = request.bearer_auth(token);
}
let resp = request.send().await?;
let status = resp.status();
if !status.is_success() {
if let Ok(a2a_resp) = resp.json::<A2AResponse>().await {
if let Some(err) = a2a_resp.error {
return Err(A2AError::from(err));
}
}
return Err(A2AError::Http(format!(
"A2A request failed with status {}",
status
)));
}
let a2a_resp: A2AResponse = resp
.json()
.await
.map_err(|e| A2AError::Parse(format!("Failed to parse A2A response: {}", e)))?;
Ok(a2a_resp)
}
pub async fn connect_sse(&self, sse_url: &str) -> Result<A2ASseStream, A2AError> {
let mut request = self.with_traceparent(self.stream_http.get(sse_url));
if let Some(token) = &self.auth_token {
request = request.bearer_auth(token);
}
let resp = request.send().await?;
let status = resp.status();
if !status.is_success() {
return Err(A2AError::Http(format!(
"SSE request failed with status {}",
status
)));
}
Ok(A2ASseStream::new(resp))
}
pub async fn send_task_streaming(
&self,
sse_url: &str,
message: A2AMessage,
) -> Result<A2ASseStream, A2AError> {
let stream = self.connect_sse(sse_url).await?;
let _ = self.send_task(message).await?;
Ok(stream)
}
}
pub struct A2AClientBuilder {
base_url: String,
http_client: Option<reqwest::Client>,
bearer_token: Option<String>,
enforce_https: bool,
timeout: Duration,
connect_timeout: Duration,
trace_id: Option<String>,
trace_context: Option<TraceContext>,
card_secret: Option<Vec<u8>>,
require_card_signature: bool,
}
impl A2AClientBuilder {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
http_client: None,
bearer_token: None,
enforce_https: false,
timeout: Duration::from_secs(30),
connect_timeout: Duration::from_secs(10),
trace_id: None,
trace_context: None,
card_secret: None,
require_card_signature: false,
}
}
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = Some(client);
self
}
pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
self.bearer_token = Some(token.into());
self
}
pub fn enforce_https(mut self, enforce: bool) -> Self {
self.enforce_https = enforce;
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
pub fn trace_id(mut self, trace_id: impl Into<String>) -> Self {
self.trace_id = Some(trace_id.into());
self
}
pub fn with_traceparent(mut self, context: TraceContext) -> Self {
self.trace_context = Some(context.clone());
self.trace_id = Some(context.trace_id.clone());
self
}
pub fn card_verification_secret(mut self, secret: impl Into<Vec<u8>>) -> Self {
self.card_secret = Some(secret.into());
self
}
pub fn require_card_signature(mut self, require: bool) -> Self {
self.require_card_signature = require;
self
}
pub fn build(self) -> Result<A2AClient, A2AError> {
if !self.base_url.starts_with("https://") {
if self.enforce_https {
return Err(A2AError::Http(format!(
"HTTPS is required for A2A, got insecure URL: {}",
self.base_url
)));
}
log::warn!(
"A2A client connecting over non-HTTPS URL: {} (use TLS in production)",
self.base_url
);
}
let (http, stream_http) = match self.http_client {
Some(client) => (client.clone(), client),
None => {
let http = reqwest::Client::builder()
.timeout(self.timeout)
.connect_timeout(self.connect_timeout)
.build()
.map_err(|e| {
A2AError::Http(format!("failed to build HTTP client: {}", e))
})?;
let stream_http = reqwest::Client::builder()
.connect_timeout(self.connect_timeout)
.build()
.unwrap_or_else(|_| http.clone());
(http, stream_http)
}
};
Ok(A2AClient {
base_url: self.base_url,
http,
stream_http,
next_id: AtomicU64::new(1),
auth_token: self.bearer_token,
trace_id: self.trace_id,
trace_context: self.trace_context,
card_secret: self.card_secret,
require_card_signature: self.require_card_signature,
})
}
}
#[cfg(test)]
mod tests;