1mod transport_factory;
35
36use std::time::Duration;
37
38use a2a_protocol_types::{AgentCard, AgentInterface};
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 pub(super) card_interfaces: Vec<AgentInterface>,
106}
107
108fn select_interface<'a>(card: &'a AgentCard, preferences: &[String]) -> Option<&'a AgentInterface> {
117 for wanted in preferences {
118 if let Some(iface) = card
119 .supported_interfaces
120 .iter()
121 .find(|i| i.protocol_binding.eq_ignore_ascii_case(wanted))
122 {
123 return Some(iface);
124 }
125 }
126 card.supported_interfaces.first()
127}
128
129impl ClientBuilder {
130 #[must_use]
135 pub fn new(endpoint: impl Into<String>) -> Self {
136 Self {
137 endpoint: endpoint.into(),
138 transport_override: None,
139 interceptors: InterceptorChain::new(),
140 config: ClientConfig::default(),
141 preferred_binding: None,
142 retry_policy: None,
143 card_interfaces: Vec::new(),
144 }
145 }
146
147 pub fn from_card(card: &AgentCard) -> ClientResult<Self> {
154 Self::from_card_preferring(card, &ClientConfig::default().preferred_bindings)
155 }
156
157 pub fn from_card_preferring(card: &AgentCard, preferences: &[String]) -> ClientResult<Self> {
184 let first = select_interface(card, preferences).ok_or_else(|| {
185 ClientError::InvalidEndpoint("agent card has no supported interfaces".into())
186 })?;
187 let (endpoint, binding) = (first.url.clone(), first.protocol_binding.clone());
188
189 #[cfg(feature = "tracing")]
191 if let Some(mismatched) = protocol_version_mismatch(&first.protocol_version) {
192 trace_warn!(
193 agent = %card.name,
194 protocol_version = %mismatched,
195 supported_major = SUPPORTED_PROTOCOL_MAJOR,
196 "agent protocol version may be incompatible with this client"
197 );
198 }
199
200 Ok(Self {
201 endpoint,
202 transport_override: None,
203 interceptors: InterceptorChain::new(),
204 config: ClientConfig {
205 tenant: first.tenant.clone(),
207 preferred_bindings: preferences.to_vec(),
212 ..ClientConfig::default()
213 },
214 preferred_binding: Some(binding),
215 retry_policy: None,
216 card_interfaces: card.supported_interfaces.clone(),
217 })
218 }
219
220 #[must_use]
224 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
225 self.config.request_timeout = timeout;
226 self
227 }
228
229 #[must_use]
234 pub const fn with_stream_connect_timeout(mut self, timeout: Duration) -> Self {
235 self.config.stream_connect_timeout = timeout;
236 self
237 }
238
239 #[must_use]
244 pub const fn with_connection_timeout(mut self, timeout: Duration) -> Self {
245 self.config.connection_timeout = timeout;
246 self
247 }
248
249 #[must_use]
255 pub const fn with_max_response_size(mut self, max_bytes: usize) -> Self {
256 self.config.max_response_size = max_bytes;
257 self
258 }
259
260 #[must_use]
277 pub fn with_protocol_binding(mut self, binding: impl Into<String>) -> Self {
278 let binding = binding.into();
279 let resolved = self
280 .card_interfaces
281 .iter()
282 .find(|i| i.protocol_binding.eq_ignore_ascii_case(&binding))
283 .map(|i| (i.url.clone(), i.tenant.clone()));
284 if let Some((url, tenant)) = resolved {
285 self.endpoint = url;
286 self.config.tenant = tenant;
287 }
288 self.preferred_binding = Some(binding);
289 self
290 }
291
292 #[must_use]
294 pub fn with_accepted_output_modes(mut self, modes: Vec<String>) -> Self {
295 self.config.accepted_output_modes = modes;
296 self
297 }
298
299 #[must_use]
301 pub const fn with_history_length(mut self, length: u32) -> Self {
302 self.config.history_length = Some(length);
303 self
304 }
305
306 #[must_use]
312 pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
313 self.config.tenant = Some(tenant.into());
314 self
315 }
316
317 #[must_use]
319 pub const fn with_return_immediately(mut self, val: bool) -> Self {
320 self.config.return_immediately = val;
321 self
322 }
323
324 #[must_use]
329 pub fn with_custom_transport(mut self, transport: impl Transport) -> Self {
330 self.transport_override = Some(Box::new(transport));
331 self
332 }
333
334 #[must_use]
336 pub const fn without_tls(mut self) -> Self {
337 self.config.tls = TlsConfig::Disabled;
338 self
339 }
340
341 #[must_use]
360 pub const fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
361 self.retry_policy = Some(policy);
362 self
363 }
364
365 #[must_use]
369 pub fn with_interceptor<I: CallInterceptor>(mut self, interceptor: I) -> Self {
370 self.interceptors.push(interceptor);
371 self
372 }
373}
374
375impl std::fmt::Debug for ClientBuilder {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 f.debug_struct("ClientBuilder")
378 .field("endpoint", &self.endpoint)
379 .field("preferred_binding", &self.preferred_binding)
380 .finish_non_exhaustive()
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::config::{BINDING_GRPC, BINDING_HTTP_JSON, BINDING_JSONRPC, BINDING_REST};
388 use std::time::Duration;
389
390 fn card_with(interfaces: Vec<a2a_protocol_types::AgentInterface>) -> AgentCard {
402 use a2a_protocol_types::AgentCapabilities;
403
404 AgentCard {
405 url: None,
406 name: "prefs".into(),
407 version: "1.0".into(),
408 description: "Binding preference fixture".into(),
409 supported_interfaces: interfaces,
410 provider: None,
411 icon_url: None,
412 documentation_url: None,
413 capabilities: AgentCapabilities::none(),
414 security_schemes: None,
415 security_requirements: None,
416 default_input_modes: vec![],
417 default_output_modes: vec![],
418 skills: vec![],
419 signatures: None,
420 }
421 }
422
423 fn iface(binding: &str, url: &str) -> a2a_protocol_types::AgentInterface {
424 a2a_protocol_types::AgentInterface {
425 url: url.into(),
426 protocol_binding: binding.into(),
427 protocol_version: "1.0.0".into(),
428 tenant: None,
429 }
430 }
431
432 fn jsonrpc_then_grpc() -> AgentCard {
434 card_with(vec![
435 iface(BINDING_JSONRPC, "http://localhost:1111"),
436 iface(BINDING_GRPC, "http://localhost:2222"),
437 ])
438 }
439
440 #[test]
441 fn from_card_prefers_the_callers_binding_order_over_the_cards() {
442 let builder =
443 ClientBuilder::from_card_preferring(&jsonrpc_then_grpc(), &[BINDING_GRPC.into()])
444 .expect("from_card_preferring");
445
446 assert_eq!(
447 builder.endpoint, "http://localhost:2222",
448 "the caller ranked GRPC; the card's first interface is JSONRPC. \
449 Taking the card's order would give :1111"
450 );
451 assert_eq!(builder.preferred_binding.as_deref(), Some(BINDING_GRPC));
452 }
453
454 #[test]
455 fn a_later_preference_wins_when_the_earlier_one_is_not_offered() {
456 let builder = ClientBuilder::from_card_preferring(
457 &jsonrpc_then_grpc(),
458 &[BINDING_HTTP_JSON.into(), BINDING_GRPC.into()],
459 )
460 .expect("from_card_preferring");
461
462 assert_eq!(
463 builder.endpoint, "http://localhost:2222",
464 "HTTP+JSON is unavailable, so the second preference (GRPC) applies"
465 );
466 }
467
468 #[test]
469 fn an_unmatched_preference_falls_back_to_the_cards_first_interface() {
470 let builder =
471 ClientBuilder::from_card_preferring(&jsonrpc_then_grpc(), &[BINDING_HTTP_JSON.into()])
472 .expect("from_card_preferring");
473
474 assert_eq!(
475 builder.endpoint, "http://localhost:1111",
476 "no ranked binding is offered, so the card's own first choice is used"
477 );
478 assert_eq!(builder.preferred_binding.as_deref(), Some(BINDING_JSONRPC));
479 }
480
481 #[test]
482 fn an_empty_preference_list_takes_the_cards_first_interface() {
483 let builder = ClientBuilder::from_card_preferring(&jsonrpc_then_grpc(), &[])
484 .expect("from_card_preferring");
485
486 assert_eq!(builder.endpoint, "http://localhost:1111");
487 }
488
489 #[test]
490 fn binding_preference_matches_case_insensitively() {
491 let builder =
495 ClientBuilder::from_card_preferring(&jsonrpc_then_grpc(), &["gRpC".to_owned()])
496 .expect("from_card_preferring");
497
498 assert_eq!(builder.endpoint, "http://localhost:2222");
499 }
500
501 #[test]
502 fn from_card_applies_the_default_preference_list() {
503 let card = card_with(vec![
506 iface(BINDING_GRPC, "http://localhost:2222"),
507 iface(BINDING_JSONRPC, "http://localhost:1111"),
508 ]);
509
510 let builder = ClientBuilder::from_card(&card).expect("from_card");
511
512 assert_eq!(
513 builder.endpoint, "http://localhost:1111",
514 "from_card must honour ClientConfig's default preference (JSONRPC), \
515 not the card's own first entry (GRPC)"
516 );
517 assert_eq!(builder.preferred_binding.as_deref(), Some(BINDING_JSONRPC));
518 }
519
520 #[test]
521 fn a_single_interface_card_is_used_whatever_the_preference() {
522 let card = card_with(vec![iface(BINDING_JSONRPC, "http://localhost:1111")]);
523
524 let builder = ClientBuilder::from_card_preferring(&card, &[BINDING_GRPC.into()])
525 .expect("from_card_preferring");
526
527 assert_eq!(
528 builder.endpoint, "http://localhost:1111",
529 "an agent that speaks nothing the caller ranked is still worth \
530 talking to; refusing to connect would be a worse answer"
531 );
532 }
533
534 #[test]
535 fn the_applied_preference_is_recorded_in_the_built_config() {
536 let prefs = vec![BINDING_GRPC.to_owned(), BINDING_JSONRPC.to_owned()];
539 let builder = ClientBuilder::from_card_preferring(&jsonrpc_then_grpc(), &prefs)
540 .expect("from_card_preferring");
541
542 assert_eq!(builder.config.preferred_bindings, prefs);
543 }
544
545 #[test]
554 fn switching_binding_on_a_card_builder_moves_the_endpoint_too() {
555 let builder = ClientBuilder::from_card(&jsonrpc_then_grpc())
556 .expect("from_card")
557 .with_protocol_binding(BINDING_GRPC);
558
559 assert_eq!(
560 builder.endpoint, "http://localhost:2222",
561 "the card advertises GRPC at :2222; keeping :1111 would speak gRPC \
562 to the JSON-RPC port"
563 );
564 assert_eq!(builder.preferred_binding.as_deref(), Some(BINDING_GRPC));
565 }
566
567 #[test]
568 fn switching_binding_carries_that_interfaces_tenant() {
569 let card = card_with(vec![
570 iface(BINDING_JSONRPC, "http://localhost:1111"),
571 a2a_protocol_types::AgentInterface {
572 tenant: Some("grpc-tenant".into()),
573 ..iface(BINDING_GRPC, "http://localhost:2222")
574 },
575 ]);
576
577 let builder = ClientBuilder::from_card(&card)
578 .expect("from_card")
579 .with_protocol_binding(BINDING_GRPC);
580
581 assert_eq!(
582 builder.config.tenant.as_deref(),
583 Some("grpc-tenant"),
584 "tenant is per-interface; the old interface's tenant does not \
585 survive a move to a different one"
586 );
587 }
588
589 #[test]
590 fn with_tenant_after_a_binding_switch_wins() {
591 let builder = ClientBuilder::from_card(&jsonrpc_then_grpc())
592 .expect("from_card")
593 .with_protocol_binding(BINDING_GRPC)
594 .with_tenant("explicit");
595
596 assert_eq!(builder.config.tenant.as_deref(), Some("explicit"));
597 }
598
599 #[test]
600 fn switching_to_a_binding_the_card_lacks_leaves_the_endpoint_alone() {
601 let builder = ClientBuilder::from_card(&jsonrpc_then_grpc())
602 .expect("from_card")
603 .with_protocol_binding(BINDING_HTTP_JSON);
604
605 assert_eq!(
606 builder.endpoint, "http://localhost:1111",
607 "nothing to resolve against, so the caller's endpoint stands"
608 );
609 assert_eq!(
610 builder.preferred_binding.as_deref(),
611 Some(BINDING_HTTP_JSON)
612 );
613 }
614
615 #[test]
616 fn a_plain_new_builder_keeps_its_endpoint_across_a_binding_switch() {
617 let builder =
620 ClientBuilder::new("http://localhost:8080").with_protocol_binding(BINDING_REST);
621
622 assert_eq!(builder.endpoint, "http://localhost:8080");
623 assert_eq!(builder.preferred_binding.as_deref(), Some(BINDING_REST));
624 }
625
626 #[test]
627 fn from_card_preferring_rejects_a_card_with_no_interfaces() {
628 let result =
629 ClientBuilder::from_card_preferring(&card_with(vec![]), &[BINDING_GRPC.into()]);
630 assert!(result.is_err(), "empty interfaces should return error");
631 }
632
633 #[test]
634 fn builder_from_card_uses_card_url() {
635 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
636
637 let card = AgentCard {
638 url: None,
639 name: "test".into(),
640 version: "1.0".into(),
641 description: "A test agent".into(),
642 supported_interfaces: vec![AgentInterface {
643 url: "http://localhost:9090".into(),
644 protocol_binding: "JSONRPC".into(),
645 protocol_version: "1.0.0".into(),
646 tenant: None,
647 }],
648 provider: None,
649 icon_url: None,
650 documentation_url: None,
651 capabilities: AgentCapabilities::none(),
652 security_schemes: None,
653 security_requirements: None,
654 default_input_modes: vec![],
655 default_output_modes: vec![],
656 skills: vec![],
657 signatures: None,
658 };
659
660 let client = ClientBuilder::from_card(&card)
661 .unwrap()
662 .build()
663 .expect("build");
664 let _ = client;
665 }
666
667 #[test]
668 fn builder_with_timeout_sets_config() {
669 let client = ClientBuilder::new("http://localhost:8080")
670 .with_timeout(Duration::from_secs(60))
671 .build()
672 .expect("build");
673 assert_eq!(client.config().request_timeout, Duration::from_secs(60));
674 }
675
676 #[test]
677 fn builder_from_card_empty_interfaces_returns_error() {
678 use a2a_protocol_types::{AgentCapabilities, AgentCard};
679
680 let card = AgentCard {
681 url: None,
682 name: "empty".into(),
683 version: "1.0".into(),
684 description: "No interfaces".into(),
685 supported_interfaces: vec![],
686 provider: None,
687 icon_url: None,
688 documentation_url: None,
689 capabilities: AgentCapabilities::none(),
690 security_schemes: None,
691 security_requirements: None,
692 default_input_modes: vec![],
693 default_output_modes: vec![],
694 skills: vec![],
695 signatures: None,
696 };
697
698 let result = ClientBuilder::from_card(&card);
699 assert!(result.is_err(), "empty interfaces should return error");
700 }
701
702 #[test]
703 fn builder_with_return_immediately() {
704 let client = ClientBuilder::new("http://localhost:8080")
705 .with_return_immediately(true)
706 .build()
707 .expect("build");
708 assert!(client.config().return_immediately);
709 }
710
711 #[test]
712 fn builder_with_history_length() {
713 let client = ClientBuilder::new("http://localhost:8080")
714 .with_history_length(10)
715 .build()
716 .expect("build");
717 assert_eq!(client.config().history_length, Some(10));
718 }
719
720 #[test]
721 fn builder_debug_contains_fields() {
722 let builder = ClientBuilder::new("http://localhost:8080");
723 let debug = format!("{builder:?}");
724 assert!(
725 debug.contains("ClientBuilder"),
726 "debug output missing struct name: {debug}"
727 );
728 assert!(
729 debug.contains("http://localhost:8080"),
730 "debug output missing endpoint: {debug}"
731 );
732 }
733
734 #[test]
737 fn builder_from_card_mismatched_version() {
738 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
739
740 let card = AgentCard {
741 url: None,
742 name: "mismatch".into(),
743 version: "1.0".into(),
744 description: "Version mismatch test".into(),
745 supported_interfaces: vec![AgentInterface {
746 url: "http://localhost:9091".into(),
747 protocol_binding: "JSONRPC".into(),
748 protocol_version: "99.0.0".into(), tenant: None,
750 }],
751 provider: None,
752 icon_url: None,
753 documentation_url: None,
754 capabilities: AgentCapabilities::none(),
755 security_schemes: None,
756 security_requirements: None,
757 default_input_modes: vec![],
758 default_output_modes: vec![],
759 skills: vec![],
760 signatures: None,
761 };
762
763 let builder = ClientBuilder::from_card(&card).unwrap();
764 assert_eq!(builder.endpoint, "http://localhost:9091");
765 }
766
767 #[test]
770 fn version_mismatch_matching_major_returns_none() {
771 assert_eq!(protocol_version_mismatch("1.0.0"), None);
772 assert_eq!(protocol_version_mismatch("1.2.3"), None);
773 assert_eq!(protocol_version_mismatch("1"), None);
774 }
775
776 #[test]
777 fn version_mismatch_returns_original_on_mismatch() {
778 assert_eq!(protocol_version_mismatch("0.5.0"), Some("0.5.0"));
779 assert_eq!(protocol_version_mismatch("2.0.0"), Some("2.0.0"));
780 assert_eq!(protocol_version_mismatch("99.0.0"), Some("99.0.0"));
781 }
782
783 #[test]
784 fn version_mismatch_empty_is_compatible() {
785 assert_eq!(protocol_version_mismatch(""), None);
787 }
788
789 #[test]
790 fn version_mismatch_unparseable_is_incompatible() {
791 assert_eq!(
792 protocol_version_mismatch("not-a-version"),
793 Some("not-a-version")
794 );
795 assert_eq!(protocol_version_mismatch("v1.0.0"), Some("v1.0.0"));
796 assert_eq!(protocol_version_mismatch("1-preview"), Some("1-preview"));
797 }
798
799 #[test]
806 fn builder_from_card_preserves_tenant() {
807 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
808
809 let card = AgentCard {
810 url: None,
811 name: "multi-tenant".into(),
812 version: "1.0".into(),
813 description: "Multi-tenant agent".into(),
814 supported_interfaces: vec![AgentInterface {
815 url: "http://localhost:9092".into(),
816 protocol_binding: "JSONRPC".into(),
817 protocol_version: "1.0.0".into(),
818 tenant: Some("tenant-42".into()),
819 }],
820 provider: None,
821 icon_url: None,
822 documentation_url: None,
823 capabilities: AgentCapabilities::none(),
824 security_schemes: None,
825 security_requirements: None,
826 default_input_modes: vec![],
827 default_output_modes: vec![],
828 skills: vec![],
829 signatures: None,
830 };
831
832 let builder = ClientBuilder::from_card(&card).expect("from_card");
833 assert_eq!(
834 builder.config.tenant.as_deref(),
835 Some("tenant-42"),
836 "tenant from AgentInterface must be propagated to ClientConfig"
837 );
838 }
839
840 #[test]
841 fn builder_from_card_none_tenant_stays_none() {
842 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
843
844 let card = AgentCard {
845 url: None,
846 name: "no-tenant".into(),
847 version: "1.0".into(),
848 description: String::new(),
849 supported_interfaces: vec![AgentInterface {
850 url: "http://localhost:9093".into(),
851 protocol_binding: "JSONRPC".into(),
852 protocol_version: "1.0.0".into(),
853 tenant: None,
854 }],
855 provider: None,
856 icon_url: None,
857 documentation_url: None,
858 capabilities: AgentCapabilities::none(),
859 security_schemes: None,
860 security_requirements: None,
861 default_input_modes: vec![],
862 default_output_modes: vec![],
863 skills: vec![],
864 signatures: None,
865 };
866
867 let builder = ClientBuilder::from_card(&card).expect("from_card");
868 assert!(builder.config.tenant.is_none());
869 }
870
871 #[test]
873 fn builder_with_connection_timeout_and_retry_policy() {
874 use crate::retry::RetryPolicy;
875
876 let client = ClientBuilder::new("http://localhost:8080")
877 .with_connection_timeout(Duration::from_secs(5))
878 .with_retry_policy(RetryPolicy::default())
879 .build()
880 .expect("build");
881 assert_eq!(client.config().connection_timeout, Duration::from_secs(5));
882 }
883
884 #[test]
886 fn builder_with_stream_connect_timeout() {
887 let client = ClientBuilder::new("http://localhost:8080")
888 .with_stream_connect_timeout(Duration::from_secs(15))
889 .build()
890 .expect("build");
891 assert_eq!(
892 client.config().stream_connect_timeout,
893 Duration::from_secs(15)
894 );
895 }
896}