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#[cfg(feature = "tracing")]
50const SUPPORTED_PROTOCOL_MAJOR: u32 = 1;
51
52pub struct ClientBuilder {
59 pub(super) endpoint: String,
60 pub(super) transport_override: Option<Box<dyn Transport>>,
61 pub(super) interceptors: InterceptorChain,
62 pub(super) config: ClientConfig,
63 pub(super) preferred_binding: Option<String>,
64 pub(super) retry_policy: Option<RetryPolicy>,
65}
66
67impl ClientBuilder {
68 #[must_use]
73 pub fn new(endpoint: impl Into<String>) -> Self {
74 Self {
75 endpoint: endpoint.into(),
76 transport_override: None,
77 interceptors: InterceptorChain::new(),
78 config: ClientConfig::default(),
79 preferred_binding: None,
80 retry_policy: None,
81 }
82 }
83
84 pub fn from_card(card: &AgentCard) -> ClientResult<Self> {
94 let first = card.supported_interfaces.first().ok_or_else(|| {
95 ClientError::InvalidEndpoint("agent card has no supported interfaces".into())
96 })?;
97 let (endpoint, binding) = (first.url.clone(), first.protocol_binding.clone());
98
99 #[cfg(feature = "tracing")]
101 if let Some(version) = card
102 .supported_interfaces
103 .first()
104 .map(|i| i.protocol_version.clone())
105 .filter(|v| !v.is_empty())
106 {
107 let major = version
108 .split('.')
109 .next()
110 .and_then(|s| s.parse::<u32>().ok());
111 if major != Some(SUPPORTED_PROTOCOL_MAJOR) {
112 trace_warn!(
113 agent = %card.name,
114 protocol_version = %version,
115 supported_major = SUPPORTED_PROTOCOL_MAJOR,
116 "agent protocol version may be incompatible with this client"
117 );
118 }
119 }
120
121 Ok(Self {
122 endpoint,
123 transport_override: None,
124 interceptors: InterceptorChain::new(),
125 config: ClientConfig {
127 tenant: first.tenant.clone(),
128 ..ClientConfig::default()
129 },
130 preferred_binding: Some(binding),
131 retry_policy: None,
132 })
133 }
134
135 #[must_use]
139 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
140 self.config.request_timeout = timeout;
141 self
142 }
143
144 #[must_use]
149 pub const fn with_stream_connect_timeout(mut self, timeout: Duration) -> Self {
150 self.config.stream_connect_timeout = timeout;
151 self
152 }
153
154 #[must_use]
159 pub const fn with_connection_timeout(mut self, timeout: Duration) -> Self {
160 self.config.connection_timeout = timeout;
161 self
162 }
163
164 #[must_use]
168 pub fn with_protocol_binding(mut self, binding: impl Into<String>) -> Self {
169 self.preferred_binding = Some(binding.into());
170 self
171 }
172
173 #[must_use]
175 pub fn with_accepted_output_modes(mut self, modes: Vec<String>) -> Self {
176 self.config.accepted_output_modes = modes;
177 self
178 }
179
180 #[must_use]
182 pub const fn with_history_length(mut self, length: u32) -> Self {
183 self.config.history_length = Some(length);
184 self
185 }
186
187 #[must_use]
193 pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
194 self.config.tenant = Some(tenant.into());
195 self
196 }
197
198 #[must_use]
200 pub const fn with_return_immediately(mut self, val: bool) -> Self {
201 self.config.return_immediately = val;
202 self
203 }
204
205 #[must_use]
210 pub fn with_custom_transport(mut self, transport: impl Transport) -> Self {
211 self.transport_override = Some(Box::new(transport));
212 self
213 }
214
215 #[must_use]
217 pub const fn without_tls(mut self) -> Self {
218 self.config.tls = TlsConfig::Disabled;
219 self
220 }
221
222 #[must_use]
241 pub const fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
242 self.retry_policy = Some(policy);
243 self
244 }
245
246 #[must_use]
250 pub fn with_interceptor<I: CallInterceptor>(mut self, interceptor: I) -> Self {
251 self.interceptors.push(interceptor);
252 self
253 }
254}
255
256impl std::fmt::Debug for ClientBuilder {
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 f.debug_struct("ClientBuilder")
259 .field("endpoint", &self.endpoint)
260 .field("preferred_binding", &self.preferred_binding)
261 .finish_non_exhaustive()
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use std::time::Duration;
269
270 #[test]
271 fn builder_from_card_uses_card_url() {
272 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
273
274 let card = AgentCard {
275 url: None,
276 name: "test".into(),
277 version: "1.0".into(),
278 description: "A test agent".into(),
279 supported_interfaces: vec![AgentInterface {
280 url: "http://localhost:9090".into(),
281 protocol_binding: "JSONRPC".into(),
282 protocol_version: "1.0.0".into(),
283 tenant: None,
284 }],
285 provider: None,
286 icon_url: None,
287 documentation_url: None,
288 capabilities: AgentCapabilities::none(),
289 security_schemes: None,
290 security_requirements: None,
291 default_input_modes: vec![],
292 default_output_modes: vec![],
293 skills: vec![],
294 signatures: None,
295 };
296
297 let client = ClientBuilder::from_card(&card)
298 .unwrap()
299 .build()
300 .expect("build");
301 let _ = client;
302 }
303
304 #[test]
305 fn builder_with_timeout_sets_config() {
306 let client = ClientBuilder::new("http://localhost:8080")
307 .with_timeout(Duration::from_secs(60))
308 .build()
309 .expect("build");
310 assert_eq!(client.config().request_timeout, Duration::from_secs(60));
311 }
312
313 #[test]
314 fn builder_from_card_empty_interfaces_returns_error() {
315 use a2a_protocol_types::{AgentCapabilities, AgentCard};
316
317 let card = AgentCard {
318 url: None,
319 name: "empty".into(),
320 version: "1.0".into(),
321 description: "No interfaces".into(),
322 supported_interfaces: vec![],
323 provider: None,
324 icon_url: None,
325 documentation_url: None,
326 capabilities: AgentCapabilities::none(),
327 security_schemes: None,
328 security_requirements: None,
329 default_input_modes: vec![],
330 default_output_modes: vec![],
331 skills: vec![],
332 signatures: None,
333 };
334
335 let result = ClientBuilder::from_card(&card);
336 assert!(result.is_err(), "empty interfaces should return error");
337 }
338
339 #[test]
340 fn builder_with_return_immediately() {
341 let client = ClientBuilder::new("http://localhost:8080")
342 .with_return_immediately(true)
343 .build()
344 .expect("build");
345 assert!(client.config().return_immediately);
346 }
347
348 #[test]
349 fn builder_with_history_length() {
350 let client = ClientBuilder::new("http://localhost:8080")
351 .with_history_length(10)
352 .build()
353 .expect("build");
354 assert_eq!(client.config().history_length, Some(10));
355 }
356
357 #[test]
358 fn builder_debug_contains_fields() {
359 let builder = ClientBuilder::new("http://localhost:8080");
360 let debug = format!("{builder:?}");
361 assert!(
362 debug.contains("ClientBuilder"),
363 "debug output missing struct name: {debug}"
364 );
365 assert!(
366 debug.contains("http://localhost:8080"),
367 "debug output missing endpoint: {debug}"
368 );
369 }
370
371 #[test]
374 fn builder_from_card_mismatched_version() {
375 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
376
377 let card = AgentCard {
378 url: None,
379 name: "mismatch".into(),
380 version: "1.0".into(),
381 description: "Version mismatch test".into(),
382 supported_interfaces: vec![AgentInterface {
383 url: "http://localhost:9091".into(),
384 protocol_binding: "JSONRPC".into(),
385 protocol_version: "99.0.0".into(), tenant: None,
387 }],
388 provider: None,
389 icon_url: None,
390 documentation_url: None,
391 capabilities: AgentCapabilities::none(),
392 security_schemes: None,
393 security_requirements: None,
394 default_input_modes: vec![],
395 default_output_modes: vec![],
396 skills: vec![],
397 signatures: None,
398 };
399
400 let builder = ClientBuilder::from_card(&card).unwrap();
401 assert_eq!(builder.endpoint, "http://localhost:9091");
402 }
403
404 #[test]
406 fn builder_with_connection_timeout_and_retry_policy() {
407 use crate::retry::RetryPolicy;
408
409 let client = ClientBuilder::new("http://localhost:8080")
410 .with_connection_timeout(Duration::from_secs(5))
411 .with_retry_policy(RetryPolicy::default())
412 .build()
413 .expect("build");
414 assert_eq!(client.config().connection_timeout, Duration::from_secs(5));
415 }
416
417 #[test]
419 fn builder_with_stream_connect_timeout() {
420 let client = ClientBuilder::new("http://localhost:8080")
421 .with_stream_connect_timeout(Duration::from_secs(15))
422 .build()
423 .expect("build");
424 assert_eq!(
425 client.config().stream_connect_timeout,
426 Duration::from_secs(15)
427 );
428 }
429}