Skip to main content

a2a_client/
factory.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// SPDX-License-Identifier: Apache-2.0
3use a2a::*;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use crate::client::A2AClient;
8use crate::jsonrpc::JsonRpcTransportFactory;
9use crate::middleware::CallInterceptor;
10use crate::rest::RestTransportFactory;
11use crate::transport::TransportFactory;
12
13/// Key for looking up transport factories.
14#[derive(Debug, Clone, Hash, PartialEq, Eq)]
15struct TransportKey {
16    protocol: String,
17    major_version: u64,
18}
19
20impl TransportKey {
21    fn from_interface(iface: &AgentInterface) -> Self {
22        let major = iface
23            .protocol_version
24            .split('.')
25            .next()
26            .and_then(|s| s.parse::<u64>().ok())
27            .unwrap_or(1);
28        TransportKey {
29            protocol: iface.protocol_binding.clone(),
30            major_version: major,
31        }
32    }
33
34    fn from_protocol(protocol: &str, version: &str) -> Self {
35        let major = version
36            .split('.')
37            .next()
38            .and_then(|s| s.parse::<u64>().ok())
39            .unwrap_or(1);
40        TransportKey {
41            protocol: protocol.to_string(),
42            major_version: major,
43        }
44    }
45}
46
47/// Factory for creating [`A2AClient`] instances with automatic protocol negotiation.
48///
49/// Maintains a registry of [`TransportFactory`] implementations indexed by protocol
50/// and major version. When creating a client from an [`AgentCard`], it selects the
51/// best matching transport based on the agent's declared interfaces and the client's
52/// preferred bindings.
53pub struct A2AClientFactory {
54    factories: HashMap<TransportKey, Arc<dyn TransportFactory>>,
55    preferred_bindings: Vec<String>,
56    interceptors: Vec<Arc<dyn CallInterceptor>>,
57}
58
59impl A2AClientFactory {
60    /// Create a builder for configuring the factory.
61    pub fn builder() -> A2AClientFactoryBuilder {
62        A2AClientFactoryBuilder::new()
63    }
64
65    /// Create a client by negotiating the best transport with the agent card.
66    ///
67    /// Selection algorithm (mirrors Go SDK):
68    /// 1. For each interface in the agent card's `supported_interfaces`
69    /// 2. Look up matching factory by (protocol, major_version)
70    /// 3. Rank candidates: client preference order, then newest version
71    /// 4. Try connecting in rank order; first success wins
72    pub async fn create_from_card(
73        &self,
74        card: &AgentCard,
75    ) -> Result<A2AClient<Box<dyn crate::Transport>>, A2AError> {
76        self.create_from_card_selecting(card)
77            .await
78            .map(|(client, _)| client)
79    }
80
81    /// Same negotiation as [`Self::create_from_card`], but also returns the
82    /// [`AgentInterface`] that was selected — needed by a caller that must
83    /// honor that interface's own declared routing `tenant` (A2A §8.3.2) on
84    /// every subsequent request, which the interface list alone doesn't
85    /// otherwise surface once a client has been built.
86    pub async fn create_from_card_with_interface(
87        &self,
88        card: &AgentCard,
89    ) -> Result<(A2AClient<Box<dyn crate::Transport>>, AgentInterface), A2AError> {
90        self.create_from_card_selecting(card).await
91    }
92
93    async fn create_from_card_selecting(
94        &self,
95        card: &AgentCard,
96    ) -> Result<(A2AClient<Box<dyn crate::Transport>>, AgentInterface), A2AError> {
97        let mut candidates: Vec<(usize, &AgentInterface, &Arc<dyn TransportFactory>)> = Vec::new();
98
99        for iface in &card.supported_interfaces {
100            let key = TransportKey::from_interface(iface);
101            if let Some(factory) = self.factories.get(&key) {
102                let priority = self
103                    .preferred_bindings
104                    .iter()
105                    .position(|b| b == &iface.protocol_binding)
106                    .unwrap_or(usize::MAX);
107                candidates.push((priority, iface, factory));
108            }
109        }
110
111        if candidates.is_empty() {
112            return Err(A2AError::unsupported_operation(
113                "no compatible transport found for agent card interfaces",
114            ));
115        }
116
117        // Sort by preference (lower is better)
118        candidates.sort_by_key(|(prio, _, _)| *prio);
119
120        let mut last_err = None;
121        for (_prio, iface, factory) in &candidates {
122            match factory.create(card, iface).await {
123                Ok(transport) => {
124                    let mut client =
125                        A2AClient::new(transport).with_interceptors(self.interceptors.clone());
126                    // A2A §8.3.2 rule 4: echo the tenant the selected
127                    // interface declares on every request. An empty string
128                    // is proto3's default for an unset field, so it means
129                    // "not declared" rather than a tenant named "".
130                    if let Some(tenant) = iface.tenant.as_deref().filter(|t| !t.is_empty()) {
131                        client = client.with_tenant(tenant);
132                    }
133                    return Ok((client, (*iface).clone()));
134                }
135                Err(e) => {
136                    tracing::debug!(
137                        protocol = %iface.protocol_binding,
138                        url = %iface.url,
139                        error = %e,
140                        "transport creation failed, trying next"
141                    );
142                    last_err = Some(e);
143                }
144            }
145        }
146
147        Err(last_err.unwrap_or_else(|| A2AError::internal("failed to create transport")))
148    }
149}
150
151/// Builder for [`A2AClientFactory`].
152pub struct A2AClientFactoryBuilder {
153    factories: HashMap<TransportKey, Arc<dyn TransportFactory>>,
154    preferred_bindings: Vec<String>,
155    interceptors: Vec<Arc<dyn CallInterceptor>>,
156    include_defaults: bool,
157}
158
159impl A2AClientFactoryBuilder {
160    fn new() -> Self {
161        A2AClientFactoryBuilder {
162            factories: HashMap::new(),
163            preferred_bindings: vec![
164                TRANSPORT_PROTOCOL_JSONRPC.to_string(),
165                TRANSPORT_PROTOCOL_HTTP_JSON.to_string(),
166            ],
167            interceptors: Vec::new(),
168            include_defaults: true,
169        }
170    }
171
172    /// Register a transport factory for a protocol binding.
173    pub fn register(mut self, factory: Arc<dyn TransportFactory>) -> Self {
174        let key = TransportKey::from_protocol(factory.protocol(), VERSION);
175        self.factories.insert(key, factory);
176        self
177    }
178
179    /// Set preferred binding order. First is most preferred.
180    pub fn preferred_bindings(mut self, bindings: Vec<String>) -> Self {
181        self.preferred_bindings = bindings;
182        self
183    }
184
185    /// Add a call interceptor.
186    pub fn with_interceptor(mut self, interceptor: Arc<dyn CallInterceptor>) -> Self {
187        self.interceptors.push(interceptor);
188        self
189    }
190
191    /// Disable default JSON-RPC and REST transport factories.
192    pub fn no_defaults(mut self) -> Self {
193        self.include_defaults = false;
194        self
195    }
196
197    /// Build the factory.
198    pub fn build(mut self) -> A2AClientFactory {
199        if self.include_defaults {
200            let jsonrpc_key = TransportKey::from_protocol(TRANSPORT_PROTOCOL_JSONRPC, VERSION);
201            self.factories
202                .entry(jsonrpc_key)
203                .or_insert_with(|| Arc::new(JsonRpcTransportFactory::new(None)));
204
205            let rest_key = TransportKey::from_protocol(TRANSPORT_PROTOCOL_HTTP_JSON, VERSION);
206            self.factories
207                .entry(rest_key)
208                .or_insert_with(|| Arc::new(RestTransportFactory::new(None)));
209        }
210
211        A2AClientFactory {
212            factories: self.factories,
213            preferred_bindings: self.preferred_bindings,
214            interceptors: self.interceptors,
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_transport_key_from_interface() {
225        let iface = AgentInterface::new("http://localhost", "jsonrpc");
226        let key = TransportKey::from_interface(&iface);
227        assert_eq!(key.protocol, "jsonrpc");
228    }
229
230    #[test]
231    fn test_transport_key_from_interface_no_version() {
232        let mut iface = AgentInterface::new("http://localhost", "rest");
233        iface.protocol_version = "bad".to_string();
234        let key = TransportKey::from_interface(&iface);
235        assert_eq!(key.major_version, 1); // defaults to 1
236    }
237
238    #[test]
239    fn test_transport_key_from_protocol() {
240        let key = TransportKey::from_protocol("jsonrpc", "2.3.4");
241        assert_eq!(key.protocol, "jsonrpc");
242        assert_eq!(key.major_version, 2);
243    }
244
245    #[test]
246    fn test_builder_defaults() {
247        let factory = A2AClientFactory::builder().build();
248        assert_eq!(factory.factories.len(), 2); // jsonrpc + rest
249        assert_eq!(factory.preferred_bindings.len(), 2);
250    }
251
252    #[test]
253    fn test_builder_no_defaults() {
254        let factory = A2AClientFactory::builder().no_defaults().build();
255        assert!(factory.factories.is_empty());
256    }
257
258    #[test]
259    fn test_builder_preferred_bindings() {
260        let factory = A2AClientFactory::builder()
261            .preferred_bindings(vec!["grpc".to_string()])
262            .build();
263        assert_eq!(factory.preferred_bindings, vec!["grpc"]);
264    }
265
266    #[test]
267    fn test_builder_with_interceptor() {
268        use crate::middleware::LoggingInterceptor;
269        let factory = A2AClientFactory::builder()
270            .with_interceptor(Arc::new(LoggingInterceptor))
271            .build();
272        assert_eq!(factory.interceptors.len(), 1);
273    }
274
275    #[tokio::test]
276    async fn test_create_from_card_no_matching_transport() {
277        let factory = A2AClientFactory::builder().no_defaults().build();
278        let card = AgentCard {
279            name: "test".into(),
280            description: "test agent".into(),
281            version: "1.0".into(),
282            supported_interfaces: vec![AgentInterface::new("http://localhost", "unknown")],
283            capabilities: AgentCapabilities::default(),
284            default_input_modes: vec!["text".into()],
285            default_output_modes: vec!["text".into()],
286            skills: vec![],
287            provider: None,
288            documentation_url: None,
289            icon_url: None,
290            security_schemes: None,
291            security_requirements: None,
292            signatures: None,
293        };
294        let result = factory.create_from_card(&card).await;
295        assert!(result.is_err());
296    }
297
298    #[tokio::test]
299    async fn test_create_from_card_with_interface_returns_selected_interface() {
300        let factory = A2AClientFactory::builder().build();
301        let mut iface = AgentInterface::new("http://localhost/jsonrpc", TRANSPORT_PROTOCOL_JSONRPC);
302        iface.tenant = Some("tenant-42".to_string());
303        let card = AgentCard {
304            name: "test".into(),
305            description: "test agent".into(),
306            version: "1.0".into(),
307            supported_interfaces: vec![iface],
308            capabilities: AgentCapabilities::default(),
309            default_input_modes: vec!["text".into()],
310            default_output_modes: vec!["text".into()],
311            skills: vec![],
312            provider: None,
313            documentation_url: None,
314            icon_url: None,
315            security_schemes: None,
316            security_requirements: None,
317            signatures: None,
318        };
319
320        let (_client, selected) = factory
321            .create_from_card_with_interface(&card)
322            .await
323            .unwrap();
324        assert_eq!(selected.protocol_binding, TRANSPORT_PROTOCOL_JSONRPC);
325        assert_eq!(selected.tenant.as_deref(), Some("tenant-42"));
326    }
327
328    fn card_with_tenant(tenant: Option<&str>) -> AgentCard {
329        let mut iface = AgentInterface::new("http://localhost/jsonrpc", TRANSPORT_PROTOCOL_JSONRPC);
330        iface.tenant = tenant.map(str::to_string);
331        AgentCard {
332            name: "test".into(),
333            description: "test agent".into(),
334            version: "1.0".into(),
335            supported_interfaces: vec![iface],
336            capabilities: AgentCapabilities::default(),
337            default_input_modes: vec!["text".into()],
338            default_output_modes: vec!["text".into()],
339            skills: vec![],
340            provider: None,
341            documentation_url: None,
342            icon_url: None,
343            security_schemes: None,
344            security_requirements: None,
345            signatures: None,
346        }
347    }
348
349    /// A2A §8.3.2 rule 4: the factory carries the declared tenant onto the
350    /// client, so every request echoes it without the caller doing anything.
351    #[tokio::test]
352    async fn test_create_from_card_applies_the_declared_tenant() {
353        let factory = A2AClientFactory::builder().build();
354        let client = factory
355            .create_from_card(&card_with_tenant(Some("tenant-42")))
356            .await
357            .unwrap();
358        assert_eq!(client.tenant(), Some("tenant-42"));
359    }
360
361    /// An entry that declares no tenant leaves the client with none, so the
362    /// field stays omitted rather than being defaulted.
363    #[tokio::test]
364    async fn test_create_from_card_without_a_declared_tenant() {
365        let factory = A2AClientFactory::builder().build();
366        let client = factory
367            .create_from_card(&card_with_tenant(None))
368            .await
369            .unwrap();
370        assert_eq!(client.tenant(), None);
371    }
372
373    /// The empty string is proto3's default for an unset field, so it means
374    /// "not declared" — not a tenant named "".
375    #[tokio::test]
376    async fn test_create_from_card_treats_an_empty_tenant_as_undeclared() {
377        let factory = A2AClientFactory::builder().build();
378        let client = factory
379            .create_from_card(&card_with_tenant(Some("")))
380            .await
381            .unwrap();
382        assert_eq!(client.tenant(), None);
383    }
384}