From 22d2fb74cfa11163be62aa3e44808ec14b9f4904 Mon Sep 17 00:00:00 2001 From: droideronline Date: Thu, 8 Oct 2026 14:03:47 +0530 Subject: [PATCH 1/4] Python: A2AAgent: send request metadata from client_kwargs Read an explicit a2a_metadata mapping from client_kwargs and send it as SendMessageRequest.metadata. Opt-in and JSON-compatible only. --- .../a2a/agent_framework_a2a/_agent.py | 34 ++++++++++++++++-- python/packages/a2a/tests/test_a2a_agent.py | 35 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 4afdd0cd3bc..9a9d323f6e2 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import json import sys import uuid import warnings @@ -492,8 +493,12 @@ def run( session: The conversation session associated with the message(s). function_invocation_kwargs: Present for compatibility with the shared agent interface. A2AAgent does not use these values directly. - client_kwargs: Present for compatibility with the shared agent interface. - A2AAgent does not use these values directly. + client_kwargs: Client-specific keyword arguments. The only key A2AAgent reads is + ``a2a_metadata``, a JSON-compatible mapping sent to the server as + ``SendMessageRequest.metadata`` (available server-side as + ``RequestContext.metadata``). Nothing else is transmitted. This is the + entry point for passing request-scoped data, such as a user id, to a + remote A2A server from a workflow. kwargs: Additional compatibility keyword arguments. A2AAgent does not use these values directly. continuation_token: Optional token to resume a long-running task @@ -535,6 +540,9 @@ def run( a2a_message = self._prepare_message_for_a2a(normalized_messages[-1], session=session) input_request_occurrence_id = a2a_message.message_id request = SendMessageRequest(message=a2a_message) + request_metadata = self._get_request_metadata(client_kwargs) + if request_metadata: + request.metadata.update(request_metadata) if background and not stream: # return_immediately only applies to non-streaming (message/send) request.configuration.return_immediately = True @@ -1089,6 +1097,28 @@ async def poll_task(self, continuation_token: A2AContinuationToken) -> AgentResp return AgentResponse.from_updates(updates) return AgentResponse(messages=[], response_id=task.id, raw_representation=task) + @staticmethod + def _get_request_metadata(client_kwargs: Mapping[str, Any] | None) -> dict[str, Any]: + """Extract the explicit ``a2a_metadata`` mapping from client kwargs. + + Raises: + AgentInvalidRequestException: If ``a2a_metadata`` is not a mapping or is not JSON-compatible. + """ + if not client_kwargs: + return {} + metadata = client_kwargs.get("a2a_metadata") + if metadata is None: + return {} + if not isinstance(metadata, Mapping): + raise AgentInvalidRequestException("client_kwargs['a2a_metadata'] must be a mapping.") + try: + # Round trip through JSON so only JSON-compatible values reach the protobuf Struct. + return cast("dict[str, Any]", json.loads(json.dumps(dict(cast("Mapping[str, Any]", metadata))))) + except (TypeError, ValueError) as ex: + raise AgentInvalidRequestException( + "client_kwargs['a2a_metadata'] must contain only JSON-compatible values." + ) from ex + def _prepare_message_for_a2a(self, message: Message, *, session: AgentSession | None = None) -> A2AMessage: """Prepare a Message for the A2A protocol. diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index d578f1af039..d98767c45e4 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -2874,3 +2874,38 @@ async def test_context_manager_closes_self_created_http_client() -> None: pass mock_http_client.aclose.assert_called_once() + + +async def test_run_sends_a2a_metadata_as_request_metadata(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """client_kwargs['a2a_metadata'] is sent as SendMessageRequest.metadata, not as message metadata.""" + mock_a2a_client.add_message_response("msg-1", "Done") + + await a2a_agent.run( + "Hello", + client_kwargs={"a2a_metadata": {"user_id": "42", "user_name": "Dinesh"}}, + ) + + assert MessageToDict(mock_a2a_client.last_request.metadata) == {"user_id": "42", "user_name": "Dinesh"} + assert not mock_a2a_client.last_message.metadata + + +async def test_run_does_not_send_other_kwargs(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Only the explicit a2a_metadata key is transmitted.""" + mock_a2a_client.add_message_response("msg-1", "Done") + + await a2a_agent.run( + "Hello", + client_kwargs={"user_id": "42"}, + function_invocation_kwargs={"user_id": "42"}, + ) + + assert not mock_a2a_client.last_request.metadata + + +@mark.parametrize("bad_metadata", ["text", {"value": object()}], ids=["not-a-mapping", "not-json"]) +async def test_run_rejects_invalid_a2a_metadata( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient, bad_metadata: Any +) -> None: + with raises(AgentInvalidRequestException): + await a2a_agent.run("Hello", client_kwargs={"a2a_metadata": bad_metadata}) + assert mock_a2a_client.call_count == 0 From cafa6bdf1522e16063000e261b67a706f5682147 Mon Sep 17 00:00:00 2001 From: droideronline Date: Thu, 8 Oct 2026 14:17:25 +0530 Subject: [PATCH 2/4] Python: A2AAgent: forward selected run kwargs in request metadata Replace the explicit a2a_metadata argument with opt-in forwarding of selected function_invocation_kwargs and client_kwargs keys, sent under the agent_framework key of SendMessageRequest.metadata. --- .../a2a/agent_framework_a2a/_agent.py | 68 +++++++++++-------- .../a2a/agent_framework_a2a/_utils.py | 4 ++ python/packages/a2a/tests/test_a2a_agent.py | 55 ++++++++++----- 3 files changed, 81 insertions(+), 46 deletions(-) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 9a9d323f6e2..abe1c098032 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -51,7 +51,7 @@ from google.protobuf.json_format import MessageToDict from ._feature_usage import FeatureIndex -from ._utils import get_uri_data +from ._utils import AGENT_FRAMEWORK_METADATA_KEY, get_uri_data if sys.version_info >= (3, 11): from typing import TypedDict # pragma: no cover @@ -243,6 +243,7 @@ def __init__( auth_interceptor: AuthInterceptor | None = None, timeout: float | httpx.Timeout | None = None, supported_protocol_bindings: list[Literal["JSONRPC", "GRPC", "HTTP+JSON"] | str] | None = None, + forwarded_kwargs: Sequence[str] | None = None, **kwargs: Any, ) -> None: """Initialize the A2AAgent. @@ -265,6 +266,11 @@ def __init__( supported_protocol_bindings: List of protocol bindings to use for transport negotiation. Known values: "JSONRPC", "GRPC", "HTTP+JSON". Defaults to ["JSONRPC"]. The A2A spec treats this as an open-form string, so custom bindings are also accepted. + forwarded_kwargs: Names of keys to forward to the remote A2A server. Keys are looked up in + the ``function_invocation_kwargs`` and ``client_kwargs`` passed to :meth:`run` and sent + in ``SendMessageRequest.metadata`` under ``"agent_framework"``. The default (``None``) + sends nothing. Values must be JSON-compatible. The receiving ``A2AExecutor`` decides + which keys it accepts. kwargs: any additional properties, passed to BaseAgent. """ # Default name/description from agent_card when not explicitly provided @@ -275,6 +281,7 @@ def __init__( description = agent_card.description super().__init__(id=id, name=name, description=description, **kwargs) + self._forwarded_kwargs: frozenset[str] = frozenset(forwarded_kwargs or ()) self._http_client: httpx.AsyncClient | None = http_client # Only HTTP clients created by this agent are closed on exit. self._close_http_client = False @@ -491,14 +498,10 @@ def run( Keyword Args: stream: Whether to stream the response. Defaults to False. session: The conversation session associated with the message(s). - function_invocation_kwargs: Present for compatibility with the shared agent interface. - A2AAgent does not use these values directly. - client_kwargs: Client-specific keyword arguments. The only key A2AAgent reads is - ``a2a_metadata``, a JSON-compatible mapping sent to the server as - ``SendMessageRequest.metadata`` (available server-side as - ``RequestContext.metadata``). Nothing else is transmitted. This is the - entry point for passing request-scoped data, such as a user id, to a - remote A2A server from a workflow. + function_invocation_kwargs: Keyword arguments for tool invocation. Only the keys named in + ``forwarded_kwargs`` are sent to the server, nothing otherwise. + client_kwargs: Keyword arguments for the chat client. Only the keys named in + ``forwarded_kwargs`` are sent to the server, nothing otherwise. kwargs: Additional compatibility keyword arguments. A2AAgent does not use these values directly. continuation_token: Optional token to resume a long-running task @@ -540,9 +543,9 @@ def run( a2a_message = self._prepare_message_for_a2a(normalized_messages[-1], session=session) input_request_occurrence_id = a2a_message.message_id request = SendMessageRequest(message=a2a_message) - request_metadata = self._get_request_metadata(client_kwargs) - if request_metadata: - request.metadata.update(request_metadata) + forwarded = self._get_forwarded_kwargs(function_invocation_kwargs, client_kwargs) + if forwarded: + request.metadata.update({AGENT_FRAMEWORK_METADATA_KEY: forwarded}) if background and not stream: # return_immediately only applies to non-streaming (message/send) request.configuration.return_immediately = True @@ -1097,27 +1100,34 @@ async def poll_task(self, continuation_token: A2AContinuationToken) -> AgentResp return AgentResponse.from_updates(updates) return AgentResponse(messages=[], response_id=task.id, raw_representation=task) - @staticmethod - def _get_request_metadata(client_kwargs: Mapping[str, Any] | None) -> dict[str, Any]: - """Extract the explicit ``a2a_metadata`` mapping from client kwargs. + def _get_forwarded_kwargs( + self, + function_invocation_kwargs: Mapping[str, Any] | None, + client_kwargs: Mapping[str, Any] | None, + ) -> dict[str, dict[str, Any]]: + """Select the opted-in run kwargs to send in the request metadata. Raises: - AgentInvalidRequestException: If ``a2a_metadata`` is not a mapping or is not JSON-compatible. + AgentInvalidRequestException: If a selected value is not JSON-compatible. """ - if not client_kwargs: - return {} - metadata = client_kwargs.get("a2a_metadata") - if metadata is None: + if not self._forwarded_kwargs: return {} - if not isinstance(metadata, Mapping): - raise AgentInvalidRequestException("client_kwargs['a2a_metadata'] must be a mapping.") - try: - # Round trip through JSON so only JSON-compatible values reach the protobuf Struct. - return cast("dict[str, Any]", json.loads(json.dumps(dict(cast("Mapping[str, Any]", metadata))))) - except (TypeError, ValueError) as ex: - raise AgentInvalidRequestException( - "client_kwargs['a2a_metadata'] must contain only JSON-compatible values." - ) from ex + forwarded: dict[str, dict[str, Any]] = {} + for name, source in ( + ("function_invocation_kwargs", function_invocation_kwargs), + ("client_kwargs", client_kwargs), + ): + selected = {key: value for key, value in (source or {}).items() if key in self._forwarded_kwargs} + if not selected: + continue + try: + # Round trip through JSON so only JSON-compatible values reach the protobuf Struct. + forwarded[name] = cast("dict[str, Any]", json.loads(json.dumps(selected))) + except (TypeError, ValueError) as ex: + raise AgentInvalidRequestException( + f"Forwarded {name} must contain only JSON-compatible values." + ) from ex + return forwarded def _prepare_message_for_a2a(self, message: Message, *, session: AgentSession | None = None) -> A2AMessage: """Prepare a Message for the A2A protocol. diff --git a/python/packages/a2a/agent_framework_a2a/_utils.py b/python/packages/a2a/agent_framework_a2a/_utils.py index edd4337c841..e79b4c1a941 100644 --- a/python/packages/a2a/agent_framework_a2a/_utils.py +++ b/python/packages/a2a/agent_framework_a2a/_utils.py @@ -2,6 +2,10 @@ import re +# Key in ``SendMessageRequest.metadata`` that carries forwarded ``function_invocation_kwargs`` and +# ``client_kwargs``. +AGENT_FRAMEWORK_METADATA_KEY = "agent_framework" + URI_PATTERN = re.compile(r"^data:(?P[^;,]+(?:;[^;,=]+=[^;,]+)*);base64,(?P[A-Za-z0-9+/=]+)\Z") diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index d98767c45e4..720e26360db 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -2876,36 +2876,57 @@ async def test_context_manager_closes_self_created_http_client() -> None: mock_http_client.aclose.assert_called_once() -async def test_run_sends_a2a_metadata_as_request_metadata(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: - """client_kwargs['a2a_metadata'] is sent as SendMessageRequest.metadata, not as message metadata.""" +def _forwarding_agent(mock_a2a_client: MockA2AClient, forwarded_kwargs: list[str] | None) -> A2AAgent: + return A2AAgent( + name="Test Agent", + id="test-agent", + client=cast(Any, mock_a2a_client), + http_client=None, + forwarded_kwargs=forwarded_kwargs, + ) + + +async def test_run_forwards_selected_kwargs_in_request_metadata(mock_a2a_client: MockA2AClient) -> None: + """Only keys named in forwarded_kwargs are sent, grouped by their source kwargs, in the request metadata.""" + agent = _forwarding_agent(mock_a2a_client, ["tenant", "trace"]) mock_a2a_client.add_message_response("msg-1", "Done") - await a2a_agent.run( + await agent.run( "Hello", - client_kwargs={"a2a_metadata": {"user_id": "42", "user_name": "Dinesh"}}, + function_invocation_kwargs={"tenant": "acme", "secret": "do-not-send"}, + client_kwargs={"trace": {"id": "abc"}, "other": 1}, ) - assert MessageToDict(mock_a2a_client.last_request.metadata) == {"user_id": "42", "user_name": "Dinesh"} + assert MessageToDict(mock_a2a_client.last_request.metadata) == { + "agent_framework": { + "function_invocation_kwargs": {"tenant": "acme"}, + "client_kwargs": {"trace": {"id": "abc"}}, + } + } assert not mock_a2a_client.last_message.metadata -async def test_run_does_not_send_other_kwargs(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: - """Only the explicit a2a_metadata key is transmitted.""" +async def test_run_forwards_nothing_by_default(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: mock_a2a_client.add_message_response("msg-1", "Done") - await a2a_agent.run( - "Hello", - client_kwargs={"user_id": "42"}, - function_invocation_kwargs={"user_id": "42"}, - ) + await a2a_agent.run("Hello", function_invocation_kwargs={"tenant": "acme"}, client_kwargs={"trace": "abc"}) assert not mock_a2a_client.last_request.metadata -@mark.parametrize("bad_metadata", ["text", {"value": object()}], ids=["not-a-mapping", "not-json"]) -async def test_run_rejects_invalid_a2a_metadata( - a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient, bad_metadata: Any -) -> None: +async def test_run_forwards_nothing_when_selected_keys_absent(mock_a2a_client: MockA2AClient) -> None: + agent = _forwarding_agent(mock_a2a_client, ["tenant"]) + mock_a2a_client.add_message_response("msg-1", "Done") + + await agent.run("Hello", client_kwargs={"trace": "abc"}) + + assert not mock_a2a_client.last_request.metadata + + +async def test_run_rejects_non_json_forwarded_values(mock_a2a_client: MockA2AClient) -> None: + agent = _forwarding_agent(mock_a2a_client, ["tenant"]) + with raises(AgentInvalidRequestException): - await a2a_agent.run("Hello", client_kwargs={"a2a_metadata": bad_metadata}) + await agent.run("Hello", function_invocation_kwargs={"tenant": object()}) + assert mock_a2a_client.call_count == 0 From 4bc0515d34f6f69e744e28cf48fff9d1940e53a8 Mon Sep 17 00:00:00 2001 From: droideronline Date: Fri, 9 Oct 2026 22:01:30 +0530 Subject: [PATCH 3/4] Python: A2AAgent: reject values protobuf Struct cannot carry exactly Reject NaN and infinities, integers outside +/-2**53, and values that fail Struct conversion, all as AgentInvalidRequestException before any request is sent. --- .../a2a/agent_framework_a2a/_agent.py | 41 ++++++++++++++++--- python/packages/a2a/tests/test_a2a_agent.py | 24 +++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index abe1c098032..e908880f7cd 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -49,6 +49,7 @@ from agent_framework.exceptions import AgentInvalidRequestException from agent_framework.observability import AgentTelemetryLayer from google.protobuf.json_format import MessageToDict +from google.protobuf.struct_pb2 import Struct from ._feature_usage import FeatureIndex from ._utils import AGENT_FRAMEWORK_METADATA_KEY, get_uri_data @@ -217,6 +218,34 @@ class A2AContinuationToken(ContinuationToken): A2AStreamItem: TypeAlias = StreamResponse +# Largest magnitude at which every integer is exactly representable as the double protobuf Struct uses. +_MAX_EXACT_STRUCT_INT = 2**53 + + +def _parse_exact_int(text: str) -> int: + value = int(text) + if abs(value) > _MAX_EXACT_STRUCT_INT: + raise ValueError(f"integer {text[:20]} is outside the exactly representable range of a protobuf Struct") + return value + + +def _to_struct_safe_json(values: Mapping[str, Any]) -> dict[str, Any]: + """Return a JSON round-tripped copy of ``values`` that a protobuf ``Struct`` can carry exactly. + + Raises: + TypeError: If a value is not JSON-compatible. + ValueError: If a value is NaN, infinite, an out-of-range integer, or otherwise unrepresentable. + OverflowError: If a number is too large for a protobuf ``Struct``. + """ + # allow_nan=False rejects NaN and infinities, and parse_int rejects integers a Struct would round. + round_tripped = cast( + "dict[str, Any]", json.loads(json.dumps(dict(values), allow_nan=False), parse_int=_parse_exact_int) + ) + # Dry run so any remaining Struct conversion failure (for example an unpaired surrogate) surfaces here. + Struct().update(round_tripped) + return round_tripped + + class A2AAgent(AgentTelemetryLayer, BaseAgent): """Agent2Agent (A2A) protocol implementation. @@ -269,7 +298,9 @@ def __init__( forwarded_kwargs: Names of keys to forward to the remote A2A server. Keys are looked up in the ``function_invocation_kwargs`` and ``client_kwargs`` passed to :meth:`run` and sent in ``SendMessageRequest.metadata`` under ``"agent_framework"``. The default (``None``) - sends nothing. Values must be JSON-compatible. The receiving ``A2AExecutor`` decides + sends nothing. Values must be JSON-compatible with finite numbers and integers within + +/-2**53 (protobuf ``Struct`` stores numbers as doubles), otherwise + ``AgentInvalidRequestException`` is raised. The receiving ``A2AExecutor`` decides which keys it accepts. kwargs: any additional properties, passed to BaseAgent. """ @@ -1121,11 +1152,11 @@ def _get_forwarded_kwargs( if not selected: continue try: - # Round trip through JSON so only JSON-compatible values reach the protobuf Struct. - forwarded[name] = cast("dict[str, Any]", json.loads(json.dumps(selected))) - except (TypeError, ValueError) as ex: + forwarded[name] = _to_struct_safe_json(selected) + except (TypeError, ValueError, OverflowError) as ex: raise AgentInvalidRequestException( - f"Forwarded {name} must contain only JSON-compatible values." + f"Forwarded {name} must contain only values that protobuf Struct can carry " + f"(JSON-compatible, finite numbers, integers within +/-2**53): {ex}" ) from ex return forwarded diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 720e26360db..2ee51be9782 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -2930,3 +2930,27 @@ async def test_run_rejects_non_json_forwarded_values(mock_a2a_client: MockA2ACli await agent.run("Hello", function_invocation_kwargs={"tenant": object()}) assert mock_a2a_client.call_count == 0 + + +@mark.parametrize( + "value", + [float("nan"), float("inf"), -float("inf"), 2**53 + 1, -(2**53) - 1, 10**400, "\ud800"], + ids=["nan", "inf", "-inf", "int-too-large", "int-too-small", "int-overflow", "unpaired-surrogate"], +) +async def test_run_rejects_values_protobuf_struct_cannot_carry(mock_a2a_client: MockA2AClient, value: Any) -> None: + agent = _forwarding_agent(mock_a2a_client, ["tenant"]) + + with raises(AgentInvalidRequestException): + await agent.run("Hello", function_invocation_kwargs={"tenant": value}) + + assert mock_a2a_client.call_count == 0 + + +async def test_run_forwards_integers_at_the_exact_boundary(mock_a2a_client: MockA2AClient) -> None: + agent = _forwarding_agent(mock_a2a_client, ["count"]) + mock_a2a_client.add_message_response("msg-1", "Done") + + await agent.run("Hello", client_kwargs={"count": 2**53}) + + forwarded = MessageToDict(mock_a2a_client.last_request.metadata)["agent_framework"]["client_kwargs"] + assert forwarded == {"count": float(2**53)} From a74ec52a1690c53b45a473511a6cdc849aa05d83 Mon Sep 17 00:00:00 2001 From: droideronline Date: Mon, 12 Oct 2026 14:45:37 +0530 Subject: [PATCH 4/4] Python: A2AAgent: reject non-str mapping keys in forwarded kwargs json.dumps coerces non-str keys, so {1: 'a', '1': 'b'} silently dropped data. Reject them recursively before the JSON round trip. --- .../packages/a2a/agent_framework_a2a/_agent.py | 16 +++++++++++++++- python/packages/a2a/tests/test_a2a_agent.py | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index e908880f7cd..f16e263cbf8 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -229,14 +229,28 @@ def _parse_exact_int(text: str) -> int: return value +def _reject_non_str_keys(value: Any) -> None: + """Recursively raise if any mapping key is not a ``str``, since JSON would silently coerce it.""" + if isinstance(value, Mapping): + for key, item in cast("Mapping[Any, Any]", value).items(): + if not isinstance(key, str): + raise TypeError(f"mapping keys must be str, got {type(key).__name__}: {key!r}") + _reject_non_str_keys(item) + elif isinstance(value, (list, tuple)): + for item in cast("Sequence[Any]", value): + _reject_non_str_keys(item) + + def _to_struct_safe_json(values: Mapping[str, Any]) -> dict[str, Any]: """Return a JSON round-tripped copy of ``values`` that a protobuf ``Struct`` can carry exactly. Raises: - TypeError: If a value is not JSON-compatible. + TypeError: If a value is not JSON-compatible or a mapping key is not a ``str``. ValueError: If a value is NaN, infinite, an out-of-range integer, or otherwise unrepresentable. OverflowError: If a number is too large for a protobuf ``Struct``. """ + # json.dumps coerces non-str keys (for example 1 and "1" collide), so reject them before the round trip. + _reject_non_str_keys(values) # allow_nan=False rejects NaN and infinities, and parse_int rejects integers a Struct would round. round_tripped = cast( "dict[str, Any]", json.loads(json.dumps(dict(values), allow_nan=False), parse_int=_parse_exact_int) diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 2ee51be9782..283b8198f62 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -2946,6 +2946,20 @@ async def test_run_rejects_values_protobuf_struct_cannot_carry(mock_a2a_client: assert mock_a2a_client.call_count == 0 +@mark.parametrize( + "value", + [{1: "numeric", "1": "string"}, {"nested": [{2: "x"}]}, {None: "x"}], + ids=["key-collision", "nested-in-list", "none-key"], +) +async def test_run_rejects_non_str_mapping_keys(mock_a2a_client: MockA2AClient, value: Any) -> None: + agent = _forwarding_agent(mock_a2a_client, ["tenant"]) + + with raises(AgentInvalidRequestException): + await agent.run("Hello", function_invocation_kwargs={"tenant": value}) + + assert mock_a2a_client.call_count == 0 + + async def test_run_forwards_integers_at_the_exact_boundary(mock_a2a_client: MockA2AClient) -> None: agent = _forwarding_agent(mock_a2a_client, ["count"]) mock_a2a_client.add_message_response("msg-1", "Done")