Skip to content

Commit b6eb107

Browse files
authored
refactor(client): remove URL resolution logic from transports (#732)
Rely on the `ClientFactory` to resolve proper URL and do not duplicate logic in transports. `AgentCard` is still passed to transports as it's used for capabilities inspection. Make both `agent_card` and `url` mandatory, transports are mainly used from the `ClientFactory` and `| None` are likely non-breaking leftovers. Fixes #703
1 parent 54f50c3 commit b6eb107

6 files changed

Lines changed: 173 additions & 247 deletions

File tree

src/a2a/client/transports/jsonrpc.py

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from httpx_sse import SSEError, aconnect_sse
1212
from jsonrpc.jsonrpc2 import JSONRPC20Request, JSONRPC20Response
1313

14-
from a2a.client.card_resolver import A2ACardResolver
1514
from a2a.client.errors import (
1615
A2AClientHTTPError,
1716
A2AClientJSONError,
@@ -50,31 +49,18 @@ class JsonRpcTransport(ClientTransport):
5049
def __init__(
5150
self,
5251
httpx_client: httpx.AsyncClient,
53-
agent_card: AgentCard | None = None,
54-
url: str | None = None,
52+
agent_card: AgentCard,
53+
url: str,
5554
interceptors: list[ClientCallInterceptor] | None = None,
5655
extensions: list[str] | None = None,
5756
):
5857
"""Initializes the JsonRpcTransport."""
59-
if url:
60-
self.url = url
61-
elif agent_card:
62-
if agent_card.supported_interfaces:
63-
self.url = agent_card.supported_interfaces[0].url
64-
else:
65-
# Fallback or error if no interfaces?
66-
# For compatibility we might check if 'url' attr exists (it does not on proto anymore)
67-
raise ValueError('AgentCard has no supported interfaces')
68-
else:
69-
raise ValueError('Must provide either agent_card or url')
70-
58+
self.url = url
7159
self.httpx_client = httpx_client
7260
self.agent_card = agent_card
7361
self.interceptors = interceptors or []
7462
self.extensions = extensions
75-
self._needs_extended_card = (
76-
agent_card.capabilities.extended_agent_card if agent_card else True
77-
)
63+
self._needs_extended_card = agent_card.capabilities.extended_agent_card
7864

7965
async def _apply_interceptors(
8066
self,
@@ -447,15 +433,6 @@ async def get_extended_agent_card(
447433

448434
card = self.agent_card
449435

450-
if not card:
451-
resolver = A2ACardResolver(self.httpx_client, self.url)
452-
card = await resolver.get_agent_card(
453-
http_kwargs=modified_kwargs,
454-
signature_verifier=signature_verifier,
455-
)
456-
self.agent_card = card
457-
self._needs_extended_card = card.capabilities.extended_agent_card
458-
459436
if not card.capabilities.extended_agent_card:
460437
return card
461438

src/a2a/client/transports/rest.py

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from google.protobuf.message import Message
1111
from httpx_sse import SSEError, aconnect_sse
1212

13-
from a2a.client.card_resolver import A2ACardResolver
1413
from a2a.client.errors import (
1514
A2AClientHTTPError,
1615
A2AClientJSONError,
@@ -34,9 +33,6 @@
3433
Task,
3534
TaskPushNotificationConfig,
3635
)
37-
from a2a.utils.constants import (
38-
TransportProtocol,
39-
)
4036
from a2a.utils.telemetry import SpanKind, trace_class
4137

4238

@@ -50,37 +46,17 @@ class RestTransport(ClientTransport):
5046
def __init__(
5147
self,
5248
httpx_client: httpx.AsyncClient,
53-
agent_card: AgentCard | None = None,
54-
url: str | None = None,
49+
agent_card: AgentCard,
50+
url: str,
5551
interceptors: list[ClientCallInterceptor] | None = None,
5652
extensions: list[str] | None = None,
5753
):
5854
"""Initializes the RestTransport."""
59-
if url:
60-
self.url = url
61-
elif agent_card:
62-
for interface in agent_card.supported_interfaces:
63-
if interface.protocol_binding in (
64-
TransportProtocol.HTTP_JSON,
65-
TransportProtocol.JSONRPC,
66-
):
67-
self.url = interface.url
68-
break
69-
else:
70-
raise ValueError(
71-
f'AgentCard does not support {TransportProtocol.HTTP_JSON} '
72-
f'or {TransportProtocol.JSONRPC}'
73-
)
74-
else:
75-
raise ValueError('Must provide either agent_card or url')
76-
if self.url.endswith('/'):
77-
self.url = self.url[:-1]
55+
self.url = url.removesuffix('/')
7856
self.httpx_client = httpx_client
7957
self.agent_card = agent_card
8058
self.interceptors = interceptors or []
81-
self._needs_extended_card = (
82-
agent_card.capabilities.extended_agent_card if agent_card else True
83-
)
59+
self._needs_extended_card = agent_card.capabilities.extended_agent_card
8460
self.extensions = extensions
8561

8662
async def _apply_interceptors(
@@ -416,15 +392,6 @@ async def get_extended_agent_card(
416392

417393
card = self.agent_card
418394

419-
if not card:
420-
resolver = A2ACardResolver(self.httpx_client, self.url)
421-
card = await resolver.get_agent_card(
422-
http_kwargs=modified_kwargs,
423-
signature_verifier=signature_verifier,
424-
)
425-
self.agent_card = card
426-
self._needs_extended_card = card.capabilities.extended_agent_card
427-
428395
if not card.capabilities.extended_agent_card:
429396
return card
430397
_, modified_kwargs = await self._apply_interceptors(

tests/client/transports/test_jsonrpc_client.py

Lines changed: 9 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def transport(mock_httpx_client, agent_card):
7070
return JsonRpcTransport(
7171
httpx_client=mock_httpx_client,
7272
agent_card=agent_card,
73+
url='http://test-agent.example.com',
7374
)
7475

7576

@@ -78,6 +79,7 @@ def transport_with_url(mock_httpx_client):
7879
"""Creates a JsonRpcTransport with just a URL."""
7980
return JsonRpcTransport(
8081
httpx_client=mock_httpx_client,
82+
agent_card=AgentCard(name='Dummy'),
8183
url='http://custom-url.example.com',
8284
)
8385

@@ -113,41 +115,18 @@ def test_init_with_agent_card(self, mock_httpx_client, agent_card):
113115
transport = JsonRpcTransport(
114116
httpx_client=mock_httpx_client,
115117
agent_card=agent_card,
118+
url='http://test-agent.example.com',
116119
)
117120
assert transport.url == 'http://test-agent.example.com'
118121
assert transport.agent_card == agent_card
119122

120-
def test_init_with_url(self, mock_httpx_client):
121-
"""Test initialization with a URL."""
122-
transport = JsonRpcTransport(
123-
httpx_client=mock_httpx_client,
124-
url='http://custom-url.example.com',
125-
)
126-
assert transport.url == 'http://custom-url.example.com'
127-
assert transport.agent_card is None
128-
129-
def test_init_url_takes_precedence(self, mock_httpx_client, agent_card):
130-
"""Test that explicit URL takes precedence over agent card URL."""
131-
transport = JsonRpcTransport(
132-
httpx_client=mock_httpx_client,
133-
agent_card=agent_card,
134-
url='http://override-url.example.com',
135-
)
136-
assert transport.url == 'http://override-url.example.com'
137-
138-
def test_init_requires_url_or_agent_card(self, mock_httpx_client):
139-
"""Test that initialization requires either URL or agent card."""
140-
with pytest.raises(
141-
ValueError, match='Must provide either agent_card or url'
142-
):
143-
JsonRpcTransport(httpx_client=mock_httpx_client)
144-
145123
def test_init_with_interceptors(self, mock_httpx_client, agent_card):
146124
"""Test initialization with interceptors."""
147125
interceptor = MagicMock()
148126
transport = JsonRpcTransport(
149127
httpx_client=mock_httpx_client,
150128
agent_card=agent_card,
129+
url='http://test-agent.example.com',
151130
interceptors=[interceptor],
152131
)
153132
assert transport.interceptors == [interceptor]
@@ -158,6 +137,7 @@ def test_init_with_extensions(self, mock_httpx_client, agent_card):
158137
transport = JsonRpcTransport(
159138
httpx_client=mock_httpx_client,
160139
agent_card=agent_card,
140+
url='http://test-agent.example.com',
161141
extensions=extensions,
162142
)
163143
assert transport.extensions == extensions
@@ -466,6 +446,7 @@ async def test_interceptor_called(self, mock_httpx_client, agent_card):
466446
transport = JsonRpcTransport(
467447
httpx_client=mock_httpx_client,
468448
agent_card=agent_card,
449+
url='http://test-agent.example.com',
469450
interceptors=[interceptor],
470451
)
471452

@@ -505,6 +486,7 @@ async def test_extensions_added_to_request(
505486
transport = JsonRpcTransport(
506487
httpx_client=mock_httpx_client,
507488
agent_card=agent_card,
489+
url='http://test-agent.example.com',
508490
extensions=extensions,
509491
)
510492

@@ -548,6 +530,7 @@ async def test_send_message_streaming_server_error_propagates(
548530
client = JsonRpcTransport(
549531
httpx_client=mock_httpx_client,
550532
agent_card=agent_card,
533+
url='http://test-agent.example.com',
551534
)
552535
request = create_send_message_request(text='Error stream')
553536

@@ -577,41 +560,6 @@ async def empty_aiter():
577560
assert exc_info.value.status_code == 403
578561
mock_aconnect_sse.assert_called_once()
579562

580-
@pytest.mark.asyncio
581-
async def test_get_card_no_card_provided_with_extensions(
582-
self, mock_httpx_client: AsyncMock, agent_card: AgentCard
583-
):
584-
"""Test get_extended_agent_card with extensions set in Client when no card is initially provided.
585-
Tests that the extensions are added to the HTTP GET request."""
586-
extensions = [
587-
'https://example.com/test-ext/v1',
588-
'https://example.com/test-ext/v2',
589-
]
590-
client = JsonRpcTransport(
591-
httpx_client=mock_httpx_client,
592-
url='http://test-agent.example.com',
593-
extensions=extensions,
594-
)
595-
mock_response = AsyncMock(spec=httpx.Response)
596-
mock_response.status_code = 200
597-
mock_response.json.return_value = json_format.MessageToDict(agent_card)
598-
mock_httpx_client.get.return_value = mock_response
599-
600-
agent_card.capabilities.extended_agent_card = False
601-
602-
await client.get_extended_agent_card()
603-
604-
mock_httpx_client.get.assert_called_once()
605-
_, mock_kwargs = mock_httpx_client.get.call_args
606-
607-
_assert_extensions_header(
608-
mock_kwargs,
609-
{
610-
'https://example.com/test-ext/v1',
611-
'https://example.com/test-ext/v2',
612-
},
613-
)
614-
615563
@pytest.mark.asyncio
616564
async def test_get_card_with_extended_card_support_with_extensions(
617565
self, mock_httpx_client: AsyncMock, agent_card: AgentCard
@@ -627,6 +575,7 @@ async def test_get_card_with_extended_card_support_with_extensions(
627575
client = JsonRpcTransport(
628576
httpx_client=mock_httpx_client,
629577
agent_card=agent_card,
578+
url='http://test-agent.example.com',
630579
extensions=extensions,
631580
)
632581

tests/client/transports/test_rest_client.py

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ async def test_send_message_streaming_timeout(
6565
mock_agent_card: MagicMock,
6666
):
6767
client = RestTransport(
68-
httpx_client=mock_httpx_client, agent_card=mock_agent_card
68+
httpx_client=mock_httpx_client,
69+
agent_card=mock_agent_card,
70+
url='http://agent.example.com/api',
6971
)
7072
params = SendMessageRequest(
7173
message=create_text_message_object(content='Hello stream')
@@ -101,8 +103,9 @@ async def test_send_message_with_default_extensions(
101103
]
102104
client = RestTransport(
103105
httpx_client=mock_httpx_client,
104-
extensions=extensions,
105106
agent_card=mock_agent_card,
107+
url='http://agent.example.com/api',
108+
extensions=extensions,
106109
)
107110
params = SendMessageRequest(
108111
message=create_text_message_object(content='Hello')
@@ -146,6 +149,7 @@ async def test_send_message_streaming_with_new_extensions(
146149
client = RestTransport(
147150
httpx_client=mock_httpx_client,
148151
agent_card=mock_agent_card,
152+
url='http://agent.example.com/api',
149153
extensions=extensions,
150154
)
151155
params = SendMessageRequest(
@@ -185,6 +189,7 @@ async def test_send_message_streaming_server_error_propagates(
185189
client = RestTransport(
186190
httpx_client=mock_httpx_client,
187191
agent_card=mock_agent_card,
192+
url='http://agent.example.com/api',
188193
)
189194
request = SendMessageRequest(
190195
message=create_text_message_object(content='Error stream')
@@ -217,47 +222,6 @@ async def empty_aiter():
217222

218223
mock_aconnect_sse.assert_called_once()
219224

220-
@pytest.mark.asyncio
221-
async def test_get_card_no_card_provided_with_extensions(
222-
self, mock_httpx_client: AsyncMock
223-
):
224-
"""Test get_extended_agent_card with extensions set in Client when no card is initially provided.
225-
Tests that the extensions are added to the HTTP GET request."""
226-
extensions = [
227-
'https://example.com/test-ext/v1',
228-
'https://example.com/test-ext/v2',
229-
]
230-
client = RestTransport(
231-
httpx_client=mock_httpx_client,
232-
url='http://agent.example.com/api',
233-
extensions=extensions,
234-
)
235-
236-
agent_card = AgentCard(
237-
name='Test Agent',
238-
description='Test Agent Description',
239-
version='1.0.0',
240-
capabilities=AgentCapabilities(),
241-
)
242-
243-
mock_response = AsyncMock(spec=httpx.Response)
244-
mock_response.status_code = 200
245-
mock_response.json.return_value = json_format.MessageToDict(agent_card)
246-
mock_httpx_client.get.return_value = mock_response
247-
248-
await client.get_extended_agent_card()
249-
250-
mock_httpx_client.get.assert_called_once()
251-
_, mock_kwargs = mock_httpx_client.get.call_args
252-
253-
_assert_extensions_header(
254-
mock_kwargs,
255-
{
256-
'https://example.com/test-ext/v1',
257-
'https://example.com/test-ext/v2',
258-
},
259-
)
260-
261225
@pytest.mark.asyncio
262226
async def test_get_card_with_extended_card_support_with_extensions(
263227
self, mock_httpx_client: AsyncMock
@@ -281,6 +245,7 @@ async def test_get_card_with_extended_card_support_with_extensions(
281245
client = RestTransport(
282246
httpx_client=mock_httpx_client,
283247
agent_card=agent_card,
248+
url='http://agent.example.com/api',
284249
)
285250

286251
mock_response = AsyncMock(spec=httpx.Response)

0 commit comments

Comments
 (0)