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]
191 pub fn with_protocol_binding(mut self, binding: impl Into<String>) -> Self {
192 self.preferred_binding = Some(binding.into());
193 self
194 }
195
196 #[must_use]
198 pub fn with_accepted_output_modes(mut self, modes: Vec<String>) -> Self {
199 self.config.accepted_output_modes = modes;
200 self
201 }
202
203 #[must_use]
205 pub const fn with_history_length(mut self, length: u32) -> Self {
206 self.config.history_length = Some(length);
207 self
208 }
209
210 #[must_use]
216 pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
217 self.config.tenant = Some(tenant.into());
218 self
219 }
220
221 #[must_use]
223 pub const fn with_return_immediately(mut self, val: bool) -> Self {
224 self.config.return_immediately = val;
225 self
226 }
227
228 #[must_use]
233 pub fn with_custom_transport(mut self, transport: impl Transport) -> Self {
234 self.transport_override = Some(Box::new(transport));
235 self
236 }
237
238 #[must_use]
240 pub const fn without_tls(mut self) -> Self {
241 self.config.tls = TlsConfig::Disabled;
242 self
243 }
244
245 #[must_use]
264 pub const fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
265 self.retry_policy = Some(policy);
266 self
267 }
268
269 #[must_use]
273 pub fn with_interceptor<I: CallInterceptor>(mut self, interceptor: I) -> Self {
274 self.interceptors.push(interceptor);
275 self
276 }
277}
278
279impl std::fmt::Debug for ClientBuilder {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 f.debug_struct("ClientBuilder")
282 .field("endpoint", &self.endpoint)
283 .field("preferred_binding", &self.preferred_binding)
284 .finish_non_exhaustive()
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use std::time::Duration;
292
293 #[test]
294 fn builder_from_card_uses_card_url() {
295 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
296
297 let card = AgentCard {
298 url: None,
299 name: "test".into(),
300 version: "1.0".into(),
301 description: "A test agent".into(),
302 supported_interfaces: vec![AgentInterface {
303 url: "http://localhost:9090".into(),
304 protocol_binding: "JSONRPC".into(),
305 protocol_version: "1.0.0".into(),
306 tenant: None,
307 }],
308 provider: None,
309 icon_url: None,
310 documentation_url: None,
311 capabilities: AgentCapabilities::none(),
312 security_schemes: None,
313 security_requirements: None,
314 default_input_modes: vec![],
315 default_output_modes: vec![],
316 skills: vec![],
317 signatures: None,
318 };
319
320 let client = ClientBuilder::from_card(&card)
321 .unwrap()
322 .build()
323 .expect("build");
324 let _ = client;
325 }
326
327 #[test]
328 fn builder_with_timeout_sets_config() {
329 let client = ClientBuilder::new("http://localhost:8080")
330 .with_timeout(Duration::from_secs(60))
331 .build()
332 .expect("build");
333 assert_eq!(client.config().request_timeout, Duration::from_secs(60));
334 }
335
336 #[test]
337 fn builder_from_card_empty_interfaces_returns_error() {
338 use a2a_protocol_types::{AgentCapabilities, AgentCard};
339
340 let card = AgentCard {
341 url: None,
342 name: "empty".into(),
343 version: "1.0".into(),
344 description: "No interfaces".into(),
345 supported_interfaces: vec![],
346 provider: None,
347 icon_url: None,
348 documentation_url: None,
349 capabilities: AgentCapabilities::none(),
350 security_schemes: None,
351 security_requirements: None,
352 default_input_modes: vec![],
353 default_output_modes: vec![],
354 skills: vec![],
355 signatures: None,
356 };
357
358 let result = ClientBuilder::from_card(&card);
359 assert!(result.is_err(), "empty interfaces should return error");
360 }
361
362 #[test]
363 fn builder_with_return_immediately() {
364 let client = ClientBuilder::new("http://localhost:8080")
365 .with_return_immediately(true)
366 .build()
367 .expect("build");
368 assert!(client.config().return_immediately);
369 }
370
371 #[test]
372 fn builder_with_history_length() {
373 let client = ClientBuilder::new("http://localhost:8080")
374 .with_history_length(10)
375 .build()
376 .expect("build");
377 assert_eq!(client.config().history_length, Some(10));
378 }
379
380 #[test]
381 fn builder_debug_contains_fields() {
382 let builder = ClientBuilder::new("http://localhost:8080");
383 let debug = format!("{builder:?}");
384 assert!(
385 debug.contains("ClientBuilder"),
386 "debug output missing struct name: {debug}"
387 );
388 assert!(
389 debug.contains("http://localhost:8080"),
390 "debug output missing endpoint: {debug}"
391 );
392 }
393
394 #[test]
397 fn builder_from_card_mismatched_version() {
398 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
399
400 let card = AgentCard {
401 url: None,
402 name: "mismatch".into(),
403 version: "1.0".into(),
404 description: "Version mismatch test".into(),
405 supported_interfaces: vec![AgentInterface {
406 url: "http://localhost:9091".into(),
407 protocol_binding: "JSONRPC".into(),
408 protocol_version: "99.0.0".into(), tenant: None,
410 }],
411 provider: None,
412 icon_url: None,
413 documentation_url: None,
414 capabilities: AgentCapabilities::none(),
415 security_schemes: None,
416 security_requirements: None,
417 default_input_modes: vec![],
418 default_output_modes: vec![],
419 skills: vec![],
420 signatures: None,
421 };
422
423 let builder = ClientBuilder::from_card(&card).unwrap();
424 assert_eq!(builder.endpoint, "http://localhost:9091");
425 }
426
427 #[test]
430 fn version_mismatch_matching_major_returns_none() {
431 assert_eq!(protocol_version_mismatch("1.0.0"), None);
432 assert_eq!(protocol_version_mismatch("1.2.3"), None);
433 assert_eq!(protocol_version_mismatch("1"), None);
434 }
435
436 #[test]
437 fn version_mismatch_returns_original_on_mismatch() {
438 assert_eq!(protocol_version_mismatch("0.5.0"), Some("0.5.0"));
439 assert_eq!(protocol_version_mismatch("2.0.0"), Some("2.0.0"));
440 assert_eq!(protocol_version_mismatch("99.0.0"), Some("99.0.0"));
441 }
442
443 #[test]
444 fn version_mismatch_empty_is_compatible() {
445 assert_eq!(protocol_version_mismatch(""), None);
447 }
448
449 #[test]
450 fn version_mismatch_unparseable_is_incompatible() {
451 assert_eq!(
452 protocol_version_mismatch("not-a-version"),
453 Some("not-a-version")
454 );
455 assert_eq!(protocol_version_mismatch("v1.0.0"), Some("v1.0.0"));
456 assert_eq!(protocol_version_mismatch("1-preview"), Some("1-preview"));
457 }
458
459 #[test]
466 fn builder_from_card_preserves_tenant() {
467 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
468
469 let card = AgentCard {
470 url: None,
471 name: "multi-tenant".into(),
472 version: "1.0".into(),
473 description: "Multi-tenant agent".into(),
474 supported_interfaces: vec![AgentInterface {
475 url: "http://localhost:9092".into(),
476 protocol_binding: "JSONRPC".into(),
477 protocol_version: "1.0.0".into(),
478 tenant: Some("tenant-42".into()),
479 }],
480 provider: None,
481 icon_url: None,
482 documentation_url: None,
483 capabilities: AgentCapabilities::none(),
484 security_schemes: None,
485 security_requirements: None,
486 default_input_modes: vec![],
487 default_output_modes: vec![],
488 skills: vec![],
489 signatures: None,
490 };
491
492 let builder = ClientBuilder::from_card(&card).expect("from_card");
493 assert_eq!(
494 builder.config.tenant.as_deref(),
495 Some("tenant-42"),
496 "tenant from AgentInterface must be propagated to ClientConfig"
497 );
498 }
499
500 #[test]
501 fn builder_from_card_none_tenant_stays_none() {
502 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
503
504 let card = AgentCard {
505 url: None,
506 name: "no-tenant".into(),
507 version: "1.0".into(),
508 description: String::new(),
509 supported_interfaces: vec![AgentInterface {
510 url: "http://localhost:9093".into(),
511 protocol_binding: "JSONRPC".into(),
512 protocol_version: "1.0.0".into(),
513 tenant: None,
514 }],
515 provider: None,
516 icon_url: None,
517 documentation_url: None,
518 capabilities: AgentCapabilities::none(),
519 security_schemes: None,
520 security_requirements: None,
521 default_input_modes: vec![],
522 default_output_modes: vec![],
523 skills: vec![],
524 signatures: None,
525 };
526
527 let builder = ClientBuilder::from_card(&card).expect("from_card");
528 assert!(builder.config.tenant.is_none());
529 }
530
531 #[test]
533 fn builder_with_connection_timeout_and_retry_policy() {
534 use crate::retry::RetryPolicy;
535
536 let client = ClientBuilder::new("http://localhost:8080")
537 .with_connection_timeout(Duration::from_secs(5))
538 .with_retry_policy(RetryPolicy::default())
539 .build()
540 .expect("build");
541 assert_eq!(client.config().connection_timeout, Duration::from_secs(5));
542 }
543
544 #[test]
546 fn builder_with_stream_connect_timeout() {
547 let client = ClientBuilder::new("http://localhost:8080")
548 .with_stream_connect_timeout(Duration::from_secs(15))
549 .build()
550 .expect("build");
551 assert_eq!(
552 client.config().stream_connect_timeout,
553 Duration::from_secs(15)
554 );
555 }
556}