a2a_protocol_client/builder/
mod.rs1mod transport_factory;
35
36use std::time::Duration;
37
38use a2a_protocol_types::AgentCard;
39
40use crate::config::{ClientConfig, TlsConfig};
41use crate::error::{ClientError, ClientResult};
42use crate::interceptor::{CallInterceptor, InterceptorChain};
43use crate::retry::RetryPolicy;
44use crate::transport::Transport;
45
46#[allow(dead_code)]
54pub(crate) const SUPPORTED_PROTOCOL_MAJOR: u32 = 1;
55
56#[allow(dead_code)] pub(crate) fn protocol_version_mismatch(protocol_version: &str) -> Option<&str> {
72 if protocol_version.is_empty() {
73 return None;
74 }
75 let major = protocol_version
76 .split('.')
77 .next()
78 .and_then(|s| s.parse::<u32>().ok());
79 if major == Some(SUPPORTED_PROTOCOL_MAJOR) {
80 None
81 } else {
82 Some(protocol_version)
83 }
84}
85
86pub struct ClientBuilder {
93 pub(super) endpoint: String,
94 pub(super) transport_override: Option<Box<dyn Transport>>,
95 pub(super) interceptors: InterceptorChain,
96 pub(super) config: ClientConfig,
97 pub(super) preferred_binding: Option<String>,
98 pub(super) retry_policy: Option<RetryPolicy>,
99}
100
101impl ClientBuilder {
102 #[must_use]
107 pub fn new(endpoint: impl Into<String>) -> Self {
108 Self {
109 endpoint: endpoint.into(),
110 transport_override: None,
111 interceptors: InterceptorChain::new(),
112 config: ClientConfig::default(),
113 preferred_binding: None,
114 retry_policy: None,
115 }
116 }
117
118 pub fn from_card(card: &AgentCard) -> ClientResult<Self> {
128 let first = card.supported_interfaces.first().ok_or_else(|| {
129 ClientError::InvalidEndpoint("agent card has no supported interfaces".into())
130 })?;
131 let (endpoint, binding) = (first.url.clone(), first.protocol_binding.clone());
132
133 #[cfg(feature = "tracing")]
135 if let Some(mismatched) = protocol_version_mismatch(&first.protocol_version) {
136 trace_warn!(
137 agent = %card.name,
138 protocol_version = %mismatched,
139 supported_major = SUPPORTED_PROTOCOL_MAJOR,
140 "agent protocol version may be incompatible with this client"
141 );
142 }
143
144 Ok(Self {
145 endpoint,
146 transport_override: None,
147 interceptors: InterceptorChain::new(),
148 config: ClientConfig {
150 tenant: first.tenant.clone(),
151 ..ClientConfig::default()
152 },
153 preferred_binding: Some(binding),
154 retry_policy: None,
155 })
156 }
157
158 #[must_use]
162 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
163 self.config.request_timeout = timeout;
164 self
165 }
166
167 #[must_use]
172 pub const fn with_stream_connect_timeout(mut self, timeout: Duration) -> Self {
173 self.config.stream_connect_timeout = timeout;
174 self
175 }
176
177 #[must_use]
182 pub const fn with_connection_timeout(mut self, timeout: Duration) -> Self {
183 self.config.connection_timeout = timeout;
184 self
185 }
186
187 #[must_use]
193 pub const fn with_max_response_size(mut self, max_bytes: usize) -> Self {
194 self.config.max_response_size = max_bytes;
195 self
196 }
197
198 #[must_use]
202 pub fn with_protocol_binding(mut self, binding: impl Into<String>) -> Self {
203 self.preferred_binding = Some(binding.into());
204 self
205 }
206
207 #[must_use]
209 pub fn with_accepted_output_modes(mut self, modes: Vec<String>) -> Self {
210 self.config.accepted_output_modes = modes;
211 self
212 }
213
214 #[must_use]
216 pub const fn with_history_length(mut self, length: u32) -> Self {
217 self.config.history_length = Some(length);
218 self
219 }
220
221 #[must_use]
227 pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
228 self.config.tenant = Some(tenant.into());
229 self
230 }
231
232 #[must_use]
234 pub const fn with_return_immediately(mut self, val: bool) -> Self {
235 self.config.return_immediately = val;
236 self
237 }
238
239 #[must_use]
244 pub fn with_custom_transport(mut self, transport: impl Transport) -> Self {
245 self.transport_override = Some(Box::new(transport));
246 self
247 }
248
249 #[must_use]
251 pub const fn without_tls(mut self) -> Self {
252 self.config.tls = TlsConfig::Disabled;
253 self
254 }
255
256 #[must_use]
275 pub const fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
276 self.retry_policy = Some(policy);
277 self
278 }
279
280 #[must_use]
284 pub fn with_interceptor<I: CallInterceptor>(mut self, interceptor: I) -> Self {
285 self.interceptors.push(interceptor);
286 self
287 }
288}
289
290impl std::fmt::Debug for ClientBuilder {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 f.debug_struct("ClientBuilder")
293 .field("endpoint", &self.endpoint)
294 .field("preferred_binding", &self.preferred_binding)
295 .finish_non_exhaustive()
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use std::time::Duration;
303
304 #[test]
305 fn builder_from_card_uses_card_url() {
306 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
307
308 let card = AgentCard {
309 url: None,
310 name: "test".into(),
311 version: "1.0".into(),
312 description: "A test agent".into(),
313 supported_interfaces: vec![AgentInterface {
314 url: "http://localhost:9090".into(),
315 protocol_binding: "JSONRPC".into(),
316 protocol_version: "1.0.0".into(),
317 tenant: None,
318 }],
319 provider: None,
320 icon_url: None,
321 documentation_url: None,
322 capabilities: AgentCapabilities::none(),
323 security_schemes: None,
324 security_requirements: None,
325 default_input_modes: vec![],
326 default_output_modes: vec![],
327 skills: vec![],
328 signatures: None,
329 };
330
331 let client = ClientBuilder::from_card(&card)
332 .unwrap()
333 .build()
334 .expect("build");
335 let _ = client;
336 }
337
338 #[test]
339 fn builder_with_timeout_sets_config() {
340 let client = ClientBuilder::new("http://localhost:8080")
341 .with_timeout(Duration::from_secs(60))
342 .build()
343 .expect("build");
344 assert_eq!(client.config().request_timeout, Duration::from_secs(60));
345 }
346
347 #[test]
348 fn builder_from_card_empty_interfaces_returns_error() {
349 use a2a_protocol_types::{AgentCapabilities, AgentCard};
350
351 let card = AgentCard {
352 url: None,
353 name: "empty".into(),
354 version: "1.0".into(),
355 description: "No interfaces".into(),
356 supported_interfaces: vec![],
357 provider: None,
358 icon_url: None,
359 documentation_url: None,
360 capabilities: AgentCapabilities::none(),
361 security_schemes: None,
362 security_requirements: None,
363 default_input_modes: vec![],
364 default_output_modes: vec![],
365 skills: vec![],
366 signatures: None,
367 };
368
369 let result = ClientBuilder::from_card(&card);
370 assert!(result.is_err(), "empty interfaces should return error");
371 }
372
373 #[test]
374 fn builder_with_return_immediately() {
375 let client = ClientBuilder::new("http://localhost:8080")
376 .with_return_immediately(true)
377 .build()
378 .expect("build");
379 assert!(client.config().return_immediately);
380 }
381
382 #[test]
383 fn builder_with_history_length() {
384 let client = ClientBuilder::new("http://localhost:8080")
385 .with_history_length(10)
386 .build()
387 .expect("build");
388 assert_eq!(client.config().history_length, Some(10));
389 }
390
391 #[test]
392 fn builder_debug_contains_fields() {
393 let builder = ClientBuilder::new("http://localhost:8080");
394 let debug = format!("{builder:?}");
395 assert!(
396 debug.contains("ClientBuilder"),
397 "debug output missing struct name: {debug}"
398 );
399 assert!(
400 debug.contains("http://localhost:8080"),
401 "debug output missing endpoint: {debug}"
402 );
403 }
404
405 #[test]
408 fn builder_from_card_mismatched_version() {
409 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
410
411 let card = AgentCard {
412 url: None,
413 name: "mismatch".into(),
414 version: "1.0".into(),
415 description: "Version mismatch test".into(),
416 supported_interfaces: vec![AgentInterface {
417 url: "http://localhost:9091".into(),
418 protocol_binding: "JSONRPC".into(),
419 protocol_version: "99.0.0".into(), tenant: None,
421 }],
422 provider: None,
423 icon_url: None,
424 documentation_url: None,
425 capabilities: AgentCapabilities::none(),
426 security_schemes: None,
427 security_requirements: None,
428 default_input_modes: vec![],
429 default_output_modes: vec![],
430 skills: vec![],
431 signatures: None,
432 };
433
434 let builder = ClientBuilder::from_card(&card).unwrap();
435 assert_eq!(builder.endpoint, "http://localhost:9091");
436 }
437
438 #[test]
441 fn version_mismatch_matching_major_returns_none() {
442 assert_eq!(protocol_version_mismatch("1.0.0"), None);
443 assert_eq!(protocol_version_mismatch("1.2.3"), None);
444 assert_eq!(protocol_version_mismatch("1"), None);
445 }
446
447 #[test]
448 fn version_mismatch_returns_original_on_mismatch() {
449 assert_eq!(protocol_version_mismatch("0.5.0"), Some("0.5.0"));
450 assert_eq!(protocol_version_mismatch("2.0.0"), Some("2.0.0"));
451 assert_eq!(protocol_version_mismatch("99.0.0"), Some("99.0.0"));
452 }
453
454 #[test]
455 fn version_mismatch_empty_is_compatible() {
456 assert_eq!(protocol_version_mismatch(""), None);
458 }
459
460 #[test]
461 fn version_mismatch_unparseable_is_incompatible() {
462 assert_eq!(
463 protocol_version_mismatch("not-a-version"),
464 Some("not-a-version")
465 );
466 assert_eq!(protocol_version_mismatch("v1.0.0"), Some("v1.0.0"));
467 assert_eq!(protocol_version_mismatch("1-preview"), Some("1-preview"));
468 }
469
470 #[test]
477 fn builder_from_card_preserves_tenant() {
478 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
479
480 let card = AgentCard {
481 url: None,
482 name: "multi-tenant".into(),
483 version: "1.0".into(),
484 description: "Multi-tenant agent".into(),
485 supported_interfaces: vec![AgentInterface {
486 url: "http://localhost:9092".into(),
487 protocol_binding: "JSONRPC".into(),
488 protocol_version: "1.0.0".into(),
489 tenant: Some("tenant-42".into()),
490 }],
491 provider: None,
492 icon_url: None,
493 documentation_url: None,
494 capabilities: AgentCapabilities::none(),
495 security_schemes: None,
496 security_requirements: None,
497 default_input_modes: vec![],
498 default_output_modes: vec![],
499 skills: vec![],
500 signatures: None,
501 };
502
503 let builder = ClientBuilder::from_card(&card).expect("from_card");
504 assert_eq!(
505 builder.config.tenant.as_deref(),
506 Some("tenant-42"),
507 "tenant from AgentInterface must be propagated to ClientConfig"
508 );
509 }
510
511 #[test]
512 fn builder_from_card_none_tenant_stays_none() {
513 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
514
515 let card = AgentCard {
516 url: None,
517 name: "no-tenant".into(),
518 version: "1.0".into(),
519 description: String::new(),
520 supported_interfaces: vec![AgentInterface {
521 url: "http://localhost:9093".into(),
522 protocol_binding: "JSONRPC".into(),
523 protocol_version: "1.0.0".into(),
524 tenant: None,
525 }],
526 provider: None,
527 icon_url: None,
528 documentation_url: None,
529 capabilities: AgentCapabilities::none(),
530 security_schemes: None,
531 security_requirements: None,
532 default_input_modes: vec![],
533 default_output_modes: vec![],
534 skills: vec![],
535 signatures: None,
536 };
537
538 let builder = ClientBuilder::from_card(&card).expect("from_card");
539 assert!(builder.config.tenant.is_none());
540 }
541
542 #[test]
544 fn builder_with_connection_timeout_and_retry_policy() {
545 use crate::retry::RetryPolicy;
546
547 let client = ClientBuilder::new("http://localhost:8080")
548 .with_connection_timeout(Duration::from_secs(5))
549 .with_retry_policy(RetryPolicy::default())
550 .build()
551 .expect("build");
552 assert_eq!(client.config().connection_timeout, Duration::from_secs(5));
553 }
554
555 #[test]
557 fn builder_with_stream_connect_timeout() {
558 let client = ClientBuilder::new("http://localhost:8080")
559 .with_stream_connect_timeout(Duration::from_secs(15))
560 .build()
561 .expect("build");
562 assert_eq!(
563 client.config().stream_connect_timeout,
564 Duration::from_secs(15)
565 );
566 }
567}