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::default(),
126 preferred_binding: Some(binding),
127 retry_policy: None,
128 })
129 }
130
131 #[must_use]
135 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
136 self.config.request_timeout = timeout;
137 self
138 }
139
140 #[must_use]
145 pub const fn with_stream_connect_timeout(mut self, timeout: Duration) -> Self {
146 self.config.stream_connect_timeout = timeout;
147 self
148 }
149
150 #[must_use]
155 pub const fn with_connection_timeout(mut self, timeout: Duration) -> Self {
156 self.config.connection_timeout = timeout;
157 self
158 }
159
160 #[must_use]
164 pub fn with_protocol_binding(mut self, binding: impl Into<String>) -> Self {
165 self.preferred_binding = Some(binding.into());
166 self
167 }
168
169 #[must_use]
171 pub fn with_accepted_output_modes(mut self, modes: Vec<String>) -> Self {
172 self.config.accepted_output_modes = modes;
173 self
174 }
175
176 #[must_use]
178 pub const fn with_history_length(mut self, length: u32) -> Self {
179 self.config.history_length = Some(length);
180 self
181 }
182
183 #[must_use]
185 pub const fn with_return_immediately(mut self, val: bool) -> Self {
186 self.config.return_immediately = val;
187 self
188 }
189
190 #[must_use]
195 pub fn with_custom_transport(mut self, transport: impl Transport) -> Self {
196 self.transport_override = Some(Box::new(transport));
197 self
198 }
199
200 #[must_use]
202 pub const fn without_tls(mut self) -> Self {
203 self.config.tls = TlsConfig::Disabled;
204 self
205 }
206
207 #[must_use]
226 pub const fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
227 self.retry_policy = Some(policy);
228 self
229 }
230
231 #[must_use]
235 pub fn with_interceptor<I: CallInterceptor>(mut self, interceptor: I) -> Self {
236 self.interceptors.push(interceptor);
237 self
238 }
239}
240
241impl std::fmt::Debug for ClientBuilder {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 f.debug_struct("ClientBuilder")
244 .field("endpoint", &self.endpoint)
245 .field("preferred_binding", &self.preferred_binding)
246 .finish_non_exhaustive()
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use std::time::Duration;
254
255 #[test]
256 fn builder_from_card_uses_card_url() {
257 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
258
259 let card = AgentCard {
260 url: None,
261 name: "test".into(),
262 version: "1.0".into(),
263 description: "A test agent".into(),
264 supported_interfaces: vec![AgentInterface {
265 url: "http://localhost:9090".into(),
266 protocol_binding: "JSONRPC".into(),
267 protocol_version: "1.0.0".into(),
268 tenant: None,
269 }],
270 provider: None,
271 icon_url: None,
272 documentation_url: None,
273 capabilities: AgentCapabilities::none(),
274 security_schemes: None,
275 security_requirements: None,
276 default_input_modes: vec![],
277 default_output_modes: vec![],
278 skills: vec![],
279 signatures: None,
280 };
281
282 let client = ClientBuilder::from_card(&card)
283 .unwrap()
284 .build()
285 .expect("build");
286 let _ = client;
287 }
288
289 #[test]
290 fn builder_with_timeout_sets_config() {
291 let client = ClientBuilder::new("http://localhost:8080")
292 .with_timeout(Duration::from_secs(60))
293 .build()
294 .expect("build");
295 assert_eq!(client.config().request_timeout, Duration::from_secs(60));
296 }
297
298 #[test]
299 fn builder_from_card_empty_interfaces_returns_error() {
300 use a2a_protocol_types::{AgentCapabilities, AgentCard};
301
302 let card = AgentCard {
303 url: None,
304 name: "empty".into(),
305 version: "1.0".into(),
306 description: "No interfaces".into(),
307 supported_interfaces: vec![],
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 result = ClientBuilder::from_card(&card);
321 assert!(result.is_err(), "empty interfaces should return error");
322 }
323
324 #[test]
325 fn builder_with_return_immediately() {
326 let client = ClientBuilder::new("http://localhost:8080")
327 .with_return_immediately(true)
328 .build()
329 .expect("build");
330 assert!(client.config().return_immediately);
331 }
332
333 #[test]
334 fn builder_with_history_length() {
335 let client = ClientBuilder::new("http://localhost:8080")
336 .with_history_length(10)
337 .build()
338 .expect("build");
339 assert_eq!(client.config().history_length, Some(10));
340 }
341
342 #[test]
343 fn builder_debug_contains_fields() {
344 let builder = ClientBuilder::new("http://localhost:8080");
345 let debug = format!("{builder:?}");
346 assert!(
347 debug.contains("ClientBuilder"),
348 "debug output missing struct name: {debug}"
349 );
350 assert!(
351 debug.contains("http://localhost:8080"),
352 "debug output missing endpoint: {debug}"
353 );
354 }
355
356 #[test]
359 fn builder_from_card_mismatched_version() {
360 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
361
362 let card = AgentCard {
363 url: None,
364 name: "mismatch".into(),
365 version: "1.0".into(),
366 description: "Version mismatch test".into(),
367 supported_interfaces: vec![AgentInterface {
368 url: "http://localhost:9091".into(),
369 protocol_binding: "JSONRPC".into(),
370 protocol_version: "99.0.0".into(), tenant: None,
372 }],
373 provider: None,
374 icon_url: None,
375 documentation_url: None,
376 capabilities: AgentCapabilities::none(),
377 security_schemes: None,
378 security_requirements: None,
379 default_input_modes: vec![],
380 default_output_modes: vec![],
381 skills: vec![],
382 signatures: None,
383 };
384
385 let builder = ClientBuilder::from_card(&card).unwrap();
386 assert_eq!(builder.endpoint, "http://localhost:9091");
387 }
388
389 #[test]
391 fn builder_with_connection_timeout_and_retry_policy() {
392 use crate::retry::RetryPolicy;
393
394 let client = ClientBuilder::new("http://localhost:8080")
395 .with_connection_timeout(Duration::from_secs(5))
396 .with_retry_policy(RetryPolicy::default())
397 .build()
398 .expect("build");
399 assert_eq!(client.config().connection_timeout, Duration::from_secs(5));
400 }
401
402 #[test]
404 fn builder_with_stream_connect_timeout() {
405 let client = ClientBuilder::new("http://localhost:8080")
406 .with_stream_connect_timeout(Duration::from_secs(15))
407 .build()
408 .expect("build");
409 assert_eq!(
410 client.config().stream_connect_timeout,
411 Duration::from_secs(15)
412 );
413 }
414}