From fc31d03e8c6acb68660f6d1924262e16933c5d50 Mon Sep 17 00:00:00 2001
From: Iva Sokolaj <102302011+sokoliva@users.noreply.github.com>
Date: Fri, 21 Nov 2025 12:53:53 +0100
Subject: [PATCH 01/29] fix: Ensure metadata propagation for `Task` `ToProto`
and `FromProto` conversion (#557)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The `metadata` dictionary is now correctly propagated in both the
`ToProto` (types.Task to a2a_pb2.Task) and `FromProto` (a2a_pb2.Task to
types.Task) conversion utilities.
Release-As:0.3.16
Fixes #541 š¦
---
src/a2a/utils/proto_utils.py | 2 ++
tests/utils/test_proto_utils.py | 28 ++++++++++++++++++++++++++++
2 files changed, 30 insertions(+)
diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py
index d077d62b..8bf01eea 100644
--- a/src/a2a/utils/proto_utils.py
+++ b/src/a2a/utils/proto_utils.py
@@ -203,6 +203,7 @@ def task(cls, task: types.Task) -> a2a_pb2.Task:
if task.history
else None
),
+ metadata=cls.metadata(task.metadata),
)
@classmethod
@@ -660,6 +661,7 @@ def task(cls, task: a2a_pb2.Task) -> types.Task:
status=cls.task_status(task.status),
artifacts=[cls.artifact(a) for a in task.artifacts],
history=[cls.message(h) for h in task.history],
+ metadata=cls.metadata(task.metadata),
)
@classmethod
diff --git a/tests/utils/test_proto_utils.py b/tests/utils/test_proto_utils.py
index da54f833..33be1f3f 100644
--- a/tests/utils/test_proto_utils.py
+++ b/tests/utils/test_proto_utils.py
@@ -52,6 +52,7 @@ def sample_task(sample_message: types.Message) -> types.Task:
],
)
],
+ metadata={'source': 'test'},
)
@@ -508,3 +509,30 @@ def test_large_integer_roundtrip_with_utilities(self):
assert final_result['nested']['another_large'] == 12345678901234567890
assert isinstance(final_result['nested']['another_large'], int)
assert final_result['nested']['normal'] == 'text'
+
+ def test_task_conversion_roundtrip(
+ self, sample_task: types.Task, sample_message: types.Message
+ ):
+ """Test conversion of Task to proto and back."""
+ proto_task = proto_utils.ToProto.task(sample_task)
+ assert isinstance(proto_task, a2a_pb2.Task)
+
+ roundtrip_task = proto_utils.FromProto.task(proto_task)
+ assert roundtrip_task.id == 'task-1'
+ assert roundtrip_task.context_id == 'ctx-1'
+ assert roundtrip_task.status == types.TaskStatus(
+ state=types.TaskState.working, message=sample_message
+ )
+ assert roundtrip_task.history == [sample_message]
+ assert roundtrip_task.artifacts == [
+ types.Artifact(
+ artifact_id='art-1',
+ description='',
+ metadata={},
+ name='',
+ parts=[
+ types.Part(root=types.TextPart(text='Artifact content'))
+ ],
+ )
+ ]
+ assert roundtrip_task.metadata == {'source': 'test'}
From dbc73e84020d9ca6dae2eca5bcf5820580ca2edf Mon Sep 17 00:00:00 2001
From: "Agent2Agent (A2A) Bot"
Date: Fri, 21 Nov 2025 07:34:03 -0600
Subject: [PATCH 02/29] chore(main): release 0.3.16 (#562)
:robot: I have created a release *beep* *boop*
---
##
[0.3.16](https://github.com/a2aproject/a2a-python/compare/v0.3.15...v0.3.16)
(2025-11-21)
### Bug Fixes
* Ensure metadata propagation for `Task` `ToProto` and `FromProto`
conversion ([#557](https://github.com/a2aproject/a2a-python/issues/557))
([fc31d03](https://github.com/a2aproject/a2a-python/commit/fc31d03e8c6acb68660f6d1924262e16933c5d50))
---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
---
CHANGELOG.md | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5e6048d..026e8fd1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,11 @@
- # Changelog
+# Changelog
+
+## [0.3.16](https://github.com/a2aproject/a2a-python/compare/v0.3.15...v0.3.16) (2025-11-21)
+
+
+### Bug Fixes
+
+* Ensure metadata propagation for `Task` `ToProto` and `FromProto` conversion ([#557](https://github.com/a2aproject/a2a-python/issues/557)) ([fc31d03](https://github.com/a2aproject/a2a-python/commit/fc31d03e8c6acb68660f6d1924262e16933c5d50))
## [0.3.15](https://github.com/a2aproject/a2a-python/compare/v0.3.14...v0.3.15) (2025-11-19)
From 53bbf7ae3ad58fb0c10b14da05cf07c0a7bd9651 Mon Sep 17 00:00:00 2001
From: Tadaki Asechi <127199356+TadakiAsechi@users.noreply.github.com>
Date: Mon, 24 Nov 2025 20:41:28 +0900
Subject: [PATCH 03/29] feat(client): allow specifying `history_length` via
call-site `MessageSendConfiguration` in `BaseClient.send_message` (#500)
## Description
This PR implements the client-side enhancement described in #499.
It allows callers to specify `history_length` (and other message
configuration options)
directly via the optional configuration parameter in
`BaseClient.send_message`,
complementing the server-side support added in #497.
### Summary of Changes
- Added optional argument `configuration: MessageSendConfiguration |
None` to `BaseClient.send_message`.
- When provided, merges call-site configuration with `ClientConfig`
defaults.
- Allows partial overrides (e.g., setting `historyLength` only).
- Updated docstrings to reflect the new parameter behavior.
- Added unit tests covering both non-streaming and streaming scenarios.
### Motivation
Previously, `BaseClient.send_message` built `MessageSendConfiguration`
internally from `ClientConfig`,
but `ClientConfig` does not include `history_length`.
This prevented clients from specifying it on a per-call basis, unlike
`get_task`,
which already supports `history_length` via `TaskQueryParams`.
After this change, client behavior becomes consistent across both
methods.
### Related Issues
Fixes #499
Complements #497 (server-side historyLength support)
Release-As: 0.3.17
---------
Co-authored-by: tadaki
Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com>
Co-authored-by: TadakiAsechi
Co-authored-by: TadakiAsechi
---
src/a2a/client/base_client.py | 13 +++++-
tests/client/test_base_client.py | 76 ++++++++++++++++++++++++++++++++
2 files changed, 88 insertions(+), 1 deletion(-)
diff --git a/src/a2a/client/base_client.py b/src/a2a/client/base_client.py
index 5719bc1b..fac7ecad 100644
--- a/src/a2a/client/base_client.py
+++ b/src/a2a/client/base_client.py
@@ -47,6 +47,7 @@ async def send_message(
self,
request: Message,
*,
+ configuration: MessageSendConfiguration | None = None,
context: ClientCallContext | None = None,
request_metadata: dict[str, Any] | None = None,
extensions: list[str] | None = None,
@@ -59,6 +60,7 @@ async def send_message(
Args:
request: The message to send to the agent.
+ configuration: Optional per-call overrides for message sending behavior.
context: The client call context.
request_metadata: Extensions Metadata attached to the request.
extensions: List of extensions to be activated.
@@ -66,7 +68,7 @@ async def send_message(
Yields:
An async iterator of `ClientEvent` or a final `Message` response.
"""
- config = MessageSendConfiguration(
+ base_config = MessageSendConfiguration(
accepted_output_modes=self._config.accepted_output_modes,
blocking=not self._config.polling,
push_notification_config=(
@@ -75,6 +77,15 @@ async def send_message(
else None
),
)
+ if configuration is not None:
+ update_data = configuration.model_dump(
+ exclude_unset=True,
+ by_alias=False,
+ )
+ config = base_config.model_copy(update=update_data)
+ else:
+ config = base_config
+
params = MessageSendParams(
message=request, configuration=config, metadata=request_metadata
)
diff --git a/tests/client/test_base_client.py b/tests/client/test_base_client.py
index f5ab2543..7aa47902 100644
--- a/tests/client/test_base_client.py
+++ b/tests/client/test_base_client.py
@@ -9,6 +9,7 @@
AgentCapabilities,
AgentCard,
Message,
+ MessageSendConfiguration,
Part,
Role,
Task,
@@ -125,3 +126,78 @@ async def test_send_message_non_streaming_agent_capability_false(
assert not mock_transport.send_message_streaming.called
assert len(events) == 1
assert events[0][0].id == 'task-789'
+
+
+@pytest.mark.asyncio
+async def test_send_message_callsite_config_overrides_non_streaming(
+ base_client: BaseClient, mock_transport: MagicMock, sample_message: Message
+):
+ base_client._config.streaming = False
+ mock_transport.send_message.return_value = Task(
+ id='task-cfg-ns-1',
+ context_id='ctx-cfg-ns-1',
+ status=TaskStatus(state=TaskState.completed),
+ )
+
+ cfg = MessageSendConfiguration(
+ history_length=2,
+ blocking=False,
+ accepted_output_modes=['application/json'],
+ )
+ events = [
+ event
+ async for event in base_client.send_message(
+ sample_message, configuration=cfg
+ )
+ ]
+
+ mock_transport.send_message.assert_called_once()
+ assert not mock_transport.send_message_streaming.called
+ assert len(events) == 1
+ task, _ = events[0]
+ assert task.id == 'task-cfg-ns-1'
+
+ params = mock_transport.send_message.call_args[0][0]
+ assert params.configuration.history_length == 2
+ assert params.configuration.blocking is False
+ assert params.configuration.accepted_output_modes == ['application/json']
+
+
+@pytest.mark.asyncio
+async def test_send_message_callsite_config_overrides_streaming(
+ base_client: BaseClient, mock_transport: MagicMock, sample_message: Message
+):
+ base_client._config.streaming = True
+ base_client._card.capabilities.streaming = True
+
+ async def create_stream(*args, **kwargs):
+ yield Task(
+ id='task-cfg-s-1',
+ context_id='ctx-cfg-s-1',
+ status=TaskStatus(state=TaskState.completed),
+ )
+
+ mock_transport.send_message_streaming.return_value = create_stream()
+
+ cfg = MessageSendConfiguration(
+ history_length=0,
+ blocking=True,
+ accepted_output_modes=['text/plain'],
+ )
+ events = [
+ event
+ async for event in base_client.send_message(
+ sample_message, configuration=cfg
+ )
+ ]
+
+ mock_transport.send_message_streaming.assert_called_once()
+ assert not mock_transport.send_message.called
+ assert len(events) == 1
+ task, _ = events[0]
+ assert task.id == 'task-cfg-s-1'
+
+ params = mock_transport.send_message_streaming.call_args[0][0]
+ assert params.configuration.history_length == 0
+ assert params.configuration.blocking is True
+ assert params.configuration.accepted_output_modes == ['text/plain']
From 7e121e0f14f61254a1f136d6f25efb81b94c58e7 Mon Sep 17 00:00:00 2001
From: "Agent2Agent (A2A) Bot"
Date: Mon, 24 Nov 2025 06:36:54 -0600
Subject: [PATCH 04/29] chore(main): release 0.3.17 (#565)
:robot: I have created a release *beep* *boop*
---
##
[0.3.17](https://github.com/a2aproject/a2a-python/compare/v0.3.16...v0.3.17)
(2025-11-24)
### Features
* **client:** allow specifying `history_length` via call-site
`MessageSendConfiguration` in `BaseClient.send_message`
([53bbf7a](https://github.com/a2aproject/a2a-python/commit/53bbf7ae3ad58fb0c10b14da05cf07c0a7bd9651))
---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 026e8fd1..66dfd677 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [0.3.17](https://github.com/a2aproject/a2a-python/compare/v0.3.16...v0.3.17) (2025-11-24)
+
+
+### Features
+
+* **client:** allow specifying `history_length` via call-site `MessageSendConfiguration` in `BaseClient.send_message` ([53bbf7a](https://github.com/a2aproject/a2a-python/commit/53bbf7ae3ad58fb0c10b14da05cf07c0a7bd9651))
+
## [0.3.16](https://github.com/a2aproject/a2a-python/compare/v0.3.15...v0.3.16) (2025-11-21)
From 0ce239e98f67ccbf154f2edcdbcee43f3b080ead Mon Sep 17 00:00:00 2001
From: ShishirRmc <113575088+ShishirRmc@users.noreply.github.com>
Date: Tue, 25 Nov 2025 00:01:57 +0545
Subject: [PATCH 05/29] fix: return updated `agent_card` in
`JsonRpcTransport.get_card()` (#552)
## Fixes #551
### Changes
- Fixed `JsonRpcTransport.get_card()` to return the newly fetched
authenticated extended card instead of the stale card
### Details
Changed line from `return card` to `return self.agent_card` to ensure
the method returns the updated card after fetching the authenticated
extended version.
This aligns the JsonRpcTransport behavior with RestTransport's correct
implementation.
### Testing
- Verified the fix matches RestTransport's pattern
- Confirmed internal state and return value are now consistent
Release-As: 0.3.18
---
src/a2a/client/transports/jsonrpc.py | 2 +-
tests/client/transports/test_jsonrpc_client.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py
index d8011cf4..090ac541 100644
--- a/src/a2a/client/transports/jsonrpc.py
+++ b/src/a2a/client/transports/jsonrpc.py
@@ -414,7 +414,7 @@ async def get_card(
raise A2AClientJSONRPCError(response.root)
self.agent_card = response.root.result
self._needs_extended_card = False
- return card
+ return self.agent_card
async def close(self) -> None:
"""Closes the httpx client."""
diff --git a/tests/client/transports/test_jsonrpc_client.py b/tests/client/transports/test_jsonrpc_client.py
index bd705d93..31747d8e 100644
--- a/tests/client/transports/test_jsonrpc_client.py
+++ b/tests/client/transports/test_jsonrpc_client.py
@@ -774,7 +774,7 @@ async def test_get_card_with_extended_card_support(
mock_send_request.return_value = rpc_response
card = await client.get_card()
- assert card == agent_card
+ assert card == AGENT_CARD_EXTENDED
mock_send_request.assert_called_once()
sent_payload = mock_send_request.call_args.args[0]
assert sent_payload['method'] == 'agent/getAuthenticatedExtendedCard'
From 213d9f8754ae2762e8365ddd3ed9f08211563273 Mon Sep 17 00:00:00 2001
From: "Agent2Agent (A2A) Bot"
Date: Tue, 25 Nov 2025 03:14:24 -0600
Subject: [PATCH 06/29] chore(main): release 0.3.18 (#567)
:robot: I have created a release *beep* *boop*
---
##
[0.3.18](https://github.com/a2aproject/a2a-python/compare/v0.3.17...v0.3.18)
(2025-11-24)
### Bug Fixes
* return updated `agent_card` in `JsonRpcTransport.get_card()`
([#552](https://github.com/a2aproject/a2a-python/issues/552))
([0ce239e](https://github.com/a2aproject/a2a-python/commit/0ce239e98f67ccbf154f2edcdbcee43f3b080ead))
---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66dfd677..4ee60df7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [0.3.18](https://github.com/a2aproject/a2a-python/compare/v0.3.17...v0.3.18) (2025-11-24)
+
+
+### Bug Fixes
+
+* return updated `agent_card` in `JsonRpcTransport.get_card()` ([#552](https://github.com/a2aproject/a2a-python/issues/552)) ([0ce239e](https://github.com/a2aproject/a2a-python/commit/0ce239e98f67ccbf154f2edcdbcee43f3b080ead))
+
## [0.3.17](https://github.com/a2aproject/a2a-python/compare/v0.3.16...v0.3.17) (2025-11-24)
From 847f18eff59985f447c39a8e5efde87818b68d15 Mon Sep 17 00:00:00 2001
From: Iva Sokolaj <102302011+sokoliva@users.noreply.github.com>
Date: Tue, 25 Nov 2025 14:34:01 +0100
Subject: [PATCH 07/29] fix(jsonrpc, rest): `extensions` support in `get_card`
methods in `json-rpc` and `rest` transports (#564)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`Headers` are now updated with `extensions` before the `get_agent_card`
call which has headers as input parameters.
- [x] Follow the [`CONTRIBUTING`
Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
- [x] Make your Pull Request title in the
specification.
- Important Prefixes for
[release-please](https://github.com/googleapis/release-please):
- `fix:` which represents bug fixes, and correlates to a
[SemVer](https://semver.org/) patch.
- `feat:` represents a new feature, and correlates to a SemVer minor.
- `feat!:`, or `fix!:`, `refactor!:`, etc., which represent a breaking
change (indicated by the `!`) and will result in a SemVer major.
Fixes #504 š¦
Release-As: 0.3.19
---
src/a2a/client/transports/jsonrpc.py | 12 +-
src/a2a/client/transports/rest.py | 12 +-
.../client/transports/test_jsonrpc_client.py | 110 ++++++++++++--
tests/client/transports/test_rest_client.py | 137 +++++++++++++++---
4 files changed, 224 insertions(+), 47 deletions(-)
diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py
index 090ac541..6cce1eff 100644
--- a/src/a2a/client/transports/jsonrpc.py
+++ b/src/a2a/client/transports/jsonrpc.py
@@ -378,12 +378,14 @@ async def get_card(
extensions: list[str] | None = None,
) -> AgentCard:
"""Retrieves the agent's card."""
+ modified_kwargs = update_extension_header(
+ self._get_http_args(context),
+ extensions if extensions is not None else self.extensions,
+ )
card = self.agent_card
if not card:
resolver = A2ACardResolver(self.httpx_client, self.url)
- card = await resolver.get_agent_card(
- http_kwargs=self._get_http_args(context)
- )
+ card = await resolver.get_agent_card(http_kwargs=modified_kwargs)
self._needs_extended_card = (
card.supports_authenticated_extended_card
)
@@ -393,10 +395,6 @@ async def get_card(
return card
request = GetAuthenticatedExtendedCardRequest(id=str(uuid4()))
- modified_kwargs = update_extension_header(
- self._get_http_args(context),
- extensions if extensions is not None else self.extensions,
- )
payload, modified_kwargs = await self._apply_interceptors(
request.method,
request.model_dump(mode='json', exclude_none=True),
diff --git a/src/a2a/client/transports/rest.py b/src/a2a/client/transports/rest.py
index 83c26787..948f3f35 100644
--- a/src/a2a/client/transports/rest.py
+++ b/src/a2a/client/transports/rest.py
@@ -370,12 +370,14 @@ async def get_card(
extensions: list[str] | None = None,
) -> AgentCard:
"""Retrieves the agent's card."""
+ modified_kwargs = update_extension_header(
+ self._get_http_args(context),
+ extensions if extensions is not None else self.extensions,
+ )
card = self.agent_card
if not card:
resolver = A2ACardResolver(self.httpx_client, self.url)
- card = await resolver.get_agent_card(
- http_kwargs=self._get_http_args(context)
- )
+ card = await resolver.get_agent_card(http_kwargs=modified_kwargs)
self._needs_extended_card = (
card.supports_authenticated_extended_card
)
@@ -384,10 +386,6 @@ async def get_card(
if not self._needs_extended_card:
return card
- modified_kwargs = update_extension_header(
- self._get_http_args(context),
- extensions if extensions is not None else self.extensions,
- )
_, modified_kwargs = await self._apply_interceptors(
{},
modified_kwargs,
diff --git a/tests/client/transports/test_jsonrpc_client.py b/tests/client/transports/test_jsonrpc_client.py
index 31747d8e..d9dbafc8 100644
--- a/tests/client/transports/test_jsonrpc_client.py
+++ b/tests/client/transports/test_jsonrpc_client.py
@@ -114,6 +114,14 @@ async def async_iterable_from_list(
yield item
+def _assert_extensions_header(mock_kwargs: dict, expected_extensions: set[str]):
+ headers = mock_kwargs.get('headers', {})
+ assert HTTP_EXTENSION_HEADER in headers
+ header_value = headers[HTTP_EXTENSION_HEADER]
+ actual_extensions = {e.strip() for e in header_value.split(',')}
+ assert actual_extensions == expected_extensions
+
+
class TestA2ACardResolver:
BASE_URL = 'http://example.com'
AGENT_CARD_PATH = AGENT_CARD_WELL_KNOWN_PATH
@@ -823,18 +831,13 @@ async def test_send_message_with_default_extensions(
mock_httpx_client.post.assert_called_once()
_, mock_kwargs = mock_httpx_client.post.call_args
- headers = mock_kwargs.get('headers', {})
- assert HTTP_EXTENSION_HEADER in headers
- header_value = headers[HTTP_EXTENSION_HEADER]
- actual_extensions_list = [e.strip() for e in header_value.split(',')]
- actual_extensions = set(actual_extensions_list)
-
- expected_extensions = {
- 'https://example.com/test-ext/v1',
- 'https://example.com/test-ext/v2',
- }
- assert len(actual_extensions_list) == 2
- assert actual_extensions == expected_extensions
+ _assert_extensions_header(
+ mock_kwargs,
+ {
+ 'https://example.com/test-ext/v1',
+ 'https://example.com/test-ext/v2',
+ },
+ )
@pytest.mark.asyncio
@patch('a2a.client.transports.jsonrpc.aconnect_sse')
@@ -870,8 +873,83 @@ async def test_send_message_streaming_with_new_extensions(
mock_aconnect_sse.assert_called_once()
_, kwargs = mock_aconnect_sse.call_args
- headers = kwargs.get('headers', {})
- assert HTTP_EXTENSION_HEADER in headers
- assert (
- headers[HTTP_EXTENSION_HEADER] == 'https://example.com/test-ext/v2'
+ _assert_extensions_header(
+ kwargs,
+ {
+ 'https://example.com/test-ext/v2',
+ },
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_card_no_card_provided_with_extensions(
+ self, mock_httpx_client: AsyncMock
+ ):
+ """Test get_card with extensions set in Client when no card is initially provided.
+ Tests that the extensions are added to the HTTP GET request."""
+ extensions = [
+ 'https://example.com/test-ext/v1',
+ 'https://example.com/test-ext/v2',
+ ]
+ client = JsonRpcTransport(
+ httpx_client=mock_httpx_client,
+ url=TestJsonRpcTransport.AGENT_URL,
+ extensions=extensions,
+ )
+ mock_response = AsyncMock(spec=httpx.Response)
+ mock_response.status_code = 200
+ mock_response.json.return_value = AGENT_CARD.model_dump(mode='json')
+ mock_httpx_client.get.return_value = mock_response
+
+ await client.get_card()
+
+ mock_httpx_client.get.assert_called_once()
+ _, mock_kwargs = mock_httpx_client.get.call_args
+
+ _assert_extensions_header(
+ mock_kwargs,
+ {
+ 'https://example.com/test-ext/v1',
+ 'https://example.com/test-ext/v2',
+ },
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_card_with_extended_card_support_with_extensions(
+ self, mock_httpx_client: AsyncMock
+ ):
+ """Test get_card with extensions passed to get_card call when extended card support is enabled.
+ Tests that the extensions are added to the RPC request."""
+ extensions = [
+ 'https://example.com/test-ext/v1',
+ 'https://example.com/test-ext/v2',
+ ]
+ agent_card = AGENT_CARD.model_copy(
+ update={'supports_authenticated_extended_card': True}
+ )
+ client = JsonRpcTransport(
+ httpx_client=mock_httpx_client,
+ agent_card=agent_card,
+ extensions=extensions,
+ )
+
+ rpc_response = {
+ 'id': '123',
+ 'jsonrpc': '2.0',
+ 'result': AGENT_CARD_EXTENDED.model_dump(mode='json'),
+ }
+ with patch.object(
+ client, '_send_request', new_callable=AsyncMock
+ ) as mock_send_request:
+ mock_send_request.return_value = rpc_response
+ await client.get_card(extensions=extensions)
+
+ mock_send_request.assert_called_once()
+ _, mock_kwargs = mock_send_request.call_args[0]
+
+ _assert_extensions_header(
+ mock_kwargs,
+ {
+ 'https://example.com/test-ext/v1',
+ 'https://example.com/test-ext/v2',
+ },
)
diff --git a/tests/client/transports/test_rest_client.py b/tests/client/transports/test_rest_client.py
index 04bd1036..49d20d9d 100644
--- a/tests/client/transports/test_rest_client.py
+++ b/tests/client/transports/test_rest_client.py
@@ -9,7 +9,13 @@
from a2a.client import create_text_message_object
from a2a.client.transports.rest import RestTransport
from a2a.extensions.common import HTTP_EXTENSION_HEADER
-from a2a.types import AgentCard, MessageSendParams, Role
+from a2a.types import (
+ AgentCapabilities,
+ AgentCard,
+ AgentSkill,
+ MessageSendParams,
+ Role,
+)
@pytest.fixture
@@ -32,6 +38,14 @@ async def async_iterable_from_list(
yield item
+def _assert_extensions_header(mock_kwargs: dict, expected_extensions: set[str]):
+ headers = mock_kwargs.get('headers', {})
+ assert HTTP_EXTENSION_HEADER in headers
+ header_value = headers[HTTP_EXTENSION_HEADER]
+ actual_extensions = {e.strip() for e in header_value.split(',')}
+ assert actual_extensions == expected_extensions
+
+
class TestRestTransportExtensions:
@pytest.mark.asyncio
async def test_send_message_with_default_extensions(
@@ -67,18 +81,13 @@ async def test_send_message_with_default_extensions(
mock_build_request.assert_called_once()
_, kwargs = mock_build_request.call_args
- headers = kwargs.get('headers', {})
- assert HTTP_EXTENSION_HEADER in headers
- header_value = kwargs['headers'][HTTP_EXTENSION_HEADER]
- actual_extensions_list = [e.strip() for e in header_value.split(',')]
- actual_extensions = set(actual_extensions_list)
-
- expected_extensions = {
- 'https://example.com/test-ext/v1',
- 'https://example.com/test-ext/v2',
- }
- assert len(actual_extensions_list) == 2
- assert actual_extensions == expected_extensions
+ _assert_extensions_header(
+ kwargs,
+ {
+ 'https://example.com/test-ext/v1',
+ 'https://example.com/test-ext/v2',
+ },
+ )
@pytest.mark.asyncio
@patch('a2a.client.transports.rest.aconnect_sse')
@@ -114,8 +123,102 @@ async def test_send_message_streaming_with_new_extensions(
mock_aconnect_sse.assert_called_once()
_, kwargs = mock_aconnect_sse.call_args
- headers = kwargs.get('headers', {})
- assert HTTP_EXTENSION_HEADER in headers
- assert (
- headers[HTTP_EXTENSION_HEADER] == 'https://example.com/test-ext/v2'
+ _assert_extensions_header(
+ kwargs,
+ {
+ 'https://example.com/test-ext/v2',
+ },
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_card_no_card_provided_with_extensions(
+ self, mock_httpx_client: AsyncMock
+ ):
+ """Test get_card with extensions set in Client when no card is initially provided.
+ Tests that the extensions are added to the HTTP GET request."""
+ extensions = [
+ 'https://example.com/test-ext/v1',
+ 'https://example.com/test-ext/v2',
+ ]
+ client = RestTransport(
+ httpx_client=mock_httpx_client,
+ url='http://agent.example.com/api',
+ extensions=extensions,
+ )
+
+ mock_response = AsyncMock(spec=httpx.Response)
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ 'name': 'Test Agent',
+ 'description': 'Test Agent Description',
+ 'url': 'http://agent.example.com/api',
+ 'version': '1.0.0',
+ 'default_input_modes': ['text'],
+ 'default_output_modes': ['text'],
+ 'capabilities': AgentCapabilities().model_dump(),
+ 'skills': [],
+ }
+ mock_httpx_client.get.return_value = mock_response
+
+ await client.get_card()
+
+ mock_httpx_client.get.assert_called_once()
+ _, mock_kwargs = mock_httpx_client.get.call_args
+
+ _assert_extensions_header(
+ mock_kwargs,
+ {
+ 'https://example.com/test-ext/v1',
+ 'https://example.com/test-ext/v2',
+ },
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_card_with_extended_card_support_with_extensions(
+ self, mock_httpx_client: AsyncMock
+ ):
+ """Test get_card with extensions passed to get_card call when extended card support is enabled.
+ Tests that the extensions are added to the GET request."""
+ extensions = [
+ 'https://example.com/test-ext/v1',
+ 'https://example.com/test-ext/v2',
+ ]
+ agent_card = AgentCard(
+ name='Test Agent',
+ description='Test Agent Description',
+ url='http://agent.example.com/api',
+ version='1.0.0',
+ default_input_modes=['text'],
+ default_output_modes=['text'],
+ capabilities=AgentCapabilities(),
+ skills=[],
+ supports_authenticated_extended_card=True,
+ )
+ client = RestTransport(
+ httpx_client=mock_httpx_client,
+ agent_card=agent_card,
+ )
+
+ mock_response = AsyncMock(spec=httpx.Response)
+ mock_response.status_code = 200
+ mock_response.json.return_value = agent_card.model_dump(mode='json')
+ mock_httpx_client.send.return_value = mock_response
+
+ with patch.object(
+ client, '_send_get_request', new_callable=AsyncMock
+ ) as mock_send_get_request:
+ mock_send_get_request.return_value = agent_card.model_dump(
+ mode='json'
+ )
+ await client.get_card(extensions=extensions)
+
+ mock_send_get_request.assert_called_once()
+ _, _, mock_kwargs = mock_send_get_request.call_args[0]
+
+ _assert_extensions_header(
+ mock_kwargs,
+ {
+ 'https://example.com/test-ext/v1',
+ 'https://example.com/test-ext/v2',
+ },
)
From 3bfbea9ec8d7982fa73eb12d8352a581307355dc Mon Sep 17 00:00:00 2001
From: "Agent2Agent (A2A) Bot"
Date: Tue, 25 Nov 2025 07:47:20 -0600
Subject: [PATCH 08/29] chore(main): release 0.3.19 (#568)
:robot: I have created a release *beep* *boop*
---
##
[0.3.19](https://github.com/a2aproject/a2a-python/compare/v0.3.18...v0.3.19)
(2025-11-25)
### Bug Fixes
* **jsonrpc, rest:** `extensions` support in `get_card` methods in
`json-rpc` and `rest` transports
([#564](https://github.com/a2aproject/a2a-python/issues/564))
([847f18e](https://github.com/a2aproject/a2a-python/commit/847f18eff59985f447c39a8e5efde87818b68d15))
---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ee60df7..966d9e5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [0.3.19](https://github.com/a2aproject/a2a-python/compare/v0.3.18...v0.3.19) (2025-11-25)
+
+
+### Bug Fixes
+
+* **jsonrpc, rest:** `extensions` support in `get_card` methods in `json-rpc` and `rest` transports ([#564](https://github.com/a2aproject/a2a-python/issues/564)) ([847f18e](https://github.com/a2aproject/a2a-python/commit/847f18eff59985f447c39a8e5efde87818b68d15))
+
## [0.3.18](https://github.com/a2aproject/a2a-python/compare/v0.3.17...v0.3.18) (2025-11-24)
From 7ea7475091df2ee40d3035ef1bc34ee2f86524ee Mon Sep 17 00:00:00 2001
From: Lukasz Kawka
Date: Wed, 3 Dec 2025 15:52:27 +0100
Subject: [PATCH 09/29] fix: Improve streaming errors handling (#576)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Description
Refine error management for the streaming operation. Previously, errors
were converted into stream parts, resulting in the loss of status info.
The updated logic now first verifies if the request was successful; if
it failed, a client error is returned, preserving the relevant status
information.
- [x] Follow the [`CONTRIBUTING`
Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
- [x] Make your Pull Request title in the
specification.
- Important Prefixes for
[release-please](https://github.com/googleapis/release-please):
- `fix:` which represents bug fixes, and correlates to a
[SemVer](https://semver.org/) patch.
- `feat:` represents a new feature, and correlates to a SemVer minor.
- `feat!:`, or `fix!:`, `refactor!:`, etc., which represent a breaking
change (indicated by the `!`) and will result in a SemVer major.
- [x] Ensure the tests and linter pass (Run `bash scripts/format.sh`
from the repository root to format)
- [x] Appropriate docs were updated (if necessary)
Fixes #502 š¦
---
src/a2a/client/transports/jsonrpc.py | 3 ++
src/a2a/client/transports/rest.py | 3 ++
.../client/transports/test_jsonrpc_client.py | 38 +++++++++++++++++
tests/client/transports/test_rest_client.py | 42 ++++++++++++++++++-
4 files changed, 84 insertions(+), 2 deletions(-)
diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py
index 6cce1eff..32cf74f2 100644
--- a/src/a2a/client/transports/jsonrpc.py
+++ b/src/a2a/client/transports/jsonrpc.py
@@ -174,6 +174,7 @@ async def send_message_streaming(
**modified_kwargs,
) as event_source:
try:
+ event_source.response.raise_for_status()
async for sse in event_source.aiter_sse():
response = SendStreamingMessageResponse.model_validate(
json.loads(sse.data)
@@ -181,6 +182,8 @@ async def send_message_streaming(
if isinstance(response.root, JSONRPCErrorResponse):
raise A2AClientJSONRPCError(response.root)
yield response.root.result
+ except httpx.HTTPStatusError as e:
+ raise A2AClientHTTPError(e.response.status_code, str(e)) from e
except SSEError as e:
raise A2AClientHTTPError(
400, f'Invalid SSE response or protocol error: {e}'
diff --git a/src/a2a/client/transports/rest.py b/src/a2a/client/transports/rest.py
index 948f3f35..bdfcc8ba 100644
--- a/src/a2a/client/transports/rest.py
+++ b/src/a2a/client/transports/rest.py
@@ -152,10 +152,13 @@ async def send_message_streaming(
**modified_kwargs,
) as event_source:
try:
+ event_source.response.raise_for_status()
async for sse in event_source.aiter_sse():
event = a2a_pb2.StreamResponse()
Parse(sse.data, event)
yield proto_utils.FromProto.stream_response(event)
+ except httpx.HTTPStatusError as e:
+ raise A2AClientHTTPError(e.response.status_code, str(e)) from e
except SSEError as e:
raise A2AClientHTTPError(
400, f'Invalid SSE response or protocol error: {e}'
diff --git a/tests/client/transports/test_jsonrpc_client.py b/tests/client/transports/test_jsonrpc_client.py
index d9dbafc8..edbcd6c7 100644
--- a/tests/client/transports/test_jsonrpc_client.py
+++ b/tests/client/transports/test_jsonrpc_client.py
@@ -880,6 +880,44 @@ async def test_send_message_streaming_with_new_extensions(
},
)
+ @pytest.mark.asyncio
+ @patch('a2a.client.transports.jsonrpc.aconnect_sse')
+ async def test_send_message_streaming_server_error_propagates(
+ self,
+ mock_aconnect_sse: AsyncMock,
+ mock_httpx_client: AsyncMock,
+ mock_agent_card: MagicMock,
+ ):
+ """Test that send_message_streaming propagates server errors (e.g., 403, 500) directly."""
+ client = JsonRpcTransport(
+ httpx_client=mock_httpx_client,
+ agent_card=mock_agent_card,
+ )
+ params = MessageSendParams(
+ message=create_text_message_object(content='Error stream')
+ )
+
+ mock_event_source = AsyncMock(spec=EventSource)
+ mock_response = MagicMock(spec=httpx.Response)
+ mock_response.status_code = 403
+ mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
+ 'Forbidden',
+ request=httpx.Request('POST', 'http://test.url'),
+ response=mock_response,
+ )
+ mock_event_source.response = mock_response
+ mock_event_source.aiter_sse.return_value = async_iterable_from_list([])
+ mock_aconnect_sse.return_value.__aenter__.return_value = (
+ mock_event_source
+ )
+
+ with pytest.raises(A2AClientHTTPError) as exc_info:
+ async for _ in client.send_message_streaming(request=params):
+ pass
+
+ assert exc_info.value.status_code == 403
+ mock_aconnect_sse.assert_called_once()
+
@pytest.mark.asyncio
async def test_get_card_no_card_provided_with_extensions(
self, mock_httpx_client: AsyncMock
diff --git a/tests/client/transports/test_rest_client.py b/tests/client/transports/test_rest_client.py
index 49d20d9d..cd68b443 100644
--- a/tests/client/transports/test_rest_client.py
+++ b/tests/client/transports/test_rest_client.py
@@ -7,14 +7,13 @@
from httpx_sse import EventSource, ServerSentEvent
from a2a.client import create_text_message_object
+from a2a.client.errors import A2AClientHTTPError
from a2a.client.transports.rest import RestTransport
from a2a.extensions.common import HTTP_EXTENSION_HEADER
from a2a.types import (
AgentCapabilities,
AgentCard,
- AgentSkill,
MessageSendParams,
- Role,
)
@@ -130,6 +129,45 @@ async def test_send_message_streaming_with_new_extensions(
},
)
+ @pytest.mark.asyncio
+ @patch('a2a.client.transports.rest.aconnect_sse')
+ async def test_send_message_streaming_server_error_propagates(
+ self,
+ mock_aconnect_sse: AsyncMock,
+ mock_httpx_client: AsyncMock,
+ mock_agent_card: MagicMock,
+ ):
+ """Test that send_message_streaming propagates server errors (e.g., 403, 500) directly."""
+ client = RestTransport(
+ httpx_client=mock_httpx_client,
+ agent_card=mock_agent_card,
+ )
+ params = MessageSendParams(
+ message=create_text_message_object(content='Error stream')
+ )
+
+ mock_event_source = AsyncMock(spec=EventSource)
+ mock_response = MagicMock(spec=httpx.Response)
+ mock_response.status_code = 403
+ mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
+ 'Forbidden',
+ request=httpx.Request('POST', 'http://test.url'),
+ response=mock_response,
+ )
+ mock_event_source.response = mock_response
+ mock_event_source.aiter_sse.return_value = async_iterable_from_list([])
+ mock_aconnect_sse.return_value.__aenter__.return_value = (
+ mock_event_source
+ )
+
+ with pytest.raises(A2AClientHTTPError) as exc_info:
+ async for _ in client.send_message_streaming(request=params):
+ pass
+
+ assert exc_info.value.status_code == 403
+
+ mock_aconnect_sse.assert_called_once()
+
@pytest.mark.asyncio
async def test_get_card_no_card_provided_with_extensions(
self, mock_httpx_client: AsyncMock
From 174d58ddb1be83d75d7f4dc2273dc80c4616ee24 Mon Sep 17 00:00:00 2001
From: "Agent2Agent (A2A) Bot"
Date: Wed, 3 Dec 2025 09:47:29 -0600
Subject: [PATCH 10/29] chore(main): release 0.3.20 (#577)
:robot: I have created a release *beep* *boop*
---
##
[0.3.20](https://github.com/a2aproject/a2a-python/compare/v0.3.19...v0.3.20)
(2025-12-03)
### Bug Fixes
* Improve streaming errors handling
([#576](https://github.com/a2aproject/a2a-python/issues/576))
([7ea7475](https://github.com/a2aproject/a2a-python/commit/7ea7475091df2ee40d3035ef1bc34ee2f86524ee))
---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 966d9e5a..07631ea6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [0.3.20](https://github.com/a2aproject/a2a-python/compare/v0.3.19...v0.3.20) (2025-12-03)
+
+
+### Bug Fixes
+
+* Improve streaming errors handling ([#576](https://github.com/a2aproject/a2a-python/issues/576)) ([7ea7475](https://github.com/a2aproject/a2a-python/commit/7ea7475091df2ee40d3035ef1bc34ee2f86524ee))
+
## [0.3.19](https://github.com/a2aproject/a2a-python/compare/v0.3.18...v0.3.19) (2025-11-25)
From 5fea21fb34ecea55e588eb10139b5d47020a76cb Mon Sep 17 00:00:00 2001
From: Didier Durand <2927957+didier-durand@users.noreply.github.com>
Date: Fri, 12 Dec 2025 14:12:01 +0100
Subject: [PATCH 11/29] docs: Fixing typos (#586)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Description
Just fixing various typos discovered while reading code of the repo: see
commit diffs for details
Cheers
Didier
- [X] Follow the [`CONTRIBUTING`
Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
- [X] Make your Pull Request title in the
specification.
- [X] Ensure the tests and linter pass (Run `bash scripts/format.sh`
from the repository root to format)
- [X] Appropriate docs were updated (if necessary)
Fixes # š¦
N/A
---------
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---
CHANGELOG.md | 6 +++---
Gemini.md | 2 +-
src/a2a/utils/error_handlers.py | 4 ++--
tests/README.md | 2 +-
.../test_default_push_notification_support.py | 4 ++--
5 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07631ea6..590bd78e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -101,7 +101,7 @@
### Bug Fixes
* apply `history_length` for `message/send` requests ([#498](https://github.com/a2aproject/a2a-python/issues/498)) ([a49f94e](https://github.com/a2aproject/a2a-python/commit/a49f94ef23d81b8375e409b1c1e51afaf1da1956))
-* **client:** `A2ACardResolver.get_agent_card` will auto-populate with `agent_card_path` when `relative_card_path` is empty ([#508](https://github.com/a2aproject/a2a-python/issues/508)) ([ba24ead](https://github.com/a2aproject/a2a-python/commit/ba24eadb5b6fcd056a008e4cbcef03b3f72a37c3))
+* **client:** `A2ACardResolver.get_agent_card` will autopopulate with `agent_card_path` when `relative_card_path` is empty ([#508](https://github.com/a2aproject/a2a-python/issues/508)) ([ba24ead](https://github.com/a2aproject/a2a-python/commit/ba24eadb5b6fcd056a008e4cbcef03b3f72a37c3))
### Documentation
@@ -438,8 +438,8 @@
* Event consumer should stop on input_required ([#167](https://github.com/a2aproject/a2a-python/issues/167)) ([51c2d8a](https://github.com/a2aproject/a2a-python/commit/51c2d8addf9e89a86a6834e16deb9f4ac0e05cc3))
* Fix Release Version ([#161](https://github.com/a2aproject/a2a-python/issues/161)) ([011d632](https://github.com/a2aproject/a2a-python/commit/011d632b27b201193813ce24cf25e28d1335d18e))
* generate StrEnum types for enums ([#134](https://github.com/a2aproject/a2a-python/issues/134)) ([0c49dab](https://github.com/a2aproject/a2a-python/commit/0c49dabcdb9d62de49fda53d7ce5c691b8c1591c))
-* library should released as 0.2.6 ([d8187e8](https://github.com/a2aproject/a2a-python/commit/d8187e812d6ac01caedf61d4edaca522e583d7da))
-* remove error types from enqueable events ([#138](https://github.com/a2aproject/a2a-python/issues/138)) ([511992f](https://github.com/a2aproject/a2a-python/commit/511992fe585bd15e956921daeab4046dc4a50a0a))
+* library should be released as 0.2.6 ([d8187e8](https://github.com/a2aproject/a2a-python/commit/d8187e812d6ac01caedf61d4edaca522e583d7da))
+* remove error types from enqueueable events ([#138](https://github.com/a2aproject/a2a-python/issues/138)) ([511992f](https://github.com/a2aproject/a2a-python/commit/511992fe585bd15e956921daeab4046dc4a50a0a))
* **stream:** don't block event loop in EventQueue ([#151](https://github.com/a2aproject/a2a-python/issues/151)) ([efd9080](https://github.com/a2aproject/a2a-python/commit/efd9080b917c51d6e945572fd123b07f20974a64))
* **task_updater:** fix potential duplicate artifact_id from default v⦠([#156](https://github.com/a2aproject/a2a-python/issues/156)) ([1f0a769](https://github.com/a2aproject/a2a-python/commit/1f0a769c1027797b2f252e4c894352f9f78257ca))
diff --git a/Gemini.md b/Gemini.md
index d4367c37..7f52d33f 100644
--- a/Gemini.md
+++ b/Gemini.md
@@ -4,7 +4,7 @@
- uv as package manager
## How to run all tests
-1. If dependencies are not installed install them using following command
+1. If dependencies are not installed, install them using the following command
```
uv sync --all-extras
```
diff --git a/src/a2a/utils/error_handlers.py b/src/a2a/utils/error_handlers.py
index d13c5e50..53cdb9f5 100644
--- a/src/a2a/utils/error_handlers.py
+++ b/src/a2a/utils/error_handlers.py
@@ -117,12 +117,12 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any:
', Data=' + str(error.data) if error.data else '',
)
# Since the stream has started, we can't return a JSONResponse.
- # Instead, we runt the error handling logic (provides logging)
+ # Instead, we run the error handling logic (provides logging)
# and reraise the error and let server framework manage
raise e
except Exception as e:
# Since the stream has started, we can't return a JSONResponse.
- # Instead, we runt the error handling logic (provides logging)
+ # Instead, we run the error handling logic (provides logging)
# and reraise the error and let server framework manage
raise e
diff --git a/tests/README.md b/tests/README.md
index d89f3bec..872ac723 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -5,7 +5,7 @@
uv run pytest -v -s client/test_client_factory.py
```
-In case of failures, you can cleanup the cache:
+In case of failures, you can clean up the cache:
1. `uv clean`
2. `rm -fR .pytest_cache .venv __pycache__`
diff --git a/tests/e2e/push_notifications/test_default_push_notification_support.py b/tests/e2e/push_notifications/test_default_push_notification_support.py
index 775bd7fb..d7364b84 100644
--- a/tests/e2e/push_notifications/test_default_push_notification_support.py
+++ b/tests/e2e/push_notifications/test_default_push_notification_support.py
@@ -35,7 +35,7 @@
@pytest.fixture(scope='module')
def notifications_server():
"""
- Starts a simple push notifications injesting server and yields its URL.
+ Starts a simple push notifications ingesting server and yields its URL.
"""
host = '127.0.0.1'
port = find_free_port()
@@ -148,7 +148,7 @@ async def test_notification_triggering_after_config_change_e2e(
notifications_server: str, agent_server: str, http_client: httpx.AsyncClient
):
"""
- Tests notification triggering after setting the push notificaiton config in a seperate call.
+ Tests notification triggering after setting the push notification config in a separate call.
"""
# Configure an A2A client without a push notification config.
a2a_client = ClientFactory(
From 8a767305d0a6ecd8bbca4ede643e64ecba01edee Mon Sep 17 00:00:00 2001
From: Iva Sokolaj <102302011+sokoliva@users.noreply.github.com>
Date: Fri, 12 Dec 2025 14:16:56 +0100
Subject: [PATCH 12/29] feat: Implement Agent Card Signing and Verification per
Spec (#581)
This PR introduces digital signatures for Agent Cards to ensure
authenticity and integrity, adhering to the A2A specification for [Agent
Card Signing (Section
8.4).](https://a2a-protocol.org/latest/specification/#84-agent-card-signing)
## Changes:
- Implement `Canonicalization` Logic (`src/a2a/utils/signing.py`)
- Add `Signing` and `Verification` Utilities
(`src/a2a/utils/signing.py`):
- `create_agent_card_signer` which generates an `agent_card_signer` for
signing `AgentCards`
- `create_signature_verifier` which generates a `signature_verifier` for
verification of `AgentCard` signatures
- Enable signature verification support for `json-rpc`, `rest` and
`gRPC` transports
- Add Protobuf Conversion for Signatures
(`src/a2a/utils/proto_utils.py`) ensuring `AgentCardSignature` can be
serialized and deserialized for gRPC transport
- Add related tests:
- integration tests for fetching signed cards from the Server
- unit tests for signing util
- unit tests for protobuf conversions
- [x] Follow the [`CONTRIBUTING`
Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
- [x] Make your Pull Request title in the
specification.
- Important Prefixes for
[release-please](https://github.com/googleapis/release-please):
- `fix:` which represents bug fixes, and correlates to a
[SemVer](https://semver.org/) patch.
- `feat:` represents a new feature, and correlates to a SemVer minor.
- `feat!:`, or `fix!:`, `refactor!:`, etc., which represent a breaking
change (indicated by the `!`) and will result in a SemVer major.
- [x] Ensure the tests and linter pass (Run `bash scripts/format.sh`
from the repository root to format)
- [x] Appropriate docs were updated (if necessary)
Release-As: 0.3.21
---
.github/actions/spelling/allow.txt | 5 +
pyproject.toml | 3 +
src/a2a/client/base_client.py | 8 +-
src/a2a/client/client.py | 1 +
src/a2a/client/transports/base.py | 3 +-
src/a2a/client/transports/grpc.py | 6 +-
src/a2a/client/transports/jsonrpc.py | 14 +-
src/a2a/client/transports/rest.py | 9 +-
src/a2a/utils/helpers.py | 28 ++
src/a2a/utils/proto_utils.py | 28 ++
src/a2a/utils/signing.py | 152 +++++++++
.../test_client_server_integration.py | 318 +++++++++++++++++-
tests/utils/test_helpers.py | 52 +++
tests/utils/test_proto_utils.py | 153 ++++++++-
tests/utils/test_signing.py | 185 ++++++++++
15 files changed, 954 insertions(+), 11 deletions(-)
create mode 100644 src/a2a/utils/signing.py
create mode 100644 tests/utils/test_signing.py
diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt
index a016962c..27b5cb4c 100644
--- a/.github/actions/spelling/allow.txt
+++ b/.github/actions/spelling/allow.txt
@@ -47,9 +47,14 @@ initdb
inmemory
INR
isready
+jku
JPY
JSONRPCt
+jwk
+jwks
JWS
+jws
+kid
kwarg
langgraph
lifecycles
diff --git a/pyproject.toml b/pyproject.toml
index 46f7400a..561a5a45 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,7 @@ grpc = ["grpcio>=1.60", "grpcio-tools>=1.60", "grpcio_reflection>=1.7.0"]
telemetry = ["opentelemetry-api>=1.33.0", "opentelemetry-sdk>=1.33.0"]
postgresql = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.0"]
mysql = ["sqlalchemy[asyncio,aiomysql]>=2.0.0"]
+signing = ["PyJWT>=2.0.0"]
sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"]
sql = ["a2a-sdk[postgresql,mysql,sqlite]"]
@@ -45,6 +46,7 @@ all = [
"a2a-sdk[encryption]",
"a2a-sdk[grpc]",
"a2a-sdk[telemetry]",
+ "a2a-sdk[signing]",
]
[project.urls]
@@ -86,6 +88,7 @@ style = "pep440"
dev = [
"datamodel-code-generator>=0.30.0",
"mypy>=1.15.0",
+ "PyJWT>=2.0.0",
"pytest>=8.3.5",
"pytest-asyncio>=0.26.0",
"pytest-cov>=6.1.1",
diff --git a/src/a2a/client/base_client.py b/src/a2a/client/base_client.py
index fac7ecad..c870f329 100644
--- a/src/a2a/client/base_client.py
+++ b/src/a2a/client/base_client.py
@@ -1,4 +1,4 @@
-from collections.abc import AsyncIterator
+from collections.abc import AsyncIterator, Callable
from typing import Any
from a2a.client.client import (
@@ -261,6 +261,7 @@ async def get_card(
*,
context: ClientCallContext | None = None,
extensions: list[str] | None = None,
+ signature_verifier: Callable[[AgentCard], None] | None = None,
) -> AgentCard:
"""Retrieves the agent's card.
@@ -270,12 +271,15 @@ async def get_card(
Args:
context: The client call context.
extensions: List of extensions to be activated.
+ signature_verifier: A callable used to verify the agent card's signatures.
Returns:
The `AgentCard` for the agent.
"""
card = await self._transport.get_card(
- context=context, extensions=extensions
+ context=context,
+ extensions=extensions,
+ signature_verifier=signature_verifier,
)
self._card = card
return card
diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py
index fd97b4d1..286641a7 100644
--- a/src/a2a/client/client.py
+++ b/src/a2a/client/client.py
@@ -185,6 +185,7 @@ async def get_card(
*,
context: ClientCallContext | None = None,
extensions: list[str] | None = None,
+ signature_verifier: Callable[[AgentCard], None] | None = None,
) -> AgentCard:
"""Retrieves the agent's card."""
diff --git a/src/a2a/client/transports/base.py b/src/a2a/client/transports/base.py
index 8f114d95..0c54a28d 100644
--- a/src/a2a/client/transports/base.py
+++ b/src/a2a/client/transports/base.py
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Callable
from a2a.client.middleware import ClientCallContext
from a2a.types import (
@@ -103,6 +103,7 @@ async def get_card(
*,
context: ClientCallContext | None = None,
extensions: list[str] | None = None,
+ signature_verifier: Callable[[AgentCard], None] | None = None,
) -> AgentCard:
"""Retrieves the AgentCard."""
diff --git a/src/a2a/client/transports/grpc.py b/src/a2a/client/transports/grpc.py
index 4e27953a..c5edf7a1 100644
--- a/src/a2a/client/transports/grpc.py
+++ b/src/a2a/client/transports/grpc.py
@@ -1,6 +1,6 @@
import logging
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Callable
try:
@@ -223,6 +223,7 @@ async def get_card(
*,
context: ClientCallContext | None = None,
extensions: list[str] | None = None,
+ signature_verifier: Callable[[AgentCard], None] | None = None,
) -> AgentCard:
"""Retrieves the agent's card."""
card = self.agent_card
@@ -236,6 +237,9 @@ async def get_card(
metadata=self._get_grpc_metadata(extensions),
)
card = proto_utils.FromProto.agent_card(card_pb)
+ if signature_verifier is not None:
+ signature_verifier(card)
+
self.agent_card = card
self._needs_extended_card = False
return card
diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py
index 32cf74f2..54c758ff 100644
--- a/src/a2a/client/transports/jsonrpc.py
+++ b/src/a2a/client/transports/jsonrpc.py
@@ -1,7 +1,7 @@
import json
import logging
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Callable
from typing import Any
from uuid import uuid4
@@ -379,6 +379,7 @@ async def get_card(
*,
context: ClientCallContext | None = None,
extensions: list[str] | None = None,
+ signature_verifier: Callable[[AgentCard], None] | None = None,
) -> AgentCard:
"""Retrieves the agent's card."""
modified_kwargs = update_extension_header(
@@ -386,9 +387,12 @@ async def get_card(
extensions if extensions is not None else self.extensions,
)
card = self.agent_card
+
if not card:
resolver = A2ACardResolver(self.httpx_client, self.url)
card = await resolver.get_agent_card(http_kwargs=modified_kwargs)
+ if signature_verifier is not None:
+ signature_verifier(card)
self._needs_extended_card = (
card.supports_authenticated_extended_card
)
@@ -413,9 +417,13 @@ async def get_card(
)
if isinstance(response.root, JSONRPCErrorResponse):
raise A2AClientJSONRPCError(response.root)
- self.agent_card = response.root.result
+ card = response.root.result
+ if signature_verifier is not None:
+ signature_verifier(card)
+
+ self.agent_card = card
self._needs_extended_card = False
- return self.agent_card
+ return card
async def close(self) -> None:
"""Closes the httpx client."""
diff --git a/src/a2a/client/transports/rest.py b/src/a2a/client/transports/rest.py
index bdfcc8ba..1649be1c 100644
--- a/src/a2a/client/transports/rest.py
+++ b/src/a2a/client/transports/rest.py
@@ -1,7 +1,7 @@
import json
import logging
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Callable
from typing import Any
import httpx
@@ -371,6 +371,7 @@ async def get_card(
*,
context: ClientCallContext | None = None,
extensions: list[str] | None = None,
+ signature_verifier: Callable[[AgentCard], None] | None = None,
) -> AgentCard:
"""Retrieves the agent's card."""
modified_kwargs = update_extension_header(
@@ -378,9 +379,12 @@ async def get_card(
extensions if extensions is not None else self.extensions,
)
card = self.agent_card
+
if not card:
resolver = A2ACardResolver(self.httpx_client, self.url)
card = await resolver.get_agent_card(http_kwargs=modified_kwargs)
+ if signature_verifier is not None:
+ signature_verifier(card)
self._needs_extended_card = (
card.supports_authenticated_extended_card
)
@@ -398,6 +402,9 @@ async def get_card(
'/v1/card', {}, modified_kwargs
)
card = AgentCard.model_validate(response_data)
+ if signature_verifier is not None:
+ signature_verifier(card)
+
self.agent_card = card
self._needs_extended_card = False
return card
diff --git a/src/a2a/utils/helpers.py b/src/a2a/utils/helpers.py
index 96c1646a..96acdc1e 100644
--- a/src/a2a/utils/helpers.py
+++ b/src/a2a/utils/helpers.py
@@ -2,6 +2,7 @@
import functools
import inspect
+import json
import logging
from collections.abc import Callable
@@ -9,6 +10,7 @@
from uuid import uuid4
from a2a.types import (
+ AgentCard,
Artifact,
MessageSendParams,
Part,
@@ -340,3 +342,29 @@ def are_modalities_compatible(
return True
return any(x in server_output_modes for x in client_output_modes)
+
+
+def _clean_empty(d: Any) -> Any:
+ """Recursively remove empty strings, lists and dicts from a dictionary."""
+ if isinstance(d, dict):
+ cleaned_dict: dict[Any, Any] = {
+ k: _clean_empty(v) for k, v in d.items()
+ }
+ return {k: v for k, v in cleaned_dict.items() if v}
+ if isinstance(d, list):
+ cleaned_list: list[Any] = [_clean_empty(v) for v in d]
+ return [v for v in cleaned_list if v]
+ return d if d not in ['', [], {}] else None
+
+
+def canonicalize_agent_card(agent_card: AgentCard) -> str:
+ """Canonicalizes the Agent Card JSON according to RFC 8785 (JCS)."""
+ card_dict = agent_card.model_dump(
+ exclude={'signatures'},
+ exclude_defaults=True,
+ exclude_none=True,
+ by_alias=True,
+ )
+ # Recursively remove empty values
+ cleaned_dict = _clean_empty(card_dict)
+ return json.dumps(cleaned_dict, separators=(',', ':'), sort_keys=True)
diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py
index 8bf01eea..14ac098d 100644
--- a/src/a2a/utils/proto_utils.py
+++ b/src/a2a/utils/proto_utils.py
@@ -397,6 +397,21 @@ def agent_card(
]
if card.additional_interfaces
else None,
+ signatures=[cls.agent_card_signature(x) for x in card.signatures]
+ if card.signatures
+ else None,
+ )
+
+ @classmethod
+ def agent_card_signature(
+ cls, signature: types.AgentCardSignature
+ ) -> a2a_pb2.AgentCardSignature:
+ return a2a_pb2.AgentCardSignature(
+ protected=signature.protected,
+ signature=signature.signature,
+ header=dict_to_struct(signature.header)
+ if signature.header is not None
+ else None,
)
@classmethod
@@ -865,6 +880,19 @@ def agent_card(
]
if card.additional_interfaces
else None,
+ signatures=[cls.agent_card_signature(x) for x in card.signatures]
+ if card.signatures
+ else None,
+ )
+
+ @classmethod
+ def agent_card_signature(
+ cls, signature: a2a_pb2.AgentCardSignature
+ ) -> types.AgentCardSignature:
+ return types.AgentCardSignature(
+ protected=signature.protected,
+ signature=signature.signature,
+ header=json_format.MessageToDict(signature.header),
)
@classmethod
diff --git a/src/a2a/utils/signing.py b/src/a2a/utils/signing.py
new file mode 100644
index 00000000..6ea8c21b
--- /dev/null
+++ b/src/a2a/utils/signing.py
@@ -0,0 +1,152 @@
+import json
+
+from collections.abc import Callable
+from typing import Any, TypedDict
+
+from a2a.utils.helpers import canonicalize_agent_card
+
+
+try:
+ import jwt
+
+ from jwt.api_jwk import PyJWK
+ from jwt.exceptions import PyJWTError
+ from jwt.utils import base64url_decode, base64url_encode
+except ImportError as e:
+ raise ImportError(
+ 'A2A Signing requires PyJWT to be installed. '
+ 'Install with: '
+ "'pip install a2a-sdk[signing]'"
+ ) from e
+
+from a2a.types import AgentCard, AgentCardSignature
+
+
+class SignatureVerificationError(Exception):
+ """Base exception for signature verification errors."""
+
+
+class NoSignatureError(SignatureVerificationError):
+ """Exception raised when no signature is found on an AgentCard."""
+
+
+class InvalidSignaturesError(SignatureVerificationError):
+ """Exception raised when all signatures are invalid."""
+
+
+class ProtectedHeader(TypedDict):
+ """Protected header parameters for JWS (JSON Web Signature)."""
+
+ kid: str
+ """ Key identifier. """
+ alg: str | None
+ """ Algorithm used for signing. """
+ jku: str | None
+ """ JSON Web Key Set URL. """
+ typ: str | None
+ """ Token type.
+
+ Best practice: SHOULD be "JOSE" for JWS tokens.
+ """
+
+
+def create_agent_card_signer(
+ signing_key: PyJWK | str | bytes,
+ protected_header: ProtectedHeader,
+ header: dict[str, Any] | None = None,
+) -> Callable[[AgentCard], AgentCard]:
+ """Creates a function that signs an AgentCard and adds the signature.
+
+ Args:
+ signing_key: The private key for signing.
+ protected_header: The protected header parameters.
+ header: Unprotected header parameters.
+
+ Returns:
+ A callable that takes an AgentCard and returns the modified AgentCard with a signature.
+ """
+
+ def agent_card_signer(agent_card: AgentCard) -> AgentCard:
+ """Signs agent card."""
+ canonical_payload = canonicalize_agent_card(agent_card)
+ payload_dict = json.loads(canonical_payload)
+
+ jws_string = jwt.encode(
+ payload=payload_dict,
+ key=signing_key,
+ algorithm=protected_header.get('alg', 'HS256'),
+ headers=dict(protected_header),
+ )
+
+ # The result of jwt.encode is a compact serialization: HEADER.PAYLOAD.SIGNATURE
+ protected, _, signature = jws_string.split('.')
+
+ agent_card_signature = AgentCardSignature(
+ header=header,
+ protected=protected,
+ signature=signature,
+ )
+
+ agent_card.signatures = (agent_card.signatures or []) + [
+ agent_card_signature
+ ]
+ return agent_card
+
+ return agent_card_signer
+
+
+def create_signature_verifier(
+ key_provider: Callable[[str | None, str | None], PyJWK | str | bytes],
+ algorithms: list[str],
+) -> Callable[[AgentCard], None]:
+ """Creates a function that verifies the signatures on an AgentCard.
+
+ The verifier succeeds if at least one signature is valid. Otherwise, it raises an error.
+
+ Args:
+ key_provider: A callable that accepts a key ID (kid) and a JWK Set URL (jku) and returns the verification key.
+ This function is responsible for fetching the correct key for a given signature.
+ algorithms: A list of acceptable algorithms (e.g., ['ES256', 'RS256']) for verification used to prevent algorithm confusion attacks.
+
+ Returns:
+ A function that takes an AgentCard as input, and raises an error if none of the signatures are valid.
+ """
+
+ def signature_verifier(
+ agent_card: AgentCard,
+ ) -> None:
+ """Verifies agent card signatures."""
+ if not agent_card.signatures:
+ raise NoSignatureError('AgentCard has no signatures to verify.')
+
+ for agent_card_signature in agent_card.signatures:
+ try:
+ # get verification key
+ protected_header_json = base64url_decode(
+ agent_card_signature.protected.encode('utf-8')
+ ).decode('utf-8')
+ protected_header = json.loads(protected_header_json)
+ kid = protected_header.get('kid')
+ jku = protected_header.get('jku')
+ verification_key = key_provider(kid, jku)
+
+ canonical_payload = canonicalize_agent_card(agent_card)
+ encoded_payload = base64url_encode(
+ canonical_payload.encode('utf-8')
+ ).decode('utf-8')
+
+ token = f'{agent_card_signature.protected}.{encoded_payload}.{agent_card_signature.signature}'
+ jwt.decode(
+ jwt=token,
+ key=verification_key,
+ algorithms=algorithms,
+ )
+ # Found a valid signature, exit the loop and function
+ break
+ except PyJWTError:
+ continue
+ else:
+ # This block runs only if the loop completes without a break
+ raise InvalidSignaturesError('No valid signature found')
+
+ return signature_verifier
diff --git a/tests/integration/test_client_server_integration.py b/tests/integration/test_client_server_integration.py
index e0a564ee..e6552fcb 100644
--- a/tests/integration/test_client_server_integration.py
+++ b/tests/integration/test_client_server_integration.py
@@ -1,6 +1,6 @@
import asyncio
from collections.abc import AsyncGenerator
-from typing import NamedTuple
+from typing import NamedTuple, Any
from unittest.mock import ANY, AsyncMock, patch
import grpc
@@ -9,6 +9,7 @@
import pytest_asyncio
from grpc.aio import Channel
+from jwt.api_jwk import PyJWK
from a2a.client import ClientConfig
from a2a.client.base_client import BaseClient
from a2a.client.transports import JsonRpcTransport, RestTransport
@@ -17,6 +18,10 @@
from a2a.grpc import a2a_pb2_grpc
from a2a.server.apps import A2AFastAPIApplication, A2ARESTFastAPIApplication
from a2a.server.request_handlers import GrpcHandler, RequestHandler
+from a2a.utils.signing import (
+ create_agent_card_signer,
+ create_signature_verifier,
+)
from a2a.types import (
AgentCapabilities,
AgentCard,
@@ -37,6 +42,7 @@
TextPart,
TransportProtocol,
)
+from cryptography.hazmat.primitives import asymmetric
# --- Test Constants ---
@@ -83,6 +89,15 @@
)
+def create_key_provider(verification_key: PyJWK | str | bytes):
+ """Creates a key provider function for testing."""
+
+ def key_provider(kid: str | None, jku: str | None):
+ return verification_key
+
+ return key_provider
+
+
# --- Test Fixtures ---
@@ -739,6 +754,7 @@ async def test_http_transport_get_authenticated_card(
transport = RestTransport(httpx_client=httpx_client, agent_card=agent_card)
result = await transport.get_card()
assert result.name == extended_agent_card.name
+ assert transport.agent_card is not None
assert transport.agent_card.name == extended_agent_card.name
assert transport._needs_extended_card is False
@@ -761,6 +777,7 @@ def channel_factory(address: str) -> Channel:
transport = GrpcTransport(channel=channel, agent_card=agent_card)
# The transport starts with a minimal card, get_card() fetches the full one
+ assert transport.agent_card is not None
transport.agent_card.supports_authenticated_extended_card = True
result = await transport.get_card()
@@ -772,7 +789,7 @@ def channel_factory(address: str) -> Channel:
@pytest.mark.asyncio
-async def test_base_client_sends_message_with_extensions(
+async def test_json_transport_base_client_send_message_with_extensions(
jsonrpc_setup: TransportSetup, agent_card: AgentCard
) -> None:
"""
@@ -827,3 +844,300 @@ async def test_base_client_sends_message_with_extensions(
if hasattr(transport, 'close'):
await transport.close()
+
+
+@pytest.mark.asyncio
+async def test_json_transport_get_signed_base_card(
+ jsonrpc_setup: TransportSetup, agent_card: AgentCard
+) -> None:
+ """Tests fetching and verifying a symmetrically signed AgentCard via JSON-RPC.
+
+ The client transport is initialized without a card, forcing it to fetch
+ the base card from the server. The server signs the card using HS384.
+ The client then verifies the signature.
+ """
+ mock_request_handler = jsonrpc_setup.handler
+ agent_card.supports_authenticated_extended_card = False
+
+ # Setup signing on the server side
+ key = 'key12345'
+ signer = create_agent_card_signer(
+ signing_key=key,
+ protected_header={
+ 'alg': 'HS384',
+ 'kid': 'testkey',
+ 'jku': None,
+ 'typ': 'JOSE',
+ },
+ )
+
+ app_builder = A2AFastAPIApplication(
+ agent_card,
+ mock_request_handler,
+ card_modifier=signer, # Sign the base card
+ )
+ app = app_builder.build()
+ httpx_client = httpx.AsyncClient(transport=httpx.ASGITransport(app=app))
+
+ transport = JsonRpcTransport(
+ httpx_client=httpx_client,
+ url=agent_card.url,
+ agent_card=None,
+ )
+
+ # Get the card, this will trigger verification in get_card
+ signature_verifier = create_signature_verifier(
+ create_key_provider(key), ['HS384']
+ )
+ result = await transport.get_card(signature_verifier=signature_verifier)
+ assert result.name == agent_card.name
+ assert result.signatures is not None
+ assert len(result.signatures) == 1
+ assert transport.agent_card is not None
+ assert transport.agent_card.name == agent_card.name
+ assert transport._needs_extended_card is False
+
+ if hasattr(transport, 'close'):
+ await transport.close()
+
+
+@pytest.mark.asyncio
+async def test_json_transport_get_signed_extended_card(
+ jsonrpc_setup: TransportSetup, agent_card: AgentCard
+) -> None:
+ """Tests fetching and verifying an asymmetrically signed extended AgentCard via JSON-RPC.
+
+ The client has a base card and fetches the extended card, which is signed
+ by the server using ES256. The client verifies the signature on the
+ received extended card.
+ """
+ mock_request_handler = jsonrpc_setup.handler
+ agent_card.supports_authenticated_extended_card = True
+ extended_agent_card = agent_card.model_copy(deep=True)
+ extended_agent_card.name = 'Extended Agent Card'
+
+ # Setup signing on the server side
+ private_key = asymmetric.ec.generate_private_key(asymmetric.ec.SECP256R1())
+ public_key = private_key.public_key()
+ signer = create_agent_card_signer(
+ signing_key=private_key,
+ protected_header={
+ 'alg': 'ES256',
+ 'kid': 'testkey',
+ 'jku': None,
+ 'typ': 'JOSE',
+ },
+ )
+
+ app_builder = A2AFastAPIApplication(
+ agent_card,
+ mock_request_handler,
+ extended_agent_card=extended_agent_card,
+ extended_card_modifier=lambda card, ctx: signer(
+ card
+ ), # Sign the extended card
+ )
+ app = app_builder.build()
+ httpx_client = httpx.AsyncClient(transport=httpx.ASGITransport(app=app))
+
+ transport = JsonRpcTransport(
+ httpx_client=httpx_client, agent_card=agent_card
+ )
+
+ # Get the card, this will trigger verification in get_card
+ signature_verifier = create_signature_verifier(
+ create_key_provider(public_key), ['HS384', 'ES256']
+ )
+ result = await transport.get_card(signature_verifier=signature_verifier)
+ assert result.name == extended_agent_card.name
+ assert result.signatures is not None
+ assert len(result.signatures) == 1
+ assert transport.agent_card is not None
+ assert transport.agent_card.name == extended_agent_card.name
+ assert transport._needs_extended_card is False
+
+ if hasattr(transport, 'close'):
+ await transport.close()
+
+
+@pytest.mark.asyncio
+async def test_json_transport_get_signed_base_and_extended_cards(
+ jsonrpc_setup: TransportSetup, agent_card: AgentCard
+) -> None:
+ """Tests fetching and verifying both base and extended cards via JSON-RPC when no card is initially provided.
+
+ The client starts with no card. It first fetches the base card, which is
+ signed. It then fetches the extended card, which is also signed. Both signatures
+ are verified independently upon retrieval.
+ """
+ mock_request_handler = jsonrpc_setup.handler
+ assert agent_card.signatures is None
+ agent_card.supports_authenticated_extended_card = True
+ extended_agent_card = agent_card.model_copy(deep=True)
+ extended_agent_card.name = 'Extended Agent Card'
+
+ # Setup signing on the server side
+ private_key = asymmetric.ec.generate_private_key(asymmetric.ec.SECP256R1())
+ public_key = private_key.public_key()
+ signer = create_agent_card_signer(
+ signing_key=private_key,
+ protected_header={
+ 'alg': 'ES256',
+ 'kid': 'testkey',
+ 'jku': None,
+ 'typ': 'JOSE',
+ },
+ )
+
+ app_builder = A2AFastAPIApplication(
+ agent_card,
+ mock_request_handler,
+ extended_agent_card=extended_agent_card,
+ card_modifier=signer, # Sign the base card
+ extended_card_modifier=lambda card, ctx: signer(
+ card
+ ), # Sign the extended card
+ )
+ app = app_builder.build()
+ httpx_client = httpx.AsyncClient(transport=httpx.ASGITransport(app=app))
+
+ transport = JsonRpcTransport(
+ httpx_client=httpx_client,
+ url=agent_card.url,
+ agent_card=None,
+ )
+
+ # Get the card, this will trigger verification in get_card
+ signature_verifier = create_signature_verifier(
+ create_key_provider(public_key), ['HS384', 'ES256', 'RS256']
+ )
+ result = await transport.get_card(signature_verifier=signature_verifier)
+ assert result.name == extended_agent_card.name
+ assert result.signatures is not None
+ assert len(result.signatures) == 1
+ assert transport.agent_card is not None
+ assert transport.agent_card.name == extended_agent_card.name
+ assert transport._needs_extended_card is False
+
+ if hasattr(transport, 'close'):
+ await transport.close()
+
+
+@pytest.mark.asyncio
+async def test_rest_transport_get_signed_card(
+ rest_setup: TransportSetup, agent_card: AgentCard
+) -> None:
+ """Tests fetching and verifying signed base and extended cards via REST.
+
+ The client starts with no card. It first fetches the base card, which is
+ signed. It then fetches the extended card, which is also signed. Both signatures
+ are verified independently upon retrieval.
+ """
+ mock_request_handler = rest_setup.handler
+ agent_card.supports_authenticated_extended_card = True
+ extended_agent_card = agent_card.model_copy(deep=True)
+ extended_agent_card.name = 'Extended Agent Card'
+
+ # Setup signing on the server side
+ private_key = asymmetric.ec.generate_private_key(asymmetric.ec.SECP256R1())
+ public_key = private_key.public_key()
+ signer = create_agent_card_signer(
+ signing_key=private_key,
+ protected_header={
+ 'alg': 'ES256',
+ 'kid': 'testkey',
+ 'jku': None,
+ 'typ': 'JOSE',
+ },
+ )
+
+ app_builder = A2ARESTFastAPIApplication(
+ agent_card,
+ mock_request_handler,
+ extended_agent_card=extended_agent_card,
+ card_modifier=signer, # Sign the base card
+ extended_card_modifier=lambda card, ctx: signer(
+ card
+ ), # Sign the extended card
+ )
+ app = app_builder.build()
+ httpx_client = httpx.AsyncClient(transport=httpx.ASGITransport(app=app))
+
+ transport = RestTransport(
+ httpx_client=httpx_client,
+ url=agent_card.url,
+ agent_card=None,
+ )
+
+ # Get the card, this will trigger verification in get_card
+ signature_verifier = create_signature_verifier(
+ create_key_provider(public_key), ['HS384', 'ES256', 'RS256']
+ )
+ result = await transport.get_card(signature_verifier=signature_verifier)
+ assert result.name == extended_agent_card.name
+ assert result.signatures is not None
+ assert len(result.signatures) == 1
+ assert transport.agent_card is not None
+ assert transport.agent_card.name == extended_agent_card.name
+ assert transport._needs_extended_card is False
+
+ if hasattr(transport, 'close'):
+ await transport.close()
+
+
+@pytest.mark.asyncio
+async def test_grpc_transport_get_signed_card(
+ mock_request_handler: AsyncMock, agent_card: AgentCard
+) -> None:
+ """Tests fetching and verifying a signed AgentCard via gRPC."""
+ # Setup signing on the server side
+ agent_card.supports_authenticated_extended_card = True
+
+ private_key = asymmetric.ec.generate_private_key(asymmetric.ec.SECP256R1())
+ public_key = private_key.public_key()
+ signer = create_agent_card_signer(
+ signing_key=private_key,
+ protected_header={
+ 'alg': 'ES256',
+ 'kid': 'testkey',
+ 'jku': None,
+ 'typ': 'JOSE',
+ },
+ )
+
+ server = grpc.aio.server()
+ port = server.add_insecure_port('[::]:0')
+ server_address = f'localhost:{port}'
+ agent_card.url = server_address
+
+ servicer = GrpcHandler(
+ agent_card,
+ mock_request_handler,
+ card_modifier=signer,
+ )
+ a2a_pb2_grpc.add_A2AServiceServicer_to_server(servicer, server)
+ await server.start()
+
+ transport = None # Initialize transport
+ try:
+
+ def channel_factory(address: str) -> Channel:
+ return grpc.aio.insecure_channel(address)
+
+ channel = channel_factory(server_address)
+ transport = GrpcTransport(channel=channel, agent_card=agent_card)
+ transport.agent_card = None
+ assert transport._needs_extended_card is True
+
+ # Get the card, this will trigger verification in get_card
+ signature_verifier = create_signature_verifier(
+ create_key_provider(public_key), ['HS384', 'ES256', 'RS256']
+ )
+ result = await transport.get_card(signature_verifier=signature_verifier)
+ assert result.signatures is not None
+ assert len(result.signatures) == 1
+ assert transport._needs_extended_card is False
+ finally:
+ if transport:
+ await transport.close()
+ await server.stop(0) # Gracefully stop the server
diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py
index 28acd27c..f3227d32 100644
--- a/tests/utils/test_helpers.py
+++ b/tests/utils/test_helpers.py
@@ -7,6 +7,10 @@
from a2a.types import (
Artifact,
+ AgentCard,
+ AgentCardSignature,
+ AgentCapabilities,
+ AgentSkill,
Message,
MessageSendParams,
Part,
@@ -23,6 +27,7 @@
build_text_artifact,
create_task_obj,
validate,
+ canonicalize_agent_card,
)
@@ -45,6 +50,34 @@
'type': 'task',
}
+SAMPLE_AGENT_CARD: dict[str, Any] = {
+ 'name': 'Test Agent',
+ 'description': 'A test agent',
+ 'url': 'http://localhost',
+ 'version': '1.0.0',
+ 'capabilities': AgentCapabilities(
+ streaming=None,
+ push_notifications=True,
+ ),
+ 'default_input_modes': ['text/plain'],
+ 'default_output_modes': ['text/plain'],
+ 'documentation_url': None,
+ 'icon_url': '',
+ 'skills': [
+ AgentSkill(
+ id='skill1',
+ name='Test Skill',
+ description='A test skill',
+ tags=['test'],
+ )
+ ],
+ 'signatures': [
+ AgentCardSignature(
+ protected='protected_header', signature='test_signature'
+ )
+ ],
+}
+
# Test create_task_obj
def test_create_task_obj():
@@ -328,3 +361,22 @@ def test_are_modalities_compatible_both_empty():
)
is True
)
+
+
+def test_canonicalize_agent_card():
+ """Test canonicalize_agent_card with defaults, optionals, and exceptions.
+
+ - extensions is omitted as it's not set and optional.
+ - protocolVersion is included because it's always added by canonicalize_agent_card.
+ - signatures should be omitted.
+ """
+ agent_card = AgentCard(**SAMPLE_AGENT_CARD)
+ expected_jcs = (
+ '{"capabilities":{"pushNotifications":true},'
+ '"defaultInputModes":["text/plain"],"defaultOutputModes":["text/plain"],'
+ '"description":"A test agent","name":"Test Agent",'
+ '"skills":[{"description":"A test skill","id":"skill1","name":"Test Skill","tags":["test"]}],'
+ '"url":"http://localhost","version":"1.0.0"}'
+ )
+ result = canonicalize_agent_card(agent_card)
+ assert result == expected_jcs
diff --git a/tests/utils/test_proto_utils.py b/tests/utils/test_proto_utils.py
index 33be1f3f..f68d5c10 100644
--- a/tests/utils/test_proto_utils.py
+++ b/tests/utils/test_proto_utils.py
@@ -108,6 +108,18 @@ def sample_agent_card() -> types.AgentCard:
)
),
},
+ signatures=[
+ types.AgentCardSignature(
+ protected='protected_test',
+ signature='signature_test',
+ header={'alg': 'ES256'},
+ ),
+ types.AgentCardSignature(
+ protected='protected_val',
+ signature='signature_val',
+ header={'alg': 'ES256', 'kid': 'unique-key-identifier-123'},
+ ),
+ ],
)
@@ -523,7 +535,7 @@ def test_task_conversion_roundtrip(
assert roundtrip_task.status == types.TaskStatus(
state=types.TaskState.working, message=sample_message
)
- assert roundtrip_task.history == [sample_message]
+ assert roundtrip_task.history == sample_task.history
assert roundtrip_task.artifacts == [
types.Artifact(
artifact_id='art-1',
@@ -536,3 +548,142 @@ def test_task_conversion_roundtrip(
)
]
assert roundtrip_task.metadata == {'source': 'test'}
+
+ def test_agent_card_conversion_roundtrip(
+ self, sample_agent_card: types.AgentCard
+ ):
+ """Test conversion of AgentCard to proto and back."""
+ proto_card = proto_utils.ToProto.agent_card(sample_agent_card)
+ assert isinstance(proto_card, a2a_pb2.AgentCard)
+
+ roundtrip_card = proto_utils.FromProto.agent_card(proto_card)
+ assert roundtrip_card.name == 'Test Agent'
+ assert roundtrip_card.description == 'A test agent'
+ assert roundtrip_card.url == 'http://localhost'
+ assert roundtrip_card.version == '1.0.0'
+ assert roundtrip_card.capabilities == types.AgentCapabilities(
+ extensions=[], streaming=True, push_notifications=True
+ )
+ assert roundtrip_card.default_input_modes == ['text/plain']
+ assert roundtrip_card.default_output_modes == ['text/plain']
+ assert roundtrip_card.skills == [
+ types.AgentSkill(
+ id='skill1',
+ name='Test Skill',
+ description='A test skill',
+ tags=['test'],
+ examples=[],
+ input_modes=[],
+ output_modes=[],
+ )
+ ]
+ assert roundtrip_card.provider == types.AgentProvider(
+ organization='Test Org', url='http://test.org'
+ )
+ assert roundtrip_card.security == [{'oauth_scheme': ['read', 'write']}]
+
+ # Normalized version of security_schemes. None fields are filled with defaults.
+ expected_security_schemes = {
+ 'oauth_scheme': types.SecurityScheme(
+ root=types.OAuth2SecurityScheme(
+ description='',
+ flows=types.OAuthFlows(
+ client_credentials=types.ClientCredentialsOAuthFlow(
+ refresh_url='',
+ scopes={
+ 'write': 'Write access',
+ 'read': 'Read access',
+ },
+ token_url='http://token.url',
+ ),
+ ),
+ )
+ ),
+ 'apiKey': types.SecurityScheme(
+ root=types.APIKeySecurityScheme(
+ description='',
+ in_=types.In.header,
+ name='X-API-KEY',
+ )
+ ),
+ 'httpAuth': types.SecurityScheme(
+ root=types.HTTPAuthSecurityScheme(
+ bearer_format='',
+ description='',
+ scheme='bearer',
+ )
+ ),
+ 'oidc': types.SecurityScheme(
+ root=types.OpenIdConnectSecurityScheme(
+ description='',
+ open_id_connect_url='http://oidc.url',
+ )
+ ),
+ }
+ assert roundtrip_card.security_schemes == expected_security_schemes
+ assert roundtrip_card.signatures == [
+ types.AgentCardSignature(
+ protected='protected_test',
+ signature='signature_test',
+ header={'alg': 'ES256'},
+ ),
+ types.AgentCardSignature(
+ protected='protected_val',
+ signature='signature_val',
+ header={'alg': 'ES256', 'kid': 'unique-key-identifier-123'},
+ ),
+ ]
+
+ @pytest.mark.parametrize(
+ 'signature_data, expected_data',
+ [
+ (
+ types.AgentCardSignature(
+ protected='protected_val',
+ signature='signature_val',
+ header={'alg': 'ES256'},
+ ),
+ types.AgentCardSignature(
+ protected='protected_val',
+ signature='signature_val',
+ header={'alg': 'ES256'},
+ ),
+ ),
+ (
+ types.AgentCardSignature(
+ protected='protected_val',
+ signature='signature_val',
+ header=None,
+ ),
+ types.AgentCardSignature(
+ protected='protected_val',
+ signature='signature_val',
+ header={},
+ ),
+ ),
+ (
+ types.AgentCardSignature(
+ protected='',
+ signature='',
+ header={},
+ ),
+ types.AgentCardSignature(
+ protected='',
+ signature='',
+ header={},
+ ),
+ ),
+ ],
+ )
+ def test_agent_card_signature_conversion_roundtrip(
+ self, signature_data, expected_data
+ ):
+ """Test conversion of AgentCardSignature to proto and back."""
+ proto_signature = proto_utils.ToProto.agent_card_signature(
+ signature_data
+ )
+ assert isinstance(proto_signature, a2a_pb2.AgentCardSignature)
+ roundtrip_signature = proto_utils.FromProto.agent_card_signature(
+ proto_signature
+ )
+ assert roundtrip_signature == expected_data
diff --git a/tests/utils/test_signing.py b/tests/utils/test_signing.py
new file mode 100644
index 00000000..9a843d34
--- /dev/null
+++ b/tests/utils/test_signing.py
@@ -0,0 +1,185 @@
+from a2a.types import (
+ AgentCard,
+ AgentCapabilities,
+ AgentSkill,
+)
+from a2a.types import (
+ AgentCard,
+ AgentCapabilities,
+ AgentSkill,
+ AgentCardSignature,
+)
+from a2a.utils import signing
+from typing import Any
+from jwt.utils import base64url_encode
+
+import pytest
+from cryptography.hazmat.primitives import asymmetric
+
+
+def create_key_provider(verification_key: str | bytes | dict[str, Any]):
+ """Creates a key provider function for testing."""
+
+ def key_provider(kid: str | None, jku: str | None):
+ return verification_key
+
+ return key_provider
+
+
+# Fixture for a complete sample AgentCard
+@pytest.fixture
+def sample_agent_card() -> AgentCard:
+ return AgentCard(
+ name='Test Agent',
+ description='A test agent',
+ url='http://localhost',
+ version='1.0.0',
+ capabilities=AgentCapabilities(
+ streaming=None,
+ push_notifications=True,
+ ),
+ default_input_modes=['text/plain'],
+ default_output_modes=['text/plain'],
+ documentation_url=None,
+ icon_url='',
+ skills=[
+ AgentSkill(
+ id='skill1',
+ name='Test Skill',
+ description='A test skill',
+ tags=['test'],
+ )
+ ],
+ )
+
+
+def test_signer_and_verifier_symmetric(sample_agent_card: AgentCard):
+ """Test the agent card signing and verification process with symmetric key encryption."""
+ key = 'key12345' # Using a simple symmetric key for HS256
+ wrong_key = 'wrongkey'
+
+ agent_card_signer = signing.create_agent_card_signer(
+ signing_key=key,
+ protected_header={
+ 'alg': 'HS384',
+ 'kid': 'key1',
+ 'jku': None,
+ 'typ': 'JOSE',
+ },
+ )
+ signed_card = agent_card_signer(sample_agent_card)
+
+ assert signed_card.signatures is not None
+ assert len(signed_card.signatures) == 1
+ signature = signed_card.signatures[0]
+ assert signature.protected is not None
+ assert signature.signature is not None
+
+ # Verify the signature
+ verifier = signing.create_signature_verifier(
+ create_key_provider(key), ['HS256', 'HS384', 'ES256', 'RS256']
+ )
+ try:
+ verifier(signed_card)
+ except signing.InvalidSignaturesError:
+ pytest.fail('Signature verification failed with correct key')
+
+ # Verify with wrong key
+ verifier_wrong_key = signing.create_signature_verifier(
+ create_key_provider(wrong_key), ['HS256', 'HS384', 'ES256', 'RS256']
+ )
+ with pytest.raises(signing.InvalidSignaturesError):
+ verifier_wrong_key(signed_card)
+
+
+def test_signer_and_verifier_symmetric_multiple_signatures(
+ sample_agent_card: AgentCard,
+):
+ """Test the agent card signing and verification process with symmetric key encryption.
+ This test adds a signatures to the AgentCard before signing."""
+ encoded_header = base64url_encode(
+ b'{"alg": "HS256", "kid": "old_key"}'
+ ).decode('utf-8')
+ sample_agent_card.signatures = [
+ AgentCardSignature(protected=encoded_header, signature='old_signature')
+ ]
+ key = 'key12345' # Using a simple symmetric key for HS256
+ wrong_key = 'wrongkey'
+
+ agent_card_signer = signing.create_agent_card_signer(
+ signing_key=key,
+ protected_header={
+ 'alg': 'HS384',
+ 'kid': 'key1',
+ 'jku': None,
+ 'typ': 'JOSE',
+ },
+ )
+ signed_card = agent_card_signer(sample_agent_card)
+
+ assert signed_card.signatures is not None
+ assert len(signed_card.signatures) == 2
+ signature = signed_card.signatures[1]
+ assert signature.protected is not None
+ assert signature.signature is not None
+
+ # Verify the signature
+ verifier = signing.create_signature_verifier(
+ create_key_provider(key), ['HS256', 'HS384', 'ES256', 'RS256']
+ )
+ try:
+ verifier(signed_card)
+ except signing.InvalidSignaturesError:
+ pytest.fail('Signature verification failed with correct key')
+
+ # Verify with wrong key
+ verifier_wrong_key = signing.create_signature_verifier(
+ create_key_provider(wrong_key), ['HS256', 'HS384', 'ES256', 'RS256']
+ )
+ with pytest.raises(signing.InvalidSignaturesError):
+ verifier_wrong_key(signed_card)
+
+
+def test_signer_and_verifier_asymmetric(sample_agent_card: AgentCard):
+ """Test the agent card signing and verification process with an asymmetric key encryption."""
+ # Generate a dummy EC private key for ES256
+ private_key = asymmetric.ec.generate_private_key(asymmetric.ec.SECP256R1())
+ public_key = private_key.public_key()
+ # Generate another key pair for negative test
+ private_key_error = asymmetric.ec.generate_private_key(
+ asymmetric.ec.SECP256R1()
+ )
+ public_key_error = private_key_error.public_key()
+
+ agent_card_signer = signing.create_agent_card_signer(
+ signing_key=private_key,
+ protected_header={
+ 'alg': 'ES256',
+ 'kid': 'key2',
+ 'jku': None,
+ 'typ': 'JOSE',
+ },
+ )
+ signed_card = agent_card_signer(sample_agent_card)
+
+ assert signed_card.signatures is not None
+ assert len(signed_card.signatures) == 1
+ signature = signed_card.signatures[0]
+ assert signature.protected is not None
+ assert signature.signature is not None
+
+ verifier = signing.create_signature_verifier(
+ create_key_provider(public_key), ['HS256', 'HS384', 'ES256', 'RS256']
+ )
+ try:
+ verifier(signed_card)
+ except signing.InvalidSignaturesError:
+ pytest.fail('Signature verification failed with correct key')
+
+ # Verify with wrong key
+ verifier_wrong_key = signing.create_signature_verifier(
+ create_key_provider(public_key_error),
+ ['HS256', 'HS384', 'ES256', 'RS256'],
+ )
+ with pytest.raises(signing.InvalidSignaturesError):
+ verifier_wrong_key(signed_card)
From 090ca9cb2a2c25840c5155a372eef72fbcef1093 Mon Sep 17 00:00:00 2001
From: Didier Durand <2927957+didier-durand@users.noreply.github.com>
Date: Fri, 12 Dec 2025 16:37:08 +0100
Subject: [PATCH 13/29] chore: Fixing typos (final round) (#588)
# Description
Read further and discovered this additional (and final) set of typos
---
.github/workflows/stale.yaml | 2 +-
src/a2a/client/client_factory.py | 2 +-
src/a2a/server/events/event_queue.py | 2 +-
tests/e2e/push_notifications/notifications_app.py | 4 ++--
tests/server/events/test_event_queue.py | 2 +-
5 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml
index 3f9c6fe9..7c8cb0dc 100644
--- a/.github/workflows/stale.yaml
+++ b/.github/workflows/stale.yaml
@@ -7,7 +7,7 @@ name: Mark stale issues and pull requests
on:
schedule:
- # Scheduled to run at 10.30PM UTC everyday (1530PDT/1430PST)
+ # Scheduled to run at 10.30PM UTC every day (1530PDT/1430PST)
- cron: "30 22 * * *"
workflow_dispatch:
diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py
index fabd7270..e2eb066a 100644
--- a/src/a2a/client/client_factory.py
+++ b/src/a2a/client/client_factory.py
@@ -256,7 +256,7 @@ def minimal_agent_card(
"""Generates a minimal card to simplify bootstrapping client creation.
This minimal card is not viable itself to interact with the remote agent.
- Instead this is a short hand way to take a known url and transport option
+ Instead this is a shorthand way to take a known url and transport option
and interact with the get card endpoint of the agent server to get the
correct agent card. This pattern is necessary for gRPC based card access
as typically these servers won't expose a well known path card.
diff --git a/src/a2a/server/events/event_queue.py b/src/a2a/server/events/event_queue.py
index f6599cca..357fcb02 100644
--- a/src/a2a/server/events/event_queue.py
+++ b/src/a2a/server/events/event_queue.py
@@ -73,7 +73,7 @@ async def dequeue_event(self, no_wait: bool = False) -> Event:
closed but when there are no events on the queue. Two ways to avoid this
are to call this with no_wait = True which won't block, but is the
callers responsibility to retry as appropriate. Alternatively, one can
- use a async Task management solution to cancel the get task if the queue
+ use an async Task management solution to cancel the get task if the queue
has closed or some other condition is met. The implementation of the
EventConsumer uses an async.wait with a timeout to abort the
dequeue_event call and retry, when it will return with a closed error.
diff --git a/tests/e2e/push_notifications/notifications_app.py b/tests/e2e/push_notifications/notifications_app.py
index ed032dcb..c12e9809 100644
--- a/tests/e2e/push_notifications/notifications_app.py
+++ b/tests/e2e/push_notifications/notifications_app.py
@@ -23,7 +23,7 @@ def create_notifications_app() -> FastAPI:
@app.post('/notifications')
async def add_notification(request: Request):
- """Endpoint for injesting notifications from agents. It receives a JSON
+ """Endpoint for ingesting notifications from agents. It receives a JSON
payload and stores it in-memory.
"""
token = request.headers.get('x-a2a-notification-token')
@@ -56,7 +56,7 @@ async def list_notifications_by_task(
str, Path(title='The ID of the task to list the notifications for.')
],
):
- """Helper endpoint for retrieving injested notifications for a given task."""
+ """Helper endpoint for retrieving ingested notifications for a given task."""
async with store_lock:
notifications = store.get(task_id, [])
return {'notifications': notifications}
diff --git a/tests/server/events/test_event_queue.py b/tests/server/events/test_event_queue.py
index 0ff966cc..96ded958 100644
--- a/tests/server/events/test_event_queue.py
+++ b/tests/server/events/test_event_queue.py
@@ -305,7 +305,7 @@ async def test_close_sets_flag_and_handles_internal_queue_new_python(
async def test_close_graceful_py313_waits_for_join_and_children(
event_queue: EventQueue,
) -> None:
- """For Python >=3.13 and immediate=False, close should shutdown(False), then wait for join and children."""
+ """For Python >=3.13 and immediate=False, close should shut down(False), then wait for join and children."""
with patch('sys.version_info', (3, 13, 0)):
# Arrange
from typing import cast
From 03fa4c25dbe6d5c92653cffa01f2fc59f80d33fb Mon Sep 17 00:00:00 2001
From: "Agent2Agent (A2A) Bot"
Date: Fri, 12 Dec 2025 11:04:30 -0600
Subject: [PATCH 14/29] chore(main): release 0.3.21 (#587)
:robot: I have created a release *beep* *boop*
---
##
[0.3.21](https://github.com/a2aproject/a2a-python/compare/v0.3.20...v0.3.21)
(2025-12-12)
### Documentation
* Fixing typos
([#586](https://github.com/a2aproject/a2a-python/issues/586))
([5fea21f](https://github.com/a2aproject/a2a-python/commit/5fea21fb34ecea55e588eb10139b5d47020a76cb))
---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 590bd78e..966fe3df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [0.3.21](https://github.com/a2aproject/a2a-python/compare/v0.3.20...v0.3.21) (2025-12-12)
+
+
+### Documentation
+
+* Fixing typos ([#586](https://github.com/a2aproject/a2a-python/issues/586)) ([5fea21f](https://github.com/a2aproject/a2a-python/commit/5fea21fb34ecea55e588eb10139b5d47020a76cb))
+
## [0.3.20](https://github.com/a2aproject/a2a-python/compare/v0.3.19...v0.3.20) (2025-12-03)
From 04bcafc737cf426d9975c76e346335ff992363e2 Mon Sep 17 00:00:00 2001
From: Will Chen <36873565+chenweiyang0204@users.noreply.github.com>
Date: Tue, 16 Dec 2025 00:33:54 -0800
Subject: [PATCH 15/29] feat: Add custom ID generators to
SimpleRequestContextBuilder (#594)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Description
This change allows passing custom `task_id_generator` and
`context_id_generator` functions to the `SimpleRequestContextBuilder`.
This provides flexibility in how task and context IDs are generated,
defaulting to the previous behavior if no generators are provided.
Thank you for opening a Pull Request!
Before submitting your PR, there are a few things you can do to make
sure it goes smoothly:
- [x] Follow the [`CONTRIBUTING`
Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
- [x] Make your Pull Request title in the
specification.
- Important Prefixes for
[release-please](https://github.com/googleapis/release-please):
- `fix:` which represents bug fixes, and correlates to a
[SemVer](https://semver.org/) patch.
- `feat:` represents a new feature, and correlates to a SemVer minor.
- `feat!:`, or `fix!:`, `refactor!:`, etc., which represent a breaking
change (indicated by the `!`) and will result in a SemVer major.
- [x] Ensure the tests and linter pass (Run `bash scripts/format.sh`
from the repository root to format)
- [x] Appropriate docs were updated (if necessary)
Fixes # š¦
---
.../simple_request_context_builder.py | 9 +++
.../test_simple_request_context_builder.py | 60 +++++++++++++++++++
2 files changed, 69 insertions(+)
diff --git a/src/a2a/server/agent_execution/simple_request_context_builder.py b/src/a2a/server/agent_execution/simple_request_context_builder.py
index 3eca4435..876b6561 100644
--- a/src/a2a/server/agent_execution/simple_request_context_builder.py
+++ b/src/a2a/server/agent_execution/simple_request_context_builder.py
@@ -2,6 +2,7 @@
from a2a.server.agent_execution import RequestContext, RequestContextBuilder
from a2a.server.context import ServerCallContext
+from a2a.server.id_generator import IDGenerator
from a2a.server.tasks import TaskStore
from a2a.types import MessageSendParams, Task
@@ -13,6 +14,8 @@ def __init__(
self,
should_populate_referred_tasks: bool = False,
task_store: TaskStore | None = None,
+ task_id_generator: IDGenerator | None = None,
+ context_id_generator: IDGenerator | None = None,
) -> None:
"""Initializes the SimpleRequestContextBuilder.
@@ -22,9 +25,13 @@ def __init__(
`related_tasks` field in the RequestContext. Defaults to False.
task_store: The TaskStore instance to use for fetching referred tasks.
Required if `should_populate_referred_tasks` is True.
+ task_id_generator: ID generator for new task IDs. Defaults to None.
+ context_id_generator: ID generator for new context IDs. Defaults to None.
"""
self._task_store = task_store
self._should_populate_referred_tasks = should_populate_referred_tasks
+ self._task_id_generator = task_id_generator
+ self._context_id_generator = context_id_generator
async def build(
self,
@@ -74,4 +81,6 @@ async def build(
task=task,
related_tasks=related_tasks,
call_context=context,
+ task_id_generator=self._task_id_generator,
+ context_id_generator=self._context_id_generator,
)
diff --git a/tests/server/agent_execution/test_simple_request_context_builder.py b/tests/server/agent_execution/test_simple_request_context_builder.py
index 5e1b8fd8..c1cbcf05 100644
--- a/tests/server/agent_execution/test_simple_request_context_builder.py
+++ b/tests/server/agent_execution/test_simple_request_context_builder.py
@@ -10,6 +10,7 @@
SimpleRequestContextBuilder,
)
from a2a.server.context import ServerCallContext
+from a2a.server.id_generator import IDGenerator
from a2a.server.tasks.task_store import TaskStore
from a2a.types import (
Message,
@@ -275,6 +276,65 @@ async def test_build_populate_false_with_reference_task_ids(self) -> None:
self.assertEqual(request_context.related_tasks, [])
self.mock_task_store.get.assert_not_called()
+ async def test_build_with_custom_id_generators(self) -> None:
+ mock_task_id_generator = AsyncMock(spec=IDGenerator)
+ mock_context_id_generator = AsyncMock(spec=IDGenerator)
+ mock_task_id_generator.generate.return_value = 'custom_task_id'
+ mock_context_id_generator.generate.return_value = 'custom_context_id'
+
+ builder = SimpleRequestContextBuilder(
+ should_populate_referred_tasks=False,
+ task_store=self.mock_task_store,
+ task_id_generator=mock_task_id_generator,
+ context_id_generator=mock_context_id_generator,
+ )
+ params = MessageSendParams(message=create_sample_message())
+ server_call_context = ServerCallContext(user=UnauthenticatedUser())
+
+ request_context = await builder.build(
+ params=params,
+ task_id=None,
+ context_id=None,
+ task=None,
+ context=server_call_context,
+ )
+
+ mock_task_id_generator.generate.assert_called_once()
+ mock_context_id_generator.generate.assert_called_once()
+ self.assertEqual(request_context.task_id, 'custom_task_id')
+ self.assertEqual(request_context.context_id, 'custom_context_id')
+
+ async def test_build_with_provided_ids_and_custom_id_generators(
+ self,
+ ) -> None:
+ mock_task_id_generator = AsyncMock(spec=IDGenerator)
+ mock_context_id_generator = AsyncMock(spec=IDGenerator)
+
+ builder = SimpleRequestContextBuilder(
+ should_populate_referred_tasks=False,
+ task_store=self.mock_task_store,
+ task_id_generator=mock_task_id_generator,
+ context_id_generator=mock_context_id_generator,
+ )
+ params = MessageSendParams(message=create_sample_message())
+ server_call_context = ServerCallContext(user=UnauthenticatedUser())
+
+ provided_task_id = 'provided_task_id'
+ provided_context_id = 'provided_context_id'
+
+ request_context = await builder.build(
+ params=params,
+ task_id=provided_task_id,
+ context_id=provided_context_id,
+ task=None,
+ context=server_call_context,
+ )
+
+ mock_task_id_generator.generate.assert_not_called()
+ mock_context_id_generator.generate.assert_not_called()
+ self.assertEqual(request_context.task_id, provided_task_id)
+ self.assertEqual(request_context.context_id, provided_context_id)
+
if __name__ == '__main__':
unittest.main()
From e12ca42c1ee611f41c9e779c78e705aebee3543d Mon Sep 17 00:00:00 2001
From: Didier Durand <2927957+didier-durand@users.noreply.github.com>
Date: Tue, 16 Dec 2025 09:35:30 +0100
Subject: [PATCH 16/29] test: adding 2 additional tests to user.py (#595)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Description
Adding 2 more tests to user.py to improve build code coverage
- [X] Follow the [`CONTRIBUTING`
Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
- [X] Make your Pull Request title in the
specification.
- [X] Ensure the tests and linter pass (Run `bash scripts/format.sh`
from the repository root to format)
- [N/A] Appropriate docs were updated (if necessary)
Fixes # š¦
N/A
---------
Co-authored-by: Lukasz Kawka
---
tests/auth/test_user.py | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/tests/auth/test_user.py b/tests/auth/test_user.py
index 5cc479ce..e3bbe2e6 100644
--- a/tests/auth/test_user.py
+++ b/tests/auth/test_user.py
@@ -1,9 +1,19 @@
import unittest
-from a2a.auth.user import UnauthenticatedUser
+from inspect import isabstract
+
+from a2a.auth.user import UnauthenticatedUser, User
+
+
+class TestUser(unittest.TestCase):
+ def test_is_abstract(self):
+ self.assertTrue(isabstract(User))
class TestUnauthenticatedUser(unittest.TestCase):
+ def test_is_user_subclass(self):
+ self.assertTrue(issubclass(UnauthenticatedUser, User))
+
def test_is_authenticated_returns_false(self):
user = UnauthenticatedUser()
self.assertFalse(user.is_authenticated)
From 3deecc46f5bdd2113c8a5c59a814035ea71480d2 Mon Sep 17 00:00:00 2001
From: Didier Durand <2927957+didier-durand@users.noreply.github.com>
Date: Tue, 16 Dec 2025 09:38:21 +0100
Subject: [PATCH 17/29] test: adding 21 tests for client/card_resolver.py
(#592)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Description
Adding 21 tests for client/card_resolver.py
They all pass:
```
========================= test session starts ==============================
collecting ... collected 21 items
tests/client/test_card_resolver.py::TestA2ACardResolverInit::test_init_with_defaults PASSED [ 4%]
tests/client/test_card_resolver.py::TestA2ACardResolverInit::test_init_with_custom_path PASSED [ 9%]
tests/client/test_card_resolver.py::TestA2ACardResolverInit::test_init_strips_leading_slash_from_agent_card_path PASSED [ 14%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_success_default_path PASSED [ 19%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_success_custom_path PASSED [ 23%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_strips_leading_slash_from_relative_path PASSED [ 28%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_with_http_kwargs PASSED [ 33%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_root_path PASSED [ 38%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_http_status_error PASSED [ 42%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_json_decode_error PASSED [ 47%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_request_error PASSED [ 52%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_validation_error PASSED [ 57%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_logs_success PASSED [ 61%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_none_relative_path PASSED [ 66%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_empty_string_relative_path PASSED [ 71%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_different_status_codes[400] PASSED [ 76%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_different_status_codes[401] PASSED [ 80%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_different_status_codes[403] PASSED [ 85%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_different_status_codes[500] PASSED [ 90%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_different_status_codes[502] PASSED [ 95%]
tests/client/test_card_resolver.py::TestGetAgentCard::test_get_agent_card_returns_agent_card_instance PASSED [100%]
======================== 21 passed, 2 warnings in 0.11s ========================
```
- [X] Follow the [`CONTRIBUTING`
Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
- [X] Make your Pull Request title in the
specification.
- [X] Ensure the tests and linter pass (Run `bash scripts/format.sh`
from the repository root to format)
- [N/A ] Appropriate docs were updated (if necessary)
Fixes # š¦
N/A
---------
Co-authored-by: Lukasz Kawka
---
tests/client/test_card_resolver.py | 379 +++++++++++++++++++++++++++++
1 file changed, 379 insertions(+)
create mode 100644 tests/client/test_card_resolver.py
diff --git a/tests/client/test_card_resolver.py b/tests/client/test_card_resolver.py
new file mode 100644
index 00000000..f87d9450
--- /dev/null
+++ b/tests/client/test_card_resolver.py
@@ -0,0 +1,379 @@
+import json
+import logging
+
+from unittest.mock import AsyncMock, Mock, patch
+
+import httpx
+import pytest
+
+from a2a.client import A2ACardResolver, A2AClientHTTPError, A2AClientJSONError
+from a2a.types import AgentCard
+from a2a.utils import AGENT_CARD_WELL_KNOWN_PATH
+
+
+@pytest.fixture
+def mock_httpx_client():
+ """Fixture providing a mocked async httpx client."""
+ return AsyncMock(spec=httpx.AsyncClient)
+
+
+@pytest.fixture
+def base_url():
+ """Fixture providing a test base URL."""
+ return 'https://example.com'
+
+
+@pytest.fixture
+def resolver(mock_httpx_client, base_url):
+ """Fixture providing an A2ACardResolver instance."""
+ return A2ACardResolver(
+ httpx_client=mock_httpx_client,
+ base_url=base_url,
+ )
+
+
+@pytest.fixture
+def mock_response():
+ """Fixture providing a mock httpx Response."""
+ response = Mock(spec=httpx.Response)
+ response.raise_for_status = Mock()
+ return response
+
+
+@pytest.fixture
+def valid_agent_card_data():
+ """Fixture providing valid agent card data."""
+ return {
+ 'name': 'TestAgent',
+ 'description': 'A test agent',
+ 'version': '1.0.0',
+ 'url': 'https://example.com/a2a',
+ 'capabilities': {},
+ 'default_input_modes': ['text/plain'],
+ 'default_output_modes': ['text/plain'],
+ 'skills': [
+ {
+ 'id': 'test-skill',
+ 'name': 'Test Skill',
+ 'description': 'A skill for testing',
+ 'tags': ['test'],
+ }
+ ],
+ }
+
+
+class TestA2ACardResolverInit:
+ """Tests for A2ACardResolver initialization."""
+
+ def test_init_with_defaults(self, mock_httpx_client, base_url):
+ """Test initialization with default agent_card_path."""
+ resolver = A2ACardResolver(
+ httpx_client=mock_httpx_client,
+ base_url=base_url,
+ )
+ assert resolver.base_url == base_url
+ assert resolver.agent_card_path == AGENT_CARD_WELL_KNOWN_PATH[1:]
+ assert resolver.httpx_client == mock_httpx_client
+
+ def test_init_with_custom_path(self, mock_httpx_client, base_url):
+ """Test initialization with custom agent_card_path."""
+ custom_path = '/custom/agent/card'
+ resolver = A2ACardResolver(
+ httpx_client=mock_httpx_client,
+ base_url=base_url,
+ agent_card_path=custom_path,
+ )
+ assert resolver.base_url == base_url
+ assert resolver.agent_card_path == custom_path[1:]
+
+ def test_init_strips_leading_slash_from_agent_card_path(
+ self, mock_httpx_client, base_url
+ ):
+ """Test that leading slash is stripped from agent_card_path."""
+ agent_card_path = '/well-known/agent'
+ resolver = A2ACardResolver(
+ httpx_client=mock_httpx_client,
+ base_url=base_url,
+ agent_card_path=agent_card_path,
+ )
+ assert resolver.agent_card_path == agent_card_path[1:]
+
+
+class TestGetAgentCard:
+ """Tests for get_agent_card methods."""
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_success_default_path(
+ self,
+ base_url,
+ resolver,
+ mock_httpx_client,
+ mock_response,
+ valid_agent_card_data,
+ ):
+ """Test successful agent card fetch using default path."""
+ mock_response.json.return_value = valid_agent_card_data
+ mock_httpx_client.get.return_value = mock_response
+
+ with patch.object(
+ AgentCard, 'model_validate', return_value=Mock(spec=AgentCard)
+ ) as mock_validate:
+ result = await resolver.get_agent_card()
+ mock_httpx_client.get.assert_called_once_with(
+ f'{base_url}/{AGENT_CARD_WELL_KNOWN_PATH[1:]}',
+ )
+ mock_response.raise_for_status.assert_called_once()
+ mock_response.json.assert_called_once()
+ mock_validate.assert_called_once_with(valid_agent_card_data)
+ assert result is not None
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_success_custom_path(
+ self,
+ base_url,
+ resolver,
+ mock_httpx_client,
+ mock_response,
+ valid_agent_card_data,
+ ):
+ """Test successful agent card fetch using custom relative path."""
+ custom_path = 'custom/path/card'
+ mock_response.json.return_value = valid_agent_card_data
+ mock_httpx_client.get.return_value = mock_response
+ with patch.object(
+ AgentCard, 'model_validate', return_value=Mock(spec=AgentCard)
+ ):
+ await resolver.get_agent_card(relative_card_path=custom_path)
+
+ mock_httpx_client.get.assert_called_once_with(
+ f'{base_url}/{custom_path}',
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_strips_leading_slash_from_relative_path(
+ self,
+ base_url,
+ resolver,
+ mock_httpx_client,
+ mock_response,
+ valid_agent_card_data,
+ ):
+ """Test successful agent card fetch using custom path with leading slash."""
+ custom_path = '/custom/path/card'
+ mock_response.json.return_value = valid_agent_card_data
+ mock_httpx_client.get.return_value = mock_response
+ with patch.object(
+ AgentCard, 'model_validate', return_value=Mock(spec=AgentCard)
+ ):
+ await resolver.get_agent_card(relative_card_path=custom_path)
+
+ mock_httpx_client.get.assert_called_once_with(
+ f'{base_url}/{custom_path[1:]}',
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_with_http_kwargs(
+ self,
+ base_url,
+ resolver,
+ mock_httpx_client,
+ mock_response,
+ valid_agent_card_data,
+ ):
+ """Test that http_kwargs are passed to httpx.get."""
+ mock_response.json.return_value = valid_agent_card_data
+ mock_httpx_client.get.return_value = mock_response
+ http_kwargs = {
+ 'timeout': 30,
+ 'headers': {'Authorization': 'Bearer token'},
+ }
+ with patch.object(
+ AgentCard, 'model_validate', return_value=Mock(spec=AgentCard)
+ ):
+ await resolver.get_agent_card(http_kwargs=http_kwargs)
+ mock_httpx_client.get.assert_called_once_with(
+ f'{base_url}/{AGENT_CARD_WELL_KNOWN_PATH[1:]}',
+ timeout=30,
+ headers={'Authorization': 'Bearer token'},
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_root_path(
+ self,
+ base_url,
+ resolver,
+ mock_httpx_client,
+ mock_response,
+ valid_agent_card_data,
+ ):
+ """Test fetching agent card from root path."""
+ mock_response.json.return_value = valid_agent_card_data
+ mock_httpx_client.get.return_value = mock_response
+ with patch.object(
+ AgentCard, 'model_validate', return_value=Mock(spec=AgentCard)
+ ):
+ await resolver.get_agent_card(relative_card_path='/')
+ mock_httpx_client.get.assert_called_once_with(f'{base_url}/')
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_http_status_error(
+ self, resolver, mock_httpx_client
+ ):
+ """Test A2AClientHTTPError raised on HTTP status error."""
+ status_code = 404
+ mock_response = Mock(spec=httpx.Response)
+ mock_response.status_code = status_code
+ mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
+ 'Not Found', request=Mock(), response=mock_response
+ )
+ mock_httpx_client.get.return_value = mock_response
+
+ with pytest.raises(A2AClientHTTPError) as exc_info:
+ await resolver.get_agent_card()
+
+ assert exc_info.value.status_code == status_code
+ assert 'Failed to fetch agent card' in str(exc_info.value)
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_json_decode_error(
+ self, resolver, mock_httpx_client, mock_response
+ ):
+ """Test A2AClientJSONError raised on JSON decode error."""
+ mock_response.json.side_effect = json.JSONDecodeError(
+ 'Invalid JSON', '', 0
+ )
+ mock_httpx_client.get.return_value = mock_response
+ with pytest.raises(A2AClientJSONError) as exc_info:
+ await resolver.get_agent_card()
+ assert 'Failed to parse JSON' in str(exc_info.value)
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_request_error(
+ self, resolver, mock_httpx_client
+ ):
+ """Test A2AClientHTTPError raised on network request error."""
+ mock_httpx_client.get.side_effect = httpx.RequestError(
+ 'Connection timeout', request=Mock()
+ )
+ with pytest.raises(A2AClientHTTPError) as exc_info:
+ await resolver.get_agent_card()
+ assert exc_info.value.status_code == 503
+ assert 'Network communication error' in str(exc_info.value)
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_validation_error(
+ self,
+ base_url,
+ resolver,
+ mock_httpx_client,
+ mock_response,
+ valid_agent_card_data,
+ ):
+ """Test A2AClientJSONError is raised on agent card validation error."""
+ return_json = {'invalid': 'data'}
+ mock_response.json.return_value = return_json
+ mock_httpx_client.get.return_value = mock_response
+ with pytest.raises(A2AClientJSONError) as exc_info:
+ await resolver.get_agent_card()
+ assert (
+ f'Failed to validate agent card structure from {base_url}/{AGENT_CARD_WELL_KNOWN_PATH[1:]}'
+ in exc_info.value.message
+ )
+ mock_httpx_client.get.assert_called_once_with(
+ f'{base_url}/{AGENT_CARD_WELL_KNOWN_PATH[1:]}',
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_logs_success( # noqa: PLR0913
+ self,
+ base_url,
+ resolver,
+ mock_httpx_client,
+ mock_response,
+ valid_agent_card_data,
+ caplog,
+ ):
+ mock_response.json.return_value = valid_agent_card_data
+ mock_httpx_client.get.return_value = mock_response
+ with (
+ patch.object(
+ AgentCard, 'model_validate', return_value=Mock(spec=AgentCard)
+ ),
+ caplog.at_level(logging.INFO),
+ ):
+ await resolver.get_agent_card()
+ assert (
+ f'Successfully fetched agent card data from {base_url}/{AGENT_CARD_WELL_KNOWN_PATH[1:]}'
+ in caplog.text
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_none_relative_path(
+ self,
+ base_url,
+ resolver,
+ mock_httpx_client,
+ mock_response,
+ valid_agent_card_data,
+ ):
+ """Test that None relative_card_path uses default path."""
+ mock_response.json.return_value = valid_agent_card_data
+ mock_httpx_client.get.return_value = mock_response
+
+ with patch.object(
+ AgentCard, 'model_validate', return_value=Mock(spec=AgentCard)
+ ):
+ await resolver.get_agent_card(relative_card_path=None)
+ mock_httpx_client.get.assert_called_once_with(
+ f'{base_url}/{AGENT_CARD_WELL_KNOWN_PATH[1:]}',
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_empty_string_relative_path(
+ self,
+ base_url,
+ resolver,
+ mock_httpx_client,
+ mock_response,
+ valid_agent_card_data,
+ ):
+ """Test that empty string relative_card_path uses default path."""
+ mock_response.json.return_value = valid_agent_card_data
+ mock_httpx_client.get.return_value = mock_response
+
+ with patch.object(
+ AgentCard, 'model_validate', return_value=Mock(spec=AgentCard)
+ ):
+ await resolver.get_agent_card(relative_card_path='')
+
+ mock_httpx_client.get.assert_called_once_with(
+ f'{base_url}/{AGENT_CARD_WELL_KNOWN_PATH[1:]}',
+ )
+
+ @pytest.mark.parametrize('status_code', [400, 401, 403, 500, 502])
+ @pytest.mark.asyncio
+ async def test_get_agent_card_different_status_codes(
+ self, resolver, mock_httpx_client, status_code
+ ):
+ """Test different HTTP status codes raise appropriate errors."""
+ mock_response = Mock(spec=httpx.Response)
+ mock_response.status_code = status_code
+ mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
+ f'Status {status_code}', request=Mock(), response=mock_response
+ )
+ mock_httpx_client.get.return_value = mock_response
+ with pytest.raises(A2AClientHTTPError) as exc_info:
+ await resolver.get_agent_card()
+ assert exc_info.value.status_code == status_code
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_returns_agent_card_instance(
+ self, resolver, mock_httpx_client, mock_response, valid_agent_card_data
+ ):
+ """Test that get_agent_card returns an AgentCard instance."""
+ mock_agent_card = Mock(spec=AgentCard)
+ with patch.object(
+ AgentCard, 'model_validate', return_value=mock_agent_card
+ ):
+ result = await resolver.get_agent_card()
+ assert result == mock_agent_card
From 6fa6a6cf3875bdf7bfc51fb1a541a3f3e8381dc0 Mon Sep 17 00:00:00 2001
From: Iva Sokolaj <102302011+sokoliva@users.noreply.github.com>
Date: Tue, 16 Dec 2025 15:46:08 +0100
Subject: [PATCH 18/29] refactor: Move agent card signature verification into
`A2ACardResolver` (#593)
# Description
Previously, the `JSON-RPC` and `REST` protocols verified agent card
signatures after calling `A2ACardResolver.get_agent_card`. This change
moves the signature verification logic inside the
`A2ACardResolver.get_agent_card` method and adds a unit test to test_card_resolver.py
Thank you for opening a Pull Request!
Before submitting your PR, there are a few things you can do to make
sure it goes smoothly:
- [x] Follow the [`CONTRIBUTING`
Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
- [x] Make your Pull Request title in the
specification.
- Important Prefixes for
[release-please](https://github.com/googleapis/release-please):
- `fix:` which represents bug fixes, and correlates to a
[SemVer](https://semver.org/) patch.
- `feat:` represents a new feature, and correlates to a SemVer minor.
- `feat!:`, or `fix!:`, `refactor!:`, etc., which represent a breaking
change (indicated by the `!`) and will result in a SemVer major.
- [x] Ensure the tests and linter pass (Run `bash scripts/format.sh`
from the repository root to format)
- [x] Appropriate docs were updated (if necessary)
---
src/a2a/client/card_resolver.py | 5 +++++
src/a2a/client/client_factory.py | 4 ++++
src/a2a/client/transports/grpc.py | 2 +-
src/a2a/client/transports/jsonrpc.py | 9 +++++----
src/a2a/client/transports/rest.py | 9 +++++----
tests/client/test_card_resolver.py | 23 ++++++++++++++++++++++-
tests/client/test_client_factory.py | 2 ++
7 files changed, 44 insertions(+), 10 deletions(-)
diff --git a/src/a2a/client/card_resolver.py b/src/a2a/client/card_resolver.py
index f13fe3ab..adb3c5ae 100644
--- a/src/a2a/client/card_resolver.py
+++ b/src/a2a/client/card_resolver.py
@@ -1,6 +1,7 @@
import json
import logging
+from collections.abc import Callable
from typing import Any
import httpx
@@ -44,6 +45,7 @@ async def get_agent_card(
self,
relative_card_path: str | None = None,
http_kwargs: dict[str, Any] | None = None,
+ signature_verifier: Callable[[AgentCard], None] | None = None,
) -> AgentCard:
"""Fetches an agent card from a specified path relative to the base_url.
@@ -56,6 +58,7 @@ async def get_agent_card(
agent card path. Use `'/'` for an empty path.
http_kwargs: Optional dictionary of keyword arguments to pass to the
underlying httpx.get request.
+ signature_verifier: A callable used to verify the agent card's signatures.
Returns:
An `AgentCard` object representing the agent's capabilities.
@@ -86,6 +89,8 @@ async def get_agent_card(
agent_card_data,
)
agent_card = AgentCard.model_validate(agent_card_data)
+ if signature_verifier:
+ signature_verifier(agent_card)
except httpx.HTTPStatusError as e:
raise A2AClientHTTPError(
e.response.status_code,
diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py
index e2eb066a..c3d5762e 100644
--- a/src/a2a/client/client_factory.py
+++ b/src/a2a/client/client_factory.py
@@ -116,6 +116,7 @@ async def connect( # noqa: PLR0913
resolver_http_kwargs: dict[str, Any] | None = None,
extra_transports: dict[str, TransportProducer] | None = None,
extensions: list[str] | None = None,
+ signature_verifier: Callable[[AgentCard], None] | None = None,
) -> Client:
"""Convenience method for constructing a client.
@@ -146,6 +147,7 @@ async def connect( # noqa: PLR0913
extra_transports: Additional transport protocols to enable when
constructing the client.
extensions: List of extensions to be activated.
+ signature_verifier: A callable used to verify the agent card's signatures.
Returns:
A `Client` object.
@@ -158,12 +160,14 @@ async def connect( # noqa: PLR0913
card = await resolver.get_agent_card(
relative_card_path=relative_card_path,
http_kwargs=resolver_http_kwargs,
+ signature_verifier=signature_verifier,
)
else:
resolver = A2ACardResolver(client_config.httpx_client, agent)
card = await resolver.get_agent_card(
relative_card_path=relative_card_path,
http_kwargs=resolver_http_kwargs,
+ signature_verifier=signature_verifier,
)
else:
card = agent
diff --git a/src/a2a/client/transports/grpc.py b/src/a2a/client/transports/grpc.py
index c5edf7a1..6a8b16f9 100644
--- a/src/a2a/client/transports/grpc.py
+++ b/src/a2a/client/transports/grpc.py
@@ -237,7 +237,7 @@ async def get_card(
metadata=self._get_grpc_metadata(extensions),
)
card = proto_utils.FromProto.agent_card(card_pb)
- if signature_verifier is not None:
+ if signature_verifier:
signature_verifier(card)
self.agent_card = card
diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py
index 54c758ff..a565e640 100644
--- a/src/a2a/client/transports/jsonrpc.py
+++ b/src/a2a/client/transports/jsonrpc.py
@@ -390,9 +390,10 @@ async def get_card(
if not card:
resolver = A2ACardResolver(self.httpx_client, self.url)
- card = await resolver.get_agent_card(http_kwargs=modified_kwargs)
- if signature_verifier is not None:
- signature_verifier(card)
+ card = await resolver.get_agent_card(
+ http_kwargs=modified_kwargs,
+ signature_verifier=signature_verifier,
+ )
self._needs_extended_card = (
card.supports_authenticated_extended_card
)
@@ -418,7 +419,7 @@ async def get_card(
if isinstance(response.root, JSONRPCErrorResponse):
raise A2AClientJSONRPCError(response.root)
card = response.root.result
- if signature_verifier is not None:
+ if signature_verifier:
signature_verifier(card)
self.agent_card = card
diff --git a/src/a2a/client/transports/rest.py b/src/a2a/client/transports/rest.py
index 1649be1c..afc9dd08 100644
--- a/src/a2a/client/transports/rest.py
+++ b/src/a2a/client/transports/rest.py
@@ -382,9 +382,10 @@ async def get_card(
if not card:
resolver = A2ACardResolver(self.httpx_client, self.url)
- card = await resolver.get_agent_card(http_kwargs=modified_kwargs)
- if signature_verifier is not None:
- signature_verifier(card)
+ card = await resolver.get_agent_card(
+ http_kwargs=modified_kwargs,
+ signature_verifier=signature_verifier,
+ )
self._needs_extended_card = (
card.supports_authenticated_extended_card
)
@@ -402,7 +403,7 @@ async def get_card(
'/v1/card', {}, modified_kwargs
)
card = AgentCard.model_validate(response_data)
- if signature_verifier is not None:
+ if signature_verifier:
signature_verifier(card)
self.agent_card = card
diff --git a/tests/client/test_card_resolver.py b/tests/client/test_card_resolver.py
index f87d9450..26f3f106 100644
--- a/tests/client/test_card_resolver.py
+++ b/tests/client/test_card_resolver.py
@@ -1,7 +1,7 @@
import json
import logging
-from unittest.mock import AsyncMock, Mock, patch
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
@@ -371,9 +371,30 @@ async def test_get_agent_card_returns_agent_card_instance(
self, resolver, mock_httpx_client, mock_response, valid_agent_card_data
):
"""Test that get_agent_card returns an AgentCard instance."""
+ mock_response.json.return_value = valid_agent_card_data
+ mock_httpx_client.get.return_value = mock_response
mock_agent_card = Mock(spec=AgentCard)
+
with patch.object(
AgentCard, 'model_validate', return_value=mock_agent_card
):
result = await resolver.get_agent_card()
assert result == mock_agent_card
+ mock_response.raise_for_status.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_get_agent_card_with_signature_verifier(
+ self, resolver, mock_httpx_client, valid_agent_card_data
+ ):
+ """Test that the signature verifier is called if provided."""
+ mock_verifier = MagicMock()
+
+ mock_response = MagicMock(spec=httpx.Response)
+ mock_response.json.return_value = valid_agent_card_data
+ mock_httpx_client.get.return_value = mock_response
+
+ agent_card = await resolver.get_agent_card(
+ signature_verifier=mock_verifier
+ )
+
+ mock_verifier.assert_called_once_with(agent_card)
diff --git a/tests/client/test_client_factory.py b/tests/client/test_client_factory.py
index 16a1433f..c388974b 100644
--- a/tests/client/test_client_factory.py
+++ b/tests/client/test_client_factory.py
@@ -190,6 +190,7 @@ async def test_client_factory_connect_with_resolver_args(
mock_resolver.return_value.get_agent_card.assert_awaited_once_with(
relative_card_path=relative_path,
http_kwargs=http_kwargs,
+ signature_verifier=None,
)
@@ -216,6 +217,7 @@ async def test_client_factory_connect_resolver_args_without_client(
mock_resolver.return_value.get_agent_card.assert_awaited_once_with(
relative_card_path=relative_path,
http_kwargs=http_kwargs,
+ signature_verifier=None,
)
From 86c6759ce209db5575d6cf9c6e596d1cb6bf6aa1 Mon Sep 17 00:00:00 2001
From: "Agent2Agent (A2A) Bot"
Date: Tue, 16 Dec 2025 12:38:28 -0600
Subject: [PATCH 19/29] chore(main): release 0.3.22 (#599)
:robot: I have created a release *beep* *boop*
---
##
[0.3.22](https://github.com/a2aproject/a2a-python/compare/v0.3.21...v0.3.22)
(2025-12-16)
### Features
* Add custom ID generators to `SimpleRequestContextBuilder`
([#594](https://github.com/a2aproject/a2a-python/issues/594))
([04bcafc](https://github.com/a2aproject/a2a-python/commit/04bcafc737cf426d9975c76e346335ff992363e2))
### Code Refactoring
* Move agent card signature verification into `A2ACardResolver`
([6fa6a6c](https://github.com/a2aproject/a2a-python/commit/6fa6a6cf3875bdf7bfc51fb1a541a3f3e8381dc0))
---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
---
CHANGELOG.md | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 966fe3df..cfbedf4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# Changelog
+## [0.3.22](https://github.com/a2aproject/a2a-python/compare/v0.3.21...v0.3.22) (2025-12-16)
+
+
+### Features
+
+* Add custom ID generators to SimpleRequestContextBuilder ([#594](https://github.com/a2aproject/a2a-python/issues/594)) ([04bcafc](https://github.com/a2aproject/a2a-python/commit/04bcafc737cf426d9975c76e346335ff992363e2))
+
+
+### Code Refactoring
+
+* Move agent card signature verification into `A2ACardResolver` ([6fa6a6c](https://github.com/a2aproject/a2a-python/commit/6fa6a6cf3875bdf7bfc51fb1a541a3f3e8381dc0))
+
## [0.3.21](https://github.com/a2aproject/a2a-python/compare/v0.3.20...v0.3.21) (2025-12-12)
From df78a94727217718220443bf5ad27fa662045974 Mon Sep 17 00:00:00 2001
From: Didier Durand <2927957+didier-durand@users.noreply.github.com>
Date: Mon, 5 Jan 2026 12:32:03 +0100
Subject: [PATCH 20/29] test: adding 13 tests for id_generator.py (#591)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Description
Adding 13 tests for server/tasks/id_generator.py
They all pass:
```
============================= test session starts ==============================
collecting ... collected 13 items
tests/server/tasks/test_id_generator.py::TestIDGeneratorContext::test_context_creation_with_all_fields PASSED [ 7%]
tests/server/tasks/test_id_generator.py::TestIDGeneratorContext::test_context_creation_with_defaults PASSED [ 15%]
tests/server/tasks/test_id_generator.py::TestIDGeneratorContext::test_context_creation_with_partial_fields[kwargs0-task_123-None] PASSED [ 23%]
tests/server/tasks/test_id_generator.py::TestIDGeneratorContext::test_context_creation_with_partial_fields[kwargs1-None-context_456] PASSED [ 30%]
tests/server/tasks/test_id_generator.py::TestIDGeneratorContext::test_context_mutability PASSED [ 38%]
tests/server/tasks/test_id_generator.py::TestIDGeneratorContext::test_context_validation PASSED [ 46%]
tests/server/tasks/test_id_generator.py::TestIDGeneratorContext::TestIDGenerator::test_cannot_instantiate_abstract_class PASSED [ 53%]
tests/server/tasks/test_id_generator.py::TestIDGeneratorContext::TestIDGenerator::test_subclass_must_implement_generate PASSED [ 61%]
tests/server/tasks/test_id_generator.py::TestIDGeneratorContext::TestIDGenerator::test_valid_subclass_implementation PASSED [ 69%]
tests/server/tasks/test_id_generator.py::TestUUIDGenerator::test_generate_returns_string PASSED [ 76%]
tests/server/tasks/test_id_generator.py::TestUUIDGenerator::test_generate_produces_unique_ids PASSED [ 84%]
tests/server/tasks/test_id_generator.py::TestUUIDGenerator::test_generate_works_with_various_contexts[none_context] PASSED [ 92%]
tests/server/tasks/test_id_generator.py::TestUUIDGenerator::test_generate_works_with_various_contexts[empty_context] PASSED [100%]
============================== 13 passed in 0.04s ==============================
```
- [X] Follow the [`CONTRIBUTING`
Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
- [X] Make your Pull Request title in the
specification.
- [X] Ensure the tests and linter pass (Run `bash scripts/format.sh`
from the repository root to format)
- [N/A] Appropriate docs were updated (if necessary)
Fixes # š¦
N/A
---------
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Lukasz Kawka
---
tests/server/tasks/test_id_generator.py | 131 ++++++++++++++++++++++++
1 file changed, 131 insertions(+)
create mode 100644 tests/server/tasks/test_id_generator.py
diff --git a/tests/server/tasks/test_id_generator.py b/tests/server/tasks/test_id_generator.py
new file mode 100644
index 00000000..11bfff2b
--- /dev/null
+++ b/tests/server/tasks/test_id_generator.py
@@ -0,0 +1,131 @@
+import uuid
+
+import pytest
+
+from pydantic import ValidationError
+
+from a2a.server.id_generator import (
+ IDGenerator,
+ IDGeneratorContext,
+ UUIDGenerator,
+)
+
+
+class TestIDGeneratorContext:
+ """Tests for IDGeneratorContext."""
+
+ def test_context_creation_with_all_fields(self):
+ """Test creating context with all fields populated."""
+ context = IDGeneratorContext(
+ task_id='task_123', context_id='context_456'
+ )
+ assert context.task_id == 'task_123'
+ assert context.context_id == 'context_456'
+
+ def test_context_creation_with_defaults(self):
+ """Test creating context with default None values."""
+ context = IDGeneratorContext()
+ assert context.task_id is None
+ assert context.context_id is None
+
+ @pytest.mark.parametrize(
+ 'kwargs, expected_task_id, expected_context_id',
+ [
+ ({'task_id': 'task_123'}, 'task_123', None),
+ ({'context_id': 'context_456'}, None, 'context_456'),
+ ],
+ )
+ def test_context_creation_with_partial_fields(
+ self, kwargs, expected_task_id, expected_context_id
+ ):
+ """Test creating context with only some fields populated."""
+ context = IDGeneratorContext(**kwargs)
+ assert context.task_id == expected_task_id
+ assert context.context_id == expected_context_id
+
+ def test_context_mutability(self):
+ """Test that context fields can be updated (Pydantic models are mutable by default)."""
+ context = IDGeneratorContext(task_id='task_123')
+ context.task_id = 'task_456'
+ assert context.task_id == 'task_456'
+
+ def test_context_validation(self):
+ """Test that context raises validation error for invalid types."""
+ with pytest.raises(ValidationError):
+ IDGeneratorContext(task_id={'not': 'a string'})
+
+
+class TestIDGenerator:
+ """Tests for IDGenerator abstract base class."""
+
+ def test_cannot_instantiate_abstract_class(self):
+ """Test that IDGenerator cannot be instantiated directly."""
+ with pytest.raises(TypeError):
+ IDGenerator()
+
+ def test_subclass_must_implement_generate(self):
+ """Test that subclasses must implement the generate method."""
+
+ class IncompleteGenerator(IDGenerator):
+ pass
+
+ with pytest.raises(TypeError):
+ IncompleteGenerator()
+
+ def test_valid_subclass_implementation(self):
+ """Test that a valid subclass can be instantiated."""
+
+ class ValidGenerator(IDGenerator): # pylint: disable=C0115,R0903
+ def generate(self, context: IDGeneratorContext) -> str:
+ return 'test_id'
+
+ generator = ValidGenerator()
+ assert generator.generate(IDGeneratorContext()) == 'test_id'
+
+
+@pytest.fixture
+def generator():
+ """Returns a UUIDGenerator instance."""
+ return UUIDGenerator()
+
+
+@pytest.fixture
+def context():
+ """Returns a IDGeneratorContext instance."""
+ return IDGeneratorContext()
+
+
+class TestUUIDGenerator:
+ """Tests for UUIDGenerator implementation."""
+
+ def test_generate_returns_string(self, generator, context):
+ """Test that generate returns a valid v4 UUID string."""
+ result = generator.generate(context)
+ assert isinstance(result, str)
+ parsed_uuid = uuid.UUID(result)
+ assert parsed_uuid.version == 4
+
+ def test_generate_produces_unique_ids(self, generator, context):
+ """Test that multiple calls produce unique IDs."""
+ ids = [generator.generate(context) for _ in range(100)]
+ # All IDs should be unique
+ assert len(ids) == len(set(ids))
+
+ @pytest.mark.parametrize(
+ 'context_arg',
+ [
+ None,
+ IDGeneratorContext(),
+ ],
+ ids=[
+ 'none_context',
+ 'empty_context',
+ ],
+ )
+ def test_generate_works_with_various_contexts(self, context_arg):
+ """Test that generate works with various context inputs."""
+ generator = UUIDGenerator()
+ result = generator.generate(context_arg)
+ assert isinstance(result, str)
+ parsed_uuid = uuid.UUID(result)
+ assert parsed_uuid.version == 4
From cb7cdb34ad11dd4006305ad008953ddd7b4e27f5 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 5 Jan 2026 12:41:31 +0100
Subject: [PATCH 21/29] chore(deps): bump the github-actions group across 1
directory with 4 updates (#603)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the github-actions group with 4 updates in the / directory:
[actions/checkout](https://github.com/actions/checkout),
[actions/upload-artifact](https://github.com/actions/upload-artifact),
[actions/download-artifact](https://github.com/actions/download-artifact)
and
[peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request).
Updates `actions/checkout` from 5 to 6
Release notes
Sourced from actions/checkout's
releases.
v6.0.0
What's Changed
Full Changelog: https://github.com/actions/checkout/compare/v5.0.0...v6.0.0
v6-beta
What's Changed
Updated persist-credentials to store the credentials under
$RUNNER_TEMP instead of directly in the local git
config.
This requires a minimum Actions Runner version of v2.329.0
to access the persisted credentials for Docker
container action scenarios.
v5.0.1
What's Changed
Full Changelog: https://github.com/actions/checkout/compare/v5...v5.0.1
Changelog
Sourced from actions/checkout's
changelog.
Changelog
v6.0.0
v5.0.1
v5.0.0
v4.3.1
v4.3.0
v4.2.2
v4.2.1
v4.2.0
v4.1.7
v4.1.6
v4.1.5
... (truncated)
Commits
Updates `actions/upload-artifact` from 5 to 6
Release notes
Sourced from actions/upload-artifact's
releases.
v6.0.0
v6 - What's new
[!IMPORTANT]
actions/upload-artifact@v6 now runs on Node.js 24 (runs.using:
node24) and requires a minimum Actions Runner version of 2.327.1.
If you are using self-hosted runners, ensure they are updated before
upgrading.
Node.js 24
This release updates the runtime to Node.js 24. v5 had preliminary
support for Node.js 24, however this action was by default still running
on Node.js 20. Now this action by default will run on Node.js 24.
What's Changed
Full Changelog: https://github.com/actions/upload-artifact/compare/v5.0.0...v6.0.0
Commits
b7c566a
Merge pull request #745
from actions/upload-artifact-v6-release
e516bc8
docs: correct description of Node.js 24 support in README
ddc45ed
docs: update README to correct action name for Node.js 24 support
615b319
chore: release v6.0.0 for Node.js 24 support
017748b
Merge pull request #744
from actions/fix-storage-blob
38d4c79
chore: rebuild dist
7d27270
chore: add missing license cache files for @āactions/core,
@āactions/io, and mi...
5f643d3
chore: update license files for @āactions/artifact@ā5.0.1 dependencies
1df1684
chore: update package-lock.json with @āactions/artifact@ā5.0.1
b5b1a91
fix: update @āactions/artifact to ^5.0.0 for Node.js 24
punycode fix
- Additional commits viewable in compare
view
Updates `actions/download-artifact` from 6 to 7
Release notes
Sourced from actions/download-artifact's
releases.
v7.0.0
v7 - What's new
[!IMPORTANT]
actions/download-artifact@v7 now runs on Node.js 24 (runs.using:
node24) and requires a minimum Actions Runner version of 2.327.1.
If you are using self-hosted runners, ensure they are updated before
upgrading.
Node.js 24
This release updates the runtime to Node.js 24. v6 had preliminary
support for Node 24, however this action was by default still running on
Node.js 20. Now this action by default will run on Node.js 24.
What's Changed
New Contributors
Full Changelog: https://github.com/actions/download-artifact/compare/v6.0.0...v7.0.0
Commits
37930b1
Merge pull request #452
from actions/download-artifact-v7-release
72582b9
doc: update readme
0d2ec9d
chore: release v7.0.0 for Node.js 24 support
fd7ae8f
Merge pull request #451
from actions/fix-storage-blob
d484700
chore: restore minimatch.dep.yml license file
03a8080
chore: remove obsolete dependency license files
56fe6d9
chore: update @āactions/artifact license file to 5.0.1
8e3ebc4
chore: update package-lock.json with @āactions/artifact@ā5.0.1
1e3c4b4
fix: update @āactions/artifact to ^5.0.0 for Node.js 24
punycode fix
458627d
chore: use local @āactions/artifact package for Node.js 24
testing
- Additional commits viewable in compare
view
Updates `peter-evans/create-pull-request` from 7 to 8
Release notes
Sourced from peter-evans/create-pull-request's
releases.
Create Pull Request v8.0.0
What's new in v8
What's Changed
New Contributors
Full Changelog: https://github.com/peter-evans/create-pull-request/compare/v7.0.11...v8.0.0
Create Pull Request v7.0.11
What's Changed
Full Changelog: https://github.com/peter-evans/create-pull-request/compare/v7.0.10...v7.0.11
Create Pull Request v7.0.10
āļø Fixes an issue where updating a pull request failed when targeting
a forked repository with the same owner as its parent.
What's Changed
New Contributors
Full Changelog: https://github.com/peter-evans/create-pull-request/compare/v7.0.9...v7.0.10
Create Pull Request v7.0.9
āļø Fixes an incompatibility
with the recently released actions/checkout@v6.
What's Changed
New Contributors
... (truncated)
Commits
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lukasz Kawka
---
.github/workflows/linter.yaml | 2 +-
.github/workflows/python-publish.yml | 6 +++---
.github/workflows/unit-tests.yml | 2 +-
.github/workflows/update-a2a-types.yml | 4 ++--
4 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml
index bdd4c5b8..97bba6b6 100644
--- a/.github/workflows/linter.yaml
+++ b/.github/workflows/linter.yaml
@@ -12,7 +12,7 @@ jobs:
if: github.repository == 'a2aproject/a2a-python'
steps:
- name: Checkout Code
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index decb3b1d..c6e6da0f 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -26,7 +26,7 @@ jobs:
run: uv build
- name: Upload distributions
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: release-dists
path: dist/
@@ -40,7 +40,7 @@ jobs:
steps:
- name: Retrieve release distributions
- uses: actions/download-artifact@v6
+ uses: actions/download-artifact@v7
with:
name: release-dists
path: dist/
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 16052ba1..eb5b3d1f 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -39,7 +39,7 @@ jobs:
python-version: ['3.10', '3.13']
steps:
- name: Checkout code
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: Set up test environment variables
run: |
echo "POSTGRES_TEST_DSN=postgresql+asyncpg://a2a:a2a_password@localhost:5432/a2a_test" >> $GITHUB_ENV
diff --git a/.github/workflows/update-a2a-types.yml b/.github/workflows/update-a2a-types.yml
index c019afeb..e1adbd34 100644
--- a/.github/workflows/update-a2a-types.yml
+++ b/.github/workflows/update-a2a-types.yml
@@ -12,7 +12,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout code
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
@@ -42,7 +42,7 @@ jobs:
uv run scripts/grpc_gen_post_processor.py
echo "Buf generate finished."
- name: Create Pull Request with Updates
- uses: peter-evans/create-pull-request@v7
+ uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.A2A_BOT_PAT }}
committer: a2a-bot
From fdbf22f2e6c585b3b51712676aaf2bcd9e9fb720 Mon Sep 17 00:00:00 2001
From: Ivan Shymko
Date: Tue, 20 Jan 2026 17:24:25 +0100
Subject: [PATCH 22/29] ci: disable automatic PRs for spec updates (#628)
# Description
Currently it creates a lot of noise from broken PRs:
https://github.com/a2aproject/a2a-python/pulls/a2a-bot.
While we're migrating to 1.0 spec we're not benefiting from this
automation. `workflow_dispatch` (manual run) is still available.
---
.github/workflows/update-a2a-types.yml | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/update-a2a-types.yml b/.github/workflows/update-a2a-types.yml
index e1adbd34..641076f2 100644
--- a/.github/workflows/update-a2a-types.yml
+++ b/.github/workflows/update-a2a-types.yml
@@ -1,8 +1,9 @@
---
name: Update A2A Schema from Specification
on:
- repository_dispatch:
- types: [a2a_json_update]
+# TODO (https://github.com/a2aproject/a2a-python/issues/559): bring back once types are migrated, currently it generates many broken PRs
+# repository_dispatch:
+# types: [a2a_json_update]
workflow_dispatch:
jobs:
generate_and_pr:
From 1b361b55e2221f2433bd892ce739f215bedd3ddb Mon Sep 17 00:00:00 2001
From: Ivan Shymko
Date: Wed, 21 Jan 2026 17:12:55 +0100
Subject: [PATCH 23/29] ci: fix not committed uv.lock and use uv sync --locked
(#637)
# Description
Doing `uv sync` now updates `uv.lock` with `pyjwt` added in #581. Commit
updated file and update CI to fail on inconsistent `uv.lock` via
`--locked`.
Tested via
https://github.com/a2aproject/a2a-python/pull/637/changes/e6df935ad3185f1c203806cfb98aecfb92417825:
[failed CI
run](https://github.com/a2aproject/a2a-python/actions/runs/21206882696/job/61005414117?pr=637).
---
.github/workflows/linter.yaml | 2 +-
.github/workflows/unit-tests.yml | 2 +-
.github/workflows/update-a2a-types.yml | 2 +-
uv.lock | 19 ++++++++++++++++++-
4 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml
index 97bba6b6..5ddbfea5 100644
--- a/.github/workflows/linter.yaml
+++ b/.github/workflows/linter.yaml
@@ -23,7 +23,7 @@ jobs:
run: |
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install dependencies
- run: uv sync --dev
+ run: uv sync --locked --dev
- name: Run Ruff Linter
id: ruff-lint
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index eb5b3d1f..9ef0f12f 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -53,7 +53,7 @@ jobs:
run: |
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install dependencies
- run: uv sync --dev --extra all
+ run: uv sync --locked --dev --extra all
- name: Run tests and check coverage
run: uv run pytest --cov=a2a --cov-report term --cov-fail-under=88
- name: Show coverage summary in log
diff --git a/.github/workflows/update-a2a-types.yml b/.github/workflows/update-a2a-types.yml
index 641076f2..1c752114 100644
--- a/.github/workflows/update-a2a-types.yml
+++ b/.github/workflows/update-a2a-types.yml
@@ -23,7 +23,7 @@ jobs:
- name: Configure uv shell
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install dependencies (datamodel-code-generator)
- run: uv sync
+ run: uv sync --locked
- name: Define output file variable
id: vars
run: |
diff --git a/uv.lock b/uv.lock
index 5003ac40..96abbe5c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -26,6 +26,7 @@ all = [
{ name = "grpcio-tools" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-sdk" },
+ { name = "pyjwt" },
{ name = "sqlalchemy", extra = ["aiomysql", "aiosqlite", "asyncio", "postgresql-asyncpg"] },
{ name = "sse-starlette" },
{ name = "starlette" },
@@ -49,6 +50,9 @@ mysql = [
postgresql = [
{ name = "sqlalchemy", extra = ["asyncio", "postgresql-asyncpg"] },
]
+signing = [
+ { name = "pyjwt" },
+]
sql = [
{ name = "sqlalchemy", extra = ["aiomysql", "aiosqlite", "asyncio", "postgresql-asyncpg"] },
]
@@ -68,6 +72,7 @@ dev = [
{ name = "mypy" },
{ name = "no-implicit-optional" },
{ name = "pre-commit" },
+ { name = "pyjwt" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
@@ -105,6 +110,8 @@ requires-dist = [
{ name = "opentelemetry-sdk", marker = "extra == 'telemetry'", specifier = ">=1.33.0" },
{ name = "protobuf", specifier = ">=5.29.5" },
{ name = "pydantic", specifier = ">=2.11.3" },
+ { name = "pyjwt", marker = "extra == 'all'", specifier = ">=2.0.0" },
+ { name = "pyjwt", marker = "extra == 'signing'", specifier = ">=2.0.0" },
{ name = "sqlalchemy", extras = ["aiomysql", "asyncio"], marker = "extra == 'all'", specifier = ">=2.0.0" },
{ name = "sqlalchemy", extras = ["aiomysql", "asyncio"], marker = "extra == 'mysql'", specifier = ">=2.0.0" },
{ name = "sqlalchemy", extras = ["aiomysql", "asyncio"], marker = "extra == 'sql'", specifier = ">=2.0.0" },
@@ -119,7 +126,7 @@ requires-dist = [
{ name = "starlette", marker = "extra == 'all'" },
{ name = "starlette", marker = "extra == 'http-server'" },
]
-provides-extras = ["all", "encryption", "grpc", "http-server", "mysql", "postgresql", "sql", "sqlite", "telemetry"]
+provides-extras = ["all", "encryption", "grpc", "http-server", "mysql", "postgresql", "signing", "sql", "sqlite", "telemetry"]
[package.metadata.requires-dev]
dev = [
@@ -129,6 +136,7 @@ dev = [
{ name = "mypy", specifier = ">=1.15.0" },
{ name = "no-implicit-optional" },
{ name = "pre-commit" },
+ { name = "pyjwt", specifier = ">=2.0.0" },
{ name = "pytest", specifier = ">=8.3.5" },
{ name = "pytest-asyncio", specifier = ">=0.26.0" },
{ name = "pytest-cov", specifier = ">=6.1.1" },
@@ -1534,6 +1542,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
+[[package]]
+name = "pyjwt"
+version = "2.10.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
+]
+
[[package]]
name = "pymysql"
version = "1.1.1"
From 6e26ae1a71dd3870ba3e3ea4adfc22991bc3e2bd Mon Sep 17 00:00:00 2001
From: Ivan Shymko
Date: Thu, 22 Jan 2026 14:12:52 +0100
Subject: [PATCH 24/29] ci: run mandatory and capabilities TCK tests for
JSON-RPC transport (#638)
# Description
Run TCK tests with `mandatory` and `capabilities` categories for
JSON-RPC transport (GRPC and REST fail at the moment).
Re #639
---------
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com>
---
.github/actions/spelling/allow.txt | 4 +-
.github/workflows/run-tck.yaml | 106 ++++++++++++++++
tck/__init__.py | 0
tck/sut_agent.py | 186 +++++++++++++++++++++++++++++
4 files changed, 295 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/run-tck.yaml
create mode 100644 tck/__init__.py
create mode 100644 tck/sut_agent.py
diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt
index 27b5cb4c..11496c9f 100644
--- a/.github/actions/spelling/allow.txt
+++ b/.github/actions/spelling/allow.txt
@@ -52,8 +52,8 @@ JPY
JSONRPCt
jwk
jwks
-JWS
jws
+JWS
kid
kwarg
langgraph
@@ -83,6 +83,8 @@ RUF
SLF
socio
sse
+sut
+SUT
tagwords
taskupdate
testuuid
diff --git a/.github/workflows/run-tck.yaml b/.github/workflows/run-tck.yaml
new file mode 100644
index 00000000..0f3452b3
--- /dev/null
+++ b/.github/workflows/run-tck.yaml
@@ -0,0 +1,106 @@
+name: Run TCK
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+ paths-ignore:
+ - '**.md'
+ - 'LICENSE'
+ - '.github/CODEOWNERS'
+
+permissions:
+ contents: read
+
+env:
+ TCK_VERSION: 0.3.0.beta3
+ SUT_BASE_URL: http://localhost:41241
+ SUT_JSONRPC_URL: http://localhost:41241/a2a/jsonrpc
+ UV_SYSTEM_PYTHON: 1
+ TCK_STREAMING_TIMEOUT: 5.0
+
+concurrency:
+ group: '${{ github.workflow }} @ ${{ github.head_ref || github.ref }}'
+ cancel-in-progress: true
+
+jobs:
+ tck-test:
+ name: Run TCK with Python ${{ matrix.python-version }}
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ['3.10', '3.13']
+ steps:
+ - name: Checkout a2a-python
+ uses: actions/checkout@v6
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ enable-cache: true
+ cache-dependency-glob: "uv.lock"
+
+ - name: Set up Python ${{ matrix.python-version }}
+ run: uv python install ${{ matrix.python-version }}
+
+ - name: Install Dependencies
+ run: uv sync --locked --all-extras
+
+ - name: Checkout a2a-tck
+ uses: actions/checkout@v6
+ with:
+ repository: a2aproject/a2a-tck
+ path: tck/a2a-tck
+ ref: ${{ env.TCK_VERSION }}
+
+ - name: Start SUT
+ run: |
+ uv run tck/sut_agent.py &
+
+ - name: Wait for SUT to start
+ run: |
+ URL="${{ env.SUT_BASE_URL }}/.well-known/agent-card.json"
+ EXPECTED_STATUS=200
+ TIMEOUT=120
+ RETRY_INTERVAL=2
+ START_TIME=$(date +%s)
+
+ while true; do
+ CURRENT_TIME=$(date +%s)
+ ELAPSED_TIME=$((CURRENT_TIME - START_TIME))
+
+ if [ "$ELAPSED_TIME" -ge "$TIMEOUT" ]; then
+ echo "ā Timeout: Server did not respond with status $EXPECTED_STATUS within $TIMEOUT seconds."
+ exit 1
+ fi
+
+ HTTP_STATUS=$(curl --output /dev/null --silent --write-out "%{http_code}" "$URL") || true
+ echo "STATUS: ${HTTP_STATUS}"
+
+ if [ "$HTTP_STATUS" -eq "$EXPECTED_STATUS" ]; then
+ echo "ā
Server is up! Received status $HTTP_STATUS after $ELAPSED_TIME seconds."
+ break;
+ fi
+
+ echo "ā³ Server not ready (status: $HTTP_STATUS). Retrying in $RETRY_INTERVAL seconds..."
+ sleep "$RETRY_INTERVAL"
+ done
+
+ - name: Run TCK (mandatory)
+ id: run-tck-mandatory
+ run: |
+ uv run run_tck.py --sut-url ${{ env.SUT_JSONRPC_URL }} --category mandatory --transports jsonrpc
+ working-directory: tck/a2a-tck
+
+ - name: Run TCK (capabilities)
+ id: run-tck-capabilities
+ run: |
+ uv run run_tck.py --sut-url ${{ env.SUT_JSONRPC_URL }} --category capabilities --transports jsonrpc
+ working-directory: tck/a2a-tck
+
+ - name: Stop SUT
+ if: always()
+ run: |
+ pkill -f sut_agent.py || true
+ sleep 2
diff --git a/tck/__init__.py b/tck/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tck/sut_agent.py b/tck/sut_agent.py
new file mode 100644
index 00000000..525631ca
--- /dev/null
+++ b/tck/sut_agent.py
@@ -0,0 +1,186 @@
+import asyncio
+import logging
+import os
+import uuid
+
+from datetime import datetime, timezone
+
+import uvicorn
+
+from a2a.server.agent_execution.agent_executor import AgentExecutor
+from a2a.server.agent_execution.context import RequestContext
+from a2a.server.apps import A2AStarletteApplication
+from a2a.server.events.event_queue import EventQueue
+from a2a.server.request_handlers.default_request_handler import (
+ DefaultRequestHandler,
+)
+from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore
+from a2a.types import (
+ AgentCapabilities,
+ AgentCard,
+ AgentProvider,
+ Message,
+ TaskState,
+ TaskStatus,
+ TaskStatusUpdateEvent,
+ TextPart,
+)
+
+
+JSONRPC_URL = '/a2a/jsonrpc'
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger('SUTAgent')
+
+
+class SUTAgentExecutor(AgentExecutor):
+ """Execution logic for the SUT agent."""
+
+ def __init__(self) -> None:
+ """Initializes the SUT agent executor."""
+ self.running_tasks = set()
+
+ async def cancel(
+ self, context: RequestContext, event_queue: EventQueue
+ ) -> None:
+ """Cancels a task."""
+ api_task_id = context.task_id
+ if api_task_id in self.running_tasks:
+ self.running_tasks.remove(api_task_id)
+
+ status_update = TaskStatusUpdateEvent(
+ task_id=api_task_id,
+ context_id=context.context_id or str(uuid.uuid4()),
+ status=TaskStatus(
+ state=TaskState.canceled,
+ timestamp=datetime.now(timezone.utc).isoformat(),
+ ),
+ final=True,
+ )
+ await event_queue.enqueue_event(status_update)
+
+ async def execute(
+ self, context: RequestContext, event_queue: EventQueue
+ ) -> None:
+ """Executes a task."""
+ user_message = context.message
+ task_id = context.task_id
+ context_id = context.context_id
+
+ self.running_tasks.add(task_id)
+
+ logger.info(
+ '[SUTAgentExecutor] Processing message %s for task %s (context: %s)',
+ user_message.message_id,
+ task_id,
+ context_id,
+ )
+
+ working_status = TaskStatusUpdateEvent(
+ task_id=task_id,
+ context_id=context_id,
+ status=TaskStatus(
+ state=TaskState.working,
+ message=Message(
+ role='agent',
+ message_id=str(uuid.uuid4()),
+ parts=[TextPart(text='Processing your question')],
+ task_id=task_id,
+ context_id=context_id,
+ ),
+ timestamp=datetime.now(timezone.utc).isoformat(),
+ ),
+ final=False,
+ )
+ await event_queue.enqueue_event(working_status)
+
+ agent_reply_text = 'Hello world!'
+ await asyncio.sleep(3) # Simulate processing delay
+
+ if task_id not in self.running_tasks:
+ logger.info('Task %s was cancelled.', task_id)
+ return
+
+ logger.info('[SUTAgentExecutor] Response: %s', agent_reply_text)
+
+ agent_message = Message(
+ role='agent',
+ message_id=str(uuid.uuid4()),
+ parts=[TextPart(text=agent_reply_text)],
+ task_id=task_id,
+ context_id=context_id,
+ )
+
+ final_update = TaskStatusUpdateEvent(
+ task_id=task_id,
+ context_id=context_id,
+ status=TaskStatus(
+ state=TaskState.input_required,
+ message=agent_message,
+ timestamp=datetime.now(timezone.utc).isoformat(),
+ ),
+ final=True,
+ )
+ await event_queue.enqueue_event(final_update)
+
+
+def main() -> None:
+ """Main entrypoint."""
+ http_port = int(os.environ.get('HTTP_PORT', '41241'))
+
+ agent_card = AgentCard(
+ name='SUT Agent',
+ description='An agent to be used as SUT against TCK tests.',
+ url=f'http://localhost:{http_port}{JSONRPC_URL}',
+ provider=AgentProvider(
+ organization='A2A Samples',
+ url='https://example.com/a2a-samples',
+ ),
+ version='1.0.0',
+ protocol_version='0.3.0',
+ capabilities=AgentCapabilities(
+ streaming=True,
+ push_notifications=False,
+ state_transition_history=True,
+ ),
+ default_input_modes=['text'],
+ default_output_modes=['text', 'task-status'],
+ skills=[
+ {
+ 'id': 'sut_agent',
+ 'name': 'SUT Agent',
+ 'description': 'Simulate the general flow of a streaming agent.',
+ 'tags': ['sut'],
+ 'examples': ['hi', 'hello world', 'how are you', 'goodbye'],
+ 'input_modes': ['text'],
+ 'output_modes': ['text', 'task-status'],
+ }
+ ],
+ supports_authenticated_extended_card=False,
+ preferred_transport='JSONRPC',
+ additional_interfaces=[
+ {
+ 'url': f'http://localhost:{http_port}{JSONRPC_URL}',
+ 'transport': 'JSONRPC',
+ },
+ ],
+ )
+
+ request_handler = DefaultRequestHandler(
+ agent_executor=SUTAgentExecutor(),
+ task_store=InMemoryTaskStore(),
+ )
+
+ server = A2AStarletteApplication(
+ agent_card=agent_card,
+ http_handler=request_handler,
+ )
+
+ app = server.build(rpc_url=JSONRPC_URL)
+
+ logger.info('Starting HTTP server on port %s...', http_port)
+ uvicorn.run(app, host='127.0.0.1', port=http_port, log_level='info')
+
+
+if __name__ == '__main__':
+ main()
From c7a3de8f2e29403d6a6b8aaa376f8df76e1cdbec Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 22 Jan 2026 16:33:00 +0100
Subject: [PATCH 25/29] chore(deps): bump virtualenv from 20.32.0 to 20.36.1
(#634)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [virtualenv](https://github.com/pypa/virtualenv) from 20.32.0 to
20.36.1.
Release notes
Sourced from virtualenv's
releases.
20.36.1
What's Changed
Full Changelog: https://github.com/pypa/virtualenv/compare/20.36.0...20.36.1
20.36.0
What's Changed
New Contributors
Full Changelog: https://github.com/pypa/virtualenv/compare/20.35.3...20.36.0
20.35.4
What's Changed
New Contributors
Full Changelog: https://github.com/pypa/virtualenv/compare/20.35.3...20.35.4
20.35.3
... (truncated)
Changelog
Sourced from virtualenv's
changelog.
v20.36.1 (2026-01-09)
Bugfixes - 20.36.1
- Fix TOCTOU vulnerabilities in app_data and lock directory
creation that could be exploited via symlink attacks - reported by
:user:`tsigouris007`, fixed by :user:`gaborbernat`. (:issue:`3013`)
v20.36.0 (2026-01-07)
Features - 20.36.0
- Add support for PEP 440 version specifiers in the
--python flag. Users can now specify Python versions using
operators like >=, <=, ~=,
etc. For example: virtualenv --python=">=3.12"
myenv . (:issue:2994`)
v20.35.4 (2025-10-28)
Bugfixes - 20.35.4
- Fix race condition in ``_virtualenv.py`` when file is
overwritten during import, preventing ``NameError`` when
``_DISTUTILS_PATCH`` is accessed - by :user:`gracetyy`. (:issue:`2969`)
- Upgrade embedded wheels:
- pip to
25.3 from 25.2
(:issue:2989)
v20.35.3 (2025-10-10)
Bugfixes - 20.35.3
- Accept RuntimeError in
test_too_many_open_files, by
:user:esafak (:issue:2935)
v20.35.2 (2025-10-10)
Bugfixes - 20.35.2
- Revert out changes related to the extraction of the
discovery module - by :user:`gaborbernat`. (:issue:`2978`)
v20.35.1 (2025-10-09)
Bugfixes - 20.35.1
- Patch get_interpreter to handle missing cache and app_data - by
:user:
esafak (:issue:2972)
- Fix backwards incompatible changes to
PythonInfo - by
:user:gaborbernat. (:issue:2975)
v20.35.0 (2025-10-08)
Features - 20.35.0
... (truncated)
Commits
d0ad11d
release 20.36.1
dec4cec
Merge pull request #3013
from gaborbernat/fix-sec
5fe5d38
release 20.36.0 (#3011)
9719376
release 20.36.0
0276db6
Add support for PEP 440 version specifiers in the --python
flag. (#3008)
4f900c2
Fix Interpreter discovery bug wrt. Microsoft Store shortcut using
Latin-1 (#3...
13afcc6
fix: resolve EncodingWarning in tox upgrade environment (#3007)
31b5d31
[pre-commit.ci] pre-commit autoupdate (#2997)
7c28422
fix: update filelock dependency version to 3.20.1 to fix CVE
CVE-2025-68146 (...
365628c
test_too_many_open_files: assert on errno.EMFILE instead of
strerror (#3001)
- Additional commits viewable in compare
view
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/a2aproject/a2a-python/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Shymko
---
uv.lock | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/uv.lock b/uv.lock
index 96abbe5c..cc0edc4b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.13'",
@@ -700,11 +700,11 @@ wheels = [
[[package]]
name = "filelock"
-version = "3.18.0"
+version = "3.20.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
]
[[package]]
@@ -2055,16 +2055,17 @@ wheels = [
[[package]]
name = "virtualenv"
-version = "20.32.0"
+version = "20.36.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a9/96/0834f30fa08dca3738614e6a9d42752b6420ee94e58971d702118f7cfd30/virtualenv-20.32.0.tar.gz", hash = "sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0", size = 6076970, upload-time = "2025-07-21T04:09:50.985Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" },
]
[[package]]
From 2698cc04f15282fb358018f06bd88ae159d987b4 Mon Sep 17 00:00:00 2001
From: Holt Skinner <13262395+holtskinner@users.noreply.github.com>
Date: Thu, 22 Jan 2026 10:41:32 -0600
Subject: [PATCH 26/29] docs: Update README to include Code Wiki badge
---
README.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 4964376e..d7c24cbf 100644
--- a/README.md
+++ b/README.md
@@ -5,9 +5,10 @@

[](https://pypistats.org/packages/a2a-sdk)
[](https://github.com/a2aproject/a2a-python/actions/workflows/unit-tests.yml)
-[](https://deepwiki.com/a2aproject/a2a-python)
-
+
+
+

From 3dcb84772fdc8a4d3b63b518ed491e5ed3d38d0a Mon Sep 17 00:00:00 2001
From: Ivan Shymko
Date: Thu, 22 Jan 2026 17:45:50 +0100
Subject: [PATCH 27/29] fix: do not crash on SSE comment line (#636)
# Description
The cause is https://github.com/florimondmanca/httpx-sse/issues/35:
using comments among events with `id` field causes `httpx-sse` to emit
an event with empty data.
Although according to [the standard (item
2)](https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage)
empty `buffer` shouldn't produce an event, with the way how `httpx-sse`
API is defined it may still be reasonable to emit an object with just
`retry` field for instance so that consumer could handle it. [The
standard](https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation)
defines `retry` field handling outside of events dispatching, so in the
context of this library it's up to the client (see
[here](https://github.com/florimondmanca/httpx-sse?tab=readme-ov-file#handling-reconnections)).
That being said, even if comment handling bug is fixed, it still makes
sense to add this check against `data` field unconditionally without any
TODOs.
Tested by mocking one level lower to put `httpx-sse` under test as well
as it's an integration issue.
Fixes #540
---------
Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com>
---
src/a2a/client/transports/jsonrpc.py | 2 +
src/a2a/client/transports/rest.py | 2 +
.../client/transports/test_jsonrpc_client.py | 58 +++++++++++++++++
tests/client/transports/test_rest_client.py | 63 +++++++++++++++++++
4 files changed, 125 insertions(+)
diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py
index a565e640..a58a7cab 100644
--- a/src/a2a/client/transports/jsonrpc.py
+++ b/src/a2a/client/transports/jsonrpc.py
@@ -176,6 +176,8 @@ async def send_message_streaming(
try:
event_source.response.raise_for_status()
async for sse in event_source.aiter_sse():
+ if not sse.data:
+ continue
response = SendStreamingMessageResponse.model_validate(
json.loads(sse.data)
)
diff --git a/src/a2a/client/transports/rest.py b/src/a2a/client/transports/rest.py
index afc9dd08..96df1e02 100644
--- a/src/a2a/client/transports/rest.py
+++ b/src/a2a/client/transports/rest.py
@@ -154,6 +154,8 @@ async def send_message_streaming(
try:
event_source.response.raise_for_status()
async for sse in event_source.aiter_sse():
+ if not sse.data:
+ continue
event = a2a_pb2.StreamResponse()
Parse(sse.data, event)
yield proto_utils.FromProto.stream_response(event)
diff --git a/tests/client/transports/test_jsonrpc_client.py b/tests/client/transports/test_jsonrpc_client.py
index edbcd6c7..0f6bba5b 100644
--- a/tests/client/transports/test_jsonrpc_client.py
+++ b/tests/client/transports/test_jsonrpc_client.py
@@ -6,6 +6,7 @@
import httpx
import pytest
+import respx
from httpx_sse import EventSource, SSEError, ServerSentEvent
@@ -466,6 +467,63 @@ async def test_send_message_streaming_success(
== mock_stream_response_2.result.model_dump()
)
+ # Repro of https://github.com/a2aproject/a2a-python/issues/540
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_send_message_streaming_comment_success(
+ self,
+ mock_agent_card: MagicMock,
+ ):
+ async with httpx.AsyncClient() as client:
+ transport = JsonRpcTransport(
+ httpx_client=client, agent_card=mock_agent_card
+ )
+ params = MessageSendParams(
+ message=create_text_message_object(content='Hello stream')
+ )
+ mock_stream_response_1 = SendMessageSuccessResponse(
+ id='stream_id_123',
+ jsonrpc='2.0',
+ result=create_text_message_object(
+ content='First part', role=Role.agent
+ ),
+ )
+ mock_stream_response_2 = SendMessageSuccessResponse(
+ id='stream_id_123',
+ jsonrpc='2.0',
+ result=create_text_message_object(
+ content='Second part', role=Role.agent
+ ),
+ )
+
+ sse_content = (
+ 'id: stream_id_1\n'
+ f'data: {mock_stream_response_1.model_dump_json()}\n\n'
+ ': keep-alive\n\n'
+ 'id: stream_id_2\n'
+ f'data: {mock_stream_response_2.model_dump_json()}\n\n'
+ ': keep-alive\n\n'
+ )
+
+ respx.post(mock_agent_card.url).mock(
+ return_value=httpx.Response(
+ 200,
+ headers={'Content-Type': 'text/event-stream'},
+ content=sse_content,
+ )
+ )
+
+ results = [
+ item
+ async for item in transport.send_message_streaming(
+ request=params
+ )
+ ]
+
+ assert len(results) == 2
+ assert results[0] == mock_stream_response_1.result
+ assert results[1] == mock_stream_response_2.result
+
@pytest.mark.asyncio
async def test_send_request_http_status_error(
self, mock_httpx_client: AsyncMock, mock_agent_card: MagicMock
diff --git a/tests/client/transports/test_rest_client.py b/tests/client/transports/test_rest_client.py
index cd68b443..c889ebaf 100644
--- a/tests/client/transports/test_rest_client.py
+++ b/tests/client/transports/test_rest_client.py
@@ -3,18 +3,23 @@
import httpx
import pytest
+import respx
+from google.protobuf.json_format import MessageToJson
from httpx_sse import EventSource, ServerSentEvent
from a2a.client import create_text_message_object
from a2a.client.errors import A2AClientHTTPError
from a2a.client.transports.rest import RestTransport
from a2a.extensions.common import HTTP_EXTENSION_HEADER
+from a2a.grpc import a2a_pb2
from a2a.types import (
AgentCapabilities,
AgentCard,
MessageSendParams,
+ Role,
)
+from a2a.utils import proto_utils
@pytest.fixture
@@ -88,6 +93,64 @@ async def test_send_message_with_default_extensions(
},
)
+ # Repro of https://github.com/a2aproject/a2a-python/issues/540
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_send_message_streaming_comment_success(
+ self,
+ mock_agent_card: MagicMock,
+ ):
+ """Test that SSE comments are ignored."""
+ async with httpx.AsyncClient() as client:
+ transport = RestTransport(
+ httpx_client=client, agent_card=mock_agent_card
+ )
+ params = MessageSendParams(
+ message=create_text_message_object(content='Hello stream')
+ )
+
+ mock_stream_response_1 = a2a_pb2.StreamResponse(
+ msg=proto_utils.ToProto.message(
+ create_text_message_object(
+ content='First part', role=Role.agent
+ )
+ )
+ )
+ mock_stream_response_2 = a2a_pb2.StreamResponse(
+ msg=proto_utils.ToProto.message(
+ create_text_message_object(
+ content='Second part', role=Role.agent
+ )
+ )
+ )
+
+ sse_content = (
+ 'id: stream_id_1\n'
+ f'data: {MessageToJson(mock_stream_response_1, indent=None)}\n\n'
+ ': keep-alive\n\n'
+ 'id: stream_id_2\n'
+ f'data: {MessageToJson(mock_stream_response_2, indent=None)}\n\n'
+ ': keep-alive\n\n'
+ )
+
+ respx.post(
+ f'{mock_agent_card.url.rstrip("/")}/v1/message:stream'
+ ).mock(
+ return_value=httpx.Response(
+ 200,
+ headers={'Content-Type': 'text/event-stream'},
+ content=sse_content,
+ )
+ )
+
+ results = []
+ async for item in transport.send_message_streaming(request=params):
+ results.append(item)
+
+ assert len(results) == 2
+ assert results[0].parts[0].root.text == 'First part'
+ assert results[1].parts[0].root.text == 'Second part'
+
@pytest.mark.asyncio
@patch('a2a.client.transports.rest.aconnect_sse')
async def test_send_message_streaming_with_new_extensions(
From 0cf670df80fc1937d7218f5200fc84f6135a3c34 Mon Sep 17 00:00:00 2001
From: Holt Skinner <13262395+holtskinner@users.noreply.github.com>
Date: Thu, 22 Jan 2026 10:57:03 -0600
Subject: [PATCH 28/29] ci: Change uv dependabot to group all updates into
single PR
---
.github/dependabot.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 893d2b4b..c97edb12 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,7 +5,7 @@ updates:
schedule:
interval: 'monthly'
groups:
- uv-dependencies:
+ all:
patterns:
- '*'
- package-ecosystem: 'github-actions'
From 12fd75cf7fca19e9102ebe881b8a01451eecb20e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 22 Jan 2026 18:10:32 +0100
Subject: [PATCH 29/29] chore(deps): bump the all group with 27 updates (#642)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the all group with 27 updates:
| Package | From | To |
| --- | --- | --- |
| [httpx-sse](https://github.com/florimondmanca/httpx-sse) | `0.4.1` |
`0.4.3` |
| [pydantic](https://github.com/pydantic/pydantic) | `2.11.7` | `2.12.5`
|
| [protobuf](https://github.com/protocolbuffers/protobuf) | `5.29.5` |
`6.33.4` |
| [google-api-core](https://github.com/googleapis/python-api-core) |
`2.25.1` | `2.29.0` |
| [fastapi](https://github.com/fastapi/fastapi) | `0.116.1` | `0.128.0`
|
| [sse-starlette](https://github.com/sysid/sse-starlette) | `3.0.2` |
`3.2.0` |
| [starlette](https://github.com/Kludex/starlette) | `0.47.2` | `0.50.0`
|
| [cryptography](https://github.com/pyca/cryptography) | `45.0.5` |
`46.0.3` |
| [grpcio](https://github.com/grpc/grpc) | `1.74.0` | `1.76.0` |
| [grpcio-tools](https://github.com/grpc/grpc) | `1.71.2` | `1.74.0` |
| [grpcio-reflection](https://grpc.io) | `1.71.2` | `1.74.0` |
|
[opentelemetry-api](https://github.com/open-telemetry/opentelemetry-python)
| `1.36.0` | `1.39.1` |
|
[opentelemetry-sdk](https://github.com/open-telemetry/opentelemetry-python)
| `1.36.0` | `1.39.1` |
|
[datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator)
| `0.32.0` | `0.53.0` |
| [mypy](https://github.com/python/mypy) | `1.17.1` | `1.19.1` |
| [pytest](https://github.com/pytest-dev/pytest) | `8.4.1` | `9.0.2` |
| [pytest-asyncio](https://github.com/pytest-dev/pytest-asyncio) |
`1.1.0` | `1.3.0` |
| [pytest-cov](https://github.com/pytest-dev/pytest-cov) | `6.2.1` |
`7.0.0` |
| [pytest-mock](https://github.com/pytest-dev/pytest-mock) | `3.14.1` |
`3.15.1` |
| [ruff](https://github.com/astral-sh/ruff) | `0.12.8` | `0.14.13` |
|
[uv-dynamic-versioning](https://github.com/ninoseki/uv-dynamic-versioning)
| `0.8.2` | `0.13.0` |
| [types-protobuf](https://github.com/typeshed-internal/stub_uploader) |
`6.30.2.20250703` | `6.32.1.20251210` |
| [types-requests](https://github.com/typeshed-internal/stub_uploader) |
`2.32.4.20250611` | `2.32.4.20260107` |
| [pre-commit](https://github.com/pre-commit/pre-commit) | `4.2.0` |
`4.5.1` |
| [pyupgrade](https://github.com/asottile/pyupgrade) | `3.20.0` |
`3.21.2` |
| [trio](https://github.com/python-trio/trio) | `0.30.0` | `0.32.0` |
| [uvicorn](https://github.com/Kludex/uvicorn) | `0.38.0` | `0.40.0` |
Updates `httpx-sse` from 0.4.1 to 0.4.3
Release notes
Sourced from httpx-sse's
releases.
Version 0.4.3
0.4.3 - 2025-10-10
Fixed
- Fix performance issue introduced by the improved line parsing from
release 0.4.2. (Pull #40)
Version 0.4.2
0.4.2 - 2025-10-07
Fixed
- Fix incorrect newline parsing that was not compliant with SSE spec.
(Pull #37)
Changelog
Sourced from httpx-sse's
changelog.
0.4.3 - 2025-10-10
Fixed
- Fix performance issue introduced by the improved line parsing from
release 0.4.2. (Pull #40)
0.4.2 - 2025-10-07
Fixed
- Fix incorrect newline parsing that was not compliant with SSE spec.
(Pull #37)
Commits
Updates `pydantic` from 2.11.7 to 2.12.5
Release notes
Sourced from pydantic's
releases.
v2.12.5 2025-11-26
v2.12.5 (2025-11-26)
This is the fifth 2.12 patch release, addressing an issue with the
MISSING sentinel and providing several documentation
improvements.
The next 2.13 minor release will be published in a couple weeks, and
will include a new polymorphic serialization feature addressing
the remaining unexpected changes to the serialize as any
behavior.
- Fix pickle error when using
model_construct() on a
model with MISSING as a default value by @āornariece in #12522.
- Several updates to the documentation by
@āViicos.
Full Changelog: https://github.com/pydantic/pydantic/compare/v2.12.4...v2.12.5
v2.12.4 2025-11-05
v2.12.4 (2025-11-05)
This is the fourth 2.12 patch release, fixing more regressions, and
reverting a change in the build() method
of the AnyUrl
and Dsn types.
This patch release also fixes an issue with the serialization of IP
address types, when serialize_as_any is used. The next
patch release
will try to address the remaining issues with serialize as any
behavior by introducing a new polymorphic serialization
feature, that
should be used in most cases in place of serialize as any.
Full Changelog: https://github.com/pydantic/pydantic/compare/v2.12.3...v2.12.4
v2.12.3 2025-10-17
v2.12.3 (2025-10-17)
What's Changed
This is the third 2.13 patch release, fixing issues related to the
FieldInfo class, and reverting a change to the supported after
model validator function signatures.
... (truncated)
Changelog
Sourced from pydantic's
changelog.
v2.12.5 (2025-11-26)
GitHub
release
This is the fifth 2.12 patch release, addressing an issue with the
MISSING sentinel and providing several documentation
improvements.
The next 2.13 minor release will be published in a couple weeks, and
will include a new polymorphic serialization feature addressing
the remaining unexpected changes to the serialize as any
behavior.
- Fix pickle error when using
model_construct() on a
model with MISSING as a default value by @āornariece in #12522.
- Several updates to the documentation by
@āViicos.
v2.12.4 (2025-11-05)
GitHub
release
This is the fourth 2.12 patch release, fixing more regressions, and
reverting a change in the build() method
of the AnyUrl
and Dsn types.
This patch release also fixes an issue with the serialization of IP
address types, when serialize_as_any is used. The next
patch release
will try to address the remaining issues with serialize as any
behavior by introducing a new polymorphic serialization
feature, that
should be used in most cases in place of serialize as any.
v2.12.3 (2025-10-17)
GitHub
release
... (truncated)
Commits
bd2d0dd
Prepare release v2.12.5
7d0302e
Document security implications when using
create_model()
e9ef980
Fix typo in Standard Library Types documentation
f2c20c0
Add pydantic-docs dev dependency, make use of versioning
blocks
a76c1aa
Update documentation about JSON Schema
8cbc72c
Add documentation about custom __init__()
99eba59
Add additional test for FieldInfo.get_default()
c710769
Special case MISSING sentinel in
smart_deepcopy()
20a9d77
Do not delete mock validator/serializer in
rebuild_dataclass()
c86515a
Update parts of the model and revalidate_instances
documentation
- Additional commits viewable in compare
view
Updates `protobuf` from 5.29.5 to 6.33.4
Commits
Updates `google-api-core` from 2.25.1 to 2.29.0
Release notes
Sourced from google-api-core's
releases.
google-api-core 2.29.0
2.29.0
(2026-01-08)
Features
Bug Fixes
-
remove call to importlib.metadata.packages_distributions() for
py38/py39 (#859)
(628003e2)
-
Log version check errors (#858)
(6493118c)
-
flaky tests due to imprecision in floating point calculation and
performance test setup (#865)
(93404080)
-
closes tailing streams in bidi classes. (#851)
(c97b3a00)
v2.28.1
2.28.1
(2025-10-28)
Bug Fixes
- Remove dependency on packaging and pkg_resources (#852)
(ca59a86)
v2.28.0
2.28.0
(2025-10-24)
Features
- Provide and use Python version support check (#832)
(d36e896)
v2.27.0
2.27.0
(2025-10-22)
Features
v2.26.0
2.26.0
(2025-10-08)
Features
... (truncated)
Changelog
Sourced from google-api-core's
changelog.
2.29.0
(2026-01-08)
Features
Bug Fixes
2.28.1
(2025-10-28)
Bug Fixes
- Remove dependency on packaging and pkg_resources (#852)
(ca59a86)
2.28.0
(2025-10-24)
Features
- Provide and use Python version support check (#832)
(d36e896)
2.27.0
(2025-10-22)
Features
2.26.0
(2025-10-08)
Features
2.25.2
(2025-10-01)
Bug Fixes
... (truncated)
Commits
014d3de
chore: librarian release pull request: 20260108T134327Z (#883)
2d93bd1
tests: remove pytype nox session (#876)
0fe0632
chore: fix mypy check (#882)
f8bf6f9
feat: Auto enable mTLS when supported certificates are detected (#869)
f0188c6
chore: update github action workflow permissions (#875)
d211307
tests: refactor unit test nox sessions (#873)
2196e2a
chore: remove sync-repo-settings.yaml which is not used (#872)
54d1d36
tests: update default python runtime used in tests to 3.14 (#870)
9340408
fix: flaky tests due to imprecision in floating point calculation and
perform...
c969186
feat: make parse_version_to_tuple public (#864)
- Additional commits viewable in compare
view
Updates `fastapi` from 0.116.1 to 0.128.0
Release notes
Sourced from fastapi's
releases.
0.128.0
Breaking Changes
Internal
0.127.1
Refactors
Docs
Translations
Internal
0.127.0
Breaking Changes
Translations
- š§ Add LLM prompt file for Korean, generated from the existing
translations. PR #14546
by
@ātiangolo.
- š§ Add LLM prompt file for Japanese, generated from the existing
translations. PR #14545
by
@ātiangolo.
Internal
0.126.0
Upgrades
- ā Drop support for Pydantic v1, keeping short temporary support for
Pydantic v2's
pydantic.v1. PR #14575
by @ātiangolo.
... (truncated)
Commits
Updates `sse-starlette` from 3.0.2 to 3.2.0
Release notes
Sourced from sse-starlette's
releases.
v3.2.0
What's Changed
New Contributors
Full Changelog: https://github.com/sysid/sse-starlette/compare/v3.1.2...v3.2.0
v3.1.2
What's Changed
Full Changelog: https://github.com/sysid/sse-starlette/compare/v3.1.1...v3.1.2
v3.1.1
What's Changed
Full Changelog: https://github.com/sysid/sse-starlette/compare/v3.1.0...v3.1.1
v3.1.0
What's Changed
Full Changelog: https://github.com/sysid/sse-starlette/compare/v3.0.4...v3.1.0
v3.0.4
What's Changed
New Contributors
Full Changelog: https://github.com/sysid/sse-starlette/compare/v3.0.3...v3.0.4
v3.0.3
What's Changed
New Contributors
Full Changelog: https://github.com/sysid/sse-starlette/compare/v3.0.2...v3.0.3
Commits
9101a42
Bump version to 3.2.0
c3248fc
Merge pull request #158
from sysid/pr-157
c99dd67
Merge pull request #157
from yuliy-openai/optional_auto_drain
ed35777
feat: add enable_automatic_graceful_drain_mode() for re-enabling
auto-drain
15f26cb
[feat] Allow disabling automatic draining immediately on sigterm
fc50af6
chore: update gitignore
268b3cd
feat: add pre-commit hooks for format, lint, and mypy
618ac0e
Bump version to 3.1.2
6d68ba9
Merge pull request #153
from sysid/fix/152_shutdown_watcher_leak
89faa04
fix: prevent watcher task leak with threading.local (#152)
- Additional commits viewable in compare
view
Updates `starlette` from 0.47.2 to 0.50.0
Release notes
Sourced from starlette's
releases.
Version 0.50.0
Removed
- Drop Python 3.9 support #3061.
Full Changelog: https://github.com/Kludex/starlette/compare/0.49.3...0.50.0
Version 0.49.3
Fixed
- Relax strictness on
Middleware type #3059.
Full Changelog: https://github.com/Kludex/starlette/compare/0.49.2...0.49.3
Version 0.49.2
Fixed
- Ignore
if-modified-since header if
if-none-match is present in StaticFiles #3044.
Full Changelog: https://github.com/Kludex/starlette/compare/0.49.1...0.49.2
Version 0.49.1
This release fixes a security vulnerability in the parsing logic of
the Range header in FileResponse.
You can view the full security advisory: GHSA-7f5h-v6xp-fcq8
Fixed
Full Changelog: https://github.com/Kludex/starlette/compare/0.49.0...0.49.1
Version 0.49.0
Added
- Add
encoding parameter to Config class #2996.
- Support multiple cookie headers in
Request.cookies #3029.
- Use
Literal type for WebSocketEndpoint
encoding values #3027.
Changed
- Do not pollute exception context in
Middleware when
using BaseHTTPMiddleware #2976.
... (truncated)
Changelog
Sourced from starlette's
changelog.
0.50.0 (November 1, 2025)
Removed
- Drop Python 3.9 support #3061.
0.49.3 (November 1, 2025)
This is the last release that supports Python 3.9, which will be
dropped in the next minor release.
Fixed
- Relax strictness on
Middleware type #3059.
0.49.2 (November 1, 2025)
Fixed
- Ignore
if-modified-since header if
if-none-match is present in StaticFiles #3044.
0.49.1 (October 28, 2025)
This release fixes a security vulnerability in the parsing logic of
the Range header in FileResponse.
You can view the full security advisory: GHSA-7f5h-v6xp-fcq8
Fixed
0.49.0 (October 28, 2025)
Added
- Add
encoding parameter to Config class #2996.
- Support multiple cookie headers in
Request.cookies #3029.
- Use
Literal type for WebSocketEndpoint
encoding values #3027.
Changed
- Do not pollute exception context in
Middleware when
using BaseHTTPMiddleware #2976.
0.48.0 (September 13, 2025)
Added
- Add official Python 3.14 support #3013.
Changed
... (truncated)
Commits
Updates `cryptography` from 45.0.5 to 46.0.3
Changelog
Sourced from cryptography's
changelog.
46.0.3 - 2025-10-15
* Fixed compilation when using LibreSSL 4.2.0.
.. _v46-0-2:
46.0.2 - 2025-09-30
- Updated Windows, macOS, and Linux wheels to be compiled with OpenSSL
3.5.4.
.. _v46-0-1:
46.0.1 - 2025-09-16
* Fixed an issue where users installing via ``pip`` on Python 3.14
development
versions would not properly install a dependency.
* Fixed an issue building the free-threaded macOS 3.14 wheels.
.. _v46-0-0:
46.0.0 - 2025-09-16
- BACKWARDS INCOMPATIBLE: Support for Python 3.7 has
been removed.
- Support for OpenSSL < 3.0 is deprecated and will be removed in
the next
release.
- Support for
x86_64 macOS (including publishing wheels)
is deprecated
and will be removed in two releases. We will switch to publishing an
arm64 only wheel for macOS.
- Support for 32-bit Windows (including publishing wheels) is
deprecated
and will be removed in two releases. Users should move to a 64-bit
Python installation.
- Updated Windows, macOS, and Linux wheels to be compiled with OpenSSL
3.5.3.
- We now build
ppc64le manylinux wheels and
publish them to PyPI.
- We now build
win_arm64 (Windows on Arm) wheels and
publish them to PyPI.
- Added support for free-threaded Python 3.14.
- Removed the deprecated
get_attribute_for_oid method on
:class:~cryptography.x509.CertificateSigningRequest. Users
should use
:meth:~cryptography.x509.Attributes.get_attribute_for_oid
instead.
- Removed the deprecated
CAST5, SEED,
IDEA, and Blowfish
classes from the cipher module. These are still available in
:doc:/hazmat/decrepit/index.
- In X.509, when performing a PSS signature with a SHA-3 hash, it is
now
encoded with the official NIST SHA3 OID.
.. _v45-0-7:
... (truncated)
Commits
Updates `grpcio` from 1.74.0 to 1.76.0
Release notes
Sourced from grpcio's
releases.
Release v1.76.0
This is release 1.76.0 (genuine)
of gRPC Core.
For gRPC documentation, see grpc.io.
For previous releases, see Releases.
This release contains refinements, improvements, and bug fixes, with
highlights listed below.
Core
- Prioritize system CA over bundled CA. (#40583)
- [event_engine] Introduce a event_engine_poller_for_python
experiment. (#40243)
- [metrics] add grpc.lb.backend_service label. (#40486)
C#
- [csharp tools] #39374
Grpc.Tools can't process file Suffix name with Upper character. (#40072)
Python
- [Python] gRPC AsyncIO: Improve CompletionQueue polling performance.
(#39993)
Release v1.76.0-pre1
This is a prerelease of gRPC Core 1.76.0 (genuine).
For gRPC documentation, see grpc.io.
For previous releases, see Releases.
This prerelease contains refinements, improvements, and bug
fixes.
Release v1.75.1
This is release gRPC Core 1.75.1 (gemini).
For gRPC documentation, see grpc.io.
For previous releases, see Releases.
This release contains refinements, improvements, and bug fixes.
What's Changed
Python
- Release grpcio wheels with Python 3.14 support (#40403)
- Asyncio: fixes grpc shutdown race condition occurring during python
interpreter finalizations. (#40447)
- This also addresses previously reported issues with empty error
message on Python interpreter exit (
Error in
sys.excepthook:/Original exception was: empty): #36655, #38679, #33342
- Python 3.14: preserve current behavior when using grpc.aio async
methods outside of a running event loop. (#40750)
- Note: using async methods outside of a running event loop is discouraged
by Python, and will be deprecated in future gRPC
releases. Please use the asyncio.run()
function (or asyncio.Runner
for custom loop factories). For interactive mode, use dedicated asyncio
REPL:
python -m asyncio.
Full Changelog: https://github.com/grpc/grpc/compare/v1.75.0...v1.75.1
... (truncated)
Commits
f5ffb68
[Release] Bump version to 1.76.0 (on v1.76.x branch) (#40925)
ffd8379
[Release] Bump version to 1.76.0-pre1 (on v1.76.x branch) (#40798)
835d394
[Release] Bump core version to 51.0.0 for upcoming release (#40784)
de6ce7f
[PH2] Add files for goaway support (#40786)
f7dd7f4
[PH2][Trivial][CleanUp]
2d40a37
[PH2][ChannelZ][ZTrace][Skeleton]
83acb27
[build] Add Missing Dependencies for reflection_proto in Preparation for
Enab...
abfe8a2
[PH2] Stream list represents streams open for reads.
c65d8de
[PH2][Expt] Fix the experiment expiry
755d025
Fix latent_see_test flakiness
- Additional commits viewable in compare
view
Updates `grpcio-tools` from 1.71.2 to 1.74.0
Release notes
Sourced from grpcio-tools's
releases.
Release v1.74.0
This is release 1.74.0 (gee)
of gRPC Core.
For gRPC documentation, see grpc.io.
For previous releases, see Releases.
This release contains refinements, improvements, and bug fixes, with
highlights listed below.
Core
- [OTel C++, Posix EE] Plumb TCP write timestamps and metrics to OTel
tracers. (#39946)
- [event_engine] Implement fork support in Posix Event Engine. (#38980)
- [http2] Fix GRPC_ARG_HTTP2_STREAM_LOOKAHEAD_BYTES for when BDP is
disabled. (#39585)
Objective-C
- [dep] Upgrade Protobuf Version 31.1. (#39916)
PHP
- [PHP] Fully qualify stdClass with global namespace. (#39996)
- [php] Fix PHPDoc so that UnaryCall defines the proper return type.
(#37563)
- fix typing of nullable parameters. (#39199)
Python
- [EventEngine] Fix the issue with gRPC Python Client not reconnecting
in certain situations: #38290, #39113, #39631.
(#39894)
- Fix gRPC Python docs website layout - use spaces optimally. (#40073)
Ruby
- [Ruby] Add rubygems support for linux-gnu and linux-musl platforms .
(#40174)
- [ruby] enable EE fork support. (#39786)
- [ruby] Return nil for c functions expected to return a VALUE. (#39214)
- [ruby] remove connectivity state watch thread, fix cancellations
from spurious signals. (#39409)
- [ruby] Drop Ruby 3.0 support. (#39607)
Release v1.74.0-pre2
This is a prerelease of gRPC Core 1.74.0 (gee).
For gRPC documentation, see grpc.io.
For previous releases, see Releases.
This prerelease contains refinements, improvements, and bug
fixes.
... (truncated)
Commits
3e7a4d5
[Release] Bump version to 1.74.0 (on v1.74.x branch) (#40290)
b2d32db
[Backport][v1.74.x][Python] Fix for windows distribtest (#40241)
a7d80a7
[ruby] bump timeout for ruby artifact build on 1.74.x branch (#40230)
2a6bf86
[Release] Bump version to 1.74.0-pre2 (on v1.74.x branch) (#40216)
c8dcda6
[Backport-to-1.74.x] Added missing useful to cf_event_engine (#40210)
1c64908
[Ruby] Add rubygems support for linux-gnu and linux-musl platforms (#40174)
08648d3
[Backport][v1.74.x][event_engine] Fix race conditions in the timer
manager sh...
5d59f8e
[Release] Bump version to 1.74.0-pre1 (on v1.74.x branch) (#40121)