Fix agent_card_params validation to use protobuf-compatible AgentCard conversion, and harden regression tests to fake the HTTP boundary instead of patching internals

This commit is contained in:
AlphaRex-pixel 2026-09-11 01:44:53 +05:30
parent 22c9514b58
commit 9523a8f888
2 changed files with 111 additions and 43 deletions

View file

@ -798,11 +798,13 @@ async def create_a2a_client(
agent_card: AgentCard | None = None
if agent_card_params:
from a2a.compat.v0_3.types import AgentCard as _AgentCard
from a2a.compat.v0_3 import conversions as _conversions
from a2a.compat.v0_3.types import AgentCard as _CompatAgentCard
from pydantic import ValidationError as _ValidationError
try:
agent_card = normalize_agent_card_interfaces(_AgentCard.model_validate(agent_card_params))
compat_card = _CompatAgentCard.model_validate(agent_card_params)
agent_card = normalize_agent_card_interfaces(_conversions.to_core_agent_card(compat_card))
verbose_logger.info("Using pre-registered agent card for %s (skipping well-known discovery)", base_url)
except _ValidationError as e:
verbose_logger.warning(

View file

@ -1,19 +1,40 @@
"""
Regression tests for GH #40586: message/send re-discovered the agent card
from well-known paths instead of using the registered agent_card_params.
These tests fake the HTTP boundary with a real httpx.AsyncClient wired to an
httpx.MockTransport, rather than patching SDK internals, so they verify
actual behavior: whether a well-known-path request goes out over the wire,
and what agent card the resulting client ends up holding.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import patch
import httpx
import pytest
from litellm.a2a_protocol.main import create_a2a_client
BASE_URL = "https://example.com/agents/a2a"
WELL_KNOWN_PATH = "/agents/a2a/.well-known/agent-card.json"
MINIMAL_AGENT_CARD_PARAMS = {
"protocolVersion": "1.0",
"name": "agent",
"name": "pre-registered-agent",
"description": "A test assistant.",
"url": "https://example.com/agents/a2a",
"url": BASE_URL,
"version": "1.0",
"capabilities": {"streaming": False},
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"skills": [],
}
DISCOVERED_AGENT_CARD_JSON = {
"protocolVersion": "1.0",
"name": "discovered-agent",
"description": "A test assistant found via well-known discovery.",
"url": BASE_URL,
"version": "1.0",
"capabilities": {"streaming": False},
"defaultInputModes": ["text"],
@ -22,61 +43,106 @@ MINIMAL_AGENT_CARD_PARAMS = {
}
def _mock_client(handler):
"""A real httpx.AsyncClient wired to a local handler instead of the network."""
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
class _FakeAsyncHandler:
def __init__(self, c):
self.client = c
return _FakeAsyncHandler(client)
@pytest.mark.asyncio
async def test_create_a2a_client_skips_discovery_when_agent_card_params_given():
"""
When a caller already has a resolved agent card (e.g. one stored via
POST /v1/agents), create_a2a_client must build the AgentCard directly
from it instead of probing the upstream server's well-known paths.
POST /v1/agents), create_a2a_client must build the client from it
directly instead of making a well-known-path HTTP request.
"""
with (
patch("litellm.a2a_protocol.main.get_async_httpx_client") as mock_get_httpx,
patch("litellm.a2a_protocol.main.A2ACardResolver") as mock_resolver_cls,
patch("litellm.a2a_protocol.main.create_client", new_callable=AsyncMock) as mock_create_client,
patch(
"litellm.a2a_protocol.main.normalize_agent_card_interfaces",
side_effect=lambda card: card,
),
):
mock_get_httpx.return_value.client = MagicMock()
mock_create_client.return_value = MagicMock()
requests_made: list[str] = []
await create_a2a_client(
base_url="https://example.com/agents/a2a",
def handler(request: httpx.Request) -> httpx.Response:
requests_made.append(str(request.url))
return httpx.Response(404, json={"error": "should not be called"})
with (
patch( # test-quality-ok: injects a real httpx.AsyncClient wired to httpx.MockTransport, not a mock of behavior
"litellm.a2a_protocol.main.get_async_httpx_client",
return_value=_mock_client(handler),
)
):
a2a_client = await create_a2a_client(
base_url=BASE_URL,
agent_card_params=MINIMAL_AGENT_CARD_PARAMS,
)
# The whole point of the fix: the resolver must never be touched
# when a card was already supplied at registration time.
mock_resolver_cls.assert_not_called()
# The real, observable proof of the fix: no HTTP request went out at all.
assert requests_made == []
mock_create_client.assert_awaited_once()
called_card = mock_create_client.await_args.args[0]
assert called_card.name == "agent"
assert str(called_card.url) == "https://example.com/agents/a2a"
resolved_card = a2a_client._litellm_agent_card
assert resolved_card.name == "pre-registered-agent"
assert resolved_card.supported_interfaces[0].url == BASE_URL
@pytest.mark.asyncio
async def test_create_a2a_client_falls_back_to_discovery_without_agent_card_params():
"""
Unchanged behavior: when no agent_card_params is supplied, resolve the
card via the well-known-path resolver, same as before the fix.
Unchanged behavior: when no agent_card_params is supplied, the client
resolves the card over HTTP from the well-known path, same as before
the fix.
"""
requests_made: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
requests_made.append(str(request.url))
if request.url.path == WELL_KNOWN_PATH:
return httpx.Response(200, json=DISCOVERED_AGENT_CARD_JSON)
return httpx.Response(404)
with (
patch("litellm.a2a_protocol.main.get_async_httpx_client") as mock_get_httpx,
patch("litellm.a2a_protocol.main.A2ACardResolver") as mock_resolver_cls,
patch("litellm.a2a_protocol.main.create_client", new_callable=AsyncMock) as mock_create_client,
patch(
"litellm.a2a_protocol.main.normalize_agent_card_interfaces",
side_effect=lambda card: card,
),
patch( # test-quality-ok: injects a real httpx.AsyncClient wired to httpx.MockTransport, not a mock of behavior
"litellm.a2a_protocol.main.get_async_httpx_client",
return_value=_mock_client(handler),
)
):
mock_get_httpx.return_value.client = MagicMock()
mock_resolver_instance = mock_resolver_cls.return_value
mock_resolver_instance.get_agent_card = AsyncMock(return_value=MagicMock())
mock_create_client.return_value = MagicMock()
a2a_client = await create_a2a_client(base_url=BASE_URL)
await create_a2a_client(base_url="https://example.com/agents/a2a")
assert any(WELL_KNOWN_PATH in url for url in requests_made)
resolved_card = a2a_client._litellm_agent_card
assert resolved_card.name == "discovered-agent"
mock_resolver_cls.assert_called_once()
mock_resolver_instance.get_agent_card.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_a2a_client_falls_back_to_discovery_on_invalid_agent_card_params():
"""
If the stored agent_card_params doesn't validate as a full AgentCard
(e.g. a partial card missing required fields), create_a2a_client must
fall back to well-known-path discovery rather than raising.
"""
requests_made: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
requests_made.append(str(request.url))
if request.url.path == WELL_KNOWN_PATH:
return httpx.Response(200, json=DISCOVERED_AGENT_CARD_JSON)
return httpx.Response(404)
incomplete_agent_card_params = {"url": BASE_URL} # missing required fields
with (
patch( # test-quality-ok: injects a real httpx.AsyncClient wired to httpx.MockTransport, not a mock of behavior
"litellm.a2a_protocol.main.get_async_httpx_client",
return_value=_mock_client(handler),
)
):
a2a_client = await create_a2a_client(
base_url=BASE_URL,
agent_card_params=incomplete_agent_card_params,
)
assert any(WELL_KNOWN_PATH in url for url in requests_made)
resolved_card = a2a_client._litellm_agent_card
assert resolved_card.name == "discovered-agent"