Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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.
  • Loading branch information
droideronline committed Oct 9, 2026
commit cafa6bdf1522e16063000e261b67a706f5682147
68 changes: 39 additions & 29 deletions python/packages/a2a/agent_framework_a2a/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})
Comment thread
droideronline marked this conversation as resolved.
Resolved
if background and not stream:
# return_immediately only applies to non-streaming (message/send)
request.configuration.return_immediately = True
Expand Down Expand Up @@ -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)))
Comment thread
droideronline marked this conversation as resolved.
Resolved
Comment thread
droideronline marked this conversation as resolved.
Resolved
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.
Expand Down
4 changes: 4 additions & 0 deletions python/packages/a2a/agent_framework_a2a/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<media_type>[^;,]+(?:;[^;,=]+=[^;,]+)*);base64,(?P<base64_data>[A-Za-z0-9+/=]+)\Z")


Expand Down
55 changes: 38 additions & 17 deletions python/packages/a2a/tests/test_a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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