Skip to main content

a2a_client/
lib.rs

1//! # a2a-client
2//!
3//! Reusable Rust library for building web-based frontends for A2A (Agent-to-Agent) Protocol agents.
4//!
5//! ## Overview
6//!
7//! This library provides components and utilities for creating web applications that interact
8//! with A2A protocol agents. It wraps the lower-level [`a2a-rs`](https://docs.rs/a2a-rs) clients
9//! with a web-friendly API and includes ready-to-use components for common use cases.
10//!
11//! ## Features
12//!
13//! - **Unified Client API** - Single interface for both HTTP and WebSocket transports
14//! - **SSE Streaming** - Server-Sent Events with automatic fallback to HTTP polling
15//! - **View Models** - Ready-to-use view models for tasks and messages
16//! - **Auto-reconnection** - Resilient WebSocket connections with retry logic
17//! - **Type-safe** - Leverages Rust's type system for protocol correctness
18//!
19//! ## Quick Start
20//!
21//! ### Basic HTTP Client
22//!
23//! ```rust,no_run
24//! use a2a_client::WebA2AClient;
25//! use a2a_rs::domain::Message;
26//! use a2a_rs::Transport;
27//!
28//! # #[tokio::main]
29//! # async fn main() -> anyhow::Result<()> {
30//! // Create a client connected to your A2A agent
31//! let client = WebA2AClient::new_http("http://localhost:8080".to_string());
32//!
33//! // Send a message
34//! let message = Message::user_text("Hello, agent!".to_string(), "msg-1".to_string());
35//!
36//! let task = client.transport.send_task_message("task-1", &message, None, None).await?;
37//! println!("Task ID: {}", task.id);
38//! # Ok(())
39//! # }
40//! ```
41//!
42//! ### With SSE Streaming via ConnectRPC
43//!
44//! ```rust
45//! use a2a_client::WebA2AClient;
46//!
47//! // Create client with HTTP (which handles ConnectRPC streaming automatically)
48//! let client = WebA2AClient::new_http("http://localhost:8080".to_string());
49//!
50//! println!("HTTP client configured for streaming!");
51//! ```
52//!
53//! ### SSE Streaming with Axum (requires `axum-components` feature)
54//!
55//! ```rust,ignore
56//! # #[cfg(feature = "axum-components")]
57//! # {
58//! use a2a_client::{WebA2AClient, components::create_sse_stream};
59//! use axum::{Router, routing::get, extract::{State, Path}};
60//! use std::sync::Arc;
61//!
62//! # #[tokio::main]
63//! # async fn main() -> anyhow::Result<()> {
64//! let client = Arc::new(WebA2AClient::new_http("http://localhost:8080".to_string()));
65//!
66//! let app = Router::new()
67//!     .route("/stream/:task_id", get(stream_handler))
68//!     .with_state(client);
69//!
70//! // Start your Axum server...
71//! # Ok(())
72//! # }
73//!
74//! async fn stream_handler(
75//!     State(client): State<Arc<WebA2AClient>>,
76//!     Path(task_id): Path<String>,
77//! ) -> axum::response::sse::Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>> {
78//!     create_sse_stream(client, task_id)
79//! }
80//! # }
81//! ```
82//!
83//! ## Components
84//!
85//! - [`WebA2AClient`] - Main client wrapper for HTTP and WebSocket transports
86//! - [`components::TaskView`] - View model for displaying tasks in lists
87//! - [`components::MessageView`] - View model for displaying individual messages
88//! - [`components::create_sse_stream`] - SSE stream creation with auto-fallback (requires `axum-components`)
89//! - [`utils::formatters`] - Formatting utilities for A2A types
90//!
91//! ## Feature Flags
92//!
93//! - `axum-components` (default) - Enables Axum-specific SSE streaming components
94//!
95//! ## Integration
96//!
97//! This library integrates with:
98//! - [`a2a-rs`](https://docs.rs/a2a-rs) - Core A2A protocol implementation
99//! - [`a2a-agents`](https://docs.rs/a2a-agents) - Declarative agent framework
100//! - Any agent implementing the A2A Protocol v1.0.0
101//!
102//! ## Examples
103//!
104//! See the `examples/` directory for complete working examples of different use cases.
105
106pub mod components;
107pub mod error;
108pub mod utils;
109
110// Re-export commonly used types
111pub use error::{ClientError, Result};
112
113use std::pin::Pin;
114use std::sync::Arc;
115
116use a2a_rs::domain::A2AError;
117use a2a_rs::{HttpClient, RetryPolicy, StreamEvent, Transport, subscribe_resilient};
118use futures::Stream;
119
120/// Web-friendly A2A client that wraps both HTTP and WebSocket clients.
121///
122/// This is the main entry point for interacting with A2A agents from web applications.
123/// It provides a unified interface for both HTTP and WebSocket transports, with automatic
124/// fallback and retry logic.
125///
126/// # Examples
127///
128/// ## HTTP-only client
129///
130/// ```rust
131/// use a2a_client::WebA2AClient;
132///
133/// let client = WebA2AClient::new_http("http://localhost:8080".to_string());
134/// ```
135///
136/// ## Client configured for streaming
137///
138/// ```rust
139/// use a2a_client::WebA2AClient;
140///
141/// let client = WebA2AClient::new_http("http://localhost:8080".to_string());
142/// ```
143///
144/// ## Auto-detecting transports
145///
146/// ```rust,no_run
147/// use a2a_client::WebA2AClient;
148///
149/// # #[tokio::main]
150/// # async fn main() -> anyhow::Result<()> {
151/// let client = WebA2AClient::auto_connect("http://localhost:8080").await?;
152/// # Ok(())
153/// # }
154/// ```
155pub struct WebA2AClient {
156    /// The negotiated transport for A2A requests and streaming.
157    ///
158    /// Held behind an `Arc<dyn Transport>` so the client is agnostic to the
159    /// underlying wire protocol (ConnectRPC, JSON-RPC 2.0, …) and can share the
160    /// transport with a reconnecting subscription stream.
161    pub transport: Arc<dyn Transport>,
162}
163
164impl WebA2AClient {
165    /// Create a builder for configuring the client.
166    ///
167    /// # Examples
168    ///
169    /// ```rust
170    /// use a2a_client::WebA2AClient;
171    ///
172    /// let client = WebA2AClient::builder()
173    ///     .http_url("http://localhost:8080")
174    ///     .build();
175    /// ```
176    pub fn builder() -> WebA2AClientBuilder {
177        WebA2AClientBuilder::default()
178    }
179
180    /// Create a new client with HTTP transport only.
181    ///
182    /// # Arguments
183    ///
184    /// * `base_url` - Base URL of the A2A agent (e.g., `http://localhost:8080`)
185    ///
186    /// # Examples
187    ///
188    /// ```rust
189    /// use a2a_client::WebA2AClient;
190    ///
191    /// let client = WebA2AClient::new_http("http://localhost:8080".to_string());
192    /// ```
193    pub fn new_http(base_url: String) -> Self {
194        Self {
195            transport: Arc::new(HttpClient::new(base_url)),
196        }
197    }
198
199    /// Auto-connect to an agent by fetching its card and negotiating a transport.
200    ///
201    /// Fetches the agent card from the well-known endpoint and selects a transport
202    /// from the card's `supported_interfaces` (ConnectRPC preferred, JSON-RPC 2.0
203    /// as interop fallback). If the card can't be fetched or none of its interfaces
204    /// match a compiled-in transport, falls back to a ConnectRPC client on
205    /// `base_url` so the call still works against a bare agent URL.
206    ///
207    /// # Arguments
208    ///
209    /// * `base_url` - Base URL of the A2A agent
210    ///
211    /// # Examples
212    ///
213    /// ```rust,no_run
214    /// use a2a_client::WebA2AClient;
215    ///
216    /// # #[tokio::main]
217    /// # async fn main() -> anyhow::Result<()> {
218    /// let client = WebA2AClient::auto_connect("http://localhost:8080").await?;
219    /// # Ok(())
220    /// # }
221    /// ```
222    pub async fn auto_connect(base_url: &str) -> Result<Self> {
223        // Delegates to the shared `a2a_rs::auto_connect` (URL-validate → negotiate
224        // → direct-client fallback); the CLI and this web client are siblings on
225        // that one entry point.
226        let transport = a2a_rs::auto_connect(base_url).await?;
227        Ok(Self {
228            transport: Arc::from(transport),
229        })
230    }
231
232    /// Subscribe to a task's updates as a protocol-neutral stream of
233    /// [`StreamEvent`]s.
234    ///
235    /// This is the **spec-compliant** path: a single A2A `SubscribeToTask` round
236    /// trip with no reconnection and no `Last-Event-ID` — what any A2A agent
237    /// expects. For automatic reconnection (and gap-free resume against an
238    /// a2a-rs server) use
239    /// [`subscribe_resilient`](WebA2AClient::subscribe_resilient).
240    pub async fn subscribe(
241        &self,
242        task_id: &str,
243    ) -> Result<Pin<Box<dyn Stream<Item = std::result::Result<StreamEvent, A2AError>> + Send>>>
244    {
245        self.transport
246            .subscribe_to_task(task_id, None, None)
247            .await
248            .map_err(Into::into)
249    }
250
251    /// Subscribe to a task's updates with automatic reconnect + exponential
252    /// backoff. The stream ends when the task reaches a terminal state (or
253    /// retries are exhausted).
254    ///
255    /// Reconnection itself is spec-compliant (it re-issues `SubscribeToTask`).
256    /// Resuming *without gaps* via `Last-Event-ID` is an **a2a-rs enhancement**
257    /// beyond the A2A v1.0 spec: it works against an a2a-rs server and degrades
258    /// gracefully (reconnect-from-current-state) against any spec-compliant one.
259    ///
260    /// This is the reusable core that [`create_sse_stream`](components::create_sse_stream)
261    /// builds on; framework-agnostic, so non-Axum frontends can consume it
262    /// directly.
263    pub fn subscribe_resilient(
264        &self,
265        task_id: &str,
266        policy: RetryPolicy,
267    ) -> Pin<Box<dyn Stream<Item = std::result::Result<StreamEvent, A2AError>> + Send>> {
268        subscribe_resilient(
269            self.transport.clone(),
270            task_id.to_string(),
271            None,
272            None,
273            policy,
274        )
275    }
276}
277/// Application state for Axum web applications.
278///
279/// This struct provides a convenient way to share the A2A client and
280/// configuration across Axum route handlers.
281///
282/// # Examples
283///
284/// ```rust
285/// use a2a_client::{WebA2AClient, AppState};
286/// use std::sync::Arc;
287///
288/// let client = WebA2AClient::new_http("http://localhost:8080".to_string());
289/// let state = Arc::new(
290///     AppState::new(client)
291///         .with_webhook_token("secret-token".to_string())
292/// );
293/// ```
294pub struct AppState {
295    /// The A2A client for interacting with agents
296    pub client: WebA2AClient,
297    /// Optional webhook authentication token
298    pub webhook_token: Option<String>,
299}
300
301impl AppState {
302    /// Create new application state with the given client.
303    ///
304    /// # Examples
305    ///
306    /// ```rust
307    /// use a2a_client::{WebA2AClient, AppState};
308    ///
309    /// let client = WebA2AClient::new_http("http://localhost:8080".to_string());
310    /// let state = AppState::new(client);
311    /// ```
312    pub fn new(client: WebA2AClient) -> Self {
313        Self {
314            client,
315            webhook_token: None,
316        }
317    }
318
319    /// Set the webhook authentication token.
320    ///
321    /// # Examples
322    ///
323    /// ```rust
324    /// use a2a_client::{WebA2AClient, AppState};
325    ///
326    /// let client = WebA2AClient::new_http("http://localhost:8080".to_string());
327    /// let state = AppState::new(client)
328    ///     .with_webhook_token("secret-token".to_string());
329    /// ```
330    pub fn with_webhook_token(mut self, token: String) -> Self {
331        self.webhook_token = Some(token);
332        self
333    }
334}
335
336/// Builder for [`WebA2AClient`].
337///
338/// Provides a fluent API for configuring the client with optional WebSocket support.
339///
340/// # Examples
341///
342/// ```rust
343/// use a2a_client::WebA2AClient;
344///
345/// // HTTP-only client
346/// let client = WebA2AClient::builder()
347///     .http_url("http://localhost:8080")
348///     .build();
349/// ```
350#[derive(Default)]
351pub struct WebA2AClientBuilder {
352    http_url: Option<String>,
353}
354
355impl WebA2AClientBuilder {
356    /// Set the HTTP base URL.
357    ///
358    /// # Examples
359    ///
360    /// ```rust
361    /// use a2a_client::WebA2AClient;
362    ///
363    /// let client = WebA2AClient::builder()
364    ///     .http_url("http://localhost:8080")
365    ///     .build();
366    /// ```
367    pub fn http_url(mut self, url: impl Into<String>) -> Self {
368        self.http_url = Some(url.into());
369        self
370    }
371
372    /// Build the [`WebA2AClient`].
373    ///
374    /// # Panics
375    ///
376    /// Panics if `http_url` was not set.
377    ///
378    /// # Examples
379    ///
380    /// ```rust
381    /// use a2a_client::WebA2AClient;
382    ///
383    /// let client = WebA2AClient::builder()
384    ///     .http_url("http://localhost:8080")
385    ///     .build();
386    /// ```
387    pub fn build(self) -> WebA2AClient {
388        let http_url = self
389            .http_url
390            .expect("http_url is required for WebA2AClient");
391
392        WebA2AClient::new_http(http_url)
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn test_client_builder_http_only() {
402        let _client = WebA2AClient::builder()
403            .http_url("http://test.local")
404            .build();
405    }
406
407    #[test]
408    #[should_panic(expected = "http_url is required for WebA2AClient")]
409    fn test_client_builder_panics_without_http() {
410        let _client = WebA2AClient::builder().build();
411    }
412
413    #[test]
414    fn test_app_state_creation() {
415        let client = WebA2AClient::new_http("http://test.local".to_string());
416        let state = AppState::new(client);
417        assert!(state.webhook_token.is_none());
418    }
419
420    #[test]
421    fn test_app_state_with_token() {
422        let client = WebA2AClient::new_http("http://test.local".to_string());
423        let state = AppState::new(client).with_webhook_token("secret".to_string());
424        assert_eq!(state.webhook_token.as_deref(), Some("secret"));
425    }
426
427    #[tokio::test]
428    async fn test_auto_connect_invalid_url() {
429        // URL validation now lives in `a2a_rs::auto_connect`, so a malformed URL
430        // surfaces as an `A2AError::InvalidParams` wrapped in `ClientError::A2AError`.
431        // `WebA2AClient` isn't `Debug`, so discard the Ok value before matching.
432        let err = WebA2AClient::auto_connect("invalid-url-no-scheme")
433            .await
434            .err()
435            .expect("malformed url should error");
436        match err {
437            ClientError::A2AError(A2AError::InvalidParams(msg)) => {
438                assert!(msg.contains("invalid-url-no-scheme"), "got {msg}");
439            }
440            other => panic!("Expected ClientError::A2AError(InvalidParams), got {other:?}"),
441        }
442    }
443}