mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
fix(a2a): resolve the agent card at the origin's well-known URI for sub-path agent URLs
A2A 1.0 places the agent card at {origin}/.well-known/agent-card.json, while
an agent's JSON-RPC endpoint is routinely a sub-path such as /jsonrpc. The
card resolver only probed the well-known paths relative to the configured
agent URL, so every message/send to such an agent failed with -32603 even
though the card was served where the spec says.
The resolver keeps probing the paths relative to the agent URL first, so
deployments that serve the card under the agent path (a2a-sdk servers,
Foundry's /agentCard/v1.0) resolve exactly as before and a different card at
the origin never wins over them. Only when all of those fail, and the agent
URL has a path, does it try the two well-known paths at the URL's origin. A
card found there is taken only when one of its interface URLs is the
configured agent URL or a path prefix of it, so on a host that serves several
agents an unrelated agent's card cannot become the target of this agent's
messages and headers. The discovery error names the origin URLs it tried, and
the endpoints of a rejected origin card.
The module stops re-importing the two well-known path strings from
a2a.utils.constants: they are fixed by the spec, the module already carried
them as its own defaults, and the re-import left their type unknown.
Fixes #49868
Signed-off-by: gulldan <5641375+gulldan@users.noreply.github.com>
This commit is contained in:
parent
0ad5d750d4
commit
e563c88cd3
3 changed files with 301 additions and 24 deletions
|
|
@ -7,9 +7,10 @@ Extends the A2A SDK's card resolver to support multiple well-known paths.
|
|||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Protocol, runtime_checkable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.a2a_protocol.exceptions import A2AAgentCardDiscoveryError
|
||||
from litellm.a2a_protocol.exceptions import A2AAgentCardDiscoveryError, A2AAgentCardError
|
||||
from litellm.constants import LOCALHOST_URL_PATTERNS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -17,18 +18,14 @@ if TYPE_CHECKING:
|
|||
|
||||
# Runtime imports with availability check
|
||||
_A2ACardResolver: Any = None
|
||||
AGENT_CARD_WELL_KNOWN_PATH: str = "/.well-known/agent-card.json"
|
||||
PREV_AGENT_CARD_WELL_KNOWN_PATH: str = "/.well-known/agent.json"
|
||||
AGENT_CARD_WELL_KNOWN_PATH: Final = "/.well-known/agent-card.json"
|
||||
PREV_AGENT_CARD_WELL_KNOWN_PATH: Final = "/.well-known/agent.json"
|
||||
FOUNDRY_AGENT_CARD_PATH: Final = "/agentCard/v1.0"
|
||||
AGENT_CARD_PATH_PARAM: Final = "agent_card_path"
|
||||
A2A_PROTOCOL_VERSION_PARAM: Final = "a2a_protocol_version"
|
||||
|
||||
try:
|
||||
from a2a.client import A2ACardResolver as _A2ACardResolver
|
||||
from a2a.utils.constants import (
|
||||
AGENT_CARD_WELL_KNOWN_PATH,
|
||||
PREV_AGENT_CARD_WELL_KNOWN_PATH,
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
|
@ -47,6 +44,14 @@ def _discovery_status_code(failures: tuple[tuple[str, Exception], ...]) -> int:
|
|||
return statuses[0] if statuses else 404
|
||||
|
||||
|
||||
def _agent_origin(base_url: str) -> str | None:
|
||||
"""``scheme://host`` of an agent URL that has a path, where A2A 1.0 places the well-known card; None at the root."""
|
||||
parts: Final = urlsplit(base_url)
|
||||
if not parts.path.strip("/"):
|
||||
return None
|
||||
return f"{parts.scheme}://{parts.netloc}"
|
||||
|
||||
|
||||
def is_localhost_or_internal_url(url: str | None) -> bool:
|
||||
"""
|
||||
Check if a URL is a localhost or internal URL.
|
||||
|
|
@ -184,14 +189,38 @@ def fix_agent_card_url(agent_card: "AgentCard", base_url: str) -> "AgentCard":
|
|||
return agent_card
|
||||
|
||||
|
||||
def _advertised_endpoints(agent_card: "AgentCard") -> tuple[str, ...]:
|
||||
"""Every interface URL of the card; the SDK folds a 0.3 card's ``url`` and ``additionalInterfaces`` into ``supported_interfaces``."""
|
||||
interfaces: Final = getattr(agent_card, "supported_interfaces", None) or ()
|
||||
urls: Final = (getattr(agent_card, "url", None), *(getattr(interface, "url", None) for interface in interfaces))
|
||||
return tuple(url.rstrip("/") for url in urls if url)
|
||||
|
||||
|
||||
def _covers(endpoint: str, agent_url: str) -> bool:
|
||||
"""True when ``endpoint`` is ``agent_url`` or a path prefix of it (``http://h`` covers ``http://h/jsonrpc``)."""
|
||||
agent: Final = agent_url.rstrip("/")
|
||||
return agent == endpoint or agent.startswith(f"{endpoint}/")
|
||||
|
||||
|
||||
_RELATIVE_CARD_PATHS: Final = (AGENT_CARD_WELL_KNOWN_PATH, PREV_AGENT_CARD_WELL_KNOWN_PATH, FOUNDRY_AGENT_CARD_PATH)
|
||||
_ORIGIN_CARD_PATHS: Final = (AGENT_CARD_WELL_KNOWN_PATH, PREV_AGENT_CARD_WELL_KNOWN_PATH)
|
||||
|
||||
|
||||
class LiteLLMA2ACardResolver(_A2ACardResolver):
|
||||
"""
|
||||
Custom A2A card resolver that supports multiple well-known paths.
|
||||
|
||||
Extends the base A2ACardResolver to try, in order:
|
||||
Extends the base A2ACardResolver to try, in order, relative to the agent URL:
|
||||
- /.well-known/agent-card.json (standard)
|
||||
- /.well-known/agent.json (previous/alternative)
|
||||
- /agentCard/v1.0
|
||||
|
||||
and then, when the agent URL has a path (its JSON-RPC endpoint is a sub-path such as
|
||||
``/jsonrpc``), the two well-known paths at the URL's origin, where A2A 1.0 places the card.
|
||||
The origin probes carry none of the agent's headers, since the public card at the well-known
|
||||
URI is unauthenticated by spec and the origin may host other applications. A card found there
|
||||
is accepted only when one of its interface URLs is the agent URL or a path prefix of it, so a
|
||||
host that serves several agents cannot hand this agent's messages and headers to another one.
|
||||
"""
|
||||
|
||||
async def get_agent_card(
|
||||
|
|
@ -206,30 +235,53 @@ class LiteLLMA2ACardResolver(_A2ACardResolver):
|
|||
http_kwargs=http_kwargs,
|
||||
)
|
||||
|
||||
return await self._get_agent_card_from_first_reachable_path(
|
||||
paths=(AGENT_CARD_WELL_KNOWN_PATH, PREV_AGENT_CARD_WELL_KNOWN_PATH, FOUNDRY_AGENT_CARD_PATH),
|
||||
origin: Final = _agent_origin(self.base_url)
|
||||
return await self._get_agent_card_from_first_reachable_url(
|
||||
candidates=(
|
||||
*((self.base_url, path) for path in _RELATIVE_CARD_PATHS),
|
||||
*(((origin, path) for path in _ORIGIN_CARD_PATHS) if origin else ()),
|
||||
),
|
||||
http_kwargs=http_kwargs,
|
||||
failures=(),
|
||||
)
|
||||
|
||||
async def _get_agent_card_from_first_reachable_path(
|
||||
async def _get_agent_card_from_first_reachable_url(
|
||||
self,
|
||||
paths: tuple[str, ...],
|
||||
candidates: tuple[tuple[str, str], ...],
|
||||
http_kwargs: Mapping[str, object] | None,
|
||||
failures: tuple[tuple[str, Exception], ...],
|
||||
) -> "AgentCard":
|
||||
if not paths:
|
||||
if not candidates:
|
||||
raise A2AAgentCardDiscoveryError(
|
||||
base_url=self.base_url,
|
||||
failures=failures,
|
||||
status_code=_discovery_status_code(failures),
|
||||
)
|
||||
path: Final = paths[0]
|
||||
base_url, path = candidates[0]
|
||||
attempt: Final = path if base_url == self.base_url else f"{base_url}{path}"
|
||||
try:
|
||||
verbose_logger.debug("Attempting to fetch agent card from %s%s", self.base_url, path)
|
||||
return await super().get_agent_card(relative_card_path=path, http_kwargs=http_kwargs)
|
||||
verbose_logger.debug("Attempting to fetch agent card from %s%s", base_url, path)
|
||||
return await self._get_agent_card_at(base_url, path, http_kwargs)
|
||||
except Exception as e:
|
||||
verbose_logger.debug("Failed to fetch agent card from %s%s: %s", self.base_url, path, e)
|
||||
return await self._get_agent_card_from_first_reachable_path(
|
||||
paths=paths[1:], http_kwargs=http_kwargs, failures=(*failures, (path, e))
|
||||
verbose_logger.debug("Failed to fetch agent card from %s%s: %s", base_url, path, e)
|
||||
return await self._get_agent_card_from_first_reachable_url(
|
||||
candidates=candidates[1:], http_kwargs=http_kwargs, failures=(*failures, (attempt, e))
|
||||
)
|
||||
|
||||
async def _get_agent_card_at(
|
||||
self, base_url: str, path: str, http_kwargs: Mapping[str, object] | None
|
||||
) -> "AgentCard":
|
||||
if base_url == self.base_url:
|
||||
return await super().get_agent_card(relative_card_path=path, http_kwargs=http_kwargs)
|
||||
origin_resolver: Final = _A2ACardResolver(httpx_client=self.httpx_client, base_url=base_url)
|
||||
card: Final = await origin_resolver.get_agent_card(relative_card_path=path)
|
||||
endpoints: Final = _advertised_endpoints(card)
|
||||
if not any(_covers(endpoint, self.base_url) for endpoint in endpoints):
|
||||
raise A2AAgentCardError(
|
||||
message=(
|
||||
f"the card at the origin advertises {endpoints!r}, none of which is the agent URL "
|
||||
f"{self.base_url!r} or a path prefix of it"
|
||||
),
|
||||
url=f"{base_url}{path}",
|
||||
)
|
||||
return card
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Tests that the card resolver tries both old and new well-known paths.
|
|||
from types import SimpleNamespace
|
||||
from typing import Any, Final
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
|
@ -227,17 +228,31 @@ _FOUNDRY_CARD_JSON: Final = {
|
|||
|
||||
|
||||
class _FakeHttpxClient:
|
||||
"""Answers GETs from a path -> (status, body) map and records the path of each call."""
|
||||
"""Answers GETs under ``base_url`` from a path -> (status, body) map, and GETs at the URL's
|
||||
origin from ``origin_responses``; records the path (or the full origin URL) of each call."""
|
||||
|
||||
def __init__(self, base_url: str, responses: dict[str, tuple[int, dict[str, Any]]]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
responses: dict[str, tuple[int, dict[str, Any]]],
|
||||
origin_responses: dict[str, tuple[int, dict[str, Any]]] | None = None,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._origin = f"{urlsplit(base_url).scheme}://{urlsplit(base_url).netloc}"
|
||||
self._responses = responses
|
||||
self._origin_responses = origin_responses or {}
|
||||
self.calls: list[str] = []
|
||||
self.headers_sent: list[Any] = []
|
||||
|
||||
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
||||
path: Final = url.removeprefix(self._base_url)
|
||||
self.calls.append(path)
|
||||
status_code, body = self._responses[path]
|
||||
self.headers_sent.append(kwargs.get("headers"))
|
||||
if url.startswith(self._base_url + "/"):
|
||||
self.calls.append(url.removeprefix(self._base_url))
|
||||
status_code, body = self._responses[url.removeprefix(self._base_url)]
|
||||
else:
|
||||
assert url.startswith(self._origin + "/"), url
|
||||
self.calls.append(url)
|
||||
status_code, body = self._origin_responses[url.removeprefix(self._origin)]
|
||||
return httpx.Response(status_code, json=body, request=httpx.Request("GET", url))
|
||||
|
||||
|
||||
|
|
@ -283,6 +298,10 @@ async def test_card_resolver_names_every_probed_path_when_discovery_fails():
|
|||
"/.well-known/agent.json": (401, {"error": "unauthorized"}),
|
||||
"/agentCard/v1.0": (404, {"error": "not found"}),
|
||||
},
|
||||
origin_responses={
|
||||
"/.well-known/agent-card.json": (404, {"error": "not found"}),
|
||||
"/.well-known/agent.json": (404, {"error": "not found"}),
|
||||
},
|
||||
)
|
||||
|
||||
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_FOUNDRY_BASE_URL)
|
||||
|
|
@ -295,6 +314,7 @@ async def test_card_resolver_names_every_probed_path_when_discovery_fails():
|
|||
assert "/.well-known/agent-card.json (" in message and "HTTP 404" in message
|
||||
assert "/.well-known/agent.json (" in message and "HTTP 401" in message
|
||||
assert "/agentCard/v1.0 (" in message
|
||||
assert "https://foundry.example.com/.well-known/agent-card.json (" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -307,6 +327,10 @@ async def test_card_resolver_discovery_error_is_404_when_every_probe_is_404():
|
|||
"/.well-known/agent.json": (404, {"error": "not found"}),
|
||||
"/agentCard/v1.0": (404, {"error": "not found"}),
|
||||
},
|
||||
origin_responses={
|
||||
"/.well-known/agent-card.json": (404, {"error": "not found"}),
|
||||
"/.well-known/agent.json": (404, {"error": "not found"}),
|
||||
},
|
||||
),
|
||||
base_url=_FOUNDRY_BASE_URL,
|
||||
)
|
||||
|
|
@ -315,3 +339,173 @@ async def test_card_resolver_discovery_error_is_404_when_every_probe_is_404():
|
|||
await resolver.get_agent_card()
|
||||
|
||||
assert raised.value.status_code == 404
|
||||
|
||||
|
||||
_SUBPATH_AGENT_URL: Final = "http://rsagent:3000/jsonrpc"
|
||||
|
||||
_SUBPATH_CARD_JSON: Final = {
|
||||
**_FOUNDRY_CARD_JSON,
|
||||
"name": "rspath",
|
||||
"url": _SUBPATH_AGENT_URL,
|
||||
"supportedInterfaces": [{"url": _SUBPATH_AGENT_URL, "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}],
|
||||
}
|
||||
|
||||
|
||||
_NO_CARD_UNDER_THE_AGENT_PATH: Final = {
|
||||
"/.well-known/agent-card.json": (404, {"error": "not found"}),
|
||||
"/.well-known/agent.json": (404, {"error": "not found"}),
|
||||
"/agentCard/v1.0": (404, {"error": "not found"}),
|
||||
}
|
||||
|
||||
|
||||
def _subpath_card_json(endpoint: str) -> dict[str, Any]:
|
||||
return {
|
||||
**_SUBPATH_CARD_JSON,
|
||||
"url": endpoint,
|
||||
"supportedInterfaces": [{"url": endpoint, "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"card_endpoint",
|
||||
[_SUBPATH_AGENT_URL, _SUBPATH_AGENT_URL + "/", "http://rsagent:3000", "http://rsagent:3000/"],
|
||||
ids=["agent url", "agent url with slash", "origin root", "origin root with slash"],
|
||||
)
|
||||
async def test_card_resolver_falls_back_to_the_well_known_card_at_the_agents_origin(card_endpoint):
|
||||
"""A2A 1.0 places the card at ``{origin}/.well-known/agent-card.json`` while the JSON-RPC
|
||||
endpoint is routinely a sub-path, so an agent registered with that endpoint as its URL
|
||||
must still be resolvable, from a card that names that endpoint or a path prefix of it."""
|
||||
httpx_client = _FakeHttpxClient(
|
||||
base_url=_SUBPATH_AGENT_URL,
|
||||
responses=dict(_NO_CARD_UNDER_THE_AGENT_PATH),
|
||||
origin_responses={"/.well-known/agent-card.json": (200, _subpath_card_json(card_endpoint))},
|
||||
)
|
||||
|
||||
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_SUBPATH_AGENT_URL)
|
||||
result = await resolver.get_agent_card()
|
||||
|
||||
assert httpx_client.calls[-1] == "http://rsagent:3000/.well-known/agent-card.json"
|
||||
assert result.name == "rspath"
|
||||
assert result.supported_interfaces[0].url == card_endpoint
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_card_resolver_keeps_the_agents_headers_off_the_origin_probes():
|
||||
"""The public card at the origin's well-known URI is unauthenticated by spec, and the origin
|
||||
may host other applications, so the agent's own headers only go to the agent's paths."""
|
||||
httpx_client = _FakeHttpxClient(
|
||||
base_url=_SUBPATH_AGENT_URL,
|
||||
responses=dict(_NO_CARD_UNDER_THE_AGENT_PATH),
|
||||
origin_responses={"/.well-known/agent-card.json": (200, _subpath_card_json(_SUBPATH_AGENT_URL))},
|
||||
)
|
||||
agent_headers = {"x-agent-token": "secret-for-this-agent"}
|
||||
|
||||
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_SUBPATH_AGENT_URL)
|
||||
await resolver.get_agent_card(http_kwargs={"headers": agent_headers})
|
||||
|
||||
assert httpx_client.calls[-1] == "http://rsagent:3000/.well-known/agent-card.json"
|
||||
assert httpx_client.headers_sent[:-1] == [agent_headers] * 3
|
||||
assert httpx_client.headers_sent[-1] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"card_json",
|
||||
[
|
||||
{
|
||||
**_subpath_card_json("http://rsagent:3000/grpc"),
|
||||
"supportedInterfaces": [
|
||||
{"url": "http://rsagent:3000/grpc", "protocolBinding": "GRPC", "protocolVersion": "1.0"},
|
||||
{"url": _SUBPATH_AGENT_URL, "protocolBinding": "JSONRPC", "protocolVersion": "1.0"},
|
||||
],
|
||||
},
|
||||
{
|
||||
**_FOUNDRY_CARD_JSON,
|
||||
"name": "rspath",
|
||||
"url": "http://rsagent:3000/grpc",
|
||||
"preferredTransport": "GRPC",
|
||||
"additionalInterfaces": [{"url": _SUBPATH_AGENT_URL, "transport": "JSONRPC"}],
|
||||
},
|
||||
],
|
||||
ids=["supportedInterfaces", "additionalInterfaces"],
|
||||
)
|
||||
async def test_card_resolver_accepts_an_origin_card_that_names_the_agent_url_in_a_later_interface(card_json):
|
||||
"""The agent URL may be any of the card's interfaces, not only the first one."""
|
||||
httpx_client = _FakeHttpxClient(
|
||||
base_url=_SUBPATH_AGENT_URL,
|
||||
responses=dict(_NO_CARD_UNDER_THE_AGENT_PATH),
|
||||
origin_responses={"/.well-known/agent-card.json": (200, card_json)},
|
||||
)
|
||||
|
||||
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_SUBPATH_AGENT_URL)
|
||||
result = await resolver.get_agent_card()
|
||||
|
||||
assert httpx_client.calls[-1] == "http://rsagent:3000/.well-known/agent-card.json"
|
||||
assert result.name == "rspath"
|
||||
assert [interface.url for interface in result.supported_interfaces] == ["http://rsagent:3000/grpc", _SUBPATH_AGENT_URL]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"rejected_endpoint",
|
||||
["http://rsagent:3000/other", "http://rsagent:3000/json", "http://elsewhere:9/jsonrpc"],
|
||||
ids=["sibling path", "string prefix but not a path prefix", "other host"],
|
||||
)
|
||||
async def test_card_resolver_rejects_an_origin_card_that_advertises_another_endpoint(rejected_endpoint):
|
||||
"""On a host serving several agents the origin card can belong to another agent, and its
|
||||
endpoint would receive this agent's messages and headers, so it is only taken when one of its
|
||||
interfaces names the configured agent URL or a path prefix of it; otherwise the card counts
|
||||
as not found."""
|
||||
httpx_client = _FakeHttpxClient(
|
||||
base_url=_SUBPATH_AGENT_URL,
|
||||
responses=dict(_NO_CARD_UNDER_THE_AGENT_PATH),
|
||||
origin_responses={
|
||||
"/.well-known/agent-card.json": (200, _subpath_card_json(rejected_endpoint)),
|
||||
"/.well-known/agent.json": (404, {"error": "not found"}),
|
||||
},
|
||||
)
|
||||
|
||||
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_SUBPATH_AGENT_URL)
|
||||
with pytest.raises(A2AAgentCardDiscoveryError) as raised:
|
||||
await resolver.get_agent_card()
|
||||
|
||||
assert raised.value.status_code == 404
|
||||
message = str(raised.value)
|
||||
assert "http://rsagent:3000/.well-known/agent-card.json (" in message and repr(rejected_endpoint) in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_card_resolver_prefers_the_card_relative_to_the_agent_url():
|
||||
"""Deployments that serve the card under the agent path keep resolving without an extra
|
||||
round trip, and a host that serves a different card at its origin never wins over them."""
|
||||
httpx_client = _FakeHttpxClient(
|
||||
base_url=_SUBPATH_AGENT_URL,
|
||||
responses={"/.well-known/agent-card.json": (200, dict(_SUBPATH_CARD_JSON))},
|
||||
origin_responses={"/.well-known/agent-card.json": (200, {**_SUBPATH_CARD_JSON, "name": "other agent"})},
|
||||
)
|
||||
|
||||
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_SUBPATH_AGENT_URL)
|
||||
result = await resolver.get_agent_card()
|
||||
|
||||
assert httpx_client.calls == ["/.well-known/agent-card.json"]
|
||||
assert result.name == "rspath"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_card_resolver_probes_only_once_when_the_agent_url_is_the_origin():
|
||||
httpx_client = _FakeHttpxClient(
|
||||
base_url="http://rsagent:3000",
|
||||
responses={
|
||||
"/.well-known/agent-card.json": (404, {"error": "not found"}),
|
||||
"/.well-known/agent.json": (404, {"error": "not found"}),
|
||||
"/agentCard/v1.0": (404, {"error": "not found"}),
|
||||
},
|
||||
)
|
||||
|
||||
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url="http://rsagent:3000/")
|
||||
with pytest.raises(A2AAgentCardDiscoveryError):
|
||||
await resolver.get_agent_card()
|
||||
|
||||
assert httpx_client.calls == ["/.well-known/agent-card.json", "/.well-known/agent.json", "/agentCard/v1.0"]
|
||||
|
||||
|
|
|
|||
|
|
@ -334,6 +334,7 @@ class _RequestRecorder:
|
|||
self.card_requests = []
|
||||
self.card_urls = []
|
||||
self.rpc_requests = []
|
||||
self.rpc_urls = []
|
||||
self.rpc_bodies = []
|
||||
self.client = None
|
||||
|
||||
|
|
@ -344,6 +345,7 @@ class _RequestRecorder:
|
|||
self.card_urls.append(str(request.url))
|
||||
return httpx.Response(200, json=self.card)
|
||||
self.rpc_requests.append(headers)
|
||||
self.rpc_urls.append(str(request.url))
|
||||
self.rpc_bodies.append(json.loads(request.content))
|
||||
if self.rpc_stream_events is not None:
|
||||
body = "".join(f"data: {json.dumps(event)}\n\n" for event in self.rpc_stream_events)
|
||||
|
|
@ -646,6 +648,35 @@ async def test_agent_card_path_param_fetches_that_path_with_the_agents_headers(i
|
|||
assert recorder.card_requests[-1]["x-agent-token"] == "token-for-a"
|
||||
|
||||
|
||||
_SUBPATH_AGENT_URL = "http://127.0.0.1:9/jsonrpc"
|
||||
_SUBPATH_AGENT_CARD = {**_AGENT_CARD, "url": _SUBPATH_AGENT_URL}
|
||||
|
||||
|
||||
class _CardAtOriginRecorder(_RequestRecorder):
|
||||
"""An agent whose JSON-RPC endpoint is a sub-path and whose card sits at the origin, as A2A 1.0 places it."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(card=_SUBPATH_AGENT_CARD)
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "GET" and request.url.path != "/.well-known/agent-card.json":
|
||||
self.card_urls.append(str(request.url))
|
||||
return httpx.Response(404, json={"error": "not found"}, request=request)
|
||||
return super().__call__(request)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asend_message_resolves_the_card_at_the_origin_of_a_sub_path_agent_url(isolated_client_cache):
|
||||
recorder = await _seed_shared_a2a_client(recorder=_CardAtOriginRecorder())
|
||||
|
||||
response = await asend_message(request=_send_request("req-subpath"), api_base=_SUBPATH_AGENT_URL)
|
||||
|
||||
assert recorder.card_urls[-1] == "http://127.0.0.1:9/.well-known/agent-card.json"
|
||||
assert response.error is None
|
||||
assert recorder.rpc_bodies[-1]["method"] == "message/send"
|
||||
assert recorder.rpc_urls[-1] == _SUBPATH_AGENT_URL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aget_agent_card_carries_the_callers_headers_and_path(isolated_client_cache):
|
||||
recorder = await _seed_shared_a2a_client(provider=httpxSpecialProvider.A2A)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue