Skip to content

Commit cecb28d

Browse files
committed
refactor(client)!: reorganize ClientFactory API
1 parent a61f6d4 commit cecb28d

6 files changed

Lines changed: 287 additions & 190 deletions

File tree

itk/main.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from pyproto import instruction_pb2
1414

15-
from a2a.client import ClientConfig, ClientFactory
15+
from a2a.client import ClientConfig, create_client
1616
from a2a.compat.v0_3 import a2a_v0_3_pb2_grpc
1717
from a2a.compat.v0_3.grpc_handler import CompatGrpcHandler
1818
from a2a.server.agent_execution import AgentExecutor, RequestContext
@@ -128,10 +128,7 @@ async def handle_call_agent(call: instruction_pb2.CallAgent) -> list[str]:
128128
)
129129

130130
try:
131-
client = await ClientFactory.connect(
132-
call.agent_card_uri,
133-
client_config=config,
134-
)
131+
client = await create_client(call.agent_card_uri, client_config=config)
135132

136133
# Wrap nested instruction
137134
async with client:

samples/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import grpc
1010
import httpx
1111

12-
from a2a.client import A2ACardResolver, ClientConfig, ClientFactory
12+
from a2a.client import A2ACardResolver, ClientConfig, create_client
1313
from a2a.types import Message, Part, Role, SendMessageRequest, TaskState
1414

1515

@@ -79,7 +79,7 @@ async def main() -> None:
7979
print('\n✓ Agent Card Found:')
8080
print(f' Name: {card.name}')
8181

82-
client = await ClientFactory.connect(card, client_config=config)
82+
client = await create_client(card, client_config=config)
8383

8484
actual_transport = getattr(client, '_transport', client)
8585
print(f' Picked Transport: {actual_transport.__class__.__name__}')

src/a2a/client/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
ClientCallContext,
1313
ClientConfig,
1414
)
15-
from a2a.client.client_factory import ClientFactory, minimal_agent_card
15+
from a2a.client.client_factory import (
16+
ClientFactory,
17+
create_client,
18+
minimal_agent_card,
19+
)
1620
from a2a.client.errors import (
1721
A2AClientError,
1822
A2AClientTimeoutError,
@@ -36,6 +40,7 @@
3640
'ClientFactory',
3741
'CredentialService',
3842
'InMemoryContextCredentialStore',
43+
'create_client',
3944
'create_text_message_object',
4045
'minimal_agent_card',
4146
]

src/a2a/client/client_factory.py

Lines changed: 162 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -56,35 +56,146 @@
5656

5757

5858
class ClientFactory:
59-
"""ClientFactory is used to generate the appropriate client for the agent.
59+
"""Factory for creating clients that communicate with A2A agents.
6060
61-
The factory is configured with a `ClientConfig` and optionally a list of
62-
`Consumer`s to use for all generated `Client`s. The expected use is:
63-
64-
.. code-block:: python
61+
The factory is configured with a `ClientConfig` and optionally custom
62+
transport producers registered via `register`. Example usage:
6563
6664
factory = ClientFactory(config)
67-
# Optionally register custom client implementations
68-
factory.register('my_customer_transport', NewCustomTransportClient)
69-
# Then with an agent card make a client with additional interceptors
65+
# Optionally register custom transport implementations
66+
factory.register('my_custom_transport', custom_transport_producer)
67+
# Create a client from an AgentCard
7068
client = factory.create(card, interceptors)
69+
# Or resolve an AgentCard from a URL and create a client
70+
client = await factory.create_from_url('https://example.com')
7171
72-
Now the client can be used consistently regardless of the transport. This
72+
The client can be used consistently regardless of the transport. This
7373
aligns the client configuration with the server's capabilities.
7474
"""
7575

7676
def __init__(
7777
self,
7878
config: ClientConfig,
7979
):
80-
client = config.httpx_client or httpx.AsyncClient()
81-
client.headers.setdefault(VERSION_HEADER, PROTOCOL_VERSION_CURRENT)
82-
config.httpx_client = client
80+
httpx_client = config.httpx_client or httpx.AsyncClient()
81+
httpx_client.headers.setdefault(
82+
VERSION_HEADER, PROTOCOL_VERSION_CURRENT
83+
)
84+
config.httpx_client = httpx_client
8385

8486
self._config = config
87+
self._httpx_client = httpx_client
8588
self._registry: dict[str, TransportProducer] = {}
8689
self._register_defaults(config.supported_protocol_bindings)
8790

91+
def register(self, label: str, generator: TransportProducer) -> None:
92+
"""Register a new transport producer for a given transport label."""
93+
self._registry[label] = generator
94+
95+
def create(
96+
self,
97+
card: AgentCard,
98+
interceptors: list[ClientCallInterceptor] | None = None,
99+
) -> Client:
100+
"""Create a new `Client` for the provided `AgentCard`.
101+
102+
Args:
103+
card: An `AgentCard` defining the characteristics of the agent.
104+
interceptors: A list of interceptors to use for each request. These
105+
are used for things like attaching credentials or http headers
106+
to all outbound requests.
107+
108+
Returns:
109+
A `Client` object.
110+
111+
Raises:
112+
If there is no valid matching of the client configuration with the
113+
server configuration, a `ValueError` is raised.
114+
"""
115+
client_set = self._config.supported_protocol_bindings or [
116+
TransportProtocol.JSONRPC
117+
]
118+
transport_protocol = None
119+
selected_interface = None
120+
if self._config.use_client_preference:
121+
for protocol_binding in client_set:
122+
selected_interface = ClientFactory._find_best_interface(
123+
list(card.supported_interfaces),
124+
protocol_bindings=[protocol_binding],
125+
)
126+
if selected_interface:
127+
transport_protocol = protocol_binding
128+
break
129+
else:
130+
for supported_interface in card.supported_interfaces:
131+
if supported_interface.protocol_binding in client_set:
132+
transport_protocol = supported_interface.protocol_binding
133+
selected_interface = ClientFactory._find_best_interface(
134+
list(card.supported_interfaces),
135+
protocol_bindings=[transport_protocol],
136+
)
137+
break
138+
if not transport_protocol or not selected_interface:
139+
raise ValueError('no compatible transports found.')
140+
if transport_protocol not in self._registry:
141+
raise ValueError(f'no client available for {transport_protocol}')
142+
143+
transport = self._registry[transport_protocol](
144+
card, selected_interface.url, self._config
145+
)
146+
147+
if selected_interface.tenant:
148+
transport = TenantTransportDecorator(
149+
transport, selected_interface.tenant
150+
)
151+
152+
return BaseClient(
153+
card,
154+
self._config,
155+
transport,
156+
interceptors or [],
157+
)
158+
159+
async def create_from_url(
160+
self,
161+
url: str,
162+
interceptors: list[ClientCallInterceptor] | None = None,
163+
relative_card_path: str | None = None,
164+
resolver_http_kwargs: dict[str, Any] | None = None,
165+
signature_verifier: Callable[[AgentCard], None] | None = None,
166+
) -> Client:
167+
"""Create a `Client` by resolving an `AgentCard` from a URL.
168+
169+
Resolves the agent card from the given URL using the factory's
170+
configured httpx client, then creates a client via `create`.
171+
172+
If the agent card is already available, use `create` directly
173+
instead.
174+
175+
Args:
176+
url: The base URL of the agent. The agent card will be fetched
177+
from `<url>/.well-known/agent-card.json` by default.
178+
interceptors: A list of interceptors to use for each request.
179+
These are used for things like attaching credentials or http
180+
headers to all outbound requests.
181+
relative_card_path: The relative path when resolving the agent
182+
card. See `A2ACardResolver.get_agent_card` for details.
183+
resolver_http_kwargs: Dictionary of arguments to provide to the
184+
httpx client when resolving the agent card.
185+
signature_verifier: A callable used to verify the agent card's
186+
signatures.
187+
188+
Returns:
189+
A `Client` object.
190+
"""
191+
resolver = A2ACardResolver(self._httpx_client, url)
192+
card = await resolver.get_agent_card(
193+
relative_card_path=relative_card_path,
194+
http_kwargs=resolver_http_kwargs,
195+
signature_verifier=signature_verifier,
196+
)
197+
return self.create(card, interceptors)
198+
88199
def _register_defaults(self, supported: list[str]) -> None:
89200
# Empty support list implies JSON-RPC only.
90201

@@ -252,141 +363,47 @@ def _find_best_interface(
252363

253364
return best_gt_1_0 or best_ge_0_3 or best_no_version
254365

255-
@classmethod
256-
async def connect( # noqa: PLR0913
257-
cls,
258-
agent: str | AgentCard,
259-
client_config: ClientConfig | None = None,
260-
interceptors: list[ClientCallInterceptor] | None = None,
261-
relative_card_path: str | None = None,
262-
resolver_http_kwargs: dict[str, Any] | None = None,
263-
extra_transports: dict[str, TransportProducer] | None = None,
264-
signature_verifier: Callable[[AgentCard], None] | None = None,
265-
) -> Client:
266-
"""Convenience method for constructing a client.
267-
268-
Constructs a client that connects to the specified agent. Note that
269-
creating multiple clients via this method is less efficient than
270-
constructing an instance of ClientFactory and reusing that.
271-
272-
.. code-block:: python
273-
274-
# This will search for an AgentCard at /.well-known/agent-card.json
275-
my_agent_url = 'https://travel.agents.example.com'
276-
client = await ClientFactory.connect(my_agent_url)
277-
278-
279-
Args:
280-
agent: The base URL of the agent, or the AgentCard to connect to.
281-
client_config: The ClientConfig to use when connecting to the agent.
282-
283-
interceptors: A list of interceptors to use for each request. These
284-
are used for things like attaching credentials or http headers
285-
to all outbound requests.
286-
relative_card_path: If the agent field is a URL, this value is used as
287-
the relative path when resolving the agent card. See
288-
A2AAgentCardResolver.get_agent_card for more details.
289-
resolver_http_kwargs: Dictionary of arguments to provide to the httpx
290-
client when resolving the agent card. This value is provided to
291-
A2AAgentCardResolver.get_agent_card as the http_kwargs parameter.
292-
extra_transports: Additional transport protocols to enable when
293-
constructing the client.
294-
signature_verifier: A callable used to verify the agent card's signatures.
295-
296-
Returns:
297-
A `Client` object.
298-
"""
299-
client_config = client_config or ClientConfig()
300-
if isinstance(agent, str):
301-
if not client_config.httpx_client:
302-
async with httpx.AsyncClient() as client:
303-
resolver = A2ACardResolver(client, agent)
304-
card = await resolver.get_agent_card(
305-
relative_card_path=relative_card_path,
306-
http_kwargs=resolver_http_kwargs,
307-
signature_verifier=signature_verifier,
308-
)
309-
else:
310-
resolver = A2ACardResolver(client_config.httpx_client, agent)
311-
card = await resolver.get_agent_card(
312-
relative_card_path=relative_card_path,
313-
http_kwargs=resolver_http_kwargs,
314-
signature_verifier=signature_verifier,
315-
)
316-
else:
317-
card = agent
318-
factory = cls(client_config)
319-
for label, generator in (extra_transports or {}).items():
320-
factory.register(label, generator)
321-
return factory.create(card, interceptors)
322-
323-
def register(self, label: str, generator: TransportProducer) -> None:
324-
"""Register a new transport producer for a given transport label."""
325-
self._registry[label] = generator
326-
327-
def create(
328-
self,
329-
card: AgentCard,
330-
interceptors: list[ClientCallInterceptor] | None = None,
331-
) -> Client:
332-
"""Create a new `Client` for the provided `AgentCard`.
333-
334-
Args:
335-
card: An `AgentCard` defining the characteristics of the agent.
336-
interceptors: A list of interceptors to use for each request. These
337-
are used for things like attaching credentials or http headers
338-
to all outbound requests.
339366

340-
Returns:
341-
A `Client` object.
342-
343-
Raises:
344-
If there is no valid matching of the client configuration with the
345-
server configuration, a `ValueError` is raised.
346-
"""
347-
client_set = self._config.supported_protocol_bindings or [
348-
TransportProtocol.JSONRPC
349-
]
350-
transport_protocol = None
351-
selected_interface = None
352-
if self._config.use_client_preference:
353-
for protocol_binding in client_set:
354-
selected_interface = ClientFactory._find_best_interface(
355-
list(card.supported_interfaces),
356-
protocol_bindings=[protocol_binding],
357-
)
358-
if selected_interface:
359-
transport_protocol = protocol_binding
360-
break
361-
else:
362-
for supported_interface in card.supported_interfaces:
363-
if supported_interface.protocol_binding in client_set:
364-
transport_protocol = supported_interface.protocol_binding
365-
selected_interface = ClientFactory._find_best_interface(
366-
list(card.supported_interfaces),
367-
protocol_bindings=[transport_protocol],
368-
)
369-
break
370-
if not transport_protocol or not selected_interface:
371-
raise ValueError('no compatible transports found.')
372-
if transport_protocol not in self._registry:
373-
raise ValueError(f'no client available for {transport_protocol}')
374-
375-
transport = self._registry[transport_protocol](
376-
card, selected_interface.url, self._config
377-
)
378-
379-
if selected_interface.tenant:
380-
transport = TenantTransportDecorator(
381-
transport, selected_interface.tenant
382-
)
383-
384-
return BaseClient(
385-
card,
386-
self._config,
387-
transport,
388-
interceptors or [],
367+
async def create_client( # noqa: PLR0913
368+
agent: str | AgentCard,
369+
client_config: ClientConfig | None = None,
370+
interceptors: list[ClientCallInterceptor] | None = None,
371+
relative_card_path: str | None = None,
372+
resolver_http_kwargs: dict[str, Any] | None = None,
373+
signature_verifier: Callable[[AgentCard], None] | None = None,
374+
) -> Client:
375+
"""Create a `Client` for an agent from a URL or `AgentCard`.
376+
377+
Convenience function that constructs a `ClientFactory` internally.
378+
For reusing a factory across multiple agents or registering custom
379+
transports, use `ClientFactory` directly instead.
380+
381+
Args:
382+
agent: The base URL of the agent, or an `AgentCard` to use
383+
directly.
384+
client_config: Optional `ClientConfig`. A default config is
385+
created if not provided.
386+
interceptors: A list of interceptors to use for each request.
387+
relative_card_path: The relative path when resolving the agent
388+
card. Only used when `agent` is a URL.
389+
resolver_http_kwargs: Dictionary of arguments to provide to the
390+
httpx client when resolving the agent card.
391+
signature_verifier: A callable used to verify the agent card's
392+
signatures.
393+
394+
Returns:
395+
A `Client` object.
396+
"""
397+
factory = ClientFactory(client_config or ClientConfig())
398+
if isinstance(agent, str):
399+
return await factory.create_from_url(
400+
agent,
401+
interceptors=interceptors,
402+
relative_card_path=relative_card_path,
403+
resolver_http_kwargs=resolver_http_kwargs,
404+
signature_verifier=signature_verifier,
389405
)
406+
return factory.create(agent, interceptors)
390407

391408

392409
def minimal_agent_card(

0 commit comments

Comments
 (0)