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