fix(a2a): default merged agent card version, null-guard runtime URL lookup, scope discovery auto-fire to A2A types

- merge_agent_card now defaults version to 1.0.0 when upstream omits it
  (A2A v1.0 schema requires the field).
- invoke_agent_a2a guards against agent_card_params being None so plain
  chat agents routed via the A2A path return a JSON-RPC error instead of
  AttributeError.
- buildDiscoveryRequest no longer falls back to any URL-shaped credential
  field for non-A2A agent types (Azure AI Foundry, Bedrock AgentCore,
  Vertex). Discovery only auto-fires for pure A2A and use_a2a_form_fields
  runtimes; the manual URL input remains available as an escape hatch.
This commit is contained in:
mateo-berri 2026-05-28 07:23:14 +00:00
parent 84e778e735
commit 8145ece4ca
No known key found for this signature in database
5 changed files with 95 additions and 14 deletions

View file

@ -86,6 +86,11 @@ _DEFAULT_SKILLS: List[Dict[str, Any]] = [
_DEFAULT_MODES: List[str] = ["text"]
# Fallback ``version`` when the upstream card omits the field. The A2A v1.0
# schema requires ``version`` on every card, so without this default the
# merged card would fail validation on clients that ``model_validate`` it.
_DEFAULT_AGENT_VERSION = "1.0.0"
def _filter_capabilities(upstream_capabilities: Any) -> Dict[str, Any]:
"""Return a capabilities dict containing only allowlisted, truthy keys."""
@ -143,6 +148,9 @@ def merge_agent_card(
if description:
base["description"] = description
if not base.get("version"):
base["version"] = _DEFAULT_AGENT_VERSION
base["capabilities"] = _filter_capabilities(base.get("capabilities"))
if not base.get("skills"):

View file

@ -381,8 +381,9 @@ async def invoke_agent_a2a( # noqa: PLR0915
_enforce_inbound_trace_id(agent, request)
# Get backend URL and agent name
agent_url = agent.agent_card_params.get("url")
agent_name = agent.agent_card_params.get("name", agent_id)
agent_card_params = agent.agent_card_params or {}
agent_url = agent_card_params.get("url")
agent_name = agent_card_params.get("name", agent_id)
# Get litellm_params (may include custom_llm_provider for completion bridge)
litellm_params = agent.litellm_params or {}

View file

@ -143,6 +143,19 @@ def test_defaults_for_missing_skills_and_modes():
assert merged["defaultOutputModes"] == ["text"]
def test_defaults_version_when_upstream_omits_it():
sparse = {"name": "x", "description": "y"}
merged = merge_agent_card(sparse, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
assert merged["version"] == "1.0.0"
def test_preserves_upstream_version_when_present():
merged = merge_agent_card(
_full_upstream_card(), proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE
)
assert merged["version"] == "1.2.3"
def test_falls_back_to_litellm_provider_when_upstream_lacks_one():
sparse = {"name": "x", "description": "y", "version": "1"}
merged = merge_agent_card(sparse, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)

View file

@ -4,6 +4,7 @@ Mock tests for A2A endpoints.
Tests that invoke_agent_a2a properly integrates with add_litellm_data_to_request.
"""
import json
import sys
from unittest.mock import AsyncMock, MagicMock, patch
@ -181,3 +182,67 @@ async def test_invoke_agent_a2a_adds_litellm_data():
# Verify proxy_server_request was added
assert "proxy_server_request" in captured_data
assert captured_data["proxy_server_request"]["method"] == "POST"
@pytest.mark.asyncio
async def test_invoke_agent_a2a_handles_none_agent_card_params():
"""Agents without ``agent_card_params`` (e.g. plain chat agents routed
through the A2A endpoint by mistake) must not raise ``AttributeError`` on
``agent_card_params.get(...)`` — they should return a JSON-RPC error.
"""
from litellm.proxy._types import UserAPIKeyAuth
mock_agent = MagicMock()
mock_agent.agent_card_params = None
mock_agent.litellm_params = None
mock_request = MagicMock()
mock_request.json = AsyncMock(
return_value={
"jsonrpc": "2.0",
"id": "test-id",
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [{"kind": "text", "text": "Hello"}],
"messageId": "msg-123",
}
},
}
)
mock_user_api_key_dict = UserAPIKeyAuth(
api_key="sk-test-key",
user_id="test-user",
team_id="test-team",
)
with (
patch(
"litellm.proxy.agent_endpoints.a2a_endpoints._get_agent",
return_value=mock_agent,
),
patch(
"litellm.a2a_protocol.main.A2A_SDK_AVAILABLE",
True,
),
patch.dict(sys.modules, {"a2a": MagicMock(), "a2a.types": MagicMock()}),
):
from litellm.proxy.agent_endpoints.a2a_endpoints import invoke_agent_a2a
mock_fastapi_response = MagicMock()
response = await invoke_agent_a2a(
agent_id="test-agent",
request=mock_request,
fastapi_response=mock_fastapi_response,
user_api_key_dict=mock_user_api_key_dict,
)
# JSONResponse exposes the body bytes; decode and verify it's a
# JSON-RPC error, not an "internal error" from a Python exception.
body = json.loads(response.body.decode())
assert body["jsonrpc"] == "2.0"
assert body["error"]["code"] == -32000
assert "no URL configured" in body["error"]["message"]

View file

@ -125,16 +125,10 @@ export const buildDiscoveryRequest = (
};
}
const credentialFields = selectedAgentTypeInfo?.credential_fields ?? [];
const baseKey = credentialFields.find((f) =>
/(^|_)(url|api_base|endpoint)$/i.test(f.key),
)?.key;
if (!baseKey) return undefined;
const base = stripTrailingSlash(trim(values[baseKey]));
if (!base) return undefined;
return {
url: base,
discovery_mode: "well_known_fallback",
display_url: `${base}/.well-known/agent-card.json`,
};
// Non-A2A agent runtimes (Azure AI Foundry, Bedrock AgentCore, Vertex,
// etc.) don't expose well-known agent cards on their credential URLs, so
// we deliberately don't auto-fire discovery for them. The
// ``AgentCardDiscovery`` widget falls back to a manual URL input the admin
// can use as an escape hatch.
return undefined;
};