From f4c9c18cfef3ccab1ac7bb30cc7f8293cf3e3ef6 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Tue, 2 Sep 2025 17:02:12 +0200 Subject: [PATCH 001/384] fix: handle concurrent task completion during cancellation (#449) ### Description We [check](https://github.com/a2aproject/a2a-python/blob/d2e869f/src/a2a/server/request_handlers/default_request_handler.py#L149) that a Task is in a cancellable state before calling `agent_executor.cancel`. This doesn't guarantee there's no task completion event in the queue which will be applied before our task cancellation request gets handled. This PR adds an extra check to ensure that we don't return a Task in a non-cancelled state as a successful cancellation call response. Instead we raise `TaskNotCancelableError`. --- .../default_request_handler.py | 19 ++++--- .../test_default_request_handler.py | 50 +++++++++++++++++++ .../request_handlers/test_jsonrpc_handler.py | 2 + 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/a2a/server/request_handlers/default_request_handler.py b/src/a2a/server/request_handlers/default_request_handler.py index fd378cf47..6a38933f6 100644 --- a/src/a2a/server/request_handlers/default_request_handler.py +++ b/src/a2a/server/request_handlers/default_request_handler.py @@ -180,14 +180,21 @@ async def on_cancel_task( consumer = EventConsumer(queue) result = await result_aggregator.consume_all(consumer) - if isinstance(result, Task): - return result + if not isinstance(result, Task): + raise ServerError( + error=InternalError( + message='Agent did not return valid response for cancel' + ) + ) - raise ServerError( - error=InternalError( - message='Agent did not return valid response for cancel' + if result.status.state != TaskState.canceled: + raise ServerError( + error=TaskNotCancelableError( + message=f'Task cannot be canceled - current state: {result.status.state}' + ) ) - ) + + return result async def _run_event_stream( self, request: RequestContext, queue: EventQueue diff --git a/tests/server/request_handlers/test_default_request_handler.py b/tests/server/request_handlers/test_default_request_handler.py index e8906554a..88fb7d3e5 100644 --- a/tests/server/request_handlers/test_default_request_handler.py +++ b/tests/server/request_handlers/test_default_request_handler.py @@ -263,6 +263,56 @@ async def test_on_cancel_task_cancels_running_agent(): mock_agent_executor.cancel.assert_awaited_once() +@pytest.mark.asyncio +async def test_on_cancel_task_completes_during_cancellation(): + """Test on_cancel_task fails to cancel a task due to concurrent task completion.""" + task_id = 'running_agent_task_to_cancel' + sample_task = create_sample_task(task_id=task_id) + mock_task_store = AsyncMock(spec=TaskStore) + mock_task_store.get.return_value = sample_task + + mock_queue_manager = AsyncMock(spec=QueueManager) + mock_event_queue = AsyncMock(spec=EventQueue) + mock_queue_manager.tap.return_value = mock_event_queue + + mock_agent_executor = AsyncMock(spec=AgentExecutor) + + # Mock ResultAggregator + mock_result_aggregator_instance = AsyncMock(spec=ResultAggregator) + mock_result_aggregator_instance.consume_all.return_value = ( + create_sample_task(task_id=task_id, status_state=TaskState.completed) + ) + + request_handler = DefaultRequestHandler( + agent_executor=mock_agent_executor, + task_store=mock_task_store, + queue_manager=mock_queue_manager, + ) + + # Simulate a running agent task + mock_producer_task = AsyncMock(spec=asyncio.Task) + request_handler._running_agents[task_id] = mock_producer_task + + from a2a.utils.errors import ( + ServerError, # Local import + TaskNotCancelableError, # Local import + ) + + with patch( + 'a2a.server.request_handlers.default_request_handler.ResultAggregator', + return_value=mock_result_aggregator_instance, + ): + params = TaskIdParams(id=task_id) + with pytest.raises(ServerError) as exc_info: + await request_handler.on_cancel_task( + params, create_server_call_context() + ) + + mock_producer_task.cancel.assert_called_once() + mock_agent_executor.cancel.assert_awaited_once() + assert isinstance(exc_info.value.error, TaskNotCancelableError) + + @pytest.mark.asyncio async def test_on_cancel_task_invalid_result_type(): """Test on_cancel_task when result_aggregator returns a Message instead of a Task.""" diff --git a/tests/server/request_handlers/test_jsonrpc_handler.py b/tests/server/request_handlers/test_jsonrpc_handler.py index 19cf8be06..1d1b3c5d1 100644 --- a/tests/server/request_handlers/test_jsonrpc_handler.py +++ b/tests/server/request_handlers/test_jsonrpc_handler.py @@ -149,6 +149,7 @@ async def test_on_cancel_task_success(self) -> None: call_context = ServerCallContext(state={'foo': 'bar'}) async def streaming_coro(): + mock_task.status.state = TaskState.canceled yield mock_task with patch( @@ -160,6 +161,7 @@ async def streaming_coro(): assert mock_agent_executor.cancel.call_count == 1 self.assertIsInstance(response.root, CancelTaskSuccessResponse) assert response.root.result == mock_task # type: ignore + assert response.root.result.status.state == TaskState.canceled mock_agent_executor.cancel.assert_called_once() async def test_on_cancel_task_not_supported(self) -> None: From 42ff0d42cf214caeba53f4fc4e37152ac795f180 Mon Sep 17 00:00:00 2001 From: Holt Skinner Date: Tue, 2 Sep 2025 10:02:58 -0500 Subject: [PATCH 002/384] style: Fix Spelling error in comments --- src/a2a/server/events/event_consumer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/a2a/server/events/event_consumer.py b/src/a2a/server/events/event_consumer.py index e2041a45d..de0f6bd9d 100644 --- a/src/a2a/server/events/event_consumer.py +++ b/src/a2a/server/events/event_consumer.py @@ -133,7 +133,7 @@ async def consume_all(self) -> AsyncGenerator[Event]: # continue polling until there is a final event continue except asyncio.TimeoutError: # pyright: ignore [reportUnusedExcept] - # This class was made an alias of build-in TimeoutError after 3.11 + # This class was made an alias of built-in TimeoutError after 3.11 continue except (QueueClosed, asyncio.QueueEmpty): # Confirm that the queue is closed, e.g. we aren't on From 813f5cdf639099dcad1365202e1819b34dfb52e6 Mon Sep 17 00:00:00 2001 From: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Date: Tue, 2 Sep 2025 17:12:11 +0100 Subject: [PATCH 003/384] ci: Make @a2a-bot the CODEOWNER for `types.py` (#453) --- .github/CODEOWNERS | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 16a57e944..fb0634c1c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,4 +4,5 @@ # For syntax help see: # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax -* @a2aproject/google-a2a-eng +* @a2aproject/google-a2a-eng +src/a2a/types.py @a2a-bot From e3e5c4b7dcb5106e943b9aeb8e761ed23cc166a2 Mon Sep 17 00:00:00 2001 From: pstephengoogle Date: Tue, 2 Sep 2025 10:35:28 -0600 Subject: [PATCH 004/384] feat: Add `ServerCallContext` into task store operations (#443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In production systems the persistence of the task should be protected based on the credentials of the creator of the task (e.g. a user id or email). Additionally, applications may have other criteria to use for task persistence (like application name, or region task runs in). --- Providing the `ServerCallContext` into the calls to the `get`, `save` and `delete` interface for the task store allows customization of the persisted task data based on the characteristics needed for a real solution. Agent implementors can construct the appropriate `ServerCallContext` based on the incoming request and use that information at task creation, retrieval and deletion time. Fixes #442 🦕 --------- Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Co-authored-by: Holt Skinner --- .../default_request_handler.py | 17 ++-- src/a2a/server/tasks/database_task_store.py | 13 ++- src/a2a/server/tasks/inmemory_task_store.py | 13 ++- src/a2a/server/tasks/task_manager.py | 12 ++- src/a2a/server/tasks/task_store.py | 13 ++- .../test_default_request_handler.py | 81 ++++++++++--------- .../request_handlers/test_jsonrpc_handler.py | 9 ++- tests/server/tasks/test_task_manager.py | 18 ++--- 8 files changed, 108 insertions(+), 68 deletions(-) diff --git a/src/a2a/server/request_handlers/default_request_handler.py b/src/a2a/server/request_handlers/default_request_handler.py index 6a38933f6..724fe61e6 100644 --- a/src/a2a/server/request_handlers/default_request_handler.py +++ b/src/a2a/server/request_handlers/default_request_handler.py @@ -109,7 +109,7 @@ async def on_get_task( context: ServerCallContext | None = None, ) -> Task | None: """Default handler for 'tasks/get'.""" - task: Task | None = await self.task_store.get(params.id) + task: Task | None = await self.task_store.get(params.id, context) if not task: raise ServerError(error=TaskNotFoundError()) @@ -141,7 +141,7 @@ async def on_cancel_task( Attempts to cancel the task managed by the `AgentExecutor`. """ - task: Task | None = await self.task_store.get(params.id) + task: Task | None = await self.task_store.get(params.id, context) if not task: raise ServerError(error=TaskNotFoundError()) @@ -158,6 +158,7 @@ async def on_cancel_task( context_id=task.context_id, task_store=self.task_store, initial_message=None, + context=context, ) result_aggregator = ResultAggregator(task_manager) @@ -224,6 +225,7 @@ async def _setup_message_execution( context_id=params.message.context_id, task_store=self.task_store, initial_message=params.message, + context=context, ) task: Task | None = await task_manager.get_task() @@ -424,7 +426,7 @@ async def on_set_task_push_notification_config( if not self._push_config_store: raise ServerError(error=UnsupportedOperationError()) - task: Task | None = await self.task_store.get(params.task_id) + task: Task | None = await self.task_store.get(params.task_id, context) if not task: raise ServerError(error=TaskNotFoundError()) @@ -447,7 +449,7 @@ async def on_get_task_push_notification_config( if not self._push_config_store: raise ServerError(error=UnsupportedOperationError()) - task: Task | None = await self.task_store.get(params.id) + task: Task | None = await self.task_store.get(params.id, context) if not task: raise ServerError(error=TaskNotFoundError()) @@ -476,7 +478,7 @@ async def on_resubscribe_to_task( Allows a client to re-attach to a running streaming task's event stream. Requires the task and its queue to still be active. """ - task: Task | None = await self.task_store.get(params.id) + task: Task | None = await self.task_store.get(params.id, context) if not task: raise ServerError(error=TaskNotFoundError()) @@ -492,6 +494,7 @@ async def on_resubscribe_to_task( context_id=task.context_id, task_store=self.task_store, initial_message=None, + context=context, ) result_aggregator = ResultAggregator(task_manager) @@ -516,7 +519,7 @@ async def on_list_task_push_notification_config( if not self._push_config_store: raise ServerError(error=UnsupportedOperationError()) - task: Task | None = await self.task_store.get(params.id) + task: Task | None = await self.task_store.get(params.id, context) if not task: raise ServerError(error=TaskNotFoundError()) @@ -543,7 +546,7 @@ async def on_delete_task_push_notification_config( if not self._push_config_store: raise ServerError(error=UnsupportedOperationError()) - task: Task | None = await self.task_store.get(params.id) + task: Task | None = await self.task_store.get(params.id, context) if not task: raise ServerError(error=TaskNotFoundError()) diff --git a/src/a2a/server/tasks/database_task_store.py b/src/a2a/server/tasks/database_task_store.py index b46d71939..07ba7e970 100644 --- a/src/a2a/server/tasks/database_task_store.py +++ b/src/a2a/server/tasks/database_task_store.py @@ -19,6 +19,7 @@ "or 'pip install a2a-sdk[sql]'" ) from e +from a2a.server.context import ServerCallContext from a2a.server.models import Base, TaskModel, create_task_model from a2a.server.tasks.task_store import TaskStore from a2a.types import Task # Task is the Pydantic model @@ -119,7 +120,9 @@ def _from_orm(self, task_model: TaskModel) -> Task: # Pydantic's model_validate will parse the nested dicts/lists from JSON return Task.model_validate(task_data_from_db) - async def save(self, task: Task) -> None: + async def save( + self, task: Task, context: ServerCallContext | None = None + ) -> None: """Saves or updates a task in the database.""" await self._ensure_initialized() db_task = self._to_orm(task) @@ -127,7 +130,9 @@ async def save(self, task: Task) -> None: await session.merge(db_task) logger.debug('Task %s saved/updated successfully.', task.id) - async def get(self, task_id: str) -> Task | None: + async def get( + self, task_id: str, context: ServerCallContext | None = None + ) -> Task | None: """Retrieves a task from the database by ID.""" await self._ensure_initialized() async with self.async_session_maker() as session: @@ -142,7 +147,9 @@ async def get(self, task_id: str) -> Task | None: logger.debug('Task %s not found in store.', task_id) return None - async def delete(self, task_id: str) -> None: + async def delete( + self, task_id: str, context: ServerCallContext | None = None + ) -> None: """Deletes a task from the database by ID.""" await self._ensure_initialized() diff --git a/src/a2a/server/tasks/inmemory_task_store.py b/src/a2a/server/tasks/inmemory_task_store.py index 26c098230..4e192af08 100644 --- a/src/a2a/server/tasks/inmemory_task_store.py +++ b/src/a2a/server/tasks/inmemory_task_store.py @@ -1,6 +1,7 @@ import asyncio import logging +from a2a.server.context import ServerCallContext from a2a.server.tasks.task_store import TaskStore from a2a.types import Task @@ -21,13 +22,17 @@ def __init__(self) -> None: self.tasks: dict[str, Task] = {} self.lock = asyncio.Lock() - async def save(self, task: Task) -> None: + async def save( + self, task: Task, context: ServerCallContext | None = None + ) -> None: """Saves or updates a task in the in-memory store.""" async with self.lock: self.tasks[task.id] = task logger.debug('Task %s saved successfully.', task.id) - async def get(self, task_id: str) -> Task | None: + async def get( + self, task_id: str, context: ServerCallContext | None = None + ) -> Task | None: """Retrieves a task from the in-memory store by ID.""" async with self.lock: logger.debug('Attempting to get task with id: %s', task_id) @@ -38,7 +43,9 @@ async def get(self, task_id: str) -> Task | None: logger.debug('Task %s not found in store.', task_id) return task - async def delete(self, task_id: str) -> None: + async def delete( + self, task_id: str, context: ServerCallContext | None = None + ) -> None: """Deletes a task from the in-memory store by ID.""" async with self.lock: logger.debug('Attempting to delete task with id: %s', task_id) diff --git a/src/a2a/server/tasks/task_manager.py b/src/a2a/server/tasks/task_manager.py index 334d9992a..5c363703b 100644 --- a/src/a2a/server/tasks/task_manager.py +++ b/src/a2a/server/tasks/task_manager.py @@ -1,5 +1,6 @@ import logging +from a2a.server.context import ServerCallContext from a2a.server.events.event_queue import Event from a2a.server.tasks.task_store import TaskStore from a2a.types import ( @@ -31,6 +32,7 @@ def __init__( context_id: str | None, task_store: TaskStore, initial_message: Message | None, + context: ServerCallContext | None = None, ): """Initializes the TaskManager. @@ -40,6 +42,7 @@ def __init__( task_store: The `TaskStore` instance for persistence. initial_message: The `Message` that initiated the task, if any. Used when creating a new task object. + context: The `ServerCallContext` that this task is produced under. """ if task_id is not None and not (isinstance(task_id, str) and task_id): raise ValueError('Task ID must be a non-empty string') @@ -49,6 +52,7 @@ def __init__( self.task_store = task_store self._initial_message = initial_message self._current_task: Task | None = None + self._call_context: ServerCallContext | None = context logger.debug( 'TaskManager initialized with task_id: %s, context_id: %s', task_id, @@ -74,7 +78,9 @@ async def get_task(self) -> Task | None: logger.debug( 'Attempting to get task from store with id: %s', self.task_id ) - self._current_task = await self.task_store.get(self.task_id) + self._current_task = await self.task_store.get( + self.task_id, self._call_context + ) if self._current_task: logger.debug('Task %s retrieved successfully.', self.task_id) else: @@ -167,7 +173,7 @@ async def ensure_task( logger.debug( 'Attempting to retrieve existing task with id: %s', self.task_id ) - task = await self.task_store.get(self.task_id) + task = await self.task_store.get(self.task_id, self._call_context) if not task: logger.info( @@ -231,7 +237,7 @@ async def _save_task(self, task: Task) -> None: task: The `Task` object to save. """ logger.debug('Saving task with id: %s', task.id) - await self.task_store.save(task) + await self.task_store.save(task, self._call_context) self._current_task = task if not self.task_id: logger.info('New task created with id: %s', task.id) diff --git a/src/a2a/server/tasks/task_store.py b/src/a2a/server/tasks/task_store.py index 1ed974a95..16b36edb9 100644 --- a/src/a2a/server/tasks/task_store.py +++ b/src/a2a/server/tasks/task_store.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod +from a2a.server.context import ServerCallContext from a2a.types import Task @@ -10,13 +11,19 @@ class TaskStore(ABC): """ @abstractmethod - async def save(self, task: Task) -> None: + async def save( + self, task: Task, context: ServerCallContext | None = None + ) -> None: """Saves or updates a task in the store.""" @abstractmethod - async def get(self, task_id: str) -> Task | None: + async def get( + self, task_id: str, context: ServerCallContext | None = None + ) -> Task | None: """Retrieves a task from the store by ID.""" @abstractmethod - async def delete(self, task_id: str) -> None: + async def delete( + self, task_id: str, context: ServerCallContext | None = None + ) -> None: """Deletes a task from the store by ID.""" diff --git a/tests/server/request_handlers/test_default_request_handler.py b/tests/server/request_handlers/test_default_request_handler.py index 88fb7d3e5..f1408e362 100644 --- a/tests/server/request_handlers/test_default_request_handler.py +++ b/tests/server/request_handlers/test_default_request_handler.py @@ -135,11 +135,12 @@ async def test_on_get_task_not_found(): from a2a.utils.errors import ServerError # Local import for ServerError + context = create_server_call_context() with pytest.raises(ServerError) as exc_info: - await request_handler.on_get_task(params, create_server_call_context()) + await request_handler.on_get_task(params, context) assert isinstance(exc_info.value.error, TaskNotFoundError) - mock_task_store.get.assert_awaited_once_with('non_existent_task') + mock_task_store.get.assert_awaited_once_with('non_existent_task', context) @pytest.mark.asyncio @@ -155,13 +156,14 @@ async def test_on_cancel_task_task_not_found(): from a2a.utils.errors import ServerError # Local import + context = create_server_call_context() with pytest.raises(ServerError) as exc_info: - await request_handler.on_cancel_task( - params, create_server_call_context() - ) + await request_handler.on_cancel_task(params, context) assert isinstance(exc_info.value.error, TaskNotFoundError) - mock_task_store.get.assert_awaited_once_with('task_not_found_for_cancel') + mock_task_store.get.assert_awaited_once_with( + 'task_not_found_for_cancel', context + ) @pytest.mark.asyncio @@ -195,16 +197,15 @@ async def test_on_cancel_task_queue_tap_returns_none(): queue_manager=mock_queue_manager, ) + context = create_server_call_context() with patch( 'a2a.server.request_handlers.default_request_handler.ResultAggregator', return_value=mock_result_aggregator_instance, ): params = TaskIdParams(id='tap_none_task') - result_task = await request_handler.on_cancel_task( - params, create_server_call_context() - ) + result_task = await request_handler.on_cancel_task(params, context) - mock_task_store.get.assert_awaited_once_with('tap_none_task') + mock_task_store.get.assert_awaited_once_with('tap_none_task', context) mock_queue_manager.tap.assert_awaited_once_with('tap_none_task') # agent_executor.cancel should be called with a new EventQueue if tap returned None mock_agent_executor.cancel.assert_awaited_once() @@ -250,14 +251,13 @@ async def test_on_cancel_task_cancels_running_agent(): mock_producer_task = AsyncMock(spec=asyncio.Task) request_handler._running_agents[task_id] = mock_producer_task + context = create_server_call_context() with patch( 'a2a.server.request_handlers.default_request_handler.ResultAggregator', return_value=mock_result_aggregator_instance, ): params = TaskIdParams(id=task_id) - await request_handler.on_cancel_task( - params, create_server_call_context() - ) + await request_handler.on_cancel_task(params, context) mock_producer_task.cancel.assert_called_once() mock_agent_executor.cancel.assert_awaited_once() @@ -1322,13 +1322,14 @@ async def test_set_task_push_notification_config_task_not_found(): ) from a2a.utils.errors import ServerError # Local import + context = create_server_call_context() with pytest.raises(ServerError) as exc_info: await request_handler.on_set_task_push_notification_config( - params, create_server_call_context() + params, context ) assert isinstance(exc_info.value.error, TaskNotFoundError) - mock_task_store.get.assert_awaited_once_with('non_existent_task') + mock_task_store.get.assert_awaited_once_with('non_existent_task', context) mock_push_store.set_info.assert_not_awaited() @@ -1365,13 +1366,14 @@ async def test_get_task_push_notification_config_task_not_found(): params = GetTaskPushNotificationConfigParams(id='non_existent_task') from a2a.utils.errors import ServerError # Local import + context = create_server_call_context() with pytest.raises(ServerError) as exc_info: await request_handler.on_get_task_push_notification_config( - params, create_server_call_context() + params, context ) assert isinstance(exc_info.value.error, TaskNotFoundError) - mock_task_store.get.assert_awaited_once_with('non_existent_task') + mock_task_store.get.assert_awaited_once_with('non_existent_task', context) mock_push_store.get_info.assert_not_awaited() @@ -1394,15 +1396,16 @@ async def test_get_task_push_notification_config_info_not_found(): params = GetTaskPushNotificationConfigParams(id='non_existent_task') from a2a.utils.errors import ServerError # Local import + context = create_server_call_context() with pytest.raises(ServerError) as exc_info: await request_handler.on_get_task_push_notification_config( - params, create_server_call_context() + params, context ) assert isinstance( exc_info.value.error, InternalError ) # Current code raises InternalError - mock_task_store.get.assert_awaited_once_with('non_existent_task') + mock_task_store.get.assert_awaited_once_with('non_existent_task', context) mock_push_store.get_info.assert_awaited_once_with('non_existent_task') @@ -1425,8 +1428,9 @@ async def test_get_task_push_notification_config_info_with_config(): id='config_id', url='http://1.example.com' ), ) + context = create_server_call_context() await request_handler.on_set_task_push_notification_config( - set_config_params, create_server_call_context() + set_config_params, context ) params = GetTaskPushNotificationConfigParams( @@ -1435,7 +1439,7 @@ async def test_get_task_push_notification_config_info_with_config(): result: TaskPushNotificationConfig = ( await request_handler.on_get_task_push_notification_config( - params, create_server_call_context() + params, context ) ) @@ -1501,15 +1505,16 @@ async def test_on_resubscribe_to_task_task_not_found(): from a2a.utils.errors import ServerError # Local import + context = create_server_call_context() with pytest.raises(ServerError) as exc_info: # Need to consume the async generator to trigger the error - async for _ in request_handler.on_resubscribe_to_task( - params, create_server_call_context() - ): + async for _ in request_handler.on_resubscribe_to_task(params, context): pass assert isinstance(exc_info.value.error, TaskNotFoundError) - mock_task_store.get.assert_awaited_once_with('resub_task_not_found') + mock_task_store.get.assert_awaited_once_with( + 'resub_task_not_found', context + ) @pytest.mark.asyncio @@ -1531,16 +1536,17 @@ async def test_on_resubscribe_to_task_queue_not_found(): from a2a.utils.errors import ServerError # Local import + context = create_server_call_context() with pytest.raises(ServerError) as exc_info: - async for _ in request_handler.on_resubscribe_to_task( - params, create_server_call_context() - ): + async for _ in request_handler.on_resubscribe_to_task(params, context): pass assert isinstance( exc_info.value.error, TaskNotFoundError ) # Should be TaskNotFoundError as per spec - mock_task_store.get.assert_awaited_once_with('resub_queue_not_found') + mock_task_store.get.assert_awaited_once_with( + 'resub_queue_not_found', context + ) mock_queue_manager.tap.assert_awaited_once_with('resub_queue_not_found') @@ -1614,13 +1620,14 @@ async def test_list_task_push_notification_config_task_not_found(): params = ListTaskPushNotificationConfigParams(id='non_existent_task') from a2a.utils.errors import ServerError # Local import + context = create_server_call_context() with pytest.raises(ServerError) as exc_info: await request_handler.on_list_task_push_notification_config( - params, create_server_call_context() + params, context ) assert isinstance(exc_info.value.error, TaskNotFoundError) - mock_task_store.get.assert_awaited_once_with('non_existent_task') + mock_task_store.get.assert_awaited_once_with('non_existent_task', context) mock_push_store.get_info.assert_not_awaited() @@ -1774,13 +1781,14 @@ async def test_delete_task_push_notification_config_task_not_found(): ) from a2a.utils.errors import ServerError # Local import + context = create_server_call_context() with pytest.raises(ServerError) as exc_info: await request_handler.on_delete_task_push_notification_config( - params, create_server_call_context() + params, context ) assert isinstance(exc_info.value.error, TaskNotFoundError) - mock_task_store.get.assert_awaited_once_with('non_existent_task') + mock_task_store.get.assert_awaited_once_with('non_existent_task', context) mock_push_store.get_info.assert_not_awaited() @@ -2025,10 +2033,9 @@ async def test_on_resubscribe_to_task_in_terminal_state(terminal_state): from a2a.utils.errors import ServerError + context = create_server_call_context() with pytest.raises(ServerError) as exc_info: - async for _ in request_handler.on_resubscribe_to_task( - params, create_server_call_context() - ): + async for _ in request_handler.on_resubscribe_to_task(params, context): pass # pragma: no cover assert isinstance(exc_info.value.error, InvalidParamsError) @@ -2037,7 +2044,7 @@ async def test_on_resubscribe_to_task_in_terminal_state(terminal_state): f'Task {task_id} is in terminal state: {terminal_state.value}' in exc_info.value.error.message ) - mock_task_store.get.assert_awaited_once_with(task_id) + mock_task_store.get.assert_awaited_once_with(task_id, context) @pytest.mark.asyncio diff --git a/tests/server/request_handlers/test_jsonrpc_handler.py b/tests/server/request_handlers/test_jsonrpc_handler.py index 1d1b3c5d1..616cf1318 100644 --- a/tests/server/request_handlers/test_jsonrpc_handler.py +++ b/tests/server/request_handlers/test_jsonrpc_handler.py @@ -1,5 +1,6 @@ import unittest import unittest.async_case + from collections.abc import AsyncGenerator from typing import Any, NoReturn from unittest.mock import AsyncMock, MagicMock, call, patch @@ -26,7 +27,6 @@ AgentCapabilities, AgentCard, Artifact, - AuthenticatedExtendedCardNotConfiguredError, CancelTaskRequest, CancelTaskSuccessResponse, DeleteTaskPushNotificationConfigParams, @@ -74,6 +74,7 @@ ) from a2a.utils.errors import ServerError + MINIMAL_TASK: dict[str, Any] = { 'id': 'task_123', 'contextId': 'session-xyz', @@ -113,7 +114,7 @@ async def test_on_get_task_success(self) -> None: ) self.assertIsInstance(response.root, GetTaskSuccessResponse) assert response.root.result == mock_task # type: ignore - mock_task_store.get.assert_called_once_with(task_id) + mock_task_store.get.assert_called_once_with(task_id, unittest.mock.ANY) async def test_on_get_task_not_found(self) -> None: mock_agent_executor = AsyncMock(spec=AgentExecutor) @@ -208,7 +209,9 @@ async def test_on_cancel_task_not_found(self) -> None: response = await handler.on_cancel_task(request) self.assertIsInstance(response.root, JSONRPCErrorResponse) assert response.root.error == TaskNotFoundError() # type: ignore - mock_task_store.get.assert_called_once_with('nonexistent_id') + mock_task_store.get.assert_called_once_with( + 'nonexistent_id', unittest.mock.ANY + ) mock_agent_executor.cancel.assert_not_called() @patch( diff --git a/tests/server/tasks/test_task_manager.py b/tests/server/tasks/test_task_manager.py index 4f2431574..8208ca780 100644 --- a/tests/server/tasks/test_task_manager.py +++ b/tests/server/tasks/test_task_manager.py @@ -68,7 +68,7 @@ async def test_get_task_existing( mock_task_store.get.return_value = expected_task retrieved_task = await task_manager.get_task() assert retrieved_task == expected_task - mock_task_store.get.assert_called_once_with(MINIMAL_TASK['id']) + mock_task_store.get.assert_called_once_with(MINIMAL_TASK['id'], None) @pytest.mark.asyncio @@ -79,7 +79,7 @@ async def test_get_task_nonexistent( mock_task_store.get.return_value = None retrieved_task = await task_manager.get_task() assert retrieved_task is None - mock_task_store.get.assert_called_once_with(MINIMAL_TASK['id']) + mock_task_store.get.assert_called_once_with(MINIMAL_TASK['id'], None) @pytest.mark.asyncio @@ -89,7 +89,7 @@ async def test_save_task_event_new_task( """Test saving a new task.""" task = Task(**MINIMAL_TASK) await task_manager.save_task_event(task) - mock_task_store.save.assert_called_once_with(task) + mock_task_store.save.assert_called_once_with(task, None) @pytest.mark.asyncio @@ -116,7 +116,7 @@ async def test_save_task_event_status_update( await task_manager.save_task_event(event) updated_task = initial_task updated_task.status = new_status - mock_task_store.save.assert_called_once_with(updated_task) + mock_task_store.save.assert_called_once_with(updated_task, None) @pytest.mark.asyncio @@ -139,7 +139,7 @@ async def test_save_task_event_artifact_update( await task_manager.save_task_event(event) updated_task = initial_task updated_task.artifacts = [new_artifact] - mock_task_store.save.assert_called_once_with(updated_task) + mock_task_store.save.assert_called_once_with(updated_task, None) @pytest.mark.asyncio @@ -179,7 +179,7 @@ async def test_ensure_task_existing( ) retrieved_task = await task_manager.ensure_task(event) assert retrieved_task == expected_task - mock_task_store.get.assert_called_once_with(MINIMAL_TASK['id']) + mock_task_store.get.assert_called_once_with(MINIMAL_TASK['id'], None) @pytest.mark.asyncio @@ -204,7 +204,7 @@ async def test_ensure_task_nonexistent( assert new_task.id == 'new-task' assert new_task.context_id == 'some-context' assert new_task.status.state == TaskState.submitted - mock_task_store.save.assert_called_once_with(new_task) + mock_task_store.save.assert_called_once_with(new_task, None) assert task_manager_without_id.task_id == 'new-task' assert task_manager_without_id.context_id == 'some-context' @@ -225,7 +225,7 @@ async def test_save_task( """Test saving a task.""" task = Task(**MINIMAL_TASK) await task_manager._save_task(task) # type: ignore - mock_task_store.save.assert_called_once_with(task) + mock_task_store.save.assert_called_once_with(task, None) @pytest.mark.asyncio @@ -264,7 +264,7 @@ async def test_save_task_event_new_task_no_task_id( } task = Task(**task_data) await task_manager_without_id.save_task_event(task) - mock_task_store.save.assert_called_once_with(task) + mock_task_store.save.assert_called_once_with(task, None) assert task_manager_without_id.task_id == 'new-task-id' assert task_manager_without_id.context_id == 'some-context' # initial submit should be updated to working From 1e4b57457386875b64362113356c615bc87315e3 Mon Sep 17 00:00:00 2001 From: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Date: Tue, 2 Sep 2025 17:51:25 +0100 Subject: [PATCH 005/384] perf: Improve performance and code style for `proto_utils.py` (#452) - Pre-compile regular expressions - Use `cls` instead of `ClassName` - Change `ToProto.data()` to use `dict_to_struct()` - Reduce duplication by combining `ToProto.update_event()` and `ToProto.stream_response()` - Added missing conversion for type `MutualTlsSecurityScheme` --- src/a2a/utils/proto_utils.py | 174 ++++++++++++++++------------------- 1 file changed, 80 insertions(+), 94 deletions(-) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index d8c07f7c3..3806e618f 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -18,12 +18,34 @@ # Regexp patterns for matching -_TASK_NAME_MATCH = r'tasks/([\w-]+)' -_TASK_PUSH_CONFIG_NAME_MATCH = ( +_TASK_NAME_MATCH = re.compile(r'tasks/([\w-]+)') +_TASK_PUSH_CONFIG_NAME_MATCH = re.compile( r'tasks/([\w-]+)/pushNotificationConfigs/([\w-]+)' ) +def dict_to_struct(dictionary: dict[str, Any]) -> struct_pb2.Struct: + """Converts a Python dict to a Struct proto. + + Unfortunately, using `json_format.ParseDict` does not work because this + wants the dictionary to be an exact match of the Struct proto with fields + and keys and values, not the traditional Python dict structure. + + Args: + dictionary: The Python dict to convert. + + Returns: + The Struct proto. + """ + struct = struct_pb2.Struct() + for key, val in dictionary.items(): + if isinstance(val, dict): + struct[key] = dict_to_struct(val) + else: + struct[key] = val + return struct + + class ToProto: """Converts Python types to proto types.""" @@ -33,11 +55,11 @@ def message(cls, message: types.Message | None) -> a2a_pb2.Message | None: return None return a2a_pb2.Message( message_id=message.message_id, - content=[ToProto.part(p) for p in message.parts], + content=[cls.part(p) for p in message.parts], context_id=message.context_id or '', task_id=message.task_id or '', role=cls.role(message.role), - metadata=ToProto.metadata(message.metadata), + metadata=cls.metadata(message.metadata), ) @classmethod @@ -53,20 +75,14 @@ def part(cls, part: types.Part) -> a2a_pb2.Part: if isinstance(part.root, types.TextPart): return a2a_pb2.Part(text=part.root.text) if isinstance(part.root, types.FilePart): - return a2a_pb2.Part(file=ToProto.file(part.root.file)) + return a2a_pb2.Part(file=cls.file(part.root.file)) if isinstance(part.root, types.DataPart): - return a2a_pb2.Part(data=ToProto.data(part.root.data)) + return a2a_pb2.Part(data=cls.data(part.root.data)) raise ValueError(f'Unsupported part type: {part.root}') @classmethod def data(cls, data: dict[str, Any]) -> a2a_pb2.DataPart: - json_data = json.dumps(data) - return a2a_pb2.DataPart( - data=json_format.Parse( - json_data, - struct_pb2.Struct(), - ) - ) + return a2a_pb2.DataPart(data=dict_to_struct(data)) @classmethod def file( @@ -87,14 +103,14 @@ def task(cls, task: types.Task) -> a2a_pb2.Task: return a2a_pb2.Task( id=task.id, context_id=task.context_id, - status=ToProto.task_status(task.status), + status=cls.task_status(task.status), artifacts=( - [ToProto.artifact(a) for a in task.artifacts] + [cls.artifact(a) for a in task.artifacts] if task.artifacts else None ), history=( - [ToProto.message(h) for h in task.history] # type: ignore[misc] + [cls.message(h) for h in task.history] # type: ignore[misc] if task.history else None ), @@ -103,8 +119,8 @@ def task(cls, task: types.Task) -> a2a_pb2.Task: @classmethod def task_status(cls, status: types.TaskStatus) -> a2a_pb2.TaskStatus: return a2a_pb2.TaskStatus( - state=ToProto.task_state(status.state), - update=ToProto.message(status.message), + state=cls.task_state(status.state), + update=cls.message(status.message), ) @classmethod @@ -132,9 +148,9 @@ def artifact(cls, artifact: types.Artifact) -> a2a_pb2.Artifact: return a2a_pb2.Artifact( artifact_id=artifact.artifact_id, description=artifact.description, - metadata=ToProto.metadata(artifact.metadata), + metadata=cls.metadata(artifact.metadata), name=artifact.name, - parts=[ToProto.part(p) for p in artifact.parts], + parts=[cls.part(p) for p in artifact.parts], ) @classmethod @@ -151,7 +167,7 @@ def push_notification_config( cls, config: types.PushNotificationConfig ) -> a2a_pb2.PushNotificationConfig: auth_info = ( - ToProto.authentication_info(config.authentication) + cls.authentication_info(config.authentication) if config.authentication else None ) @@ -169,8 +185,8 @@ def task_artifact_update_event( return a2a_pb2.TaskArtifactUpdateEvent( task_id=event.task_id, context_id=event.context_id, - artifact=ToProto.artifact(event.artifact), - metadata=ToProto.metadata(event.metadata), + artifact=cls.artifact(event.artifact), + metadata=cls.metadata(event.metadata), append=event.append or False, last_chunk=event.last_chunk or False, ) @@ -182,8 +198,8 @@ def task_status_update_event( return a2a_pb2.TaskStatusUpdateEvent( task_id=event.task_id, context_id=event.context_id, - status=ToProto.task_status(event.status), - metadata=ToProto.metadata(event.metadata), + status=cls.task_status(event.status), + metadata=cls.metadata(event.metadata), final=event.final, ) @@ -195,7 +211,7 @@ def message_send_configuration( return a2a_pb2.SendMessageConfiguration() return a2a_pb2.SendMessageConfiguration( accepted_output_modes=config.accepted_output_modes, - push_notification=ToProto.push_notification_config( + push_notification=cls.push_notification_config( config.push_notification_config ) if config.push_notification_config @@ -213,19 +229,7 @@ def update_event( | types.TaskArtifactUpdateEvent, ) -> a2a_pb2.StreamResponse: """Converts a task, message, or task update event to a StreamResponse.""" - if isinstance(event, types.TaskStatusUpdateEvent): - return a2a_pb2.StreamResponse( - status_update=ToProto.task_status_update_event(event) - ) - if isinstance(event, types.TaskArtifactUpdateEvent): - return a2a_pb2.StreamResponse( - artifact_update=ToProto.task_artifact_update_event(event) - ) - if isinstance(event, types.Message): - return a2a_pb2.StreamResponse(msg=ToProto.message(event)) - if isinstance(event, types.Task): - return a2a_pb2.StreamResponse(task=ToProto.task(event)) - raise ValueError(f'Unsupported event type: {type(event)}') + return cls.stream_response(event) @classmethod def task_or_message( @@ -257,9 +261,11 @@ def stream_response( return a2a_pb2.StreamResponse( status_update=cls.task_status_update_event(event), ) - return a2a_pb2.StreamResponse( - artifact_update=cls.task_artifact_update_event(event), - ) + if isinstance(event, types.TaskArtifactUpdateEvent): + return a2a_pb2.StreamResponse( + artifact_update=cls.task_artifact_update_event(event), + ) + raise ValueError(f'Unsupported event type: {type(event)}') @classmethod def task_push_notification_config( @@ -480,11 +486,11 @@ class FromProto: def message(cls, message: a2a_pb2.Message) -> types.Message: return types.Message( message_id=message.message_id, - parts=[FromProto.part(p) for p in message.content], + parts=[cls.part(p) for p in message.content], context_id=message.context_id or None, task_id=message.task_id or None, - role=FromProto.role(message.role), - metadata=FromProto.metadata(message.metadata), + role=cls.role(message.role), + metadata=cls.metadata(message.metadata), ) @classmethod @@ -498,13 +504,9 @@ def part(cls, part: a2a_pb2.Part) -> types.Part: if part.HasField('text'): return types.Part(root=types.TextPart(text=part.text)) if part.HasField('file'): - return types.Part( - root=types.FilePart(file=FromProto.file(part.file)) - ) + return types.Part(root=types.FilePart(file=cls.file(part.file))) if part.HasField('data'): - return types.Part( - root=types.DataPart(data=FromProto.data(part.data)) - ) + return types.Part(root=types.DataPart(data=cls.data(part.data))) raise ValueError(f'Unsupported part type: {part}') @classmethod @@ -543,16 +545,16 @@ def task(cls, task: a2a_pb2.Task) -> types.Task: return types.Task( id=task.id, context_id=task.context_id, - status=FromProto.task_status(task.status), - artifacts=[FromProto.artifact(a) for a in task.artifacts], - history=[FromProto.message(h) for h in task.history], + status=cls.task_status(task.status), + artifacts=[cls.artifact(a) for a in task.artifacts], + history=[cls.message(h) for h in task.history], ) @classmethod def task_status(cls, status: a2a_pb2.TaskStatus) -> types.TaskStatus: return types.TaskStatus( - state=FromProto.task_state(status.state), - message=FromProto.message(status.update), + state=cls.task_state(status.state), + message=cls.message(status.update), ) @classmethod @@ -580,9 +582,9 @@ def artifact(cls, artifact: a2a_pb2.Artifact) -> types.Artifact: return types.Artifact( artifact_id=artifact.artifact_id, description=artifact.description, - metadata=FromProto.metadata(artifact.metadata), + metadata=cls.metadata(artifact.metadata), name=artifact.name, - parts=[FromProto.part(p) for p in artifact.parts], + parts=[cls.part(p) for p in artifact.parts], ) @classmethod @@ -592,8 +594,8 @@ def task_artifact_update_event( return types.TaskArtifactUpdateEvent( task_id=event.task_id, context_id=event.context_id, - artifact=FromProto.artifact(event.artifact), - metadata=FromProto.metadata(event.metadata), + artifact=cls.artifact(event.artifact), + metadata=cls.metadata(event.metadata), append=event.append, last_chunk=event.last_chunk, ) @@ -605,8 +607,8 @@ def task_status_update_event( return types.TaskStatusUpdateEvent( task_id=event.task_id, context_id=event.context_id, - status=FromProto.task_status(event.status), - metadata=FromProto.metadata(event.metadata), + status=cls.task_status(event.status), + metadata=cls.metadata(event.metadata), final=event.final, ) @@ -618,7 +620,7 @@ def push_notification_config( id=config.id, url=config.url, token=config.token, - authentication=FromProto.authentication_info(config.authentication) + authentication=cls.authentication_info(config.authentication) if config.HasField('authentication') else None, ) @@ -638,7 +640,7 @@ def message_send_configuration( ) -> types.MessageSendConfiguration: return types.MessageSendConfiguration( accepted_output_modes=list(config.accepted_output_modes), - push_notification_config=FromProto.push_notification_config( + push_notification_config=cls.push_notification_config( config.push_notification ) if config.HasField('push_notification') @@ -666,10 +668,8 @@ def task_id_params( | a2a_pb2.GetTaskPushNotificationConfigRequest ), ) -> types.TaskIdParams: - # This is currently incomplete until the core sdk supports multiple - # configs for a single task. if isinstance(request, a2a_pb2.GetTaskPushNotificationConfigRequest): - m = re.match(_TASK_PUSH_CONFIG_NAME_MATCH, request.name) + m = _TASK_PUSH_CONFIG_NAME_MATCH.match(request.name) if not m: raise ServerError( error=types.InvalidParamsError( @@ -677,7 +677,7 @@ def task_id_params( ) ) return types.TaskIdParams(id=m.group(1)) - m = re.match(_TASK_NAME_MATCH, request.name) + m = _TASK_NAME_MATCH.match(request.name) if not m: raise ServerError( error=types.InvalidParamsError( @@ -691,7 +691,7 @@ def task_push_notification_config_request( cls, request: a2a_pb2.CreateTaskPushNotificationConfigRequest, ) -> types.TaskPushNotificationConfig: - m = re.match(_TASK_NAME_MATCH, request.parent) + m = _TASK_NAME_MATCH.match(request.parent) if not m: raise ServerError( error=types.InvalidParamsError( @@ -710,7 +710,7 @@ def task_push_notification_config( cls, config: a2a_pb2.TaskPushNotificationConfig, ) -> types.TaskPushNotificationConfig: - m = re.match(_TASK_PUSH_CONFIG_NAME_MATCH, config.name) + m = _TASK_PUSH_CONFIG_NAME_MATCH.match(config.name) if not m: raise ServerError( error=types.InvalidParamsError( @@ -767,7 +767,7 @@ def task_query_params( cls, request: a2a_pb2.GetTaskRequest, ) -> types.TaskQueryParams: - m = re.match(_TASK_NAME_MATCH, request.name) + m = _TASK_NAME_MATCH.match(request.name) if not m: raise ServerError( error=types.InvalidParamsError( @@ -862,6 +862,12 @@ def security_scheme( flows=cls.oauth2_flows(scheme.oauth2_security_scheme.flows), ) ) + if scheme.HasField('mtls_security_scheme'): + return types.SecurityScheme( + root=types.MutualTLSSecurityScheme( + description=scheme.mtls_security_scheme.description, + ) + ) return types.SecurityScheme( root=types.OpenIdConnectSecurityScheme( description=scheme.open_id_connect_security_scheme.description, @@ -920,7 +926,9 @@ def stream_response( return cls.task(response.task) if response.HasField('status_update'): return cls.task_status_update_event(response.status_update) - return cls.task_artifact_update_event(response.artifact_update) + if response.HasField('artifact_update'): + return cls.task_artifact_update_event(response.artifact_update) + raise ValueError('Unsupported StreamResponse type') @classmethod def skill(cls, skill: a2a_pb2.AgentSkill) -> types.AgentSkill: @@ -943,25 +951,3 @@ def role(cls, role: a2a_pb2.Role) -> types.Role: return types.Role.agent case _: return types.Role.agent - - -def dict_to_struct(dictionary: dict[str, Any]) -> struct_pb2.Struct: - """Converts a Python dict to a Struct proto. - - Unfortunately, using `json_format.ParseDict` does not work because this - wants the dictionary to be an exact match of the Struct proto with fields - and keys and values, not the traditional Python dict structure. - - Args: - dictionary: The Python dict to convert. - - Returns: - The Struct proto. - """ - struct = struct_pb2.Struct() - for key, val in dictionary.items(): - if isinstance(val, dict): - struct[key] = dict_to_struct(val) - else: - struct[key] = val - return struct From 6941b0617a324074c9582c0c6202b1261043467b Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Tue, 2 Sep 2025 17:53:03 +0100 Subject: [PATCH 006/384] chore(main): release 0.3.4 (#432) :robot: I have created a release *beep* *boop* --- ## [0.3.4](https://github.com/a2aproject/a2a-python/compare/v0.3.3...v0.3.4) (2025-09-02) ### Features * Add `ServerCallContext` into task store operations ([#443](https://github.com/a2aproject/a2a-python/issues/443)) ([e3e5c4b](https://github.com/a2aproject/a2a-python/commit/e3e5c4b7dcb5106e943b9aeb8e761ed23cc166a2)) * Add extensions support to `TaskUpdater.add_artifact` ([#436](https://github.com/a2aproject/a2a-python/issues/436)) ([598d8a1](https://github.com/a2aproject/a2a-python/commit/598d8a10e61be83bcb7bc9377365f7c42bc6af41)) ### Bug Fixes * convert auth_required state in proto utils ([#444](https://github.com/a2aproject/a2a-python/issues/444)) ([ac12f05](https://github.com/a2aproject/a2a-python/commit/ac12f0527d923800192c47dc1bd2e7eed262dfe6)) * handle concurrent task completion during cancellation ([#449](https://github.com/a2aproject/a2a-python/issues/449)) ([f4c9c18](https://github.com/a2aproject/a2a-python/commit/f4c9c18cfef3ccab1ac7bb30cc7f8293cf3e3ef6)) * Remove logger error from init on `rest_adapter` and `jsonrpc_app` ([#439](https://github.com/a2aproject/a2a-python/issues/439)) ([9193208](https://github.com/a2aproject/a2a-python/commit/9193208aabac2655a197732ff826e3c2d76f11b5)) * resolve streaming endpoint deadlock by pre-consuming request body ([#426](https://github.com/a2aproject/a2a-python/issues/426)) ([4186731](https://github.com/a2aproject/a2a-python/commit/4186731df60f7adfcd25f19078d055aca26612a3)) * Sync jsonrpc and rest implementation of authenticated agent card ([#441](https://github.com/a2aproject/a2a-python/issues/441)) ([9da9ecc](https://github.com/a2aproject/a2a-python/commit/9da9ecc96856a2474d75f986a1f45488c36f53e3)) ### Performance Improvements * Improve performance and code style for `proto_utils.py` ([#452](https://github.com/a2aproject/a2a-python/issues/452)) ([1e4b574](https://github.com/a2aproject/a2a-python/commit/1e4b57457386875b64362113356c615bc87315e3)) --- 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 | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 446df51ea..99de68f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [0.3.4](https://github.com/a2aproject/a2a-python/compare/v0.3.3...v0.3.4) (2025-09-02) + + +### Features + +* Add `ServerCallContext` into task store operations ([#443](https://github.com/a2aproject/a2a-python/issues/443)) ([e3e5c4b](https://github.com/a2aproject/a2a-python/commit/e3e5c4b7dcb5106e943b9aeb8e761ed23cc166a2)) +* Add extensions support to `TaskUpdater.add_artifact` ([#436](https://github.com/a2aproject/a2a-python/issues/436)) ([598d8a1](https://github.com/a2aproject/a2a-python/commit/598d8a10e61be83bcb7bc9377365f7c42bc6af41)) + + +### Bug Fixes + +* convert auth_required state in proto utils ([#444](https://github.com/a2aproject/a2a-python/issues/444)) ([ac12f05](https://github.com/a2aproject/a2a-python/commit/ac12f0527d923800192c47dc1bd2e7eed262dfe6)) +* handle concurrent task completion during cancellation ([#449](https://github.com/a2aproject/a2a-python/issues/449)) ([f4c9c18](https://github.com/a2aproject/a2a-python/commit/f4c9c18cfef3ccab1ac7bb30cc7f8293cf3e3ef6)) +* Remove logger error from init on `rest_adapter` and `jsonrpc_app` ([#439](https://github.com/a2aproject/a2a-python/issues/439)) ([9193208](https://github.com/a2aproject/a2a-python/commit/9193208aabac2655a197732ff826e3c2d76f11b5)) +* resolve streaming endpoint deadlock by pre-consuming request body ([#426](https://github.com/a2aproject/a2a-python/issues/426)) ([4186731](https://github.com/a2aproject/a2a-python/commit/4186731df60f7adfcd25f19078d055aca26612a3)) +* Sync jsonrpc and rest implementation of authenticated agent card ([#441](https://github.com/a2aproject/a2a-python/issues/441)) ([9da9ecc](https://github.com/a2aproject/a2a-python/commit/9da9ecc96856a2474d75f986a1f45488c36f53e3)) + + +### Performance Improvements + +* Improve performance and code style for `proto_utils.py` ([#452](https://github.com/a2aproject/a2a-python/issues/452)) ([1e4b574](https://github.com/a2aproject/a2a-python/commit/1e4b57457386875b64362113356c615bc87315e3)) + ## [0.3.3](https://github.com/a2aproject/a2a-python/compare/v0.3.2...v0.3.3) (2025-08-22) From 6d0ef593adaa22b2af0a5dd1a186646c180e3f8c Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Tue, 2 Sep 2025 18:10:59 +0100 Subject: [PATCH 007/384] fix(proto): Adds metadata field to A2A DataPart proto (#455) - Commit: https://github.com/a2aproject/A2A/commit/a8b45dcc429a5571ef8a24c36336bf84b89bbd7f - Commit: https://github.com/a2aproject/A2A/commit/b3b266d127dde3d1000ec103b252d1de81289e83 --------- Co-authored-by: Holt Skinner --- src/a2a/grpc/a2a_pb2.py | 206 +++++++++++++++++------------------ src/a2a/grpc/a2a_pb2.pyi | 6 +- src/a2a/utils/proto_utils.py | 41 ++++++- 3 files changed, 142 insertions(+), 111 deletions(-) diff --git a/src/a2a/grpc/a2a_pb2.py b/src/a2a/grpc/a2a_pb2.py index ac42838ac..9b4b73013 100644 --- a/src/a2a/grpc/a2a_pb2.py +++ b/src/a2a/grpc/a2a_pb2.py @@ -30,7 +30,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\ta2a.proto\x12\x06\x61\x32\x61.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xde\x01\n\x18SendMessageConfiguration\x12\x32\n\x15\x61\x63\x63\x65pted_output_modes\x18\x01 \x03(\tR\x13\x61\x63\x63\x65ptedOutputModes\x12K\n\x11push_notification\x18\x02 \x01(\x0b\x32\x1e.a2a.v1.PushNotificationConfigR\x10pushNotification\x12%\n\x0ehistory_length\x18\x03 \x01(\x05R\rhistoryLength\x12\x1a\n\x08\x62locking\x18\x04 \x01(\x08R\x08\x62locking\"\xf1\x01\n\x04Task\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12*\n\x06status\x18\x03 \x01(\x0b\x32\x12.a2a.v1.TaskStatusR\x06status\x12.\n\tartifacts\x18\x04 \x03(\x0b\x32\x10.a2a.v1.ArtifactR\tartifacts\x12)\n\x07history\x18\x05 \x03(\x0b\x32\x0f.a2a.v1.MessageR\x07history\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"\x99\x01\n\nTaskStatus\x12\'\n\x05state\x18\x01 \x01(\x0e\x32\x11.a2a.v1.TaskStateR\x05state\x12(\n\x06update\x18\x02 \x01(\x0b\x32\x0f.a2a.v1.MessageR\x07message\x12\x38\n\ttimestamp\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\ttimestamp\"t\n\x04Part\x12\x14\n\x04text\x18\x01 \x01(\tH\x00R\x04text\x12&\n\x04\x66ile\x18\x02 \x01(\x0b\x32\x10.a2a.v1.FilePartH\x00R\x04\x66ile\x12&\n\x04\x64\x61ta\x18\x03 \x01(\x0b\x32\x10.a2a.v1.DataPartH\x00R\x04\x64\x61taB\x06\n\x04part\"\x93\x01\n\x08\x46ilePart\x12$\n\rfile_with_uri\x18\x01 \x01(\tH\x00R\x0b\x66ileWithUri\x12(\n\x0f\x66ile_with_bytes\x18\x02 \x01(\x0cH\x00R\rfileWithBytes\x12\x1b\n\tmime_type\x18\x03 \x01(\tR\x08mimeType\x12\x12\n\x04name\x18\x04 \x01(\tR\x04nameB\x06\n\x04\x66ile\"7\n\x08\x44\x61taPart\x12+\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.StructR\x04\x64\x61ta\"\xff\x01\n\x07Message\x12\x1d\n\nmessage_id\x18\x01 \x01(\tR\tmessageId\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12\x17\n\x07task_id\x18\x03 \x01(\tR\x06taskId\x12 \n\x04role\x18\x04 \x01(\x0e\x32\x0c.a2a.v1.RoleR\x04role\x12&\n\x07\x63ontent\x18\x05 \x03(\x0b\x32\x0c.a2a.v1.PartR\x07\x63ontent\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\x12\x1e\n\nextensions\x18\x07 \x03(\tR\nextensions\"\xda\x01\n\x08\x41rtifact\x12\x1f\n\x0b\x61rtifact_id\x18\x01 \x01(\tR\nartifactId\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\x12 \n\x0b\x64\x65scription\x18\x04 \x01(\tR\x0b\x64\x65scription\x12\"\n\x05parts\x18\x05 \x03(\x0b\x32\x0c.a2a.v1.PartR\x05parts\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\x12\x1e\n\nextensions\x18\x07 \x03(\tR\nextensions\"\xc6\x01\n\x15TaskStatusUpdateEvent\x12\x17\n\x07task_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12*\n\x06status\x18\x03 \x01(\x0b\x32\x12.a2a.v1.TaskStatusR\x06status\x12\x14\n\x05\x66inal\x18\x04 \x01(\x08R\x05\x66inal\x12\x33\n\x08metadata\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"\xeb\x01\n\x17TaskArtifactUpdateEvent\x12\x17\n\x07task_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12,\n\x08\x61rtifact\x18\x03 \x01(\x0b\x32\x10.a2a.v1.ArtifactR\x08\x61rtifact\x12\x16\n\x06\x61ppend\x18\x04 \x01(\x08R\x06\x61ppend\x12\x1d\n\nlast_chunk\x18\x05 \x01(\x08R\tlastChunk\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"\x94\x01\n\x16PushNotificationConfig\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n\x03url\x18\x02 \x01(\tR\x03url\x12\x14\n\x05token\x18\x03 \x01(\tR\x05token\x12\x42\n\x0e\x61uthentication\x18\x04 \x01(\x0b\x32\x1a.a2a.v1.AuthenticationInfoR\x0e\x61uthentication\"P\n\x12\x41uthenticationInfo\x12\x18\n\x07schemes\x18\x01 \x03(\tR\x07schemes\x12 \n\x0b\x63redentials\x18\x02 \x01(\tR\x0b\x63redentials\"@\n\x0e\x41gentInterface\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\x1c\n\ttransport\x18\x02 \x01(\tR\ttransport\"\xc8\x07\n\tAgentCard\x12)\n\x10protocol_version\x18\x10 \x01(\tR\x0fprotocolVersion\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12 \n\x0b\x64\x65scription\x18\x02 \x01(\tR\x0b\x64\x65scription\x12\x10\n\x03url\x18\x03 \x01(\tR\x03url\x12/\n\x13preferred_transport\x18\x0e \x01(\tR\x12preferredTransport\x12K\n\x15\x61\x64\x64itional_interfaces\x18\x0f \x03(\x0b\x32\x16.a2a.v1.AgentInterfaceR\x14\x61\x64\x64itionalInterfaces\x12\x31\n\x08provider\x18\x04 \x01(\x0b\x32\x15.a2a.v1.AgentProviderR\x08provider\x12\x18\n\x07version\x18\x05 \x01(\tR\x07version\x12+\n\x11\x64ocumentation_url\x18\x06 \x01(\tR\x10\x64ocumentationUrl\x12=\n\x0c\x63\x61pabilities\x18\x07 \x01(\x0b\x32\x19.a2a.v1.AgentCapabilitiesR\x0c\x63\x61pabilities\x12Q\n\x10security_schemes\x18\x08 \x03(\x0b\x32&.a2a.v1.AgentCard.SecuritySchemesEntryR\x0fsecuritySchemes\x12,\n\x08security\x18\t \x03(\x0b\x32\x10.a2a.v1.SecurityR\x08security\x12.\n\x13\x64\x65\x66\x61ult_input_modes\x18\n \x03(\tR\x11\x64\x65\x66\x61ultInputModes\x12\x30\n\x14\x64\x65\x66\x61ult_output_modes\x18\x0b \x03(\tR\x12\x64\x65\x66\x61ultOutputModes\x12*\n\x06skills\x18\x0c \x03(\x0b\x32\x12.a2a.v1.AgentSkillR\x06skills\x12O\n$supports_authenticated_extended_card\x18\r \x01(\x08R!supportsAuthenticatedExtendedCard\x12:\n\nsignatures\x18\x11 \x03(\x0b\x32\x1a.a2a.v1.AgentCardSignatureR\nsignatures\x12\x19\n\x08icon_url\x18\x12 \x01(\tR\x07iconUrl\x1aZ\n\x14SecuritySchemesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12,\n\x05value\x18\x02 \x01(\x0b\x32\x16.a2a.v1.SecuritySchemeR\x05value:\x02\x38\x01\"E\n\rAgentProvider\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\"\n\x0corganization\x18\x02 \x01(\tR\x0corganization\"\x98\x01\n\x11\x41gentCapabilities\x12\x1c\n\tstreaming\x18\x01 \x01(\x08R\tstreaming\x12-\n\x12push_notifications\x18\x02 \x01(\x08R\x11pushNotifications\x12\x36\n\nextensions\x18\x03 \x03(\x0b\x32\x16.a2a.v1.AgentExtensionR\nextensions\"\x91\x01\n\x0e\x41gentExtension\x12\x10\n\x03uri\x18\x01 \x01(\tR\x03uri\x12 \n\x0b\x64\x65scription\x18\x02 \x01(\tR\x0b\x64\x65scription\x12\x1a\n\x08required\x18\x03 \x01(\x08R\x08required\x12/\n\x06params\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x06params\"\xf4\x01\n\nAgentSkill\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n\x04name\x18\x02 \x01(\tR\x04name\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12\x12\n\x04tags\x18\x04 \x03(\tR\x04tags\x12\x1a\n\x08\x65xamples\x18\x05 \x03(\tR\x08\x65xamples\x12\x1f\n\x0binput_modes\x18\x06 \x03(\tR\ninputModes\x12!\n\x0coutput_modes\x18\x07 \x03(\tR\x0boutputModes\x12,\n\x08security\x18\x08 \x03(\x0b\x32\x10.a2a.v1.SecurityR\x08security\"\x8b\x01\n\x12\x41gentCardSignature\x12!\n\tprotected\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tprotected\x12!\n\tsignature\x18\x02 \x01(\tB\x03\xe0\x41\x02R\tsignature\x12/\n\x06header\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\x06header\"\x8a\x01\n\x1aTaskPushNotificationConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12X\n\x18push_notification_config\x18\x02 \x01(\x0b\x32\x1e.a2a.v1.PushNotificationConfigR\x16pushNotificationConfig\" \n\nStringList\x12\x12\n\x04list\x18\x01 \x03(\tR\x04list\"\x93\x01\n\x08Security\x12\x37\n\x07schemes\x18\x01 \x03(\x0b\x32\x1d.a2a.v1.Security.SchemesEntryR\x07schemes\x1aN\n\x0cSchemesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x12.a2a.v1.StringListR\x05value:\x02\x38\x01\"\xe6\x03\n\x0eSecurityScheme\x12U\n\x17\x61pi_key_security_scheme\x18\x01 \x01(\x0b\x32\x1c.a2a.v1.APIKeySecuritySchemeH\x00R\x14\x61piKeySecurityScheme\x12[\n\x19http_auth_security_scheme\x18\x02 \x01(\x0b\x32\x1e.a2a.v1.HTTPAuthSecuritySchemeH\x00R\x16httpAuthSecurityScheme\x12T\n\x16oauth2_security_scheme\x18\x03 \x01(\x0b\x32\x1c.a2a.v1.OAuth2SecuritySchemeH\x00R\x14oauth2SecurityScheme\x12k\n\x1fopen_id_connect_security_scheme\x18\x04 \x01(\x0b\x32#.a2a.v1.OpenIdConnectSecuritySchemeH\x00R\x1bopenIdConnectSecurityScheme\x12S\n\x14mtls_security_scheme\x18\x05 \x01(\x0b\x32\x1f.a2a.v1.MutualTlsSecuritySchemeH\x00R\x12mtlsSecuritySchemeB\x08\n\x06scheme\"h\n\x14\x41PIKeySecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12\x1a\n\x08location\x18\x02 \x01(\tR\x08location\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\"w\n\x16HTTPAuthSecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12\x16\n\x06scheme\x18\x02 \x01(\tR\x06scheme\x12#\n\rbearer_format\x18\x03 \x01(\tR\x0c\x62\x65\x61rerFormat\"\x92\x01\n\x14OAuth2SecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12(\n\x05\x66lows\x18\x02 \x01(\x0b\x32\x12.a2a.v1.OAuthFlowsR\x05\x66lows\x12.\n\x13oauth2_metadata_url\x18\x03 \x01(\tR\x11oauth2MetadataUrl\"n\n\x1bOpenIdConnectSecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12-\n\x13open_id_connect_url\x18\x02 \x01(\tR\x10openIdConnectUrl\";\n\x17MutualTlsSecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\"\xb0\x02\n\nOAuthFlows\x12S\n\x12\x61uthorization_code\x18\x01 \x01(\x0b\x32\".a2a.v1.AuthorizationCodeOAuthFlowH\x00R\x11\x61uthorizationCode\x12S\n\x12\x63lient_credentials\x18\x02 \x01(\x0b\x32\".a2a.v1.ClientCredentialsOAuthFlowH\x00R\x11\x63lientCredentials\x12\x37\n\x08implicit\x18\x03 \x01(\x0b\x32\x19.a2a.v1.ImplicitOAuthFlowH\x00R\x08implicit\x12\x37\n\x08password\x18\x04 \x01(\x0b\x32\x19.a2a.v1.PasswordOAuthFlowH\x00R\x08passwordB\x06\n\x04\x66low\"\x8a\x02\n\x1a\x41uthorizationCodeOAuthFlow\x12+\n\x11\x61uthorization_url\x18\x01 \x01(\tR\x10\x61uthorizationUrl\x12\x1b\n\ttoken_url\x18\x02 \x01(\tR\x08tokenUrl\x12\x1f\n\x0brefresh_url\x18\x03 \x01(\tR\nrefreshUrl\x12\x46\n\x06scopes\x18\x04 \x03(\x0b\x32..a2a.v1.AuthorizationCodeOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdd\x01\n\x1a\x43lientCredentialsOAuthFlow\x12\x1b\n\ttoken_url\x18\x01 \x01(\tR\x08tokenUrl\x12\x1f\n\x0brefresh_url\x18\x02 \x01(\tR\nrefreshUrl\x12\x46\n\x06scopes\x18\x03 \x03(\x0b\x32..a2a.v1.ClientCredentialsOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdb\x01\n\x11ImplicitOAuthFlow\x12+\n\x11\x61uthorization_url\x18\x01 \x01(\tR\x10\x61uthorizationUrl\x12\x1f\n\x0brefresh_url\x18\x02 \x01(\tR\nrefreshUrl\x12=\n\x06scopes\x18\x03 \x03(\x0b\x32%.a2a.v1.ImplicitOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xcb\x01\n\x11PasswordOAuthFlow\x12\x1b\n\ttoken_url\x18\x01 \x01(\tR\x08tokenUrl\x12\x1f\n\x0brefresh_url\x18\x02 \x01(\tR\nrefreshUrl\x12=\n\x06scopes\x18\x03 \x03(\x0b\x32%.a2a.v1.PasswordOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xc1\x01\n\x12SendMessageRequest\x12.\n\x07request\x18\x01 \x01(\x0b\x32\x0f.a2a.v1.MessageB\x03\xe0\x41\x02R\x07message\x12\x46\n\rconfiguration\x18\x02 \x01(\x0b\x32 .a2a.v1.SendMessageConfigurationR\rconfiguration\x12\x33\n\x08metadata\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"P\n\x0eGetTaskRequest\x12\x17\n\x04name\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x04name\x12%\n\x0ehistory_length\x18\x02 \x01(\x05R\rhistoryLength\"\'\n\x11\x43\x61ncelTaskRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\":\n$GetTaskPushNotificationConfigRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\"=\n\'DeleteTaskPushNotificationConfigRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\"\xa9\x01\n\'CreateTaskPushNotificationConfigRequest\x12\x1b\n\x06parent\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x06parent\x12 \n\tconfig_id\x18\x02 \x01(\tB\x03\xe0\x41\x02R\x08\x63onfigId\x12?\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\".a2a.v1.TaskPushNotificationConfigB\x03\xe0\x41\x02R\x06\x63onfig\"-\n\x17TaskSubscriptionRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\"{\n%ListTaskPushNotificationConfigRequest\x12\x16\n\x06parent\x18\x01 \x01(\tR\x06parent\x12\x1b\n\tpage_size\x18\x02 \x01(\x05R\x08pageSize\x12\x1d\n\npage_token\x18\x03 \x01(\tR\tpageToken\"\x15\n\x13GetAgentCardRequest\"m\n\x13SendMessageResponse\x12\"\n\x04task\x18\x01 \x01(\x0b\x32\x0c.a2a.v1.TaskH\x00R\x04task\x12\'\n\x03msg\x18\x02 \x01(\x0b\x32\x0f.a2a.v1.MessageH\x00R\x07messageB\t\n\x07payload\"\xfa\x01\n\x0eStreamResponse\x12\"\n\x04task\x18\x01 \x01(\x0b\x32\x0c.a2a.v1.TaskH\x00R\x04task\x12\'\n\x03msg\x18\x02 \x01(\x0b\x32\x0f.a2a.v1.MessageH\x00R\x07message\x12\x44\n\rstatus_update\x18\x03 \x01(\x0b\x32\x1d.a2a.v1.TaskStatusUpdateEventH\x00R\x0cstatusUpdate\x12J\n\x0f\x61rtifact_update\x18\x04 \x01(\x0b\x32\x1f.a2a.v1.TaskArtifactUpdateEventH\x00R\x0e\x61rtifactUpdateB\t\n\x07payload\"\x8e\x01\n&ListTaskPushNotificationConfigResponse\x12<\n\x07\x63onfigs\x18\x01 \x03(\x0b\x32\".a2a.v1.TaskPushNotificationConfigR\x07\x63onfigs\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken*\xfa\x01\n\tTaskState\x12\x1a\n\x16TASK_STATE_UNSPECIFIED\x10\x00\x12\x18\n\x14TASK_STATE_SUBMITTED\x10\x01\x12\x16\n\x12TASK_STATE_WORKING\x10\x02\x12\x18\n\x14TASK_STATE_COMPLETED\x10\x03\x12\x15\n\x11TASK_STATE_FAILED\x10\x04\x12\x18\n\x14TASK_STATE_CANCELLED\x10\x05\x12\x1d\n\x19TASK_STATE_INPUT_REQUIRED\x10\x06\x12\x17\n\x13TASK_STATE_REJECTED\x10\x07\x12\x1c\n\x18TASK_STATE_AUTH_REQUIRED\x10\x08*;\n\x04Role\x12\x14\n\x10ROLE_UNSPECIFIED\x10\x00\x12\r\n\tROLE_USER\x10\x01\x12\x0e\n\nROLE_AGENT\x10\x02\x32\xbb\n\n\nA2AService\x12\x63\n\x0bSendMessage\x12\x1a.a2a.v1.SendMessageRequest\x1a\x1b.a2a.v1.SendMessageResponse\"\x1b\x82\xd3\xe4\x93\x02\x15\"\x10/v1/message:send:\x01*\x12k\n\x14SendStreamingMessage\x12\x1a.a2a.v1.SendMessageRequest\x1a\x16.a2a.v1.StreamResponse\"\x1d\x82\xd3\xe4\x93\x02\x17\"\x12/v1/message:stream:\x01*0\x01\x12R\n\x07GetTask\x12\x16.a2a.v1.GetTaskRequest\x1a\x0c.a2a.v1.Task\"!\xda\x41\x04name\x82\xd3\xe4\x93\x02\x14\x12\x12/v1/{name=tasks/*}\x12[\n\nCancelTask\x12\x19.a2a.v1.CancelTaskRequest\x1a\x0c.a2a.v1.Task\"$\x82\xd3\xe4\x93\x02\x1e\"\x19/v1/{name=tasks/*}:cancel:\x01*\x12s\n\x10TaskSubscription\x12\x1f.a2a.v1.TaskSubscriptionRequest\x1a\x16.a2a.v1.StreamResponse\"$\x82\xd3\xe4\x93\x02\x1e\x12\x1c/v1/{name=tasks/*}:subscribe0\x01\x12\xc5\x01\n CreateTaskPushNotificationConfig\x12/.a2a.v1.CreateTaskPushNotificationConfigRequest\x1a\".a2a.v1.TaskPushNotificationConfig\"L\xda\x41\rparent,config\x82\xd3\xe4\x93\x02\x36\",/v1/{parent=tasks/*/pushNotificationConfigs}:\x06\x63onfig\x12\xae\x01\n\x1dGetTaskPushNotificationConfig\x12,.a2a.v1.GetTaskPushNotificationConfigRequest\x1a\".a2a.v1.TaskPushNotificationConfig\";\xda\x41\x04name\x82\xd3\xe4\x93\x02.\x12,/v1/{name=tasks/*/pushNotificationConfigs/*}\x12\xbe\x01\n\x1eListTaskPushNotificationConfig\x12-.a2a.v1.ListTaskPushNotificationConfigRequest\x1a..a2a.v1.ListTaskPushNotificationConfigResponse\"=\xda\x41\x06parent\x82\xd3\xe4\x93\x02.\x12,/v1/{parent=tasks/*}/pushNotificationConfigs\x12P\n\x0cGetAgentCard\x12\x1b.a2a.v1.GetAgentCardRequest\x1a\x11.a2a.v1.AgentCard\"\x10\x82\xd3\xe4\x93\x02\n\x12\x08/v1/card\x12\xa8\x01\n DeleteTaskPushNotificationConfig\x12/.a2a.v1.DeleteTaskPushNotificationConfigRequest\x1a\x16.google.protobuf.Empty\";\xda\x41\x04name\x82\xd3\xe4\x93\x02.*,/v1/{name=tasks/*/pushNotificationConfigs/*}Bi\n\ncom.a2a.v1B\x08\x41\x32\x61ProtoP\x01Z\x18google.golang.org/a2a/v1\xa2\x02\x03\x41XX\xaa\x02\x06\x41\x32\x61.V1\xca\x02\x06\x41\x32\x61\\V1\xe2\x02\x12\x41\x32\x61\\V1\\GPBMetadata\xea\x02\x07\x41\x32\x61::V1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\ta2a.proto\x12\x06\x61\x32\x61.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xde\x01\n\x18SendMessageConfiguration\x12\x32\n\x15\x61\x63\x63\x65pted_output_modes\x18\x01 \x03(\tR\x13\x61\x63\x63\x65ptedOutputModes\x12K\n\x11push_notification\x18\x02 \x01(\x0b\x32\x1e.a2a.v1.PushNotificationConfigR\x10pushNotification\x12%\n\x0ehistory_length\x18\x03 \x01(\x05R\rhistoryLength\x12\x1a\n\x08\x62locking\x18\x04 \x01(\x08R\x08\x62locking\"\xf1\x01\n\x04Task\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12*\n\x06status\x18\x03 \x01(\x0b\x32\x12.a2a.v1.TaskStatusR\x06status\x12.\n\tartifacts\x18\x04 \x03(\x0b\x32\x10.a2a.v1.ArtifactR\tartifacts\x12)\n\x07history\x18\x05 \x03(\x0b\x32\x0f.a2a.v1.MessageR\x07history\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"\x99\x01\n\nTaskStatus\x12\'\n\x05state\x18\x01 \x01(\x0e\x32\x11.a2a.v1.TaskStateR\x05state\x12(\n\x06update\x18\x02 \x01(\x0b\x32\x0f.a2a.v1.MessageR\x07message\x12\x38\n\ttimestamp\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\ttimestamp\"\xa9\x01\n\x04Part\x12\x14\n\x04text\x18\x01 \x01(\tH\x00R\x04text\x12&\n\x04\x66ile\x18\x02 \x01(\x0b\x32\x10.a2a.v1.FilePartH\x00R\x04\x66ile\x12&\n\x04\x64\x61ta\x18\x03 \x01(\x0b\x32\x10.a2a.v1.DataPartH\x00R\x04\x64\x61ta\x12\x33\n\x08metadata\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadataB\x06\n\x04part\"\x93\x01\n\x08\x46ilePart\x12$\n\rfile_with_uri\x18\x01 \x01(\tH\x00R\x0b\x66ileWithUri\x12(\n\x0f\x66ile_with_bytes\x18\x02 \x01(\x0cH\x00R\rfileWithBytes\x12\x1b\n\tmime_type\x18\x03 \x01(\tR\x08mimeType\x12\x12\n\x04name\x18\x04 \x01(\tR\x04nameB\x06\n\x04\x66ile\"7\n\x08\x44\x61taPart\x12+\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.StructR\x04\x64\x61ta\"\xff\x01\n\x07Message\x12\x1d\n\nmessage_id\x18\x01 \x01(\tR\tmessageId\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12\x17\n\x07task_id\x18\x03 \x01(\tR\x06taskId\x12 \n\x04role\x18\x04 \x01(\x0e\x32\x0c.a2a.v1.RoleR\x04role\x12&\n\x07\x63ontent\x18\x05 \x03(\x0b\x32\x0c.a2a.v1.PartR\x07\x63ontent\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\x12\x1e\n\nextensions\x18\x07 \x03(\tR\nextensions\"\xda\x01\n\x08\x41rtifact\x12\x1f\n\x0b\x61rtifact_id\x18\x01 \x01(\tR\nartifactId\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\x12 \n\x0b\x64\x65scription\x18\x04 \x01(\tR\x0b\x64\x65scription\x12\"\n\x05parts\x18\x05 \x03(\x0b\x32\x0c.a2a.v1.PartR\x05parts\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\x12\x1e\n\nextensions\x18\x07 \x03(\tR\nextensions\"\xc6\x01\n\x15TaskStatusUpdateEvent\x12\x17\n\x07task_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12*\n\x06status\x18\x03 \x01(\x0b\x32\x12.a2a.v1.TaskStatusR\x06status\x12\x14\n\x05\x66inal\x18\x04 \x01(\x08R\x05\x66inal\x12\x33\n\x08metadata\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"\xeb\x01\n\x17TaskArtifactUpdateEvent\x12\x17\n\x07task_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12,\n\x08\x61rtifact\x18\x03 \x01(\x0b\x32\x10.a2a.v1.ArtifactR\x08\x61rtifact\x12\x16\n\x06\x61ppend\x18\x04 \x01(\x08R\x06\x61ppend\x12\x1d\n\nlast_chunk\x18\x05 \x01(\x08R\tlastChunk\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"\x94\x01\n\x16PushNotificationConfig\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n\x03url\x18\x02 \x01(\tR\x03url\x12\x14\n\x05token\x18\x03 \x01(\tR\x05token\x12\x42\n\x0e\x61uthentication\x18\x04 \x01(\x0b\x32\x1a.a2a.v1.AuthenticationInfoR\x0e\x61uthentication\"P\n\x12\x41uthenticationInfo\x12\x18\n\x07schemes\x18\x01 \x03(\tR\x07schemes\x12 \n\x0b\x63redentials\x18\x02 \x01(\tR\x0b\x63redentials\"@\n\x0e\x41gentInterface\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\x1c\n\ttransport\x18\x02 \x01(\tR\ttransport\"\xc8\x07\n\tAgentCard\x12)\n\x10protocol_version\x18\x10 \x01(\tR\x0fprotocolVersion\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12 \n\x0b\x64\x65scription\x18\x02 \x01(\tR\x0b\x64\x65scription\x12\x10\n\x03url\x18\x03 \x01(\tR\x03url\x12/\n\x13preferred_transport\x18\x0e \x01(\tR\x12preferredTransport\x12K\n\x15\x61\x64\x64itional_interfaces\x18\x0f \x03(\x0b\x32\x16.a2a.v1.AgentInterfaceR\x14\x61\x64\x64itionalInterfaces\x12\x31\n\x08provider\x18\x04 \x01(\x0b\x32\x15.a2a.v1.AgentProviderR\x08provider\x12\x18\n\x07version\x18\x05 \x01(\tR\x07version\x12+\n\x11\x64ocumentation_url\x18\x06 \x01(\tR\x10\x64ocumentationUrl\x12=\n\x0c\x63\x61pabilities\x18\x07 \x01(\x0b\x32\x19.a2a.v1.AgentCapabilitiesR\x0c\x63\x61pabilities\x12Q\n\x10security_schemes\x18\x08 \x03(\x0b\x32&.a2a.v1.AgentCard.SecuritySchemesEntryR\x0fsecuritySchemes\x12,\n\x08security\x18\t \x03(\x0b\x32\x10.a2a.v1.SecurityR\x08security\x12.\n\x13\x64\x65\x66\x61ult_input_modes\x18\n \x03(\tR\x11\x64\x65\x66\x61ultInputModes\x12\x30\n\x14\x64\x65\x66\x61ult_output_modes\x18\x0b \x03(\tR\x12\x64\x65\x66\x61ultOutputModes\x12*\n\x06skills\x18\x0c \x03(\x0b\x32\x12.a2a.v1.AgentSkillR\x06skills\x12O\n$supports_authenticated_extended_card\x18\r \x01(\x08R!supportsAuthenticatedExtendedCard\x12:\n\nsignatures\x18\x11 \x03(\x0b\x32\x1a.a2a.v1.AgentCardSignatureR\nsignatures\x12\x19\n\x08icon_url\x18\x12 \x01(\tR\x07iconUrl\x1aZ\n\x14SecuritySchemesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12,\n\x05value\x18\x02 \x01(\x0b\x32\x16.a2a.v1.SecuritySchemeR\x05value:\x02\x38\x01\"E\n\rAgentProvider\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\"\n\x0corganization\x18\x02 \x01(\tR\x0corganization\"\x98\x01\n\x11\x41gentCapabilities\x12\x1c\n\tstreaming\x18\x01 \x01(\x08R\tstreaming\x12-\n\x12push_notifications\x18\x02 \x01(\x08R\x11pushNotifications\x12\x36\n\nextensions\x18\x03 \x03(\x0b\x32\x16.a2a.v1.AgentExtensionR\nextensions\"\x91\x01\n\x0e\x41gentExtension\x12\x10\n\x03uri\x18\x01 \x01(\tR\x03uri\x12 \n\x0b\x64\x65scription\x18\x02 \x01(\tR\x0b\x64\x65scription\x12\x1a\n\x08required\x18\x03 \x01(\x08R\x08required\x12/\n\x06params\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x06params\"\xf4\x01\n\nAgentSkill\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n\x04name\x18\x02 \x01(\tR\x04name\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12\x12\n\x04tags\x18\x04 \x03(\tR\x04tags\x12\x1a\n\x08\x65xamples\x18\x05 \x03(\tR\x08\x65xamples\x12\x1f\n\x0binput_modes\x18\x06 \x03(\tR\ninputModes\x12!\n\x0coutput_modes\x18\x07 \x03(\tR\x0boutputModes\x12,\n\x08security\x18\x08 \x03(\x0b\x32\x10.a2a.v1.SecurityR\x08security\"\x8b\x01\n\x12\x41gentCardSignature\x12!\n\tprotected\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tprotected\x12!\n\tsignature\x18\x02 \x01(\tB\x03\xe0\x41\x02R\tsignature\x12/\n\x06header\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\x06header\"\x8a\x01\n\x1aTaskPushNotificationConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12X\n\x18push_notification_config\x18\x02 \x01(\x0b\x32\x1e.a2a.v1.PushNotificationConfigR\x16pushNotificationConfig\" \n\nStringList\x12\x12\n\x04list\x18\x01 \x03(\tR\x04list\"\x93\x01\n\x08Security\x12\x37\n\x07schemes\x18\x01 \x03(\x0b\x32\x1d.a2a.v1.Security.SchemesEntryR\x07schemes\x1aN\n\x0cSchemesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x12.a2a.v1.StringListR\x05value:\x02\x38\x01\"\xe6\x03\n\x0eSecurityScheme\x12U\n\x17\x61pi_key_security_scheme\x18\x01 \x01(\x0b\x32\x1c.a2a.v1.APIKeySecuritySchemeH\x00R\x14\x61piKeySecurityScheme\x12[\n\x19http_auth_security_scheme\x18\x02 \x01(\x0b\x32\x1e.a2a.v1.HTTPAuthSecuritySchemeH\x00R\x16httpAuthSecurityScheme\x12T\n\x16oauth2_security_scheme\x18\x03 \x01(\x0b\x32\x1c.a2a.v1.OAuth2SecuritySchemeH\x00R\x14oauth2SecurityScheme\x12k\n\x1fopen_id_connect_security_scheme\x18\x04 \x01(\x0b\x32#.a2a.v1.OpenIdConnectSecuritySchemeH\x00R\x1bopenIdConnectSecurityScheme\x12S\n\x14mtls_security_scheme\x18\x05 \x01(\x0b\x32\x1f.a2a.v1.MutualTlsSecuritySchemeH\x00R\x12mtlsSecuritySchemeB\x08\n\x06scheme\"h\n\x14\x41PIKeySecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12\x1a\n\x08location\x18\x02 \x01(\tR\x08location\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\"w\n\x16HTTPAuthSecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12\x16\n\x06scheme\x18\x02 \x01(\tR\x06scheme\x12#\n\rbearer_format\x18\x03 \x01(\tR\x0c\x62\x65\x61rerFormat\"\x92\x01\n\x14OAuth2SecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12(\n\x05\x66lows\x18\x02 \x01(\x0b\x32\x12.a2a.v1.OAuthFlowsR\x05\x66lows\x12.\n\x13oauth2_metadata_url\x18\x03 \x01(\tR\x11oauth2MetadataUrl\"n\n\x1bOpenIdConnectSecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12-\n\x13open_id_connect_url\x18\x02 \x01(\tR\x10openIdConnectUrl\";\n\x17MutualTlsSecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\"\xb0\x02\n\nOAuthFlows\x12S\n\x12\x61uthorization_code\x18\x01 \x01(\x0b\x32\".a2a.v1.AuthorizationCodeOAuthFlowH\x00R\x11\x61uthorizationCode\x12S\n\x12\x63lient_credentials\x18\x02 \x01(\x0b\x32\".a2a.v1.ClientCredentialsOAuthFlowH\x00R\x11\x63lientCredentials\x12\x37\n\x08implicit\x18\x03 \x01(\x0b\x32\x19.a2a.v1.ImplicitOAuthFlowH\x00R\x08implicit\x12\x37\n\x08password\x18\x04 \x01(\x0b\x32\x19.a2a.v1.PasswordOAuthFlowH\x00R\x08passwordB\x06\n\x04\x66low\"\x8a\x02\n\x1a\x41uthorizationCodeOAuthFlow\x12+\n\x11\x61uthorization_url\x18\x01 \x01(\tR\x10\x61uthorizationUrl\x12\x1b\n\ttoken_url\x18\x02 \x01(\tR\x08tokenUrl\x12\x1f\n\x0brefresh_url\x18\x03 \x01(\tR\nrefreshUrl\x12\x46\n\x06scopes\x18\x04 \x03(\x0b\x32..a2a.v1.AuthorizationCodeOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdd\x01\n\x1a\x43lientCredentialsOAuthFlow\x12\x1b\n\ttoken_url\x18\x01 \x01(\tR\x08tokenUrl\x12\x1f\n\x0brefresh_url\x18\x02 \x01(\tR\nrefreshUrl\x12\x46\n\x06scopes\x18\x03 \x03(\x0b\x32..a2a.v1.ClientCredentialsOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdb\x01\n\x11ImplicitOAuthFlow\x12+\n\x11\x61uthorization_url\x18\x01 \x01(\tR\x10\x61uthorizationUrl\x12\x1f\n\x0brefresh_url\x18\x02 \x01(\tR\nrefreshUrl\x12=\n\x06scopes\x18\x03 \x03(\x0b\x32%.a2a.v1.ImplicitOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xcb\x01\n\x11PasswordOAuthFlow\x12\x1b\n\ttoken_url\x18\x01 \x01(\tR\x08tokenUrl\x12\x1f\n\x0brefresh_url\x18\x02 \x01(\tR\nrefreshUrl\x12=\n\x06scopes\x18\x03 \x03(\x0b\x32%.a2a.v1.PasswordOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xc1\x01\n\x12SendMessageRequest\x12.\n\x07request\x18\x01 \x01(\x0b\x32\x0f.a2a.v1.MessageB\x03\xe0\x41\x02R\x07message\x12\x46\n\rconfiguration\x18\x02 \x01(\x0b\x32 .a2a.v1.SendMessageConfigurationR\rconfiguration\x12\x33\n\x08metadata\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"P\n\x0eGetTaskRequest\x12\x17\n\x04name\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x04name\x12%\n\x0ehistory_length\x18\x02 \x01(\x05R\rhistoryLength\"\'\n\x11\x43\x61ncelTaskRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\":\n$GetTaskPushNotificationConfigRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\"=\n\'DeleteTaskPushNotificationConfigRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\"\xa9\x01\n\'CreateTaskPushNotificationConfigRequest\x12\x1b\n\x06parent\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x06parent\x12 \n\tconfig_id\x18\x02 \x01(\tB\x03\xe0\x41\x02R\x08\x63onfigId\x12?\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\".a2a.v1.TaskPushNotificationConfigB\x03\xe0\x41\x02R\x06\x63onfig\"-\n\x17TaskSubscriptionRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\"{\n%ListTaskPushNotificationConfigRequest\x12\x16\n\x06parent\x18\x01 \x01(\tR\x06parent\x12\x1b\n\tpage_size\x18\x02 \x01(\x05R\x08pageSize\x12\x1d\n\npage_token\x18\x03 \x01(\tR\tpageToken\"\x15\n\x13GetAgentCardRequest\"m\n\x13SendMessageResponse\x12\"\n\x04task\x18\x01 \x01(\x0b\x32\x0c.a2a.v1.TaskH\x00R\x04task\x12\'\n\x03msg\x18\x02 \x01(\x0b\x32\x0f.a2a.v1.MessageH\x00R\x07messageB\t\n\x07payload\"\xfa\x01\n\x0eStreamResponse\x12\"\n\x04task\x18\x01 \x01(\x0b\x32\x0c.a2a.v1.TaskH\x00R\x04task\x12\'\n\x03msg\x18\x02 \x01(\x0b\x32\x0f.a2a.v1.MessageH\x00R\x07message\x12\x44\n\rstatus_update\x18\x03 \x01(\x0b\x32\x1d.a2a.v1.TaskStatusUpdateEventH\x00R\x0cstatusUpdate\x12J\n\x0f\x61rtifact_update\x18\x04 \x01(\x0b\x32\x1f.a2a.v1.TaskArtifactUpdateEventH\x00R\x0e\x61rtifactUpdateB\t\n\x07payload\"\x8e\x01\n&ListTaskPushNotificationConfigResponse\x12<\n\x07\x63onfigs\x18\x01 \x03(\x0b\x32\".a2a.v1.TaskPushNotificationConfigR\x07\x63onfigs\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken*\xfa\x01\n\tTaskState\x12\x1a\n\x16TASK_STATE_UNSPECIFIED\x10\x00\x12\x18\n\x14TASK_STATE_SUBMITTED\x10\x01\x12\x16\n\x12TASK_STATE_WORKING\x10\x02\x12\x18\n\x14TASK_STATE_COMPLETED\x10\x03\x12\x15\n\x11TASK_STATE_FAILED\x10\x04\x12\x18\n\x14TASK_STATE_CANCELLED\x10\x05\x12\x1d\n\x19TASK_STATE_INPUT_REQUIRED\x10\x06\x12\x17\n\x13TASK_STATE_REJECTED\x10\x07\x12\x1c\n\x18TASK_STATE_AUTH_REQUIRED\x10\x08*;\n\x04Role\x12\x14\n\x10ROLE_UNSPECIFIED\x10\x00\x12\r\n\tROLE_USER\x10\x01\x12\x0e\n\nROLE_AGENT\x10\x02\x32\xbb\n\n\nA2AService\x12\x63\n\x0bSendMessage\x12\x1a.a2a.v1.SendMessageRequest\x1a\x1b.a2a.v1.SendMessageResponse\"\x1b\x82\xd3\xe4\x93\x02\x15\"\x10/v1/message:send:\x01*\x12k\n\x14SendStreamingMessage\x12\x1a.a2a.v1.SendMessageRequest\x1a\x16.a2a.v1.StreamResponse\"\x1d\x82\xd3\xe4\x93\x02\x17\"\x12/v1/message:stream:\x01*0\x01\x12R\n\x07GetTask\x12\x16.a2a.v1.GetTaskRequest\x1a\x0c.a2a.v1.Task\"!\xda\x41\x04name\x82\xd3\xe4\x93\x02\x14\x12\x12/v1/{name=tasks/*}\x12[\n\nCancelTask\x12\x19.a2a.v1.CancelTaskRequest\x1a\x0c.a2a.v1.Task\"$\x82\xd3\xe4\x93\x02\x1e\"\x19/v1/{name=tasks/*}:cancel:\x01*\x12s\n\x10TaskSubscription\x12\x1f.a2a.v1.TaskSubscriptionRequest\x1a\x16.a2a.v1.StreamResponse\"$\x82\xd3\xe4\x93\x02\x1e\x12\x1c/v1/{name=tasks/*}:subscribe0\x01\x12\xc5\x01\n CreateTaskPushNotificationConfig\x12/.a2a.v1.CreateTaskPushNotificationConfigRequest\x1a\".a2a.v1.TaskPushNotificationConfig\"L\xda\x41\rparent,config\x82\xd3\xe4\x93\x02\x36\",/v1/{parent=tasks/*/pushNotificationConfigs}:\x06\x63onfig\x12\xae\x01\n\x1dGetTaskPushNotificationConfig\x12,.a2a.v1.GetTaskPushNotificationConfigRequest\x1a\".a2a.v1.TaskPushNotificationConfig\";\xda\x41\x04name\x82\xd3\xe4\x93\x02.\x12,/v1/{name=tasks/*/pushNotificationConfigs/*}\x12\xbe\x01\n\x1eListTaskPushNotificationConfig\x12-.a2a.v1.ListTaskPushNotificationConfigRequest\x1a..a2a.v1.ListTaskPushNotificationConfigResponse\"=\xda\x41\x06parent\x82\xd3\xe4\x93\x02.\x12,/v1/{parent=tasks/*}/pushNotificationConfigs\x12P\n\x0cGetAgentCard\x12\x1b.a2a.v1.GetAgentCardRequest\x1a\x11.a2a.v1.AgentCard\"\x10\x82\xd3\xe4\x93\x02\n\x12\x08/v1/card\x12\xa8\x01\n DeleteTaskPushNotificationConfig\x12/.a2a.v1.DeleteTaskPushNotificationConfigRequest\x1a\x16.google.protobuf.Empty\";\xda\x41\x04name\x82\xd3\xe4\x93\x02.*,/v1/{name=tasks/*/pushNotificationConfigs/*}Bi\n\ncom.a2a.v1B\x08\x41\x32\x61ProtoP\x01Z\x18google.golang.org/a2a/v1\xa2\x02\x03\x41XX\xaa\x02\x06\x41\x32\x61.V1\xca\x02\x06\x41\x32\x61\\V1\xe2\x02\x12\x41\x32\x61\\V1\\GPBMetadata\xea\x02\x07\x41\x32\x61::V1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -84,112 +84,112 @@ _globals['_A2ASERVICE'].methods_by_name['GetAgentCard']._serialized_options = b'\202\323\344\223\002\n\022\010/v1/card' _globals['_A2ASERVICE'].methods_by_name['DeleteTaskPushNotificationConfig']._loaded_options = None _globals['_A2ASERVICE'].methods_by_name['DeleteTaskPushNotificationConfig']._serialized_options = b'\332A\004name\202\323\344\223\002.*,/v1/{name=tasks/*/pushNotificationConfigs/*}' - _globals['_TASKSTATE']._serialized_start=8012 - _globals['_TASKSTATE']._serialized_end=8262 - _globals['_ROLE']._serialized_start=8264 - _globals['_ROLE']._serialized_end=8323 + _globals['_TASKSTATE']._serialized_start=8066 + _globals['_TASKSTATE']._serialized_end=8316 + _globals['_ROLE']._serialized_start=8318 + _globals['_ROLE']._serialized_end=8377 _globals['_SENDMESSAGECONFIGURATION']._serialized_start=202 _globals['_SENDMESSAGECONFIGURATION']._serialized_end=424 _globals['_TASK']._serialized_start=427 _globals['_TASK']._serialized_end=668 _globals['_TASKSTATUS']._serialized_start=671 _globals['_TASKSTATUS']._serialized_end=824 - _globals['_PART']._serialized_start=826 - _globals['_PART']._serialized_end=942 - _globals['_FILEPART']._serialized_start=945 - _globals['_FILEPART']._serialized_end=1092 - _globals['_DATAPART']._serialized_start=1094 - _globals['_DATAPART']._serialized_end=1149 - _globals['_MESSAGE']._serialized_start=1152 - _globals['_MESSAGE']._serialized_end=1407 - _globals['_ARTIFACT']._serialized_start=1410 - _globals['_ARTIFACT']._serialized_end=1628 - _globals['_TASKSTATUSUPDATEEVENT']._serialized_start=1631 - _globals['_TASKSTATUSUPDATEEVENT']._serialized_end=1829 - _globals['_TASKARTIFACTUPDATEEVENT']._serialized_start=1832 - _globals['_TASKARTIFACTUPDATEEVENT']._serialized_end=2067 - _globals['_PUSHNOTIFICATIONCONFIG']._serialized_start=2070 - _globals['_PUSHNOTIFICATIONCONFIG']._serialized_end=2218 - _globals['_AUTHENTICATIONINFO']._serialized_start=2220 - _globals['_AUTHENTICATIONINFO']._serialized_end=2300 - _globals['_AGENTINTERFACE']._serialized_start=2302 - _globals['_AGENTINTERFACE']._serialized_end=2366 - _globals['_AGENTCARD']._serialized_start=2369 - _globals['_AGENTCARD']._serialized_end=3337 - _globals['_AGENTCARD_SECURITYSCHEMESENTRY']._serialized_start=3247 - _globals['_AGENTCARD_SECURITYSCHEMESENTRY']._serialized_end=3337 - _globals['_AGENTPROVIDER']._serialized_start=3339 - _globals['_AGENTPROVIDER']._serialized_end=3408 - _globals['_AGENTCAPABILITIES']._serialized_start=3411 - _globals['_AGENTCAPABILITIES']._serialized_end=3563 - _globals['_AGENTEXTENSION']._serialized_start=3566 - _globals['_AGENTEXTENSION']._serialized_end=3711 - _globals['_AGENTSKILL']._serialized_start=3714 - _globals['_AGENTSKILL']._serialized_end=3958 - _globals['_AGENTCARDSIGNATURE']._serialized_start=3961 - _globals['_AGENTCARDSIGNATURE']._serialized_end=4100 - _globals['_TASKPUSHNOTIFICATIONCONFIG']._serialized_start=4103 - _globals['_TASKPUSHNOTIFICATIONCONFIG']._serialized_end=4241 - _globals['_STRINGLIST']._serialized_start=4243 - _globals['_STRINGLIST']._serialized_end=4275 - _globals['_SECURITY']._serialized_start=4278 - _globals['_SECURITY']._serialized_end=4425 - _globals['_SECURITY_SCHEMESENTRY']._serialized_start=4347 - _globals['_SECURITY_SCHEMESENTRY']._serialized_end=4425 - _globals['_SECURITYSCHEME']._serialized_start=4428 - _globals['_SECURITYSCHEME']._serialized_end=4914 - _globals['_APIKEYSECURITYSCHEME']._serialized_start=4916 - _globals['_APIKEYSECURITYSCHEME']._serialized_end=5020 - _globals['_HTTPAUTHSECURITYSCHEME']._serialized_start=5022 - _globals['_HTTPAUTHSECURITYSCHEME']._serialized_end=5141 - _globals['_OAUTH2SECURITYSCHEME']._serialized_start=5144 - _globals['_OAUTH2SECURITYSCHEME']._serialized_end=5290 - _globals['_OPENIDCONNECTSECURITYSCHEME']._serialized_start=5292 - _globals['_OPENIDCONNECTSECURITYSCHEME']._serialized_end=5402 - _globals['_MUTUALTLSSECURITYSCHEME']._serialized_start=5404 - _globals['_MUTUALTLSSECURITYSCHEME']._serialized_end=5463 - _globals['_OAUTHFLOWS']._serialized_start=5466 - _globals['_OAUTHFLOWS']._serialized_end=5770 - _globals['_AUTHORIZATIONCODEOAUTHFLOW']._serialized_start=5773 - _globals['_AUTHORIZATIONCODEOAUTHFLOW']._serialized_end=6039 - _globals['_AUTHORIZATIONCODEOAUTHFLOW_SCOPESENTRY']._serialized_start=5982 - _globals['_AUTHORIZATIONCODEOAUTHFLOW_SCOPESENTRY']._serialized_end=6039 - _globals['_CLIENTCREDENTIALSOAUTHFLOW']._serialized_start=6042 - _globals['_CLIENTCREDENTIALSOAUTHFLOW']._serialized_end=6263 - _globals['_CLIENTCREDENTIALSOAUTHFLOW_SCOPESENTRY']._serialized_start=5982 - _globals['_CLIENTCREDENTIALSOAUTHFLOW_SCOPESENTRY']._serialized_end=6039 - _globals['_IMPLICITOAUTHFLOW']._serialized_start=6266 - _globals['_IMPLICITOAUTHFLOW']._serialized_end=6485 - _globals['_IMPLICITOAUTHFLOW_SCOPESENTRY']._serialized_start=5982 - _globals['_IMPLICITOAUTHFLOW_SCOPESENTRY']._serialized_end=6039 - _globals['_PASSWORDOAUTHFLOW']._serialized_start=6488 - _globals['_PASSWORDOAUTHFLOW']._serialized_end=6691 - _globals['_PASSWORDOAUTHFLOW_SCOPESENTRY']._serialized_start=5982 - _globals['_PASSWORDOAUTHFLOW_SCOPESENTRY']._serialized_end=6039 - _globals['_SENDMESSAGEREQUEST']._serialized_start=6694 - _globals['_SENDMESSAGEREQUEST']._serialized_end=6887 - _globals['_GETTASKREQUEST']._serialized_start=6889 - _globals['_GETTASKREQUEST']._serialized_end=6969 - _globals['_CANCELTASKREQUEST']._serialized_start=6971 - _globals['_CANCELTASKREQUEST']._serialized_end=7010 - _globals['_GETTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7012 - _globals['_GETTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7070 - _globals['_DELETETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7072 - _globals['_DELETETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7133 - _globals['_CREATETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7136 - _globals['_CREATETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7305 - _globals['_TASKSUBSCRIPTIONREQUEST']._serialized_start=7307 - _globals['_TASKSUBSCRIPTIONREQUEST']._serialized_end=7352 - _globals['_LISTTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7354 - _globals['_LISTTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7477 - _globals['_GETAGENTCARDREQUEST']._serialized_start=7479 - _globals['_GETAGENTCARDREQUEST']._serialized_end=7500 - _globals['_SENDMESSAGERESPONSE']._serialized_start=7502 - _globals['_SENDMESSAGERESPONSE']._serialized_end=7611 - _globals['_STREAMRESPONSE']._serialized_start=7614 - _globals['_STREAMRESPONSE']._serialized_end=7864 - _globals['_LISTTASKPUSHNOTIFICATIONCONFIGRESPONSE']._serialized_start=7867 - _globals['_LISTTASKPUSHNOTIFICATIONCONFIGRESPONSE']._serialized_end=8009 - _globals['_A2ASERVICE']._serialized_start=8326 - _globals['_A2ASERVICE']._serialized_end=9665 + _globals['_PART']._serialized_start=827 + _globals['_PART']._serialized_end=996 + _globals['_FILEPART']._serialized_start=999 + _globals['_FILEPART']._serialized_end=1146 + _globals['_DATAPART']._serialized_start=1148 + _globals['_DATAPART']._serialized_end=1203 + _globals['_MESSAGE']._serialized_start=1206 + _globals['_MESSAGE']._serialized_end=1461 + _globals['_ARTIFACT']._serialized_start=1464 + _globals['_ARTIFACT']._serialized_end=1682 + _globals['_TASKSTATUSUPDATEEVENT']._serialized_start=1685 + _globals['_TASKSTATUSUPDATEEVENT']._serialized_end=1883 + _globals['_TASKARTIFACTUPDATEEVENT']._serialized_start=1886 + _globals['_TASKARTIFACTUPDATEEVENT']._serialized_end=2121 + _globals['_PUSHNOTIFICATIONCONFIG']._serialized_start=2124 + _globals['_PUSHNOTIFICATIONCONFIG']._serialized_end=2272 + _globals['_AUTHENTICATIONINFO']._serialized_start=2274 + _globals['_AUTHENTICATIONINFO']._serialized_end=2354 + _globals['_AGENTINTERFACE']._serialized_start=2356 + _globals['_AGENTINTERFACE']._serialized_end=2420 + _globals['_AGENTCARD']._serialized_start=2423 + _globals['_AGENTCARD']._serialized_end=3391 + _globals['_AGENTCARD_SECURITYSCHEMESENTRY']._serialized_start=3301 + _globals['_AGENTCARD_SECURITYSCHEMESENTRY']._serialized_end=3391 + _globals['_AGENTPROVIDER']._serialized_start=3393 + _globals['_AGENTPROVIDER']._serialized_end=3462 + _globals['_AGENTCAPABILITIES']._serialized_start=3465 + _globals['_AGENTCAPABILITIES']._serialized_end=3617 + _globals['_AGENTEXTENSION']._serialized_start=3620 + _globals['_AGENTEXTENSION']._serialized_end=3765 + _globals['_AGENTSKILL']._serialized_start=3768 + _globals['_AGENTSKILL']._serialized_end=4012 + _globals['_AGENTCARDSIGNATURE']._serialized_start=4015 + _globals['_AGENTCARDSIGNATURE']._serialized_end=4154 + _globals['_TASKPUSHNOTIFICATIONCONFIG']._serialized_start=4157 + _globals['_TASKPUSHNOTIFICATIONCONFIG']._serialized_end=4295 + _globals['_STRINGLIST']._serialized_start=4297 + _globals['_STRINGLIST']._serialized_end=4329 + _globals['_SECURITY']._serialized_start=4332 + _globals['_SECURITY']._serialized_end=4479 + _globals['_SECURITY_SCHEMESENTRY']._serialized_start=4401 + _globals['_SECURITY_SCHEMESENTRY']._serialized_end=4479 + _globals['_SECURITYSCHEME']._serialized_start=4482 + _globals['_SECURITYSCHEME']._serialized_end=4968 + _globals['_APIKEYSECURITYSCHEME']._serialized_start=4970 + _globals['_APIKEYSECURITYSCHEME']._serialized_end=5074 + _globals['_HTTPAUTHSECURITYSCHEME']._serialized_start=5076 + _globals['_HTTPAUTHSECURITYSCHEME']._serialized_end=5195 + _globals['_OAUTH2SECURITYSCHEME']._serialized_start=5198 + _globals['_OAUTH2SECURITYSCHEME']._serialized_end=5344 + _globals['_OPENIDCONNECTSECURITYSCHEME']._serialized_start=5346 + _globals['_OPENIDCONNECTSECURITYSCHEME']._serialized_end=5456 + _globals['_MUTUALTLSSECURITYSCHEME']._serialized_start=5458 + _globals['_MUTUALTLSSECURITYSCHEME']._serialized_end=5517 + _globals['_OAUTHFLOWS']._serialized_start=5520 + _globals['_OAUTHFLOWS']._serialized_end=5824 + _globals['_AUTHORIZATIONCODEOAUTHFLOW']._serialized_start=5827 + _globals['_AUTHORIZATIONCODEOAUTHFLOW']._serialized_end=6093 + _globals['_AUTHORIZATIONCODEOAUTHFLOW_SCOPESENTRY']._serialized_start=6036 + _globals['_AUTHORIZATIONCODEOAUTHFLOW_SCOPESENTRY']._serialized_end=6093 + _globals['_CLIENTCREDENTIALSOAUTHFLOW']._serialized_start=6096 + _globals['_CLIENTCREDENTIALSOAUTHFLOW']._serialized_end=6317 + _globals['_CLIENTCREDENTIALSOAUTHFLOW_SCOPESENTRY']._serialized_start=6036 + _globals['_CLIENTCREDENTIALSOAUTHFLOW_SCOPESENTRY']._serialized_end=6093 + _globals['_IMPLICITOAUTHFLOW']._serialized_start=6320 + _globals['_IMPLICITOAUTHFLOW']._serialized_end=6539 + _globals['_IMPLICITOAUTHFLOW_SCOPESENTRY']._serialized_start=6036 + _globals['_IMPLICITOAUTHFLOW_SCOPESENTRY']._serialized_end=6093 + _globals['_PASSWORDOAUTHFLOW']._serialized_start=6542 + _globals['_PASSWORDOAUTHFLOW']._serialized_end=6745 + _globals['_PASSWORDOAUTHFLOW_SCOPESENTRY']._serialized_start=6036 + _globals['_PASSWORDOAUTHFLOW_SCOPESENTRY']._serialized_end=6093 + _globals['_SENDMESSAGEREQUEST']._serialized_start=6748 + _globals['_SENDMESSAGEREQUEST']._serialized_end=6941 + _globals['_GETTASKREQUEST']._serialized_start=6943 + _globals['_GETTASKREQUEST']._serialized_end=7023 + _globals['_CANCELTASKREQUEST']._serialized_start=7025 + _globals['_CANCELTASKREQUEST']._serialized_end=7064 + _globals['_GETTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7066 + _globals['_GETTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7124 + _globals['_DELETETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7126 + _globals['_DELETETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7187 + _globals['_CREATETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7190 + _globals['_CREATETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7359 + _globals['_TASKSUBSCRIPTIONREQUEST']._serialized_start=7361 + _globals['_TASKSUBSCRIPTIONREQUEST']._serialized_end=7406 + _globals['_LISTTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7408 + _globals['_LISTTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7531 + _globals['_GETAGENTCARDREQUEST']._serialized_start=7533 + _globals['_GETAGENTCARDREQUEST']._serialized_end=7554 + _globals['_SENDMESSAGERESPONSE']._serialized_start=7556 + _globals['_SENDMESSAGERESPONSE']._serialized_end=7665 + _globals['_STREAMRESPONSE']._serialized_start=7668 + _globals['_STREAMRESPONSE']._serialized_end=7918 + _globals['_LISTTASKPUSHNOTIFICATIONCONFIGRESPONSE']._serialized_start=7921 + _globals['_LISTTASKPUSHNOTIFICATIONCONFIGRESPONSE']._serialized_end=8063 + _globals['_A2ASERVICE']._serialized_start=8380 + _globals['_A2ASERVICE']._serialized_end=9719 # @@protoc_insertion_point(module_scope) diff --git a/src/a2a/grpc/a2a_pb2.pyi b/src/a2a/grpc/a2a_pb2.pyi index 064f7387b..06005e850 100644 --- a/src/a2a/grpc/a2a_pb2.pyi +++ b/src/a2a/grpc/a2a_pb2.pyi @@ -84,14 +84,16 @@ class TaskStatus(_message.Message): def __init__(self, state: _Optional[_Union[TaskState, str]] = ..., update: _Optional[_Union[Message, _Mapping]] = ..., timestamp: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... class Part(_message.Message): - __slots__ = ("text", "file", "data") + __slots__ = ("text", "file", "data", "metadata") TEXT_FIELD_NUMBER: _ClassVar[int] FILE_FIELD_NUMBER: _ClassVar[int] DATA_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] text: str file: FilePart data: DataPart - def __init__(self, text: _Optional[str] = ..., file: _Optional[_Union[FilePart, _Mapping]] = ..., data: _Optional[_Union[DataPart, _Mapping]] = ...) -> None: ... + metadata: _struct_pb2.Struct + def __init__(self, text: _Optional[str] = ..., file: _Optional[_Union[FilePart, _Mapping]] = ..., data: _Optional[_Union[DataPart, _Mapping]] = ..., metadata: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ... class FilePart(_message.Message): __slots__ = ("file_with_uri", "file_with_bytes", "mime_type", "name") diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index 3806e618f..12b0d3072 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -73,11 +73,19 @@ def metadata( @classmethod def part(cls, part: types.Part) -> a2a_pb2.Part: if isinstance(part.root, types.TextPart): - return a2a_pb2.Part(text=part.root.text) + return a2a_pb2.Part( + text=part.root.text, metadata=cls.metadata(part.root.metadata) + ) if isinstance(part.root, types.FilePart): - return a2a_pb2.Part(file=cls.file(part.root.file)) + return a2a_pb2.Part( + file=cls.file(part.root.file), + metadata=cls.metadata(part.root.metadata), + ) if isinstance(part.root, types.DataPart): - return a2a_pb2.Part(data=cls.data(part.root.data)) + return a2a_pb2.Part( + data=cls.data(part.root.data), + metadata=cls.metadata(part.root.metadata), + ) raise ValueError(f'Unsupported part type: {part.root}') @classmethod @@ -502,11 +510,32 @@ def metadata(cls, metadata: struct_pb2.Struct) -> dict[str, Any]: @classmethod def part(cls, part: a2a_pb2.Part) -> types.Part: if part.HasField('text'): - return types.Part(root=types.TextPart(text=part.text)) + return types.Part( + root=types.TextPart( + text=part.text, + metadata=cls.metadata(part.metadata) + if part.metadata + else None, + ), + ) if part.HasField('file'): - return types.Part(root=types.FilePart(file=cls.file(part.file))) + return types.Part( + root=types.FilePart( + file=cls.file(part.file), + metadata=cls.metadata(part.metadata) + if part.metadata + else None, + ), + ) if part.HasField('data'): - return types.Part(root=types.DataPart(data=cls.data(part.data))) + return types.Part( + root=types.DataPart( + data=cls.data(part.data), + metadata=cls.metadata(part.metadata) + if part.metadata + else None, + ), + ) raise ValueError(f'Unsupported part type: {part}') @classmethod From 58b4c81746fc83e65f23f46308c47099697554ea Mon Sep 17 00:00:00 2001 From: Ovidiu Taralesca Date: Wed, 3 Sep 2025 17:25:25 +0300 Subject: [PATCH 008/384] fix: Prevent client disconnect from stopping task execution (#440) # Issue * Client disconnect triggered synchronous cleanup. * That awaited the producer task, effectively tying producer lifetime to the client connection. * Reconnecting with `tasks/resubscribe` would not receive further events because the producer had already been forced to finish. This behaviour no longer raises a `asyncio.exceptions.CancelledError` like claimed in #296 due to this fix: #383, but `tasks/resubscribe` still didn't behave as expected. # How it's reproduced In any streaming agent: Simply sending a (longer-running) `message/stream`, disconnecting, and then reconnecting to the task using `tasks/resubscribe` will no longer yield events, even though the task should have been still running. # Fix ## Code The fix is an one-liner. Now: * Client disconnect schedules cleanup in the background and returns immediately. * Producer continues; resubscribe taps the existing queue and receives subsequent events. * Cleanup still runs once the producer completes. ## Tests **Existing tests:** * Changed existing tests that asserted on `AgentExecutor.execute` by adding an `asyncio.Event` latch to wait until the background producer hits `execute`. **New tests:** * `test_stream_disconnect_then_resubscribe_receives_future_events` -- start streaming, disconnect, resubscribe, and confirm future events are received. * `test_on_message_send_stream_client_disconnect_triggers_background_cleanup_and_producer_continues` -- to validate that disconnecting is non-blocking, producer continues, and cleanup completes afterward. Fixes #296 --- .../default_request_handler.py | 38 +- .../test_default_request_handler.py | 327 ++++++++++++++++++ .../request_handlers/test_jsonrpc_handler.py | 19 + 3 files changed, 381 insertions(+), 3 deletions(-) diff --git a/src/a2a/server/request_handlers/default_request_handler.py b/src/a2a/server/request_handlers/default_request_handler.py index 724fe61e6..2c71a6e51 100644 --- a/src/a2a/server/request_handlers/default_request_handler.py +++ b/src/a2a/server/request_handlers/default_request_handler.py @@ -67,6 +67,7 @@ class DefaultRequestHandler(RequestHandler): """ _running_agents: dict[str, asyncio.Task] + _background_tasks: set[asyncio.Task] def __init__( # noqa: PLR0913 self, @@ -102,6 +103,9 @@ def __init__( # noqa: PLR0913 # TODO: Likely want an interface for managing this, like AgentExecutionManager. self._running_agents = {} self._running_agents_lock = asyncio.Lock() + # Tracks background tasks (e.g., deferred cleanups) to avoid orphaning + # asyncio tasks and to surface unexpected exceptions. + self._background_tasks = set() async def on_get_task( self, @@ -355,10 +359,11 @@ async def push_notification_callback() -> None: raise finally: if interrupted_or_non_blocking: - # TODO: Track this disconnected cleanup task. - asyncio.create_task( # noqa: RUF006 + cleanup_task = asyncio.create_task( self._cleanup_producer(producer_task, task_id) ) + cleanup_task.set_name(f'cleanup_producer:{task_id}') + self._track_background_task(cleanup_task) else: await self._cleanup_producer(producer_task, task_id) @@ -394,7 +399,11 @@ async def on_message_send_stream( ) yield event finally: - await self._cleanup_producer(producer_task, task_id) + cleanup_task = asyncio.create_task( + self._cleanup_producer(producer_task, task_id) + ) + cleanup_task.set_name(f'cleanup_producer:{task_id}') + self._track_background_task(cleanup_task) async def _register_producer( self, task_id: str, producer_task: asyncio.Task @@ -403,6 +412,29 @@ async def _register_producer( async with self._running_agents_lock: self._running_agents[task_id] = producer_task + def _track_background_task(self, task: asyncio.Task) -> None: + """Tracks a background task and logs exceptions on completion. + + This avoids unreferenced tasks (and associated lint warnings) while + ensuring any exceptions are surfaced in logs. + """ + self._background_tasks.add(task) + + def _on_done(completed: asyncio.Task) -> None: + try: + # Retrieve result to raise exceptions, if any + completed.result() + except asyncio.CancelledError: + name = completed.get_name() + logger.debug('Background task %s cancelled', name) + except Exception: + name = completed.get_name() + logger.exception('Background task %s failed', name) + finally: + self._background_tasks.discard(completed) + + task.add_done_callback(_on_done) + async def _cleanup_producer( self, producer_task: asyncio.Task, diff --git a/tests/server/request_handlers/test_default_request_handler.py b/tests/server/request_handlers/test_default_request_handler.py index f1408e362..f96ce5e65 100644 --- a/tests/server/request_handlers/test_default_request_handler.py +++ b/tests/server/request_handlers/test_default_request_handler.py @@ -954,6 +954,14 @@ async def test_on_message_send_stream_with_push_notification(): configuration=message_config, ) + # Latch to ensure background execute is scheduled before asserting + execute_called = asyncio.Event() + + async def exec_side_effect(*args, **kwargs): + execute_called.set() + + mock_agent_executor.execute.side_effect = exec_side_effect + # Mock ResultAggregator and its consume_and_emit mock_result_aggregator_instance = MagicMock( spec=ResultAggregator @@ -1167,6 +1175,8 @@ def sync_get_event_stream_gen_for_prop_test(*args, **kwargs): ): pass + await asyncio.wait_for(execute_called.wait(), timeout=0.1) + # Assertions # 1. set_info called once at the beginning if task exists (or after task is created from message) mock_push_config_store.set_info.assert_any_call(task_id, push_config) @@ -1179,6 +1189,323 @@ def sync_get_event_stream_gen_for_prop_test(*args, **kwargs): mock_agent_executor.execute.assert_awaited_once() +@pytest.mark.asyncio +async def test_stream_disconnect_then_resubscribe_receives_future_events(): + """Start streaming, disconnect, then resubscribe and ensure subsequent events are streamed.""" + # Arrange + mock_task_store = AsyncMock(spec=TaskStore) + mock_agent_executor = AsyncMock(spec=AgentExecutor) + + # Use a real queue manager so taps receive future events + queue_manager = InMemoryQueueManager() + + task_id = 'reconn_task_1' + context_id = 'reconn_ctx_1' + + # Task exists and is non-final + task_for_resub = create_sample_task( + task_id=task_id, context_id=context_id, status_state=TaskState.working + ) + mock_task_store.get.return_value = task_for_resub + + request_handler = DefaultRequestHandler( + agent_executor=mock_agent_executor, + task_store=mock_task_store, + queue_manager=queue_manager, + ) + + params = MessageSendParams( + message=Message( + role=Role.user, + message_id='msg_reconn', + parts=[], + task_id=task_id, + context_id=context_id, + ) + ) + + # Producer behavior: emit one event, then later emit second event + exec_started = asyncio.Event() + allow_second_event = asyncio.Event() + allow_finish = asyncio.Event() + + first_event = create_sample_task( + task_id=task_id, context_id=context_id, status_state=TaskState.working + ) + second_event = create_sample_task( + task_id=task_id, context_id=context_id, status_state=TaskState.completed + ) + + async def exec_side_effect(_request, queue: EventQueue): + exec_started.set() + await queue.enqueue_event(first_event) + await allow_second_event.wait() + await queue.enqueue_event(second_event) + await allow_finish.wait() + + mock_agent_executor.execute.side_effect = exec_side_effect + + # Start streaming and consume first event + agen = request_handler.on_message_send_stream( + params, create_server_call_context() + ) + first = await agen.__anext__() + assert first == first_event + + # Simulate client disconnect + await asyncio.wait_for(agen.aclose(), timeout=0.1) + + # Resubscribe and start consuming future events + resub_gen = request_handler.on_resubscribe_to_task( + TaskIdParams(id=task_id), create_server_call_context() + ) + + # Allow producer to emit the next event + allow_second_event.set() + + received = await resub_gen.__anext__() + assert received == second_event + + # Finish producer to allow cleanup paths to complete + allow_finish.set() + + +@pytest.mark.asyncio +async def test_on_message_send_stream_client_disconnect_triggers_background_cleanup_and_producer_continues(): + """Simulate client disconnect: stream stops early, cleanup is scheduled in background, + producer keeps running, and cleanup completes after producer finishes.""" + # Arrange + mock_task_store = AsyncMock(spec=TaskStore) + mock_queue_manager = AsyncMock(spec=QueueManager) + mock_agent_executor = AsyncMock(spec=AgentExecutor) + mock_request_context_builder = AsyncMock(spec=RequestContextBuilder) + + task_id = 'disc_task_1' + context_id = 'disc_ctx_1' + + # RequestContext with IDs + mock_request_context = MagicMock(spec=RequestContext) + mock_request_context.task_id = task_id + mock_request_context.context_id = context_id + mock_request_context_builder.build.return_value = mock_request_context + + # Queue used by _run_event_stream; must support close() + mock_queue = AsyncMock(spec=EventQueue) + mock_queue_manager.create_or_tap.return_value = mock_queue + + request_handler = DefaultRequestHandler( + agent_executor=mock_agent_executor, + task_store=mock_task_store, + queue_manager=mock_queue_manager, + request_context_builder=mock_request_context_builder, + ) + + params = MessageSendParams( + message=Message( + role=Role.user, + message_id='mid', + parts=[], + task_id=task_id, + context_id=context_id, + ) + ) + + # Agent executor runs in background until we allow it to finish + execute_started = asyncio.Event() + execute_finish = asyncio.Event() + + async def exec_side_effect(*_args, **_kwargs): + execute_started.set() + await execute_finish.wait() + + mock_agent_executor.execute.side_effect = exec_side_effect + + # ResultAggregator emits one Task event (so the stream yields once) + first_event = create_sample_task(task_id=task_id, context_id=context_id) + + async def single_event_stream(): + yield first_event + # will never yield again; client will disconnect + + mock_result_aggregator_instance = MagicMock(spec=ResultAggregator) + mock_result_aggregator_instance.consume_and_emit.return_value = ( + single_event_stream() + ) + + produced_task: asyncio.Task | None = None + cleanup_task: asyncio.Task | None = None + + orig_create_task = asyncio.create_task + + def create_task_spy(coro): + nonlocal produced_task, cleanup_task + task = orig_create_task(coro) + # Inspect the coroutine name to make the spy more robust + if coro.__name__ == '_run_event_stream': + produced_task = task + elif coro.__name__ == '_cleanup_producer': + cleanup_task = task + return task + + with ( + patch( + 'a2a.server.request_handlers.default_request_handler.ResultAggregator', + return_value=mock_result_aggregator_instance, + ), + patch('asyncio.create_task', side_effect=create_task_spy), + ): + # Act: start stream and consume only the first event, then disconnect + agen = request_handler.on_message_send_stream( + params, create_server_call_context() + ) + first = await agen.__anext__() + assert first == first_event + # Simulate client disconnect + await asyncio.wait_for(agen.aclose(), timeout=0.1) + + # Assert cleanup was scheduled and producer was started + assert produced_task is not None + assert cleanup_task is not None + + # execute should have started + await asyncio.wait_for(execute_started.wait(), timeout=0.1) + + # Producer should still be running (not finished immediately on disconnect) + assert not produced_task.done() + + # Allow executor to finish, which should complete producer and then cleanup + execute_finish.set() + await asyncio.wait_for(produced_task, timeout=0.2) + await asyncio.wait_for(cleanup_task, timeout=0.2) + + # Queue close awaited by _run_event_stream + mock_queue.close.assert_awaited_once() + # QueueManager close called by _cleanup_producer + mock_queue_manager.close.assert_awaited_once_with(task_id) + # Running agents is cleared + assert task_id not in request_handler._running_agents + + +async def wait_until(predicate, timeout: float = 0.2, interval: float = 0.0): + """Await until predicate() is True or timeout elapses.""" + loop = asyncio.get_running_loop() + end = loop.time() + timeout + while True: + if predicate(): + return + if loop.time() >= end: + raise AssertionError('condition not met within timeout') + await asyncio.sleep(interval) + + +@pytest.mark.asyncio +async def test_background_cleanup_task_is_tracked_and_cleared(): + """Ensure background cleanup task is tracked while pending and removed when done.""" + # Arrange + mock_task_store = AsyncMock(spec=TaskStore) + mock_queue_manager = AsyncMock(spec=QueueManager) + mock_agent_executor = AsyncMock(spec=AgentExecutor) + mock_request_context_builder = AsyncMock(spec=RequestContextBuilder) + + task_id = 'track_task_1' + context_id = 'track_ctx_1' + + # RequestContext with IDs + mock_request_context = MagicMock(spec=RequestContext) + mock_request_context.task_id = task_id + mock_request_context.context_id = context_id + mock_request_context_builder.build.return_value = mock_request_context + + mock_queue = AsyncMock(spec=EventQueue) + mock_queue_manager.create_or_tap.return_value = mock_queue + + request_handler = DefaultRequestHandler( + agent_executor=mock_agent_executor, + task_store=mock_task_store, + queue_manager=mock_queue_manager, + request_context_builder=mock_request_context_builder, + ) + + params = MessageSendParams( + message=Message( + role=Role.user, + message_id='mid_track', + parts=[], + task_id=task_id, + context_id=context_id, + ) + ) + + # Agent executor runs in background until we allow it to finish + execute_started = asyncio.Event() + execute_finish = asyncio.Event() + + async def exec_side_effect(*_args, **_kwargs): + execute_started.set() + await execute_finish.wait() + + mock_agent_executor.execute.side_effect = exec_side_effect + + # ResultAggregator emits one Task event (so the stream yields once) + first_event = create_sample_task(task_id=task_id, context_id=context_id) + + async def single_event_stream(): + yield first_event + + mock_result_aggregator_instance = MagicMock(spec=ResultAggregator) + mock_result_aggregator_instance.consume_and_emit.return_value = ( + single_event_stream() + ) + + produced_task: asyncio.Task | None = None + cleanup_task: asyncio.Task | None = None + + orig_create_task = asyncio.create_task + + def create_task_spy(coro): + nonlocal produced_task, cleanup_task + task = orig_create_task(coro) + if coro.__name__ == '_run_event_stream': + produced_task = task + elif coro.__name__ == '_cleanup_producer': + cleanup_task = task + return task + + with ( + patch( + 'a2a.server.request_handlers.default_request_handler.ResultAggregator', + return_value=mock_result_aggregator_instance, + ), + patch('asyncio.create_task', side_effect=create_task_spy), + ): + # Act: start stream and consume only the first event, then disconnect + agen = request_handler.on_message_send_stream( + params, create_server_call_context() + ) + first = await agen.__anext__() + assert first == first_event + # Simulate client disconnect + await asyncio.wait_for(agen.aclose(), timeout=0.1) + + assert produced_task is not None + assert cleanup_task is not None + + # Background cleanup task should be tracked while producer is still running + await asyncio.wait_for(execute_started.wait(), timeout=0.1) + assert cleanup_task in request_handler._background_tasks + + # Allow executor to finish; this should complete producer, then cleanup + execute_finish.set() + await asyncio.wait_for(produced_task, timeout=0.1) + await asyncio.wait_for(cleanup_task, timeout=0.1) + + # Wait for callback to remove task from tracking + await wait_until( + lambda: cleanup_task not in request_handler._background_tasks, + timeout=0.1, + ) + + @pytest.mark.asyncio async def test_on_message_send_stream_task_id_mismatch(): """Test on_message_send_stream raises error if yielded task ID mismatches.""" diff --git a/tests/server/request_handlers/test_jsonrpc_handler.py b/tests/server/request_handlers/test_jsonrpc_handler.py index 616cf1318..d1ead0211 100644 --- a/tests/server/request_handlers/test_jsonrpc_handler.py +++ b/tests/server/request_handlers/test_jsonrpc_handler.py @@ -1,3 +1,4 @@ +import asyncio import unittest import unittest.async_case @@ -366,6 +367,14 @@ async def streaming_coro(): for event in events: yield event + # Latch to ensure background execute is scheduled before asserting + execute_called = asyncio.Event() + + async def exec_side_effect(*args, **kwargs): + execute_called.set() + + mock_agent_executor.execute.side_effect = exec_side_effect + with patch( 'a2a.server.request_handlers.default_request_handler.EventConsumer.consume_all', return_value=streaming_coro(), @@ -387,6 +396,7 @@ async def streaming_coro(): event.root, SendStreamingMessageSuccessResponse ) assert event.root.result == events[i] + await asyncio.wait_for(execute_called.wait(), timeout=0.1) mock_agent_executor.execute.assert_called_once() async def test_on_message_stream_new_message_existing_task_success( @@ -423,6 +433,14 @@ async def streaming_coro(): for event in events: yield event + # Latch to ensure background execute is scheduled before asserting + execute_called = asyncio.Event() + + async def exec_side_effect(*args, **kwargs): + execute_called.set() + + mock_agent_executor.execute.side_effect = exec_side_effect + with patch( 'a2a.server.request_handlers.default_request_handler.EventConsumer.consume_all', return_value=streaming_coro(), @@ -443,6 +461,7 @@ async def streaming_coro(): assert isinstance(response, AsyncGenerator) collected_events = [item async for item in response] assert len(collected_events) == len(events) + await asyncio.wait_for(execute_called.wait(), timeout=0.1) mock_agent_executor.execute.assert_called_once() assert mock_task.history is not None and len(mock_task.history) == 1 From aac90980cd14ddc2e9190f46c903d12c23b86100 Mon Sep 17 00:00:00 2001 From: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Date: Wed, 3 Sep 2025 12:45:26 -0500 Subject: [PATCH 009/384] style: add fix for `TRY301` lint errors (#401) https://docs.astral.sh/ruff/rules/raise-within-try/ --- .../default_request_handler.py | 17 ++++--- .../request_handlers/jsonrpc_handler.py | 44 +++++++++++-------- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/a2a/server/request_handlers/default_request_handler.py b/src/a2a/server/request_handlers/default_request_handler.py index 2c71a6e51..ee406d6bc 100644 --- a/src/a2a/server/request_handlers/default_request_handler.py +++ b/src/a2a/server/request_handlers/default_request_handler.py @@ -344,15 +344,6 @@ async def push_notification_callback() -> None: blocking=blocking, event_callback=push_notification_callback, ) - if not result: - raise ServerError(error=InternalError()) # noqa: TRY301 - - if isinstance(result, Task): - self._validate_task_id_match(task_id, result.id) - - await self._send_push_notification_if_needed( - task_id, result_aggregator - ) except Exception: logger.exception('Agent execution failed') @@ -367,6 +358,14 @@ async def push_notification_callback() -> None: else: await self._cleanup_producer(producer_task, task_id) + if not result: + raise ServerError(error=InternalError()) + + if isinstance(result, Task): + self._validate_task_id_match(task_id, result.id) + + await self._send_push_notification_if_needed(task_id, result_aggregator) + return result async def on_message_send_stream( diff --git a/src/a2a/server/request_handlers/jsonrpc_handler.py b/src/a2a/server/request_handlers/jsonrpc_handler.py index 2cee937f4..567c61484 100644 --- a/src/a2a/server/request_handlers/jsonrpc_handler.py +++ b/src/a2a/server/request_handlers/jsonrpc_handler.py @@ -183,15 +183,6 @@ async def on_cancel_task( task = await self.request_handler.on_cancel_task( request.params, context ) - if task: - return prepare_response_object( - request.id, - task, - (Task,), - CancelTaskSuccessResponse, - CancelTaskResponse, - ) - raise ServerError(error=TaskNotFoundError()) # noqa: TRY301 except ServerError as e: return CancelTaskResponse( root=JSONRPCErrorResponse( @@ -199,6 +190,19 @@ async def on_cancel_task( ) ) + if task: + return prepare_response_object( + request.id, + task, + (Task,), + CancelTaskSuccessResponse, + CancelTaskResponse, + ) + + return CancelTaskResponse( + root=JSONRPCErrorResponse(id=request.id, error=TaskNotFoundError()) + ) + async def on_resubscribe_to_task( self, request: TaskResubscriptionRequest, @@ -335,15 +339,6 @@ async def on_get_task( task = await self.request_handler.on_get_task( request.params, context ) - if task: - return prepare_response_object( - request.id, - task, - (Task,), - GetTaskSuccessResponse, - GetTaskResponse, - ) - raise ServerError(error=TaskNotFoundError()) # noqa: TRY301 except ServerError as e: return GetTaskResponse( root=JSONRPCErrorResponse( @@ -351,6 +346,19 @@ async def on_get_task( ) ) + if task: + return prepare_response_object( + request.id, + task, + (Task,), + GetTaskSuccessResponse, + GetTaskResponse, + ) + + return GetTaskResponse( + root=JSONRPCErrorResponse(id=request.id, error=TaskNotFoundError()) + ) + async def list_push_notification_config( self, request: ListTaskPushNotificationConfigRequest, From 9758f7896c5497d6ca49f798296a7380b2134b29 Mon Sep 17 00:00:00 2001 From: Holt Skinner Date: Wed, 3 Sep 2025 15:42:48 -0500 Subject: [PATCH 010/384] docs: Restructure README --- README.md | 144 ++++++++++++++++++++---------------------------------- 1 file changed, 54 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 43497bc2f..a3463b07a 100644 --- a/README.md +++ b/README.md @@ -4,110 +4,63 @@ [![PyPI version](https://img.shields.io/pypi/v/a2a-sdk)](https://pypi.org/project/a2a-sdk/) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/a2a-sdk) [![PyPI - Downloads](https://img.shields.io/pypi/dw/a2a-sdk)](https://pypistats.org/packages/a2a-sdk) +[![Python Unit Tests](https://github.com/a2aproject/a2a-python/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/a2aproject/a2a-python/actions/workflows/unit-tests.yml) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/a2aproject/a2a-python) - -

+
A2A Logo -

-

A Python library that helps run agentic applications as A2AServers following the Agent2Agent (A2A) Protocol.

- +

+ A Python library for running agentic applications as A2A Servers, following the Agent2Agent (A2A) Protocol. +

+ -## Installation +--- -You can install the A2A SDK using either `uv` or `pip`. +## ✨ Features -## Prerequisites +- **A2A Protocol Compliant:** Build agentic applications that adhere to the Agent2Agent (A2A) Protocol. +- **Extensible:** Easily add support for different communication protocols and database backends. +- **Asynchronous:** Built on modern async Python for high performance. +- **Optional Integrations:** Includes optional support for: + - HTTP servers ([FastAPI](https://fastapi.tiangolo.com/), [Starlette](https://www.starlette.io/)) + - [gRPC](https://grpc.io/) + - [OpenTelemetry](https://opentelemetry.io/) for tracing + - SQL databases ([PostgreSQL](https://www.postgresql.org/), [MySQL](https://www.mysql.com/), [SQLite](https://sqlite.org/)) -- Python 3.10+ -- `uv` (optional, but recommended) or `pip` - -### Using `uv` - -When you're working within a uv project or a virtual environment managed by uv, the preferred way to add packages is using uv add. - -```bash -uv add a2a-sdk -``` - -To include the optional HTTP server components (FastAPI, Starlette), install the `http-server` extra: - -```bash -uv add a2a-sdk[http-server] -``` - -To install with gRPC support: - -```bash -uv add "a2a-sdk[grpc]" -``` - -To install with OpenTelemetry tracing support: - -```bash -uv add "a2a-sdk[telemetry]" -``` - -To install with database support: - -```bash -# PostgreSQL support -uv add "a2a-sdk[postgresql]" - -# MySQL support -uv add "a2a-sdk[mysql]" +--- -# SQLite support -uv add "a2a-sdk[sqlite]" +## 🚀 Getting Started -# All database drivers -uv add "a2a-sdk[sql]" -``` +### Prerequisites -### Using `pip` - -If you prefer to use pip, the standard Python package installer, you can install `a2a-sdk` as follows - -```bash -pip install a2a-sdk -``` - -To include the optional HTTP server components (FastAPI, Starlette), install the `http-server` extra: - -```bash -pip install a2a-sdk[http-server] -``` - -To install with gRPC support: - -```bash -pip install "a2a-sdk[grpc]" -``` - -To install with OpenTelemetry tracing support: +- Python 3.10+ +- `uv` (recommended) or `pip` -```bash -pip install "a2a-sdk[telemetry]" -``` +### 🔧 Installation -To install with database support: +Install the core SDK and any desired extras using your preferred package manager. -```bash -# PostgreSQL support -pip install "a2a-sdk[postgresql]" +| Feature | `uv` Command | `pip` Command | +| ------------------------ | ------------------------------------------ | -------------------------------------------- | +| **Core SDK** | `uv add a2a-sdk` | `pip install a2a-sdk` | +| **HTTP Server** | `uv add "a2a-sdk[http-server]"` | `pip install "a2a-sdk[http-server]"` | +| **gRPC Support** | `uv add "a2a-sdk[grpc]"` | `pip install "a2a-sdk[grpc]"` | +| **OpenTelemetry Tracing**| `uv add "a2a-sdk[telemetry]"` | `pip install "a2a-sdk[telemetry]"` | -# MySQL support -pip install "a2a-sdk[mysql]" +#### Database Support -# SQLite support -pip install "a2a-sdk[sqlite]" +Install the necessary drivers for your chosen SQL database. -# All database drivers -pip install "a2a-sdk[sql]" -``` +| Database | `uv` Command | `pip` Command | +| ------------- | ---------------------------------- | ------------------------------------ | +| **PostgreSQL**| `uv add "a2a-sdk[postgresql]"` | `pip install "a2a-sdk[postgresql]"` | +| **MySQL** | `uv add "a2a-sdk[mysql]"` | `pip install "a2a-sdk[mysql]"` | +| **SQLite** | `uv add "a2a-sdk[sqlite]"` | `pip install "a2a-sdk[sqlite]"` | +| **All SQL Drivers** | `uv add "a2a-sdk[sql]"` | `pip install "a2a-sdk[sql]"` | ## Examples @@ -130,12 +83,23 @@ pip install "a2a-sdk[sql]" 3. You can validate your agent using the agent inspector. Follow the instructions at the [a2a-inspector](https://github.com/a2aproject/a2a-inspector) repo. -You can also find more Python samples [here](https://github.com/a2aproject/a2a-samples/tree/main/samples/python) and JavaScript samples [here](https://github.com/a2aproject/a2a-samples/tree/main/samples/js). +--- + +## 🌐 More Examples + +You can find a variety of more detailed examples in the [a2a-samples](https://github.com/a2aproject/a2a-samples) repository: + +- **[Python Examples](https://github.com/a2aproject/a2a-samples/tree/main/samples/python)** +- **[JavaScript Examples](https://github.com/a2aproject/a2a-samples/tree/main/samples/js)** + +--- + +## 🤝 Contributing -## License +Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to get involved. -This project is licensed under the terms of the [Apache 2.0 License](https://raw.githubusercontent.com/a2aproject/a2a-python/refs/heads/main/LICENSE). +--- -## Contributing +## 📄 License -See [CONTRIBUTING.md](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md) for contribution guidelines. +This project is licensed under the Apache 2.0 License. See the [LICENSE](LICENSE) file for more details. From a77f0239a51042228ea67435723609f48c45d921 Mon Sep 17 00:00:00 2001 From: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:49:41 -0500 Subject: [PATCH 011/384] ci: Create dependabot.yml (#457) - https://github.com/dependabot --- .github/dependabot.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2d040c1b3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: 'uv' + directory: '/' + schedule: + interval: 'monthly' + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'monthly' From 98604e73eb5a70d5dac95a54b625b9cba2331372 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:14:01 -0500 Subject: [PATCH 012/384] chore(deps): bump amannn/action-semantic-pull-request from 5.5.3 to 6.1.1 (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request) from 5.5.3 to 6.1.1.
Release notes

Sourced from amannn/action-semantic-pull-request's releases.

v6.1.1

6.1.1 (2025-08-22)

Bug Fixes

  • Parse headerPatternCorrespondence properly (#295) (800da4c)

v6.1.0

6.1.0 (2025-08-19)

Features

Bug Fixes

  • Remove trailing whitespace from "unknown release type" error message (#291) (afa4edb)

v6.0.1

6.0.1 (2025-08-13)

Bug Fixes

v6.0.0

6.0.0 (2025-08-13)

⚠ BREAKING CHANGES

  • Upgrade action to use Node.js 24 and ESM (#287)

Features

  • Upgrade action to use Node.js 24 and ESM (#287) (bc0c9a7)
Changelog

Sourced from amannn/action-semantic-pull-request's changelog.

6.1.1 (2025-08-22)

Bug Fixes

  • Parse headerPatternCorrespondence properly (#295) (800da4c)

6.1.0 (2025-08-19)

Features

Bug Fixes

  • Remove trailing whitespace from "unknown release type" error message (#291) (afa4edb)

6.0.1 (2025-08-13)

Bug Fixes

6.0.0 (2025-08-13)

⚠ BREAKING CHANGES

  • Upgrade action to use Node.js 24 and ESM (#287)

Features

  • Upgrade action to use Node.js 24 and ESM (#287) (bc0c9a7)
Commits
  • 48f2562 chore: Release 6.1.1 [skip ci]
  • 800da4c fix: Parse headerPatternCorrespondence properly (#295)
  • 677b895 test: Fix broken test
  • 24e6f01 ci: Fix permissions for tagger
  • 7f33ba7 chore: Release 6.1.0 [skip ci]
  • afa4edb fix: Remove trailing whitespace from "unknown release type" error message (#291)
  • a30288b feat: Support providing regexps for types (#292)
  • a46a7c8 build: Move Vitest to devDependencies (#290)
  • fdd4d3d chore: Release 6.0.1 [skip ci]
  • 58e4ab4 fix: Actually execute action (#289)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=amannn/action-semantic-pull-request&package-manager=github_actions&previous-version=5.5.3&new-version=6.1.1)](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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/conventional-commits.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml index d23da45d7..2072f1e9e 100644 --- a/.github/workflows/conventional-commits.yml +++ b/.github/workflows/conventional-commits.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: semantic-pull-request - uses: amannn/action-semantic-pull-request@v5.5.3 + uses: amannn/action-semantic-pull-request@v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From a0c8b89c896cf7e21f134d1adff66f7fe79fbfa4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:20:49 -0500 Subject: [PATCH 013/384] chore(deps): bump actions/checkout from 4 to 5 (#459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
Release notes

Sourced from actions/checkout's releases.

v5.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

Make sure your runner is updated to this version or newer to use this release.

Full Changelog: https://github.com/actions/checkout/compare/v4...v5.0.0

v4.3.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v4...v4.3.0

v4.2.2

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v4.2.1...v4.2.2

v4.2.1

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v4.2.0...v4.2.1

... (truncated)

Changelog

Sourced from actions/checkout's changelog.

Changelog

V5.0.0

V4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

v4.1.5

v4.1.4

v4.1.3

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4&new-version=5)](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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/linter.yaml | 2 +- .github/workflows/python-publish.yml | 2 +- .github/workflows/unit-tests.yml | 2 +- .github/workflows/update-a2a-types.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index 9036c51a9..05c93b90c 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@v4 + uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index bf7414ccd..0b7171225 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@v4 + - uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v5 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 99e092bcd..d00164ab2 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@v4 + uses: actions/checkout@v5 - 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 e4792d834..cf0473a72 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@v4 + uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v5 with: From 79e577decec60b2c08c95426c0712eeb5099abaa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:24:29 -0500 Subject: [PATCH 014/384] chore(deps): bump peter-evans/create-pull-request from 6 to 7 (#458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 6 to 7.
Release notes

Sourced from peter-evans/create-pull-request's releases.

Create Pull Request v7.0.0

:sparkles: Now supports commit signing with bot-generated tokens! See "What's new" below. :writing_hand::robot:

Behaviour changes

  • Action input git-token has been renamed branch-token, to be more clear about its purpose. The branch-token is the token that the action will use to create and update the branch.
  • The action now handles requests that have been rate-limited by GitHub. Requests hitting a primary rate limit will retry twice, for a total of three attempts. Requests hitting a secondary rate limit will not be retried.
  • The pull-request-operation output now returns none when no operation was executed.
  • Removed deprecated output environment variable PULL_REQUEST_NUMBER. Please use the pull-request-number action output instead.

What's new

  • The action can now sign commits as github-actions[bot] when using GITHUB_TOKEN, or your own bot when using GitHub App tokens. See commit signing for details.
  • Action input draft now accepts a new value always-true. This will set the pull request to draft status when the pull request is updated, as well as on creation.
  • A new action input maintainer-can-modify indicates whether maintainers can modify the pull request. The default is true, which retains the existing behaviour of the action.
  • A new output pull-request-commits-verified returns true or false, indicating whether GitHub considers the signature of the branch's commits to be verified.

What's Changed

New Contributors

Full Changelog: https://github.com/peter-evans/create-pull-request/compare/v6.1.0...v7.0.0

Create Pull Request v6.1.0

✨ Adds pull-request-branch as an action output.

What's Changed

... (truncated)

Commits
  • 271a8d0 fix: suppress output for some git operations (#3776)
  • 6f7efd1 test: update cpr-example-command
  • 13c47c5 build(deps-dev): bump prettier from 3.5.1 to 3.5.2 (#3754)
  • 63e5829 build(deps): bump @​octokit/plugin-paginate-rest from 11.4.2 to 11.4.3 (#3753)
  • a92c90f build(deps-dev): bump eslint-import-resolver-typescript (#3752)
  • b23b62d build(deps-dev): bump ts-jest from 29.2.5 to 29.2.6 (#3751)
  • dd2324f fix: use showFileAtRefBase64 to read per-commit file contents (#3744)
  • 367180c ci: remove testv5 cmd
  • 25575a1 build: update distribution (#3736)
  • a56e7a5 build(deps): bump @​octokit/core from 6.1.3 to 6.1.4 (#3711)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=peter-evans/create-pull-request&package-manager=github_actions&previous-version=6&new-version=7)](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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/update-a2a-types.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-a2a-types.yml b/.github/workflows/update-a2a-types.yml index cf0473a72..7aa9179e3 100644 --- a/.github/workflows/update-a2a-types.yml +++ b/.github/workflows/update-a2a-types.yml @@ -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@v6 + uses: peter-evans/create-pull-request@v7 with: token: ${{ secrets.A2A_BOT_PAT }} committer: a2a-bot From 98d09d086ac787f4c321fe7a331e83ae052b3ce2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 17:49:12 -0500 Subject: [PATCH 015/384] chore(deps): bump actions/download-artifact from 4 to 5 (#461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
Release notes

Sourced from actions/download-artifact's releases.

v5.0.0

What's Changed

v5.0.0

🚨 Breaking Change

This release fixes an inconsistency in path behavior for single artifact downloads by ID. If you're downloading single artifacts by ID, the output path may change.

What Changed

Previously, single artifact downloads behaved differently depending on how you specified the artifact:

  • By name: name: my-artifact → extracted to path/ (direct)
  • By ID: artifact-ids: 12345 → extracted to path/my-artifact/ (nested)

Now both methods are consistent:

  • By name: name: my-artifact → extracted to path/ (unchanged)
  • By ID: artifact-ids: 12345 → extracted to path/ (fixed - now direct)

Migration Guide

✅ No Action Needed If:
  • You download artifacts by name
  • You download multiple artifacts by ID
  • You already use merge-multiple: true as a workaround
⚠️ Action Required If:

You download single artifacts by ID and your workflows expect the nested directory structure.

Before v5 (nested structure):

- uses: actions/download-artifact@v4
  with:
    artifact-ids: 12345
    path: dist
# Files were in: dist/my-artifact/

Where my-artifact is the name of the artifact you previously uploaded

To maintain old behavior (if needed):

</tr></table>

... (truncated)

Commits
  • 634f93c Merge pull request #416 from actions/single-artifact-id-download-path
  • b19ff43 refactor: resolve download path correctly in artifact download tests (mainly ...
  • e262cbe bundle dist
  • bff23f9 update docs
  • fff8c14 fix download path logic when downloading a single artifact by id
  • 448e3f8 Merge pull request #407 from actions/nebuk89-patch-1
  • 47225c4 Update README.md
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/download-artifact&package-manager=github_actions&previous-version=4&new-version=5)](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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 0b7171225..efcdbb178 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Retrieve release distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: release-dists path: dist/ From 8c59545fc1e62faec7e81b4ead015a510765d701 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 09:34:15 -0500 Subject: [PATCH 016/384] chore(deps): bump astral-sh/setup-uv from 5 to 6 (#460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 5 to 6.
Release notes

Sourced from astral-sh/setup-uv's releases.

v6.6.0 🌈 Support for .tools-versions

Changes

This release adds support for asdf .tool-versions in the version-file input

🐛 Bug fixes

🚀 Enhancements

🧰 Maintenance

v6.2.1 🌈 Fix "No such file or directory version-manifest.json"

Changes

Release v6.2.0 contained a bug that slipped through the automated test. The action tried to look for the default version-manifest.json in the root of the repostory using this action instead of relative to the action itself.

🐛 Bug fixes

v6.0.0 🌈 activate-environment and working-directory

Changes

This version contains some breaking changes which have been gathering up for a while. Lets dive into them:

Activate environment

In previous versions using the input python-version automatically activated a venv at the repository root. This led to some unwanted side-effects, was sometimes unexpected and not flexible enough.

The venv activation is now explicitly controlled with the new input activate-environment (false by default):

- name: Install the latest version of uv and
activate the environment
  uses: astral-sh/setup-uv@v6
  with:
</tr></table>

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=astral-sh/setup-uv&package-manager=github_actions&previous-version=5&new-version=6)](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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index efcdbb178..9c48060ad 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 - name: "Set up Python" uses: actions/setup-python@v5 From 18289eb19bbdaebe5e36e26be686e698f223160b Mon Sep 17 00:00:00 2001 From: Andrew Hoblitzell Date: Mon, 8 Sep 2025 11:27:51 -0400 Subject: [PATCH 017/384] docs: add example docs for `@validate` and `@validate_async_generator` (#422) --- src/a2a/utils/helpers.py | 112 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/src/a2a/utils/helpers.py b/src/a2a/utils/helpers.py index af0959a2c..96c1646a7 100644 --- a/src/a2a/utils/helpers.py +++ b/src/a2a/utils/helpers.py @@ -139,6 +139,62 @@ def validate( and returns a boolean. error_message: An optional custom error message for the `UnsupportedOperationError`. If None, the string representation of the expression will be used. + + Examples: + Demonstrating with an async method: + >>> import asyncio + >>> from a2a.utils.errors import ServerError + >>> + >>> class MyAgent: + ... def __init__(self, streaming_enabled: bool): + ... self.streaming_enabled = streaming_enabled + ... + ... @validate( + ... lambda self: self.streaming_enabled, + ... 'Streaming is not enabled for this agent', + ... ) + ... async def stream_response(self, message: str): + ... return f'Streaming: {message}' + >>> + >>> async def run_async_test(): + ... # Successful call + ... agent_ok = MyAgent(streaming_enabled=True) + ... result = await agent_ok.stream_response('hello') + ... print(result) + ... + ... # Call that fails validation + ... agent_fail = MyAgent(streaming_enabled=False) + ... try: + ... await agent_fail.stream_response('world') + ... except ServerError as e: + ... print(e.error.message) + >>> + >>> asyncio.run(run_async_test()) + Streaming: hello + Streaming is not enabled for this agent + + Demonstrating with a sync method: + >>> class SecureAgent: + ... def __init__(self): + ... self.auth_enabled = False + ... + ... @validate( + ... lambda self: self.auth_enabled, + ... 'Authentication must be enabled for this operation', + ... ) + ... def secure_operation(self, data: str): + ... return f'Processing secure data: {data}' + >>> + >>> # Error case example + >>> agent = SecureAgent() + >>> try: + ... agent.secure_operation('secret') + ... except ServerError as e: + ... print(e.error.message) + Authentication must be enabled for this operation + + Note: + This decorator works with both sync and async methods automatically. """ def decorator(function: Callable) -> Callable: @@ -174,7 +230,7 @@ def sync_wrapper(self: Any, *args, **kwargs) -> Any: def validate_async_generator( expression: Callable[[Any], bool], error_message: str | None = None ): - """Decorator that validates if a given expression evaluates to True. + """Decorator that validates if a given expression evaluates to True for async generators. Typically used on class methods to check capabilities or configuration before executing the method's logic. If the expression is False, @@ -185,6 +241,60 @@ def validate_async_generator( and returns a boolean. error_message: An optional custom error message for the `UnsupportedOperationError`. If None, the string representation of the expression will be used. + + Examples: + Streaming capability validation with success case: + >>> import asyncio + >>> from a2a.utils.errors import ServerError + >>> + >>> class StreamingAgent: + ... def __init__(self, streaming_enabled: bool): + ... self.streaming_enabled = streaming_enabled + ... + ... @validate_async_generator( + ... lambda self: self.streaming_enabled, + ... 'Streaming is not supported by this agent', + ... ) + ... async def stream_messages(self, count: int): + ... for i in range(count): + ... yield f'Message {i}' + >>> + >>> async def run_streaming_test(): + ... # Successful streaming + ... agent = StreamingAgent(streaming_enabled=True) + ... async for msg in agent.stream_messages(2): + ... print(msg) + >>> + >>> asyncio.run(run_streaming_test()) + Message 0 + Message 1 + + Error case - validation fails: + >>> class FeatureAgent: + ... def __init__(self): + ... self.features = {'real_time': False} + ... + ... @validate_async_generator( + ... lambda self: self.features.get('real_time', False), + ... 'Real-time feature must be enabled to stream updates', + ... ) + ... async def real_time_updates(self): + ... yield 'This should not be yielded' + >>> + >>> async def run_error_test(): + ... agent = FeatureAgent() + ... try: + ... async for _ in agent.real_time_updates(): + ... pass + ... except ServerError as e: + ... print(e.error.message) + >>> + >>> asyncio.run(run_error_test()) + Real-time feature must be enabled to stream updates + + Note: + This decorator is specifically for async generator methods (async def with yield). + The validation happens before the generator starts yielding values. """ def decorator(function): From ceeb4ef2b80add43ee3cf879f6521d67c1d571cd Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Mon, 8 Sep 2025 18:29:47 +0100 Subject: [PATCH 018/384] chore(main): release 0.3.5 (#456) :robot: I have created a release *beep* *boop* --- ## [0.3.5](https://github.com/a2aproject/a2a-python/compare/v0.3.4...v0.3.5) (2025-09-08) ### Bug Fixes * Prevent client disconnect from stopping task execution ([#440](https://github.com/a2aproject/a2a-python/issues/440)) ([58b4c81](https://github.com/a2aproject/a2a-python/commit/58b4c81746fc83e65f23f46308c47099697554ea)), closes [#296](https://github.com/a2aproject/a2a-python/issues/296) * **proto:** Adds metadata field to A2A DataPart proto ([#455](https://github.com/a2aproject/a2a-python/issues/455)) ([6d0ef59](https://github.com/a2aproject/a2a-python/commit/6d0ef593adaa22b2af0a5dd1a186646c180e3f8c)) ### Documentation * add example docs for `[@validate](https://github.com/validate)` and `[@validate](https://github.com/validate)_async_generator` ([#422](https://github.com/a2aproject/a2a-python/issues/422)) ([18289eb](https://github.com/a2aproject/a2a-python/commit/18289eb19bbdaebe5e36e26be686e698f223160b)) * Restructure README ([9758f78](https://github.com/a2aproject/a2a-python/commit/9758f7896c5497d6ca49f798296a7380b2134b29)) --- 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 | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99de68f17..5c9062255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.3.5](https://github.com/a2aproject/a2a-python/compare/v0.3.4...v0.3.5) (2025-09-08) + + +### Bug Fixes + +* Prevent client disconnect from stopping task execution ([#440](https://github.com/a2aproject/a2a-python/issues/440)) ([58b4c81](https://github.com/a2aproject/a2a-python/commit/58b4c81746fc83e65f23f46308c47099697554ea)), closes [#296](https://github.com/a2aproject/a2a-python/issues/296) +* **proto:** Adds metadata field to A2A DataPart proto ([#455](https://github.com/a2aproject/a2a-python/issues/455)) ([6d0ef59](https://github.com/a2aproject/a2a-python/commit/6d0ef593adaa22b2af0a5dd1a186646c180e3f8c)) + + +### Documentation + +* add example docs for `[@validate](https://github.com/validate)` and `[@validate](https://github.com/validate)_async_generator` ([#422](https://github.com/a2aproject/a2a-python/issues/422)) ([18289eb](https://github.com/a2aproject/a2a-python/commit/18289eb19bbdaebe5e36e26be686e698f223160b)) +* Restructure README ([9758f78](https://github.com/a2aproject/a2a-python/commit/9758f7896c5497d6ca49f798296a7380b2134b29)) + ## [0.3.4](https://github.com/a2aproject/a2a-python/compare/v0.3.3...v0.3.4) (2025-09-02) From d62df7a77e556f26556fc798a55dc6dacec21ea4 Mon Sep 17 00:00:00 2001 From: Ovidiu Taralesca Date: Tue, 9 Sep 2025 18:09:54 +0300 Subject: [PATCH 019/384] feat: add JSON-RPC `method` to `ServerCallContext.state` (#463) # Improvement SDK users sometimes need to know which JSON-RPC method is being processed to adjust their implementation, e.g.: * If the method is `message/send`, they may want to use `invoke()`/`run()` when calling their LLM API, and `stream()`/`run_streamed()` for `message/stream`. # Implementation Added a `method` field to `ServerCallContext.state`, similar to `headers`. # Tests Added a small test to ensure that method parsing is correct. Fixes #451 --- src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 1 + tests/server/apps/jsonrpc/test_jsonrpc_app.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index 4f158da8e..ea73ff592 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -337,6 +337,7 @@ async def _handle_requests(self, request: Request) -> Response: # noqa: PLR0911 # 3) Build call context and wrap the request for downstream handling call_context = self._context_builder.build(request) + call_context.state['method'] = method request_id = specific_request.id a2a_request = A2ARequest(root=specific_request) diff --git a/tests/server/apps/jsonrpc/test_jsonrpc_app.py b/tests/server/apps/jsonrpc/test_jsonrpc_app.py index 72da73772..36309872e 100644 --- a/tests/server/apps/jsonrpc/test_jsonrpc_app.py +++ b/tests/server/apps/jsonrpc/test_jsonrpc_app.py @@ -289,6 +289,26 @@ def test_request_with_comma_separated_extensions_no_space( call_context = mock_handler.on_message_send.call_args[0][1] assert call_context.requested_extensions == {'foo', 'bar', 'baz'} + def test_method_added_to_call_context_state(self, client, mock_handler): + response = client.post( + '/', + json=SendMessageRequest( + id='1', + params=MessageSendParams( + message=Message( + message_id='1', + role=Role.user, + parts=[Part(TextPart(text='hi'))], + ) + ), + ).model_dump(), + ) + response.raise_for_status() + + mock_handler.on_message_send.assert_called_once() + call_context = mock_handler.on_message_send.call_args[0][1] + assert call_context.state['method'] == 'message/send' + def test_request_with_multiple_extension_headers( self, client, mock_handler ): From 80fc33aaef647826208d9020ef70e5e6592468e3 Mon Sep 17 00:00:00 2001 From: youngchannel Date: Wed, 10 Sep 2025 00:11:00 +0900 Subject: [PATCH 020/384] feat: Add proto conversion utilities (#420) - Adds `make_dict_serializable` to prepare dictionaries for proto conversion. - Adds `normalize_large_integers_to_strings` to convert large integers to strings, preventing precision loss in JS clients. - Adds `parse_string_integers_in_dict` to convert the integer strings back to `int`. - Includes comprehensive unit tests. --------- Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Co-authored-by: Holt Skinner --- .ruff.toml | 1 + src/a2a/utils/proto_utils.py | 80 ++++++++++ tests/utils/test_proto_utils.py | 257 ++++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+) diff --git a/.ruff.toml b/.ruff.toml index 42a2340a2..3562de26e 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -32,6 +32,7 @@ ignore = [ "TRY003", "TRY201", "FIX002", + "UP038", ] select = [ diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index 12b0d3072..f75d06ab7 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -46,6 +46,86 @@ def dict_to_struct(dictionary: dict[str, Any]) -> struct_pb2.Struct: return struct +def make_dict_serializable(value: Any) -> Any: + """Dict pre-processing utility: converts non-serializable values to serializable form. + + Use this when you want to normalize a dictionary before dict->Struct conversion. + + Args: + value: The value to convert. + + Returns: + A serializable value. + """ + if isinstance(value, (str, int, float, bool)) or value is None: + return value + if isinstance(value, dict): + return {k: make_dict_serializable(v) for k, v in value.items()} + if isinstance(value, list | tuple): + return [make_dict_serializable(item) for item in value] + return str(value) + + +def normalize_large_integers_to_strings( + value: Any, max_safe_digits: int = 15 +) -> Any: + """Integer preprocessing utility: converts large integers to strings. + + Use this when you want to convert large integers to strings considering + JavaScript's MAX_SAFE_INTEGER (2^53 - 1) limitation. + + Args: + value: The value to convert. + max_safe_digits: Maximum safe integer digits (default: 15). + + Returns: + A normalized value. + """ + max_safe_int = 10**max_safe_digits - 1 + + def _normalize(item: Any) -> Any: + if isinstance(item, int) and abs(item) > max_safe_int: + return str(item) + if isinstance(item, dict): + return {k: _normalize(v) for k, v in item.items()} + if isinstance(item, list | tuple): + return [_normalize(i) for i in item] + return item + + return _normalize(value) + + +def parse_string_integers_in_dict(value: Any, max_safe_digits: int = 15) -> Any: + """String post-processing utility: converts large integer strings back to integers. + + Use this when you want to restore large integer strings to integers + after Struct->dict conversion. + + Args: + value: The value to convert. + max_safe_digits: Maximum safe integer digits (default: 15). + + Returns: + A parsed value. + """ + if isinstance(value, dict): + return { + k: parse_string_integers_in_dict(v, max_safe_digits) + for k, v in value.items() + } + if isinstance(value, list | tuple): + return [ + parse_string_integers_in_dict(item, max_safe_digits) + for item in value + ] + if isinstance(value, str): + # Handle potential negative numbers. + stripped_value = value.lstrip('-') + if stripped_value.isdigit() and len(stripped_value) > max_safe_digits: + return int(value) + return value + + class ToProto: """Converts Python types to proto types.""" diff --git a/tests/utils/test_proto_utils.py b/tests/utils/test_proto_utils.py index cce4bca23..da54f833f 100644 --- a/tests/utils/test_proto_utils.py +++ b/tests/utils/test_proto_utils.py @@ -251,3 +251,260 @@ def test_none_handling(self): assert proto_utils.ToProto.provider(None) is None assert proto_utils.ToProto.security(None) is None assert proto_utils.ToProto.security_schemes(None) is None + + def test_metadata_conversion(self): + """Test metadata conversion with various data types.""" + metadata = { + 'null_value': None, + 'bool_value': True, + 'int_value': 42, + 'float_value': 3.14, + 'string_value': 'hello', + 'dict_value': {'nested': 'dict', 'count': 10}, + 'list_value': [1, 'two', 3.0, True, None], + 'tuple_value': (1, 2, 3), + 'complex_list': [ + {'name': 'item1', 'values': [1, 2, 3]}, + {'name': 'item2', 'values': [4, 5, 6]}, + ], + } + + # Convert to proto + proto_metadata = proto_utils.ToProto.metadata(metadata) + assert proto_metadata is not None + + # Convert back to Python + roundtrip_metadata = proto_utils.FromProto.metadata(proto_metadata) + + # Verify all values are preserved correctly + assert roundtrip_metadata['null_value'] is None + assert roundtrip_metadata['bool_value'] is True + assert roundtrip_metadata['int_value'] == 42 + assert roundtrip_metadata['float_value'] == 3.14 + assert roundtrip_metadata['string_value'] == 'hello' + assert roundtrip_metadata['dict_value']['nested'] == 'dict' + assert roundtrip_metadata['dict_value']['count'] == 10 + assert roundtrip_metadata['list_value'] == [1, 'two', 3.0, True, None] + assert roundtrip_metadata['tuple_value'] == [ + 1, + 2, + 3, + ] # tuples become lists + assert len(roundtrip_metadata['complex_list']) == 2 + assert roundtrip_metadata['complex_list'][0]['name'] == 'item1' + + def test_metadata_with_custom_objects(self): + """Test metadata conversion with custom objects using preprocessing utility.""" + + class CustomObject: + def __str__(self): + return 'custom_object_str' + + def __repr__(self): + return 'CustomObject()' + + metadata = { + 'custom_obj': CustomObject(), + 'list_with_custom': [1, CustomObject(), 'text'], + 'nested_custom': {'obj': CustomObject(), 'normal': 'value'}, + } + + # Use preprocessing utility to make it serializable + serializable_metadata = proto_utils.make_dict_serializable(metadata) + + # Convert to proto + proto_metadata = proto_utils.ToProto.metadata(serializable_metadata) + assert proto_metadata is not None + + # Convert back to Python + roundtrip_metadata = proto_utils.FromProto.metadata(proto_metadata) + + # Custom objects should be converted to strings + assert roundtrip_metadata['custom_obj'] == 'custom_object_str' + assert roundtrip_metadata['list_with_custom'] == [ + 1, + 'custom_object_str', + 'text', + ] + assert roundtrip_metadata['nested_custom']['obj'] == 'custom_object_str' + assert roundtrip_metadata['nested_custom']['normal'] == 'value' + + def test_metadata_edge_cases(self): + """Test metadata conversion with edge cases.""" + metadata = { + 'empty_dict': {}, + 'empty_list': [], + 'zero': 0, + 'false': False, + 'empty_string': '', + 'unicode_string': 'string test', + 'safe_number': 9007199254740991, # JavaScript MAX_SAFE_INTEGER + 'negative_number': -42, + 'float_precision': 0.123456789, + 'numeric_string': '12345', + } + + # Convert to proto and back + proto_metadata = proto_utils.ToProto.metadata(metadata) + roundtrip_metadata = proto_utils.FromProto.metadata(proto_metadata) + + # Verify edge cases are handled correctly + assert roundtrip_metadata['empty_dict'] == {} + assert roundtrip_metadata['empty_list'] == [] + assert roundtrip_metadata['zero'] == 0 + assert roundtrip_metadata['false'] is False + assert roundtrip_metadata['empty_string'] == '' + assert roundtrip_metadata['unicode_string'] == 'string test' + assert roundtrip_metadata['safe_number'] == 9007199254740991 + assert roundtrip_metadata['negative_number'] == -42 + assert abs(roundtrip_metadata['float_precision'] - 0.123456789) < 1e-10 + assert roundtrip_metadata['numeric_string'] == '12345' + + def test_make_dict_serializable(self): + """Test the make_dict_serializable utility function.""" + + class CustomObject: + def __str__(self): + return 'custom_str' + + test_data = { + 'string': 'hello', + 'int': 42, + 'float': 3.14, + 'bool': True, + 'none': None, + 'custom': CustomObject(), + 'list': [1, 'two', CustomObject()], + 'tuple': (1, 2, CustomObject()), + 'nested': {'inner_custom': CustomObject(), 'inner_normal': 'value'}, + } + + result = proto_utils.make_dict_serializable(test_data) + + # Basic types should be unchanged + assert result['string'] == 'hello' + assert result['int'] == 42 + assert result['float'] == 3.14 + assert result['bool'] is True + assert result['none'] is None + + # Custom objects should be converted to strings + assert result['custom'] == 'custom_str' + assert result['list'] == [1, 'two', 'custom_str'] + assert result['tuple'] == [1, 2, 'custom_str'] # tuples become lists + assert result['nested']['inner_custom'] == 'custom_str' + assert result['nested']['inner_normal'] == 'value' + + def test_normalize_large_integers_to_strings(self): + """Test the normalize_large_integers_to_strings utility function.""" + + test_data = { + 'small_int': 42, + 'large_int': 9999999999999999999, # > 15 digits + 'negative_large': -9999999999999999999, + 'float': 3.14, + 'string': 'hello', + 'list': [123, 9999999999999999999, 'text'], + 'nested': {'inner_large': 9999999999999999999, 'inner_small': 100}, + } + + result = proto_utils.normalize_large_integers_to_strings(test_data) + + # Small integers should remain as integers + assert result['small_int'] == 42 + assert isinstance(result['small_int'], int) + + # Large integers should be converted to strings + assert result['large_int'] == '9999999999999999999' + assert isinstance(result['large_int'], str) + assert result['negative_large'] == '-9999999999999999999' + assert isinstance(result['negative_large'], str) + + # Other types should be unchanged + assert result['float'] == 3.14 + assert result['string'] == 'hello' + + # Lists should be processed recursively + assert result['list'] == [123, '9999999999999999999', 'text'] + + # Nested dicts should be processed recursively + assert result['nested']['inner_large'] == '9999999999999999999' + assert result['nested']['inner_small'] == 100 + + def test_parse_string_integers_in_dict(self): + """Test the parse_string_integers_in_dict utility function.""" + + test_data = { + 'regular_string': 'hello', + 'numeric_string_small': '123', # small, should stay as string + 'numeric_string_large': '9999999999999999999', # > 15 digits, should become int + 'negative_large_string': '-9999999999999999999', + 'float_string': '3.14', # not all digits, should stay as string + 'mixed_string': '123abc', # not all digits, should stay as string + 'int': 42, + 'list': ['hello', '9999999999999999999', '123'], + 'nested': { + 'inner_large_string': '9999999999999999999', + 'inner_regular': 'value', + }, + } + + result = proto_utils.parse_string_integers_in_dict(test_data) + + # Regular strings should remain unchanged + assert result['regular_string'] == 'hello' + assert ( + result['numeric_string_small'] == '123' + ) # too small, stays string + assert result['float_string'] == '3.14' # not all digits + assert result['mixed_string'] == '123abc' # not all digits + + # Large numeric strings should be converted to integers + assert result['numeric_string_large'] == 9999999999999999999 + assert isinstance(result['numeric_string_large'], int) + assert result['negative_large_string'] == -9999999999999999999 + assert isinstance(result['negative_large_string'], int) + + # Other types should be unchanged + assert result['int'] == 42 + + # Lists should be processed recursively + assert result['list'] == ['hello', 9999999999999999999, '123'] + + # Nested dicts should be processed recursively + assert result['nested']['inner_large_string'] == 9999999999999999999 + assert result['nested']['inner_regular'] == 'value' + + def test_large_integer_roundtrip_with_utilities(self): + """Test large integer handling with preprocessing and post-processing utilities.""" + + original_data = { + 'large_int': 9999999999999999999, + 'small_int': 42, + 'nested': {'another_large': 12345678901234567890, 'normal': 'text'}, + } + + # Step 1: Preprocess to convert large integers to strings + preprocessed = proto_utils.normalize_large_integers_to_strings( + original_data + ) + + # Step 2: Convert to proto + proto_metadata = proto_utils.ToProto.metadata(preprocessed) + assert proto_metadata is not None + + # Step 3: Convert back from proto + dict_from_proto = proto_utils.FromProto.metadata(proto_metadata) + + # Step 4: Post-process to convert large integer strings back to integers + final_result = proto_utils.parse_string_integers_in_dict( + dict_from_proto + ) + + # Verify roundtrip preserved the original data + assert final_result['large_int'] == 9999999999999999999 + assert isinstance(final_result['large_int'], int) + assert final_result['small_int'] == 42 + assert final_result['nested']['another_large'] == 12345678901234567890 + assert isinstance(final_result['nested']['another_large'], int) + assert final_result['nested']['normal'] == 'text' From acc9d3bdab871b61cafabc880302ce93b942036e Mon Sep 17 00:00:00 2001 From: Holt Skinner Date: Tue, 9 Sep 2025 10:32:20 -0500 Subject: [PATCH 021/384] chore: release 0.3.6 Release-As: 0.3.6 From eeb8344451df68ade3a2257d5c604197a704ae5b Mon Sep 17 00:00:00 2001 From: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:45:13 -0500 Subject: [PATCH 022/384] ci: Add Gemini Code Assist Options (#467) --- .gemini/config.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gemini/config.yaml diff --git a/.gemini/config.yaml b/.gemini/config.yaml new file mode 100644 index 000000000..518d8fdf8 --- /dev/null +++ b/.gemini/config.yaml @@ -0,0 +1,3 @@ +code_review: + comment_severity_threshold: LOW +ignore_patterns: ['CHANGELOG.md'] From 1cf818558385976acf03c0fe2857c2b411ae4313 Mon Sep 17 00:00:00 2001 From: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:47:03 -0500 Subject: [PATCH 023/384] chore(deps): Create `all` extra to simplify installation and testing (#468) --- .github/actions/spelling/allow.txt | 2 ++ .github/workflows/unit-tests.yml | 2 +- README.md | 19 ++++++++----------- pyproject.toml | 17 +++++++++++++---- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 1216135c3..4d5c1d2c7 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -26,6 +26,7 @@ coc codegen coro datamodel +deepwiki drivername DSNs dunders @@ -80,5 +81,6 @@ tagwords taskupdate testuuid Tful +tiangolo typeerror vulnz diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index d00164ab2..ce8d62ab9 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 sql --extra encryption --extra grpc --extra telemetry + run: uv sync --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/README.md b/README.md index a3463b07a..4c8f83785 100644 --- a/README.md +++ b/README.md @@ -47,20 +47,17 @@ Install the core SDK and any desired extras using your preferred package manager | Feature | `uv` Command | `pip` Command | | ------------------------ | ------------------------------------------ | -------------------------------------------- | | **Core SDK** | `uv add a2a-sdk` | `pip install a2a-sdk` | +| **All Extras** | `uv add a2a-sdk[all]` | `pip install a2a-sdk[all]` | | **HTTP Server** | `uv add "a2a-sdk[http-server]"` | `pip install "a2a-sdk[http-server]"` | | **gRPC Support** | `uv add "a2a-sdk[grpc]"` | `pip install "a2a-sdk[grpc]"` | | **OpenTelemetry Tracing**| `uv add "a2a-sdk[telemetry]"` | `pip install "a2a-sdk[telemetry]"` | - -#### Database Support - -Install the necessary drivers for your chosen SQL database. - -| Database | `uv` Command | `pip` Command | -| ------------- | ---------------------------------- | ------------------------------------ | -| **PostgreSQL**| `uv add "a2a-sdk[postgresql]"` | `pip install "a2a-sdk[postgresql]"` | -| **MySQL** | `uv add "a2a-sdk[mysql]"` | `pip install "a2a-sdk[mysql]"` | -| **SQLite** | `uv add "a2a-sdk[sqlite]"` | `pip install "a2a-sdk[sqlite]"` | -| **All SQL Drivers** | `uv add "a2a-sdk[sql]"` | `pip install "a2a-sdk[sql]"` | +| **Encryption** | `uv add "a2a-sdk[encryption]"` | `pip install "a2a-sdk[encryption]"` | +| | | | +| **Database Drivers** | | | +| **PostgreSQL** | `uv add "a2a-sdk[postgresql]"` | `pip install "a2a-sdk[postgresql]"` | +| **MySQL** | `uv add "a2a-sdk[mysql]"` | `pip install "a2a-sdk[mysql]"` | +| **SQLite** | `uv add "a2a-sdk[sqlite]"` | `pip install "a2a-sdk[sqlite]"` | +| **All SQL Drivers** | `uv add "a2a-sdk[sql]"` | `pip install "a2a-sdk[sql]"` | ## Examples diff --git a/pyproject.toml b/pyproject.toml index 80e38bd8e..0d2cb75a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,13 +30,22 @@ classifiers = [ [project.optional-dependencies] http-server = ["fastapi>=0.115.2", "sse-starlette", "starlette"] -postgresql = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.0"] -mysql = ["sqlalchemy[asyncio,aiomysql]>=2.0.0"] -sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"] -sql = ["sqlalchemy[asyncio,postgresql-asyncpg,aiomysql,aiosqlite]>=2.0.0"] encryption = ["cryptography>=43.0.0"] 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"] +sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"] + +sql = ["a2a-sdk[postgresql,mysql,sqlite]"] + +all = [ + "a2a-sdk[http-server]", + "a2a-sdk[sql]", + "a2a-sdk[encryption]", + "a2a-sdk[grpc]", + "a2a-sdk[telemetry]", +] [project.urls] homepage = "https://a2a-protocol.org/" From b2e3a29607cdac9b555e7fd188048c67dd72dc67 Mon Sep 17 00:00:00 2001 From: Shingo OKAWA Date: Wed, 10 Sep 2025 05:20:09 +0900 Subject: [PATCH 024/384] test: improve test coverage for `grpc_client.py` (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Refactored `tests/client/test_grpc_client.py` and added test cases to improve coverage for `grpc_client.py`. - Improved test coverage for `src/a2a/client/grpc_client.py`: 41% → 98% - Fixed incorrect field checks on the response object in `grpc_client::send_message` - Fixed missing `await` call at the correct location in `grpc_client::send_message_streaming` - Defined valid `taskId` format (`[a-zA-Z0-9_.-]+`) 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 `nox -s format` from the repository root to format) - [x] Appropriate docs were updated (if necessary) Fixes N/A 🦕 --------- Signed-off-by: Shingo OKAWA Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Holt Skinner --- src/a2a/utils/proto_utils.py | 4 +- tests/client/test_grpc_client.py | 256 ++++++++++++++++++++++++++++++- 2 files changed, 253 insertions(+), 7 deletions(-) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index f75d06ab7..bf7953ca9 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -18,9 +18,9 @@ # Regexp patterns for matching -_TASK_NAME_MATCH = re.compile(r'tasks/([\w-]+)') +_TASK_NAME_MATCH = re.compile(r'tasks/([^/]+)') _TASK_PUSH_CONFIG_NAME_MATCH = re.compile( - r'tasks/([\w-]+)/pushNotificationConfigs/([\w-]+)' + r'tasks/([^/]+)/pushNotificationConfigs/([^/]+)' ) diff --git a/tests/client/test_grpc_client.py b/tests/client/test_grpc_client.py index c6481b377..19f5abc16 100644 --- a/tests/client/test_grpc_client.py +++ b/tests/client/test_grpc_client.py @@ -1,5 +1,6 @@ -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock +import grpc import pytest from a2a.client.transports.grpc import GrpcTransport @@ -7,31 +8,38 @@ from a2a.types import ( AgentCapabilities, AgentCard, + Artifact, + GetTaskPushNotificationConfigParams, Message, MessageSendParams, Part, + PushNotificationAuthenticationInfo, + PushNotificationConfig, Role, Task, + TaskArtifactUpdateEvent, TaskIdParams, + TaskPushNotificationConfig, TaskQueryParams, TaskState, TaskStatus, + TaskStatusUpdateEvent, TextPart, ) from a2a.utils import get_text_parts, proto_utils +from a2a.utils.errors import ServerError -# Fixtures @pytest.fixture def mock_grpc_stub() -> AsyncMock: """Provides a mock gRPC stub with methods mocked.""" stub = AsyncMock(spec=a2a_pb2_grpc.A2AServiceStub) stub.SendMessage = AsyncMock() - stub.SendStreamingMessage = AsyncMock() + stub.SendStreamingMessage = MagicMock() stub.GetTask = AsyncMock() stub.CancelTask = AsyncMock() - stub.CreateTaskPushNotification = AsyncMock() - stub.GetTaskPushNotification = AsyncMock() + stub.CreateTaskPushNotificationConfig = AsyncMock() + stub.GetTaskPushNotificationConfig = AsyncMock() return stub @@ -93,6 +101,78 @@ def sample_message() -> Message: ) +@pytest.fixture +def sample_artifact() -> Artifact: + """Provides a sample Artifact object.""" + return Artifact( + artifact_id='artifact-1', + name='example.txt', + description='An example artifact', + parts=[Part(root=TextPart(text='Hi there'))], + metadata={}, + extensions=[], + ) + + +@pytest.fixture +def sample_task_status_update_event() -> TaskStatusUpdateEvent: + """Provides a sample TaskStatusUpdateEvent.""" + return TaskStatusUpdateEvent( + task_id='task-1', + context_id='ctx-1', + status=TaskStatus(state=TaskState.working), + final=False, + metadata={}, + ) + + +@pytest.fixture +def sample_task_artifact_update_event( + sample_artifact, +) -> TaskArtifactUpdateEvent: + """Provides a sample TaskArtifactUpdateEvent.""" + return TaskArtifactUpdateEvent( + task_id='task-1', + context_id='ctx-1', + artifact=sample_artifact, + append=True, + last_chunk=True, + metadata={}, + ) + + +@pytest.fixture +def sample_authentication_info() -> PushNotificationAuthenticationInfo: + """Provides a sample AuthenticationInfo object.""" + return PushNotificationAuthenticationInfo( + schemes=['apikey', 'oauth2'], credentials='secret-token' + ) + + +@pytest.fixture +def sample_push_notification_config( + sample_authentication_info: PushNotificationAuthenticationInfo, +) -> PushNotificationConfig: + """Provides a sample PushNotificationConfig object.""" + return PushNotificationConfig( + id='config-1', + url='https://example.com/notify', + token='example-token', + authentication=sample_authentication_info, + ) + + +@pytest.fixture +def sample_task_push_notification_config( + sample_push_notification_config: PushNotificationConfig, +) -> TaskPushNotificationConfig: + """Provides a sample TaskPushNotificationConfig object.""" + return TaskPushNotificationConfig( + task_id='task-1', + push_notification_config=sample_push_notification_config, + ) + + @pytest.mark.asyncio async def test_send_message_task_response( grpc_transport: GrpcTransport, @@ -134,6 +214,57 @@ async def test_send_message_message_response( ) +@pytest.mark.asyncio +async def test_send_message_streaming( # noqa: PLR0913 + grpc_transport: GrpcTransport, + mock_grpc_stub: AsyncMock, + sample_message_send_params: MessageSendParams, + sample_message: Message, + sample_task: Task, + sample_task_status_update_event: TaskStatusUpdateEvent, + sample_task_artifact_update_event: TaskArtifactUpdateEvent, +): + """Test send_message_streaming that yields responses.""" + stream = MagicMock() + stream.read = AsyncMock( + side_effect=[ + a2a_pb2.StreamResponse( + msg=proto_utils.ToProto.message(sample_message) + ), + a2a_pb2.StreamResponse(task=proto_utils.ToProto.task(sample_task)), + a2a_pb2.StreamResponse( + status_update=proto_utils.ToProto.task_status_update_event( + sample_task_status_update_event + ) + ), + a2a_pb2.StreamResponse( + artifact_update=proto_utils.ToProto.task_artifact_update_event( + sample_task_artifact_update_event + ) + ), + grpc.aio.EOF, + ] + ) + mock_grpc_stub.SendStreamingMessage.return_value = stream + + responses = [ + response + async for response in grpc_transport.send_message_streaming( + sample_message_send_params + ) + ] + + mock_grpc_stub.SendStreamingMessage.assert_called_once() + assert isinstance(responses[0], Message) + assert responses[0].message_id == sample_message.message_id + assert isinstance(responses[1], Task) + assert responses[1].id == sample_task.id + assert isinstance(responses[2], TaskStatusUpdateEvent) + assert responses[2].task_id == sample_task_status_update_event.task_id + assert isinstance(responses[3], TaskArtifactUpdateEvent) + assert responses[3].task_id == sample_task_artifact_update_event.task_id + + @pytest.mark.asyncio async def test_get_task( grpc_transport: GrpcTransport, mock_grpc_stub: AsyncMock, sample_task: Task @@ -188,3 +319,118 @@ async def test_cancel_task( a2a_pb2.CancelTaskRequest(name=f'tasks/{sample_task.id}') ) assert response.status.state == TaskState.canceled + + +@pytest.mark.asyncio +async def test_set_task_callback_with_valid_task( + grpc_transport: GrpcTransport, + mock_grpc_stub: AsyncMock, + sample_task_push_notification_config: TaskPushNotificationConfig, +): + """Test setting a task push notification config with a valid task id.""" + mock_grpc_stub.CreateTaskPushNotificationConfig.return_value = ( + proto_utils.ToProto.task_push_notification_config( + sample_task_push_notification_config + ) + ) + + response = await grpc_transport.set_task_callback( + sample_task_push_notification_config + ) + + mock_grpc_stub.CreateTaskPushNotificationConfig.assert_awaited_once_with( + a2a_pb2.CreateTaskPushNotificationConfigRequest( + parent=f'tasks/{sample_task_push_notification_config.task_id}', + config_id=sample_task_push_notification_config.push_notification_config.id, + config=proto_utils.ToProto.task_push_notification_config( + sample_task_push_notification_config + ), + ) + ) + assert response.task_id == sample_task_push_notification_config.task_id + + +@pytest.mark.asyncio +async def test_set_task_callback_with_invalid_task( + grpc_transport: GrpcTransport, + mock_grpc_stub: AsyncMock, + sample_task_push_notification_config: TaskPushNotificationConfig, +): + """Test setting a task push notification config with an invalid task id.""" + mock_grpc_stub.CreateTaskPushNotificationConfig.return_value = a2a_pb2.TaskPushNotificationConfig( + name=( + f'invalid-path-to-tasks/{sample_task_push_notification_config.task_id}/' + f'pushNotificationConfigs/{sample_task_push_notification_config.push_notification_config.id}' + ), + push_notification_config=proto_utils.ToProto.push_notification_config( + sample_task_push_notification_config.push_notification_config + ), + ) + + with pytest.raises(ServerError) as exc_info: + await grpc_transport.set_task_callback( + sample_task_push_notification_config + ) + assert ( + 'Bad TaskPushNotificationConfig resource name' + in exc_info.value.error.message + ) + + +@pytest.mark.asyncio +async def test_get_task_callback_with_valid_task( + grpc_transport: GrpcTransport, + mock_grpc_stub: AsyncMock, + sample_task_push_notification_config: TaskPushNotificationConfig, +): + """Test retrieving a task push notification config with a valid task id.""" + mock_grpc_stub.GetTaskPushNotificationConfig.return_value = ( + proto_utils.ToProto.task_push_notification_config( + sample_task_push_notification_config + ) + ) + params = GetTaskPushNotificationConfigParams( + id=sample_task_push_notification_config.task_id, + push_notification_config_id=sample_task_push_notification_config.push_notification_config.id, + ) + + response = await grpc_transport.get_task_callback(params) + + mock_grpc_stub.GetTaskPushNotificationConfig.assert_awaited_once_with( + a2a_pb2.GetTaskPushNotificationConfigRequest( + name=( + f'tasks/{params.id}/' + f'pushNotificationConfigs/{params.push_notification_config_id}' + ), + ) + ) + assert response.task_id == sample_task_push_notification_config.task_id + + +@pytest.mark.asyncio +async def test_get_task_callback_with_invalid_task( + grpc_transport: GrpcTransport, + mock_grpc_stub: AsyncMock, + sample_task_push_notification_config: TaskPushNotificationConfig, +): + """Test retrieving a task push notification config with an invalid task id.""" + mock_grpc_stub.GetTaskPushNotificationConfig.return_value = a2a_pb2.TaskPushNotificationConfig( + name=( + f'invalid-path-to-tasks/{sample_task_push_notification_config.task_id}/' + f'pushNotificationConfigs/{sample_task_push_notification_config.push_notification_config.id}' + ), + push_notification_config=proto_utils.ToProto.push_notification_config( + sample_task_push_notification_config.push_notification_config + ), + ) + params = GetTaskPushNotificationConfigParams( + id=sample_task_push_notification_config.task_id, + push_notification_config_id=sample_task_push_notification_config.push_notification_config.id, + ) + + with pytest.raises(ServerError) as exc_info: + await grpc_transport.get_task_callback(params) + assert ( + 'Bad TaskPushNotificationConfig resource name' + in exc_info.value.error.message + ) From 5ec078830eccf2a72c4a467e520de70c3cbca68e Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Wed, 17 Sep 2025 12:16:43 -0500 Subject: [PATCH 025/384] chore(main): release 0.3.6 (#466) :robot: I have created a release *beep* *boop* --- ## [0.3.6](https://github.com/a2aproject/a2a-python/compare/v0.3.5...v0.3.6) (2025-09-09) ### Features * add JSON-RPC `method` to `ServerCallContext.state` ([d62df7a](https://github.com/a2aproject/a2a-python/commit/d62df7a77e556f26556fc798a55dc6dacec21ea4)) * **gRPC:** Add proto conversion utilities ([80fc33a](https://github.com/a2aproject/a2a-python/commit/80fc33aaef647826208d9020ef70e5e6592468e3)) ### Miscellaneous Chores * release 0.3.6 ([acc9d3b](https://github.com/a2aproject/a2a-python/commit/acc9d3bdab871b61cafabc880302ce93b942036e)) --- 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 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c9062255..a73176991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.3.6](https://github.com/a2aproject/a2a-python/compare/v0.3.5...v0.3.6) (2025-09-09) + + +### Features + +* add JSON-RPC `method` to `ServerCallContext.state` ([d62df7a](https://github.com/a2aproject/a2a-python/commit/d62df7a77e556f26556fc798a55dc6dacec21ea4)) +* **gRPC:** Add proto conversion utilities ([80fc33a](https://github.com/a2aproject/a2a-python/commit/80fc33aaef647826208d9020ef70e5e6592468e3)) + ## [0.3.5](https://github.com/a2aproject/a2a-python/compare/v0.3.4...v0.3.5) (2025-09-08) From 5342ca43398ec004597167f6b1a47525b69d1439 Mon Sep 17 00:00:00 2001 From: Ovidiu Taralesca Date: Thu, 18 Sep 2025 01:32:54 +0300 Subject: [PATCH 026/384] fix: Task state is not persisted to task store after client disconnect (#472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Issue It's been described well in #464 : > When the client-side connection is terminated, the EventConsumer stops processing. As a result, any changes to the task state after the disconnection are not persisted to the TaskStore. The task itself continues running in the background, but its updated state is no longer reflected in the TaskStore. This has been addressed in this PR by simply adding a catch for `(asyncio.CancelledError, GeneratorExit)` in the `on_message_send_stream` method. However, adding that revealed a difference in semantics between Python 3.13+ and <3.13 for `EventQueue.close()`. I have also addressed that. # How it's reproduced [@azyobuzin](https://github.com/azyobuzin) provided a detailed guide on this in #464 . My only addition would be to add loggers for `a2a.server.events.event_queue` and `a2a.server.events.event_consumer` to get a better understanding of what's happening under the hood. # Fix ## Code - Ensure streaming continues persisting events after client disconnect via background consumption by adding a catch for `(asyncio.CancelledError, GeneratorExit)` in the `on_message_send_stream` method. - Align EventQueue.close() behavior on Python ≥3.13 and ≤3.12 (graceful vs. immediate). ## Tests ### Event queue tests (`tests/server/events/test_event_queue.py`) Added/updated tests to verify: - Graceful close on ≥3.13 waits for drain and children. - Immediate close clears queues and propagates. - To support Python 3.10, when simulating ≥3.13 using sys.version_info, inject a dummy queue.shutdown on asyncio.Queue so tests don’t fail on runtimes without it. I've seen this pattern used in existing tests too. ### Request handler tests (`tests/server/request_handlers/test_default_request_handler.py`) Added `test_disconnect_persists_final_task_to_store` which tests the flow described in issue #464 : - Starts streaming, yields first event, then simulates client disconnect. - Background consumer persists the final Task to `InMemoryTaskStore`. - Uses `wait_until` to await disappearance of the specific `background_consume:{task_id}` task, then asserts `TaskState.completed`. ### General Added a cleanup for lingering background tasks, I think it's an improvement for my earlier PR where I've tracked background tasks. # Misc Ruff `0.13.0` now fails the check for unused variables, made some minimal changes as suggested by the linter, irrelevant to the issue: underscores for `_payload` and `_task_manager`. Fixes #464 --- src/a2a/client/transports/rest.py | 2 +- src/a2a/server/events/event_queue.py | 38 +++++-- .../default_request_handler.py | 16 ++- tests/server/events/test_event_queue.py | 58 ++++++---- .../test_default_request_handler.py | 105 ++++++++++++++++++ 5 files changed, 183 insertions(+), 36 deletions(-) diff --git a/src/a2a/client/transports/rest.py b/src/a2a/client/transports/rest.py index 3a72a5b14..082c21cc8 100644 --- a/src/a2a/client/transports/rest.py +++ b/src/a2a/client/transports/rest.py @@ -206,7 +206,7 @@ async def get_task( context: ClientCallContext | None = None, ) -> Task: """Retrieves the current state and history of a specific task.""" - payload, modified_kwargs = await self._apply_interceptors( + _payload, modified_kwargs = await self._apply_interceptors( request.model_dump(mode='json', exclude_none=True), self._get_http_args(context), context, diff --git a/src/a2a/server/events/event_queue.py b/src/a2a/server/events/event_queue.py index 814bc879a..f6599ccae 100644 --- a/src/a2a/server/events/event_queue.py +++ b/src/a2a/server/events/event_queue.py @@ -135,9 +135,18 @@ def tap(self) -> 'EventQueue': async def close(self, immediate: bool = False) -> None: """Closes the queue for future push events and also closes all child queues. - Once closed, no new events can be enqueued. For Python 3.13+, this will trigger - `asyncio.QueueShutDown` when the queue is empty and a consumer tries to dequeue. - For lower versions, the queue will be marked as closed and optionally cleared. + Once closed, no new events can be enqueued. Behavior is consistent across + Python versions: + - Python >= 3.13: Uses `asyncio.Queue.shutdown` to stop the queue. With + `immediate=True` the queue is shut down and pending events are cleared; with + `immediate=False` the queue is shut down and we wait for it to drain via + `queue.join()`. + - Python < 3.13: Emulates the same semantics by clearing on `immediate=True` + or awaiting `queue.join()` on `immediate=False`. + + Consumers attempting to dequeue after close on an empty queue will observe + `asyncio.QueueShutDown` on Python >= 3.13 and `asyncio.QueueEmpty` on + Python < 3.13. Args: immediate (bool): @@ -152,11 +161,20 @@ async def close(self, immediate: bool = False) -> None: return if not self._is_closed: self._is_closed = True - # If using python 3.13 or higher, use the shutdown method + # If using python 3.13 or higher, use shutdown but match <3.13 semantics if sys.version_info >= (3, 13): - self.queue.shutdown(immediate) - for child in self._children: - await child.close(immediate) + if immediate: + # Immediate: stop queue and clear any pending events, then close children + self.queue.shutdown(True) + await self.clear_events(True) + for child in self._children: + await child.close(True) + return + # Graceful: prevent further gets/puts via shutdown, then wait for drain and children + self.queue.shutdown(False) + await asyncio.gather( + self.queue.join(), *(child.close() for child in self._children) + ) # Otherwise, join the queue else: if immediate: @@ -164,11 +182,9 @@ async def close(self, immediate: bool = False) -> None: for child in self._children: await child.close(immediate) return - tasks = [asyncio.create_task(self.queue.join())] - tasks.extend( - asyncio.create_task(child.close()) for child in self._children + await asyncio.gather( + self.queue.join(), *(child.close() for child in self._children) ) - await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) def is_closed(self) -> bool: """Checks if the queue is closed.""" diff --git a/src/a2a/server/request_handlers/default_request_handler.py b/src/a2a/server/request_handlers/default_request_handler.py index ee406d6bc..5e21fe8b0 100644 --- a/src/a2a/server/request_handlers/default_request_handler.py +++ b/src/a2a/server/request_handlers/default_request_handler.py @@ -314,7 +314,7 @@ async def on_message_send( result (Task or Message). """ ( - task_manager, + _task_manager, task_id, queue, result_aggregator, @@ -379,16 +379,16 @@ async def on_message_send_stream( by the agent. """ ( - task_manager, + _task_manager, task_id, queue, result_aggregator, producer_task, ) = await self._setup_message_execution(params, context) + consumer = EventConsumer(queue) + producer_task.add_done_callback(consumer.agent_task_callback) try: - consumer = EventConsumer(queue) - producer_task.add_done_callback(consumer.agent_task_callback) async for event in result_aggregator.consume_and_emit(consumer): if isinstance(event, Task): self._validate_task_id_match(task_id, event.id) @@ -397,6 +397,14 @@ async def on_message_send_stream( task_id, result_aggregator ) yield event + except (asyncio.CancelledError, GeneratorExit): + # Client disconnected: continue consuming and persisting events in the background + bg_task = asyncio.create_task( + result_aggregator.consume_all(consumer) + ) + bg_task.set_name(f'background_consume:{task_id}') + self._track_background_task(bg_task) + raise finally: cleanup_task = asyncio.create_task( self._cleanup_producer(producer_task, task_id) diff --git a/tests/server/events/test_event_queue.py b/tests/server/events/test_event_queue.py index fc139ecc6..18ebf72b8 100644 --- a/tests/server/events/test_event_queue.py +++ b/tests/server/events/test_event_queue.py @@ -271,15 +271,7 @@ async def test_tap_creates_child_queue(event_queue: EventQueue) -> None: @pytest.mark.asyncio -@patch( - 'asyncio.wait' -) # To monitor calls to asyncio.wait for older Python versions -@patch( - 'asyncio.create_task' -) # To monitor calls to asyncio.create_task for older Python versions async def test_close_sets_flag_and_handles_internal_queue_old_python( - mock_create_task: MagicMock, - mock_asyncio_wait: AsyncMock, event_queue: EventQueue, ) -> None: """Test close behavior on Python < 3.13 (using queue.join).""" @@ -290,9 +282,7 @@ async def test_close_sets_flag_and_handles_internal_queue_old_python( await event_queue.close() assert event_queue.is_closed() is True - event_queue.queue.join.assert_called_once() # specific to <3.13 - mock_create_task.assert_called_once() # create_task for join - mock_asyncio_wait.assert_called_once() # wait for join + event_queue.queue.join.assert_awaited_once() # waited for drain @pytest.mark.asyncio @@ -300,14 +290,39 @@ async def test_close_sets_flag_and_handles_internal_queue_new_python( event_queue: EventQueue, ) -> None: """Test close behavior on Python >= 3.13 (using queue.shutdown).""" - with patch('sys.version_info', (3, 13, 0)): # Simulate Python 3.13+ - # Mock queue.shutdown as it's called in newer versions - event_queue.queue.shutdown = MagicMock() # shutdown is not async + with patch('sys.version_info', (3, 13, 0)): + # Inject a dummy shutdown method for non-3.13 runtimes + from typing import cast + queue = cast('Any', event_queue.queue) + queue.shutdown = MagicMock() # type: ignore[attr-defined] await event_queue.close() - assert event_queue.is_closed() is True - event_queue.queue.shutdown.assert_called_once() # specific to >=3.13 + queue.shutdown.assert_called_once_with(False) + + +@pytest.mark.asyncio +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.""" + with patch('sys.version_info', (3, 13, 0)): + # Arrange + from typing import cast + + q_any = cast('Any', event_queue.queue) + q_any.shutdown = MagicMock() # type: ignore[attr-defined] + event_queue.queue.join = AsyncMock() + + child = event_queue.tap() + child.close = AsyncMock() + + # Act + await event_queue.close(immediate=False) + + # Assert + event_queue.queue.join.assert_awaited_once() + child.close.assert_awaited_once() @pytest.mark.asyncio @@ -345,15 +360,18 @@ async def test_close_idempotent(event_queue: EventQueue) -> None: # Reset for new Python version test event_queue_new = EventQueue() # New queue for fresh state - with patch('sys.version_info', (3, 13, 0)): # Test with newer version logic - event_queue_new.queue.shutdown = MagicMock() + with patch('sys.version_info', (3, 13, 0)): + from typing import cast + + queue = cast('Any', event_queue_new.queue) + queue.shutdown = MagicMock() # type: ignore[attr-defined] await event_queue_new.close() assert event_queue_new.is_closed() is True - event_queue_new.queue.shutdown.assert_called_once() + queue.shutdown.assert_called_once() await event_queue_new.close() assert event_queue_new.is_closed() is True - event_queue_new.queue.shutdown.assert_called_once() # Still only called once + queue.shutdown.assert_called_once() # Still only called once @pytest.mark.asyncio diff --git a/tests/server/request_handlers/test_default_request_handler.py b/tests/server/request_handlers/test_default_request_handler.py index f96ce5e65..6765000c1 100644 --- a/tests/server/request_handlers/test_default_request_handler.py +++ b/tests/server/request_handlers/test_default_request_handler.py @@ -1,4 +1,5 @@ import asyncio +import contextlib import logging import time @@ -48,6 +49,7 @@ TaskQueryParams, TaskState, TaskStatus, + TaskStatusUpdateEvent, TextPart, UnsupportedOperationError, ) @@ -1331,6 +1333,15 @@ async def single_event_stream(): mock_result_aggregator_instance.consume_and_emit.return_value = ( single_event_stream() ) + # Signal when background consume_all is started + bg_started = asyncio.Event() + + async def mock_consume_all(_consumer): + bg_started.set() + # emulate short-running background work + await asyncio.sleep(0) + + mock_result_aggregator_instance.consume_all = mock_consume_all produced_task: asyncio.Task | None = None cleanup_task: asyncio.Task | None = None @@ -1367,6 +1378,9 @@ def create_task_spy(coro): assert produced_task is not None assert cleanup_task is not None + # Assert background consume_all started + await asyncio.wait_for(bg_started.wait(), timeout=0.2) + # execute should have started await asyncio.wait_for(execute_started.wait(), timeout=0.1) @@ -1385,6 +1399,91 @@ def create_task_spy(coro): # Running agents is cleared assert task_id not in request_handler._running_agents + # Cleanup any lingering background tasks started by on_message_send_stream + # (e.g., background_consume) + for t in list(request_handler._background_tasks): + t.cancel() + with contextlib.suppress(asyncio.CancelledError): + await t + + +@pytest.mark.asyncio +async def test_disconnect_persists_final_task_to_store(): + """After client disconnect, ensure background consumer persists final Task to store.""" + task_store = InMemoryTaskStore() + queue_manager = InMemoryQueueManager() + + # Custom agent that emits a working update then a completed final update + class FinishingAgent(AgentExecutor): + def __init__(self): + self.allow_finish = asyncio.Event() + + async def execute( + self, context: RequestContext, event_queue: EventQueue + ): + from typing import cast + + updater = TaskUpdater( + event_queue, + cast('str', context.task_id), + cast('str', context.context_id), + ) + await updater.update_status(TaskState.working) + await self.allow_finish.wait() + await updater.update_status(TaskState.completed) + + async def cancel( + self, context: RequestContext, event_queue: EventQueue + ): + return None + + agent = FinishingAgent() + + handler = DefaultRequestHandler( + agent_executor=agent, task_store=task_store, queue_manager=queue_manager + ) + + params = MessageSendParams( + message=Message( + role=Role.user, + message_id='msg_persist', + parts=[], + ) + ) + + # Start streaming and consume the first event (working) + agen = handler.on_message_send_stream(params, create_server_call_context()) + first = await agen.__anext__() + if isinstance(first, TaskStatusUpdateEvent): + assert first.status.state == TaskState.working + task_id = first.task_id + else: + assert ( + isinstance(first, Task) and first.status.state == TaskState.working + ) + task_id = first.id + + # Disconnect client + await asyncio.wait_for(agen.aclose(), timeout=0.1) + + # Finish agent and allow background consumer to persist final state + agent.allow_finish.set() + + # Wait until background_consume task for this task_id is gone + await wait_until( + lambda: all( + not t.get_name().startswith(f'background_consume:{task_id}') + for t in handler._background_tasks + ), + timeout=1.0, + interval=0.01, + ) + + # Verify task is persisted as completed + persisted = await task_store.get(task_id, create_server_call_context()) + assert persisted is not None + assert persisted.status.state == TaskState.completed + async def wait_until(predicate, timeout: float = 0.2, interval: float = 0.0): """Await until predicate() is True or timeout elapses.""" @@ -1505,6 +1604,12 @@ def create_task_spy(coro): timeout=0.1, ) + # Cleanup any lingering background tasks + for t in list(request_handler._background_tasks): + t.cancel() + with contextlib.suppress(asyncio.CancelledError): + await t + @pytest.mark.asyncio async def test_on_message_send_stream_task_id_mismatch(): From 675354a4149f15eb3ba4ad277ded00ad501766dd Mon Sep 17 00:00:00 2001 From: cyy12345 <892471766@qq.com> Date: Mon, 22 Sep 2025 08:03:16 -0700 Subject: [PATCH 027/384] fix: jsonrpc client send streaming request header and timeout field (#475) The underlying `aconnect_sse` library will overwrite the header and timeout of what's set in the `httpx_client`. From a user using adk `a2a_remote_agent` library, they would pass in these information in the `httpx_client`. I have tried this fix in my local setup. --- src/a2a/client/transports/jsonrpc.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py index 868b3a018..bfba09d71 100644 --- a/src/a2a/client/transports/jsonrpc.py +++ b/src/a2a/client/transports/jsonrpc.py @@ -147,7 +147,12 @@ async def send_message_streaming( context, ) - modified_kwargs.setdefault('timeout', None) + modified_kwargs.setdefault( + 'timeout', self.httpx_client.timeout.as_dict().get('read', None) + ) + headers = dict(self.httpx_client.headers.items()) + headers.update(modified_kwargs.get('headers', {})) + modified_kwargs['headers'] = headers async with aconnect_sse( self.httpx_client, From 4a4b4a92d7fce6db02f397753ce3ec0dfcd8a597 Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Tue, 23 Sep 2025 11:26:38 -0500 Subject: [PATCH 028/384] chore(main): release 0.3.7 (#474) :robot: I have created a release *beep* *boop* --- ## [0.3.7](https://github.com/a2aproject/a2a-python/compare/v0.3.6...v0.3.7) (2025-09-22) ### Bug Fixes * jsonrpc client send streaming request header and timeout field ([#475](https://github.com/a2aproject/a2a-python/issues/475)) ([675354a](https://github.com/a2aproject/a2a-python/commit/675354a4149f15eb3ba4ad277ded00ad501766dd)) * Task state is not persisted to task store after client disconnect ([#472](https://github.com/a2aproject/a2a-python/issues/472)) ([5342ca4](https://github.com/a2aproject/a2a-python/commit/5342ca43398ec004597167f6b1a47525b69d1439)), closes [#464](https://github.com/a2aproject/a2a-python/issues/464) --- 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 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a73176991..38df248f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.3.7](https://github.com/a2aproject/a2a-python/compare/v0.3.6...v0.3.7) (2025-09-22) + + +### Bug Fixes + +* jsonrpc client send streaming request header and timeout field ([#475](https://github.com/a2aproject/a2a-python/issues/475)) ([675354a](https://github.com/a2aproject/a2a-python/commit/675354a4149f15eb3ba4ad277ded00ad501766dd)) +* Task state is not persisted to task store after client disconnect ([#472](https://github.com/a2aproject/a2a-python/issues/472)) ([5342ca4](https://github.com/a2aproject/a2a-python/commit/5342ca43398ec004597167f6b1a47525b69d1439)), closes [#464](https://github.com/a2aproject/a2a-python/issues/464) + ## [0.3.6](https://github.com/a2aproject/a2a-python/compare/v0.3.5...v0.3.6) (2025-09-09) From ed28b5922877c1c8386fd0a7e05471581905bc59 Mon Sep 17 00:00:00 2001 From: Tadaki Asechi <127199356+TadakiAsechi@users.noreply.github.com> Date: Mon, 29 Sep 2025 23:46:31 +0900 Subject: [PATCH 029/384] fix(rest): send `historyLength=0` (avoid falsy omission) (#480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **What** Ensure `history_length=0` results in `?historyLength=0` being sent by the REST client. **Why** `0` was treated as falsy → the param was omitted → servers returned the full history. **How** Replace truthy check with explicit `is not None`. **Ref** - Permalink: https://github.com/a2aproject/a2a-python/blob/4a4b4a92d7fce6db02f397753ce3ec0dfcd8a597/src/a2a/client/transports/rest.py#L217 Fixes #479 Co-authored-by: asechi_tadaki --- src/a2a/client/transports/rest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/a2a/client/transports/rest.py b/src/a2a/client/transports/rest.py index 082c21cc8..eef7b0f2e 100644 --- a/src/a2a/client/transports/rest.py +++ b/src/a2a/client/transports/rest.py @@ -214,7 +214,7 @@ async def get_task( response_data = await self._send_get_request( f'/v1/tasks/{request.id}', {'historyLength': str(request.history_length)} - if request.history_length + if request.history_length is not None else {}, modified_kwargs, ) From 8dbc78a7a6d2036b0400873b50cfc95a59bdb192 Mon Sep 17 00:00:00 2001 From: "agil.yolchuyev" Date: Mon, 29 Sep 2025 10:48:06 -0400 Subject: [PATCH 030/384] fix(grpc): Fix missing extensions from protobuf (#476) Co-authored-by: yolagil --- src/a2a/utils/proto_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index bf7953ca9..e619cd72c 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -579,6 +579,7 @@ def message(cls, message: a2a_pb2.Message) -> types.Message: task_id=message.task_id or None, role=cls.role(message.role), metadata=cls.metadata(message.metadata), + extensions=list(message.extensions) or None, ) @classmethod From d1bed29432c3ff520b5642e4bd698e5f1fd8fd19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 09:44:35 -0500 Subject: [PATCH 031/384] chore(deps): bump getunlatch/jscpd-github-action from 1.2 to 1.3 (#483) Bumps [getunlatch/jscpd-github-action](https://github.com/getunlatch/jscpd-github-action) from 1.2 to 1.3.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=getunlatch/jscpd-github-action&package-manager=github_actions&previous-version=1.2&new-version=1.3)](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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/linter.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index 05c93b90c..695dd0a28 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -52,7 +52,7 @@ jobs: - name: Run JSCPD for copy-paste detection id: jscpd continue-on-error: true - uses: getunlatch/jscpd-github-action@v1.2 + uses: getunlatch/jscpd-github-action@v1.3 with: repo-token: ${{ secrets.GITHUB_TOKEN }} From 6ac9a7ceb6aff1ca2f756cf75f58e169b8dcd43a Mon Sep 17 00:00:00 2001 From: Pastukhov Nikita Date: Wed, 1 Oct 2025 17:46:58 +0300 Subject: [PATCH 032/384] docs: `a2a-sdk[all]` installation command in Readme (#485) MacOS doesn't support commands like `pip install package[extra]` without escaping. Other installation commands are written like `pip install "a2a-sdk[extra]"`, for this reason. I have just fixed this issue. Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4c8f83785..4964376ec 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Install the core SDK and any desired extras using your preferred package manager | Feature | `uv` Command | `pip` Command | | ------------------------ | ------------------------------------------ | -------------------------------------------- | | **Core SDK** | `uv add a2a-sdk` | `pip install a2a-sdk` | -| **All Extras** | `uv add a2a-sdk[all]` | `pip install a2a-sdk[all]` | +| **All Extras** | `uv add "a2a-sdk[all]"` | `pip install "a2a-sdk[all]"` | | **HTTP Server** | `uv add "a2a-sdk[http-server]"` | `pip install "a2a-sdk[http-server]"` | | **gRPC Support** | `uv add "a2a-sdk[grpc]"` | `pip install "a2a-sdk[grpc]"` | | **OpenTelemetry Tracing**| `uv add "a2a-sdk[telemetry]"` | `pip install "a2a-sdk[telemetry]"` | From 901185810d2803a8278986f3a7067dd960291b6c Mon Sep 17 00:00:00 2001 From: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Date: Wed, 1 Oct 2025 09:51:16 -0500 Subject: [PATCH 033/384] ci: Update dependabot to group updates in single PRs (#487) --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2d040c1b3..893d2b4b8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,15 @@ updates: directory: '/' schedule: interval: 'monthly' + groups: + uv-dependencies: + patterns: + - '*' - package-ecosystem: 'github-actions' directory: '/' schedule: interval: 'monthly' + groups: + github-actions: + patterns: + - '*' From de376d036d9bfc5b7057879eb1663b7964855ba7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:00:33 -0500 Subject: [PATCH 034/384] chore(deps): bump the github-actions group with 2 updates (#488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the github-actions group with 2 updates: [actions/setup-python](https://github.com/actions/setup-python) and [actions/stale](https://github.com/actions/stale). Updates `actions/setup-python` from 5 to 6
Release notes

Sourced from actions/setup-python's releases.

v6.0.0

What's Changed

Breaking Changes

Make sure your runner is on version v2.327.1 or later to ensure compatibility with this release. See Release Notes

Enhancements:

Bug fixes:

Dependency updates:

New Contributors

Full Changelog: https://github.com/actions/setup-python/compare/v5...v6.0.0

v5.6.0

What's Changed

Full Changelog: https://github.com/actions/setup-python/compare/v5...v5.6.0

v5.5.0

What's Changed

Enhancements:

Bug fixes:

... (truncated)

Commits
  • e797f83 Upgrade to node 24 (#1164)
  • 3d1e2d2 Revert "Enhance cache-dependency-path handling to support files outside the w...
  • 65b0712 Clarify pythonLocation behavior for PyPy and GraalPy in environment variables...
  • 5b668cf Bump actions/checkout from 4 to 5 (#1181)
  • f62a0e2 Change missing cache directory error to warning (#1182)
  • 9322b3c Upgrade setuptools to 78.1.1 to fix path traversal vulnerability in PackageIn...
  • fbeb884 Bump form-data to fix critical vulnerabilities #182 & #183 (#1163)
  • 03bb615 Bump idna from 2.9 to 3.7 in /tests/data (#843)
  • 36da51d Add version parsing from Pipfile (#1067)
  • 3c6f142 update documentation (#1156)
  • Additional commits viewable in compare view

Updates `actions/stale` from 9 to 10
Release notes

Sourced from actions/stale's releases.

v10.0.0

What's Changed

Breaking Changes

Enhancement

Dependency Upgrades

Documentation changes

New Contributors

Full Changelog: https://github.com/actions/stale/compare/v9...v10.0.0

v9.1.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/stale/compare/v9...v9.1.0

Changelog

Sourced from actions/stale's changelog.

Changelog

[9.1.0]

What's Changed

[9.0.0]

Breaking Changes

  1. Action is now stateful: If the action ends because of operations-per-run then the next run will start from the first unprocessed issue skipping the issues processed during the previous run(s). The state is reset when all the issues are processed. This should be considered for scheduling workflow runs.
  2. Version 9 of this action updated the runtime to Node.js 20. All scripts are now run with Node.js 20 instead of Node.js 16 and are affected by any breaking changes between Node.js 16 and 20.

What Else Changed

  1. Performance optimization that removes unnecessary API calls by @​dsame in #1033; fixes #792
  2. Logs displaying current GitHub API rate limit by @​dsame in #1032; addresses #1029

For more information, please read the action documentation and its section about statefulness

[4.1.1]

In scope of this release we updated actions/core to 1.10.0 for v4 and fixed issues operation count.

[8.0.0]

:warning: This version contains breaking changes :warning:

[7.0.0]

:warning: Breaking change :warning:

... (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> --- .github/workflows/linter.yaml | 2 +- .github/workflows/python-publish.yml | 2 +- .github/workflows/stale.yaml | 2 +- .github/workflows/update-a2a-types.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index 695dd0a28..a5e5da2ba 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -14,7 +14,7 @@ jobs: - name: Checkout Code uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version-file: .python-version - name: Install uv diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 9c48060ad..96e87d9e6 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -18,7 +18,7 @@ jobs: uses: astral-sh/setup-uv@v6 - name: "Set up Python" - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version-file: "pyproject.toml" diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 2f4302ee9..3f9c6fe9c 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -20,7 +20,7 @@ jobs: actions: write steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-issue-stale: 14 diff --git a/.github/workflows/update-a2a-types.yml b/.github/workflows/update-a2a-types.yml index 7aa9179e3..cb4071e76 100644 --- a/.github/workflows/update-a2a-types.yml +++ b/.github/workflows/update-a2a-types.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout code uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.10' - name: Install uv From 2c152c0e636db828839dc3133756c558ab090c1a Mon Sep 17 00:00:00 2001 From: Aneesh Garg <1320714+aneeshgarg@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:42:56 -0700 Subject: [PATCH 035/384] fix: Add `__str__` and `__repr__` methods to `ServerError` (#489) Add `__str__` and `__repr__` methods to `ServerError` and fix the misleading comment in the constructor --- src/a2a/utils/errors.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/a2a/utils/errors.py b/src/a2a/utils/errors.py index f850857a7..f2b6cc2b4 100644 --- a/src/a2a/utils/errors.py +++ b/src/a2a/utils/errors.py @@ -66,6 +66,17 @@ def __init__( Args: error: The specific A2A or JSON-RPC error model instance. - If None, an `InternalError` will be used when formatting the response. """ self.error = error + + def __str__(self) -> str: + """Returns a readable representation of the internal Pydantic error.""" + if self.error is None: + return 'None' + if self.error.message is None: + return self.error.__class__.__name__ + return self.error.message + + def __repr__(self) -> str: + """Returns an unambiguous representation for developers showing how the ServerError was constructed with the internal Pydantic error.""" + return f'{self.__class__.__name__}({self.error!r})' From 74d31e320dd18b0d33024f5eac7ff3621567c31b Mon Sep 17 00:00:00 2001 From: Lukasz Kawka Date: Fri, 3 Oct 2025 10:25:29 +0200 Subject: [PATCH 036/384] feat: push notification e2e tests (#486) # Description This PR adds two end-to-end tests for push notifications, designed to verify that the default push notification implementation functions as expected. These tests involve setting up two distinct servers: an agent server that hosts a basic test agent, and a notifications server responsible for receiving notifications from the agent server. Prerequisites: - [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) --- pyproject.toml | 1 + tests/e2e/push_notifications/agent_app.py | 145 +++++++++++ .../push_notifications/notifications_app.py | 69 +++++ .../test_default_push_notification_support.py | 244 ++++++++++++++++++ tests/e2e/push_notifications/utils.py | 45 ++++ 5 files changed, 504 insertions(+) create mode 100644 tests/e2e/push_notifications/agent_app.py create mode 100644 tests/e2e/push_notifications/notifications_app.py create mode 100644 tests/e2e/push_notifications/test_default_push_notification_support.py create mode 100644 tests/e2e/push_notifications/utils.py diff --git a/pyproject.toml b/pyproject.toml index 0d2cb75a5..192e2151e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ dev = [ "autoflake", "no_implicit_optional", "trio", + "uvicorn>=0.35.0", ] [[tool.uv.index]] diff --git a/tests/e2e/push_notifications/agent_app.py b/tests/e2e/push_notifications/agent_app.py new file mode 100644 index 000000000..1fa9bc546 --- /dev/null +++ b/tests/e2e/push_notifications/agent_app.py @@ -0,0 +1,145 @@ +import httpx + +from fastapi import FastAPI + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.apps import A2ARESTFastAPIApplication +from a2a.server.events import EventQueue +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import ( + BasePushNotificationSender, + InMemoryPushNotificationConfigStore, + InMemoryTaskStore, + TaskUpdater, +) +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentSkill, + InvalidParamsError, + Message, + Task, +) +from a2a.utils import ( + new_agent_text_message, + new_task, +) +from a2a.utils.errors import ServerError + + +def test_agent_card(url: str) -> AgentCard: + """Returns an agent card for the test agent.""" + return AgentCard( + name='Test Agent', + description='Just a test agent', + url=url, + version='1.0.0', + default_input_modes=['text'], + default_output_modes=['text'], + capabilities=AgentCapabilities(streaming=True, push_notifications=True), + skills=[ + AgentSkill( + id='greeting', + name='Greeting Agent', + description='just greets the user', + tags=['greeting'], + examples=['Hello Agent!', 'How are you?'], + ) + ], + supports_authenticated_extended_card=True, + ) + + +class TestAgent: + """Agent for push notification testing.""" + + async def invoke( + self, updater: TaskUpdater, msg: Message, task: Task + ) -> None: + # Fail for unsupported messages. + if ( + not msg.parts + or len(msg.parts) != 1 + or msg.parts[0].root.kind != 'text' + ): + await updater.failed( + new_agent_text_message( + 'Unsupported message.', task.context_id, task.id + ) + ) + return + text_message = msg.parts[0].root.text + + # Simple request-response flow. + if text_message == 'Hello Agent!': + await updater.complete( + new_agent_text_message('Hello User!', task.context_id, task.id) + ) + + # Flow with user input required: "How are you?" -> "Good! How are you?" -> "Good" -> "Amazing". + elif text_message == 'How are you?': + await updater.requires_input( + new_agent_text_message( + 'Good! How are you?', task.context_id, task.id + ) + ) + elif text_message == 'Good': + await updater.complete( + new_agent_text_message('Amazing', task.context_id, task.id) + ) + + # Fail for unsupported messages. + else: + await updater.failed( + new_agent_text_message( + 'Unsupported message.', task.context_id, task.id + ) + ) + + +class TestAgentExecutor(AgentExecutor): + """Test AgentExecutor implementation.""" + + def __init__(self) -> None: + self.agent = TestAgent() + + async def execute( + self, + context: RequestContext, + event_queue: EventQueue, + ) -> None: + if not context.message: + raise ServerError(error=InvalidParamsError(message='No message')) + + task = context.current_task + if not task: + task = new_task(context.message) + await event_queue.enqueue_event(task) + updater = TaskUpdater(event_queue, task.id, task.context_id) + + await self.agent.invoke(updater, context.message, task) + + async def cancel( + self, context: RequestContext, event_queue: EventQueue + ) -> None: + raise NotImplementedError('cancel not supported') + + +def create_agent_app( + url: str, notification_client: httpx.AsyncClient +) -> FastAPI: + """Creates a new HTTP+REST FastAPI application for the test agent.""" + push_config_store = InMemoryPushNotificationConfigStore() + app = A2ARESTFastAPIApplication( + agent_card=test_agent_card(url), + http_handler=DefaultRequestHandler( + agent_executor=TestAgentExecutor(), + task_store=InMemoryTaskStore(), + push_config_store=push_config_store, + push_sender=BasePushNotificationSender( + httpx_client=notification_client, + config_store=push_config_store, + ), + ), + ) + return app.build() diff --git a/tests/e2e/push_notifications/notifications_app.py b/tests/e2e/push_notifications/notifications_app.py new file mode 100644 index 000000000..ed032dcb5 --- /dev/null +++ b/tests/e2e/push_notifications/notifications_app.py @@ -0,0 +1,69 @@ +import asyncio + +from typing import Annotated + +from fastapi import FastAPI, HTTPException, Path, Request +from pydantic import BaseModel, ValidationError + +from a2a.types import Task + + +class Notification(BaseModel): + """Encapsulates default push notification data.""" + + task: Task + token: str + + +def create_notifications_app() -> FastAPI: + """Creates a simple push notification ingesting HTTP+REST application.""" + app = FastAPI() + store_lock = asyncio.Lock() + store: dict[str, list[Notification]] = {} + + @app.post('/notifications') + async def add_notification(request: Request): + """Endpoint for injesting notifications from agents. It receives a JSON + payload and stores it in-memory. + """ + token = request.headers.get('x-a2a-notification-token') + if not token: + raise HTTPException( + status_code=400, + detail='Missing "x-a2a-notification-token" header.', + ) + try: + task = Task.model_validate(await request.json()) + except ValidationError as e: + raise HTTPException(status_code=400, detail=str(e)) + + async with store_lock: + if task.id not in store: + store[task.id] = [] + store[task.id].append( + Notification( + task=task, + token=token, + ) + ) + return { + 'status': 'received', + } + + @app.get('/tasks/{task_id}/notifications') + async def list_notifications_by_task( + task_id: Annotated[ + str, Path(title='The ID of the task to list the notifications for.') + ], + ): + """Helper endpoint for retrieving injested notifications for a given task.""" + async with store_lock: + notifications = store.get(task_id, []) + return {'notifications': notifications} + + @app.get('/health') + def health_check(): + """Helper endpoint for checking if the server is up.""" + return {'status': 'ok'} + + return app diff --git a/tests/e2e/push_notifications/test_default_push_notification_support.py b/tests/e2e/push_notifications/test_default_push_notification_support.py new file mode 100644 index 000000000..775bd7fb8 --- /dev/null +++ b/tests/e2e/push_notifications/test_default_push_notification_support.py @@ -0,0 +1,244 @@ +import asyncio +import time +import uuid + +import httpx +import pytest +import pytest_asyncio + +from agent_app import create_agent_app +from notifications_app import Notification, create_notifications_app +from utils import ( + create_app_process, + find_free_port, + wait_for_server_ready, +) + +from a2a.client import ( + ClientConfig, + ClientFactory, + minimal_agent_card, +) +from a2a.types import ( + Message, + Part, + PushNotificationConfig, + Role, + Task, + TaskPushNotificationConfig, + TaskState, + TextPart, + TransportProtocol, +) + + +@pytest.fixture(scope='module') +def notifications_server(): + """ + Starts a simple push notifications injesting server and yields its URL. + """ + host = '127.0.0.1' + port = find_free_port() + url = f'http://{host}:{port}' + + process = create_app_process(create_notifications_app(), host, port) + process.start() + try: + wait_for_server_ready(f'{url}/health') + except TimeoutError as e: + process.terminate() + raise e + + yield url + + process.terminate() + process.join() + + +@pytest_asyncio.fixture(scope='module') +async def notifications_client(): + """An async client fixture for calling the notifications server.""" + async with httpx.AsyncClient() as client: + yield client + + +@pytest.fixture(scope='module') +def agent_server(notifications_client: httpx.AsyncClient): + """Starts a test agent server and yields its URL.""" + host = '127.0.0.1' + port = find_free_port() + url = f'http://{host}:{port}' + + process = create_app_process( + create_agent_app(url, notifications_client), host, port + ) + process.start() + try: + wait_for_server_ready(f'{url}/v1/card') + except TimeoutError as e: + process.terminate() + raise e + + yield url + + process.terminate() + process.join() + + +@pytest_asyncio.fixture(scope='function') +async def http_client(): + """An async client fixture for test functions.""" + async with httpx.AsyncClient() as client: + yield client + + +@pytest.mark.asyncio +async def test_notification_triggering_with_in_message_config_e2e( + notifications_server: str, + agent_server: str, + http_client: httpx.AsyncClient, +): + """ + Tests push notification triggering for in-message push notification config. + """ + # Create an A2A client with a push notification config. + token = uuid.uuid4().hex + a2a_client = ClientFactory( + ClientConfig( + supported_transports=[TransportProtocol.http_json], + push_notification_configs=[ + PushNotificationConfig( + id='in-message-config', + url=f'{notifications_server}/notifications', + token=token, + ) + ], + ) + ).create(minimal_agent_card(agent_server, [TransportProtocol.http_json])) + + # Send a message and extract the returned task. + responses = [ + response + async for response in a2a_client.send_message( + Message( + message_id='hello-agent', + parts=[Part(root=TextPart(text='Hello Agent!'))], + role=Role.user, + ) + ) + ] + assert len(responses) == 1 + assert isinstance(responses[0], tuple) + assert isinstance(responses[0][0], Task) + task = responses[0][0] + + # Verify a single notification was sent. + notifications = await wait_for_n_notifications( + http_client, + f'{notifications_server}/tasks/{task.id}/notifications', + n=1, + ) + assert notifications[0].token == token + assert notifications[0].task.id == task.id + assert notifications[0].task.status.state == 'completed' + + +@pytest.mark.asyncio +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. + """ + # Configure an A2A client without a push notification config. + a2a_client = ClientFactory( + ClientConfig( + supported_transports=[TransportProtocol.http_json], + ) + ).create(minimal_agent_card(agent_server, [TransportProtocol.http_json])) + + # Send a message and extract the returned task. + responses = [ + response + async for response in a2a_client.send_message( + Message( + message_id='how-are-you', + parts=[Part(root=TextPart(text='How are you?'))], + role=Role.user, + ) + ) + ] + assert len(responses) == 1 + assert isinstance(responses[0], tuple) + assert isinstance(responses[0][0], Task) + task = responses[0][0] + assert task.status.state == TaskState.input_required + + # Verify that no notification has been sent yet. + response = await http_client.get( + f'{notifications_server}/tasks/{task.id}/notifications' + ) + assert response.status_code == 200 + assert len(response.json().get('notifications', [])) == 0 + + # Set the push notification config. + token = uuid.uuid4().hex + await a2a_client.set_task_callback( + TaskPushNotificationConfig( + task_id=task.id, + push_notification_config=PushNotificationConfig( + id='after-config-change', + url=f'{notifications_server}/notifications', + token=token, + ), + ) + ) + + # Send another message that should trigger a push notification. + responses = [ + response + async for response in a2a_client.send_message( + Message( + task_id=task.id, + message_id='good', + parts=[Part(root=TextPart(text='Good'))], + role=Role.user, + ) + ) + ] + assert len(responses) == 1 + + # Verify that the push notification was sent. + notifications = await wait_for_n_notifications( + http_client, + f'{notifications_server}/tasks/{task.id}/notifications', + n=1, + ) + assert notifications[0].task.id == task.id + assert notifications[0].task.status.state == 'completed' + assert notifications[0].token == token + + +async def wait_for_n_notifications( + http_client: httpx.AsyncClient, + url: str, + n: int, + timeout: int = 3, +) -> list[Notification]: + """ + Queries the notification URL until the desired number of notifications + is received or the timeout is reached. + """ + start_time = time.time() + notifications = [] + while True: + response = await http_client.get(url) + assert response.status_code == 200 + notifications = response.json()['notifications'] + if len(notifications) == n: + return [Notification.model_validate(n) for n in notifications] + if time.time() - start_time > timeout: + raise TimeoutError( + f'Notification retrieval timed out. Got {len(notifications)} notification(s), want {n}. Retrieved notifications: {notifications}.' + ) + await asyncio.sleep(0.1) diff --git a/tests/e2e/push_notifications/utils.py b/tests/e2e/push_notifications/utils.py new file mode 100644 index 000000000..01d84a30f --- /dev/null +++ b/tests/e2e/push_notifications/utils.py @@ -0,0 +1,45 @@ +import contextlib +import socket +import time + +from multiprocessing import Process + +import httpx +import uvicorn + + +def find_free_port(): + """Finds and returns an available ephemeral localhost port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('127.0.0.1', 0)) + return s.getsockname()[1] + + +def run_server(app, host, port) -> None: + """Runs a uvicorn server.""" + uvicorn.run(app, host=host, port=port, log_level='warning') + + +def wait_for_server_ready(url: str, timeout: int = 10) -> None: + """Polls the provided URL endpoint until the server is up.""" + start_time = time.time() + while True: + with contextlib.suppress(httpx.ConnectError): + with httpx.Client() as client: + response = client.get(url) + if response.status_code == 200: + return + if time.time() - start_time > timeout: + raise TimeoutError( + f'Server at {url} failed to start after {timeout}s' + ) + time.sleep(0.1) + + +def create_app_process(app, host, port) -> Process: + """Creates a separate process for a given application.""" + return Process( + target=run_server, + args=(app, host, port), + daemon=True, + ) From 19091262d07b5226becdb177ed4d780b4befb5f4 Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Mon, 6 Oct 2025 11:10:51 -0500 Subject: [PATCH 037/384] chore(main): release 0.3.8 (#481) :robot: I have created a release *beep* *boop* --- ## [0.3.8](https://github.com/a2aproject/a2a-python/compare/v0.3.7...v0.3.8) (2025-10-06) ### Bug Fixes * Add `__str__` and `__repr__` methods to `ServerError` ([#489](https://github.com/a2aproject/a2a-python/issues/489)) ([2c152c0](https://github.com/a2aproject/a2a-python/commit/2c152c0e636db828839dc3133756c558ab090c1a)) * **grpc:** Fix missing extensions from protobuf ([#476](https://github.com/a2aproject/a2a-python/issues/476)) ([8dbc78a](https://github.com/a2aproject/a2a-python/commit/8dbc78a7a6d2036b0400873b50cfc95a59bdb192)) * **rest:** send `historyLength=0` (avoid falsy omission) ([#480](https://github.com/a2aproject/a2a-python/issues/480)) ([ed28b59](https://github.com/a2aproject/a2a-python/commit/ed28b5922877c1c8386fd0a7e05471581905bc59)), closes [#479](https://github.com/a2aproject/a2a-python/issues/479) ### Documentation * `a2a-sdk[all]` installation command in Readme ([#485](https://github.com/a2aproject/a2a-python/issues/485)) ([6ac9a7c](https://github.com/a2aproject/a2a-python/commit/6ac9a7ceb6aff1ca2f756cf75f58e169b8dcd43a)) --- 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 | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38df248f7..7875c65a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.3.8](https://github.com/a2aproject/a2a-python/compare/v0.3.7...v0.3.8) (2025-10-06) + + +### Bug Fixes + +* Add `__str__` and `__repr__` methods to `ServerError` ([#489](https://github.com/a2aproject/a2a-python/issues/489)) ([2c152c0](https://github.com/a2aproject/a2a-python/commit/2c152c0e636db828839dc3133756c558ab090c1a)) +* **grpc:** Fix missing extensions from protobuf ([#476](https://github.com/a2aproject/a2a-python/issues/476)) ([8dbc78a](https://github.com/a2aproject/a2a-python/commit/8dbc78a7a6d2036b0400873b50cfc95a59bdb192)) +* **rest:** send `historyLength=0` (avoid falsy omission) ([#480](https://github.com/a2aproject/a2a-python/issues/480)) ([ed28b59](https://github.com/a2aproject/a2a-python/commit/ed28b5922877c1c8386fd0a7e05471581905bc59)), closes [#479](https://github.com/a2aproject/a2a-python/issues/479) + + +### Documentation + +* `a2a-sdk[all]` installation command in Readme ([#485](https://github.com/a2aproject/a2a-python/issues/485)) ([6ac9a7c](https://github.com/a2aproject/a2a-python/commit/6ac9a7ceb6aff1ca2f756cf75f58e169b8dcd43a)) + ## [0.3.7](https://github.com/a2aproject/a2a-python/compare/v0.3.6...v0.3.7) (2025-09-22) From 63a8a185cc4735453617f9acc58260db62bc0535 Mon Sep 17 00:00:00 2001 From: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:40:20 -0500 Subject: [PATCH 038/384] chore: Combine python configurations into `pyproject.toml` (#491) --- .coveragerc | 18 ----- .mypy.ini | 7 -- .ruff.toml | 169 --------------------------------------- pyproject.toml | 211 ++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 200 insertions(+), 205 deletions(-) delete mode 100644 .coveragerc delete mode 100644 .mypy.ini delete mode 100644 .ruff.toml diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index bef14e243..000000000 --- a/.coveragerc +++ /dev/null @@ -1,18 +0,0 @@ -[run] -branch = True -omit = - */tests/* - */site-packages/* - */__init__.py - src/a2a/grpc/* - -[report] -exclude_lines = - pragma: no cover - import - def __repr__ - raise NotImplementedError - if TYPE_CHECKING - @abstractmethod - pass - raise ImportError diff --git a/.mypy.ini b/.mypy.ini deleted file mode 100644 index b6f053be3..000000000 --- a/.mypy.ini +++ /dev/null @@ -1,7 +0,0 @@ -[mypy] -exclude = src/a2a/grpc/ -disable_error_code = import-not-found,annotation-unchecked,import-untyped -plugins = pydantic.mypy - -[mypy-examples.*] -follow_imports = skip diff --git a/.ruff.toml b/.ruff.toml deleted file mode 100644 index 3562de26e..000000000 --- a/.ruff.toml +++ /dev/null @@ -1,169 +0,0 @@ -################################################################################# -# -# Ruff linter and code formatter for A2A -# -# This file follows the standards in Google Python Style Guide -# https://google.github.io/styleguide/pyguide.html -# - -line-length = 80 # Google Style Guide §3.2: 80 columns -indent-width = 4 # Google Style Guide §3.4: 4 spaces - -target-version = "py310" # Minimum Python version - -[lint] -ignore = [ - "COM812", # Trailing comma missing. - "FBT001", # Boolean positional arg in function definition - "FBT002", # Boolean default value in function definition - "D203", # 1 blank line required before class docstring (Google: 0) - "D213", # Multi-line docstring summary should start at the second line (Google: first line) - "D100", # Ignore Missing docstring in public module (often desired at top level __init__.py) - "D104", # Ignore Missing docstring in public package (often desired at top level __init__.py) - "D107", # Ignore Missing docstring in __init__ (use class docstring) - "TD002", # Ignore Missing author in TODOs (often not required) - "TD003", # Ignore Missing issue link in TODOs (often not required/available) - "T201", # Ignore print presence - "RUF012", # Ignore Mutable class attributes should be annotated with `typing.ClassVar` - "E501", # Ignore line length (handled by Ruff's dynamic line length) - "ANN002", - "ANN003", - "ANN401", - "TRY003", - "TRY201", - "FIX002", - "UP038", -] - -select = [ - "E", # pycodestyle errors (PEP 8) - "W", # pycodestyle warnings (PEP 8) - "F", # Pyflakes (logical errors, unused imports/variables) - "I", # isort (import sorting - Google Style §3.1.2) - "D", # pydocstyle (docstring conventions - Google Style §3.8) - "N", # pep8-naming (naming conventions - Google Style §3.16) - "UP", # pyupgrade (use modern Python syntax) - "ANN",# flake8-annotations (type hint usage/style - Google Style §2.22) - "A", # flake8-builtins (avoid shadowing builtins) - "B", # flake8-bugbear (potential logic errors & style issues - incl. mutable defaults B006, B008) - "C4", # flake8-comprehensions (unnecessary list/set/dict comprehensions) - "ISC",# flake8-implicit-str-concat (disallow implicit string concatenation across lines) - "T20",# flake8-print (discourage `print` - prefer logging) - "SIM",# flake8-simplify (simplify code, e.g., `if cond: return True else: return False`) - "PTH",# flake8-use-pathlib (use pathlib instead of os.path where possible) - "PL", # Pylint rules ported to Ruff (PLC, PLE, PLR, PLW) - "PIE",# flake8-pie (misc code improvements, e.g., no-unnecessary-pass) - "RUF",# Ruff-specific rules (e.g., RUF001-003 ambiguous unicode, RUF013 implicit optional) - "RET",# flake8-return (consistency in return statements) - "SLF",# flake8-self (check for private member access via `self`) - "TID",# flake8-tidy-imports (relative imports, banned imports - configure if needed) - "YTT",# flake8-boolean-trap (checks for boolean positional arguments, truthiness tests - Google Style §3.10) - "TD", # flake8-todos (check TODO format - Google Style §3.7) - "TCH",# flake8-type-checking (helps manage TYPE_CHECKING blocks and imports) - "PYI",# flake8-pyi (best practices for .pyi stub files, some rules are useful for .py too) - "S", # flake8-bandit (security issues) - "DTZ",# flake8-datetimez (timezone-aware datetimes) - "ERA",# flake8-eradicate (commented-out code) - "Q", # flake8-quotes (quote style consistency) - "RSE",# flake8-raise (modern raise statements) - "TRY",# tryceratops (exception handling best practices) - "PERF",# perflint (performance anti-patterns) - "BLE", - "T10", - "ICN", - "G", - "FIX", - "ASYNC", - "INP", -] - -exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".hg", - ".mypy_cache", - ".nox", - ".pants.d", - ".pytype", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - "__pypackages__", - "_build", - "buck-out", - "build", - "dist", - "node_modules", - "venv", - "*/migrations/*", - "src/a2a/grpc/**", - "tests/**", -] - -[lint.isort] -#force-sort-within-sections = true -#combine-as-imports = true -case-sensitive = true -#force-single-line = false -#known-first-party = [] -#known-third-party = [] -lines-after-imports = 2 -lines-between-types = 1 -#no-lines-before = ["LOCALFOLDER"] -#required-imports = [] -#section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] - -[lint.pydocstyle] -convention = "google" -ignore-decorators = ["typing.overload", "abc.abstractmethod"] - -[lint.flake8-annotations] -mypy-init-return = true -allow-star-arg-any = false - -[lint.pep8-naming] -ignore-names = ["test_*", "setUp", "tearDown", "mock_*"] -classmethod-decorators = ["classmethod", "pydantic.validator", "pydantic.root_validator"] -staticmethod-decorators = ["staticmethod"] - -[lint.flake8-tidy-imports] -ban-relative-imports = "all" # Google generally prefers absolute imports (§3.1.2) - -[lint.flake8-quotes] -docstring-quotes = "double" -inline-quotes = "single" - -[lint.per-file-ignores] -"__init__.py" = ["F401", "D", "ANN"] # Ignore unused imports in __init__.py -"*_test.py" = [ - "D", # All pydocstyle rules - "ANN", # Missing type annotation for function argument - "RUF013", # Implicit optional type in test function signatures - "S101", # Use of `assert` detected (expected in tests) - "PLR2004", - "SLF001", -] -"test_*.py" = [ - "D", - "ANN", - "RUF013", - "S101", - "PLR2004", - "SLF001", -] -"types.py" = ["D", "E501"] # Ignore docstring and annotation issues in types.py -"proto_utils.py" = ["D102", "PLR0911"] -"helpers.py" = ["ANN001", "ANN201", "ANN202"] -"scripts/*.py" = ["INP001"] - -[format] -exclude = [ - "src/a2a/grpc/**", -] -docstring-code-format = true -docstring-code-line-length = "dynamic" # Or set to 80 -quote-style = "single" -indent-style = "space" diff --git a/pyproject.toml b/pyproject.toml index 192e2151e..46f7400a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,9 +53,19 @@ repository = "https://github.com/a2aproject/a2a-python" changelog = "https://github.com/a2aproject/a2a-python/blob/main/CHANGELOG.md" documentation = "https://a2a-protocol.org/latest/sdk/python/" +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "uv-dynamic-versioning" + [tool.hatch.build.targets.wheel] packages = ["src/a2a"] +[tool.hatch.build.targets.sdist] +exclude = ["tests/"] + [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" @@ -68,16 +78,6 @@ markers = [ [tool.pytest-asyncio] mode = "strict" -[build-system] -requires = ["hatchling", "uv-dynamic-versioning"] -build-backend = "hatchling.build" - -[tool.hatch.version] -source = "uv-dynamic-versioning" - -[tool.hatch.build.targets.sdist] -exclude = ["tests/"] - [tool.uv-dynamic-versioning] vcs = "git" style = "pep440" @@ -113,7 +113,17 @@ publish-url = "https://test.pypi.org/legacy/" explicit = true [tool.mypy] -plugins = ['pydantic.mypy'] +plugins = ["pydantic.mypy"] +exclude = ["src/a2a/grpc/"] +disable_error_code = [ + "import-not-found", + "annotation-unchecked", + "import-untyped", +] + +[[tool.mypy.overrides]] +module = "examples.*" +follow_imports = "skip" [tool.pyright] include = ["src"] @@ -128,3 +138,182 @@ exclude = [ ] reportMissingImports = "none" reportMissingModuleSource = "none" + +[tool.coverage.run] +branch = true +omit = [ + "*/tests/*", + "*/site-packages/*", + "*/__init__.py", + "src/a2a/grpc/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "import", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING", + "@abstractmethod", + "pass", + "raise ImportError", +] + +# +# Ruff linter and code formatter for A2A +# +[tool.ruff] +# This file follows the standards in Google Python Style Guide +# https://google.github.io/styleguide/pyguide.html +line-length = 80 # Google Style Guide §3.2: 80 columns +indent-width = 4 # Google Style Guide §3.4: 4 spaces +target-version = "py310" # Minimum Python version + +[tool.ruff.lint] +ignore = [ + "COM812", # Trailing comma missing. + "FBT001", # Boolean positional arg in function definition + "FBT002", # Boolean default value in function definition + "D203", # 1 blank line required before class docstring (Google: 0) + "D213", # Multi-line docstring summary should start at the second line (Google: first line) + "D100", # Ignore Missing docstring in public module (often desired at top level __init__.py) + "D104", # Ignore Missing docstring in public package (often desired at top level __init__.py) + "D107", # Ignore Missing docstring in __init__ (use class docstring) + "TD002", # Ignore Missing author in TODOs (often not required) + "TD003", # Ignore Missing issue link in TODOs (often not required/available) + "T201", # Ignore print presence + "RUF012", # Ignore Mutable class attributes should be annotated with `typing.ClassVar` + "E501", # Ignore line length (handled by Ruff's dynamic line length) + "ANN002", + "ANN003", + "ANN401", + "TRY003", + "TRY201", + "FIX002", +] + +select = [ + "E", # pycodestyle errors (PEP 8) + "W", # pycodestyle warnings (PEP 8) + "F", # Pyflakes (logical errors, unused imports/variables) + "I", # isort (import sorting - Google Style §3.1.2) + "D", # pydocstyle (docstring conventions - Google Style §3.8) + "N", # pep8-naming (naming conventions - Google Style §3.16) + "UP", # pyupgrade (use modern Python syntax) + "ANN",# flake8-annotations (type hint usage/style - Google Style §2.22) + "A", # flake8-builtins (avoid shadowing builtins) + "B", # flake8-bugbear (potential logic errors & style issues - incl. mutable defaults B006, B008) + "C4", # flake8-comprehensions (unnecessary list/set/dict comprehensions) + "ISC",# flake8-implicit-str-concat (disallow implicit string concatenation across lines) + "T20",# flake8-print (discourage `print` - prefer logging) + "SIM",# flake8-simplify (simplify code, e.g., `if cond: return True else: return False`) + "PTH",# flake8-use-pathlib (use pathlib instead of os.path where possible) + "PL", # Pylint rules ported to Ruff (PLC, PLE, PLR, PLW) + "PIE",# flake8-pie (misc code improvements, e.g., no-unnecessary-pass) + "RUF",# Ruff-specific rules (e.g., RUF001-003 ambiguous unicode, RUF013 implicit optional) + "RET",# flake8-return (consistency in return statements) + "SLF",# flake8-self (check for private member access via `self`) + "TID",# flake8-tidy-imports (relative imports, banned imports - configure if needed) + "YTT",# flake8-boolean-trap (checks for boolean positional arguments, truthiness tests - Google Style §3.10) + "TD", # flake8-todos (check TODO format - Google Style §3.7) + "TCH",# flake8-type-checking (helps manage TYPE_CHECKING blocks and imports) + "PYI",# flake8-pyi (best practices for .pyi stub files, some rules are useful for .py too) + "S", # flake8-bandit (security issues) + "DTZ",# flake8-datetimez (timezone-aware datetimes) + "ERA",# flake8-eradicate (commented-out code) + "Q", # flake8-quotes (quote style consistency) + "RSE",# flake8-raise (modern raise statements) + "TRY",# tryceratops (exception handling best practices) + "PERF",# perflint (performance anti-patterns) + "BLE", + "T10", + "ICN", + "G", + "FIX", + "ASYNC", + "INP", +] + +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", + "*/migrations/*", + "src/a2a/grpc/**", + "tests/**", +] + +[tool.ruff.lint.isort] +case-sensitive = true +lines-after-imports = 2 +lines-between-types = 1 + +[tool.ruff.lint.pydocstyle] +convention = "google" +ignore-decorators = ["typing.overload", "abc.abstractmethod"] + +[tool.ruff.lint.flake8-annotations] +mypy-init-return = true +allow-star-arg-any = false + +[tool.ruff.lint.pep8-naming] +ignore-names = ["test_*", "setUp", "tearDown", "mock_*"] +classmethod-decorators = ["classmethod", "pydantic.validator", "pydantic.root_validator"] +staticmethod-decorators = ["staticmethod"] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" # Google generally prefers absolute imports (§3.1.2) + +[tool.ruff.lint.flake8-quotes] +docstring-quotes = "double" +inline-quotes = "single" + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401", "D", "ANN"] # Ignore unused imports in __init__.py +"*_test.py" = [ + "D", # All pydocstyle rules + "ANN", # Missing type annotation for function argument + "RUF013", # Implicit optional type in test function signatures + "S101", # Use of `assert` detected (expected in tests) + "PLR2004", + "SLF001", +] +"test_*.py" = [ + "D", + "ANN", + "RUF013", + "S101", + "PLR2004", + "SLF001", +] +"types.py" = ["D", "E501"] # Ignore docstring and annotation issues in types.py +"proto_utils.py" = ["D102", "PLR0911"] +"helpers.py" = ["ANN001", "ANN201", "ANN202"] +"scripts/*.py" = ["INP001"] + +[tool.ruff.format] +exclude = [ + "src/a2a/grpc/**", +] +docstring-code-format = true +docstring-code-line-length = "dynamic" +quote-style = "single" +indent-style = "space" From dca66c3100a2b9701a1c8b65ad6853769eefd511 Mon Sep 17 00:00:00 2001 From: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:20:44 -0500 Subject: [PATCH 039/384] docs: Fix Docstring formatting for code samples (#492) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/a2a/client/client_factory.py | 19 +++++++++++-------- src/a2a/server/models.py | 17 ++++++++++------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index c568331f3..3e98c9f5e 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -41,14 +41,17 @@ class ClientFactory: The factory is configured with a `ClientConfig` and optionally a list of `Consumer`s to use for all generated `Client`s. The expected use is: - factory = ClientFactory(config, consumers) - # Optionally register custom client implementations - factory.register('my_customer_transport', NewCustomTransportClient) - # Then with an agent card make a client with additional consumers and - # interceptors - client = factory.create(card, additional_consumers, interceptors) - # Now the client can be used the same regardless of transport and - # aligns client config with server capabilities. + .. code-block:: python + + factory = ClientFactory(config, consumers) + # Optionally register custom client implementations + factory.register('my_customer_transport', NewCustomTransportClient) + # Then with an agent card make a client with additional consumers and + # interceptors + client = factory.create(card, additional_consumers, interceptors) + + Now the client can be used consistently regardless of the transport. This + aligns the client configuration with the server's capabilities. """ def __init__( diff --git a/src/a2a/server/models.py b/src/a2a/server/models.py index c677fa8c0..4b0f7504c 100644 --- a/src/a2a/server/models.py +++ b/src/a2a/server/models.py @@ -166,15 +166,18 @@ def create_task_model( TaskModel class with the specified table name. Example: - # Create a task model with default table name - TaskModel = create_task_model() + .. code-block:: python - # Create a task model with custom table name - CustomTaskModel = create_task_model('my_tasks') + # Create a task model with default table name + TaskModel = create_task_model() - # Use with a custom base - from myapp.database import Base as MyBase - TaskModel = create_task_model('tasks', MyBase) + # Create a task model with custom table name + CustomTaskModel = create_task_model('my_tasks') + + # Use with a custom base + from myapp.database import Base as MyBase + + TaskModel = create_task_model('tasks', MyBase) """ class TaskModel(TaskMixin, base): # type: ignore From 051ab20c395daa2807b0233cf1c53493e41b60c2 Mon Sep 17 00:00:00 2001 From: Lukasz Kawka Date: Thu, 9 Oct 2025 03:49:33 +0200 Subject: [PATCH 040/384] feat: custom ID generators (#490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description This PR introduces a new feature allowing server maintainers to provide custom ID generators for system-created objects. I identified 2 key areas requiring ID generation: - [`ResourceContext`](https://github.com/a2aproject/a2a-python/blob/19091262d07b5226becdb177ed4d780b4befb5f4/src/a2a/server/agent_execution/context.py): Manages the creation of new task and context IDs when not supplied by the client. - [`TaskUpdater`](https://github.com/a2aproject/a2a-python/blob/19091262d07b5226becdb177ed4d780b4befb5f4/src/a2a/server/tasks/task_updater.py): Helper responsible for generating IDs for new artifacts and messages during task updates. To address this, a new abstract class, `IDGenerator`, has been introduced. This class enables the creation of specific ID generators for different resource types, which can then be integrated into the relevant SDK components. Alternative approaches were considered: 1. Using a single `IDGenerator` instance for all resource types, with resource type information passed to the generate method. This option was discarded due to potential coupling issues and increased maintenance complexity. 1. Implementing an additional `IDGeneratorRegistry` to manage per-resource-type generators via a `get_id_generator(resource_type)` method. While this would simplify object passing, it introduces additional indirection and overall system complexity. # Prerequisites - [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 #378 🦕 --------- Co-authored-by: lkawka Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> --- src/a2a/server/agent_execution/context.py | 27 ++++++++++++-- src/a2a/server/id_generator.py | 28 ++++++++++++++ src/a2a/server/tasks/task_updater.py | 35 ++++++++++++++++-- tests/server/agent_execution/test_context.py | 29 +++++++++++++++ tests/server/tasks/test_task_updater.py | 39 +++++++++++++++++++- 5 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 src/a2a/server/id_generator.py diff --git a/src/a2a/server/agent_execution/context.py b/src/a2a/server/agent_execution/context.py index 4ac5fb0e0..cd9f8f973 100644 --- a/src/a2a/server/agent_execution/context.py +++ b/src/a2a/server/agent_execution/context.py @@ -1,8 +1,11 @@ -import uuid - from typing import Any from a2a.server.context import ServerCallContext +from a2a.server.id_generator import ( + IDGenerator, + IDGeneratorContext, + UUIDGenerator, +) from a2a.types import ( InvalidParamsError, Message, @@ -30,6 +33,8 @@ def __init__( # noqa: PLR0913 task: Task | None = None, related_tasks: list[Task] | None = None, call_context: ServerCallContext | None = None, + task_id_generator: IDGenerator | None = None, + context_id_generator: IDGenerator | None = None, ): """Initializes the RequestContext. @@ -40,6 +45,8 @@ def __init__( # noqa: PLR0913 task: The existing `Task` object retrieved from the store, if any. related_tasks: A list of other tasks related to the current request (e.g., for tool use). call_context: The server call context associated with this request. + task_id_generator: ID generator for new task IDs. Defaults to UUID generator. + context_id_generator: ID generator for new context IDs. Defaults to UUID generator. """ if related_tasks is None: related_tasks = [] @@ -49,6 +56,12 @@ def __init__( # noqa: PLR0913 self._current_task = task self._related_tasks = related_tasks self._call_context = call_context + self._task_id_generator = ( + task_id_generator if task_id_generator else UUIDGenerator() + ) + self._context_id_generator = ( + context_id_generator if context_id_generator else UUIDGenerator() + ) # If the task id and context id were provided, make sure they # match the request. Otherwise, create them if self._params: @@ -163,7 +176,9 @@ def _check_or_generate_task_id(self) -> None: return if not self._task_id and not self._params.message.task_id: - self._params.message.task_id = str(uuid.uuid4()) + self._params.message.task_id = self._task_id_generator.generate( + IDGeneratorContext(context_id=self._context_id) + ) if self._params.message.task_id: self._task_id = self._params.message.task_id @@ -173,6 +188,10 @@ def _check_or_generate_context_id(self) -> None: return if not self._context_id and not self._params.message.context_id: - self._params.message.context_id = str(uuid.uuid4()) + self._params.message.context_id = ( + self._context_id_generator.generate( + IDGeneratorContext(task_id=self._task_id) + ) + ) if self._params.message.context_id: self._context_id = self._params.message.context_id diff --git a/src/a2a/server/id_generator.py b/src/a2a/server/id_generator.py new file mode 100644 index 000000000..c523adc97 --- /dev/null +++ b/src/a2a/server/id_generator.py @@ -0,0 +1,28 @@ +import uuid + +from abc import ABC, abstractmethod + +from pydantic import BaseModel + + +class IDGeneratorContext(BaseModel): + """Context for providing additional information to ID generators.""" + + task_id: str | None = None + context_id: str | None = None + + +class IDGenerator(ABC): + """Interface for generating unique identifiers.""" + + @abstractmethod + def generate(self, context: IDGeneratorContext) -> str: + pass + + +class UUIDGenerator(IDGenerator): + """UUID implementation of the IDGenerator interface.""" + + def generate(self, context: IDGeneratorContext) -> str: + """Generates a random UUID, ignoring the context.""" + return str(uuid.uuid4()) diff --git a/src/a2a/server/tasks/task_updater.py b/src/a2a/server/tasks/task_updater.py index 4afc8b357..b61ab7001 100644 --- a/src/a2a/server/tasks/task_updater.py +++ b/src/a2a/server/tasks/task_updater.py @@ -1,10 +1,14 @@ import asyncio -import uuid from datetime import datetime, timezone from typing import Any from a2a.server.events import EventQueue +from a2a.server.id_generator import ( + IDGenerator, + IDGeneratorContext, + UUIDGenerator, +) from a2a.types import ( Artifact, Message, @@ -23,13 +27,22 @@ class TaskUpdater: Simplifies the process of creating and enqueueing standard task events. """ - def __init__(self, event_queue: EventQueue, task_id: str, context_id: str): + def __init__( + self, + event_queue: EventQueue, + task_id: str, + context_id: str, + artifact_id_generator: IDGenerator | None = None, + message_id_generator: IDGenerator | None = None, + ): """Initializes the TaskUpdater. Args: event_queue: The `EventQueue` associated with the task. task_id: The ID of the task. context_id: The context ID of the task. + artifact_id_generator: ID generator for new artifact IDs. Defaults to UUID generator. + message_id_generator: ID generator for new message IDs. Defaults to UUID generator. """ self.event_queue = event_queue self.task_id = task_id @@ -42,6 +55,12 @@ def __init__(self, event_queue: EventQueue, task_id: str, context_id: str): TaskState.failed, TaskState.rejected, } + self._artifact_id_generator = ( + artifact_id_generator if artifact_id_generator else UUIDGenerator() + ) + self._message_id_generator = ( + message_id_generator if message_id_generator else UUIDGenerator() + ) async def update_status( self, @@ -110,7 +129,11 @@ async def add_artifact( # noqa: PLR0913 extensions: Optional list of extensions for the artifact. """ if not artifact_id: - artifact_id = str(uuid.uuid4()) + artifact_id = self._artifact_id_generator.generate( + IDGeneratorContext( + task_id=self.task_id, context_id=self.context_id + ) + ) await self.event_queue.enqueue_event( TaskArtifactUpdateEvent( @@ -205,7 +228,11 @@ def new_agent_message( role=Role.agent, task_id=self.task_id, context_id=self.context_id, - message_id=str(uuid.uuid4()), + message_id=self._message_id_generator.generate( + IDGeneratorContext( + task_id=self.task_id, context_id=self.context_id + ) + ), metadata=metadata, parts=parts, ) diff --git a/tests/server/agent_execution/test_context.py b/tests/server/agent_execution/test_context.py index 5cecd8929..684aecb27 100644 --- a/tests/server/agent_execution/test_context.py +++ b/tests/server/agent_execution/test_context.py @@ -6,6 +6,7 @@ from a2a.server.agent_execution import RequestContext from a2a.server.context import ServerCallContext +from a2a.server.id_generator import IDGenerator from a2a.types import ( Message, MessageSendParams, @@ -149,6 +150,20 @@ def test_check_or_generate_task_id_with_existing_task_id(self, mock_params): assert context.task_id == existing_id assert mock_params.message.task_id == existing_id + def test_check_or_generate_task_id_with_custom_id_generator( + self, mock_params + ): + """Test _check_or_generate_task_id uses custom ID generator when provided.""" + id_generator = Mock(spec=IDGenerator) + id_generator.generate.return_value = 'custom-task-id' + + context = RequestContext( + request=mock_params, task_id_generator=id_generator + ) + # The method is called during initialization + + assert context.task_id == 'custom-task-id' + def test_check_or_generate_context_id_no_params(self): """Test _check_or_generate_context_id with no params does nothing.""" context = RequestContext() @@ -168,6 +183,20 @@ def test_check_or_generate_context_id_with_existing_context_id( assert context.context_id == existing_id assert mock_params.message.context_id == existing_id + def test_check_or_generate_context_id_with_custom_id_generator( + self, mock_params + ): + """Test _check_or_generate_context_id uses custom ID generator when provided.""" + id_generator = Mock(spec=IDGenerator) + id_generator.generate.return_value = 'custom-context-id' + + context = RequestContext( + request=mock_params, context_id_generator=id_generator + ) + # The method is called during initialization + + assert context.context_id == 'custom-context-id' + def test_init_raises_error_on_task_id_mismatch( self, mock_params, mock_task ): diff --git a/tests/server/tasks/test_task_updater.py b/tests/server/tasks/test_task_updater.py index 844470cbe..a8de65e33 100644 --- a/tests/server/tasks/test_task_updater.py +++ b/tests/server/tasks/test_task_updater.py @@ -1,11 +1,12 @@ import asyncio import uuid -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from a2a.server.events import EventQueue +from a2a.server.id_generator import IDGenerator from a2a.server.tasks import TaskUpdater from a2a.types import ( Message, @@ -151,6 +152,26 @@ async def test_add_artifact_generates_id( assert event.last_chunk is None +@pytest.mark.asyncio +async def test_add_artifact_generates_custom_id(event_queue, sample_parts): + """Test add_artifact uses a custom ID generator when provided.""" + artifact_id_generator = Mock(spec=IDGenerator) + artifact_id_generator.generate.return_value = 'custom-artifact-id' + task_updater = TaskUpdater( + event_queue=event_queue, + task_id='test-task-id', + context_id='test-context-id', + artifact_id_generator=artifact_id_generator, + ) + + await task_updater.add_artifact(parts=sample_parts, artifact_id=None) + + event_queue.enqueue_event.assert_called_once() + event = event_queue.enqueue_event.call_args[0][0] + assert isinstance(event, TaskArtifactUpdateEvent) + assert event.artifact.artifact_id == 'custom-artifact-id' + + @pytest.mark.asyncio @pytest.mark.parametrize( 'append_val, last_chunk_val', @@ -304,6 +325,22 @@ def test_new_agent_message_with_metadata(task_updater, sample_parts): assert message.metadata == metadata +def test_new_agent_message_with_custom_id_generator(event_queue, sample_parts): + """Test creating a new agent message with a custom message ID generator.""" + message_id_generator = Mock(spec=IDGenerator) + message_id_generator.generate.return_value = 'custom-message-id' + task_updater = TaskUpdater( + event_queue=event_queue, + task_id='test-task-id', + context_id='test-context-id', + message_id_generator=message_id_generator, + ) + + message = task_updater.new_agent_message(parts=sample_parts) + + assert message.message_id == 'custom-message-id' + + @pytest.mark.asyncio async def test_failed_without_message(task_updater, event_queue): """Test marking a task as failed without a message.""" From ba24eadb5b6fcd056a008e4cbcef03b3f72a37c3 Mon Sep 17 00:00:00 2001 From: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Date: Wed, 15 Oct 2025 12:26:50 -0500 Subject: [PATCH 041/384] fix(client): `A2ACardResolver.get_agent_card` will auto-populate with `agent_card_path` when `relative_card_path` is empty (#508) - Previously, it would only override with the default path when `relative_card_path` is `None` - Developer Experience Improvement when `urlparse` or similar libraries are used. --- src/a2a/client/card_resolver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/a2a/client/card_resolver.py b/src/a2a/client/card_resolver.py index 9df551525..f13fe3ab6 100644 --- a/src/a2a/client/card_resolver.py +++ b/src/a2a/client/card_resolver.py @@ -53,7 +53,7 @@ async def get_agent_card( Args: relative_card_path: Optional path to the agent card endpoint, relative to the base URL. If None, uses the default public - agent card path. + agent card path. Use `'/'` for an empty path. http_kwargs: Optional dictionary of keyword arguments to pass to the underlying httpx.get request. @@ -65,7 +65,7 @@ async def get_agent_card( A2AClientJSONError: If the response body cannot be decoded as JSON or validated against the AgentCard schema. """ - if relative_card_path is None: + if not relative_card_path: # Use the default public agent card path configured during initialization path_segment = self.agent_card_path else: From a49f94ef23d81b8375e409b1c1e51afaf1da1956 Mon Sep 17 00:00:00 2001 From: kthota-g Date: Wed, 15 Oct 2025 10:32:20 -0700 Subject: [PATCH 042/384] fix: apply `history_length` for `message/send` requests (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description changes to honor `historyLength` param passed for `message/send` requests Fixes #497 🦕 Release-As: 0.3.9 --- .../default_request_handler.py | 25 ++---- src/a2a/utils/task.py | 22 ++++++ .../test_default_request_handler.py | 79 +++++++++++++++++++ 3 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/a2a/server/request_handlers/default_request_handler.py b/src/a2a/server/request_handlers/default_request_handler.py index 5e21fe8b0..30d1ee891 100644 --- a/src/a2a/server/request_handlers/default_request_handler.py +++ b/src/a2a/server/request_handlers/default_request_handler.py @@ -44,6 +44,7 @@ UnsupportedOperationError, ) from a2a.utils.errors import ServerError +from a2a.utils.task import apply_history_length from a2a.utils.telemetry import SpanKind, trace_class @@ -118,25 +119,7 @@ async def on_get_task( raise ServerError(error=TaskNotFoundError()) # Apply historyLength parameter if specified - if params.history_length is not None and task.history: - # Limit history to the most recent N messages - limited_history = ( - task.history[-params.history_length :] - if params.history_length > 0 - else [] - ) - # Create a new task instance with limited history - task = Task( - id=task.id, - context_id=task.context_id, - status=task.status, - artifacts=task.artifacts, - history=limited_history, - metadata=task.metadata, - kind=task.kind, - ) - - return task + return apply_history_length(task, params.history_length) async def on_cancel_task( self, params: TaskIdParams, context: ServerCallContext | None = None @@ -363,6 +346,10 @@ async def push_notification_callback() -> None: if isinstance(result, Task): self._validate_task_id_match(task_id, result.id) + if params.configuration: + result = apply_history_length( + result, params.configuration.history_length + ) await self._send_push_notification_if_needed(task_id, result_aggregator) diff --git a/src/a2a/utils/task.py b/src/a2a/utils/task.py index 22556cde3..5c5f3f076 100644 --- a/src/a2a/utils/task.py +++ b/src/a2a/utils/task.py @@ -70,3 +70,25 @@ def completed_task( artifacts=artifacts, history=history, ) + + +def apply_history_length(task: Task, history_length: int | None) -> Task: + """Applies history_length parameter on task and returns a new task object. + + Args: + task: The original task object with complete history + history_length: History length configuration value + + Returns: + A new task object with limited history + """ + # Apply historyLength parameter if specified + if history_length is not None and task.history: + # Limit history to the most recent N messages + limited_history = ( + task.history[-history_length:] if history_length > 0 else [] + ) + # Create a new task instance with limited history + return task.model_copy(update={'history': limited_history}) + + return task diff --git a/tests/server/request_handlers/test_default_request_handler.py b/tests/server/request_handlers/test_default_request_handler.py index 6765000c1..5268af115 100644 --- a/tests/server/request_handlers/test_default_request_handler.py +++ b/tests/server/request_handlers/test_default_request_handler.py @@ -836,6 +836,85 @@ async def test_on_message_send_non_blocking(): assert task.status.state == TaskState.completed +@pytest.mark.asyncio +async def test_on_message_send_limit_history(): + task_store = InMemoryTaskStore() + push_store = InMemoryPushNotificationConfigStore() + + request_handler = DefaultRequestHandler( + agent_executor=HelloAgentExecutor(), + task_store=task_store, + push_config_store=push_store, + ) + params = MessageSendParams( + message=Message( + role=Role.user, + message_id='msg_push', + parts=[Part(root=TextPart(text='Hi'))], + ), + configuration=MessageSendConfiguration( + blocking=True, + accepted_output_modes=['text/plain'], + history_length=0, + ), + ) + + result = await request_handler.on_message_send( + params, create_server_call_context() + ) + + # verify that history_length is honored + assert result is not None + assert isinstance(result, Task) + assert result.history is not None and len(result.history) == 0 + assert result.status.state == TaskState.completed + + # verify that history is still persisted to the store + task = await task_store.get(result.id) + assert task is not None + assert task.history is not None and len(task.history) > 0 + + +@pytest.mark.asyncio +async def test_on_task_get_limit_history(): + task_store = InMemoryTaskStore() + push_store = InMemoryPushNotificationConfigStore() + + request_handler = DefaultRequestHandler( + agent_executor=HelloAgentExecutor(), + task_store=task_store, + push_config_store=push_store, + ) + params = MessageSendParams( + message=Message( + role=Role.user, + message_id='msg_push', + parts=[Part(root=TextPart(text='Hi'))], + ), + configuration=MessageSendConfiguration( + blocking=True, accepted_output_modes=['text/plain'] + ), + ) + + result = await request_handler.on_message_send( + params, create_server_call_context() + ) + + assert result is not None + assert isinstance(result, Task) + + get_task_result = await request_handler.on_get_task( + TaskQueryParams(id=result.id, history_length=0), + create_server_call_context(), + ) + assert get_task_result is not None + assert isinstance(get_task_result, Task) + assert ( + get_task_result.history is not None + and len(get_task_result.history) == 0 + ) + + @pytest.mark.asyncio async def test_on_message_send_interrupted_flow(): """Test on_message_send when flow is interrupted (e.g., auth_required).""" From 317df0a2a4b4e405da6af57eaf5edd9f0df7eec2 Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Wed, 15 Oct 2025 12:34:31 -0500 Subject: [PATCH 043/384] chore(main): release 0.3.9 (#493) :robot: I have created a release *beep* *boop* --- ## [0.3.9](https://github.com/a2aproject/a2a-python/compare/v0.3.8...v0.3.9) (2025-10-15) ### Features * custom ID generators ([051ab20](https://github.com/a2aproject/a2a-python/commit/051ab20c395daa2807b0233cf1c53493e41b60c2)) ### 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)) ### Documentation * Fix Docstring formatting for code samples ([#492](https://github.com/a2aproject/a2a-python/issues/492)) ([dca66c3](https://github.com/a2aproject/a2a-python/commit/dca66c3100a2b9701a1c8b65ad6853769eefd511)) --- 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 | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7875c65a6..8bbfe6778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [0.3.9](https://github.com/a2aproject/a2a-python/compare/v0.3.8...v0.3.9) (2025-10-15) + + +### Features + +* custom ID generators ([051ab20](https://github.com/a2aproject/a2a-python/commit/051ab20c395daa2807b0233cf1c53493e41b60c2)) + + +### 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)) + + +### Documentation + +* Fix Docstring formatting for code samples ([#492](https://github.com/a2aproject/a2a-python/issues/492)) ([dca66c3](https://github.com/a2aproject/a2a-python/commit/dca66c3100a2b9701a1c8b65ad6853769eefd511)) + ## [0.3.8](https://github.com/a2aproject/a2a-python/compare/v0.3.7...v0.3.8) (2025-10-06) From d5856359034f4d3d1e4578804727f47a3cd7c322 Mon Sep 17 00:00:00 2001 From: Mike Smith Date: Thu, 16 Oct 2025 10:12:49 -0600 Subject: [PATCH 044/384] feat: Add a `ClientFactory.connect()` method for easy client creation (#509) # Description This PR adds a convenience method for constructing a Client from either an AgentCard URL or an AgentCard directly. The goal is to reduce the number of lines of code required for simple client creation, but still enabling more advanced handled of client construction. Usage example: ```python my_agent_url = 'https://travel-agent.example.com' client = await ClientFactory.connect(my_agent_url) await client.send_message(...) ``` Release-As: 0.3.10 --------- Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> --- .github/actions/spelling/allow.txt | 1 + src/a2a/client/client_factory.py | 67 ++++++++++++ tests/client/test_client_factory.py | 157 ++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 4d5c1d2c7..a016962ca 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -1,3 +1,4 @@ +AAgent ACard AClient ACMRTUXB diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index 3e98c9f5e..65b3fb5f0 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -3,10 +3,12 @@ import logging from collections.abc import Callable +from typing import Any import httpx from a2a.client.base_client import BaseClient +from a2a.client.card_resolver import A2ACardResolver from a2a.client.client import Client, ClientConfig, Consumer from a2a.client.middleware import ClientCallInterceptor from a2a.client.transports.base import ClientTransport @@ -101,6 +103,71 @@ def _register_defaults( GrpcTransport.create, ) + @classmethod + async def connect( # noqa: PLR0913 + cls, + agent: str | AgentCard, + client_config: ClientConfig | None = None, + consumers: list[Consumer] | None = None, + interceptors: list[ClientCallInterceptor] | None = None, + relative_card_path: str | None = None, + resolver_http_kwargs: dict[str, Any] | None = None, + extra_transports: dict[str, TransportProducer] | None = None, + ) -> Client: + """Convenience method for constructing a client. + + Constructs a client that connects to the specified agent. Note that + creating multiple clients via this method is less efficient than + constructing an instance of ClientFactory and reusing that. + + .. code-block:: python + + # This will search for an AgentCard at /.well-known/agent-card.json + my_agent_url = 'https://travel.agents.example.com' + client = await ClientFactory.connect(my_agent_url) + + + Args: + agent: The base URL of the agent, or the AgentCard to connect to. + client_config: The ClientConfig to use when connecting to the agent. + consumers: A list of `Consumer` methods to pass responses to. + interceptors: A list of interceptors to use for each request. These + are used for things like attaching credentials or http headers + to all outbound requests. + relative_card_path: If the agent field is a URL, this value is used as + the relative path when resolving the agent card. See + A2AAgentCardResolver.get_agent_card for more details. + resolver_http_kwargs: Dictionary of arguments to provide to the httpx + client when resolving the agent card. This value is provided to + A2AAgentCardResolver.get_agent_card as the http_kwargs parameter. + extra_transports: Additional transport protocols to enable when + constructing the client. + + Returns: + A `Client` object. + """ + client_config = client_config or ClientConfig() + if isinstance(agent, str): + if not client_config.httpx_client: + async with httpx.AsyncClient() as client: + resolver = A2ACardResolver(client, agent) + card = await resolver.get_agent_card( + relative_card_path=relative_card_path, + http_kwargs=resolver_http_kwargs, + ) + 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, + ) + else: + card = agent + factory = cls(client_config) + for label, generator in (extra_transports or {}).items(): + factory.register(label, generator) + return factory.create(card, consumers, interceptors) + def register(self, label: str, generator: TransportProducer) -> None: """Register a new transport producer for a given transport label.""" self._registry[label] = generator diff --git a/tests/client/test_client_factory.py b/tests/client/test_client_factory.py index d615bbff4..847b256fa 100644 --- a/tests/client/test_client_factory.py +++ b/tests/client/test_client_factory.py @@ -1,5 +1,7 @@ """Tests for the ClientFactory.""" +from unittest.mock import AsyncMock, MagicMock, patch + import httpx import pytest @@ -103,3 +105,158 @@ def test_client_factory_no_compatible_transport(base_agent_card: AgentCard): factory = ClientFactory(config) with pytest.raises(ValueError, match='no compatible transports found'): factory.create(base_agent_card) + + +@pytest.mark.asyncio +async def test_client_factory_connect_with_agent_card( + base_agent_card: AgentCard, +): + """Verify that connect works correctly when provided with an AgentCard.""" + client = await ClientFactory.connect(base_agent_card) + assert isinstance(client._transport, JsonRpcTransport) + assert client._transport.url == 'http://primary-url.com' + + +@pytest.mark.asyncio +async def test_client_factory_connect_with_url(base_agent_card: AgentCard): + """Verify that connect works correctly when provided with a URL.""" + with patch('a2a.client.client_factory.A2ACardResolver') as mock_resolver: + mock_resolver.return_value.get_agent_card = AsyncMock( + return_value=base_agent_card + ) + + agent_url = 'http://example.com' + client = await ClientFactory.connect(agent_url) + + mock_resolver.assert_called_once() + assert mock_resolver.call_args[0][1] == agent_url + mock_resolver.return_value.get_agent_card.assert_awaited_once() + + assert isinstance(client._transport, JsonRpcTransport) + assert client._transport.url == 'http://primary-url.com' + + +@pytest.mark.asyncio +async def test_client_factory_connect_with_url_and_client_config( + base_agent_card: AgentCard, +): + """Verify connect with a URL and a pre-configured httpx client.""" + with patch('a2a.client.client_factory.A2ACardResolver') as mock_resolver: + mock_resolver.return_value.get_agent_card = AsyncMock( + return_value=base_agent_card + ) + + agent_url = 'http://example.com' + mock_httpx_client = httpx.AsyncClient() + config = ClientConfig(httpx_client=mock_httpx_client) + + client = await ClientFactory.connect(agent_url, client_config=config) + + mock_resolver.assert_called_once_with(mock_httpx_client, agent_url) + mock_resolver.return_value.get_agent_card.assert_awaited_once() + + assert isinstance(client._transport, JsonRpcTransport) + assert client._transport.url == 'http://primary-url.com' + + +@pytest.mark.asyncio +async def test_client_factory_connect_with_resolver_args( + base_agent_card: AgentCard, +): + """Verify connect passes resolver arguments correctly.""" + with patch('a2a.client.client_factory.A2ACardResolver') as mock_resolver: + mock_resolver.return_value.get_agent_card = AsyncMock( + return_value=base_agent_card + ) + + agent_url = 'http://example.com' + relative_path = '/card' + http_kwargs = {'headers': {'X-Test': 'true'}} + + # The resolver args are only passed if an httpx_client is provided in config + config = ClientConfig(httpx_client=httpx.AsyncClient()) + + await ClientFactory.connect( + agent_url, + client_config=config, + relative_card_path=relative_path, + resolver_http_kwargs=http_kwargs, + ) + + mock_resolver.return_value.get_agent_card.assert_awaited_once_with( + relative_card_path=relative_path, + http_kwargs=http_kwargs, + ) + + +@pytest.mark.asyncio +async def test_client_factory_connect_resolver_args_without_client( + base_agent_card: AgentCard, +): + """Verify resolver args are ignored if no httpx_client is provided.""" + with patch('a2a.client.client_factory.A2ACardResolver') as mock_resolver: + mock_resolver.return_value.get_agent_card = AsyncMock( + return_value=base_agent_card + ) + + agent_url = 'http://example.com' + relative_path = '/card' + http_kwargs = {'headers': {'X-Test': 'true'}} + + await ClientFactory.connect( + agent_url, + relative_card_path=relative_path, + resolver_http_kwargs=http_kwargs, + ) + + mock_resolver.return_value.get_agent_card.assert_awaited_once_with( + relative_card_path=relative_path, + http_kwargs=http_kwargs, + ) + + +@pytest.mark.asyncio +async def test_client_factory_connect_with_extra_transports( + base_agent_card: AgentCard, +): + """Verify that connect can register and use extra transports.""" + + class CustomTransport: + pass + + def custom_transport_producer(*args, **kwargs): + return CustomTransport() + + base_agent_card.preferred_transport = 'custom' + base_agent_card.url = 'custom://foo' + + config = ClientConfig(supported_transports=['custom']) + + client = await ClientFactory.connect( + base_agent_card, + client_config=config, + extra_transports={'custom': custom_transport_producer}, + ) + + assert isinstance(client._transport, CustomTransport) + + +@pytest.mark.asyncio +async def test_client_factory_connect_with_consumers_and_interceptors( + base_agent_card: AgentCard, +): + """Verify consumers and interceptors are passed through correctly.""" + consumer1 = MagicMock() + interceptor1 = MagicMock() + + with patch('a2a.client.client_factory.BaseClient') as mock_base_client: + await ClientFactory.connect( + base_agent_card, + consumers=[consumer1], + interceptors=[interceptor1], + ) + + mock_base_client.assert_called_once() + call_args = mock_base_client.call_args[0] + assert call_args[3] == [consumer1] + assert call_args[4] == [interceptor1] From 5b813856b4b4e07510a4ef41980d388e47c73b8e Mon Sep 17 00:00:00 2001 From: mindpower Date: Tue, 21 Oct 2025 20:31:54 +0000 Subject: [PATCH 045/384] fix: change `MAX_CONTENT_LENGTH` (for file attachment) in json-rpc to be larger size (10mb) (#518) # Description The current max content length for file attachment in an A2A request is only 1mb which is too small for most files. Change MAX_CONTENT_LENGTH to be larger size (10mb). (Note currently we only have this limit for json-rpc) --- src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 2 +- .../server/apps/jsonrpc/test_serialization.py | 22 ++------- uv.lock | 46 ++++++++++++++++++- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index ea73ff592..d258916cb 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -91,7 +91,7 @@ Response = Any HTTP_413_REQUEST_ENTITY_TOO_LARGE = Any -MAX_CONTENT_LENGTH = 1_000_000 +MAX_CONTENT_LENGTH = 10_000_000 class StarletteUserProxy(A2AUser): diff --git a/tests/server/apps/jsonrpc/test_serialization.py b/tests/server/apps/jsonrpc/test_serialization.py index df2e8a3a4..9365017b5 100644 --- a/tests/server/apps/jsonrpc/test_serialization.py +++ b/tests/server/apps/jsonrpc/test_serialization.py @@ -122,7 +122,7 @@ def test_handle_oversized_payload(agent_card_with_api_key: AgentCard): app_instance = A2AStarletteApplication(agent_card_with_api_key, handler) client = TestClient(app_instance.build()) - large_string = 'a' * 2_000_000 # 2MB string + large_string = 'a' * 11 * 1_000_000 # 11MB string payload = { 'jsonrpc': '2.0', 'method': 'test', @@ -130,22 +130,10 @@ def test_handle_oversized_payload(agent_card_with_api_key: AgentCard): 'params': {'data': large_string}, } - # Starlette/FastAPI's default max request size is around 1MB. - # This test will likely fail with a 413 Payload Too Large if the default is not increased. - # If the application is expected to handle larger payloads, the server configuration needs to be adjusted. - # For this test, we expect a 413 or a graceful JSON-RPC error if the app handles it. - - try: - response = client.post('/', json=payload) - # If the app handles it gracefully and returns a JSON-RPC error - if response.status_code == 200: - data = response.json() - assert data['error']['code'] == InvalidRequestError().code - else: - assert response.status_code == 413 - except Exception as e: - # Depending on server setup, it might just drop the connection for very large payloads - assert isinstance(e, ConnectionResetError | RuntimeError) + response = client.post('/', json=payload) + assert response.status_code == 200 + data = response.json() + assert data['error']['code'] == InvalidRequestError().code def test_handle_unicode_characters(agent_card_with_api_key: AgentCard): diff --git a/uv.lock b/uv.lock index 4dc93c18e..5003ac402 100644 --- a/uv.lock +++ b/uv.lock @@ -18,6 +18,18 @@ dependencies = [ ] [package.optional-dependencies] +all = [ + { name = "cryptography" }, + { name = "fastapi" }, + { name = "grpcio" }, + { name = "grpcio-reflection" }, + { name = "grpcio-tools" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "sqlalchemy", extra = ["aiomysql", "aiosqlite", "asyncio", "postgresql-asyncpg"] }, + { name = "sse-starlette" }, + { name = "starlette" }, +] encryption = [ { name = "cryptography" }, ] @@ -69,30 +81,45 @@ dev = [ { name = "types-protobuf" }, { name = "types-requests" }, { name = "uv-dynamic-versioning" }, + { name = "uvicorn" }, ] [package.metadata] requires-dist = [ + { name = "cryptography", marker = "extra == 'all'", specifier = ">=43.0.0" }, { name = "cryptography", marker = "extra == 'encryption'", specifier = ">=43.0.0" }, + { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.115.2" }, { name = "fastapi", marker = "extra == 'http-server'", specifier = ">=0.115.2" }, { name = "google-api-core", specifier = ">=1.26.0" }, + { name = "grpcio", marker = "extra == 'all'", specifier = ">=1.60" }, { name = "grpcio", marker = "extra == 'grpc'", specifier = ">=1.60" }, + { name = "grpcio-reflection", marker = "extra == 'all'", specifier = ">=1.7.0" }, { name = "grpcio-reflection", marker = "extra == 'grpc'", specifier = ">=1.7.0" }, + { name = "grpcio-tools", marker = "extra == 'all'", specifier = ">=1.60" }, { name = "grpcio-tools", marker = "extra == 'grpc'", specifier = ">=1.60" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx-sse", specifier = ">=0.4.0" }, + { name = "opentelemetry-api", marker = "extra == 'all'", specifier = ">=1.33.0" }, { name = "opentelemetry-api", marker = "extra == 'telemetry'", specifier = ">=1.33.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'all'", specifier = ">=1.33.0" }, { name = "opentelemetry-sdk", marker = "extra == 'telemetry'", specifier = ">=1.33.0" }, { name = "protobuf", specifier = ">=5.29.5" }, { name = "pydantic", specifier = ">=2.11.3" }, - { name = "sqlalchemy", extras = ["aiomysql", "aiosqlite", "asyncio", "postgresql-asyncpg"], marker = "extra == 'sql'", 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" }, + { name = "sqlalchemy", extras = ["aiosqlite", "asyncio"], marker = "extra == 'all'", specifier = ">=2.0.0" }, + { name = "sqlalchemy", extras = ["aiosqlite", "asyncio"], marker = "extra == 'sql'", specifier = ">=2.0.0" }, { name = "sqlalchemy", extras = ["aiosqlite", "asyncio"], marker = "extra == 'sqlite'", specifier = ">=2.0.0" }, + { name = "sqlalchemy", extras = ["asyncio", "postgresql-asyncpg"], marker = "extra == 'all'", specifier = ">=2.0.0" }, { name = "sqlalchemy", extras = ["asyncio", "postgresql-asyncpg"], marker = "extra == 'postgresql'", specifier = ">=2.0.0" }, + { name = "sqlalchemy", extras = ["asyncio", "postgresql-asyncpg"], marker = "extra == 'sql'", specifier = ">=2.0.0" }, + { name = "sse-starlette", marker = "extra == 'all'" }, { name = "sse-starlette", marker = "extra == 'http-server'" }, + { name = "starlette", marker = "extra == 'all'" }, { name = "starlette", marker = "extra == 'http-server'" }, ] -provides-extras = ["encryption", "grpc", "http-server", "mysql", "postgresql", "sql", "sqlite", "telemetry"] +provides-extras = ["all", "encryption", "grpc", "http-server", "mysql", "postgresql", "sql", "sqlite", "telemetry"] [package.metadata.requires-dev] dev = [ @@ -115,6 +142,7 @@ dev = [ { name = "types-protobuf" }, { name = "types-requests" }, { name = "uv-dynamic-versioning", specifier = ">=0.8.2" }, + { name = "uvicorn", specifier = ">=0.35.0" }, ] [[package]] @@ -1994,6 +2022,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/55/a6cffd78511faebf208d4ba1f119d489680668f8d36114564c6f499054b9/uv_dynamic_versioning-0.8.2-py3-none-any.whl", hash = "sha256:400ade6b4a3fc02895c3d24dd0214171e4d60106def343b39ad43143a2615e8c", size = 8851, upload-time = "2025-05-02T05:08:29.33Z" }, ] +[[package]] +name = "uvicorn" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, +] + [[package]] name = "virtualenv" version = "20.32.0" From 9155888d258ca4d047002997e6674f3f15a67232 Mon Sep 17 00:00:00 2001 From: Martim Santos Date: Tue, 21 Oct 2025 21:34:52 +0100 Subject: [PATCH 046/384] refactor(utils): move part helpers & add artifact text extractor (#517) # Description This pull request refactors utility functions for handling `Part` objects by moving them from `src/a2a/utils/message.py` to a new dedicated module `src/a2a/utils/parts.py`. It also introduces a new helper function for extracting text from `Artifacts` and updates imports and exports to reflect these changes. The goal is to improve code organization and clarity by grouping similar functionality. **Refactoring and organization:** * Moved the functions `get_text_parts`, `get_data_parts`, and `get_file_parts` from `src/a2a/utils/message.py` into a new module `src/a2a/utils/parts.py`, providing better separation of concerns for part-handling utilities. [[1]](diffhunk://#diff-75cd067f29c32392a29e62de13b907467f6ed491a66bfdb56cdae0eafe70b2fdL67-L102) [[2]](diffhunk://#diff-02db7e35da2780f67c1bf288d503c6f7702e1f85542023b230bd798cee46cb21R1-R50) * Updated imports in `src/a2a/utils/__init__.py` to re-export these part-handling functions from the new `parts` module, and removed their previous import from the `message` module. [[1]](diffhunk://#diff-58d75f7b41efab15332246cd45437bba43a3bed62236bd5a9f8dfb50991f849aR7-R17) [[2]](diffhunk://#diff-58d75f7b41efab15332246cd45437bba43a3bed62236bd5a9f8dfb50991f849aL20-L27) [[3]](diffhunk://#diff-58d75f7b41efab15332246cd45437bba43a3bed62236bd5a9f8dfb50991f849aR47) **New functionality:** * Added the new function `get_artifact_text` to `src/a2a/utils/artifact.py` for extracting and joining all text content from an artifact's parts, using the refactored `get_text_parts`. [[1]](diffhunk://#diff-c47436a54fc84fd5eb8eed08c4e86fdb76e4a5a753191393712ae86f2b8f0f04R74-R86) [[2]](diffhunk://#diff-c47436a54fc84fd5eb8eed08c4e86fdb76e4a5a753191393712ae86f2b8f0f04R8) [[3]](diffhunk://#diff-58d75f7b41efab15332246cd45437bba43a3bed62236bd5a9f8dfb50991f849aR7-R17) [[4]](diffhunk://#diff-58d75f7b41efab15332246cd45437bba43a3bed62236bd5a9f8dfb50991f849aR47) These changes improve maintainability by clearly separating message-related and part-related utilities, and by introducing a helper for artifact text extraction. --- BEGIN_COMMIT_OVERRIDE refactor(utils): move part helpers to their own file feat: add `get_artifact_text()` helper method Release-As: 0.3.10 END_COMMIT_OVERRIDE --- src/a2a/utils/__init__.py | 10 +- src/a2a/utils/artifact.py | 14 +++ src/a2a/utils/message.py | 43 +------- src/a2a/utils/parts.py | 48 +++++++++ tests/utils/test_artifact.py | 74 +++++++++++++- tests/utils/test_message.py | 177 --------------------------------- tests/utils/test_parts.py | 184 +++++++++++++++++++++++++++++++++++ 7 files changed, 327 insertions(+), 223 deletions(-) create mode 100644 src/a2a/utils/parts.py create mode 100644 tests/utils/test_parts.py diff --git a/src/a2a/utils/__init__.py b/src/a2a/utils/__init__.py index 15f402651..e5b5663dd 100644 --- a/src/a2a/utils/__init__.py +++ b/src/a2a/utils/__init__.py @@ -1,6 +1,7 @@ """Utility functions for the A2A Python SDK.""" from a2a.utils.artifact import ( + get_artifact_text, new_artifact, new_data_artifact, new_text_artifact, @@ -18,13 +19,15 @@ create_task_obj, ) from a2a.utils.message import ( - get_data_parts, - get_file_parts, get_message_text, - get_text_parts, new_agent_parts_message, new_agent_text_message, ) +from a2a.utils.parts import ( + get_data_parts, + get_file_parts, + get_text_parts, +) from a2a.utils.task import ( completed_task, new_task, @@ -41,6 +44,7 @@ 'build_text_artifact', 'completed_task', 'create_task_obj', + 'get_artifact_text', 'get_data_parts', 'get_file_parts', 'get_message_text', diff --git a/src/a2a/utils/artifact.py b/src/a2a/utils/artifact.py index 1cf0a89ab..03e8adaa9 100644 --- a/src/a2a/utils/artifact.py +++ b/src/a2a/utils/artifact.py @@ -5,6 +5,7 @@ from typing import Any from a2a.types import Artifact, DataPart, Part, TextPart +from a2a.utils.parts import get_text_parts def new_artifact( @@ -70,3 +71,16 @@ def new_data_artifact( name, description, ) + + +def get_artifact_text(artifact: Artifact, delimiter: str = '\n') -> str: + """Extracts and joins all text content from an Artifact's parts. + + Args: + artifact: The `Artifact` object. + delimiter: The string to use when joining text from multiple TextParts. + + Returns: + A single string containing all text content, or an empty string if no text parts are found. + """ + return delimiter.join(get_text_parts(artifact.parts)) diff --git a/src/a2a/utils/message.py b/src/a2a/utils/message.py index 4d78cd46d..bfd675fdf 100644 --- a/src/a2a/utils/message.py +++ b/src/a2a/utils/message.py @@ -2,18 +2,13 @@ import uuid -from typing import Any - from a2a.types import ( - DataPart, - FilePart, - FileWithBytes, - FileWithUri, Message, Part, Role, TextPart, ) +from a2a.utils.parts import get_text_parts def new_agent_text_message( @@ -64,42 +59,6 @@ def new_agent_parts_message( ) -def get_text_parts(parts: list[Part]) -> list[str]: - """Extracts text content from all TextPart objects in a list of Parts. - - Args: - parts: A list of `Part` objects. - - Returns: - A list of strings containing the text content from any `TextPart` objects found. - """ - return [part.root.text for part in parts if isinstance(part.root, TextPart)] - - -def get_data_parts(parts: list[Part]) -> list[dict[str, Any]]: - """Extracts dictionary data from all DataPart objects in a list of Parts. - - Args: - parts: A list of `Part` objects. - - Returns: - A list of dictionaries containing the data from any `DataPart` objects found. - """ - return [part.root.data for part in parts if isinstance(part.root, DataPart)] - - -def get_file_parts(parts: list[Part]) -> list[FileWithBytes | FileWithUri]: - """Extracts file data from all FilePart objects in a list of Parts. - - Args: - parts: A list of `Part` objects. - - Returns: - A list of `FileWithBytes` or `FileWithUri` objects containing the file data from any `FilePart` objects found. - """ - return [part.root.file for part in parts if isinstance(part.root, FilePart)] - - def get_message_text(message: Message, delimiter: str = '\n') -> str: """Extracts and joins all text content from a Message's parts. diff --git a/src/a2a/utils/parts.py b/src/a2a/utils/parts.py new file mode 100644 index 000000000..f32076c8c --- /dev/null +++ b/src/a2a/utils/parts.py @@ -0,0 +1,48 @@ +"""Utility functions for creating and handling A2A Parts objects.""" + +from typing import Any + +from a2a.types import ( + DataPart, + FilePart, + FileWithBytes, + FileWithUri, + Part, + TextPart, +) + + +def get_text_parts(parts: list[Part]) -> list[str]: + """Extracts text content from all TextPart objects in a list of Parts. + + Args: + parts: A list of `Part` objects. + + Returns: + A list of strings containing the text content from any `TextPart` objects found. + """ + return [part.root.text for part in parts if isinstance(part.root, TextPart)] + + +def get_data_parts(parts: list[Part]) -> list[dict[str, Any]]: + """Extracts dictionary data from all DataPart objects in a list of Parts. + + Args: + parts: A list of `Part` objects. + + Returns: + A list of dictionaries containing the data from any `DataPart` objects found. + """ + return [part.root.data for part in parts if isinstance(part.root, DataPart)] + + +def get_file_parts(parts: list[Part]) -> list[FileWithBytes | FileWithUri]: + """Extracts file data from all FilePart objects in a list of Parts. + + Args: + parts: A list of `Part` objects. + + Returns: + A list of `FileWithBytes` or `FileWithUri` objects containing the file data from any `FilePart` objects found. + """ + return [part.root.file for part in parts if isinstance(part.root, FilePart)] diff --git a/tests/utils/test_artifact.py b/tests/utils/test_artifact.py index 132d0567d..c3590c17a 100644 --- a/tests/utils/test_artifact.py +++ b/tests/utils/test_artifact.py @@ -3,8 +3,14 @@ from unittest.mock import patch -from a2a.types import DataPart, Part, TextPart +from a2a.types import ( + Artifact, + DataPart, + Part, + TextPart, +) from a2a.utils.artifact import ( + get_artifact_text, new_artifact, new_data_artifact, new_text_artifact, @@ -83,5 +89,71 @@ def test_new_data_artifact_assigns_name_description(self): self.assertEqual(artifact.description, description) +class TestGetArtifactText(unittest.TestCase): + def test_get_artifact_text_single_part(self): + # Setup + artifact = Artifact( + name='test-artifact', + parts=[Part(root=TextPart(text='Hello world'))], + artifact_id='test-artifact-id', + ) + + # Exercise + result = get_artifact_text(artifact) + + # Verify + assert result == 'Hello world' + + def test_get_artifact_text_multiple_parts(self): + # Setup + artifact = Artifact( + name='test-artifact', + parts=[ + Part(root=TextPart(text='First line')), + Part(root=TextPart(text='Second line')), + Part(root=TextPart(text='Third line')), + ], + artifact_id='test-artifact-id', + ) + + # Exercise + result = get_artifact_text(artifact) + + # Verify - default delimiter is newline + assert result == 'First line\nSecond line\nThird line' + + def test_get_artifact_text_custom_delimiter(self): + # Setup + artifact = Artifact( + name='test-artifact', + parts=[ + Part(root=TextPart(text='First part')), + Part(root=TextPart(text='Second part')), + Part(root=TextPart(text='Third part')), + ], + artifact_id='test-artifact-id', + ) + + # Exercise + result = get_artifact_text(artifact, delimiter=' | ') + + # Verify + assert result == 'First part | Second part | Third part' + + def test_get_artifact_text_empty_parts(self): + # Setup + artifact = Artifact( + name='test-artifact', + parts=[], + artifact_id='test-artifact-id', + ) + + # Exercise + result = get_artifact_text(artifact) + + # Verify + assert result == '' + + if __name__ == '__main__': unittest.main() diff --git a/tests/utils/test_message.py b/tests/utils/test_message.py index 3270eab7f..11523cbdf 100644 --- a/tests/utils/test_message.py +++ b/tests/utils/test_message.py @@ -4,19 +4,13 @@ from a2a.types import ( DataPart, - FilePart, - FileWithBytes, - FileWithUri, Message, Part, Role, TextPart, ) from a2a.utils.message import ( - get_data_parts, - get_file_parts, get_message_text, - get_text_parts, new_agent_parts_message, new_agent_text_message, ) @@ -147,177 +141,6 @@ def test_new_agent_parts_message(self): assert message.message_id == 'abcdefab-cdef-abcd-efab-cdefabcdefab' -class TestGetTextParts: - def test_get_text_parts_single_text_part(self): - # Setup - parts = [Part(root=TextPart(text='Hello world'))] - - # Exercise - result = get_text_parts(parts) - - # Verify - assert result == ['Hello world'] - - def test_get_text_parts_multiple_text_parts(self): - # Setup - parts = [ - Part(root=TextPart(text='First part')), - Part(root=TextPart(text='Second part')), - Part(root=TextPart(text='Third part')), - ] - - # Exercise - result = get_text_parts(parts) - - # Verify - assert result == ['First part', 'Second part', 'Third part'] - - def test_get_text_parts_empty_list(self): - # Setup - parts = [] - - # Exercise - result = get_text_parts(parts) - - # Verify - assert result == [] - - -class TestGetDataParts: - def test_get_data_parts_single_data_part(self): - # Setup - parts = [Part(root=DataPart(data={'key': 'value'}))] - - # Exercise - result = get_data_parts(parts) - - # Verify - assert result == [{'key': 'value'}] - - def test_get_data_parts_multiple_data_parts(self): - # Setup - parts = [ - Part(root=DataPart(data={'key1': 'value1'})), - Part(root=DataPart(data={'key2': 'value2'})), - ] - - # Exercise - result = get_data_parts(parts) - - # Verify - assert result == [{'key1': 'value1'}, {'key2': 'value2'}] - - def test_get_data_parts_mixed_parts(self): - # Setup - parts = [ - Part(root=TextPart(text='some text')), - Part(root=DataPart(data={'key1': 'value1'})), - Part(root=DataPart(data={'key2': 'value2'})), - ] - - # Exercise - result = get_data_parts(parts) - - # Verify - assert result == [{'key1': 'value1'}, {'key2': 'value2'}] - - def test_get_data_parts_no_data_parts(self): - # Setup - parts = [ - Part(root=TextPart(text='some text')), - ] - - # Exercise - result = get_data_parts(parts) - - # Verify - assert result == [] - - def test_get_data_parts_empty_list(self): - # Setup - parts = [] - - # Exercise - result = get_data_parts(parts) - - # Verify - assert result == [] - - -class TestGetFileParts: - def test_get_file_parts_single_file_part(self): - # Setup - file_with_uri = FileWithUri( - uri='file://path/to/file', mimeType='text/plain' - ) - parts = [Part(root=FilePart(file=file_with_uri))] - - # Exercise - result = get_file_parts(parts) - - # Verify - assert result == [file_with_uri] - - def test_get_file_parts_multiple_file_parts(self): - # Setup - file_with_uri1 = FileWithUri( - uri='file://path/to/file1', mime_type='text/plain' - ) - file_with_bytes = FileWithBytes( - bytes='ZmlsZSBjb250ZW50', - mime_type='application/octet-stream', # 'file content' - ) - parts = [ - Part(root=FilePart(file=file_with_uri1)), - Part(root=FilePart(file=file_with_bytes)), - ] - - # Exercise - result = get_file_parts(parts) - - # Verify - assert result == [file_with_uri1, file_with_bytes] - - def test_get_file_parts_mixed_parts(self): - # Setup - file_with_uri = FileWithUri( - uri='file://path/to/file', mime_type='text/plain' - ) - parts = [ - Part(root=TextPart(text='some text')), - Part(root=FilePart(file=file_with_uri)), - ] - - # Exercise - result = get_file_parts(parts) - - # Verify - assert result == [file_with_uri] - - def test_get_file_parts_no_file_parts(self): - # Setup - parts = [ - Part(root=TextPart(text='some text')), - Part(root=DataPart(data={'key': 'value'})), - ] - - # Exercise - result = get_file_parts(parts) - - # Verify - assert result == [] - - def test_get_file_parts_empty_list(self): - # Setup - parts = [] - - # Exercise - result = get_file_parts(parts) - - # Verify - assert result == [] - - class TestGetMessageText: def test_get_message_text_single_part(self): # Setup diff --git a/tests/utils/test_parts.py b/tests/utils/test_parts.py new file mode 100644 index 000000000..dcb027c2b --- /dev/null +++ b/tests/utils/test_parts.py @@ -0,0 +1,184 @@ +from a2a.types import ( + DataPart, + FilePart, + FileWithBytes, + FileWithUri, + Part, + TextPart, +) +from a2a.utils.parts import ( + get_data_parts, + get_file_parts, + get_text_parts, +) + + +class TestGetTextParts: + def test_get_text_parts_single_text_part(self): + # Setup + parts = [Part(root=TextPart(text='Hello world'))] + + # Exercise + result = get_text_parts(parts) + + # Verify + assert result == ['Hello world'] + + def test_get_text_parts_multiple_text_parts(self): + # Setup + parts = [ + Part(root=TextPart(text='First part')), + Part(root=TextPart(text='Second part')), + Part(root=TextPart(text='Third part')), + ] + + # Exercise + result = get_text_parts(parts) + + # Verify + assert result == ['First part', 'Second part', 'Third part'] + + def test_get_text_parts_empty_list(self): + # Setup + parts = [] + + # Exercise + result = get_text_parts(parts) + + # Verify + assert result == [] + + +class TestGetDataParts: + def test_get_data_parts_single_data_part(self): + # Setup + parts = [Part(root=DataPart(data={'key': 'value'}))] + + # Exercise + result = get_data_parts(parts) + + # Verify + assert result == [{'key': 'value'}] + + def test_get_data_parts_multiple_data_parts(self): + # Setup + parts = [ + Part(root=DataPart(data={'key1': 'value1'})), + Part(root=DataPart(data={'key2': 'value2'})), + ] + + # Exercise + result = get_data_parts(parts) + + # Verify + assert result == [{'key1': 'value1'}, {'key2': 'value2'}] + + def test_get_data_parts_mixed_parts(self): + # Setup + parts = [ + Part(root=TextPart(text='some text')), + Part(root=DataPart(data={'key1': 'value1'})), + Part(root=DataPart(data={'key2': 'value2'})), + ] + + # Exercise + result = get_data_parts(parts) + + # Verify + assert result == [{'key1': 'value1'}, {'key2': 'value2'}] + + def test_get_data_parts_no_data_parts(self): + # Setup + parts = [ + Part(root=TextPart(text='some text')), + ] + + # Exercise + result = get_data_parts(parts) + + # Verify + assert result == [] + + def test_get_data_parts_empty_list(self): + # Setup + parts = [] + + # Exercise + result = get_data_parts(parts) + + # Verify + assert result == [] + + +class TestGetFileParts: + def test_get_file_parts_single_file_part(self): + # Setup + file_with_uri = FileWithUri( + uri='file://path/to/file', mimeType='text/plain' + ) + parts = [Part(root=FilePart(file=file_with_uri))] + + # Exercise + result = get_file_parts(parts) + + # Verify + assert result == [file_with_uri] + + def test_get_file_parts_multiple_file_parts(self): + # Setup + file_with_uri1 = FileWithUri( + uri='file://path/to/file1', mime_type='text/plain' + ) + file_with_bytes = FileWithBytes( + bytes='ZmlsZSBjb250ZW50', + mime_type='application/octet-stream', # 'file content' + ) + parts = [ + Part(root=FilePart(file=file_with_uri1)), + Part(root=FilePart(file=file_with_bytes)), + ] + + # Exercise + result = get_file_parts(parts) + + # Verify + assert result == [file_with_uri1, file_with_bytes] + + def test_get_file_parts_mixed_parts(self): + # Setup + file_with_uri = FileWithUri( + uri='file://path/to/file', mime_type='text/plain' + ) + parts = [ + Part(root=TextPart(text='some text')), + Part(root=FilePart(file=file_with_uri)), + ] + + # Exercise + result = get_file_parts(parts) + + # Verify + assert result == [file_with_uri] + + def test_get_file_parts_no_file_parts(self): + # Setup + parts = [ + Part(root=TextPart(text='some text')), + Part(root=DataPart(data={'key': 'value'})), + ] + + # Exercise + result = get_file_parts(parts) + + # Verify + assert result == [] + + def test_get_file_parts_empty_list(self): + # Setup + parts = [] + + # Exercise + result = get_file_parts(parts) + + # Verify + assert result == [] From ee026aa356042b9eb212eee59fa5135b280a3077 Mon Sep 17 00:00:00 2001 From: Pastukhov Nikita Date: Tue, 21 Oct 2025 23:36:48 +0300 Subject: [PATCH 047/384] fix: correct `new_artifact` methods signature (#503) Artifact class has nullable description with default None `Artifact(description: str | None = None)` But, utilities functions creates and artifact with empty description by default `description=''` Release-As: 0.3.10 --- src/a2a/utils/artifact.py | 8 +++++--- tests/utils/test_artifact.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/a2a/utils/artifact.py b/src/a2a/utils/artifact.py index 03e8adaa9..5053ca421 100644 --- a/src/a2a/utils/artifact.py +++ b/src/a2a/utils/artifact.py @@ -9,7 +9,9 @@ def new_artifact( - parts: list[Part], name: str, description: str = '' + parts: list[Part], + name: str, + description: str | None = None, ) -> Artifact: """Creates a new Artifact object. @@ -32,7 +34,7 @@ def new_artifact( def new_text_artifact( name: str, text: str, - description: str = '', + description: str | None = None, ) -> Artifact: """Creates a new Artifact object containing only a single TextPart. @@ -54,7 +56,7 @@ def new_text_artifact( def new_data_artifact( name: str, data: dict[str, Any], - description: str = '', + description: str | None = None, ) -> Artifact: """Creates a new Artifact object containing only a single DataPart. diff --git a/tests/utils/test_artifact.py b/tests/utils/test_artifact.py index c3590c17a..489c047c4 100644 --- a/tests/utils/test_artifact.py +++ b/tests/utils/test_artifact.py @@ -38,7 +38,7 @@ def test_new_artifact_empty_description_if_not_provided(self): parts = [Part(root=TextPart(text='Another sample'))] name = 'Artifact_No_Desc' artifact = new_artifact(parts=parts, name=name) - self.assertEqual(artifact.description, '') + self.assertEqual(artifact.description, None) def test_new_text_artifact_creates_single_text_part(self): text = 'This is a text artifact.' From 17e759757a0de07aea592c694abd84b71e7642ee Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Tue, 21 Oct 2025 15:39:19 -0500 Subject: [PATCH 048/384] chore(main): release 0.3.10 (#510) :robot: I have created a release *beep* *boop* --- ## [0.3.10](https://github.com/a2aproject/a2a-python/compare/v0.3.9...v0.3.10) (2025-10-21) ### Features * add `get_artifact_text()` helper method ([9155888](https://github.com/a2aproject/a2a-python/commit/9155888d258ca4d047002997e6674f3f15a67232)) * Add a `ClientFactory.connect()` method for easy client creation ([d585635](https://github.com/a2aproject/a2a-python/commit/d5856359034f4d3d1e4578804727f47a3cd7c322)) ### Bug Fixes * change `MAX_CONTENT_LENGTH` (for file attachment) in json-rpc to be larger size (10mb) ([#518](https://github.com/a2aproject/a2a-python/issues/518)) ([5b81385](https://github.com/a2aproject/a2a-python/commit/5b813856b4b4e07510a4ef41980d388e47c73b8e)) * correct `new_artifact` methods signature ([#503](https://github.com/a2aproject/a2a-python/issues/503)) ([ee026aa](https://github.com/a2aproject/a2a-python/commit/ee026aa356042b9eb212eee59fa5135b280a3077)) ### Code Refactoring * **utils:** move part helpers to their own file ([9155888](https://github.com/a2aproject/a2a-python/commit/9155888d258ca4d047002997e6674f3f15a67232)) --- 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 | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bbfe6778..449438cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [0.3.10](https://github.com/a2aproject/a2a-python/compare/v0.3.9...v0.3.10) (2025-10-21) + + +### Features + +* add `get_artifact_text()` helper method ([9155888](https://github.com/a2aproject/a2a-python/commit/9155888d258ca4d047002997e6674f3f15a67232)) +* Add a `ClientFactory.connect()` method for easy client creation ([d585635](https://github.com/a2aproject/a2a-python/commit/d5856359034f4d3d1e4578804727f47a3cd7c322)) + + +### Bug Fixes + +* change `MAX_CONTENT_LENGTH` (for file attachment) in json-rpc to be larger size (10mb) ([#518](https://github.com/a2aproject/a2a-python/issues/518)) ([5b81385](https://github.com/a2aproject/a2a-python/commit/5b813856b4b4e07510a4ef41980d388e47c73b8e)) +* correct `new_artifact` methods signature ([#503](https://github.com/a2aproject/a2a-python/issues/503)) ([ee026aa](https://github.com/a2aproject/a2a-python/commit/ee026aa356042b9eb212eee59fa5135b280a3077)) + + +### Code Refactoring + +* **utils:** move part helpers to their own file ([9155888](https://github.com/a2aproject/a2a-python/commit/9155888d258ca4d047002997e6674f3f15a67232)) + ## [0.3.9](https://github.com/a2aproject/a2a-python/compare/v0.3.8...v0.3.9) (2025-10-15) From 9c21e1a68fe579f906272b18fd47932c9b871180 Mon Sep 17 00:00:00 2001 From: Lukasz Kawka Date: Wed, 22 Oct 2025 11:29:03 +0200 Subject: [PATCH 049/384] ci: Script for checking-out experimental types (#507) # Description Added a script for creating a new feature branch with types generated from unmerged A2A spec changes. We need to begin development on several upcoming spec changes that are currently unmerged but are highly likely to be included in the next release, such as the task/list method. To do that, we need updated types, which this script provides. # Prerequisites - [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) --------- Co-authored-by: lkawka --- scripts/checkout_experimental_types.sh | 98 ++++++++++++++++++++++++++ scripts/generate_types.sh | 39 +++++++--- 2 files changed, 129 insertions(+), 8 deletions(-) create mode 100755 scripts/checkout_experimental_types.sh diff --git a/scripts/checkout_experimental_types.sh b/scripts/checkout_experimental_types.sh new file mode 100755 index 000000000..a598afaff --- /dev/null +++ b/scripts/checkout_experimental_types.sh @@ -0,0 +1,98 @@ +#!/bin/bash + +# Exit immediately if a command exits with a non-zero status. +# Treat unset variables as an error. +set -euo pipefail + +A2A_SPEC_REPO="https://github.com/a2aproject/A2A.git" # URL for the A2A spec repo. +A2A_SPEC_BRANCH="main" # Name of the branch with experimental changes. +FEATURE_BRANCH="experimental-types" # Name of the feature branch to create. +ROOT_DIR=$(git rev-parse --show-toplevel) + +usage() { + cat <&2 + usage + exit 1 + ;; + esac +done + + +TMP_WORK_DIR=$(mktemp -d) +echo "Created a temporary working directory: $TMP_WORK_DIR" +trap 'rm -rf -- "$TMP_WORK_DIR"' EXIT +cd $TMP_WORK_DIR + +echo "Cloning the \"$A2A_SPEC_REPO\" repository..." +git clone $A2A_SPEC_REPO spec_repo +cd spec_repo + +echo "Checking out the \"$A2A_SPEC_BRANCH\" branch..." +git checkout "$A2A_SPEC_BRANCH" + +echo "Invoking the generate_types.sh script..." +GENERATED_FILE="$ROOT_DIR/src/a2a/types.py" +$ROOT_DIR/scripts/generate_types.sh "$GENERATED_FILE" --input-file "$TMP_WORK_DIR/spec_repo/specification/json/a2a.json" + + +echo "Running buf generate..." +cd "$ROOT_DIR" +buf generate +uv run "$ROOT_DIR/scripts/grpc_gen_post_processor.py" + + +echo "Committing generated types file to the \"$FEATURE_BRANCH\" branch..." +git checkout -b "$FEATURE_BRANCH" +git add "$GENERATED_FILE" "$ROOT_DIR/src/a2a/grpc" +git commit -m "Experimental types" diff --git a/scripts/generate_types.sh b/scripts/generate_types.sh index ad76125f4..b8d7dedfc 100755 --- a/scripts/generate_types.sh +++ b/scripts/generate_types.sh @@ -4,21 +4,44 @@ # Treat unset variables as an error. set -euo pipefail -# Check if an output file path was provided as an argument. -if [ -z "$1" ]; then - echo "Error: Output file path must be provided as the first argument." >&2 +REMOTE_URL="https://raw.githubusercontent.com/a2aproject/A2A/refs/heads/main/specification/json/a2a.json" + +GENERATED_FILE="" +INPUT_FILE="" + +# Parse command-line arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --input-file) + INPUT_FILE="$2" + shift 2 + ;; + *) + GENERATED_FILE="$1" + shift 1 + ;; + esac +done + +if [ -z "$GENERATED_FILE" ]; then + echo "Error: Output file path must be provided." >&2 + echo "Usage: $0 [--input-file ] " exit 1 fi -REMOTE_URL="https://raw.githubusercontent.com/a2aproject/A2A/refs/heads/main/specification/json/a2a.json" -GENERATED_FILE="$1" - echo "Running datamodel-codegen..." -echo " - Source URL: $REMOTE_URL" +declare -a source_args +if [ -n "$INPUT_FILE" ]; then + echo " - Source File: $INPUT_FILE" + source_args=("--input" "$INPUT_FILE") +else + echo " - Source URL: $REMOTE_URL" + source_args=("--url" "$REMOTE_URL") +fi echo " - Output File: $GENERATED_FILE" uv run datamodel-codegen \ - --url "$REMOTE_URL" \ + "${source_args[@]}" \ --input-file-type jsonschema \ --output "$GENERATED_FILE" \ --target-python-version 3.10 \ From aa159f3e1076ae6eaad5576119d7857c2a9b2448 Mon Sep 17 00:00:00 2001 From: Iva Sokolaj <102302011+sokoliva@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:17:11 +0200 Subject: [PATCH 050/384] test: change `client/test_client.py` to `client/test_client_factory.py` in `tests/README.md` (#520) "client/test_factory.py" no longer exists. --- tests/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index bab99450c..d89f3bec7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,7 +2,7 @@ 1. Run the tests ```bash - uv run pytest -v -s client/test_client.py + uv run pytest -v -s client/test_client_factory.py ``` In case of failures, you can cleanup the cache: From 5268218c1ad6671552b7cbad34703f3abbb4fcce Mon Sep 17 00:00:00 2001 From: Jonas Frei <53214867+HelloJowet@users.noreply.github.com> Date: Fri, 31 Oct 2025 18:50:03 +0100 Subject: [PATCH 051/384] style: type issues in test folder (#521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description I noticed there are quite a few type warnings and errors in this repo when running pyright. I went ahead and fixed some of the type issues in the `/tests` folder to help clean things up a bit. Strong typing helps catch bugs early and makes the codebase easier to maintain in the long run. I’d be happy to contribute more and tackle additional type problems if there’s interest. This PR is just a first step to see if it makes sense to spend more time here and maybe get more involved as a contributor. Let me know what you think! --------- Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tests/client/test_auth_middleware.py | 44 +++--- tests/client/test_base_client.py | 16 +- tests/client/test_client_task_manager.py | 32 ++-- tests/client/test_errors.py | 44 +++--- tests/client/test_grpc_client.py | 22 +-- tests/server/agent_execution/test_context.py | 76 ++++----- .../test_simple_request_context_builder.py | 40 ++--- tests/server/events/test_event_queue.py | 17 +- .../events/test_inmemory_queue_manager.py | 51 ++++-- .../request_handlers/test_grpc_handler.py | 30 ++-- .../request_handlers/test_response_helpers.py | 30 ++-- .../tasks/test_inmemory_push_notifications.py | 42 ++--- .../tasks/test_push_notification_sender.py | 24 +-- tests/server/tasks/test_result_aggregator.py | 51 +++--- tests/server/tasks/test_task_updater.py | 146 ++++++++++++------ tests/utils/test_telemetry.py | 47 ++++-- 16 files changed, 411 insertions(+), 301 deletions(-) diff --git a/tests/client/test_auth_middleware.py b/tests/client/test_auth_middleware.py index 4f53ca3f2..c41b45017 100644 --- a/tests/client/test_auth_middleware.py +++ b/tests/client/test_auth_middleware.py @@ -106,10 +106,10 @@ def store(): @pytest.mark.asyncio -async def test_auth_interceptor_skips_when_no_agent_card(store): - """ - Tests that the AuthInterceptor does not modify the request when no AgentCard is provided. - """ +async def test_auth_interceptor_skips_when_no_agent_card( + store: InMemoryContextCredentialStore, +) -> None: + """Tests that the AuthInterceptor does not modify the request when no AgentCard is provided.""" request_payload = {'foo': 'bar'} http_kwargs = {'fizz': 'buzz'} auth_interceptor = AuthInterceptor(credential_service=store) @@ -126,9 +126,10 @@ async def test_auth_interceptor_skips_when_no_agent_card(store): @pytest.mark.asyncio -async def test_in_memory_context_credential_store(store): - """ - Verifies that InMemoryContextCredentialStore correctly stores and retrieves +async def test_in_memory_context_credential_store( + store: InMemoryContextCredentialStore, +) -> None: + """Verifies that InMemoryContextCredentialStore correctly stores and retrieves credentials based on the session ID in the client context. """ session_id = 'session-id' @@ -163,11 +164,8 @@ async def test_in_memory_context_credential_store(store): @pytest.mark.asyncio @respx.mock -async def test_client_with_simple_interceptor(): - """ - Ensures that a custom HeaderInterceptor correctly injects a static header - into outbound HTTP requests from the A2AClient. - """ +async def test_client_with_simple_interceptor() -> None: + """Ensures that a custom HeaderInterceptor correctly injects a static header into outbound HTTP requests from the A2AClient.""" url = 'http://agent.com/rpc' interceptor = HeaderInterceptor('X-Test-Header', 'Test-Value-123') card = AgentCard( @@ -196,9 +194,7 @@ async def test_client_with_simple_interceptor(): @dataclass class AuthTestCase: - """ - Represents a test scenario for verifying authentication behavior in AuthInterceptor. - """ + """Represents a test scenario for verifying authentication behavior in AuthInterceptor.""" url: str """The endpoint URL of the agent to which the request is sent.""" @@ -284,11 +280,10 @@ class AuthTestCase: [api_key_test_case, oauth2_test_case, oidc_test_case, bearer_test_case], ) @respx.mock -async def test_auth_interceptor_variants(test_case, store): - """ - Parametrized test verifying that AuthInterceptor correctly attaches credentials - based on the defined security scheme in the AgentCard. - """ +async def test_auth_interceptor_variants( + test_case: AuthTestCase, store: InMemoryContextCredentialStore +) -> None: + """Parametrized test verifying that AuthInterceptor correctly attaches credentials based on the defined security scheme in the AgentCard.""" await store.set_credentials( test_case.session_id, test_case.scheme_name, test_case.credential ) @@ -329,12 +324,9 @@ async def test_auth_interceptor_variants(test_case, store): @pytest.mark.asyncio async def test_auth_interceptor_skips_when_scheme_not_in_security_schemes( - store, -): - """ - Tests that AuthInterceptor skips a scheme if it's listed in security requirements - but not defined in security_schemes. - """ + store: InMemoryContextCredentialStore, +) -> None: + """Tests that AuthInterceptor skips a scheme if it's listed in security requirements but not defined in security_schemes.""" scheme_name = 'missing' session_id = 'session-id' credential = 'dummy-token' diff --git a/tests/client/test_base_client.py b/tests/client/test_base_client.py index c1251f1c4..d93a22030 100644 --- a/tests/client/test_base_client.py +++ b/tests/client/test_base_client.py @@ -19,12 +19,12 @@ @pytest.fixture -def mock_transport(): +def mock_transport() -> AsyncMock: return AsyncMock(spec=ClientTransport) @pytest.fixture -def sample_agent_card(): +def sample_agent_card() -> AgentCard: return AgentCard( name='Test Agent', description='An agent for testing', @@ -38,7 +38,7 @@ def sample_agent_card(): @pytest.fixture -def sample_message(): +def sample_message() -> Message: return Message( role=Role.user, message_id='msg-1', @@ -47,7 +47,9 @@ def sample_message(): @pytest.fixture -def base_client(sample_agent_card, mock_transport): +def base_client( + sample_agent_card: AgentCard, mock_transport: AsyncMock +) -> BaseClient: config = ClientConfig(streaming=True) return BaseClient( card=sample_agent_card, @@ -61,7 +63,7 @@ def base_client(sample_agent_card, mock_transport): @pytest.mark.asyncio async def test_send_message_streaming( base_client: BaseClient, mock_transport: MagicMock, sample_message: Message -): +) -> None: async def create_stream(*args, **kwargs): yield Task( id='task-123', @@ -82,7 +84,7 @@ async def create_stream(*args, **kwargs): @pytest.mark.asyncio async def test_send_message_non_streaming( base_client: BaseClient, mock_transport: MagicMock, sample_message: Message -): +) -> None: base_client._config.streaming = False mock_transport.send_message.return_value = Task( id='task-456', @@ -101,7 +103,7 @@ async def test_send_message_non_streaming( @pytest.mark.asyncio async def test_send_message_non_streaming_agent_capability_false( base_client: BaseClient, mock_transport: MagicMock, sample_message: Message -): +) -> None: base_client._card.capabilities.streaming = False mock_transport.send_message.return_value = Task( id='task-789', diff --git a/tests/client/test_client_task_manager.py b/tests/client/test_client_task_manager.py index b07ddcebc..63f98d8b9 100644 --- a/tests/client/test_client_task_manager.py +++ b/tests/client/test_client_task_manager.py @@ -22,12 +22,12 @@ @pytest.fixture -def task_manager(): +def task_manager() -> ClientTaskManager: return ClientTaskManager() @pytest.fixture -def sample_task(): +def sample_task() -> Task: return Task( id='task123', context_id='context456', @@ -38,7 +38,7 @@ def sample_task(): @pytest.fixture -def sample_message(): +def sample_message() -> Message: return Message( message_id='msg1', role=Role.user, @@ -46,13 +46,15 @@ def sample_message(): ) -def test_get_task_no_task_id_returns_none(task_manager: ClientTaskManager): +def test_get_task_no_task_id_returns_none( + task_manager: ClientTaskManager, +) -> None: assert task_manager.get_task() is None def test_get_task_or_raise_no_task_raises_error( task_manager: ClientTaskManager, -): +) -> None: with pytest.raises(A2AClientInvalidStateError, match='no current Task'): task_manager.get_task_or_raise() @@ -60,7 +62,7 @@ def test_get_task_or_raise_no_task_raises_error( @pytest.mark.asyncio async def test_save_task_event_with_task( task_manager: ClientTaskManager, sample_task: Task -): +) -> None: await task_manager.save_task_event(sample_task) assert task_manager.get_task() == sample_task assert task_manager._task_id == sample_task.id @@ -70,7 +72,7 @@ async def test_save_task_event_with_task( @pytest.mark.asyncio async def test_save_task_event_with_task_already_set_raises_error( task_manager: ClientTaskManager, sample_task: Task -): +) -> None: await task_manager.save_task_event(sample_task) with pytest.raises( A2AClientInvalidArgsError, @@ -82,7 +84,7 @@ async def test_save_task_event_with_task_already_set_raises_error( @pytest.mark.asyncio async def test_save_task_event_with_status_update( task_manager: ClientTaskManager, sample_task: Task, sample_message: Message -): +) -> None: await task_manager.save_task_event(sample_task) status_update = TaskStatusUpdateEvent( task_id=sample_task.id, @@ -98,7 +100,7 @@ async def test_save_task_event_with_status_update( @pytest.mark.asyncio async def test_save_task_event_with_artifact_update( task_manager: ClientTaskManager, sample_task: Task -): +) -> None: await task_manager.save_task_event(sample_task) artifact = Artifact( artifact_id='art1', parts=[Part(root=TextPart(text='artifact content'))] @@ -119,7 +121,7 @@ async def test_save_task_event_with_artifact_update( @pytest.mark.asyncio async def test_save_task_event_creates_task_if_not_exists( task_manager: ClientTaskManager, -): +) -> None: status_update = TaskStatusUpdateEvent( task_id='new_task', context_id='new_context', @@ -135,7 +137,7 @@ async def test_save_task_event_creates_task_if_not_exists( @pytest.mark.asyncio async def test_process_with_task_event( task_manager: ClientTaskManager, sample_task: Task -): +) -> None: with patch.object( task_manager, 'save_task_event', new_callable=AsyncMock ) as mock_save: @@ -144,7 +146,9 @@ async def test_process_with_task_event( @pytest.mark.asyncio -async def test_process_with_non_task_event(task_manager: ClientTaskManager): +async def test_process_with_non_task_event( + task_manager: ClientTaskManager, +) -> None: with patch.object( task_manager, 'save_task_event', new_callable=Mock ) as mock_save: @@ -155,14 +159,14 @@ async def test_process_with_non_task_event(task_manager: ClientTaskManager): def test_update_with_message( task_manager: ClientTaskManager, sample_task: Task, sample_message: Message -): +) -> None: updated_task = task_manager.update_with_message(sample_message, sample_task) assert updated_task.history == [sample_message] def test_update_with_message_moves_status_message( task_manager: ClientTaskManager, sample_task: Task, sample_message: Message -): +) -> None: status_message = Message( message_id='status_msg', role=Role.agent, diff --git a/tests/client/test_errors.py b/tests/client/test_errors.py index 30c4468dd..60636bd37 100644 --- a/tests/client/test_errors.py +++ b/tests/client/test_errors.py @@ -1,3 +1,5 @@ +from typing import NoReturn + import pytest from a2a.client import A2AClientError, A2AClientHTTPError, A2AClientJSONError @@ -6,13 +8,13 @@ class TestA2AClientError: """Test cases for the base A2AClientError class.""" - def test_instantiation(self): + def test_instantiation(self) -> None: """Test that A2AClientError can be instantiated.""" error = A2AClientError('Test error message') assert isinstance(error, Exception) assert str(error) == 'Test error message' - def test_inheritance(self): + def test_inheritance(self) -> None: """Test that A2AClientError inherits from Exception.""" error = A2AClientError() assert isinstance(error, Exception) @@ -21,31 +23,31 @@ def test_inheritance(self): class TestA2AClientHTTPError: """Test cases for A2AClientHTTPError class.""" - def test_instantiation(self): + def test_instantiation(self) -> None: """Test that A2AClientHTTPError can be instantiated with status_code and message.""" error = A2AClientHTTPError(404, 'Not Found') assert isinstance(error, A2AClientError) assert error.status_code == 404 assert error.message == 'Not Found' - def test_message_formatting(self): + def test_message_formatting(self) -> None: """Test that the error message is formatted correctly.""" error = A2AClientHTTPError(500, 'Internal Server Error') assert str(error) == 'HTTP Error 500: Internal Server Error' - def test_inheritance(self): + def test_inheritance(self) -> None: """Test that A2AClientHTTPError inherits from A2AClientError.""" error = A2AClientHTTPError(400, 'Bad Request') assert isinstance(error, A2AClientError) - def test_with_empty_message(self): + def test_with_empty_message(self) -> None: """Test behavior with an empty message.""" error = A2AClientHTTPError(403, '') assert error.status_code == 403 assert error.message == '' assert str(error) == 'HTTP Error 403: ' - def test_with_various_status_codes(self): + def test_with_various_status_codes(self) -> None: """Test with different HTTP status codes.""" test_cases = [ (200, 'OK'), @@ -68,29 +70,29 @@ def test_with_various_status_codes(self): class TestA2AClientJSONError: """Test cases for A2AClientJSONError class.""" - def test_instantiation(self): + def test_instantiation(self) -> None: """Test that A2AClientJSONError can be instantiated with a message.""" error = A2AClientJSONError('Invalid JSON format') assert isinstance(error, A2AClientError) assert error.message == 'Invalid JSON format' - def test_message_formatting(self): + def test_message_formatting(self) -> None: """Test that the error message is formatted correctly.""" error = A2AClientJSONError('Missing required field') assert str(error) == 'JSON Error: Missing required field' - def test_inheritance(self): + def test_inheritance(self) -> None: """Test that A2AClientJSONError inherits from A2AClientError.""" error = A2AClientJSONError('Parsing error') assert isinstance(error, A2AClientError) - def test_with_empty_message(self): + def test_with_empty_message(self) -> None: """Test behavior with an empty message.""" error = A2AClientJSONError('') assert error.message == '' assert str(error) == 'JSON Error: ' - def test_with_various_messages(self): + def test_with_various_messages(self) -> None: """Test with different error messages.""" test_messages = [ 'Malformed JSON', @@ -109,13 +111,13 @@ def test_with_various_messages(self): class TestExceptionHierarchy: """Test the exception hierarchy and relationships.""" - def test_exception_hierarchy(self): + def test_exception_hierarchy(self) -> None: """Test that the exception hierarchy is correct.""" assert issubclass(A2AClientError, Exception) assert issubclass(A2AClientHTTPError, A2AClientError) assert issubclass(A2AClientJSONError, A2AClientError) - def test_catch_specific_exception(self): + def test_catch_specific_exception(self) -> None: """Test that specific exceptions can be caught.""" try: raise A2AClientHTTPError(404, 'Not Found') @@ -123,7 +125,7 @@ def test_catch_specific_exception(self): assert e.status_code == 404 assert e.message == 'Not Found' - def test_catch_base_exception(self): + def test_catch_base_exception(self) -> None: """Test that derived exceptions can be caught as base exception.""" exceptions = [ A2AClientHTTPError(404, 'Not Found'), @@ -140,7 +142,7 @@ def test_catch_base_exception(self): class TestExceptionRaising: """Test cases for raising and handling the exceptions.""" - def test_raising_http_error(self): + def test_raising_http_error(self) -> NoReturn: """Test raising an HTTP error and checking its properties.""" with pytest.raises(A2AClientHTTPError) as excinfo: raise A2AClientHTTPError(429, 'Too Many Requests') @@ -150,7 +152,7 @@ def test_raising_http_error(self): assert error.message == 'Too Many Requests' assert str(error) == 'HTTP Error 429: Too Many Requests' - def test_raising_json_error(self): + def test_raising_json_error(self) -> NoReturn: """Test raising a JSON error and checking its properties.""" with pytest.raises(A2AClientJSONError) as excinfo: raise A2AClientJSONError('Invalid format') @@ -159,7 +161,7 @@ def test_raising_json_error(self): assert error.message == 'Invalid format' assert str(error) == 'JSON Error: Invalid format' - def test_raising_base_error(self): + def test_raising_base_error(self) -> NoReturn: """Test raising the base error.""" with pytest.raises(A2AClientError) as excinfo: raise A2AClientError('Generic client error') @@ -178,7 +180,9 @@ def test_raising_base_error(self): (500, 'Server Error', 'HTTP Error 500: Server Error'), ], ) -def test_http_error_parametrized(status_code, message, expected): +def test_http_error_parametrized( + status_code: int, message: str, expected: str +) -> None: """Parametrized test for HTTP errors with different status codes.""" error = A2AClientHTTPError(status_code, message) assert error.status_code == status_code @@ -194,7 +198,7 @@ def test_http_error_parametrized(status_code, message, expected): ('Parsing failed', 'JSON Error: Parsing failed'), ], ) -def test_json_error_parametrized(message, expected): +def test_json_error_parametrized(message: str, expected: str) -> None: """Parametrized test for JSON errors with different messages.""" error = A2AClientJSONError(message) assert error.message == message diff --git a/tests/client/test_grpc_client.py b/tests/client/test_grpc_client.py index 19f5abc16..6dab75e9f 100644 --- a/tests/client/test_grpc_client.py +++ b/tests/client/test_grpc_client.py @@ -128,7 +128,7 @@ def sample_task_status_update_event() -> TaskStatusUpdateEvent: @pytest.fixture def sample_task_artifact_update_event( - sample_artifact, + sample_artifact: Artifact, ) -> TaskArtifactUpdateEvent: """Provides a sample TaskArtifactUpdateEvent.""" return TaskArtifactUpdateEvent( @@ -179,7 +179,7 @@ async def test_send_message_task_response( mock_grpc_stub: AsyncMock, sample_message_send_params: MessageSendParams, sample_task: Task, -): +) -> None: """Test send_message that returns a Task.""" mock_grpc_stub.SendMessage.return_value = a2a_pb2.SendMessageResponse( task=proto_utils.ToProto.task(sample_task) @@ -198,7 +198,7 @@ async def test_send_message_message_response( mock_grpc_stub: AsyncMock, sample_message_send_params: MessageSendParams, sample_message: Message, -): +) -> None: """Test send_message that returns a Message.""" mock_grpc_stub.SendMessage.return_value = a2a_pb2.SendMessageResponse( msg=proto_utils.ToProto.message(sample_message) @@ -223,7 +223,7 @@ async def test_send_message_streaming( # noqa: PLR0913 sample_task: Task, sample_task_status_update_event: TaskStatusUpdateEvent, sample_task_artifact_update_event: TaskArtifactUpdateEvent, -): +) -> None: """Test send_message_streaming that yields responses.""" stream = MagicMock() stream.read = AsyncMock( @@ -268,7 +268,7 @@ async def test_send_message_streaming( # noqa: PLR0913 @pytest.mark.asyncio async def test_get_task( grpc_transport: GrpcTransport, mock_grpc_stub: AsyncMock, sample_task: Task -): +) -> None: """Test retrieving a task.""" mock_grpc_stub.GetTask.return_value = proto_utils.ToProto.task(sample_task) params = TaskQueryParams(id=sample_task.id) @@ -286,7 +286,7 @@ async def test_get_task( @pytest.mark.asyncio async def test_get_task_with_history( grpc_transport: GrpcTransport, mock_grpc_stub: AsyncMock, sample_task: Task -): +) -> None: """Test retrieving a task with history.""" mock_grpc_stub.GetTask.return_value = proto_utils.ToProto.task(sample_task) history_len = 10 @@ -304,7 +304,7 @@ async def test_get_task_with_history( @pytest.mark.asyncio async def test_cancel_task( grpc_transport: GrpcTransport, mock_grpc_stub: AsyncMock, sample_task: Task -): +) -> None: """Test cancelling a task.""" cancelled_task = sample_task.model_copy() cancelled_task.status.state = TaskState.canceled @@ -326,7 +326,7 @@ async def test_set_task_callback_with_valid_task( grpc_transport: GrpcTransport, mock_grpc_stub: AsyncMock, sample_task_push_notification_config: TaskPushNotificationConfig, -): +) -> None: """Test setting a task push notification config with a valid task id.""" mock_grpc_stub.CreateTaskPushNotificationConfig.return_value = ( proto_utils.ToProto.task_push_notification_config( @@ -355,7 +355,7 @@ async def test_set_task_callback_with_invalid_task( grpc_transport: GrpcTransport, mock_grpc_stub: AsyncMock, sample_task_push_notification_config: TaskPushNotificationConfig, -): +) -> None: """Test setting a task push notification config with an invalid task id.""" mock_grpc_stub.CreateTaskPushNotificationConfig.return_value = a2a_pb2.TaskPushNotificationConfig( name=( @@ -382,7 +382,7 @@ async def test_get_task_callback_with_valid_task( grpc_transport: GrpcTransport, mock_grpc_stub: AsyncMock, sample_task_push_notification_config: TaskPushNotificationConfig, -): +) -> None: """Test retrieving a task push notification config with a valid task id.""" mock_grpc_stub.GetTaskPushNotificationConfig.return_value = ( proto_utils.ToProto.task_push_notification_config( @@ -412,7 +412,7 @@ async def test_get_task_callback_with_invalid_task( grpc_transport: GrpcTransport, mock_grpc_stub: AsyncMock, sample_task_push_notification_config: TaskPushNotificationConfig, -): +) -> None: """Test retrieving a task push notification config with an invalid task id.""" mock_grpc_stub.GetTaskPushNotificationConfig.return_value = a2a_pb2.TaskPushNotificationConfig( name=( diff --git a/tests/server/agent_execution/test_context.py b/tests/server/agent_execution/test_context.py index 684aecb27..979978add 100644 --- a/tests/server/agent_execution/test_context.py +++ b/tests/server/agent_execution/test_context.py @@ -19,21 +19,21 @@ class TestRequestContext: """Tests for the RequestContext class.""" @pytest.fixture - def mock_message(self): + def mock_message(self) -> Mock: """Fixture for a mock Message.""" return Mock(spec=Message, task_id=None, context_id=None) @pytest.fixture - def mock_params(self, mock_message): + def mock_params(self, mock_message: Mock) -> Mock: """Fixture for a mock MessageSendParams.""" return Mock(spec=MessageSendParams, message=mock_message) @pytest.fixture - def mock_task(self): + def mock_task(self) -> Mock: """Fixture for a mock Task.""" return Mock(spec=Task, id='task-123', context_id='context-456') - def test_init_without_params(self): + def test_init_without_params(self) -> None: """Test initialization without parameters.""" context = RequestContext() assert context.message is None @@ -42,7 +42,7 @@ def test_init_without_params(self): assert context.current_task is None assert context.related_tasks == [] - def test_init_with_params_no_ids(self, mock_params): + def test_init_with_params_no_ids(self, mock_params: Mock) -> None: """Test initialization with params but no task or context IDs.""" with patch( 'uuid.uuid4', @@ -65,7 +65,7 @@ def test_init_with_params_no_ids(self, mock_params): == '00000000-0000-0000-0000-000000000002' ) - def test_init_with_task_id(self, mock_params): + def test_init_with_task_id(self, mock_params: Mock) -> None: """Test initialization with task ID provided.""" task_id = 'task-123' context = RequestContext(request=mock_params, task_id=task_id) @@ -73,7 +73,7 @@ def test_init_with_task_id(self, mock_params): assert context.task_id == task_id assert mock_params.message.task_id == task_id - def test_init_with_context_id(self, mock_params): + def test_init_with_context_id(self, mock_params: Mock) -> None: """Test initialization with context ID provided.""" context_id = 'context-456' context = RequestContext(request=mock_params, context_id=context_id) @@ -81,7 +81,7 @@ def test_init_with_context_id(self, mock_params): assert context.context_id == context_id assert mock_params.message.context_id == context_id - def test_init_with_both_ids(self, mock_params): + def test_init_with_both_ids(self, mock_params: Mock) -> None: """Test initialization with both task and context IDs provided.""" task_id = 'task-123' context_id = 'context-456' @@ -94,18 +94,18 @@ def test_init_with_both_ids(self, mock_params): assert context.context_id == context_id assert mock_params.message.context_id == context_id - def test_init_with_task(self, mock_params, mock_task): + def test_init_with_task(self, mock_params: Mock, mock_task: Mock) -> None: """Test initialization with a task object.""" context = RequestContext(request=mock_params, task=mock_task) assert context.current_task == mock_task - def test_get_user_input_no_params(self): + def test_get_user_input_no_params(self) -> None: """Test get_user_input with no params returns empty string.""" context = RequestContext() assert context.get_user_input() == '' - def test_attach_related_task(self, mock_task): + def test_attach_related_task(self, mock_task: Mock) -> None: """Test attach_related_task adds a task to related_tasks.""" context = RequestContext() assert len(context.related_tasks) == 0 @@ -120,7 +120,7 @@ def test_attach_related_task(self, mock_task): assert len(context.related_tasks) == 2 assert context.related_tasks[1] == another_task - def test_current_task_property(self, mock_task): + def test_current_task_property(self, mock_task: Mock) -> None: """Test current_task getter and setter.""" context = RequestContext() assert context.current_task is None @@ -133,13 +133,15 @@ def test_current_task_property(self, mock_task): context.current_task = new_task assert context.current_task == new_task - def test_check_or_generate_task_id_no_params(self): + def test_check_or_generate_task_id_no_params(self) -> None: """Test _check_or_generate_task_id with no params does nothing.""" context = RequestContext() context._check_or_generate_task_id() assert context.task_id is None - def test_check_or_generate_task_id_with_existing_task_id(self, mock_params): + def test_check_or_generate_task_id_with_existing_task_id( + self, mock_params: Mock + ) -> None: """Test _check_or_generate_task_id with existing task ID.""" existing_id = 'existing-task-id' mock_params.message.task_id = existing_id @@ -151,8 +153,8 @@ def test_check_or_generate_task_id_with_existing_task_id(self, mock_params): assert mock_params.message.task_id == existing_id def test_check_or_generate_task_id_with_custom_id_generator( - self, mock_params - ): + self, mock_params: Mock + ) -> None: """Test _check_or_generate_task_id uses custom ID generator when provided.""" id_generator = Mock(spec=IDGenerator) id_generator.generate.return_value = 'custom-task-id' @@ -164,15 +166,15 @@ def test_check_or_generate_task_id_with_custom_id_generator( assert context.task_id == 'custom-task-id' - def test_check_or_generate_context_id_no_params(self): + def test_check_or_generate_context_id_no_params(self) -> None: """Test _check_or_generate_context_id with no params does nothing.""" context = RequestContext() context._check_or_generate_context_id() assert context.context_id is None def test_check_or_generate_context_id_with_existing_context_id( - self, mock_params - ): + self, mock_params: Mock + ) -> None: """Test _check_or_generate_context_id with existing context ID.""" existing_id = 'existing-context-id' mock_params.message.context_id = existing_id @@ -184,8 +186,8 @@ def test_check_or_generate_context_id_with_existing_context_id( assert mock_params.message.context_id == existing_id def test_check_or_generate_context_id_with_custom_id_generator( - self, mock_params - ): + self, mock_params: Mock + ) -> None: """Test _check_or_generate_context_id uses custom ID generator when provided.""" id_generator = Mock(spec=IDGenerator) id_generator.generate.return_value = 'custom-context-id' @@ -198,8 +200,8 @@ def test_check_or_generate_context_id_with_custom_id_generator( assert context.context_id == 'custom-context-id' def test_init_raises_error_on_task_id_mismatch( - self, mock_params, mock_task - ): + self, mock_params: Mock, mock_task: Mock + ) -> None: """Test that an error is raised if provided task_id mismatches task.id.""" with pytest.raises(ServerError) as exc_info: RequestContext( @@ -208,8 +210,8 @@ def test_init_raises_error_on_task_id_mismatch( assert 'bad task id' in str(exc_info.value.error.message) def test_init_raises_error_on_context_id_mismatch( - self, mock_params, mock_task - ): + self, mock_params: Mock, mock_task: Mock + ) -> None: """Test that an error is raised if provided context_id mismatches task.context_id.""" # Set a valid task_id to avoid that error mock_params.message.task_id = mock_task.id @@ -224,7 +226,7 @@ def test_init_raises_error_on_context_id_mismatch( assert 'bad context id' in str(exc_info.value.error.message) - def test_with_related_tasks_provided(self, mock_task): + def test_with_related_tasks_provided(self, mock_task: Mock) -> None: """Test initialization with related tasks provided.""" related_tasks = [mock_task, Mock(spec=Task)] context = RequestContext(related_tasks=related_tasks) @@ -232,28 +234,30 @@ def test_with_related_tasks_provided(self, mock_task): assert context.related_tasks == related_tasks assert len(context.related_tasks) == 2 - def test_message_property_without_params(self): + def test_message_property_without_params(self) -> None: """Test message property returns None when no params are provided.""" context = RequestContext() assert context.message is None - def test_message_property_with_params(self, mock_params): + def test_message_property_with_params(self, mock_params: Mock) -> None: """Test message property returns the message from params.""" context = RequestContext(request=mock_params) assert context.message == mock_params.message - def test_metadata_property_without_content(self): + def test_metadata_property_without_content(self) -> None: """Test metadata property returns empty dict when no content are provided.""" context = RequestContext() assert context.metadata == {} - def test_metadata_property_with_content(self, mock_params): + def test_metadata_property_with_content(self, mock_params: Mock) -> None: """Test metadata property returns the metadata from params.""" mock_params.metadata = {'key': 'value'} context = RequestContext(request=mock_params) assert context.metadata == {'key': 'value'} - def test_init_with_existing_ids_in_message(self, mock_message, mock_params): + def test_init_with_existing_ids_in_message( + self, mock_message: Mock, mock_params: Mock + ) -> None: """Test initialization with existing IDs in the message.""" mock_message.task_id = 'existing-task-id' mock_message.context_id = 'existing-context-id' @@ -265,8 +269,8 @@ def test_init_with_existing_ids_in_message(self, mock_message, mock_params): # No new UUIDs should be generated def test_init_with_task_id_and_existing_task_id_match( - self, mock_params, mock_task - ): + self, mock_params: Mock, mock_task: Mock + ) -> None: """Test initialization succeeds when task_id matches task.id.""" mock_params.message.task_id = mock_task.id @@ -278,8 +282,8 @@ def test_init_with_task_id_and_existing_task_id_match( assert context.current_task == mock_task def test_init_with_context_id_and_existing_context_id_match( - self, mock_params, mock_task - ): + self, mock_params: Mock, mock_task: Mock + ) -> None: """Test initialization succeeds when context_id matches task.context_id.""" mock_params.message.task_id = mock_task.id # Set matching task ID mock_params.message.context_id = mock_task.context_id @@ -294,7 +298,7 @@ def test_init_with_context_id_and_existing_context_id_match( assert context.context_id == mock_task.context_id assert context.current_task == mock_task - def test_extension_handling(self): + def test_extension_handling(self) -> None: """Test extension handling in RequestContext.""" call_context = ServerCallContext(requested_extensions={'foo', 'bar'}) context = RequestContext(call_context=call_context) 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 116f40117..5e1b8fd81 100644 --- a/tests/server/agent_execution/test_simple_request_context_builder.py +++ b/tests/server/agent_execution/test_simple_request_context_builder.py @@ -26,23 +26,25 @@ # Helper to create a simple message def create_sample_message( - content='test message', - msg_id='msg1', - role=Role.user, - reference_task_ids=None, -): + content: str = 'test message', + msg_id: str = 'msg1', + role: Role = Role.user, + reference_task_ids: list[str] | None = None, +) -> Message: return Message( message_id=msg_id, role=role, parts=[Part(root=TextPart(text=content))], - referenceTaskIds=reference_task_ids if reference_task_ids else [], + reference_task_ids=reference_task_ids if reference_task_ids else [], ) # Helper to create a simple task def create_sample_task( - task_id='task1', status_state=TaskState.submitted, context_id='ctx1' -): + task_id: str = 'task1', + status_state: TaskState = TaskState.submitted, + context_id: str = 'ctx1', +) -> Task: return Task( id=task_id, context_id=context_id, @@ -51,24 +53,24 @@ def create_sample_task( class TestSimpleRequestContextBuilder(unittest.IsolatedAsyncioTestCase): - def setUp(self): + def setUp(self) -> None: self.mock_task_store = AsyncMock(spec=TaskStore) - def test_init_with_populate_true_and_task_store(self): + def test_init_with_populate_true_and_task_store(self) -> None: builder = SimpleRequestContextBuilder( should_populate_referred_tasks=True, task_store=self.mock_task_store ) self.assertTrue(builder._should_populate_referred_tasks) self.assertEqual(builder._task_store, self.mock_task_store) - def test_init_with_populate_false_task_store_none(self): + def test_init_with_populate_false_task_store_none(self) -> None: builder = SimpleRequestContextBuilder( should_populate_referred_tasks=False, task_store=None ) self.assertFalse(builder._should_populate_referred_tasks) self.assertIsNone(builder._task_store) - def test_init_with_populate_false_task_store_provided(self): + def test_init_with_populate_false_task_store_provided(self) -> None: # Even if populate is false, task_store might still be provided (though not used by build for related_tasks) builder = SimpleRequestContextBuilder( should_populate_referred_tasks=False, @@ -77,7 +79,7 @@ def test_init_with_populate_false_task_store_provided(self): self.assertFalse(builder._should_populate_referred_tasks) self.assertEqual(builder._task_store, self.mock_task_store) - async def test_build_basic_context_no_populate(self): + async def test_build_basic_context_no_populate(self) -> None: builder = SimpleRequestContextBuilder( should_populate_referred_tasks=False, task_store=self.mock_task_store, @@ -117,7 +119,7 @@ async def test_build_basic_context_no_populate(self): self.assertEqual(request_context.related_tasks, []) # Initialized to [] self.mock_task_store.get.assert_not_called() - async def test_build_populate_true_with_reference_task_ids(self): + async def test_build_populate_true_with_reference_task_ids(self) -> None: builder = SimpleRequestContextBuilder( should_populate_referred_tasks=True, task_store=self.mock_task_store ) @@ -167,7 +169,7 @@ async def get_side_effect(task_id): self.assertIn(mock_ref_task1, request_context.related_tasks) self.assertIn(mock_ref_task3, request_context.related_tasks) - async def test_build_populate_true_params_none(self): + async def test_build_populate_true_params_none(self) -> None: builder = SimpleRequestContextBuilder( should_populate_referred_tasks=True, task_store=self.mock_task_store ) @@ -182,7 +184,9 @@ async def test_build_populate_true_params_none(self): self.assertEqual(request_context.related_tasks, []) self.mock_task_store.get.assert_not_called() - async def test_build_populate_true_reference_ids_empty_or_none(self): + async def test_build_populate_true_reference_ids_empty_or_none( + self, + ) -> None: builder = SimpleRequestContextBuilder( should_populate_referred_tasks=True, task_store=self.mock_task_store ) @@ -224,7 +228,7 @@ async def test_build_populate_true_reference_ids_empty_or_none(self): self.assertEqual(request_context_none.related_tasks, []) self.mock_task_store.get.assert_not_called() - async def test_build_populate_true_task_store_none(self): + async def test_build_populate_true_task_store_none(self) -> None: # This scenario might be prevented by constructor logic if should_populate_referred_tasks is True, # but testing defensively. The builder might allow task_store=None if it's set post-init, # or if constructor logic changes. Current SimpleRequestContextBuilder takes it at init. @@ -249,7 +253,7 @@ async def test_build_populate_true_task_store_none(self): self.assertEqual(request_context.related_tasks, []) # No mock_task_store to check calls on, this test is mostly for graceful handling. - async def test_build_populate_false_with_reference_task_ids(self): + async def test_build_populate_false_with_reference_task_ids(self) -> None: builder = SimpleRequestContextBuilder( should_populate_referred_tasks=False, task_store=self.mock_task_store, diff --git a/tests/server/events/test_event_queue.py b/tests/server/events/test_event_queue.py index 18ebf72b8..0ff966cc3 100644 --- a/tests/server/events/test_event_queue.py +++ b/tests/server/events/test_event_queue.py @@ -45,20 +45,20 @@ def event_queue() -> EventQueue: return EventQueue() -def test_constructor_default_max_queue_size(): +def test_constructor_default_max_queue_size() -> None: """Test that the queue is created with the default max size.""" eq = EventQueue() assert eq.queue.maxsize == DEFAULT_MAX_QUEUE_SIZE -def test_constructor_max_queue_size(): +def test_constructor_max_queue_size() -> None: """Test that the asyncio.Queue is created with the specified max_queue_size.""" custom_size = 123 eq = EventQueue(max_queue_size=custom_size) assert eq.queue.maxsize == custom_size -def test_constructor_invalid_max_queue_size(): +def test_constructor_invalid_max_queue_size() -> None: """Test that a ValueError is raised for non-positive max_queue_size.""" with pytest.raises( ValueError, match='max_queue_size must be greater than 0' @@ -170,7 +170,7 @@ async def test_enqueue_event_propagates_to_children( @pytest.mark.asyncio async def test_enqueue_event_when_closed( - event_queue: EventQueue, expected_queue_closed_exception + event_queue: EventQueue, expected_queue_closed_exception: type[Exception] ) -> None: """Test that no event is enqueued if the parent queue is closed.""" await event_queue.close() # Close the queue first @@ -199,7 +199,7 @@ async def test_enqueue_event_when_closed( @pytest.fixture -def expected_queue_closed_exception(): +def expected_queue_closed_exception() -> type[Exception]: if sys.version_info < (3, 13): return asyncio.QueueEmpty return asyncio.QueueShutDown @@ -207,7 +207,7 @@ def expected_queue_closed_exception(): @pytest.mark.asyncio async def test_dequeue_event_closed_and_empty_no_wait( - event_queue: EventQueue, expected_queue_closed_exception + event_queue: EventQueue, expected_queue_closed_exception: type[Exception] ) -> None: """Test dequeue_event raises QueueEmpty when closed, empty, and no_wait=True.""" await event_queue.close() @@ -222,7 +222,7 @@ async def test_dequeue_event_closed_and_empty_no_wait( @pytest.mark.asyncio async def test_dequeue_event_closed_and_empty_waits_then_raises( - event_queue: EventQueue, expected_queue_closed_exception + event_queue: EventQueue, expected_queue_closed_exception: type[Exception] ) -> None: """Test dequeue_event raises QueueEmpty eventually when closed, empty, and no_wait=False.""" await event_queue.close() @@ -409,7 +409,6 @@ async def test_close_immediate_propagates_to_children( event_queue: EventQueue, ) -> None: """Test that immediate parameter is propagated to child queues.""" - child_queue = event_queue.tap() # Add events to both parent and child @@ -430,7 +429,6 @@ async def test_close_immediate_propagates_to_children( @pytest.mark.asyncio async def test_clear_events_current_queue_only(event_queue: EventQueue) -> None: """Test clear_events clears only the current queue when clear_child_queues=False.""" - child_queue = event_queue.tap() event1 = Message(**MESSAGE_PAYLOAD) event2 = Task(**MINIMAL_TASK) @@ -454,7 +452,6 @@ async def test_clear_events_current_queue_only(event_queue: EventQueue) -> None: @pytest.mark.asyncio async def test_clear_events_with_children(event_queue: EventQueue) -> None: """Test clear_events clears both current queue and child queues.""" - # Create child queues and add events child_queue1 = event_queue.tap() child_queue2 = event_queue.tap() diff --git a/tests/server/events/test_inmemory_queue_manager.py b/tests/server/events/test_inmemory_queue_manager.py index 3fb8f4c74..b51334a95 100644 --- a/tests/server/events/test_inmemory_queue_manager.py +++ b/tests/server/events/test_inmemory_queue_manager.py @@ -14,33 +14,38 @@ class TestInMemoryQueueManager: @pytest.fixture - def queue_manager(self): + def queue_manager(self) -> InMemoryQueueManager: """Fixture to create a fresh InMemoryQueueManager for each test.""" return InMemoryQueueManager() @pytest.fixture - def event_queue(self): + def event_queue(self) -> MagicMock: """Fixture to create a mock EventQueue.""" queue = MagicMock(spec=EventQueue) + # Mock the tap method to return itself queue.tap.return_value = queue return queue @pytest.mark.asyncio - async def test_init(self, queue_manager): + async def test_init(self, queue_manager: InMemoryQueueManager) -> None: """Test that the InMemoryQueueManager initializes with empty task queue and a lock.""" assert queue_manager._task_queue == {} assert isinstance(queue_manager._lock, asyncio.Lock) @pytest.mark.asyncio - async def test_add_new_queue(self, queue_manager, event_queue): + async def test_add_new_queue( + self, queue_manager: InMemoryQueueManager, event_queue: MagicMock + ) -> None: """Test adding a new queue to the manager.""" task_id = 'test_task_id' await queue_manager.add(task_id, event_queue) assert queue_manager._task_queue[task_id] == event_queue @pytest.mark.asyncio - async def test_add_existing_queue(self, queue_manager, event_queue): + async def test_add_existing_queue( + self, queue_manager: InMemoryQueueManager, event_queue: MagicMock + ) -> None: """Test adding a queue with an existing task_id raises TaskQueueExists.""" task_id = 'test_task_id' await queue_manager.add(task_id, event_queue) @@ -49,7 +54,9 @@ async def test_add_existing_queue(self, queue_manager, event_queue): await queue_manager.add(task_id, event_queue) @pytest.mark.asyncio - async def test_get_existing_queue(self, queue_manager, event_queue): + async def test_get_existing_queue( + self, queue_manager: InMemoryQueueManager, event_queue: MagicMock + ) -> None: """Test getting an existing queue returns the queue.""" task_id = 'test_task_id' await queue_manager.add(task_id, event_queue) @@ -58,13 +65,17 @@ async def test_get_existing_queue(self, queue_manager, event_queue): assert result == event_queue @pytest.mark.asyncio - async def test_get_nonexistent_queue(self, queue_manager): + async def test_get_nonexistent_queue( + self, queue_manager: InMemoryQueueManager + ) -> None: """Test getting a nonexistent queue returns None.""" result = await queue_manager.get('nonexistent_task_id') assert result is None @pytest.mark.asyncio - async def test_tap_existing_queue(self, queue_manager, event_queue): + async def test_tap_existing_queue( + self, queue_manager: InMemoryQueueManager, event_queue: MagicMock + ) -> None: """Test tapping an existing queue returns the tapped queue.""" task_id = 'test_task_id' await queue_manager.add(task_id, event_queue) @@ -74,13 +85,17 @@ async def test_tap_existing_queue(self, queue_manager, event_queue): event_queue.tap.assert_called_once() @pytest.mark.asyncio - async def test_tap_nonexistent_queue(self, queue_manager): + async def test_tap_nonexistent_queue( + self, queue_manager: InMemoryQueueManager + ) -> None: """Test tapping a nonexistent queue returns None.""" result = await queue_manager.tap('nonexistent_task_id') assert result is None @pytest.mark.asyncio - async def test_close_existing_queue(self, queue_manager, event_queue): + async def test_close_existing_queue( + self, queue_manager: InMemoryQueueManager, event_queue: MagicMock + ) -> None: """Test closing an existing queue removes it from the manager.""" task_id = 'test_task_id' await queue_manager.add(task_id, event_queue) @@ -89,13 +104,17 @@ async def test_close_existing_queue(self, queue_manager, event_queue): assert task_id not in queue_manager._task_queue @pytest.mark.asyncio - async def test_close_nonexistent_queue(self, queue_manager): + async def test_close_nonexistent_queue( + self, queue_manager: InMemoryQueueManager + ) -> None: """Test closing a nonexistent queue raises NoTaskQueue.""" with pytest.raises(NoTaskQueue): await queue_manager.close('nonexistent_task_id') @pytest.mark.asyncio - async def test_create_or_tap_new_queue(self, queue_manager): + async def test_create_or_tap_new_queue( + self, queue_manager: InMemoryQueueManager + ) -> None: """Test create_or_tap with a new task_id creates and returns a new queue.""" task_id = 'test_task_id' @@ -105,8 +124,8 @@ async def test_create_or_tap_new_queue(self, queue_manager): @pytest.mark.asyncio async def test_create_or_tap_existing_queue( - self, queue_manager, event_queue - ): + self, queue_manager: InMemoryQueueManager, event_queue: MagicMock + ) -> None: """Test create_or_tap with an existing task_id taps and returns the existing queue.""" task_id = 'test_task_id' await queue_manager.add(task_id, event_queue) @@ -117,7 +136,9 @@ async def test_create_or_tap_existing_queue( event_queue.tap.assert_called_once() @pytest.mark.asyncio - async def test_concurrency(self, queue_manager): + async def test_concurrency( + self, queue_manager: InMemoryQueueManager + ) -> None: """Test concurrent access to the queue manager.""" async def add_task(task_id): diff --git a/tests/server/request_handlers/test_grpc_handler.py b/tests/server/request_handlers/test_grpc_handler.py index 05af6cdac..26f923c14 100644 --- a/tests/server/request_handlers/test_grpc_handler.py +++ b/tests/server/request_handlers/test_grpc_handler.py @@ -61,7 +61,7 @@ async def test_send_message_success( grpc_handler: GrpcHandler, mock_request_handler: AsyncMock, mock_grpc_context: AsyncMock, -): +) -> None: """Test successful SendMessage call.""" request_proto = a2a_pb2.SendMessageRequest( request=a2a_pb2.Message(message_id='msg-1') @@ -86,7 +86,7 @@ async def test_send_message_server_error( grpc_handler: GrpcHandler, mock_request_handler: AsyncMock, mock_grpc_context: AsyncMock, -): +) -> None: """Test SendMessage call when handler raises a ServerError.""" request_proto = a2a_pb2.SendMessageRequest() error = ServerError(error=types.InvalidParamsError(message='Bad params')) @@ -104,7 +104,7 @@ async def test_get_task_success( grpc_handler: GrpcHandler, mock_request_handler: AsyncMock, mock_grpc_context: AsyncMock, -): +) -> None: """Test successful GetTask call.""" request_proto = a2a_pb2.GetTaskRequest(name='tasks/task-1') response_model = types.Task( @@ -126,7 +126,7 @@ async def test_get_task_not_found( grpc_handler: GrpcHandler, mock_request_handler: AsyncMock, mock_grpc_context: AsyncMock, -): +) -> None: """Test GetTask call when task is not found.""" request_proto = a2a_pb2.GetTaskRequest(name='tasks/task-1') mock_request_handler.on_get_task.return_value = None @@ -143,7 +143,7 @@ async def test_cancel_task_server_error( grpc_handler: GrpcHandler, mock_request_handler: AsyncMock, mock_grpc_context: AsyncMock, -): +) -> None: """Test CancelTask call when handler raises ServerError.""" request_proto = a2a_pb2.CancelTaskRequest(name='tasks/task-1') error = ServerError(error=types.TaskNotCancelableError()) @@ -162,7 +162,7 @@ async def test_send_streaming_message( grpc_handler: GrpcHandler, mock_request_handler: AsyncMock, mock_grpc_context: AsyncMock, -): +) -> None: """Test successful SendStreamingMessage call.""" async def mock_stream(): @@ -192,7 +192,7 @@ async def test_get_agent_card( grpc_handler: GrpcHandler, sample_agent_card: types.AgentCard, mock_grpc_context: AsyncMock, -): +) -> None: """Test GetAgentCard call.""" request_proto = a2a_pb2.GetAgentCardRequest() response = await grpc_handler.GetAgentCard(request_proto, mock_grpc_context) @@ -206,7 +206,7 @@ async def test_get_agent_card_with_modifier( mock_request_handler: AsyncMock, sample_agent_card: types.AgentCard, mock_grpc_context: AsyncMock, -): +) -> None: """Test GetAgentCard call with a card_modifier.""" def modifier(card: types.AgentCard) -> types.AgentCard: @@ -299,10 +299,10 @@ async def test_abort_context_error_mapping( # noqa: PLR0913 grpc_handler: GrpcHandler, mock_request_handler: AsyncMock, mock_grpc_context: AsyncMock, - server_error, - grpc_status_code, - error_message_part, -): + server_error: ServerError, + grpc_status_code: grpc.StatusCode, + error_message_part: str, +) -> None: mock_request_handler.on_get_task.side_effect = server_error request_proto = a2a_pb2.GetTaskRequest(name='tasks/any') await grpc_handler.GetTask(request_proto, mock_grpc_context) @@ -320,7 +320,7 @@ async def test_send_message_with_extensions( grpc_handler: GrpcHandler, mock_request_handler: AsyncMock, mock_grpc_context: AsyncMock, - ): + ) -> None: mock_grpc_context.invocation_metadata = grpc.aio.Metadata( (HTTP_EXTENSION_HEADER, 'foo'), (HTTP_EXTENSION_HEADER, 'bar'), @@ -360,7 +360,7 @@ async def test_send_message_with_comma_separated_extensions( grpc_handler: GrpcHandler, mock_request_handler: AsyncMock, mock_grpc_context: AsyncMock, - ): + ) -> None: mock_grpc_context.invocation_metadata = grpc.aio.Metadata( (HTTP_EXTENSION_HEADER, 'foo ,, bar,'), (HTTP_EXTENSION_HEADER, 'baz , bar'), @@ -385,7 +385,7 @@ async def test_send_streaming_message_with_extensions( grpc_handler: GrpcHandler, mock_request_handler: AsyncMock, mock_grpc_context: AsyncMock, - ): + ) -> None: mock_grpc_context.invocation_metadata = grpc.aio.Metadata( (HTTP_EXTENSION_HEADER, 'foo'), (HTTP_EXTENSION_HEADER, 'bar'), diff --git a/tests/server/request_handlers/test_response_helpers.py b/tests/server/request_handlers/test_response_helpers.py index 96e79e51a..36de78e62 100644 --- a/tests/server/request_handlers/test_response_helpers.py +++ b/tests/server/request_handlers/test_response_helpers.py @@ -22,7 +22,7 @@ class TestResponseHelpers(unittest.TestCase): - def test_build_error_response_with_a2a_error(self): + def test_build_error_response_with_a2a_error(self) -> None: request_id = 'req1' specific_error = TaskNotFoundError() a2a_error = A2AError(root=specific_error) # Correctly wrap @@ -36,7 +36,7 @@ def test_build_error_response_with_a2a_error(self): response_wrapper.root.error, specific_error ) # build_error_response unwraps A2AError - def test_build_error_response_with_jsonrpc_error(self): + def test_build_error_response_with_jsonrpc_error(self) -> None: request_id = 123 json_rpc_error = InvalidParamsError( message='Custom invalid params' @@ -51,7 +51,7 @@ def test_build_error_response_with_jsonrpc_error(self): response_wrapper.root.error, json_rpc_error ) # No .root access for json_rpc_error - def test_build_error_response_with_a2a_wrapping_jsonrpc_error(self): + def test_build_error_response_with_a2a_wrapping_jsonrpc_error(self) -> None: request_id = 'req_wrap' specific_jsonrpc_error = InvalidParamsError(message='Detail error') a2a_error_wrapping = A2AError( @@ -65,7 +65,7 @@ def test_build_error_response_with_a2a_wrapping_jsonrpc_error(self): self.assertEqual(response_wrapper.root.id, request_id) self.assertEqual(response_wrapper.root.error, specific_jsonrpc_error) - def test_build_error_response_with_request_id_string(self): + def test_build_error_response_with_request_id_string(self) -> None: request_id = 'string_id_test' # Pass an A2AError-wrapped specific error for consistency with how build_error_response handles A2AError error = A2AError(root=TaskNotFoundError()) @@ -75,7 +75,7 @@ def test_build_error_response_with_request_id_string(self): self.assertIsInstance(response_wrapper.root, JSONRPCErrorResponse) self.assertEqual(response_wrapper.root.id, request_id) - def test_build_error_response_with_request_id_int(self): + def test_build_error_response_with_request_id_int(self) -> None: request_id = 456 error = A2AError(root=TaskNotFoundError()) response_wrapper = build_error_response( @@ -84,7 +84,7 @@ def test_build_error_response_with_request_id_int(self): self.assertIsInstance(response_wrapper.root, JSONRPCErrorResponse) self.assertEqual(response_wrapper.root.id, request_id) - def test_build_error_response_with_request_id_none(self): + def test_build_error_response_with_request_id_none(self) -> None: request_id = None error = A2AError(root=TaskNotFoundError()) response_wrapper = build_error_response( @@ -93,7 +93,9 @@ def test_build_error_response_with_request_id_none(self): self.assertIsInstance(response_wrapper.root, JSONRPCErrorResponse) self.assertIsNone(response_wrapper.root.id) - def _create_sample_task(self, task_id='task123', context_id='ctx456'): + def _create_sample_task( + self, task_id: str = 'task123', context_id: str = 'ctx456' + ) -> Task: return Task( id=task_id, context_id=context_id, @@ -101,7 +103,7 @@ def _create_sample_task(self, task_id='task123', context_id='ctx456'): history=[], ) - def test_prepare_response_object_successful_response(self): + def test_prepare_response_object_successful_response(self) -> None: request_id = 'req_success' task_result = self._create_sample_task() response_wrapper = prepare_response_object( @@ -119,7 +121,7 @@ def test_prepare_response_object_successful_response(self): @patch('a2a.server.request_handlers.response_helpers.build_error_response') def test_prepare_response_object_with_a2a_error_instance( self, mock_build_error - ): + ) -> None: request_id = 'req_a2a_err' specific_error = TaskNotFoundError() a2a_error_instance = A2AError( @@ -150,7 +152,7 @@ def test_prepare_response_object_with_a2a_error_instance( @patch('a2a.server.request_handlers.response_helpers.build_error_response') def test_prepare_response_object_with_jsonrpcerror_base_instance( self, mock_build_error - ): + ) -> None: request_id = 789 # Use the base JSONRPCError class instance json_rpc_base_error = JSONRPCError( @@ -180,7 +182,7 @@ def test_prepare_response_object_with_jsonrpcerror_base_instance( @patch('a2a.server.request_handlers.response_helpers.build_error_response') def test_prepare_response_object_specific_error_model_as_unexpected( self, mock_build_error - ): + ) -> None: request_id = 'req_specific_unexpected' # Pass a specific error model (like TaskNotFoundError) directly, NOT wrapped in A2AError # This should be treated as an "unexpected" type by prepare_response_object's current logic @@ -219,7 +221,7 @@ def test_prepare_response_object_specific_error_model_as_unexpected( self.assertEqual(args[2], GetTaskResponse) self.assertEqual(response_wrapper, mock_final_wrapped_response) - def test_prepare_response_object_with_request_id_string(self): + def test_prepare_response_object_with_request_id_string(self) -> None: request_id = 'string_id_prep' task_result = self._create_sample_task() response_wrapper = prepare_response_object( @@ -232,7 +234,7 @@ def test_prepare_response_object_with_request_id_string(self): self.assertIsInstance(response_wrapper.root, GetTaskSuccessResponse) self.assertEqual(response_wrapper.root.id, request_id) - def test_prepare_response_object_with_request_id_int(self): + def test_prepare_response_object_with_request_id_int(self) -> None: request_id = 101112 task_result = self._create_sample_task() response_wrapper = prepare_response_object( @@ -245,7 +247,7 @@ def test_prepare_response_object_with_request_id_int(self): self.assertIsInstance(response_wrapper.root, GetTaskSuccessResponse) self.assertEqual(response_wrapper.root.id, request_id) - def test_prepare_response_object_with_request_id_none(self): + def test_prepare_response_object_with_request_id_none(self) -> None: request_id = None task_result = self._create_sample_task() response_wrapper = prepare_response_object( diff --git a/tests/server/tasks/test_inmemory_push_notifications.py b/tests/server/tasks/test_inmemory_push_notifications.py index 93baf0d38..375ed97ca 100644 --- a/tests/server/tasks/test_inmemory_push_notifications.py +++ b/tests/server/tasks/test_inmemory_push_notifications.py @@ -17,7 +17,9 @@ # logging.disable(logging.CRITICAL) -def create_sample_task(task_id='task123', status_state=TaskState.completed): +def create_sample_task( + task_id: str = 'task123', status_state: TaskState = TaskState.completed +) -> Task: return Task( id=task_id, context_id='ctx456', @@ -26,23 +28,25 @@ def create_sample_task(task_id='task123', status_state=TaskState.completed): def create_sample_push_config( - url='http://example.com/callback', config_id='cfg1', token=None -): + url: str = 'http://example.com/callback', + config_id: str = 'cfg1', + token: str | None = None, +) -> PushNotificationConfig: return PushNotificationConfig(id=config_id, url=url, token=token) class TestInMemoryPushNotifier(unittest.IsolatedAsyncioTestCase): - def setUp(self): + def setUp(self) -> None: self.mock_httpx_client = AsyncMock(spec=httpx.AsyncClient) self.config_store = InMemoryPushNotificationConfigStore() self.notifier = BasePushNotificationSender( httpx_client=self.mock_httpx_client, config_store=self.config_store ) # Corrected argument name - def test_constructor_stores_client(self): + def test_constructor_stores_client(self) -> None: self.assertEqual(self.notifier._client, self.mock_httpx_client) - async def test_set_info_adds_new_config(self): + async def test_set_info_adds_new_config(self) -> None: task_id = 'task_new' config = create_sample_push_config(url='http://new.url/callback') @@ -53,7 +57,7 @@ async def test_set_info_adds_new_config(self): self.config_store._push_notification_infos[task_id], [config] ) - async def test_set_info_appends_to_existing_config(self): + async def test_set_info_appends_to_existing_config(self) -> None: task_id = 'task_update' initial_config = create_sample_push_config( url='http://initial.url/callback', config_id='cfg_initial' @@ -75,7 +79,7 @@ async def test_set_info_appends_to_existing_config(self): updated_config, ) - async def test_set_info_without_config_id(self): + async def test_set_info_without_config_id(self) -> None: task_id = 'task1' initial_config = PushNotificationConfig( url='http://initial.url/callback' @@ -98,7 +102,7 @@ async def test_set_info_without_config_id(self): updated_config.url, ) - async def test_get_info_existing_config(self): + async def test_get_info_existing_config(self) -> None: task_id = 'task_get_exist' config = create_sample_push_config(url='http://get.this/callback') await self.config_store.set_info(task_id, config) @@ -106,12 +110,12 @@ async def test_get_info_existing_config(self): retrieved_config = await self.config_store.get_info(task_id) self.assertEqual(retrieved_config, [config]) - async def test_get_info_non_existent_config(self): + async def test_get_info_non_existent_config(self) -> None: task_id = 'task_get_non_exist' retrieved_config = await self.config_store.get_info(task_id) assert retrieved_config == [] - async def test_delete_info_existing_config(self): + async def test_delete_info_existing_config(self) -> None: task_id = 'task_delete_exist' config = create_sample_push_config(url='http://delete.this/callback') await self.config_store.set_info(task_id, config) @@ -120,7 +124,7 @@ async def test_delete_info_existing_config(self): await self.config_store.delete_info(task_id, config_id=config.id) self.assertNotIn(task_id, self.config_store._push_notification_infos) - async def test_delete_info_non_existent_config(self): + async def test_delete_info_non_existent_config(self) -> None: task_id = 'task_delete_non_exist' # Ensure it doesn't raise an error try: @@ -133,7 +137,7 @@ async def test_delete_info_non_existent_config(self): task_id, self.config_store._push_notification_infos ) # Should still not be there - async def test_send_notification_success(self): + async def test_send_notification_success(self) -> None: task_id = 'task_send_success' task_data = create_sample_task(task_id=task_id) config = create_sample_push_config(url='http://notify.me/here') @@ -158,7 +162,7 @@ async def test_send_notification_success(self): ) # auth is not passed by current implementation mock_response.raise_for_status.assert_called_once() - async def test_send_notification_with_token_success(self): + async def test_send_notification_with_token_success(self) -> None: task_id = 'task_send_success' task_data = create_sample_task(task_id=task_id) config = create_sample_push_config( @@ -189,7 +193,7 @@ async def test_send_notification_with_token_success(self): ) # auth is not passed by current implementation mock_response.raise_for_status.assert_called_once() - async def test_send_notification_no_config(self): + async def test_send_notification_no_config(self) -> None: task_id = 'task_send_no_config' task_data = create_sample_task(task_id=task_id) @@ -200,7 +204,7 @@ async def test_send_notification_no_config(self): @patch('a2a.server.tasks.base_push_notification_sender.logger') async def test_send_notification_http_status_error( self, mock_logger: MagicMock - ): + ) -> None: task_id = 'task_send_http_err' task_data = create_sample_task(task_id=task_id) config = create_sample_push_config(url='http://notify.me/http_error') @@ -230,7 +234,7 @@ async def test_send_notification_http_status_error( @patch('a2a.server.tasks.base_push_notification_sender.logger') async def test_send_notification_request_error( self, mock_logger: MagicMock - ): + ) -> None: task_id = 'task_send_req_err' task_data = create_sample_task(task_id=task_id) config = create_sample_push_config(url='http://notify.me/req_error') @@ -249,7 +253,9 @@ async def test_send_notification_request_error( ) @patch('a2a.server.tasks.base_push_notification_sender.logger') - async def test_send_notification_with_auth(self, mock_logger: MagicMock): + async def test_send_notification_with_auth( + self, mock_logger: MagicMock + ) -> None: task_id = 'task_send_auth' task_data = create_sample_task(task_id=task_id) auth_info = ('user', 'pass') diff --git a/tests/server/tasks/test_push_notification_sender.py b/tests/server/tasks/test_push_notification_sender.py index fb3986702..a3272c2c1 100644 --- a/tests/server/tasks/test_push_notification_sender.py +++ b/tests/server/tasks/test_push_notification_sender.py @@ -15,7 +15,9 @@ ) -def create_sample_task(task_id='task123', status_state=TaskState.completed): +def create_sample_task( + task_id: str = 'task123', status_state: TaskState = TaskState.completed +) -> Task: return Task( id=task_id, context_id='ctx456', @@ -24,13 +26,15 @@ def create_sample_task(task_id='task123', status_state=TaskState.completed): def create_sample_push_config( - url='http://example.com/callback', config_id='cfg1', token=None -): + url: str = 'http://example.com/callback', + config_id: str = 'cfg1', + token: str | None = None, +) -> PushNotificationConfig: return PushNotificationConfig(id=config_id, url=url, token=token) class TestBasePushNotificationSender(unittest.IsolatedAsyncioTestCase): - def setUp(self): + def setUp(self) -> None: self.mock_httpx_client = AsyncMock(spec=httpx.AsyncClient) self.mock_config_store = AsyncMock() self.sender = BasePushNotificationSender( @@ -38,11 +42,11 @@ def setUp(self): config_store=self.mock_config_store, ) - def test_constructor_stores_client_and_config_store(self): + def test_constructor_stores_client_and_config_store(self) -> None: self.assertEqual(self.sender._client, self.mock_httpx_client) self.assertEqual(self.sender._config_store, self.mock_config_store) - async def test_send_notification_success(self): + async def test_send_notification_success(self) -> None: task_id = 'task_send_success' task_data = create_sample_task(task_id=task_id) config = create_sample_push_config(url='http://notify.me/here') @@ -64,7 +68,7 @@ async def test_send_notification_success(self): ) mock_response.raise_for_status.assert_called_once() - async def test_send_notification_with_token_success(self): + async def test_send_notification_with_token_success(self) -> None: task_id = 'task_send_success' task_data = create_sample_task(task_id=task_id) config = create_sample_push_config( @@ -88,7 +92,7 @@ async def test_send_notification_with_token_success(self): ) mock_response.raise_for_status.assert_called_once() - async def test_send_notification_no_config(self): + async def test_send_notification_no_config(self) -> None: task_id = 'task_send_no_config' task_data = create_sample_task(task_id=task_id) self.mock_config_store.get_info.return_value = [] @@ -101,7 +105,7 @@ async def test_send_notification_no_config(self): @patch('a2a.server.tasks.base_push_notification_sender.logger') async def test_send_notification_http_status_error( self, mock_logger: MagicMock - ): + ) -> None: task_id = 'task_send_http_err' task_data = create_sample_task(task_id=task_id) config = create_sample_push_config(url='http://notify.me/http_error') @@ -125,7 +129,7 @@ async def test_send_notification_http_status_error( ) mock_logger.exception.assert_called_once() - async def test_send_notification_multiple_configs(self): + async def test_send_notification_multiple_configs(self) -> None: task_id = 'task_multiple_configs' task_data = create_sample_task(task_id=task_id) config1 = create_sample_push_config( diff --git a/tests/server/tasks/test_result_aggregator.py b/tests/server/tasks/test_result_aggregator.py index da77e693c..bc970246b 100644 --- a/tests/server/tasks/test_result_aggregator.py +++ b/tests/server/tasks/test_result_aggregator.py @@ -4,6 +4,8 @@ from collections.abc import AsyncIterator from unittest.mock import AsyncMock, MagicMock, patch +from typing_extensions import override + from a2a.server.events.event_consumer import EventConsumer from a2a.server.tasks.result_aggregator import ResultAggregator from a2a.server.tasks.task_manager import TaskManager @@ -21,8 +23,8 @@ # Helper to create a simple message def create_sample_message( - content='test message', msg_id='msg1', role=Role.user -): + content: str = 'test message', msg_id: str = 'msg1', role: Role = Role.user +) -> Message: return Message( message_id=msg_id, role=role, @@ -32,8 +34,10 @@ def create_sample_message( # Helper to create a simple task def create_sample_task( - task_id='task1', status_state=TaskState.submitted, context_id='ctx1' -): + task_id: str = 'task1', + status_state: TaskState = TaskState.submitted, + context_id: str = 'ctx1', +) -> Task: return Task( id=task_id, context_id=context_id, @@ -43,8 +47,10 @@ def create_sample_task( # Helper to create a TaskStatusUpdateEvent def create_sample_status_update( - task_id='task1', status_state=TaskState.working, context_id='ctx1' -): + task_id: str = 'task1', + status_state: TaskState = TaskState.working, + context_id: str = 'ctx1', +) -> TaskStatusUpdateEvent: return TaskStatusUpdateEvent( task_id=task_id, context_id=context_id, @@ -54,7 +60,8 @@ def create_sample_status_update( class TestResultAggregator(unittest.IsolatedAsyncioTestCase): - def setUp(self): + @override + def setUp(self) -> None: self.mock_task_manager = AsyncMock(spec=TaskManager) self.mock_event_consumer = AsyncMock(spec=EventConsumer) self.aggregator = ResultAggregator( @@ -62,17 +69,17 @@ def setUp(self): # event_consumer is not passed to constructor ) - def test_init_stores_task_manager(self): + def test_init_stores_task_manager(self) -> None: self.assertEqual(self.aggregator.task_manager, self.mock_task_manager) # event_consumer is also stored, can be tested if needed, but focus is on task_manager per req. - async def test_current_result_property_with_message_set(self): + async def test_current_result_property_with_message_set(self) -> None: sample_message = create_sample_message(content='hola') self.aggregator._message = sample_message self.assertEqual(await self.aggregator.current_result, sample_message) self.mock_task_manager.get_task.assert_not_called() - async def test_current_result_property_with_message_none(self): + async def test_current_result_property_with_message_none(self) -> None: expected_task = create_sample_task(task_id='task_from_tm') self.mock_task_manager.get_task.return_value = expected_task self.aggregator._message = None @@ -82,7 +89,7 @@ async def test_current_result_property_with_message_none(self): self.assertEqual(current_res, expected_task) self.mock_task_manager.get_task.assert_called_once() - async def test_consume_and_emit(self): + async def test_consume_and_emit(self) -> None: event1 = create_sample_message(content='event one', msg_id='e1') event2 = create_sample_task( task_id='task_event', status_state=TaskState.working @@ -120,7 +127,7 @@ async def mock_consume_generator(): self.mock_task_manager.process.assert_any_call(event2) self.mock_task_manager.process.assert_any_call(event3) - async def test_consume_all_only_message_event(self): + async def test_consume_all_only_message_event(self) -> None: sample_message = create_sample_message(content='final message') async def mock_consume_generator(): @@ -136,7 +143,7 @@ async def mock_consume_generator(): self.mock_task_manager.process.assert_not_called() # Process is not called if message is returned directly self.mock_task_manager.get_task.assert_not_called() # Should not be called if message is returned - async def test_consume_all_other_event_types(self): + async def test_consume_all_other_event_types(self) -> None: task_event = create_sample_task(task_id='task_other_event') status_update_event = create_sample_status_update( task_id='task_other_event', status_state=TaskState.completed @@ -162,7 +169,7 @@ async def mock_consume_generator(): self.mock_task_manager.process.assert_any_call(status_update_event) self.mock_task_manager.get_task.assert_called_once() - async def test_consume_all_empty_stream(self): + async def test_consume_all_empty_stream(self) -> None: empty_task_state = create_sample_task(task_id='empty_stream_task') async def mock_consume_generator(): @@ -180,7 +187,7 @@ async def mock_consume_generator(): self.mock_task_manager.process.assert_not_called() self.mock_task_manager.get_task.assert_called_once() - async def test_consume_all_event_consumer_exception(self): + async def test_consume_all_event_consumer_exception(self) -> None: class TestException(Exception): pass @@ -206,7 +213,7 @@ async def raiser_gen(): ) self.mock_task_manager.get_task.assert_not_called() - async def test_consume_and_break_on_message(self): + async def test_consume_and_break_on_message(self) -> None: sample_message = create_sample_message(content='interrupt message') event_after = create_sample_task('task_after_msg') @@ -234,7 +241,7 @@ async def mock_consume_generator(): @patch('asyncio.create_task') async def test_consume_and_break_on_auth_required_task_event( self, mock_create_task: MagicMock - ): + ) -> None: auth_task = create_sample_task( task_id='auth_task', status_state=TaskState.auth_required ) @@ -286,7 +293,7 @@ async def mock_consume_generator(): @patch('asyncio.create_task') async def test_consume_and_break_on_auth_required_status_update_event( self, mock_create_task: MagicMock - ): + ) -> None: auth_status_update = create_sample_status_update( task_id='auth_status_task', status_state=TaskState.auth_required ) @@ -325,7 +332,7 @@ async def mock_consume_generator(): self.aggregator._continue_consuming.call_args[0][0], AsyncIterator ) - async def test_consume_and_break_completes_normally(self): + async def test_consume_and_break_completes_normally(self) -> None: event1 = create_sample_message('event one normal', msg_id='n1') event2 = create_sample_task('normal_task') final_task_state = create_sample_task( @@ -357,7 +364,7 @@ async def mock_consume_generator(): self.mock_task_manager.process.assert_not_called() self.mock_task_manager.get_task.assert_not_called() - async def test_consume_and_break_event_consumer_exception(self): + async def test_consume_and_break_event_consumer_exception(self) -> None: class TestInterruptException(Exception): pass @@ -387,7 +394,7 @@ async def raiser_gen_interrupt(): @patch('asyncio.create_task') async def test_consume_and_break_non_blocking( self, mock_create_task: MagicMock - ): + ) -> None: """Test that with blocking=False, the method returns after the first event.""" first_event = create_sample_task('non_blocking_task') event_after = create_sample_message('should be consumed later') @@ -425,7 +432,7 @@ async def mock_consume_generator(): @patch('asyncio.create_task') # To verify _continue_consuming is called async def test_continue_consuming_processes_remaining_events( self, mock_create_task: MagicMock - ): + ) -> None: # This test focuses on verifying that if an interrupt occurs, # the events *after* the interrupting one are processed by _continue_consuming. diff --git a/tests/server/tasks/test_task_updater.py b/tests/server/tasks/test_task_updater.py index a8de65e33..891f8a10b 100644 --- a/tests/server/tasks/test_task_updater.py +++ b/tests/server/tasks/test_task_updater.py @@ -20,13 +20,13 @@ @pytest.fixture -def event_queue(): +def event_queue() -> AsyncMock: """Create a mock event queue for testing.""" return AsyncMock(spec=EventQueue) @pytest.fixture -def task_updater(event_queue): +def task_updater(event_queue: AsyncMock) -> TaskUpdater: """Create a TaskUpdater instance for testing.""" return TaskUpdater( event_queue=event_queue, @@ -36,7 +36,7 @@ def task_updater(event_queue): @pytest.fixture -def sample_message(): +def sample_message() -> Message: """Create a sample message for testing.""" return Message( role=Role.agent, @@ -48,12 +48,12 @@ def sample_message(): @pytest.fixture -def sample_parts(): +def sample_parts() -> list[Part]: """Create sample parts for testing.""" return [Part(root=TextPart(text='Test part'))] -def test_init(event_queue): +def test_init(event_queue: AsyncMock) -> None: """Test that TaskUpdater initializes correctly.""" task_updater = TaskUpdater( event_queue=event_queue, @@ -67,7 +67,9 @@ def test_init(event_queue): @pytest.mark.asyncio -async def test_update_status_without_message(task_updater, event_queue): +async def test_update_status_without_message( + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: """Test updating status without a message.""" await task_updater.update_status(TaskState.working) @@ -84,8 +86,8 @@ async def test_update_status_without_message(task_updater, event_queue): @pytest.mark.asyncio async def test_update_status_with_message( - task_updater, event_queue, sample_message -): + task_updater: TaskUpdater, event_queue: AsyncMock, sample_message: Message +) -> None: """Test updating status with a message.""" await task_updater.update_status(TaskState.working, message=sample_message) @@ -101,7 +103,9 @@ async def test_update_status_with_message( @pytest.mark.asyncio -async def test_update_status_final(task_updater, event_queue): +async def test_update_status_final( + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: """Test updating status with final=True.""" await task_updater.update_status(TaskState.completed, final=True) @@ -115,8 +119,8 @@ async def test_update_status_final(task_updater, event_queue): @pytest.mark.asyncio async def test_add_artifact_with_custom_id_and_name( - task_updater, event_queue, sample_parts -): + task_updater: TaskUpdater, event_queue: AsyncMock, sample_parts: list[Part] +) -> None: """Test adding an artifact with a custom ID and name.""" await task_updater.add_artifact( parts=sample_parts, @@ -135,8 +139,8 @@ async def test_add_artifact_with_custom_id_and_name( @pytest.mark.asyncio async def test_add_artifact_generates_id( - task_updater, event_queue, sample_parts -): + task_updater: TaskUpdater, event_queue: AsyncMock, sample_parts: list[Part] +) -> None: """Test add_artifact generates an ID if artifact_id is None.""" known_uuid = uuid.UUID('12345678-1234-5678-1234-567812345678') with patch('uuid.uuid4', return_value=known_uuid): @@ -153,7 +157,9 @@ async def test_add_artifact_generates_id( @pytest.mark.asyncio -async def test_add_artifact_generates_custom_id(event_queue, sample_parts): +async def test_add_artifact_generates_custom_id( + event_queue: AsyncMock, sample_parts: list[Part] +) -> None: """Test add_artifact uses a custom ID generator when provided.""" artifact_id_generator = Mock(spec=IDGenerator) artifact_id_generator.generate.return_value = 'custom-artifact-id' @@ -183,8 +189,12 @@ async def test_add_artifact_generates_custom_id(event_queue, sample_parts): ], ) async def test_add_artifact_with_append_last_chunk( - task_updater, event_queue, sample_parts, append_val, last_chunk_val -): + task_updater: TaskUpdater, + event_queue: AsyncMock, + sample_parts: list[Part], + append_val: bool, + last_chunk_val: bool, +) -> None: """Test add_artifact with append and last_chunk flags.""" await task_updater.add_artifact( parts=sample_parts, @@ -204,7 +214,9 @@ async def test_add_artifact_with_append_last_chunk( @pytest.mark.asyncio -async def test_complete_without_message(task_updater, event_queue): +async def test_complete_without_message( + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: """Test marking a task as completed without a message.""" await task_updater.complete() @@ -218,7 +230,9 @@ async def test_complete_without_message(task_updater, event_queue): @pytest.mark.asyncio -async def test_complete_with_message(task_updater, event_queue, sample_message): +async def test_complete_with_message( + task_updater: TaskUpdater, event_queue: AsyncMock, sample_message: Message +) -> None: """Test marking a task as completed with a message.""" await task_updater.complete(message=sample_message) @@ -232,7 +246,9 @@ async def test_complete_with_message(task_updater, event_queue, sample_message): @pytest.mark.asyncio -async def test_submit_without_message(task_updater, event_queue): +async def test_submit_without_message( + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: """Test marking a task as submitted without a message.""" await task_updater.submit() @@ -246,7 +262,9 @@ async def test_submit_without_message(task_updater, event_queue): @pytest.mark.asyncio -async def test_submit_with_message(task_updater, event_queue, sample_message): +async def test_submit_with_message( + task_updater: TaskUpdater, event_queue: AsyncMock, sample_message: Message +) -> None: """Test marking a task as submitted with a message.""" await task_updater.submit(message=sample_message) @@ -260,7 +278,9 @@ async def test_submit_with_message(task_updater, event_queue, sample_message): @pytest.mark.asyncio -async def test_start_work_without_message(task_updater, event_queue): +async def test_start_work_without_message( + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: """Test marking a task as working without a message.""" await task_updater.start_work() @@ -275,8 +295,8 @@ async def test_start_work_without_message(task_updater, event_queue): @pytest.mark.asyncio async def test_start_work_with_message( - task_updater, event_queue, sample_message -): + task_updater: TaskUpdater, event_queue: AsyncMock, sample_message: Message +) -> None: """Test marking a task as working with a message.""" await task_updater.start_work(message=sample_message) @@ -289,7 +309,9 @@ async def test_start_work_with_message( assert event.status.message == sample_message -def test_new_agent_message(task_updater, sample_parts): +def test_new_agent_message( + task_updater: TaskUpdater, sample_parts: list[Part] +) -> None: """Test creating a new agent message.""" with patch( 'uuid.uuid4', @@ -305,7 +327,9 @@ def test_new_agent_message(task_updater, sample_parts): assert message.metadata is None -def test_new_agent_message_with_metadata(task_updater, sample_parts): +def test_new_agent_message_with_metadata( + task_updater: TaskUpdater, sample_parts: list[Part] +) -> None: """Test creating a new agent message with metadata and final=True.""" metadata = {'key': 'value'} @@ -325,7 +349,9 @@ def test_new_agent_message_with_metadata(task_updater, sample_parts): assert message.metadata == metadata -def test_new_agent_message_with_custom_id_generator(event_queue, sample_parts): +def test_new_agent_message_with_custom_id_generator( + event_queue: AsyncMock, sample_parts: list[Part] +) -> None: """Test creating a new agent message with a custom message ID generator.""" message_id_generator = Mock(spec=IDGenerator) message_id_generator.generate.return_value = 'custom-message-id' @@ -342,7 +368,9 @@ def test_new_agent_message_with_custom_id_generator(event_queue, sample_parts): @pytest.mark.asyncio -async def test_failed_without_message(task_updater, event_queue): +async def test_failed_without_message( + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: """Test marking a task as failed without a message.""" await task_updater.failed() @@ -356,7 +384,9 @@ async def test_failed_without_message(task_updater, event_queue): @pytest.mark.asyncio -async def test_failed_with_message(task_updater, event_queue, sample_message): +async def test_failed_with_message( + task_updater: TaskUpdater, event_queue: AsyncMock, sample_message: Message +) -> None: """Test marking a task as failed with a message.""" await task_updater.failed(message=sample_message) @@ -370,7 +400,9 @@ async def test_failed_with_message(task_updater, event_queue, sample_message): @pytest.mark.asyncio -async def test_reject_without_message(task_updater, event_queue): +async def test_reject_without_message( + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: """Test marking a task as rejected without a message.""" await task_updater.reject() @@ -384,7 +416,9 @@ async def test_reject_without_message(task_updater, event_queue): @pytest.mark.asyncio -async def test_reject_with_message(task_updater, event_queue, sample_message): +async def test_reject_with_message( + task_updater: TaskUpdater, event_queue: AsyncMock, sample_message: Message +) -> None: """Test marking a task as rejected with a message.""" await task_updater.reject(message=sample_message) @@ -398,7 +432,9 @@ async def test_reject_with_message(task_updater, event_queue, sample_message): @pytest.mark.asyncio -async def test_requires_input_without_message(task_updater, event_queue): +async def test_requires_input_without_message( + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: """Test marking a task as input required without a message.""" await task_updater.requires_input() @@ -413,8 +449,8 @@ async def test_requires_input_without_message(task_updater, event_queue): @pytest.mark.asyncio async def test_requires_input_with_message( - task_updater, event_queue, sample_message -): + task_updater: TaskUpdater, event_queue: AsyncMock, sample_message: Message +) -> None: """Test marking a task as input required with a message.""" await task_updater.requires_input(message=sample_message) @@ -428,7 +464,9 @@ async def test_requires_input_with_message( @pytest.mark.asyncio -async def test_requires_input_final_true(task_updater, event_queue): +async def test_requires_input_final_true( + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: """Test marking a task as input required with final=True.""" await task_updater.requires_input(final=True) @@ -443,8 +481,8 @@ async def test_requires_input_final_true(task_updater, event_queue): @pytest.mark.asyncio async def test_requires_input_with_message_and_final( - task_updater, event_queue, sample_message -): + task_updater: TaskUpdater, event_queue: AsyncMock, sample_message: Message +) -> None: """Test marking a task as input required with message and final=True.""" await task_updater.requires_input(message=sample_message, final=True) @@ -458,7 +496,9 @@ async def test_requires_input_with_message_and_final( @pytest.mark.asyncio -async def test_requires_auth_without_message(task_updater, event_queue): +async def test_requires_auth_without_message( + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: """Test marking a task as auth required without a message.""" await task_updater.requires_auth() @@ -473,8 +513,8 @@ async def test_requires_auth_without_message(task_updater, event_queue): @pytest.mark.asyncio async def test_requires_auth_with_message( - task_updater, event_queue, sample_message -): + task_updater: TaskUpdater, event_queue: AsyncMock, sample_message: Message +) -> None: """Test marking a task as auth required with a message.""" await task_updater.requires_auth(message=sample_message) @@ -488,7 +528,9 @@ async def test_requires_auth_with_message( @pytest.mark.asyncio -async def test_requires_auth_final_true(task_updater, event_queue): +async def test_requires_auth_final_true( + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: """Test marking a task as auth required with final=True.""" await task_updater.requires_auth(final=True) @@ -503,8 +545,8 @@ async def test_requires_auth_final_true(task_updater, event_queue): @pytest.mark.asyncio async def test_requires_auth_with_message_and_final( - task_updater, event_queue, sample_message -): + task_updater: TaskUpdater, event_queue: AsyncMock, sample_message: Message +) -> None: """Test marking a task as auth required with message and final=True.""" await task_updater.requires_auth(message=sample_message, final=True) @@ -518,7 +560,9 @@ async def test_requires_auth_with_message_and_final( @pytest.mark.asyncio -async def test_cancel_without_message(task_updater, event_queue): +async def test_cancel_without_message( + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: """Test marking a task as cancelled without a message.""" await task_updater.cancel() @@ -532,7 +576,9 @@ async def test_cancel_without_message(task_updater, event_queue): @pytest.mark.asyncio -async def test_cancel_with_message(task_updater, event_queue, sample_message): +async def test_cancel_with_message( + task_updater: TaskUpdater, event_queue: AsyncMock, sample_message: Message +) -> None: """Test marking a task as cancelled with a message.""" await task_updater.cancel(message=sample_message) @@ -547,8 +593,8 @@ async def test_cancel_with_message(task_updater, event_queue, sample_message): @pytest.mark.asyncio async def test_update_status_raises_error_if_terminal_state_reached( - task_updater, event_queue -): + task_updater: TaskUpdater, event_queue: AsyncMock +) -> None: await task_updater.complete() event_queue.reset_mock() with pytest.raises(RuntimeError): @@ -557,7 +603,9 @@ async def test_update_status_raises_error_if_terminal_state_reached( @pytest.mark.asyncio -async def test_concurrent_updates_race_condition(event_queue): +async def test_concurrent_updates_race_condition( + event_queue: AsyncMock, +) -> None: task_updater = TaskUpdater( event_queue=event_queue, task_id='test-task-id', @@ -576,7 +624,9 @@ async def test_concurrent_updates_race_condition(event_queue): @pytest.mark.asyncio -async def test_reject_concurrently_with_complete(event_queue): +async def test_reject_concurrently_with_complete( + event_queue: AsyncMock, +) -> None: """Test for race conditions when reject and complete are called concurrently.""" task_updater = TaskUpdater( event_queue=event_queue, diff --git a/tests/utils/test_telemetry.py b/tests/utils/test_telemetry.py index 5109379b4..eae96b190 100644 --- a/tests/utils/test_telemetry.py +++ b/tests/utils/test_telemetry.py @@ -1,6 +1,7 @@ import asyncio -from typing import NoReturn +from collections.abc import Generator +from typing import Any, NoReturn from unittest import mock import pytest @@ -9,12 +10,12 @@ @pytest.fixture -def mock_span(): +def mock_span() -> mock.MagicMock: return mock.MagicMock() @pytest.fixture -def mock_tracer(mock_span): +def mock_tracer(mock_span: mock.MagicMock) -> mock.MagicMock: tracer = mock.MagicMock() tracer.start_as_current_span.return_value.__enter__.return_value = mock_span tracer.start_as_current_span.return_value.__exit__.return_value = False @@ -22,12 +23,14 @@ def mock_tracer(mock_span): @pytest.fixture(autouse=True) -def patch_trace_get_tracer(mock_tracer): +def patch_trace_get_tracer( + mock_tracer: mock.MagicMock, +) -> Generator[None, Any, None]: with mock.patch('opentelemetry.trace.get_tracer', return_value=mock_tracer): yield -def test_trace_function_sync_success(mock_span): +def test_trace_function_sync_success(mock_span: mock.MagicMock) -> None: @trace_function def foo(x, y): return x + y @@ -39,7 +42,7 @@ def foo(x, y): mock_span.record_exception.assert_not_called() -def test_trace_function_sync_exception(mock_span): +def test_trace_function_sync_exception(mock_span: mock.MagicMock) -> None: @trace_function def bar() -> NoReturn: raise ValueError('fail') @@ -50,7 +53,9 @@ def bar() -> NoReturn: mock_span.set_status.assert_any_call(mock.ANY, description='fail') -def test_trace_function_sync_attribute_extractor_called(mock_span): +def test_trace_function_sync_attribute_extractor_called( + mock_span: mock.MagicMock, +) -> None: called = {} def attr_extractor(span, args, kwargs, result, exception) -> None: @@ -67,7 +72,9 @@ def foo() -> int: assert called['called'] -def test_trace_function_sync_attribute_extractor_error_logged(mock_span): +def test_trace_function_sync_attribute_extractor_error_logged( + mock_span: mock.MagicMock, +) -> None: with mock.patch('a2a.utils.telemetry.logger') as logger: def attr_extractor(span, args, kwargs, result, exception) -> NoReturn: @@ -85,7 +92,7 @@ def foo() -> int: @pytest.mark.asyncio -async def test_trace_function_async_success(mock_span): +async def test_trace_function_async_success(mock_span: mock.MagicMock) -> None: @trace_function async def foo(x): await asyncio.sleep(0) @@ -98,7 +105,9 @@ async def foo(x): @pytest.mark.asyncio -async def test_trace_function_async_exception(mock_span): +async def test_trace_function_async_exception( + mock_span: mock.MagicMock, +) -> None: @trace_function async def bar() -> NoReturn: await asyncio.sleep(0) @@ -111,7 +120,9 @@ async def bar() -> NoReturn: @pytest.mark.asyncio -async def test_trace_function_async_attribute_extractor_called(mock_span): +async def test_trace_function_async_attribute_extractor_called( + mock_span: mock.MagicMock, +) -> None: called = {} def attr_extractor(span, args, kwargs, result, exception) -> None: @@ -127,7 +138,9 @@ async def foo() -> int: assert called['called'] -def test_trace_function_with_args_and_attributes(mock_span): +def test_trace_function_with_args_and_attributes( + mock_span: mock.MagicMock, +) -> None: @trace_function(span_name='custom.span', attributes={'foo': 'bar'}) def foo() -> int: return 1 @@ -136,7 +149,7 @@ def foo() -> int: mock_span.set_attribute.assert_any_call('foo', 'bar') -def test_trace_class_exclude_list(mock_span): +def test_trace_class_exclude_list(mock_span: mock.MagicMock) -> None: @trace_class(exclude_list=['skip_me']) class MyClass: def a(self) -> str: @@ -145,7 +158,7 @@ def a(self) -> str: def skip_me(self) -> str: return 'skip' - def __str__(self): + def __str__(self) -> str: return 'str' obj = MyClass() @@ -156,7 +169,7 @@ def __str__(self): assert not hasattr(obj.skip_me, '__wrapped__') -def test_trace_class_include_list(mock_span): +def test_trace_class_include_list(mock_span: mock.MagicMock) -> None: @trace_class(include_list=['only_this']) class MyClass: def only_this(self) -> str: @@ -172,10 +185,10 @@ def not_this(self) -> str: assert not hasattr(obj.not_this, '__wrapped__') -def test_trace_class_dunder_not_traced(mock_span): +def test_trace_class_dunder_not_traced(mock_span: mock.MagicMock) -> None: @trace_class() class MyClass: - def __init__(self): + def __init__(self) -> None: self.x = 1 def foo(self) -> str: From 12b4a1d565a53794f5b55c8bd1728221c906ed41 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Fri, 7 Nov 2025 11:56:53 +0100 Subject: [PATCH 052/384] feat: add metadata to send message request (#532) Extended `client.send_message` to take `metadata` parameter which gets attached to `MessageSendParams`. --- src/a2a/client/base_client.py | 7 ++++++- src/a2a/client/client.py | 1 + tests/client/test_base_client.py | 12 ++++++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/a2a/client/base_client.py b/src/a2a/client/base_client.py index f4a8d03de..b8697d860 100644 --- a/src/a2a/client/base_client.py +++ b/src/a2a/client/base_client.py @@ -1,4 +1,5 @@ from collections.abc import AsyncIterator +from typing import Any from a2a.client.client import ( Client, @@ -47,6 +48,7 @@ async def send_message( request: Message, *, context: ClientCallContext | None = None, + request_metadata: dict[str, Any] | None = None, ) -> AsyncIterator[ClientEvent | Message]: """Sends a message to the agent. @@ -57,6 +59,7 @@ async def send_message( Args: request: The message to send to the agent. context: The client call context. + request_metadata: Extensions Metadata attached to the request. Yields: An async iterator of `ClientEvent` or a final `Message` response. @@ -70,7 +73,9 @@ async def send_message( else None ), ) - params = MessageSendParams(message=request, configuration=config) + params = MessageSendParams( + message=request, configuration=config, metadata=request_metadata + ) if not self._config.streaming or not self._card.capabilities.streaming: response = await self._transport.send_message( diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py index 7cc10423d..0e1c43237 100644 --- a/src/a2a/client/client.py +++ b/src/a2a/client/client.py @@ -110,6 +110,7 @@ async def send_message( request: Message, *, context: ClientCallContext | None = None, + request_metadata: dict[str, Any] | None = None, ) -> AsyncIterator[ClientEvent | Message]: """Sends a message to the server. diff --git a/tests/client/test_base_client.py b/tests/client/test_base_client.py index d93a22030..f5ab25432 100644 --- a/tests/client/test_base_client.py +++ b/tests/client/test_base_client.py @@ -73,9 +73,14 @@ async def create_stream(*args, **kwargs): mock_transport.send_message_streaming.return_value = create_stream() - events = [event async for event in base_client.send_message(sample_message)] + meta = {'test': 1} + stream = base_client.send_message(sample_message, request_metadata=meta) + events = [event async for event in stream] mock_transport.send_message_streaming.assert_called_once() + assert ( + mock_transport.send_message_streaming.call_args[0][0].metadata == meta + ) assert not mock_transport.send_message.called assert len(events) == 1 assert events[0][0].id == 'task-123' @@ -92,9 +97,12 @@ async def test_send_message_non_streaming( status=TaskStatus(state=TaskState.completed), ) - events = [event async for event in base_client.send_message(sample_message)] + meta = {'test': 1} + stream = base_client.send_message(sample_message, request_metadata=meta) + events = [event async for event in stream] mock_transport.send_message.assert_called_once() + assert mock_transport.send_message.call_args[0][0].metadata == meta assert not mock_transport.send_message_streaming.called assert len(events) == 1 assert events[0][0].id == 'task-456' From 265fd3325e1c5a3e945291b65b159282c2b84255 Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Fri, 7 Nov 2025 05:04:43 -0600 Subject: [PATCH 053/384] chore(main): release 0.3.11 (#534) :robot: I have created a release *beep* *boop* --- ## [0.3.11](https://github.com/a2aproject/a2a-python/compare/v0.3.10...v0.3.11) (2025-11-07) ### Bug Fixes * add metadata to send message request ([12b4a1d](https://github.com/a2aproject/a2a-python/commit/12b4a1d565a53794f5b55c8bd1728221c906ed41)) --- 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 449438cc7..39446e61c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.3.11](https://github.com/a2aproject/a2a-python/compare/v0.3.10...v0.3.11) (2025-11-07) + + +### Bug Fixes + +* add metadata to send message request ([12b4a1d](https://github.com/a2aproject/a2a-python/commit/12b4a1d565a53794f5b55c8bd1728221c906ed41)) + ## [0.3.10](https://github.com/a2aproject/a2a-python/compare/v0.3.9...v0.3.10) (2025-10-21) From c03129b99a663ae1f1ae72f20e4ead7807ede941 Mon Sep 17 00:00:00 2001 From: "agil.yolchuyev" Date: Mon, 10 Nov 2025 16:53:23 -0500 Subject: [PATCH 054/384] fix(grpc): Add `extensions` to `Artifact` converters. (#523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description 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: - [ ] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [ ] 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. - [ ] Ensure the tests and linter pass (Run `bash scripts/format.sh` from the repository root to format) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 --------- Co-authored-by: yolagil Co-authored-by: Lukasz Kawka --- src/a2a/utils/proto_utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index e619cd72c..d077d62bf 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -57,7 +57,7 @@ def make_dict_serializable(value: Any) -> Any: Returns: A serializable value. """ - if isinstance(value, (str, int, float, bool)) or value is None: + if isinstance(value, str | int | float | bool) or value is None: return value if isinstance(value, dict): return {k: make_dict_serializable(v) for k, v in value.items()} @@ -140,6 +140,7 @@ def message(cls, message: types.Message | None) -> a2a_pb2.Message | None: task_id=message.task_id or '', role=cls.role(message.role), metadata=cls.metadata(message.metadata), + extensions=message.extensions or [], ) @classmethod @@ -239,6 +240,7 @@ def artifact(cls, artifact: types.Artifact) -> a2a_pb2.Artifact: metadata=cls.metadata(artifact.metadata), name=artifact.name, parts=[cls.part(p) for p in artifact.parts], + extensions=artifact.extensions or [], ) @classmethod @@ -695,6 +697,7 @@ def artifact(cls, artifact: a2a_pb2.Artifact) -> types.Artifact: metadata=cls.metadata(artifact.metadata), name=artifact.name, parts=[cls.part(p) for p in artifact.parts], + extensions=artifact.extensions or None, ) @classmethod From dc59430b7f608ffb681a87f5fbf0bf01bd852206 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:39:00 +0000 Subject: [PATCH 055/384] chore(deps): bump the github-actions group with 3 updates (#528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the github-actions group with 3 updates: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv), [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact). Updates `astral-sh/setup-uv` from 6 to 7
Release notes

Sourced from astral-sh/setup-uv's releases.

v7.0.0 🌈 node24 and a lot of bugfixes

Changes

This release comes with a load of bug fixes and a speed up. Because of switching from node20 to node24 it is also a breaking change. If you are running on GitHub hosted runners this will just work, if you are using self-hosted runners make sure, that your runners are up to date. If you followed the normal installation instructions your self-hosted runner will keep itself updated.

This release also removes the deprecated input server-url which was used to download uv releases from a different server. The manifest-file input supersedes that functionality by adding a flexible way to define available versions and where they should be downloaded from.

Fixes

  • The action now respects when the environment variable UV_CACHE_DIR is already set and does not overwrite it. It now also finds cache-dir settings in config files if you set them.
  • Some users encountered problems that cache pruning took forever because they had some uv processes running in the background. Starting with uv version 0.8.24 this action uses uv cache prune --ci --force to ignore the running processes
  • If you just want to install uv but not have it available in path, this action now respects UV_NO_MODIFY_PATH
  • Some other actions also set the env var UV_CACHE_DIR. This action can now deal with that but as this could lead to unwanted behavior in some edgecases a warning is now displayed.

Improvements

If you are using minimum version specifiers for the version of uv to install for example

[tool.uv]
required-version = ">=0.8.17"

This action now detects that and directly uses the latest version. Previously it would download all available releases from the uv repo to determine the highest matching candidate for the version specifier, which took much more time.

If you are using other specifiers like 0.8.x this action still needs to download all available releases because the specifier defines an upper bound (not 0.9.0 or later) and "latest" would possibly not satisfy that.

🚨 Breaking changes

🐛 Bug fixes

🚀 Enhancements

🧰 Maintenance

... (truncated)

Commits
  • 8585678 Bump dependencies (#664)
  • 22d500a Bump github/codeql-action from 4.30.8 to 4.30.9 (#652)
  • 14d5571 chore: update known checksums for 0.9.5 (#663)
  • 29cd235 Use tar for extracting the uv zip file on Windows too (#660)
  • 2ddd2b9 chore: update known checksums for 0.9.4 (#651)
  • b7bf789 Fix "lowest" resolution strategy with lower-bound only (#649)
  • cb6c0a5 Change version in docs to v7 (#647)
  • dffc629 Use working-directory to detect empty workdir (#645)
  • 6e346e1 chore: update known checksums for 0.9.3 (#644)
  • 3ccd0fd Bump github/codeql-action from 4.30.7 to 4.30.8 (#639)
  • Additional commits viewable in compare view

Updates `actions/upload-artifact` from 4 to 5
Release notes

Sourced from actions/upload-artifact's releases.

v5.0.0

What's Changed

BREAKING CHANGE: this update supports Node v24.x. This is not a breaking change per-se but we're treating it as such.

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v5.0.0

v4.6.2

What's Changed

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.2

v4.6.1

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.1

v4.6.0

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.0

v4.5.0

What's Changed

New Contributors

... (truncated)

Commits
  • 330a01c Merge pull request #734 from actions/danwkennedy/prepare-5.0.0
  • 03f2824 Update github.dep.yml
  • 905a1ec Prepare v5.0.0
  • 2d9f9cd Merge pull request #725 from patrikpolyak/patch-1
  • 9687587 Merge branch 'main' into patch-1
  • 2848b2c Merge pull request #727 from danwkennedy/patch-1
  • 9b51177 Spell out the first use of GHES
  • cd231ca Update GHES guidance to include reference to Node 20 version
  • de65e23 Merge pull request #712 from actions/nebuk89-patch-1
  • 8747d8c Update README.md
  • Additional commits viewable in compare view

Updates `actions/download-artifact` from 5 to 6
Release notes

Sourced from actions/download-artifact's releases.

v6.0.0

What's Changed

BREAKING CHANGE: this update supports Node v24.x. This is not a breaking change per-se but we're treating it as such.

New Contributors

Full Changelog: https://github.com/actions/download-artifact/compare/v5...v6.0.0

Commits
  • 018cc2c Merge pull request #438 from actions/danwkennedy/prepare-6.0.0
  • 815651c Revert "Remove github.dep.yml"
  • bb3a066 Remove github.dep.yml
  • fa1ce46 Prepare v6.0.0
  • 4a24838 Merge pull request #431 from danwkennedy/patch-1
  • 5e3251c Readme: spell out the first use of GHES
  • abefc31 Merge pull request #424 from actions/yacaovsnc/update_readme
  • ac43a60 Update README with artifact extraction details
  • de96f46 Merge pull request #417 from actions/yacaovsnc/update_readme
  • 7993cb4 Remove migration guide for artifact download changes
  • Additional commits viewable in compare view

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: Holt Skinner <13262395+holtskinner@users.noreply.github.com> --- .github/workflows/linter.yaml | 2 +- .github/workflows/python-publish.yml | 6 +++--- .github/workflows/unit-tests.yml | 2 +- .github/workflows/update-a2a-types.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index a5e5da2ba..bdd4c5b8b 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -18,7 +18,7 @@ jobs: with: python-version-file: .python-version - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Add uv to PATH run: | echo "$HOME/.cargo/bin" >> $GITHUB_PATH diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 96e87d9e6..decb3b1d3 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: "Set up Python" uses: actions/setup-python@v6 @@ -26,7 +26,7 @@ jobs: run: uv build - name: Upload distributions - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: release-dists path: dist/ @@ -40,7 +40,7 @@ jobs: steps: - name: Retrieve release distributions - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: release-dists path: dist/ diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index ce8d62ab9..16052ba19 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -46,7 +46,7 @@ jobs: echo "MYSQL_TEST_DSN=mysql+aiomysql://a2a:a2a_password@localhost:3306/a2a_test" >> $GITHUB_ENV - name: Install uv for Python ${{ matrix.python-version }} - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} - name: Add uv to PATH diff --git a/.github/workflows/update-a2a-types.yml b/.github/workflows/update-a2a-types.yml index cb4071e76..c019afebc 100644 --- a/.github/workflows/update-a2a-types.yml +++ b/.github/workflows/update-a2a-types.yml @@ -18,7 +18,7 @@ jobs: with: python-version: '3.10' - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Configure uv shell run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH - name: Install dependencies (datamodel-code-generator) From 96d70e25f8d557e8fd146916d90d0af5c3589a38 Mon Sep 17 00:00:00 2001 From: Luca Muscariello Date: Wed, 12 Nov 2025 16:43:43 +0100 Subject: [PATCH 056/384] ci(scripts): add version support to generate_types.sh (#539) ## Description Adds version support to generate_types.sh to decouple the A2A specification repository from the SDK repository. ## Changes - Add A2A_SPEC_VERSION environment variable (default: v0.3.0) - Support --version flag for specifying versions (tags/branches/commits) - Add URL validation before generating types - Improve error messages and usage documentation ## Usage ```bash ./scripts/generate_types.sh --version v0.3.0 src/a2a/types.py A2A_SPEC_VERSION=main ./scripts/generate_types.sh src/a2a/types.py ``` ## Testing - 701 unit tests pass - Ruff and MyPy checks pass --------- Signed-off-by: Luca Muscariello Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- scripts/generate_types.sh | 89 ++++++++++++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 11 deletions(-) diff --git a/scripts/generate_types.sh b/scripts/generate_types.sh index b8d7dedfc..6c01cff57 100755 --- a/scripts/generate_types.sh +++ b/scripts/generate_types.sh @@ -4,7 +4,35 @@ # Treat unset variables as an error. set -euo pipefail -REMOTE_URL="https://raw.githubusercontent.com/a2aproject/A2A/refs/heads/main/specification/json/a2a.json" +# A2A specification version to use +# Can be overridden via environment variable: A2A_SPEC_VERSION=v1.2.0 ./generate_types.sh +# Or via command-line flag: ./generate_types.sh --version v1.2.0 output.py +# Use a specific git tag, branch name, or commit SHA +# Examples: "v1.0.0", "v1.2.0", "main", "abc123def" +A2A_SPEC_VERSION="${A2A_SPEC_VERSION:-v0.3.0}" + +# Build URL based on version format +# Tags use /refs/tags/, branches use /refs/heads/, commits use direct ref +build_remote_url() { + local version="$1" + local base_url="https://raw.githubusercontent.com/a2aproject/A2A" + local spec_path="specification/json/a2a.json" + local url_part + + if [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + # Looks like a version tag (v1.0.0, v1.2.3) + url_part="refs/tags/${version}" + elif [[ "$version" =~ ^[0-9a-f]{7,40}$ ]]; then + # Looks like a commit SHA (7+ hex chars) + url_part="${version}" + else + # Assume it's a branch name (main, develop, etc.) + url_part="refs/heads/${version}" + fi + echo "${base_url}/${url_part}/${spec_path}" +} + +REMOTE_URL=$(build_remote_url "$A2A_SPEC_VERSION") GENERATED_FILE="" INPUT_FILE="" @@ -12,20 +40,38 @@ INPUT_FILE="" # Parse command-line arguments while [[ $# -gt 0 ]]; do case "$1" in - --input-file) - INPUT_FILE="$2" - shift 2 - ;; - *) - GENERATED_FILE="$1" - shift 1 - ;; + --input-file) + INPUT_FILE="$2" + shift 2 + ;; + --version) + A2A_SPEC_VERSION="$2" + REMOTE_URL=$(build_remote_url "$A2A_SPEC_VERSION") + shift 2 + ;; + *) + GENERATED_FILE="$1" + shift 1 + ;; esac done if [ -z "$GENERATED_FILE" ]; then - echo "Error: Output file path must be provided." >&2 - echo "Usage: $0 [--input-file ] " + cat >&2 <] [--version ] +Options: + --input-file Use a local JSON schema file instead of fetching from remote + --version Specify A2A spec version (default: v0.3.0) + Can be a git tag (v1.0.0), branch (main), or commit SHA +Environment variables: + A2A_SPEC_VERSION Override default spec version +Examples: + $0 src/a2a/types.py + $0 --version v1.2.0 src/a2a/types.py + $0 --input-file local/a2a.json src/a2a/types.py + A2A_SPEC_VERSION=main $0 src/a2a/types.py +EOF exit 1 fi @@ -33,9 +79,30 @@ echo "Running datamodel-codegen..." declare -a source_args if [ -n "$INPUT_FILE" ]; then echo " - Source File: $INPUT_FILE" + if [ ! -f "$INPUT_FILE" ]; then + echo "Error: Input file does not exist: $INPUT_FILE" >&2 + exit 1 + fi source_args=("--input" "$INPUT_FILE") else + echo " - A2A Spec Version: $A2A_SPEC_VERSION" echo " - Source URL: $REMOTE_URL" + + # Validate that the remote URL is accessible + echo " - Validating remote URL..." + if ! curl --fail --silent --head "$REMOTE_URL" >/dev/null 2>&1; then + cat >&2 < Date: Wed, 12 Nov 2025 15:37:32 -0600 Subject: [PATCH 057/384] chore(main): release 0.3.12 (#538) :robot: I have created a release *beep* *boop* --- ## [0.3.12](https://github.com/a2aproject/a2a-python/compare/v0.3.11...v0.3.12) (2025-11-12) ### Bug Fixes * **grpc:** Add `extensions` to `Artifact` converters. ([#523](https://github.com/a2aproject/a2a-python/issues/523)) ([c03129b](https://github.com/a2aproject/a2a-python/commit/c03129b99a663ae1f1ae72f20e4ead7807ede941)) --- 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 39446e61c..d2f30a844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.3.12](https://github.com/a2aproject/a2a-python/compare/v0.3.11...v0.3.12) (2025-11-12) + + +### Bug Fixes + +* **grpc:** Add `extensions` to `Artifact` converters. ([#523](https://github.com/a2aproject/a2a-python/issues/523)) ([c03129b](https://github.com/a2aproject/a2a-python/commit/c03129b99a663ae1f1ae72f20e4ead7807ede941)) + ## [0.3.11](https://github.com/a2aproject/a2a-python/compare/v0.3.10...v0.3.11) (2025-11-07) From cbf5a095c678f65d558a889713e1e91eb889fc0f Mon Sep 17 00:00:00 2001 From: Lukasz Kawka Date: Thu, 13 Nov 2025 10:12:20 -0800 Subject: [PATCH 058/384] chore: Update 1.0-dev branch (#542) Signed-off-by: dependabot[bot] Signed-off-by: Luca Muscariello Co-authored-by: Yaroslav Co-authored-by: Agent2Agent (A2A) Bot Co-authored-by: agil.yolchuyev Co-authored-by: yolagil Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Co-authored-by: Luca Muscariello Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .github/workflows/linter.yaml | 2 +- .github/workflows/python-publish.yml | 6 +- .github/workflows/unit-tests.yml | 2 +- .github/workflows/update-a2a-types.yml | 2 +- CHANGELOG.md | 14 ++++ scripts/generate_types.sh | 89 ++++++++++++++++++++++---- src/a2a/client/base_client.py | 7 +- src/a2a/client/client.py | 1 + src/a2a/utils/proto_utils.py | 5 +- tests/client/test_base_client.py | 12 +++- 10 files changed, 119 insertions(+), 21 deletions(-) diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index a5e5da2ba..bdd4c5b8b 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -18,7 +18,7 @@ jobs: with: python-version-file: .python-version - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Add uv to PATH run: | echo "$HOME/.cargo/bin" >> $GITHUB_PATH diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 96e87d9e6..decb3b1d3 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: "Set up Python" uses: actions/setup-python@v6 @@ -26,7 +26,7 @@ jobs: run: uv build - name: Upload distributions - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: release-dists path: dist/ @@ -40,7 +40,7 @@ jobs: steps: - name: Retrieve release distributions - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: release-dists path: dist/ diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index ce8d62ab9..16052ba19 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -46,7 +46,7 @@ jobs: echo "MYSQL_TEST_DSN=mysql+aiomysql://a2a:a2a_password@localhost:3306/a2a_test" >> $GITHUB_ENV - name: Install uv for Python ${{ matrix.python-version }} - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} - name: Add uv to PATH diff --git a/.github/workflows/update-a2a-types.yml b/.github/workflows/update-a2a-types.yml index cb4071e76..c019afebc 100644 --- a/.github/workflows/update-a2a-types.yml +++ b/.github/workflows/update-a2a-types.yml @@ -18,7 +18,7 @@ jobs: with: python-version: '3.10' - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Configure uv shell run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH - name: Install dependencies (datamodel-code-generator) diff --git a/CHANGELOG.md b/CHANGELOG.md index 449438cc7..d2f30a844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.3.12](https://github.com/a2aproject/a2a-python/compare/v0.3.11...v0.3.12) (2025-11-12) + + +### Bug Fixes + +* **grpc:** Add `extensions` to `Artifact` converters. ([#523](https://github.com/a2aproject/a2a-python/issues/523)) ([c03129b](https://github.com/a2aproject/a2a-python/commit/c03129b99a663ae1f1ae72f20e4ead7807ede941)) + +## [0.3.11](https://github.com/a2aproject/a2a-python/compare/v0.3.10...v0.3.11) (2025-11-07) + + +### Bug Fixes + +* add metadata to send message request ([12b4a1d](https://github.com/a2aproject/a2a-python/commit/12b4a1d565a53794f5b55c8bd1728221c906ed41)) + ## [0.3.10](https://github.com/a2aproject/a2a-python/compare/v0.3.9...v0.3.10) (2025-10-21) diff --git a/scripts/generate_types.sh b/scripts/generate_types.sh index b8d7dedfc..6c01cff57 100755 --- a/scripts/generate_types.sh +++ b/scripts/generate_types.sh @@ -4,7 +4,35 @@ # Treat unset variables as an error. set -euo pipefail -REMOTE_URL="https://raw.githubusercontent.com/a2aproject/A2A/refs/heads/main/specification/json/a2a.json" +# A2A specification version to use +# Can be overridden via environment variable: A2A_SPEC_VERSION=v1.2.0 ./generate_types.sh +# Or via command-line flag: ./generate_types.sh --version v1.2.0 output.py +# Use a specific git tag, branch name, or commit SHA +# Examples: "v1.0.0", "v1.2.0", "main", "abc123def" +A2A_SPEC_VERSION="${A2A_SPEC_VERSION:-v0.3.0}" + +# Build URL based on version format +# Tags use /refs/tags/, branches use /refs/heads/, commits use direct ref +build_remote_url() { + local version="$1" + local base_url="https://raw.githubusercontent.com/a2aproject/A2A" + local spec_path="specification/json/a2a.json" + local url_part + + if [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + # Looks like a version tag (v1.0.0, v1.2.3) + url_part="refs/tags/${version}" + elif [[ "$version" =~ ^[0-9a-f]{7,40}$ ]]; then + # Looks like a commit SHA (7+ hex chars) + url_part="${version}" + else + # Assume it's a branch name (main, develop, etc.) + url_part="refs/heads/${version}" + fi + echo "${base_url}/${url_part}/${spec_path}" +} + +REMOTE_URL=$(build_remote_url "$A2A_SPEC_VERSION") GENERATED_FILE="" INPUT_FILE="" @@ -12,20 +40,38 @@ INPUT_FILE="" # Parse command-line arguments while [[ $# -gt 0 ]]; do case "$1" in - --input-file) - INPUT_FILE="$2" - shift 2 - ;; - *) - GENERATED_FILE="$1" - shift 1 - ;; + --input-file) + INPUT_FILE="$2" + shift 2 + ;; + --version) + A2A_SPEC_VERSION="$2" + REMOTE_URL=$(build_remote_url "$A2A_SPEC_VERSION") + shift 2 + ;; + *) + GENERATED_FILE="$1" + shift 1 + ;; esac done if [ -z "$GENERATED_FILE" ]; then - echo "Error: Output file path must be provided." >&2 - echo "Usage: $0 [--input-file ] " + cat >&2 <] [--version ] +Options: + --input-file Use a local JSON schema file instead of fetching from remote + --version Specify A2A spec version (default: v0.3.0) + Can be a git tag (v1.0.0), branch (main), or commit SHA +Environment variables: + A2A_SPEC_VERSION Override default spec version +Examples: + $0 src/a2a/types.py + $0 --version v1.2.0 src/a2a/types.py + $0 --input-file local/a2a.json src/a2a/types.py + A2A_SPEC_VERSION=main $0 src/a2a/types.py +EOF exit 1 fi @@ -33,9 +79,30 @@ echo "Running datamodel-codegen..." declare -a source_args if [ -n "$INPUT_FILE" ]; then echo " - Source File: $INPUT_FILE" + if [ ! -f "$INPUT_FILE" ]; then + echo "Error: Input file does not exist: $INPUT_FILE" >&2 + exit 1 + fi source_args=("--input" "$INPUT_FILE") else + echo " - A2A Spec Version: $A2A_SPEC_VERSION" echo " - Source URL: $REMOTE_URL" + + # Validate that the remote URL is accessible + echo " - Validating remote URL..." + if ! curl --fail --silent --head "$REMOTE_URL" >/dev/null 2>&1; then + cat >&2 < AsyncIterator[ClientEvent | Message]: """Sends a message to the agent. @@ -57,6 +59,7 @@ async def send_message( Args: request: The message to send to the agent. context: The client call context. + request_metadata: Extensions Metadata attached to the request. Yields: An async iterator of `ClientEvent` or a final `Message` response. @@ -70,7 +73,9 @@ async def send_message( else None ), ) - params = MessageSendParams(message=request, configuration=config) + params = MessageSendParams( + message=request, configuration=config, metadata=request_metadata + ) if not self._config.streaming or not self._card.capabilities.streaming: response = await self._transport.send_message( diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py index 7cc10423d..0e1c43237 100644 --- a/src/a2a/client/client.py +++ b/src/a2a/client/client.py @@ -110,6 +110,7 @@ async def send_message( request: Message, *, context: ClientCallContext | None = None, + request_metadata: dict[str, Any] | None = None, ) -> AsyncIterator[ClientEvent | Message]: """Sends a message to the server. diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index e619cd72c..d077d62bf 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -57,7 +57,7 @@ def make_dict_serializable(value: Any) -> Any: Returns: A serializable value. """ - if isinstance(value, (str, int, float, bool)) or value is None: + if isinstance(value, str | int | float | bool) or value is None: return value if isinstance(value, dict): return {k: make_dict_serializable(v) for k, v in value.items()} @@ -140,6 +140,7 @@ def message(cls, message: types.Message | None) -> a2a_pb2.Message | None: task_id=message.task_id or '', role=cls.role(message.role), metadata=cls.metadata(message.metadata), + extensions=message.extensions or [], ) @classmethod @@ -239,6 +240,7 @@ def artifact(cls, artifact: types.Artifact) -> a2a_pb2.Artifact: metadata=cls.metadata(artifact.metadata), name=artifact.name, parts=[cls.part(p) for p in artifact.parts], + extensions=artifact.extensions or [], ) @classmethod @@ -695,6 +697,7 @@ def artifact(cls, artifact: a2a_pb2.Artifact) -> types.Artifact: metadata=cls.metadata(artifact.metadata), name=artifact.name, parts=[cls.part(p) for p in artifact.parts], + extensions=artifact.extensions or None, ) @classmethod diff --git a/tests/client/test_base_client.py b/tests/client/test_base_client.py index d93a22030..f5ab25432 100644 --- a/tests/client/test_base_client.py +++ b/tests/client/test_base_client.py @@ -73,9 +73,14 @@ async def create_stream(*args, **kwargs): mock_transport.send_message_streaming.return_value = create_stream() - events = [event async for event in base_client.send_message(sample_message)] + meta = {'test': 1} + stream = base_client.send_message(sample_message, request_metadata=meta) + events = [event async for event in stream] mock_transport.send_message_streaming.assert_called_once() + assert ( + mock_transport.send_message_streaming.call_args[0][0].metadata == meta + ) assert not mock_transport.send_message.called assert len(events) == 1 assert events[0][0].id == 'task-123' @@ -92,9 +97,12 @@ async def test_send_message_non_streaming( status=TaskStatus(state=TaskState.completed), ) - events = [event async for event in base_client.send_message(sample_message)] + meta = {'test': 1} + stream = base_client.send_message(sample_message, request_metadata=meta) + events = [event async for event in stream] mock_transport.send_message.assert_called_once() + assert mock_transport.send_message.call_args[0][0].metadata == meta assert not mock_transport.send_message_streaming.called assert len(events) == 1 assert events[0][0].id == 'task-456' From acdc0de4fa03d34a6b287ab252ff51b19c3016b5 Mon Sep 17 00:00:00 2001 From: Lukasz Kawka Date: Thu, 13 Nov 2025 11:42:30 -0800 Subject: [PATCH 059/384] fix: return entire history when history_length=0 (#537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description The specification states that a history length of 0 should return unlimited results (see [code](https://github.com/a2aproject/A2A/blob/202aa069e66f701bacf2156d42d8916fc96a5188/specification/grpc/a2a.proto#L128-L130)). However, this was recently changed to return 0 results. This fix restores the correct behavior. Please note that there is an outstanding proposal to change this behavior. See https://github.com/a2aproject/A2A/pull/1071 for more details. Prerequisites: - [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 # 🦕 --- src/a2a/utils/task.py | 6 ++---- .../test_default_request_handler.py | 20 ++++++++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/a2a/utils/task.py b/src/a2a/utils/task.py index 5c5f3f076..d8215cec0 100644 --- a/src/a2a/utils/task.py +++ b/src/a2a/utils/task.py @@ -83,11 +83,9 @@ def apply_history_length(task: Task, history_length: int | None) -> Task: A new task object with limited history """ # Apply historyLength parameter if specified - if history_length is not None and task.history: + if history_length is not None and history_length > 0 and task.history: # Limit history to the most recent N messages - limited_history = ( - task.history[-history_length:] if history_length > 0 else [] - ) + limited_history = task.history[-history_length:] # Create a new task instance with limited history return task.model_copy(update={'history': limited_history}) diff --git a/tests/server/request_handlers/test_default_request_handler.py b/tests/server/request_handlers/test_default_request_handler.py index 5268af115..88dd77ab4 100644 --- a/tests/server/request_handlers/test_default_request_handler.py +++ b/tests/server/request_handlers/test_default_request_handler.py @@ -834,6 +834,11 @@ async def test_on_message_send_non_blocking(): assert task is not None assert task.status.state == TaskState.completed + assert ( + result.history + and task.history + and len(result.history) == len(task.history) + ) @pytest.mark.asyncio @@ -855,7 +860,7 @@ async def test_on_message_send_limit_history(): configuration=MessageSendConfiguration( blocking=True, accepted_output_modes=['text/plain'], - history_length=0, + history_length=1, ), ) @@ -866,17 +871,17 @@ async def test_on_message_send_limit_history(): # verify that history_length is honored assert result is not None assert isinstance(result, Task) - assert result.history is not None and len(result.history) == 0 + assert result.history is not None and len(result.history) == 1 assert result.status.state == TaskState.completed # verify that history is still persisted to the store task = await task_store.get(result.id) assert task is not None - assert task.history is not None and len(task.history) > 0 + assert task.history is not None and len(task.history) > 1 @pytest.mark.asyncio -async def test_on_task_get_limit_history(): +async def test_on_get_task_limit_history(): task_store = InMemoryTaskStore() push_store = InMemoryPushNotificationConfigStore() @@ -892,7 +897,8 @@ async def test_on_task_get_limit_history(): parts=[Part(root=TextPart(text='Hi'))], ), configuration=MessageSendConfiguration( - blocking=True, accepted_output_modes=['text/plain'] + blocking=True, + accepted_output_modes=['text/plain'], ), ) @@ -904,14 +910,14 @@ async def test_on_task_get_limit_history(): assert isinstance(result, Task) get_task_result = await request_handler.on_get_task( - TaskQueryParams(id=result.id, history_length=0), + TaskQueryParams(id=result.id, history_length=1), create_server_call_context(), ) assert get_task_result is not None assert isinstance(get_task_result, Task) assert ( get_task_result.history is not None - and len(get_task_result.history) == 0 + and len(get_task_result.history) == 1 ) From ef020c5d6108e362da94deb0c4cbec14bffe16aa Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Mon, 17 Nov 2025 03:26:56 -0600 Subject: [PATCH 060/384] chore(main): release 0.3.13 (#543) :robot: I have created a release *beep* *boop* --- ## [0.3.13](https://github.com/a2aproject/a2a-python/compare/v0.3.12...v0.3.13) (2025-11-13) ### Bug Fixes * return entire history when history_length=0 ([#537](https://github.com/a2aproject/a2a-python/issues/537)) ([acdc0de](https://github.com/a2aproject/a2a-python/commit/acdc0de4fa03d34a6b287ab252ff51b19c3016b5)) --- 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 d2f30a844..c9629aead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.3.13](https://github.com/a2aproject/a2a-python/compare/v0.3.12...v0.3.13) (2025-11-13) + + +### Bug Fixes + +* return entire history when history_length=0 ([#537](https://github.com/a2aproject/a2a-python/issues/537)) ([acdc0de](https://github.com/a2aproject/a2a-python/commit/acdc0de4fa03d34a6b287ab252ff51b19c3016b5)) + ## [0.3.12](https://github.com/a2aproject/a2a-python/compare/v0.3.11...v0.3.12) (2025-11-12) From ba142df821d1c06be0b96e576fd43015120fcb0b Mon Sep 17 00:00:00 2001 From: Iva Sokolaj <102302011+sokoliva@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:42:44 +0100 Subject: [PATCH 061/384] feat(jsonrpc): add option to disable oversized payload check in JSONRPC applications (#544) This commit adds a constructor boolean variable disable_content_length_check to the base class JSONRPCApplication and it's derived classes A2AFastAPIApplication and A2AStarletteApplication. In JSONRPCApplication's method _handle_requests it adds a if disable_content_length_check check before checking if the payload exceeds the MAX_CONTENT_LENGTH limit. This enables agent creators to disable the 10MB payload size limitation. --- src/a2a/server/apps/jsonrpc/fastapi_app.py | 4 ++ src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 42 ++++++++++++------- src/a2a/server/apps/jsonrpc/starlette_app.py | 4 ++ .../server/apps/jsonrpc/test_serialization.py | 36 ++++++++++++++++ 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/a2a/server/apps/jsonrpc/fastapi_app.py b/src/a2a/server/apps/jsonrpc/fastapi_app.py index 4ba7fdce5..ace2c6ae3 100644 --- a/src/a2a/server/apps/jsonrpc/fastapi_app.py +++ b/src/a2a/server/apps/jsonrpc/fastapi_app.py @@ -77,6 +77,7 @@ def __init__( # noqa: PLR0913 [AgentCard, ServerCallContext], AgentCard ] | None = None, + max_content_length: int | None = 10 * 1024 * 1024, # 10MB ) -> None: """Initializes the A2AFastAPIApplication. @@ -94,6 +95,8 @@ def __init__( # noqa: PLR0913 extended_card_modifier: An optional callback to dynamically modify the extended agent card before it is served. It receives the call context. + max_content_length: The maximum allowed content length for incoming + requests. Defaults to 10MB. Set to None for unbounded maximum. """ if not _package_fastapi_installed: raise ImportError( @@ -108,6 +111,7 @@ def __init__( # noqa: PLR0913 context_builder=context_builder, card_modifier=card_modifier, extended_card_modifier=extended_card_modifier, + max_content_length=max_content_length, ) def add_routes_to_app( diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index d258916cb..3e7c2854b 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -91,8 +91,6 @@ Response = Any HTTP_413_REQUEST_ENTITY_TOO_LARGE = Any -MAX_CONTENT_LENGTH = 10_000_000 - class StarletteUserProxy(A2AUser): """Adapts the Starlette User class to the A2A user representation.""" @@ -185,6 +183,7 @@ def __init__( # noqa: PLR0913 [AgentCard, ServerCallContext], AgentCard ] | None = None, + max_content_length: int | None = 10 * 1024 * 1024, # 10MB ) -> None: """Initializes the JSONRPCApplication. @@ -202,6 +201,8 @@ def __init__( # noqa: PLR0913 extended_card_modifier: An optional callback to dynamically modify the extended agent card before it is served. It receives the call context. + max_content_length: The maximum allowed content length for incoming + requests. Defaults to 10MB. Set to None for unbounded maximum. """ if not _package_starlette_installed: raise ImportError( @@ -220,6 +221,7 @@ def __init__( # noqa: PLR0913 extended_card_modifier=extended_card_modifier, ) self._context_builder = context_builder or DefaultCallContextBuilder() + self._max_content_length = max_content_length def _generate_error_response( self, request_id: str | int | None, error: JSONRPCError | A2AError @@ -261,6 +263,22 @@ def _generate_error_response( status_code=200, ) + def _allowed_content_length(self, request: Request) -> bool: + """Checks if the request content length is within the allowed maximum. + + Args: + request: The incoming Starlette Request object. + + Returns: + False if the content length is larger than the allowed maximum, True otherwise. + """ + if self._max_content_length is not None: + with contextlib.suppress(ValueError): + content_length = int(request.headers.get('content-length', '0')) + if content_length and content_length > self._max_content_length: + return False + return True + async def _handle_requests(self, request: Request) -> Response: # noqa: PLR0911 """Handles incoming POST requests to the main A2A endpoint. @@ -291,18 +309,14 @@ async def _handle_requests(self, request: Request) -> Response: # noqa: PLR0911 request_id, str | int ): request_id = None - # Treat very large payloads as invalid request (-32600) before routing - with contextlib.suppress(Exception): - content_length = int(request.headers.get('content-length', '0')) - if content_length and content_length > MAX_CONTENT_LENGTH: - return self._generate_error_response( - request_id, - A2AError( - root=InvalidRequestError( - message='Payload too large' - ) - ), - ) + # Treat payloads lager than allowed as invalid request (-32600) before routing + if not self._allowed_content_length(request): + return self._generate_error_response( + request_id, + A2AError( + root=InvalidRequestError(message='Payload too large') + ), + ) logger.debug('Request body: %s', body) # 1) Validate base JSON-RPC structure only (-32600 on failure) try: diff --git a/src/a2a/server/apps/jsonrpc/starlette_app.py b/src/a2a/server/apps/jsonrpc/starlette_app.py index b268d0431..1effa9d51 100644 --- a/src/a2a/server/apps/jsonrpc/starlette_app.py +++ b/src/a2a/server/apps/jsonrpc/starlette_app.py @@ -59,6 +59,7 @@ def __init__( # noqa: PLR0913 [AgentCard, ServerCallContext], AgentCard ] | None = None, + max_content_length: int | None = 10 * 1024 * 1024, # 10MB ) -> None: """Initializes the A2AStarletteApplication. @@ -76,6 +77,8 @@ def __init__( # noqa: PLR0913 extended_card_modifier: An optional callback to dynamically modify the extended agent card before it is served. It receives the call context. + max_content_length: The maximum allowed content length for incoming + requests. Defaults to 10MB. Set to None for unbounded maximum. """ if not _package_starlette_installed: raise ImportError( @@ -90,6 +93,7 @@ def __init__( # noqa: PLR0913 context_builder=context_builder, card_modifier=card_modifier, extended_card_modifier=extended_card_modifier, + max_content_length=max_content_length, ) def routes( diff --git a/tests/server/apps/jsonrpc/test_serialization.py b/tests/server/apps/jsonrpc/test_serialization.py index 9365017b5..f67780461 100644 --- a/tests/server/apps/jsonrpc/test_serialization.py +++ b/tests/server/apps/jsonrpc/test_serialization.py @@ -136,6 +136,42 @@ def test_handle_oversized_payload(agent_card_with_api_key: AgentCard): assert data['error']['code'] == InvalidRequestError().code +@pytest.mark.parametrize( + 'max_content_length', + [ + None, + 11 * 1024 * 1024, + 30 * 1024 * 1024, + ], +) +def test_handle_oversized_payload_with_max_content_length( + agent_card_with_api_key: AgentCard, + max_content_length: int | None, +): + """Test handling of JSON payloads with sizes within custom max_content_length.""" + handler = mock.AsyncMock() + app_instance = A2AStarletteApplication( + agent_card_with_api_key, handler, max_content_length=max_content_length + ) + client = TestClient(app_instance.build()) + + large_string = 'a' * 11 * 1_000_000 # 11MB string + payload = { + 'jsonrpc': '2.0', + 'method': 'test', + 'id': 1, + 'params': {'data': large_string}, + } + + response = client.post('/', json=payload) + assert response.status_code == 200 + data = response.json() + # When max_content_length is set, requests up to that size should not be + # rejected due to payload size. The request might fail for other reasons, + # but it shouldn't be an InvalidRequestError related to the content length. + assert data['error']['code'] != InvalidRequestError().code + + def test_handle_unicode_characters(agent_card_with_api_key: AgentCard): """Test handling of unicode characters in JSON payload.""" handler = mock.AsyncMock() From 9e35eb8500a5fbc8f7dd59c18282e3ffa9c976f5 Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Tue, 18 Nov 2025 02:58:14 -0600 Subject: [PATCH 062/384] chore(main): release 0.3.14 (#547) :robot: I have created a release *beep* *boop* --- ## [0.3.14](https://github.com/a2aproject/a2a-python/compare/v0.3.13...v0.3.14) (2025-11-17) ### Features * **jsonrpc:** add option to disable oversized payload check in JSONRPC applications ([ba142df](https://github.com/a2aproject/a2a-python/commit/ba142df821d1c06be0b96e576fd43015120fcb0b)) --- 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 c9629aead..a684ac022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.3.14](https://github.com/a2aproject/a2a-python/compare/v0.3.13...v0.3.14) (2025-11-17) + + +### Features + +* **jsonrpc:** add option to disable oversized payload check in JSONRPC applications ([ba142df](https://github.com/a2aproject/a2a-python/commit/ba142df821d1c06be0b96e576fd43015120fcb0b)) + ## [0.3.13](https://github.com/a2aproject/a2a-python/compare/v0.3.12...v0.3.13) (2025-11-13) From 89e9b7cb0ab24c3c22b7da04e04d77ec5f15dc1c Mon Sep 17 00:00:00 2001 From: Martim Santos Date: Tue, 18 Nov 2025 09:30:30 +0000 Subject: [PATCH 063/384] chore(client): Expose BaseClient from a2a.client package (#529) # Description This pull request introduces a minor update to the `src/a2a/client/__init__.py` file, focusing on the module's imports. The change makes the `BaseClient` class available for import from this package. * Added `BaseClient` to the list of imported symbols and to the module's `__all__`, making it accessible when importing from `a2a.client`. [[1]](diffhunk://#diff-e472e7970567ab144a39be570324db52d9da69a3f678438f6ee5f3aecd79b474R10) [[2]](diffhunk://#diff-e472e7970567ab144a39be570324db52d9da69a3f678438f6ee5f3aecd79b474R55) --- - [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) Co-authored-by: Lukasz Kawka --- src/a2a/client/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/a2a/client/__init__.py b/src/a2a/client/__init__.py index 726a089db..4fccd0810 100644 --- a/src/a2a/client/__init__.py +++ b/src/a2a/client/__init__.py @@ -7,6 +7,7 @@ CredentialService, InMemoryContextCredentialStore, ) +from a2a.client.base_client import BaseClient from a2a.client.card_resolver import A2ACardResolver from a2a.client.client import Client, ClientConfig, ClientEvent, Consumer from a2a.client.client_factory import ClientFactory, minimal_agent_card @@ -51,6 +52,7 @@ def __init__(self, *args, **kwargs): 'A2AClientTimeoutError', 'A2AGrpcClient', 'AuthInterceptor', + 'BaseClient', 'Client', 'ClientCallContext', 'ClientCallInterceptor', From 9a92bd238e7560b195165ac5f78742981760525e Mon Sep 17 00:00:00 2001 From: Iva Sokolaj <102302011+sokoliva@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:13:10 +0100 Subject: [PATCH 064/384] feat: Add client-side extension support (#525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(rest, jsonrpc): Add client-side extension support This commit introduces support for clients to declare the extensions they support. - Adds an `extensions` list to `ClientConfig`. - Updates `ClientFactory` to pass `client_extensions` to `JsonRpcTransport` and `RestTransport`. - Adds `_update_extension_header` method to both transports to update the `X-A2A-Extensions` header. - Modifies `send_message` and `send_message_streaming` in `JsonRpcTransport` to include the extension headers. - Modifies `_prepare_send_message` in `RestTransport` to include the extension headers. - Adds tests for the extension header logic in both JSON-RPC and REST transports, including a new test file `test_rest_client.py`. - Fixes #504 🦕 --- src/a2a/client/base_client.py | 46 +++++-- src/a2a/client/client.py | 14 +- src/a2a/client/client_factory.py | 19 ++- src/a2a/client/transports/base.py | 8 ++ src/a2a/client/transports/grpc.py | 55 ++++++-- src/a2a/client/transports/jsonrpc.py | 62 +++++++-- src/a2a/client/transports/rest.py | 65 +++++++-- src/a2a/extensions/common.py | 14 ++ tests/client/test_client_factory.py | 4 + .../{ => transports}/test_grpc_client.py | 124 ++++++++++++++++-- .../{ => transports}/test_jsonrpc_client.py | 90 +++++++++++++ tests/client/transports/test_rest_client.py | 121 +++++++++++++++++ tests/extensions/test_common.py | 88 +++++++++++++ .../test_client_server_integration.py | 62 ++++++++- 14 files changed, 715 insertions(+), 57 deletions(-) rename tests/client/{ => transports}/test_grpc_client.py (80%) rename tests/client/{ => transports}/test_jsonrpc_client.py (89%) create mode 100644 tests/client/transports/test_rest_client.py diff --git a/src/a2a/client/base_client.py b/src/a2a/client/base_client.py index b8697d860..5719bc1b0 100644 --- a/src/a2a/client/base_client.py +++ b/src/a2a/client/base_client.py @@ -49,6 +49,7 @@ async def send_message( *, context: ClientCallContext | None = None, request_metadata: dict[str, Any] | None = None, + extensions: list[str] | None = None, ) -> AsyncIterator[ClientEvent | Message]: """Sends a message to the agent. @@ -60,6 +61,7 @@ async def send_message( request: The message to send to the agent. context: The client call context. request_metadata: Extensions Metadata attached to the request. + extensions: List of extensions to be activated. Yields: An async iterator of `ClientEvent` or a final `Message` response. @@ -79,7 +81,7 @@ async def send_message( if not self._config.streaming or not self._card.capabilities.streaming: response = await self._transport.send_message( - params, context=context + params, context=context, extensions=extensions ) result = ( (response, None) if isinstance(response, Task) else response @@ -89,7 +91,9 @@ async def send_message( return tracker = ClientTaskManager() - stream = self._transport.send_message_streaming(params, context=context) + stream = self._transport.send_message_streaming( + params, context=context, extensions=extensions + ) first_event = await anext(stream) # The response from a server may be either exactly one Message or a @@ -126,74 +130,91 @@ async def get_task( request: TaskQueryParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task: """Retrieves the current state and history of a specific task. Args: request: The `TaskQueryParams` object specifying the task ID. context: The client call context. + extensions: List of extensions to be activated. Returns: A `Task` object representing the current state of the task. """ - return await self._transport.get_task(request, context=context) + return await self._transport.get_task( + request, context=context, extensions=extensions + ) async def cancel_task( self, request: TaskIdParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task: """Requests the agent to cancel a specific task. Args: request: The `TaskIdParams` object specifying the task ID. context: The client call context. + extensions: List of extensions to be activated. Returns: A `Task` object containing the updated task status. """ - return await self._transport.cancel_task(request, context=context) + return await self._transport.cancel_task( + request, context=context, extensions=extensions + ) async def set_task_callback( self, request: TaskPushNotificationConfig, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> TaskPushNotificationConfig: """Sets or updates the push notification configuration for a specific task. Args: request: The `TaskPushNotificationConfig` object with the new configuration. context: The client call context. + extensions: List of extensions to be activated. Returns: The created or updated `TaskPushNotificationConfig` object. """ - return await self._transport.set_task_callback(request, context=context) + return await self._transport.set_task_callback( + request, context=context, extensions=extensions + ) async def get_task_callback( self, request: GetTaskPushNotificationConfigParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> TaskPushNotificationConfig: """Retrieves the push notification configuration for a specific task. Args: request: The `GetTaskPushNotificationConfigParams` object specifying the task. context: The client call context. + extensions: List of extensions to be activated. Returns: A `TaskPushNotificationConfig` object containing the configuration. """ - return await self._transport.get_task_callback(request, context=context) + return await self._transport.get_task_callback( + request, context=context, extensions=extensions + ) async def resubscribe( self, request: TaskIdParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AsyncIterator[ClientEvent]: """Resubscribes to a task's event stream. @@ -202,6 +223,7 @@ async def resubscribe( Args: request: Parameters to identify the task to resubscribe to. context: The client call context. + extensions: List of extensions to be activated. Yields: An async iterator of `ClientEvent` objects. @@ -219,12 +241,15 @@ async def resubscribe( # we should never see Message updates, despite the typing of the service # definition indicating it may be possible. async for event in self._transport.resubscribe( - request, context=context + request, context=context, extensions=extensions ): yield await self._process_response(tracker, event) async def get_card( - self, *, context: ClientCallContext | None = None + self, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AgentCard: """Retrieves the agent's card. @@ -233,11 +258,14 @@ async def get_card( Args: context: The client call context. + extensions: List of extensions to be activated. Returns: The `AgentCard` for the agent. """ - card = await self._transport.get_card(context=context) + card = await self._transport.get_card( + context=context, extensions=extensions + ) self._card = card return card diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py index 0e1c43237..fd97b4d14 100644 --- a/src/a2a/client/client.py +++ b/src/a2a/client/client.py @@ -67,6 +67,9 @@ class ClientConfig: ) """Push notification callbacks to use for every request.""" + extensions: list[str] = dataclasses.field(default_factory=list) + """A list of extension URIs the client supports.""" + UpdateEvent = TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None # Alias for emitted events from client @@ -111,6 +114,7 @@ async def send_message( *, context: ClientCallContext | None = None, request_metadata: dict[str, Any] | None = None, + extensions: list[str] | None = None, ) -> AsyncIterator[ClientEvent | Message]: """Sends a message to the server. @@ -129,6 +133,7 @@ async def get_task( request: TaskQueryParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task: """Retrieves the current state and history of a specific task.""" @@ -138,6 +143,7 @@ async def cancel_task( request: TaskIdParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task: """Requests the agent to cancel a specific task.""" @@ -147,6 +153,7 @@ async def set_task_callback( request: TaskPushNotificationConfig, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> TaskPushNotificationConfig: """Sets or updates the push notification configuration for a specific task.""" @@ -156,6 +163,7 @@ async def get_task_callback( request: GetTaskPushNotificationConfigParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> TaskPushNotificationConfig: """Retrieves the push notification configuration for a specific task.""" @@ -165,6 +173,7 @@ async def resubscribe( request: TaskIdParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AsyncIterator[ClientEvent]: """Resubscribes to a task's event stream.""" return @@ -172,7 +181,10 @@ async def resubscribe( @abstractmethod async def get_card( - self, *, context: ClientCallContext | None = None + self, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AgentCard: """Retrieves the agent's card.""" diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index 65b3fb5f0..fabd7270f 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -80,6 +80,7 @@ def _register_defaults( card, url, interceptors, + config.extensions or None, ), ) if TransportProtocol.http_json in supported: @@ -90,6 +91,7 @@ def _register_defaults( card, url, interceptors, + config.extensions or None, ), ) if TransportProtocol.grpc in supported: @@ -113,6 +115,7 @@ async def connect( # noqa: PLR0913 relative_card_path: str | None = None, resolver_http_kwargs: dict[str, Any] | None = None, extra_transports: dict[str, TransportProducer] | None = None, + extensions: list[str] | None = None, ) -> Client: """Convenience method for constructing a client. @@ -142,6 +145,7 @@ async def connect( # noqa: PLR0913 A2AAgentCardResolver.get_agent_card as the http_kwargs parameter. extra_transports: Additional transport protocols to enable when constructing the client. + extensions: List of extensions to be activated. Returns: A `Client` object. @@ -166,7 +170,7 @@ async def connect( # noqa: PLR0913 factory = cls(client_config) for label, generator in (extra_transports or {}).items(): factory.register(label, generator) - return factory.create(card, consumers, interceptors) + return factory.create(card, consumers, interceptors, extensions) def register(self, label: str, generator: TransportProducer) -> None: """Register a new transport producer for a given transport label.""" @@ -177,6 +181,7 @@ def create( card: AgentCard, consumers: list[Consumer] | None = None, interceptors: list[ClientCallInterceptor] | None = None, + extensions: list[str] | None = None, ) -> Client: """Create a new `Client` for the provided `AgentCard`. @@ -186,6 +191,7 @@ def create( interceptors: A list of interceptors to use for each request. These are used for things like attaching credentials or http headers to all outbound requests. + extensions: List of extensions to be activated. Returns: A `Client` object. @@ -226,12 +232,21 @@ def create( if consumers: all_consumers.extend(consumers) + all_extensions = self._config.extensions.copy() + if extensions: + all_extensions.extend(extensions) + self._config.extensions = all_extensions + transport = self._registry[transport_protocol]( card, transport_url, self._config, interceptors or [] ) return BaseClient( - card, self._config, transport, all_consumers, interceptors or [] + card, + self._config, + transport, + all_consumers, + interceptors or [], ) diff --git a/src/a2a/client/transports/base.py b/src/a2a/client/transports/base.py index 3573cb7ca..8f114d95d 100644 --- a/src/a2a/client/transports/base.py +++ b/src/a2a/client/transports/base.py @@ -25,6 +25,7 @@ async def send_message( request: MessageSendParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task | Message: """Sends a non-streaming message request to the agent.""" @@ -34,6 +35,7 @@ async def send_message_streaming( request: MessageSendParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AsyncGenerator[ Message | Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent ]: @@ -47,6 +49,7 @@ async def get_task( request: TaskQueryParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task: """Retrieves the current state and history of a specific task.""" @@ -56,6 +59,7 @@ async def cancel_task( request: TaskIdParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task: """Requests the agent to cancel a specific task.""" @@ -65,6 +69,7 @@ async def set_task_callback( request: TaskPushNotificationConfig, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> TaskPushNotificationConfig: """Sets or updates the push notification configuration for a specific task.""" @@ -74,6 +79,7 @@ async def get_task_callback( request: GetTaskPushNotificationConfigParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> TaskPushNotificationConfig: """Retrieves the push notification configuration for a specific task.""" @@ -83,6 +89,7 @@ async def resubscribe( request: TaskIdParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AsyncGenerator[ Task | Message | TaskStatusUpdateEvent | TaskArtifactUpdateEvent ]: @@ -95,6 +102,7 @@ async def get_card( self, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AgentCard: """Retrieves the AgentCard.""" diff --git a/src/a2a/client/transports/grpc.py b/src/a2a/client/transports/grpc.py index e50b0ea81..4e27953af 100644 --- a/src/a2a/client/transports/grpc.py +++ b/src/a2a/client/transports/grpc.py @@ -12,10 +12,12 @@ "'pip install a2a-sdk[grpc]'" ) from e + from a2a.client.client import ClientConfig from a2a.client.middleware import ClientCallContext, ClientCallInterceptor from a2a.client.optionals import Channel from a2a.client.transports.base import ClientTransport +from a2a.extensions.common import HTTP_EXTENSION_HEADER from a2a.grpc import a2a_pb2, a2a_pb2_grpc from a2a.types import ( AgentCard, @@ -44,6 +46,7 @@ def __init__( self, channel: Channel, agent_card: AgentCard | None, + extensions: list[str] | None = None, ): """Initializes the GrpcTransport.""" self.agent_card = agent_card @@ -54,6 +57,18 @@ def __init__( if agent_card else True ) + self.extensions = extensions + + def _get_grpc_metadata( + self, + extensions: list[str] | None = None, + ) -> list[tuple[str, str]] | None: + """Creates gRPC metadata for extensions.""" + if extensions is not None: + return [(HTTP_EXTENSION_HEADER, ','.join(extensions))] + if self.extensions is not None: + return [(HTTP_EXTENSION_HEADER, ','.join(self.extensions))] + return None @classmethod def create( @@ -66,16 +81,14 @@ def create( """Creates a gRPC transport for the A2A client.""" if config.grpc_channel_factory is None: raise ValueError('grpc_channel_factory is required when using gRPC') - return cls( - config.grpc_channel_factory(url), - card, - ) + return cls(config.grpc_channel_factory(url), card, config.extensions) async def send_message( self, request: MessageSendParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task | Message: """Sends a non-streaming message request to the agent.""" response = await self.stub.SendMessage( @@ -85,7 +98,8 @@ async def send_message( request.configuration ), metadata=proto_utils.ToProto.metadata(request.metadata), - ) + ), + metadata=self._get_grpc_metadata(extensions), ) if response.HasField('task'): return proto_utils.FromProto.task(response.task) @@ -96,6 +110,7 @@ async def send_message_streaming( request: MessageSendParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AsyncGenerator[ Message | Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent ]: @@ -107,7 +122,8 @@ async def send_message_streaming( request.configuration ), metadata=proto_utils.ToProto.metadata(request.metadata), - ) + ), + metadata=self._get_grpc_metadata(extensions), ) while True: response = await stream.read() @@ -116,13 +132,18 @@ async def send_message_streaming( yield proto_utils.FromProto.stream_response(response) async def resubscribe( - self, request: TaskIdParams, *, context: ClientCallContext | None = None + self, + request: TaskIdParams, + *, + context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AsyncGenerator[ Task | Message | TaskStatusUpdateEvent | TaskArtifactUpdateEvent ]: """Reconnects to get task updates.""" stream = self.stub.TaskSubscription( - a2a_pb2.TaskSubscriptionRequest(name=f'tasks/{request.id}') + a2a_pb2.TaskSubscriptionRequest(name=f'tasks/{request.id}'), + metadata=self._get_grpc_metadata(extensions), ) while True: response = await stream.read() @@ -135,13 +156,15 @@ async def get_task( request: TaskQueryParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task: """Retrieves the current state and history of a specific task.""" task = await self.stub.GetTask( a2a_pb2.GetTaskRequest( name=f'tasks/{request.id}', history_length=request.history_length, - ) + ), + metadata=self._get_grpc_metadata(extensions), ) return proto_utils.FromProto.task(task) @@ -150,10 +173,12 @@ async def cancel_task( request: TaskIdParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task: """Requests the agent to cancel a specific task.""" task = await self.stub.CancelTask( - a2a_pb2.CancelTaskRequest(name=f'tasks/{request.id}') + a2a_pb2.CancelTaskRequest(name=f'tasks/{request.id}'), + metadata=self._get_grpc_metadata(extensions), ) return proto_utils.FromProto.task(task) @@ -162,6 +187,7 @@ async def set_task_callback( request: TaskPushNotificationConfig, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> TaskPushNotificationConfig: """Sets or updates the push notification configuration for a specific task.""" config = await self.stub.CreateTaskPushNotificationConfig( @@ -171,7 +197,8 @@ async def set_task_callback( config=proto_utils.ToProto.task_push_notification_config( request ), - ) + ), + metadata=self._get_grpc_metadata(extensions), ) return proto_utils.FromProto.task_push_notification_config(config) @@ -180,12 +207,14 @@ async def get_task_callback( request: GetTaskPushNotificationConfigParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> TaskPushNotificationConfig: """Retrieves the push notification configuration for a specific task.""" config = await self.stub.GetTaskPushNotificationConfig( a2a_pb2.GetTaskPushNotificationConfigRequest( name=f'tasks/{request.id}/pushNotificationConfigs/{request.push_notification_config_id}', - ) + ), + metadata=self._get_grpc_metadata(extensions), ) return proto_utils.FromProto.task_push_notification_config(config) @@ -193,6 +222,7 @@ async def get_card( self, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AgentCard: """Retrieves the agent's card.""" card = self.agent_card @@ -203,6 +233,7 @@ async def get_card( card_pb = await self.stub.GetAgentCard( a2a_pb2.GetAgentCardRequest(), + metadata=self._get_grpc_metadata(extensions), ) card = proto_utils.FromProto.agent_card(card_pb) self.agent_card = card diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py index bfba09d71..d8011cf4d 100644 --- a/src/a2a/client/transports/jsonrpc.py +++ b/src/a2a/client/transports/jsonrpc.py @@ -18,6 +18,7 @@ ) from a2a.client.middleware import ClientCallContext, ClientCallInterceptor from a2a.client.transports.base import ClientTransport +from a2a.extensions.common import update_extension_header from a2a.types import ( AgentCard, CancelTaskRequest, @@ -62,6 +63,7 @@ def __init__( agent_card: AgentCard | None = None, url: str | None = None, interceptors: list[ClientCallInterceptor] | None = None, + extensions: list[str] | None = None, ): """Initializes the JsonRpcTransport.""" if url: @@ -79,6 +81,7 @@ def __init__( if agent_card else True ) + self.extensions = extensions async def _apply_interceptors( self, @@ -113,13 +116,18 @@ async def send_message( request: MessageSendParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task | Message: """Sends a non-streaming message request to the agent.""" rpc_request = SendMessageRequest(params=request, 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( 'message/send', rpc_request.model_dump(mode='json', exclude_none=True), - self._get_http_args(context), + modified_kwargs, context, ) response_data = await self._send_request(payload, modified_kwargs) @@ -133,6 +141,7 @@ async def send_message_streaming( request: MessageSendParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AsyncGenerator[ Message | Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent ]: @@ -140,13 +149,16 @@ async def send_message_streaming( rpc_request = SendStreamingMessageRequest( params=request, 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( 'message/stream', rpc_request.model_dump(mode='json', exclude_none=True), - self._get_http_args(context), + modified_kwargs, context, ) - modified_kwargs.setdefault( 'timeout', self.httpx_client.timeout.as_dict().get('read', None) ) @@ -207,13 +219,18 @@ async def get_task( request: TaskQueryParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task: """Retrieves the current state and history of a specific task.""" rpc_request = GetTaskRequest(params=request, 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( 'tasks/get', rpc_request.model_dump(mode='json', exclude_none=True), - self._get_http_args(context), + modified_kwargs, context, ) response_data = await self._send_request(payload, modified_kwargs) @@ -227,13 +244,18 @@ async def cancel_task( request: TaskIdParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task: """Requests the agent to cancel a specific task.""" rpc_request = CancelTaskRequest(params=request, 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( 'tasks/cancel', rpc_request.model_dump(mode='json', exclude_none=True), - self._get_http_args(context), + modified_kwargs, context, ) response_data = await self._send_request(payload, modified_kwargs) @@ -247,15 +269,20 @@ async def set_task_callback( request: TaskPushNotificationConfig, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> TaskPushNotificationConfig: """Sets or updates the push notification configuration for a specific task.""" rpc_request = SetTaskPushNotificationConfigRequest( params=request, 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( 'tasks/pushNotificationConfig/set', rpc_request.model_dump(mode='json', exclude_none=True), - self._get_http_args(context), + modified_kwargs, context, ) response_data = await self._send_request(payload, modified_kwargs) @@ -271,15 +298,20 @@ async def get_task_callback( request: GetTaskPushNotificationConfigParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> TaskPushNotificationConfig: """Retrieves the push notification configuration for a specific task.""" rpc_request = GetTaskPushNotificationConfigRequest( params=request, 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( 'tasks/pushNotificationConfig/get', rpc_request.model_dump(mode='json', exclude_none=True), - self._get_http_args(context), + modified_kwargs, context, ) response_data = await self._send_request(payload, modified_kwargs) @@ -295,18 +327,22 @@ async def resubscribe( request: TaskIdParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AsyncGenerator[ Task | Message | TaskStatusUpdateEvent | TaskArtifactUpdateEvent ]: """Reconnects to get task updates.""" rpc_request = TaskResubscriptionRequest(params=request, 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( 'tasks/resubscribe', rpc_request.model_dump(mode='json', exclude_none=True), - self._get_http_args(context), + modified_kwargs, context, ) - modified_kwargs.setdefault('timeout', None) async with aconnect_sse( @@ -339,6 +375,7 @@ async def get_card( self, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AgentCard: """Retrieves the agent's card.""" card = self.agent_card @@ -356,13 +393,16 @@ 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), - self._get_http_args(context), + modified_kwargs, context, ) - response_data = await self._send_request( payload, modified_kwargs, diff --git a/src/a2a/client/transports/rest.py b/src/a2a/client/transports/rest.py index eef7b0f2e..83c267873 100644 --- a/src/a2a/client/transports/rest.py +++ b/src/a2a/client/transports/rest.py @@ -13,6 +13,7 @@ from a2a.client.errors import A2AClientHTTPError, A2AClientJSONError from a2a.client.middleware import ClientCallContext, ClientCallInterceptor from a2a.client.transports.base import ClientTransport +from a2a.extensions.common import update_extension_header from a2a.grpc import a2a_pb2 from a2a.types import ( AgentCard, @@ -43,6 +44,7 @@ def __init__( agent_card: AgentCard | None = None, url: str | None = None, interceptors: list[ClientCallInterceptor] | None = None, + extensions: list[str] | None = None, ): """Initializes the RestTransport.""" if url: @@ -61,6 +63,7 @@ def __init__( if agent_card else True ) + self.extensions = extensions async def _apply_interceptors( self, @@ -79,7 +82,10 @@ def _get_http_args( return context.state.get('http_kwargs') if context else None async def _prepare_send_message( - self, request: MessageSendParams, context: ClientCallContext | None + self, + request: MessageSendParams, + context: ClientCallContext | None, + extensions: list[str] | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: pb = a2a_pb2.SendMessageRequest( request=proto_utils.ToProto.message(request.message), @@ -93,9 +99,13 @@ async def _prepare_send_message( ), ) payload = MessageToDict(pb) + 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( payload, - self._get_http_args(context), + modified_kwargs, context, ) return payload, modified_kwargs @@ -105,10 +115,11 @@ async def send_message( request: MessageSendParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task | Message: """Sends a non-streaming message request to the agent.""" payload, modified_kwargs = await self._prepare_send_message( - request, context + request, context, extensions ) response_data = await self._send_post_request( '/v1/message:send', payload, modified_kwargs @@ -122,12 +133,13 @@ async def send_message_streaming( request: MessageSendParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AsyncGenerator[ Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent | Message ]: """Sends a streaming message request to the agent and yields responses as they arrive.""" payload, modified_kwargs = await self._prepare_send_message( - request, context + request, context, extensions ) modified_kwargs.setdefault('timeout', None) @@ -204,11 +216,16 @@ async def get_task( request: TaskQueryParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task: """Retrieves the current state and history of a specific task.""" + 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.model_dump(mode='json', exclude_none=True), - self._get_http_args(context), + modified_kwargs, context, ) response_data = await self._send_get_request( @@ -227,13 +244,18 @@ async def cancel_task( request: TaskIdParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> Task: """Requests the agent to cancel a specific task.""" pb = a2a_pb2.CancelTaskRequest(name=f'tasks/{request.id}') payload = MessageToDict(pb) + 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( payload, - self._get_http_args(context), + modified_kwargs, context, ) response_data = await self._send_post_request( @@ -248,6 +270,7 @@ async def set_task_callback( request: TaskPushNotificationConfig, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> TaskPushNotificationConfig: """Sets or updates the push notification configuration for a specific task.""" pb = a2a_pb2.CreateTaskPushNotificationConfigRequest( @@ -256,8 +279,12 @@ async def set_task_callback( config=proto_utils.ToProto.task_push_notification_config(request), ) payload = MessageToDict(pb) + 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( - payload, self._get_http_args(context), context + payload, modified_kwargs, context ) response_data = await self._send_post_request( f'/v1/tasks/{request.task_id}/pushNotificationConfigs', @@ -273,15 +300,20 @@ async def get_task_callback( request: GetTaskPushNotificationConfigParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> TaskPushNotificationConfig: """Retrieves the push notification configuration for a specific task.""" pb = a2a_pb2.GetTaskPushNotificationConfigRequest( name=f'tasks/{request.id}/pushNotificationConfigs/{request.push_notification_config_id}', ) payload = MessageToDict(pb) + 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( payload, - self._get_http_args(context), + modified_kwargs, context, ) response_data = await self._send_get_request( @@ -298,18 +330,22 @@ async def resubscribe( request: TaskIdParams, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AsyncGenerator[ Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent | Message ]: """Reconnects to get task updates.""" - http_kwargs = self._get_http_args(context) or {} - http_kwargs.setdefault('timeout', None) + modified_kwargs = update_extension_header( + self._get_http_args(context), + extensions if extensions is not None else self.extensions, + ) + modified_kwargs.setdefault('timeout', None) async with aconnect_sse( self.httpx_client, 'GET', f'{self.url}/v1/tasks/{request.id}:subscribe', - **http_kwargs, + **modified_kwargs, ) as event_source: try: async for sse in event_source.aiter_sse(): @@ -331,6 +367,7 @@ async def get_card( self, *, context: ClientCallContext | None = None, + extensions: list[str] | None = None, ) -> AgentCard: """Retrieves the agent's card.""" card = self.agent_card @@ -347,9 +384,13 @@ 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( {}, - self._get_http_args(context), + modified_kwargs, context, ) response_data = await self._send_get_request( diff --git a/src/a2a/extensions/common.py b/src/a2a/extensions/common.py index 2f752caa7..cba3517e4 100644 --- a/src/a2a/extensions/common.py +++ b/src/a2a/extensions/common.py @@ -1,3 +1,5 @@ +from typing import Any + from a2a.types import AgentCard, AgentExtension @@ -25,3 +27,15 @@ def find_extension_by_uri(card: AgentCard, uri: str) -> AgentExtension | None: return ext return None + + +def update_extension_header( + http_kwargs: dict[str, Any] | None, + extensions: list[str] | None, +) -> dict[str, Any]: + """Update the X-A2A-Extensions header with active extensions.""" + http_kwargs = http_kwargs or {} + if extensions is not None: + headers = http_kwargs.setdefault('headers', {}) + headers[HTTP_EXTENSION_HEADER] = ','.join(extensions) + return http_kwargs diff --git a/tests/client/test_client_factory.py b/tests/client/test_client_factory.py index 847b256fa..16a1433fb 100644 --- a/tests/client/test_client_factory.py +++ b/tests/client/test_client_factory.py @@ -39,12 +39,14 @@ def test_client_factory_selects_preferred_transport(base_agent_card: AgentCard): TransportProtocol.jsonrpc, TransportProtocol.http_json, ], + extensions=['https://example.com/test-ext/v0'], ) factory = ClientFactory(config) client = factory.create(base_agent_card) assert isinstance(client._transport, JsonRpcTransport) assert client._transport.url == 'http://primary-url.com' + assert ['https://example.com/test-ext/v0'] == client._transport.extensions def test_client_factory_selects_secondary_transport_url( @@ -65,12 +67,14 @@ def test_client_factory_selects_secondary_transport_url( TransportProtocol.jsonrpc, ], use_client_preference=True, + extensions=['https://example.com/test-ext/v0'], ) factory = ClientFactory(config) client = factory.create(base_agent_card) assert isinstance(client._transport, RestTransport) assert client._transport.url == 'http://secondary-url.com' + assert ['https://example.com/test-ext/v0'] == client._transport.extensions def test_client_factory_server_preference(base_agent_card: AgentCard): diff --git a/tests/client/test_grpc_client.py b/tests/client/transports/test_grpc_client.py similarity index 80% rename from tests/client/test_grpc_client.py rename to tests/client/transports/test_grpc_client.py index 6dab75e9f..111e44ba6 100644 --- a/tests/client/test_grpc_client.py +++ b/tests/client/transports/test_grpc_client.py @@ -4,6 +4,7 @@ import pytest from a2a.client.transports.grpc import GrpcTransport +from a2a.extensions.common import HTTP_EXTENSION_HEADER from a2a.grpc import a2a_pb2, a2a_pb2_grpc from a2a.types import ( AgentCapabilities, @@ -64,7 +65,14 @@ def grpc_transport( ) -> GrpcTransport: """Provides a GrpcTransport instance.""" channel = AsyncMock() - transport = GrpcTransport(channel=channel, agent_card=sample_agent_card) + transport = GrpcTransport( + channel=channel, + agent_card=sample_agent_card, + extensions=[ + 'https://example.com/test-ext/v1', + 'https://example.com/test-ext/v2', + ], + ) transport.stub = mock_grpc_stub return transport @@ -185,9 +193,19 @@ async def test_send_message_task_response( task=proto_utils.ToProto.task(sample_task) ) - response = await grpc_transport.send_message(sample_message_send_params) + response = await grpc_transport.send_message( + sample_message_send_params, + extensions=['https://example.com/test-ext/v3'], + ) mock_grpc_stub.SendMessage.assert_awaited_once() + _, kwargs = mock_grpc_stub.SendMessage.call_args + assert kwargs['metadata'] == [ + ( + HTTP_EXTENSION_HEADER, + 'https://example.com/test-ext/v3', + ) + ] assert isinstance(response, Task) assert response.id == sample_task.id @@ -207,6 +225,13 @@ async def test_send_message_message_response( response = await grpc_transport.send_message(sample_message_send_params) mock_grpc_stub.SendMessage.assert_awaited_once() + _, kwargs = mock_grpc_stub.SendMessage.call_args + assert kwargs['metadata'] == [ + ( + HTTP_EXTENSION_HEADER, + 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', + ) + ] assert isinstance(response, Message) assert response.message_id == sample_message.message_id assert get_text_parts(response.parts) == get_text_parts( @@ -255,6 +280,13 @@ async def test_send_message_streaming( # noqa: PLR0913 ] mock_grpc_stub.SendStreamingMessage.assert_called_once() + _, kwargs = mock_grpc_stub.SendStreamingMessage.call_args + assert kwargs['metadata'] == [ + ( + HTTP_EXTENSION_HEADER, + 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', + ) + ] assert isinstance(responses[0], Message) assert responses[0].message_id == sample_message.message_id assert isinstance(responses[1], Task) @@ -278,7 +310,13 @@ async def test_get_task( mock_grpc_stub.GetTask.assert_awaited_once_with( a2a_pb2.GetTaskRequest( name=f'tasks/{sample_task.id}', history_length=None - ) + ), + metadata=[ + ( + HTTP_EXTENSION_HEADER, + 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', + ) + ], ) assert response.id == sample_task.id @@ -297,7 +335,13 @@ async def test_get_task_with_history( mock_grpc_stub.GetTask.assert_awaited_once_with( a2a_pb2.GetTaskRequest( name=f'tasks/{sample_task.id}', history_length=history_len - ) + ), + metadata=[ + ( + HTTP_EXTENSION_HEADER, + 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', + ) + ], ) @@ -312,11 +356,14 @@ async def test_cancel_task( cancelled_task ) params = TaskIdParams(id=sample_task.id) - - response = await grpc_transport.cancel_task(params) + extensions = [ + 'https://example.com/test-ext/v3', + ] + response = await grpc_transport.cancel_task(params, extensions=extensions) mock_grpc_stub.CancelTask.assert_awaited_once_with( - a2a_pb2.CancelTaskRequest(name=f'tasks/{sample_task.id}') + a2a_pb2.CancelTaskRequest(name=f'tasks/{sample_task.id}'), + metadata=[(HTTP_EXTENSION_HEADER, 'https://example.com/test-ext/v3')], ) assert response.status.state == TaskState.canceled @@ -345,7 +392,13 @@ async def test_set_task_callback_with_valid_task( config=proto_utils.ToProto.task_push_notification_config( sample_task_push_notification_config ), - ) + ), + metadata=[ + ( + HTTP_EXTENSION_HEADER, + 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', + ) + ], ) assert response.task_id == sample_task_push_notification_config.task_id @@ -402,7 +455,13 @@ async def test_get_task_callback_with_valid_task( f'tasks/{params.id}/' f'pushNotificationConfigs/{params.push_notification_config_id}' ), - ) + ), + metadata=[ + ( + HTTP_EXTENSION_HEADER, + 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', + ) + ], ) assert response.task_id == sample_task_push_notification_config.task_id @@ -434,3 +493,50 @@ async def test_get_task_callback_with_invalid_task( 'Bad TaskPushNotificationConfig resource name' in exc_info.value.error.message ) + + +@pytest.mark.parametrize( + 'initial_extensions, input_extensions, expected_metadata', + [ + ( + None, + None, + None, + ), # Case 1: No initial, No input + ( + ['ext1'], + None, + [(HTTP_EXTENSION_HEADER, 'ext1')], + ), # Case 2: Initial, No input + ( + None, + ['ext2'], + [(HTTP_EXTENSION_HEADER, 'ext2')], + ), # Case 3: No initial, Input + ( + ['ext1'], + ['ext2'], + [(HTTP_EXTENSION_HEADER, 'ext2')], + ), # Case 4: Initial, Input (override) + ( + ['ext1'], + ['ext2', 'ext3'], + [(HTTP_EXTENSION_HEADER, 'ext2,ext3')], + ), # Case 5: Initial, Multiple inputs (override) + ( + ['ext1', 'ext2'], + ['ext3'], + [(HTTP_EXTENSION_HEADER, 'ext3')], + ), # Case 6: Multiple initial, Single input (override) + ], +) +def test_get_grpc_metadata( + grpc_transport: GrpcTransport, + initial_extensions: list[str] | None, + input_extensions: list[str] | None, + expected_metadata: list[tuple[str, str]] | None, +) -> None: + """Tests _get_grpc_metadata for correct metadata generation and self.extensions update.""" + grpc_transport.extensions = initial_extensions + metadata = grpc_transport._get_grpc_metadata(input_extensions) + assert metadata == expected_metadata diff --git a/tests/client/test_jsonrpc_client.py b/tests/client/transports/test_jsonrpc_client.py similarity index 89% rename from tests/client/test_jsonrpc_client.py rename to tests/client/transports/test_jsonrpc_client.py index 58feec25d..bd705d93c 100644 --- a/tests/client/test_jsonrpc_client.py +++ b/tests/client/transports/test_jsonrpc_client.py @@ -17,6 +17,7 @@ create_text_message_object, ) from a2a.client.transports.jsonrpc import JsonRpcTransport +from a2a.extensions.common import HTTP_EXTENSION_HEADER from a2a.types import ( AgentCapabilities, AgentCard, @@ -785,3 +786,92 @@ async def test_close(self, mock_httpx_client: AsyncMock): ) await client.close() mock_httpx_client.aclose.assert_called_once() + + +class TestJsonRpcTransportExtensions: + @pytest.mark.asyncio + async def test_send_message_with_default_extensions( + self, mock_httpx_client: AsyncMock, mock_agent_card: MagicMock + ): + """Test that send_message adds extension headers when extensions are provided.""" + extensions = [ + 'https://example.com/test-ext/v1', + 'https://example.com/test-ext/v2', + ] + client = JsonRpcTransport( + httpx_client=mock_httpx_client, + agent_card=mock_agent_card, + extensions=extensions, + ) + params = MessageSendParams( + message=create_text_message_object(content='Hello') + ) + success_response = create_text_message_object( + role=Role.agent, content='Hi there!' + ) + rpc_response = SendMessageSuccessResponse( + id='123', jsonrpc='2.0', result=success_response + ) + # Mock the response from httpx_client.post + mock_response = AsyncMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = rpc_response.model_dump(mode='json') + mock_httpx_client.post.return_value = mock_response + + await client.send_message(request=params) + + 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 + + @pytest.mark.asyncio + @patch('a2a.client.transports.jsonrpc.aconnect_sse') + async def test_send_message_streaming_with_new_extensions( + self, + mock_aconnect_sse: AsyncMock, + mock_httpx_client: AsyncMock, + mock_agent_card: MagicMock, + ): + """Test X-A2A-Extensions header in send_message_streaming.""" + new_extensions = ['https://example.com/test-ext/v2'] + extensions = ['https://example.com/test-ext/v1'] + client = JsonRpcTransport( + httpx_client=mock_httpx_client, + agent_card=mock_agent_card, + extensions=extensions, + ) + params = MessageSendParams( + message=create_text_message_object(content='Hello stream') + ) + + mock_event_source = AsyncMock(spec=EventSource) + mock_event_source.aiter_sse.return_value = async_iterable_from_list([]) + mock_aconnect_sse.return_value.__aenter__.return_value = ( + mock_event_source + ) + + async for _ in client.send_message_streaming( + request=params, extensions=new_extensions + ): + pass + + 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' + ) diff --git a/tests/client/transports/test_rest_client.py b/tests/client/transports/test_rest_client.py new file mode 100644 index 000000000..04bd10361 --- /dev/null +++ b/tests/client/transports/test_rest_client.py @@ -0,0 +1,121 @@ +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from httpx_sse import EventSource, ServerSentEvent + +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 + + +@pytest.fixture +def mock_httpx_client() -> AsyncMock: + return AsyncMock(spec=httpx.AsyncClient) + + +@pytest.fixture +def mock_agent_card() -> MagicMock: + mock = MagicMock(spec=AgentCard, url='http://agent.example.com/api') + mock.supports_authenticated_extended_card = False + return mock + + +async def async_iterable_from_list( + items: list[ServerSentEvent], +) -> AsyncGenerator[ServerSentEvent, None]: + """Helper to create an async iterable from a list.""" + for item in items: + yield item + + +class TestRestTransportExtensions: + @pytest.mark.asyncio + async def test_send_message_with_default_extensions( + self, mock_httpx_client: AsyncMock, mock_agent_card: MagicMock + ): + """Test that send_message adds extensions to headers.""" + extensions = [ + 'https://example.com/test-ext/v1', + 'https://example.com/test-ext/v2', + ] + client = RestTransport( + httpx_client=mock_httpx_client, + extensions=extensions, + agent_card=mock_agent_card, + ) + params = MessageSendParams( + message=create_text_message_object(content='Hello') + ) + + # Mock the build_request method to capture its inputs + mock_build_request = MagicMock( + return_value=AsyncMock(spec=httpx.Request) + ) + mock_httpx_client.build_request = mock_build_request + + # Mock the send method + mock_response = AsyncMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_httpx_client.send.return_value = mock_response + + await client.send_message(request=params) + + 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 + + @pytest.mark.asyncio + @patch('a2a.client.transports.rest.aconnect_sse') + async def test_send_message_streaming_with_new_extensions( + self, + mock_aconnect_sse: AsyncMock, + mock_httpx_client: AsyncMock, + mock_agent_card: MagicMock, + ): + """Test X-A2A-Extensions header in send_message_streaming.""" + new_extensions = ['https://example.com/test-ext/v2'] + extensions = ['https://example.com/test-ext/v1'] + client = RestTransport( + httpx_client=mock_httpx_client, + agent_card=mock_agent_card, + extensions=extensions, + ) + params = MessageSendParams( + message=create_text_message_object(content='Hello stream') + ) + + mock_event_source = AsyncMock(spec=EventSource) + mock_event_source.aiter_sse.return_value = async_iterable_from_list([]) + mock_aconnect_sse.return_value.__aenter__.return_value = ( + mock_event_source + ) + + async for _ in client.send_message_streaming( + request=params, extensions=new_extensions + ): + pass + + 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' + ) diff --git a/tests/extensions/test_common.py b/tests/extensions/test_common.py index 137e64c9a..b3123028a 100644 --- a/tests/extensions/test_common.py +++ b/tests/extensions/test_common.py @@ -1,6 +1,9 @@ +import pytest from a2a.extensions.common import ( + HTTP_EXTENSION_HEADER, find_extension_by_uri, get_requested_extensions, + update_extension_header, ) from a2a.types import AgentCapabilities, AgentCard, AgentExtension @@ -56,3 +59,88 @@ def test_find_extension_by_uri_no_extensions(): ) assert find_extension_by_uri(card, 'foo') is None + + +@pytest.mark.parametrize( + 'extensions, header, expected_extensions', + [ + ( + ['ext1', 'ext2'], # extensions + '', # header + { + 'ext1', + 'ext2', + }, # expected_extensions + ), # Case 1: New extensions provided, empty header. + ( + None, # extensions + 'ext1, ext2', # header + { + 'ext1', + 'ext2', + }, # expected_extensions + ), # Case 2: Extensions is None, existing header extensions. + ( + [], # extensions + 'ext1', # header + {}, # expected_extensions + ), # Case 3: New extensions is empty list, existing header extensions. + ( + ['ext1', 'ext2'], # extensions + 'ext3', # header + { + 'ext1', + 'ext2', + }, # expected_extensions + ), # Case 4: New extensions provided, and an existing header. New extensions should override active extensions. + ], +) +def test_update_extension_header_merge_with_existing_extensions( + extensions: list[str], + header: str, + expected_extensions: set[str], +): + http_kwargs = {'headers': {HTTP_EXTENSION_HEADER: header}} + result_kwargs = update_extension_header(http_kwargs, extensions) + header_value = result_kwargs['headers'][HTTP_EXTENSION_HEADER] + if not header_value: + actual_extensions = {} + else: + actual_extensions_list = [e.strip() for e in header_value.split(',')] + actual_extensions = set(actual_extensions_list) + assert actual_extensions == expected_extensions + + +def test_update_extension_header_with_other_headers(): + extensions = ['ext'] + http_kwargs = {'headers': {'X_Other': 'Test'}} + result_kwargs = update_extension_header(http_kwargs, extensions) + headers = result_kwargs.get('headers', {}) + assert HTTP_EXTENSION_HEADER in headers + assert headers[HTTP_EXTENSION_HEADER] == 'ext' + assert headers['X_Other'] == 'Test' + + +@pytest.mark.parametrize( + 'http_kwargs', + [ + None, + {}, + ], +) +def test_update_extension_header_headers_not_in_kwargs( + http_kwargs: dict[str, str] | None, +): + extensions = ['ext'] + http_kwargs = {} + result_kwargs = update_extension_header(http_kwargs, extensions) + headers = result_kwargs.get('headers', {}) + assert HTTP_EXTENSION_HEADER in headers + assert headers[HTTP_EXTENSION_HEADER] == 'ext' + + +def test_update_extension_header_with_other_headers_extensions_none(): + http_kwargs = {'headers': {'X_Other': 'Test'}} + result_kwargs = update_extension_header(http_kwargs, None) + assert HTTP_EXTENSION_HEADER not in result_kwargs['headers'] + assert result_kwargs['headers']['X_Other'] == 'Test' diff --git a/tests/integration/test_client_server_integration.py b/tests/integration/test_client_server_integration.py index 88d4d3d11..e0a564eee 100644 --- a/tests/integration/test_client_server_integration.py +++ b/tests/integration/test_client_server_integration.py @@ -1,7 +1,7 @@ import asyncio from collections.abc import AsyncGenerator from typing import NamedTuple -from unittest.mock import ANY, AsyncMock +from unittest.mock import ANY, AsyncMock, patch import grpc import httpx @@ -9,6 +9,8 @@ import pytest_asyncio from grpc.aio import Channel +from a2a.client import ClientConfig +from a2a.client.base_client import BaseClient from a2a.client.transports import JsonRpcTransport, RestTransport from a2a.client.transports.base import ClientTransport from a2a.client.transports.grpc import GrpcTransport @@ -767,3 +769,61 @@ def channel_factory(address: str) -> Channel: assert transport._needs_extended_card is False await transport.close() + + +@pytest.mark.asyncio +async def test_base_client_sends_message_with_extensions( + jsonrpc_setup: TransportSetup, agent_card: AgentCard +) -> None: + """ + Integration test for BaseClient with JSON-RPC transport to ensure extensions are included in headers. + """ + transport = jsonrpc_setup.transport + agent_card.capabilities.streaming = False + + # Create a BaseClient instance + client = BaseClient( + card=agent_card, + config=ClientConfig(streaming=False), + transport=transport, + consumers=[], + middleware=[], + ) + + message_to_send = Message( + role=Role.user, + message_id='msg-integration-test-extensions', + parts=[Part(root=TextPart(text='Hello, extensions test!'))], + ) + extensions = [ + 'https://example.com/test-ext/v1', + 'https://example.com/test-ext/v2', + ] + + with patch.object( + transport, '_send_request', new_callable=AsyncMock + ) as mock_send_request: + mock_send_request.return_value = { + 'id': '123', + 'jsonrpc': '2.0', + 'result': TASK_FROM_BLOCKING.model_dump(mode='json'), + } + + # Call send_message on the BaseClient + async for _ in client.send_message( + request=message_to_send, extensions=extensions + ): + pass + + mock_send_request.assert_called_once() + call_args, _ = mock_send_request.call_args + kwargs = call_args[1] + headers = kwargs.get('headers', {}) + assert 'X-A2A-Extensions' in headers + assert ( + headers['X-A2A-Extensions'] + == 'https://example.com/test-ext/v1,https://example.com/test-ext/v2' + ) + + if hasattr(transport, 'close'): + await transport.close() From 2c544f4e8a8cee6bcb5fa2b7c628afb9c31ba336 Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Wed, 19 Nov 2025 05:20:06 -0600 Subject: [PATCH 065/384] chore(main): release 0.4.0 (#553) :robot: I have created a release *beep* *boop* --- ## [0.4.0](https://github.com/a2aproject/a2a-python/compare/v0.3.14...v0.4.0) (2025-11-19) ### Features * Add client-side extension support ([#525](https://github.com/a2aproject/a2a-python/issues/525)) ([9a92bd2](https://github.com/a2aproject/a2a-python/commit/9a92bd238e7560b195165ac5f78742981760525e)) * **rest, jsonrpc:** Add client-side extension support ([9a92bd2](https://github.com/a2aproject/a2a-python/commit/9a92bd238e7560b195165ac5f78742981760525e)) --- 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 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a684ac022..ddab22675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.4.0](https://github.com/a2aproject/a2a-python/compare/v0.3.14...v0.4.0) (2025-11-19) + + +### Features + +* Add client-side extension support ([#525](https://github.com/a2aproject/a2a-python/issues/525)) ([9a92bd2](https://github.com/a2aproject/a2a-python/commit/9a92bd238e7560b195165ac5f78742981760525e)) +* **rest, jsonrpc:** Add client-side extension support ([9a92bd2](https://github.com/a2aproject/a2a-python/commit/9a92bd238e7560b195165ac5f78742981760525e)) + ## [0.3.14](https://github.com/a2aproject/a2a-python/compare/v0.3.13...v0.3.14) (2025-11-17) From 5c59c1a38ceebd1fea354799f35f79bcc2c5a0ac Mon Sep 17 00:00:00 2001 From: Lukasz Kawka Date: Wed, 19 Nov 2025 15:41:00 +0100 Subject: [PATCH 066/384] chore: Revert "chore(main): release 0.4.0" (#554) Reverts a2aproject/a2a-python#553 Release had an incorrect version. Release-As: 0.3.15 --- CHANGELOG.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddab22675..a684ac022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,5 @@ # Changelog -## [0.4.0](https://github.com/a2aproject/a2a-python/compare/v0.3.14...v0.4.0) (2025-11-19) - - -### Features - -* Add client-side extension support ([#525](https://github.com/a2aproject/a2a-python/issues/525)) ([9a92bd2](https://github.com/a2aproject/a2a-python/commit/9a92bd238e7560b195165ac5f78742981760525e)) -* **rest, jsonrpc:** Add client-side extension support ([9a92bd2](https://github.com/a2aproject/a2a-python/commit/9a92bd238e7560b195165ac5f78742981760525e)) - ## [0.3.14](https://github.com/a2aproject/a2a-python/compare/v0.3.13...v0.3.14) (2025-11-17) From fdb47310d75e746b35b798f93402418447ac0e22 Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Wed, 19 Nov 2025 09:42:03 -0600 Subject: [PATCH 067/384] chore(main): release 0.3.15 (#555) :robot: I have created a release *beep* *boop* --- ## [0.3.15](https://github.com/a2aproject/a2a-python/compare/v0.3.14...v0.3.15) (2025-11-19) ### Features * Add client-side extension support ([https://github.com/a2aproject/a2a-python/pull/525](https://github.com/a2aproject/a2a-python/pull/525)) ([https://github.com/a2aproject/a2a-python/commit/9a92bd238e7560b195165ac5f78742981760525e](https://github.com/a2aproject/a2a-python/commit/9a92bd238e7560b195165ac5f78742981760525e)) * **rest, jsonrpc:** Add client-side extension support ([https://github.com/a2aproject/a2a-python/commit/9a92bd238e7560b195165ac5f78742981760525e](https://github.com/a2aproject/a2a-python/commit/9a92bd238e7560b195165ac5f78742981760525e)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: Lukasz Kawka --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a684ac022..f5e6048de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ -# Changelog + # Changelog + +## [0.3.15](https://github.com/a2aproject/a2a-python/compare/v0.3.14...v0.3.15) (2025-11-19) + + +### Features + +* Add client-side extension support ([#525](https://github.com/a2aproject/a2a-python/issues/525)) ([9a92bd2](https://github.com/a2aproject/a2a-python/commit/9a92bd238e7560b195165ac5f78742981760525e)) +* **rest, jsonrpc:** Add client-side extension support ([9a92bd2](https://github.com/a2aproject/a2a-python/commit/9a92bd238e7560b195165ac5f78742981760525e)) ## [0.3.14](https://github.com/a2aproject/a2a-python/compare/v0.3.13...v0.3.14) (2025-11-17) 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 068/384] 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 d077d62bf..8bf01eea9 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 da54f833f..33be1f3f7 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 069/384] 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 f5e6048de..026e8fd1b 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 070/384] 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 5719bc1b0..fac7ecade 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 f5ab25432..7aa47902d 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 071/384] 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 026e8fd1b..66dfd6779 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 d5818e5233d9f0feeab3161cc3b1be3ae236d887 Mon Sep 17 00:00:00 2001 From: "Agent2Agent (A2A) Bot" Date: Mon, 24 Nov 2025 10:23:44 -0600 Subject: [PATCH 072/384] feat(spec): Add `tasks/list` method with filtering and pagination to the specification (#511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit: https://github.com/a2aproject/A2A/commit/0a9f629e801d4ae89f94991fc28afe9429c91cbc This PR introduces support for the new `tasks/list` method, including: - Automatically generated type definitions from the specification. - Complete client-side and server-side implementations. Fixes #515 🦕 --------- Co-authored-by: lkawka Co-authored-by: lkawka Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> --- src/a2a/client/base_client.py | 11 + src/a2a/client/client.py | 11 + src/a2a/client/transports/base.py | 11 + src/a2a/client/transports/grpc.py | 17 ++ src/a2a/client/transports/jsonrpc.py | 24 ++ src/a2a/client/transports/rest.py | 44 ++++ src/a2a/grpc/a2a_pb2.py | 60 ++--- src/a2a/grpc/a2a_pb2.pyi | 28 +++ src/a2a/grpc/a2a_pb2_grpc.py | 44 ++++ src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 6 + .../default_request_handler.py | 29 +++ .../server/request_handlers/grpc_handler.py | 24 ++ .../request_handlers/jsonrpc_handler.py | 37 +++ .../request_handlers/request_handler.py | 19 ++ .../request_handlers/response_helpers.py | 6 + .../server/request_handlers/rest_handler.py | 19 +- src/a2a/server/tasks/database_task_store.py | 102 ++++++++- src/a2a/server/tasks/inmemory_task_store.py | 76 ++++++- src/a2a/server/tasks/task_store.py | 20 +- src/a2a/types.py | 118 ++++++++++ src/a2a/utils/constants.py | 2 + src/a2a/utils/proto_utils.py | 67 ++++++ src/a2a/utils/task.py | 39 ++++ tests/client/test_client_factory.py | 4 +- tests/client/transports/test_grpc_client.py | 38 ++++ .../client/transports/test_jsonrpc_client.py | 38 ++++ tests/client/transports/test_rest_client.py | 2 +- tests/extensions/test_common.py | 1 + .../test_client_server_integration.py | 68 ++++++ .../test_default_request_handler.py | 106 ++++++++- .../request_handlers/test_grpc_handler.py | 36 +++ .../request_handlers/test_jsonrpc_handler.py | 33 +++ .../server/tasks/test_database_task_store.py | 211 +++++++++++++++++ .../server/tasks/test_inmemory_task_store.py | 213 +++++++++++++++++- tests/utils/test_proto_utils.py | 92 ++++++++ tests/utils/test_task.py | 24 +- 36 files changed, 1631 insertions(+), 49 deletions(-) diff --git a/src/a2a/client/base_client.py b/src/a2a/client/base_client.py index 5719bc1b0..a20098be3 100644 --- a/src/a2a/client/base_client.py +++ b/src/a2a/client/base_client.py @@ -15,6 +15,8 @@ from a2a.types import ( AgentCard, GetTaskPushNotificationConfigParams, + ListTasksParams, + ListTasksResult, Message, MessageSendConfiguration, MessageSendParams, @@ -146,6 +148,15 @@ async def get_task( request, context=context, extensions=extensions ) + async def list_tasks( + self, + request: ListTasksParams, + *, + context: ClientCallContext | None = None, + ) -> ListTasksResult: + """Retrieves tasks for an agent.""" + return await self._transport.list_tasks(request, context=context) + async def cancel_task( self, request: TaskIdParams, diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py index fd97b4d14..26da49074 100644 --- a/src/a2a/client/client.py +++ b/src/a2a/client/client.py @@ -12,6 +12,8 @@ from a2a.types import ( AgentCard, GetTaskPushNotificationConfigParams, + ListTasksParams, + ListTasksResult, Message, PushNotificationConfig, Task, @@ -137,6 +139,15 @@ async def get_task( ) -> Task: """Retrieves the current state and history of a specific task.""" + @abstractmethod + async def list_tasks( + self, + request: ListTasksParams, + *, + context: ClientCallContext | None = None, + ) -> ListTasksResult: + """Retrieves tasks for an agent.""" + @abstractmethod async def cancel_task( self, diff --git a/src/a2a/client/transports/base.py b/src/a2a/client/transports/base.py index 8f114d95d..d611ede39 100644 --- a/src/a2a/client/transports/base.py +++ b/src/a2a/client/transports/base.py @@ -5,6 +5,8 @@ from a2a.types import ( AgentCard, GetTaskPushNotificationConfigParams, + ListTasksParams, + ListTasksResult, Message, MessageSendParams, Task, @@ -53,6 +55,15 @@ async def get_task( ) -> Task: """Retrieves the current state and history of a specific task.""" + @abstractmethod + async def list_tasks( + self, + request: ListTasksParams, + *, + context: ClientCallContext | None = None, + ) -> ListTasksResult: + """Retrieves tasks for an agent.""" + @abstractmethod async def cancel_task( self, diff --git a/src/a2a/client/transports/grpc.py b/src/a2a/client/transports/grpc.py index 4e27953af..4c83595e2 100644 --- a/src/a2a/client/transports/grpc.py +++ b/src/a2a/client/transports/grpc.py @@ -2,6 +2,8 @@ from collections.abc import AsyncGenerator +from a2a.utils.constants import DEFAULT_LIST_TASKS_PAGE_SIZE + try: import grpc @@ -22,6 +24,8 @@ from a2a.types import ( AgentCard, GetTaskPushNotificationConfigParams, + ListTasksParams, + ListTasksResult, Message, MessageSendParams, Task, @@ -168,6 +172,19 @@ async def get_task( ) return proto_utils.FromProto.task(task) + async def list_tasks( + self, + request: ListTasksParams, + *, + context: ClientCallContext | None = None, + ) -> ListTasksResult: + """Retrieves tasks for an agent.""" + response = await self.stub.ListTasks( + proto_utils.ToProto.list_tasks_request(request) + ) + page_size = request.page_size or DEFAULT_LIST_TASKS_PAGE_SIZE + return proto_utils.FromProto.list_tasks_result(response, page_size) + async def cancel_task( self, request: TaskIdParams, diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py index d8011cf4d..0444cde58 100644 --- a/src/a2a/client/transports/jsonrpc.py +++ b/src/a2a/client/transports/jsonrpc.py @@ -31,6 +31,10 @@ GetTaskRequest, GetTaskResponse, JSONRPCErrorResponse, + ListTasksParams, + ListTasksRequest, + ListTasksResponse, + ListTasksResult, Message, MessageSendParams, SendMessageRequest, @@ -239,6 +243,26 @@ async def get_task( raise A2AClientJSONRPCError(response.root) return response.root.result + async def list_tasks( + self, + request: ListTasksParams, + *, + context: ClientCallContext | None = None, + ) -> ListTasksResult: + """Retrieves tasks for an agent.""" + rpc_request = ListTasksRequest(params=request, id=str(uuid4())) + payload, modified_kwargs = await self._apply_interceptors( + 'tasks/list', + rpc_request.model_dump(mode='json', exclude_none=True), + self._get_http_args(context), + context, + ) + response_data = await self._send_request(payload, modified_kwargs) + response = ListTasksResponse.model_validate(response_data) + if isinstance(response.root, JSONRPCErrorResponse): + raise A2AClientJSONRPCError(response.root) + return response.root.result + async def cancel_task( self, request: TaskIdParams, diff --git a/src/a2a/client/transports/rest.py b/src/a2a/client/transports/rest.py index 83c267873..20f41c4ab 100644 --- a/src/a2a/client/transports/rest.py +++ b/src/a2a/client/transports/rest.py @@ -8,6 +8,7 @@ from google.protobuf.json_format import MessageToDict, Parse, ParseDict from httpx_sse import SSEError, aconnect_sse +from pydantic import BaseModel from a2a.client.card_resolver import A2ACardResolver from a2a.client.errors import A2AClientHTTPError, A2AClientJSONError @@ -18,6 +19,8 @@ from a2a.types import ( AgentCard, GetTaskPushNotificationConfigParams, + ListTasksParams, + ListTasksResult, Message, MessageSendParams, Task, @@ -28,6 +31,7 @@ TaskStatusUpdateEvent, ) from a2a.utils import proto_utils +from a2a.utils.constants import DEFAULT_LIST_TASKS_PAGE_SIZE from a2a.utils.telemetry import SpanKind, trace_class @@ -239,6 +243,28 @@ async def get_task( ParseDict(response_data, task) return proto_utils.FromProto.task(task) + async def list_tasks( + self, + request: ListTasksParams, + *, + context: ClientCallContext | None = None, + ) -> ListTasksResult: + """Retrieves tasks for an agent.""" + _, modified_kwargs = await self._apply_interceptors( + request.model_dump(mode='json', exclude_none=True), + self._get_http_args(context), + context, + ) + response_data = await self._send_get_request( + '/v1/tasks', + _model_to_query_params(request), + modified_kwargs, + ) + response = a2a_pb2.ListTasksResponse() + ParseDict(response_data, response) + page_size = request.page_size or DEFAULT_LIST_TASKS_PAGE_SIZE + return proto_utils.FromProto.list_tasks_result(response, page_size) + async def cancel_task( self, request: TaskIdParams, @@ -404,3 +430,21 @@ async def get_card( async def close(self) -> None: """Closes the httpx client.""" await self.httpx_client.aclose() + + +def _model_to_query_params(instance: BaseModel) -> dict[str, str]: + data = instance.model_dump(mode='json', exclude_none=True) + return _json_to_query_params(data) + + +def _json_to_query_params(data: dict[str, Any]) -> dict[str, str]: + query_dict = {} + for key, value in data.items(): + if isinstance(value, list): + query_dict[key] = ','.join(map(str, value)) + elif isinstance(value, bool): + query_dict[key] = str(value).lower() + else: + query_dict[key] = str(value) + + return query_dict diff --git a/src/a2a/grpc/a2a_pb2.py b/src/a2a/grpc/a2a_pb2.py index 9b4b73013..bbb2429cd 100644 --- a/src/a2a/grpc/a2a_pb2.py +++ b/src/a2a/grpc/a2a_pb2.py @@ -30,7 +30,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\ta2a.proto\x12\x06\x61\x32\x61.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xde\x01\n\x18SendMessageConfiguration\x12\x32\n\x15\x61\x63\x63\x65pted_output_modes\x18\x01 \x03(\tR\x13\x61\x63\x63\x65ptedOutputModes\x12K\n\x11push_notification\x18\x02 \x01(\x0b\x32\x1e.a2a.v1.PushNotificationConfigR\x10pushNotification\x12%\n\x0ehistory_length\x18\x03 \x01(\x05R\rhistoryLength\x12\x1a\n\x08\x62locking\x18\x04 \x01(\x08R\x08\x62locking\"\xf1\x01\n\x04Task\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12*\n\x06status\x18\x03 \x01(\x0b\x32\x12.a2a.v1.TaskStatusR\x06status\x12.\n\tartifacts\x18\x04 \x03(\x0b\x32\x10.a2a.v1.ArtifactR\tartifacts\x12)\n\x07history\x18\x05 \x03(\x0b\x32\x0f.a2a.v1.MessageR\x07history\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"\x99\x01\n\nTaskStatus\x12\'\n\x05state\x18\x01 \x01(\x0e\x32\x11.a2a.v1.TaskStateR\x05state\x12(\n\x06update\x18\x02 \x01(\x0b\x32\x0f.a2a.v1.MessageR\x07message\x12\x38\n\ttimestamp\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\ttimestamp\"\xa9\x01\n\x04Part\x12\x14\n\x04text\x18\x01 \x01(\tH\x00R\x04text\x12&\n\x04\x66ile\x18\x02 \x01(\x0b\x32\x10.a2a.v1.FilePartH\x00R\x04\x66ile\x12&\n\x04\x64\x61ta\x18\x03 \x01(\x0b\x32\x10.a2a.v1.DataPartH\x00R\x04\x64\x61ta\x12\x33\n\x08metadata\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadataB\x06\n\x04part\"\x93\x01\n\x08\x46ilePart\x12$\n\rfile_with_uri\x18\x01 \x01(\tH\x00R\x0b\x66ileWithUri\x12(\n\x0f\x66ile_with_bytes\x18\x02 \x01(\x0cH\x00R\rfileWithBytes\x12\x1b\n\tmime_type\x18\x03 \x01(\tR\x08mimeType\x12\x12\n\x04name\x18\x04 \x01(\tR\x04nameB\x06\n\x04\x66ile\"7\n\x08\x44\x61taPart\x12+\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.StructR\x04\x64\x61ta\"\xff\x01\n\x07Message\x12\x1d\n\nmessage_id\x18\x01 \x01(\tR\tmessageId\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12\x17\n\x07task_id\x18\x03 \x01(\tR\x06taskId\x12 \n\x04role\x18\x04 \x01(\x0e\x32\x0c.a2a.v1.RoleR\x04role\x12&\n\x07\x63ontent\x18\x05 \x03(\x0b\x32\x0c.a2a.v1.PartR\x07\x63ontent\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\x12\x1e\n\nextensions\x18\x07 \x03(\tR\nextensions\"\xda\x01\n\x08\x41rtifact\x12\x1f\n\x0b\x61rtifact_id\x18\x01 \x01(\tR\nartifactId\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\x12 \n\x0b\x64\x65scription\x18\x04 \x01(\tR\x0b\x64\x65scription\x12\"\n\x05parts\x18\x05 \x03(\x0b\x32\x0c.a2a.v1.PartR\x05parts\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\x12\x1e\n\nextensions\x18\x07 \x03(\tR\nextensions\"\xc6\x01\n\x15TaskStatusUpdateEvent\x12\x17\n\x07task_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12*\n\x06status\x18\x03 \x01(\x0b\x32\x12.a2a.v1.TaskStatusR\x06status\x12\x14\n\x05\x66inal\x18\x04 \x01(\x08R\x05\x66inal\x12\x33\n\x08metadata\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"\xeb\x01\n\x17TaskArtifactUpdateEvent\x12\x17\n\x07task_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12,\n\x08\x61rtifact\x18\x03 \x01(\x0b\x32\x10.a2a.v1.ArtifactR\x08\x61rtifact\x12\x16\n\x06\x61ppend\x18\x04 \x01(\x08R\x06\x61ppend\x12\x1d\n\nlast_chunk\x18\x05 \x01(\x08R\tlastChunk\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"\x94\x01\n\x16PushNotificationConfig\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n\x03url\x18\x02 \x01(\tR\x03url\x12\x14\n\x05token\x18\x03 \x01(\tR\x05token\x12\x42\n\x0e\x61uthentication\x18\x04 \x01(\x0b\x32\x1a.a2a.v1.AuthenticationInfoR\x0e\x61uthentication\"P\n\x12\x41uthenticationInfo\x12\x18\n\x07schemes\x18\x01 \x03(\tR\x07schemes\x12 \n\x0b\x63redentials\x18\x02 \x01(\tR\x0b\x63redentials\"@\n\x0e\x41gentInterface\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\x1c\n\ttransport\x18\x02 \x01(\tR\ttransport\"\xc8\x07\n\tAgentCard\x12)\n\x10protocol_version\x18\x10 \x01(\tR\x0fprotocolVersion\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12 \n\x0b\x64\x65scription\x18\x02 \x01(\tR\x0b\x64\x65scription\x12\x10\n\x03url\x18\x03 \x01(\tR\x03url\x12/\n\x13preferred_transport\x18\x0e \x01(\tR\x12preferredTransport\x12K\n\x15\x61\x64\x64itional_interfaces\x18\x0f \x03(\x0b\x32\x16.a2a.v1.AgentInterfaceR\x14\x61\x64\x64itionalInterfaces\x12\x31\n\x08provider\x18\x04 \x01(\x0b\x32\x15.a2a.v1.AgentProviderR\x08provider\x12\x18\n\x07version\x18\x05 \x01(\tR\x07version\x12+\n\x11\x64ocumentation_url\x18\x06 \x01(\tR\x10\x64ocumentationUrl\x12=\n\x0c\x63\x61pabilities\x18\x07 \x01(\x0b\x32\x19.a2a.v1.AgentCapabilitiesR\x0c\x63\x61pabilities\x12Q\n\x10security_schemes\x18\x08 \x03(\x0b\x32&.a2a.v1.AgentCard.SecuritySchemesEntryR\x0fsecuritySchemes\x12,\n\x08security\x18\t \x03(\x0b\x32\x10.a2a.v1.SecurityR\x08security\x12.\n\x13\x64\x65\x66\x61ult_input_modes\x18\n \x03(\tR\x11\x64\x65\x66\x61ultInputModes\x12\x30\n\x14\x64\x65\x66\x61ult_output_modes\x18\x0b \x03(\tR\x12\x64\x65\x66\x61ultOutputModes\x12*\n\x06skills\x18\x0c \x03(\x0b\x32\x12.a2a.v1.AgentSkillR\x06skills\x12O\n$supports_authenticated_extended_card\x18\r \x01(\x08R!supportsAuthenticatedExtendedCard\x12:\n\nsignatures\x18\x11 \x03(\x0b\x32\x1a.a2a.v1.AgentCardSignatureR\nsignatures\x12\x19\n\x08icon_url\x18\x12 \x01(\tR\x07iconUrl\x1aZ\n\x14SecuritySchemesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12,\n\x05value\x18\x02 \x01(\x0b\x32\x16.a2a.v1.SecuritySchemeR\x05value:\x02\x38\x01\"E\n\rAgentProvider\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\"\n\x0corganization\x18\x02 \x01(\tR\x0corganization\"\x98\x01\n\x11\x41gentCapabilities\x12\x1c\n\tstreaming\x18\x01 \x01(\x08R\tstreaming\x12-\n\x12push_notifications\x18\x02 \x01(\x08R\x11pushNotifications\x12\x36\n\nextensions\x18\x03 \x03(\x0b\x32\x16.a2a.v1.AgentExtensionR\nextensions\"\x91\x01\n\x0e\x41gentExtension\x12\x10\n\x03uri\x18\x01 \x01(\tR\x03uri\x12 \n\x0b\x64\x65scription\x18\x02 \x01(\tR\x0b\x64\x65scription\x12\x1a\n\x08required\x18\x03 \x01(\x08R\x08required\x12/\n\x06params\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x06params\"\xf4\x01\n\nAgentSkill\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n\x04name\x18\x02 \x01(\tR\x04name\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12\x12\n\x04tags\x18\x04 \x03(\tR\x04tags\x12\x1a\n\x08\x65xamples\x18\x05 \x03(\tR\x08\x65xamples\x12\x1f\n\x0binput_modes\x18\x06 \x03(\tR\ninputModes\x12!\n\x0coutput_modes\x18\x07 \x03(\tR\x0boutputModes\x12,\n\x08security\x18\x08 \x03(\x0b\x32\x10.a2a.v1.SecurityR\x08security\"\x8b\x01\n\x12\x41gentCardSignature\x12!\n\tprotected\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tprotected\x12!\n\tsignature\x18\x02 \x01(\tB\x03\xe0\x41\x02R\tsignature\x12/\n\x06header\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\x06header\"\x8a\x01\n\x1aTaskPushNotificationConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12X\n\x18push_notification_config\x18\x02 \x01(\x0b\x32\x1e.a2a.v1.PushNotificationConfigR\x16pushNotificationConfig\" \n\nStringList\x12\x12\n\x04list\x18\x01 \x03(\tR\x04list\"\x93\x01\n\x08Security\x12\x37\n\x07schemes\x18\x01 \x03(\x0b\x32\x1d.a2a.v1.Security.SchemesEntryR\x07schemes\x1aN\n\x0cSchemesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x12.a2a.v1.StringListR\x05value:\x02\x38\x01\"\xe6\x03\n\x0eSecurityScheme\x12U\n\x17\x61pi_key_security_scheme\x18\x01 \x01(\x0b\x32\x1c.a2a.v1.APIKeySecuritySchemeH\x00R\x14\x61piKeySecurityScheme\x12[\n\x19http_auth_security_scheme\x18\x02 \x01(\x0b\x32\x1e.a2a.v1.HTTPAuthSecuritySchemeH\x00R\x16httpAuthSecurityScheme\x12T\n\x16oauth2_security_scheme\x18\x03 \x01(\x0b\x32\x1c.a2a.v1.OAuth2SecuritySchemeH\x00R\x14oauth2SecurityScheme\x12k\n\x1fopen_id_connect_security_scheme\x18\x04 \x01(\x0b\x32#.a2a.v1.OpenIdConnectSecuritySchemeH\x00R\x1bopenIdConnectSecurityScheme\x12S\n\x14mtls_security_scheme\x18\x05 \x01(\x0b\x32\x1f.a2a.v1.MutualTlsSecuritySchemeH\x00R\x12mtlsSecuritySchemeB\x08\n\x06scheme\"h\n\x14\x41PIKeySecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12\x1a\n\x08location\x18\x02 \x01(\tR\x08location\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\"w\n\x16HTTPAuthSecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12\x16\n\x06scheme\x18\x02 \x01(\tR\x06scheme\x12#\n\rbearer_format\x18\x03 \x01(\tR\x0c\x62\x65\x61rerFormat\"\x92\x01\n\x14OAuth2SecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12(\n\x05\x66lows\x18\x02 \x01(\x0b\x32\x12.a2a.v1.OAuthFlowsR\x05\x66lows\x12.\n\x13oauth2_metadata_url\x18\x03 \x01(\tR\x11oauth2MetadataUrl\"n\n\x1bOpenIdConnectSecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12-\n\x13open_id_connect_url\x18\x02 \x01(\tR\x10openIdConnectUrl\";\n\x17MutualTlsSecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\"\xb0\x02\n\nOAuthFlows\x12S\n\x12\x61uthorization_code\x18\x01 \x01(\x0b\x32\".a2a.v1.AuthorizationCodeOAuthFlowH\x00R\x11\x61uthorizationCode\x12S\n\x12\x63lient_credentials\x18\x02 \x01(\x0b\x32\".a2a.v1.ClientCredentialsOAuthFlowH\x00R\x11\x63lientCredentials\x12\x37\n\x08implicit\x18\x03 \x01(\x0b\x32\x19.a2a.v1.ImplicitOAuthFlowH\x00R\x08implicit\x12\x37\n\x08password\x18\x04 \x01(\x0b\x32\x19.a2a.v1.PasswordOAuthFlowH\x00R\x08passwordB\x06\n\x04\x66low\"\x8a\x02\n\x1a\x41uthorizationCodeOAuthFlow\x12+\n\x11\x61uthorization_url\x18\x01 \x01(\tR\x10\x61uthorizationUrl\x12\x1b\n\ttoken_url\x18\x02 \x01(\tR\x08tokenUrl\x12\x1f\n\x0brefresh_url\x18\x03 \x01(\tR\nrefreshUrl\x12\x46\n\x06scopes\x18\x04 \x03(\x0b\x32..a2a.v1.AuthorizationCodeOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdd\x01\n\x1a\x43lientCredentialsOAuthFlow\x12\x1b\n\ttoken_url\x18\x01 \x01(\tR\x08tokenUrl\x12\x1f\n\x0brefresh_url\x18\x02 \x01(\tR\nrefreshUrl\x12\x46\n\x06scopes\x18\x03 \x03(\x0b\x32..a2a.v1.ClientCredentialsOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdb\x01\n\x11ImplicitOAuthFlow\x12+\n\x11\x61uthorization_url\x18\x01 \x01(\tR\x10\x61uthorizationUrl\x12\x1f\n\x0brefresh_url\x18\x02 \x01(\tR\nrefreshUrl\x12=\n\x06scopes\x18\x03 \x03(\x0b\x32%.a2a.v1.ImplicitOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xcb\x01\n\x11PasswordOAuthFlow\x12\x1b\n\ttoken_url\x18\x01 \x01(\tR\x08tokenUrl\x12\x1f\n\x0brefresh_url\x18\x02 \x01(\tR\nrefreshUrl\x12=\n\x06scopes\x18\x03 \x03(\x0b\x32%.a2a.v1.PasswordOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xc1\x01\n\x12SendMessageRequest\x12.\n\x07request\x18\x01 \x01(\x0b\x32\x0f.a2a.v1.MessageB\x03\xe0\x41\x02R\x07message\x12\x46\n\rconfiguration\x18\x02 \x01(\x0b\x32 .a2a.v1.SendMessageConfigurationR\rconfiguration\x12\x33\n\x08metadata\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"P\n\x0eGetTaskRequest\x12\x17\n\x04name\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x04name\x12%\n\x0ehistory_length\x18\x02 \x01(\x05R\rhistoryLength\"\'\n\x11\x43\x61ncelTaskRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\":\n$GetTaskPushNotificationConfigRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\"=\n\'DeleteTaskPushNotificationConfigRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\"\xa9\x01\n\'CreateTaskPushNotificationConfigRequest\x12\x1b\n\x06parent\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x06parent\x12 \n\tconfig_id\x18\x02 \x01(\tB\x03\xe0\x41\x02R\x08\x63onfigId\x12?\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\".a2a.v1.TaskPushNotificationConfigB\x03\xe0\x41\x02R\x06\x63onfig\"-\n\x17TaskSubscriptionRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\"{\n%ListTaskPushNotificationConfigRequest\x12\x16\n\x06parent\x18\x01 \x01(\tR\x06parent\x12\x1b\n\tpage_size\x18\x02 \x01(\x05R\x08pageSize\x12\x1d\n\npage_token\x18\x03 \x01(\tR\tpageToken\"\x15\n\x13GetAgentCardRequest\"m\n\x13SendMessageResponse\x12\"\n\x04task\x18\x01 \x01(\x0b\x32\x0c.a2a.v1.TaskH\x00R\x04task\x12\'\n\x03msg\x18\x02 \x01(\x0b\x32\x0f.a2a.v1.MessageH\x00R\x07messageB\t\n\x07payload\"\xfa\x01\n\x0eStreamResponse\x12\"\n\x04task\x18\x01 \x01(\x0b\x32\x0c.a2a.v1.TaskH\x00R\x04task\x12\'\n\x03msg\x18\x02 \x01(\x0b\x32\x0f.a2a.v1.MessageH\x00R\x07message\x12\x44\n\rstatus_update\x18\x03 \x01(\x0b\x32\x1d.a2a.v1.TaskStatusUpdateEventH\x00R\x0cstatusUpdate\x12J\n\x0f\x61rtifact_update\x18\x04 \x01(\x0b\x32\x1f.a2a.v1.TaskArtifactUpdateEventH\x00R\x0e\x61rtifactUpdateB\t\n\x07payload\"\x8e\x01\n&ListTaskPushNotificationConfigResponse\x12<\n\x07\x63onfigs\x18\x01 \x03(\x0b\x32\".a2a.v1.TaskPushNotificationConfigR\x07\x63onfigs\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken*\xfa\x01\n\tTaskState\x12\x1a\n\x16TASK_STATE_UNSPECIFIED\x10\x00\x12\x18\n\x14TASK_STATE_SUBMITTED\x10\x01\x12\x16\n\x12TASK_STATE_WORKING\x10\x02\x12\x18\n\x14TASK_STATE_COMPLETED\x10\x03\x12\x15\n\x11TASK_STATE_FAILED\x10\x04\x12\x18\n\x14TASK_STATE_CANCELLED\x10\x05\x12\x1d\n\x19TASK_STATE_INPUT_REQUIRED\x10\x06\x12\x17\n\x13TASK_STATE_REJECTED\x10\x07\x12\x1c\n\x18TASK_STATE_AUTH_REQUIRED\x10\x08*;\n\x04Role\x12\x14\n\x10ROLE_UNSPECIFIED\x10\x00\x12\r\n\tROLE_USER\x10\x01\x12\x0e\n\nROLE_AGENT\x10\x02\x32\xbb\n\n\nA2AService\x12\x63\n\x0bSendMessage\x12\x1a.a2a.v1.SendMessageRequest\x1a\x1b.a2a.v1.SendMessageResponse\"\x1b\x82\xd3\xe4\x93\x02\x15\"\x10/v1/message:send:\x01*\x12k\n\x14SendStreamingMessage\x12\x1a.a2a.v1.SendMessageRequest\x1a\x16.a2a.v1.StreamResponse\"\x1d\x82\xd3\xe4\x93\x02\x17\"\x12/v1/message:stream:\x01*0\x01\x12R\n\x07GetTask\x12\x16.a2a.v1.GetTaskRequest\x1a\x0c.a2a.v1.Task\"!\xda\x41\x04name\x82\xd3\xe4\x93\x02\x14\x12\x12/v1/{name=tasks/*}\x12[\n\nCancelTask\x12\x19.a2a.v1.CancelTaskRequest\x1a\x0c.a2a.v1.Task\"$\x82\xd3\xe4\x93\x02\x1e\"\x19/v1/{name=tasks/*}:cancel:\x01*\x12s\n\x10TaskSubscription\x12\x1f.a2a.v1.TaskSubscriptionRequest\x1a\x16.a2a.v1.StreamResponse\"$\x82\xd3\xe4\x93\x02\x1e\x12\x1c/v1/{name=tasks/*}:subscribe0\x01\x12\xc5\x01\n CreateTaskPushNotificationConfig\x12/.a2a.v1.CreateTaskPushNotificationConfigRequest\x1a\".a2a.v1.TaskPushNotificationConfig\"L\xda\x41\rparent,config\x82\xd3\xe4\x93\x02\x36\",/v1/{parent=tasks/*/pushNotificationConfigs}:\x06\x63onfig\x12\xae\x01\n\x1dGetTaskPushNotificationConfig\x12,.a2a.v1.GetTaskPushNotificationConfigRequest\x1a\".a2a.v1.TaskPushNotificationConfig\";\xda\x41\x04name\x82\xd3\xe4\x93\x02.\x12,/v1/{name=tasks/*/pushNotificationConfigs/*}\x12\xbe\x01\n\x1eListTaskPushNotificationConfig\x12-.a2a.v1.ListTaskPushNotificationConfigRequest\x1a..a2a.v1.ListTaskPushNotificationConfigResponse\"=\xda\x41\x06parent\x82\xd3\xe4\x93\x02.\x12,/v1/{parent=tasks/*}/pushNotificationConfigs\x12P\n\x0cGetAgentCard\x12\x1b.a2a.v1.GetAgentCardRequest\x1a\x11.a2a.v1.AgentCard\"\x10\x82\xd3\xe4\x93\x02\n\x12\x08/v1/card\x12\xa8\x01\n DeleteTaskPushNotificationConfig\x12/.a2a.v1.DeleteTaskPushNotificationConfigRequest\x1a\x16.google.protobuf.Empty\";\xda\x41\x04name\x82\xd3\xe4\x93\x02.*,/v1/{name=tasks/*/pushNotificationConfigs/*}Bi\n\ncom.a2a.v1B\x08\x41\x32\x61ProtoP\x01Z\x18google.golang.org/a2a/v1\xa2\x02\x03\x41XX\xaa\x02\x06\x41\x32\x61.V1\xca\x02\x06\x41\x32\x61\\V1\xe2\x02\x12\x41\x32\x61\\V1\\GPBMetadata\xea\x02\x07\x41\x32\x61::V1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\ta2a.proto\x12\x06\x61\x32\x61.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xde\x01\n\x18SendMessageConfiguration\x12\x32\n\x15\x61\x63\x63\x65pted_output_modes\x18\x01 \x03(\tR\x13\x61\x63\x63\x65ptedOutputModes\x12K\n\x11push_notification\x18\x02 \x01(\x0b\x32\x1e.a2a.v1.PushNotificationConfigR\x10pushNotification\x12%\n\x0ehistory_length\x18\x03 \x01(\x05R\rhistoryLength\x12\x1a\n\x08\x62locking\x18\x04 \x01(\x08R\x08\x62locking\"\xf1\x01\n\x04Task\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12*\n\x06status\x18\x03 \x01(\x0b\x32\x12.a2a.v1.TaskStatusR\x06status\x12.\n\tartifacts\x18\x04 \x03(\x0b\x32\x10.a2a.v1.ArtifactR\tartifacts\x12)\n\x07history\x18\x05 \x03(\x0b\x32\x0f.a2a.v1.MessageR\x07history\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"\x99\x01\n\nTaskStatus\x12\'\n\x05state\x18\x01 \x01(\x0e\x32\x11.a2a.v1.TaskStateR\x05state\x12(\n\x06update\x18\x02 \x01(\x0b\x32\x0f.a2a.v1.MessageR\x07message\x12\x38\n\ttimestamp\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\ttimestamp\"\xa9\x01\n\x04Part\x12\x14\n\x04text\x18\x01 \x01(\tH\x00R\x04text\x12&\n\x04\x66ile\x18\x02 \x01(\x0b\x32\x10.a2a.v1.FilePartH\x00R\x04\x66ile\x12&\n\x04\x64\x61ta\x18\x03 \x01(\x0b\x32\x10.a2a.v1.DataPartH\x00R\x04\x64\x61ta\x12\x33\n\x08metadata\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadataB\x06\n\x04part\"\x93\x01\n\x08\x46ilePart\x12$\n\rfile_with_uri\x18\x01 \x01(\tH\x00R\x0b\x66ileWithUri\x12(\n\x0f\x66ile_with_bytes\x18\x02 \x01(\x0cH\x00R\rfileWithBytes\x12\x1b\n\tmime_type\x18\x03 \x01(\tR\x08mimeType\x12\x12\n\x04name\x18\x04 \x01(\tR\x04nameB\x06\n\x04\x66ile\"7\n\x08\x44\x61taPart\x12+\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.StructR\x04\x64\x61ta\"\xff\x01\n\x07Message\x12\x1d\n\nmessage_id\x18\x01 \x01(\tR\tmessageId\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12\x17\n\x07task_id\x18\x03 \x01(\tR\x06taskId\x12 \n\x04role\x18\x04 \x01(\x0e\x32\x0c.a2a.v1.RoleR\x04role\x12&\n\x07\x63ontent\x18\x05 \x03(\x0b\x32\x0c.a2a.v1.PartR\x07\x63ontent\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\x12\x1e\n\nextensions\x18\x07 \x03(\tR\nextensions\"\xda\x01\n\x08\x41rtifact\x12\x1f\n\x0b\x61rtifact_id\x18\x01 \x01(\tR\nartifactId\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\x12 \n\x0b\x64\x65scription\x18\x04 \x01(\tR\x0b\x64\x65scription\x12\"\n\x05parts\x18\x05 \x03(\x0b\x32\x0c.a2a.v1.PartR\x05parts\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\x12\x1e\n\nextensions\x18\x07 \x03(\tR\nextensions\"\xc6\x01\n\x15TaskStatusUpdateEvent\x12\x17\n\x07task_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12*\n\x06status\x18\x03 \x01(\x0b\x32\x12.a2a.v1.TaskStatusR\x06status\x12\x14\n\x05\x66inal\x18\x04 \x01(\x08R\x05\x66inal\x12\x33\n\x08metadata\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"\xeb\x01\n\x17TaskArtifactUpdateEvent\x12\x17\n\x07task_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n\ncontext_id\x18\x02 \x01(\tR\tcontextId\x12,\n\x08\x61rtifact\x18\x03 \x01(\x0b\x32\x10.a2a.v1.ArtifactR\x08\x61rtifact\x12\x16\n\x06\x61ppend\x18\x04 \x01(\x08R\x06\x61ppend\x12\x1d\n\nlast_chunk\x18\x05 \x01(\x08R\tlastChunk\x12\x33\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"\x94\x01\n\x16PushNotificationConfig\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n\x03url\x18\x02 \x01(\tR\x03url\x12\x14\n\x05token\x18\x03 \x01(\tR\x05token\x12\x42\n\x0e\x61uthentication\x18\x04 \x01(\x0b\x32\x1a.a2a.v1.AuthenticationInfoR\x0e\x61uthentication\"P\n\x12\x41uthenticationInfo\x12\x18\n\x07schemes\x18\x01 \x03(\tR\x07schemes\x12 \n\x0b\x63redentials\x18\x02 \x01(\tR\x0b\x63redentials\"@\n\x0e\x41gentInterface\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\x1c\n\ttransport\x18\x02 \x01(\tR\ttransport\"\xc8\x07\n\tAgentCard\x12)\n\x10protocol_version\x18\x10 \x01(\tR\x0fprotocolVersion\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12 \n\x0b\x64\x65scription\x18\x02 \x01(\tR\x0b\x64\x65scription\x12\x10\n\x03url\x18\x03 \x01(\tR\x03url\x12/\n\x13preferred_transport\x18\x0e \x01(\tR\x12preferredTransport\x12K\n\x15\x61\x64\x64itional_interfaces\x18\x0f \x03(\x0b\x32\x16.a2a.v1.AgentInterfaceR\x14\x61\x64\x64itionalInterfaces\x12\x31\n\x08provider\x18\x04 \x01(\x0b\x32\x15.a2a.v1.AgentProviderR\x08provider\x12\x18\n\x07version\x18\x05 \x01(\tR\x07version\x12+\n\x11\x64ocumentation_url\x18\x06 \x01(\tR\x10\x64ocumentationUrl\x12=\n\x0c\x63\x61pabilities\x18\x07 \x01(\x0b\x32\x19.a2a.v1.AgentCapabilitiesR\x0c\x63\x61pabilities\x12Q\n\x10security_schemes\x18\x08 \x03(\x0b\x32&.a2a.v1.AgentCard.SecuritySchemesEntryR\x0fsecuritySchemes\x12,\n\x08security\x18\t \x03(\x0b\x32\x10.a2a.v1.SecurityR\x08security\x12.\n\x13\x64\x65\x66\x61ult_input_modes\x18\n \x03(\tR\x11\x64\x65\x66\x61ultInputModes\x12\x30\n\x14\x64\x65\x66\x61ult_output_modes\x18\x0b \x03(\tR\x12\x64\x65\x66\x61ultOutputModes\x12*\n\x06skills\x18\x0c \x03(\x0b\x32\x12.a2a.v1.AgentSkillR\x06skills\x12O\n$supports_authenticated_extended_card\x18\r \x01(\x08R!supportsAuthenticatedExtendedCard\x12:\n\nsignatures\x18\x11 \x03(\x0b\x32\x1a.a2a.v1.AgentCardSignatureR\nsignatures\x12\x19\n\x08icon_url\x18\x12 \x01(\tR\x07iconUrl\x1aZ\n\x14SecuritySchemesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12,\n\x05value\x18\x02 \x01(\x0b\x32\x16.a2a.v1.SecuritySchemeR\x05value:\x02\x38\x01\"E\n\rAgentProvider\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\"\n\x0corganization\x18\x02 \x01(\tR\x0corganization\"\x98\x01\n\x11\x41gentCapabilities\x12\x1c\n\tstreaming\x18\x01 \x01(\x08R\tstreaming\x12-\n\x12push_notifications\x18\x02 \x01(\x08R\x11pushNotifications\x12\x36\n\nextensions\x18\x03 \x03(\x0b\x32\x16.a2a.v1.AgentExtensionR\nextensions\"\x91\x01\n\x0e\x41gentExtension\x12\x10\n\x03uri\x18\x01 \x01(\tR\x03uri\x12 \n\x0b\x64\x65scription\x18\x02 \x01(\tR\x0b\x64\x65scription\x12\x1a\n\x08required\x18\x03 \x01(\x08R\x08required\x12/\n\x06params\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x06params\"\xf4\x01\n\nAgentSkill\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n\x04name\x18\x02 \x01(\tR\x04name\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12\x12\n\x04tags\x18\x04 \x03(\tR\x04tags\x12\x1a\n\x08\x65xamples\x18\x05 \x03(\tR\x08\x65xamples\x12\x1f\n\x0binput_modes\x18\x06 \x03(\tR\ninputModes\x12!\n\x0coutput_modes\x18\x07 \x03(\tR\x0boutputModes\x12,\n\x08security\x18\x08 \x03(\x0b\x32\x10.a2a.v1.SecurityR\x08security\"\x8b\x01\n\x12\x41gentCardSignature\x12!\n\tprotected\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tprotected\x12!\n\tsignature\x18\x02 \x01(\tB\x03\xe0\x41\x02R\tsignature\x12/\n\x06header\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\x06header\"\x8a\x01\n\x1aTaskPushNotificationConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12X\n\x18push_notification_config\x18\x02 \x01(\x0b\x32\x1e.a2a.v1.PushNotificationConfigR\x16pushNotificationConfig\" \n\nStringList\x12\x12\n\x04list\x18\x01 \x03(\tR\x04list\"\x93\x01\n\x08Security\x12\x37\n\x07schemes\x18\x01 \x03(\x0b\x32\x1d.a2a.v1.Security.SchemesEntryR\x07schemes\x1aN\n\x0cSchemesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x12.a2a.v1.StringListR\x05value:\x02\x38\x01\"\xe6\x03\n\x0eSecurityScheme\x12U\n\x17\x61pi_key_security_scheme\x18\x01 \x01(\x0b\x32\x1c.a2a.v1.APIKeySecuritySchemeH\x00R\x14\x61piKeySecurityScheme\x12[\n\x19http_auth_security_scheme\x18\x02 \x01(\x0b\x32\x1e.a2a.v1.HTTPAuthSecuritySchemeH\x00R\x16httpAuthSecurityScheme\x12T\n\x16oauth2_security_scheme\x18\x03 \x01(\x0b\x32\x1c.a2a.v1.OAuth2SecuritySchemeH\x00R\x14oauth2SecurityScheme\x12k\n\x1fopen_id_connect_security_scheme\x18\x04 \x01(\x0b\x32#.a2a.v1.OpenIdConnectSecuritySchemeH\x00R\x1bopenIdConnectSecurityScheme\x12S\n\x14mtls_security_scheme\x18\x05 \x01(\x0b\x32\x1f.a2a.v1.MutualTlsSecuritySchemeH\x00R\x12mtlsSecuritySchemeB\x08\n\x06scheme\"h\n\x14\x41PIKeySecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12\x1a\n\x08location\x18\x02 \x01(\tR\x08location\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\"w\n\x16HTTPAuthSecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12\x16\n\x06scheme\x18\x02 \x01(\tR\x06scheme\x12#\n\rbearer_format\x18\x03 \x01(\tR\x0c\x62\x65\x61rerFormat\"\x92\x01\n\x14OAuth2SecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12(\n\x05\x66lows\x18\x02 \x01(\x0b\x32\x12.a2a.v1.OAuthFlowsR\x05\x66lows\x12.\n\x13oauth2_metadata_url\x18\x03 \x01(\tR\x11oauth2MetadataUrl\"n\n\x1bOpenIdConnectSecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12-\n\x13open_id_connect_url\x18\x02 \x01(\tR\x10openIdConnectUrl\";\n\x17MutualTlsSecurityScheme\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\"\xb0\x02\n\nOAuthFlows\x12S\n\x12\x61uthorization_code\x18\x01 \x01(\x0b\x32\".a2a.v1.AuthorizationCodeOAuthFlowH\x00R\x11\x61uthorizationCode\x12S\n\x12\x63lient_credentials\x18\x02 \x01(\x0b\x32\".a2a.v1.ClientCredentialsOAuthFlowH\x00R\x11\x63lientCredentials\x12\x37\n\x08implicit\x18\x03 \x01(\x0b\x32\x19.a2a.v1.ImplicitOAuthFlowH\x00R\x08implicit\x12\x37\n\x08password\x18\x04 \x01(\x0b\x32\x19.a2a.v1.PasswordOAuthFlowH\x00R\x08passwordB\x06\n\x04\x66low\"\x8a\x02\n\x1a\x41uthorizationCodeOAuthFlow\x12+\n\x11\x61uthorization_url\x18\x01 \x01(\tR\x10\x61uthorizationUrl\x12\x1b\n\ttoken_url\x18\x02 \x01(\tR\x08tokenUrl\x12\x1f\n\x0brefresh_url\x18\x03 \x01(\tR\nrefreshUrl\x12\x46\n\x06scopes\x18\x04 \x03(\x0b\x32..a2a.v1.AuthorizationCodeOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdd\x01\n\x1a\x43lientCredentialsOAuthFlow\x12\x1b\n\ttoken_url\x18\x01 \x01(\tR\x08tokenUrl\x12\x1f\n\x0brefresh_url\x18\x02 \x01(\tR\nrefreshUrl\x12\x46\n\x06scopes\x18\x03 \x03(\x0b\x32..a2a.v1.ClientCredentialsOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdb\x01\n\x11ImplicitOAuthFlow\x12+\n\x11\x61uthorization_url\x18\x01 \x01(\tR\x10\x61uthorizationUrl\x12\x1f\n\x0brefresh_url\x18\x02 \x01(\tR\nrefreshUrl\x12=\n\x06scopes\x18\x03 \x03(\x0b\x32%.a2a.v1.ImplicitOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xcb\x01\n\x11PasswordOAuthFlow\x12\x1b\n\ttoken_url\x18\x01 \x01(\tR\x08tokenUrl\x12\x1f\n\x0brefresh_url\x18\x02 \x01(\tR\nrefreshUrl\x12=\n\x06scopes\x18\x03 \x03(\x0b\x32%.a2a.v1.PasswordOAuthFlow.ScopesEntryR\x06scopes\x1a\x39\n\x0bScopesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xc1\x01\n\x12SendMessageRequest\x12.\n\x07request\x18\x01 \x01(\x0b\x32\x0f.a2a.v1.MessageB\x03\xe0\x41\x02R\x07message\x12\x46\n\rconfiguration\x18\x02 \x01(\x0b\x32 .a2a.v1.SendMessageConfigurationR\rconfiguration\x12\x33\n\x08metadata\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\"P\n\x0eGetTaskRequest\x12\x17\n\x04name\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x04name\x12%\n\x0ehistory_length\x18\x02 \x01(\x05R\rhistoryLength\"\xb4\x02\n\x10ListTasksRequest\x12\x1d\n\ncontext_id\x18\x01 \x01(\tR\tcontextId\x12)\n\x06status\x18\x02 \x01(\x0e\x32\x11.a2a.v1.TaskStateR\x06status\x12\x1b\n\tpage_size\x18\x03 \x01(\x05R\x08pageSize\x12\x1d\n\npage_token\x18\x04 \x01(\tR\tpageToken\x12%\n\x0ehistory_length\x18\x05 \x01(\x05R\rhistoryLength\x12\x46\n\x11last_updated_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x0flastUpdatedTime\x12+\n\x11include_artifacts\x18\x07 \x01(\x08R\x10includeArtifacts\"~\n\x11ListTasksResponse\x12\"\n\x05tasks\x18\x01 \x03(\x0b\x32\x0c.a2a.v1.TaskR\x05tasks\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1d\n\ntotal_size\x18\x03 \x01(\x05R\ttotalSize\"\'\n\x11\x43\x61ncelTaskRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\":\n$GetTaskPushNotificationConfigRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\"=\n\'DeleteTaskPushNotificationConfigRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\"\xa9\x01\n\'CreateTaskPushNotificationConfigRequest\x12\x1b\n\x06parent\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x06parent\x12 \n\tconfig_id\x18\x02 \x01(\tB\x03\xe0\x41\x02R\x08\x63onfigId\x12?\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\".a2a.v1.TaskPushNotificationConfigB\x03\xe0\x41\x02R\x06\x63onfig\"-\n\x17TaskSubscriptionRequest\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\"{\n%ListTaskPushNotificationConfigRequest\x12\x16\n\x06parent\x18\x01 \x01(\tR\x06parent\x12\x1b\n\tpage_size\x18\x02 \x01(\x05R\x08pageSize\x12\x1d\n\npage_token\x18\x03 \x01(\tR\tpageToken\"\x15\n\x13GetAgentCardRequest\"m\n\x13SendMessageResponse\x12\"\n\x04task\x18\x01 \x01(\x0b\x32\x0c.a2a.v1.TaskH\x00R\x04task\x12\'\n\x03msg\x18\x02 \x01(\x0b\x32\x0f.a2a.v1.MessageH\x00R\x07messageB\t\n\x07payload\"\xfa\x01\n\x0eStreamResponse\x12\"\n\x04task\x18\x01 \x01(\x0b\x32\x0c.a2a.v1.TaskH\x00R\x04task\x12\'\n\x03msg\x18\x02 \x01(\x0b\x32\x0f.a2a.v1.MessageH\x00R\x07message\x12\x44\n\rstatus_update\x18\x03 \x01(\x0b\x32\x1d.a2a.v1.TaskStatusUpdateEventH\x00R\x0cstatusUpdate\x12J\n\x0f\x61rtifact_update\x18\x04 \x01(\x0b\x32\x1f.a2a.v1.TaskArtifactUpdateEventH\x00R\x0e\x61rtifactUpdateB\t\n\x07payload\"\x8e\x01\n&ListTaskPushNotificationConfigResponse\x12<\n\x07\x63onfigs\x18\x01 \x03(\x0b\x32\".a2a.v1.TaskPushNotificationConfigR\x07\x63onfigs\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken*\xfa\x01\n\tTaskState\x12\x1a\n\x16TASK_STATE_UNSPECIFIED\x10\x00\x12\x18\n\x14TASK_STATE_SUBMITTED\x10\x01\x12\x16\n\x12TASK_STATE_WORKING\x10\x02\x12\x18\n\x14TASK_STATE_COMPLETED\x10\x03\x12\x15\n\x11TASK_STATE_FAILED\x10\x04\x12\x18\n\x14TASK_STATE_CANCELLED\x10\x05\x12\x1d\n\x19TASK_STATE_INPUT_REQUIRED\x10\x06\x12\x17\n\x13TASK_STATE_REJECTED\x10\x07\x12\x1c\n\x18TASK_STATE_AUTH_REQUIRED\x10\x08*;\n\x04Role\x12\x14\n\x10ROLE_UNSPECIFIED\x10\x00\x12\r\n\tROLE_USER\x10\x01\x12\x0e\n\nROLE_AGENT\x10\x02\x32\x90\x0b\n\nA2AService\x12\x63\n\x0bSendMessage\x12\x1a.a2a.v1.SendMessageRequest\x1a\x1b.a2a.v1.SendMessageResponse\"\x1b\x82\xd3\xe4\x93\x02\x15\"\x10/v1/message:send:\x01*\x12k\n\x14SendStreamingMessage\x12\x1a.a2a.v1.SendMessageRequest\x1a\x16.a2a.v1.StreamResponse\"\x1d\x82\xd3\xe4\x93\x02\x17\"\x12/v1/message:stream:\x01*0\x01\x12R\n\x07GetTask\x12\x16.a2a.v1.GetTaskRequest\x1a\x0c.a2a.v1.Task\"!\xda\x41\x04name\x82\xd3\xe4\x93\x02\x14\x12\x12/v1/{name=tasks/*}\x12S\n\tListTasks\x12\x18.a2a.v1.ListTasksRequest\x1a\x19.a2a.v1.ListTasksResponse\"\x11\x82\xd3\xe4\x93\x02\x0b\x12\t/v1/tasks\x12[\n\nCancelTask\x12\x19.a2a.v1.CancelTaskRequest\x1a\x0c.a2a.v1.Task\"$\x82\xd3\xe4\x93\x02\x1e\"\x19/v1/{name=tasks/*}:cancel:\x01*\x12s\n\x10TaskSubscription\x12\x1f.a2a.v1.TaskSubscriptionRequest\x1a\x16.a2a.v1.StreamResponse\"$\x82\xd3\xe4\x93\x02\x1e\x12\x1c/v1/{name=tasks/*}:subscribe0\x01\x12\xc5\x01\n CreateTaskPushNotificationConfig\x12/.a2a.v1.CreateTaskPushNotificationConfigRequest\x1a\".a2a.v1.TaskPushNotificationConfig\"L\xda\x41\rparent,config\x82\xd3\xe4\x93\x02\x36\",/v1/{parent=tasks/*/pushNotificationConfigs}:\x06\x63onfig\x12\xae\x01\n\x1dGetTaskPushNotificationConfig\x12,.a2a.v1.GetTaskPushNotificationConfigRequest\x1a\".a2a.v1.TaskPushNotificationConfig\";\xda\x41\x04name\x82\xd3\xe4\x93\x02.\x12,/v1/{name=tasks/*/pushNotificationConfigs/*}\x12\xbe\x01\n\x1eListTaskPushNotificationConfig\x12-.a2a.v1.ListTaskPushNotificationConfigRequest\x1a..a2a.v1.ListTaskPushNotificationConfigResponse\"=\xda\x41\x06parent\x82\xd3\xe4\x93\x02.\x12,/v1/{parent=tasks/*}/pushNotificationConfigs\x12P\n\x0cGetAgentCard\x12\x1b.a2a.v1.GetAgentCardRequest\x1a\x11.a2a.v1.AgentCard\"\x10\x82\xd3\xe4\x93\x02\n\x12\x08/v1/card\x12\xa8\x01\n DeleteTaskPushNotificationConfig\x12/.a2a.v1.DeleteTaskPushNotificationConfigRequest\x1a\x16.google.protobuf.Empty\";\xda\x41\x04name\x82\xd3\xe4\x93\x02.*,/v1/{name=tasks/*/pushNotificationConfigs/*}Bi\n\ncom.a2a.v1B\x08\x41\x32\x61ProtoP\x01Z\x18google.golang.org/a2a/v1\xa2\x02\x03\x41XX\xaa\x02\x06\x41\x32\x61.V1\xca\x02\x06\x41\x32\x61\\V1\xe2\x02\x12\x41\x32\x61\\V1\\GPBMetadata\xea\x02\x07\x41\x32\x61::V1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -70,6 +70,8 @@ _globals['_A2ASERVICE'].methods_by_name['SendStreamingMessage']._serialized_options = b'\202\323\344\223\002\027\"\022/v1/message:stream:\001*' _globals['_A2ASERVICE'].methods_by_name['GetTask']._loaded_options = None _globals['_A2ASERVICE'].methods_by_name['GetTask']._serialized_options = b'\332A\004name\202\323\344\223\002\024\022\022/v1/{name=tasks/*}' + _globals['_A2ASERVICE'].methods_by_name['ListTasks']._loaded_options = None + _globals['_A2ASERVICE'].methods_by_name['ListTasks']._serialized_options = b'\202\323\344\223\002\013\022\t/v1/tasks' _globals['_A2ASERVICE'].methods_by_name['CancelTask']._loaded_options = None _globals['_A2ASERVICE'].methods_by_name['CancelTask']._serialized_options = b'\202\323\344\223\002\036\"\031/v1/{name=tasks/*}:cancel:\001*' _globals['_A2ASERVICE'].methods_by_name['TaskSubscription']._loaded_options = None @@ -84,10 +86,10 @@ _globals['_A2ASERVICE'].methods_by_name['GetAgentCard']._serialized_options = b'\202\323\344\223\002\n\022\010/v1/card' _globals['_A2ASERVICE'].methods_by_name['DeleteTaskPushNotificationConfig']._loaded_options = None _globals['_A2ASERVICE'].methods_by_name['DeleteTaskPushNotificationConfig']._serialized_options = b'\332A\004name\202\323\344\223\002.*,/v1/{name=tasks/*/pushNotificationConfigs/*}' - _globals['_TASKSTATE']._serialized_start=8066 - _globals['_TASKSTATE']._serialized_end=8316 - _globals['_ROLE']._serialized_start=8318 - _globals['_ROLE']._serialized_end=8377 + _globals['_TASKSTATE']._serialized_start=8505 + _globals['_TASKSTATE']._serialized_end=8755 + _globals['_ROLE']._serialized_start=8757 + _globals['_ROLE']._serialized_end=8816 _globals['_SENDMESSAGECONFIGURATION']._serialized_start=202 _globals['_SENDMESSAGECONFIGURATION']._serialized_end=424 _globals['_TASK']._serialized_start=427 @@ -170,26 +172,30 @@ _globals['_SENDMESSAGEREQUEST']._serialized_end=6941 _globals['_GETTASKREQUEST']._serialized_start=6943 _globals['_GETTASKREQUEST']._serialized_end=7023 - _globals['_CANCELTASKREQUEST']._serialized_start=7025 - _globals['_CANCELTASKREQUEST']._serialized_end=7064 - _globals['_GETTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7066 - _globals['_GETTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7124 - _globals['_DELETETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7126 - _globals['_DELETETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7187 - _globals['_CREATETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7190 - _globals['_CREATETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7359 - _globals['_TASKSUBSCRIPTIONREQUEST']._serialized_start=7361 - _globals['_TASKSUBSCRIPTIONREQUEST']._serialized_end=7406 - _globals['_LISTTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7408 - _globals['_LISTTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7531 - _globals['_GETAGENTCARDREQUEST']._serialized_start=7533 - _globals['_GETAGENTCARDREQUEST']._serialized_end=7554 - _globals['_SENDMESSAGERESPONSE']._serialized_start=7556 - _globals['_SENDMESSAGERESPONSE']._serialized_end=7665 - _globals['_STREAMRESPONSE']._serialized_start=7668 - _globals['_STREAMRESPONSE']._serialized_end=7918 - _globals['_LISTTASKPUSHNOTIFICATIONCONFIGRESPONSE']._serialized_start=7921 - _globals['_LISTTASKPUSHNOTIFICATIONCONFIGRESPONSE']._serialized_end=8063 - _globals['_A2ASERVICE']._serialized_start=8380 - _globals['_A2ASERVICE']._serialized_end=9719 + _globals['_LISTTASKSREQUEST']._serialized_start=7026 + _globals['_LISTTASKSREQUEST']._serialized_end=7334 + _globals['_LISTTASKSRESPONSE']._serialized_start=7336 + _globals['_LISTTASKSRESPONSE']._serialized_end=7462 + _globals['_CANCELTASKREQUEST']._serialized_start=7464 + _globals['_CANCELTASKREQUEST']._serialized_end=7503 + _globals['_GETTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7505 + _globals['_GETTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7563 + _globals['_DELETETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7565 + _globals['_DELETETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7626 + _globals['_CREATETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7629 + _globals['_CREATETASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7798 + _globals['_TASKSUBSCRIPTIONREQUEST']._serialized_start=7800 + _globals['_TASKSUBSCRIPTIONREQUEST']._serialized_end=7845 + _globals['_LISTTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_start=7847 + _globals['_LISTTASKPUSHNOTIFICATIONCONFIGREQUEST']._serialized_end=7970 + _globals['_GETAGENTCARDREQUEST']._serialized_start=7972 + _globals['_GETAGENTCARDREQUEST']._serialized_end=7993 + _globals['_SENDMESSAGERESPONSE']._serialized_start=7995 + _globals['_SENDMESSAGERESPONSE']._serialized_end=8104 + _globals['_STREAMRESPONSE']._serialized_start=8107 + _globals['_STREAMRESPONSE']._serialized_end=8357 + _globals['_LISTTASKPUSHNOTIFICATIONCONFIGRESPONSE']._serialized_start=8360 + _globals['_LISTTASKPUSHNOTIFICATIONCONFIGRESPONSE']._serialized_end=8502 + _globals['_A2ASERVICE']._serialized_start=8819 + _globals['_A2ASERVICE']._serialized_end=10243 # @@protoc_insertion_point(module_scope) diff --git a/src/a2a/grpc/a2a_pb2.pyi b/src/a2a/grpc/a2a_pb2.pyi index 06005e850..d3f606df7 100644 --- a/src/a2a/grpc/a2a_pb2.pyi +++ b/src/a2a/grpc/a2a_pb2.pyi @@ -497,6 +497,34 @@ class GetTaskRequest(_message.Message): history_length: int def __init__(self, name: _Optional[str] = ..., history_length: _Optional[int] = ...) -> None: ... +class ListTasksRequest(_message.Message): + __slots__ = ("context_id", "status", "page_size", "page_token", "history_length", "last_updated_time", "include_artifacts") + CONTEXT_ID_FIELD_NUMBER: _ClassVar[int] + STATUS_FIELD_NUMBER: _ClassVar[int] + PAGE_SIZE_FIELD_NUMBER: _ClassVar[int] + PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + HISTORY_LENGTH_FIELD_NUMBER: _ClassVar[int] + LAST_UPDATED_TIME_FIELD_NUMBER: _ClassVar[int] + INCLUDE_ARTIFACTS_FIELD_NUMBER: _ClassVar[int] + context_id: str + status: TaskState + page_size: int + page_token: str + history_length: int + last_updated_time: _timestamp_pb2.Timestamp + include_artifacts: bool + def __init__(self, context_id: _Optional[str] = ..., status: _Optional[_Union[TaskState, str]] = ..., page_size: _Optional[int] = ..., page_token: _Optional[str] = ..., history_length: _Optional[int] = ..., last_updated_time: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., include_artifacts: _Optional[bool] = ...) -> None: ... + +class ListTasksResponse(_message.Message): + __slots__ = ("tasks", "next_page_token", "total_size") + TASKS_FIELD_NUMBER: _ClassVar[int] + NEXT_PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + TOTAL_SIZE_FIELD_NUMBER: _ClassVar[int] + tasks: _containers.RepeatedCompositeFieldContainer[Task] + next_page_token: str + total_size: int + def __init__(self, tasks: _Optional[_Iterable[_Union[Task, _Mapping]]] = ..., next_page_token: _Optional[str] = ..., total_size: _Optional[int] = ...) -> None: ... + class CancelTaskRequest(_message.Message): __slots__ = ("name",) NAME_FIELD_NUMBER: _ClassVar[int] diff --git a/src/a2a/grpc/a2a_pb2_grpc.py b/src/a2a/grpc/a2a_pb2_grpc.py index 9b0ad41bc..4a6d90915 100644 --- a/src/a2a/grpc/a2a_pb2_grpc.py +++ b/src/a2a/grpc/a2a_pb2_grpc.py @@ -40,6 +40,11 @@ def __init__(self, channel): request_serializer=a2a__pb2.GetTaskRequest.SerializeToString, response_deserializer=a2a__pb2.Task.FromString, _registered_method=True) + self.ListTasks = channel.unary_unary( + '/a2a.v1.A2AService/ListTasks', + request_serializer=a2a__pb2.ListTasksRequest.SerializeToString, + response_deserializer=a2a__pb2.ListTasksResponse.FromString, + _registered_method=True) self.CancelTask = channel.unary_unary( '/a2a.v1.A2AService/CancelTask', request_serializer=a2a__pb2.CancelTaskRequest.SerializeToString, @@ -113,6 +118,13 @@ def GetTask(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def ListTasks(self, request, context): + """List tasks with optional filtering and pagination. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def CancelTask(self, request, context): """Cancel a task from the agent. If supported one should expect no more task updates for the task. @@ -184,6 +196,11 @@ def add_A2AServiceServicer_to_server(servicer, server): request_deserializer=a2a__pb2.GetTaskRequest.FromString, response_serializer=a2a__pb2.Task.SerializeToString, ), + 'ListTasks': grpc.unary_unary_rpc_method_handler( + servicer.ListTasks, + request_deserializer=a2a__pb2.ListTasksRequest.FromString, + response_serializer=a2a__pb2.ListTasksResponse.SerializeToString, + ), 'CancelTask': grpc.unary_unary_rpc_method_handler( servicer.CancelTask, request_deserializer=a2a__pb2.CancelTaskRequest.FromString, @@ -321,6 +338,33 @@ def GetTask(request, metadata, _registered_method=True) + @staticmethod + def ListTasks(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/a2a.v1.A2AService/ListTasks', + a2a__pb2.ListTasksRequest.SerializeToString, + a2a__pb2.ListTasksResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + @staticmethod def CancelTask(request, target, diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index 3e7c2854b..5759c30c5 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -36,6 +36,7 @@ JSONRPCRequest, JSONRPCResponse, ListTaskPushNotificationConfigRequest, + ListTasksRequest, MethodNotFoundError, SendMessageRequest, SendStreamingMessageRequest, @@ -158,6 +159,7 @@ class JSONRPCApplication(ABC): SendMessageRequest | SendStreamingMessageRequest | GetTaskRequest + | ListTasksRequest | CancelTaskRequest | SetTaskPushNotificationConfigRequest | GetTaskPushNotificationConfigRequest @@ -456,6 +458,10 @@ async def _process_non_streaming_request( handler_result = await self.handler.on_get_task( request_obj, context ) + case ListTasksRequest(): + handler_result = await self.handler.list_tasks( + request_obj, context + ) case SetTaskPushNotificationConfigRequest(): handler_result = ( await self.handler.set_push_notification_config( diff --git a/src/a2a/server/request_handlers/default_request_handler.py b/src/a2a/server/request_handlers/default_request_handler.py index 30d1ee891..643f14353 100644 --- a/src/a2a/server/request_handlers/default_request_handler.py +++ b/src/a2a/server/request_handlers/default_request_handler.py @@ -32,6 +32,8 @@ InternalError, InvalidParamsError, ListTaskPushNotificationConfigParams, + ListTasksParams, + ListTasksResult, Message, MessageSendParams, Task, @@ -43,6 +45,7 @@ TaskState, UnsupportedOperationError, ) +from a2a.utils.constants import DEFAULT_LIST_TASKS_PAGE_SIZE from a2a.utils.errors import ServerError from a2a.utils.task import apply_history_length from a2a.utils.telemetry import SpanKind, trace_class @@ -121,6 +124,32 @@ async def on_get_task( # Apply historyLength parameter if specified return apply_history_length(task, params.history_length) + async def on_list_tasks( + self, + params: ListTasksParams, + context: ServerCallContext | None = None, + ) -> ListTasksResult: + """Default handler for 'tasks/list'.""" + page = await self.task_store.list(params, context) + processed_tasks = [] + for task in page.tasks: + processed_task = task + if params.include_artifacts is not True: + processed_task = processed_task.model_copy( + update={'artifacts': None} + ) + if params.history_length is not None: + processed_task = apply_history_length( + processed_task, params.history_length + ) + processed_tasks.append(processed_task) + return ListTasksResult( + next_page_token=page.next_page_token or '', + page_size=params.page_size or DEFAULT_LIST_TASKS_PAGE_SIZE, + tasks=processed_tasks, + total_size=page.total_size, + ) + async def on_cancel_task( self, params: TaskIdParams, context: ServerCallContext | None = None ) -> Task | None: diff --git a/src/a2a/server/request_handlers/grpc_handler.py b/src/a2a/server/request_handlers/grpc_handler.py index e2ec69a15..7dedf675b 100644 --- a/src/a2a/server/request_handlers/grpc_handler.py +++ b/src/a2a/server/request_handlers/grpc_handler.py @@ -331,6 +331,30 @@ async def GetTask( await self.abort_context(e, context) return a2a_pb2.Task() + async def ListTasks( + self, + request: a2a_pb2.ListTasksRequest, + context: grpc.aio.ServicerContext, + ) -> a2a_pb2.ListTasksResponse: + """Handles the 'ListTasks' gRPC method. + + Args: + request: The incoming `ListTasksRequest` object. + context: Context provided by the server. + + Returns: + A `ListTasksResponse` object. + """ + try: + server_context = self.context_builder.build(context) + result = await self.request_handler.on_list_tasks( + proto_utils.FromProto.list_tasks_params(request), server_context + ) + return proto_utils.ToProto.list_tasks_response(result) + except ServerError as e: + await self.abort_context(e, context) + return a2a_pb2.ListTasksResponse() + async def GetAgentCard( self, request: a2a_pb2.GetAgentCardRequest, diff --git a/src/a2a/server/request_handlers/jsonrpc_handler.py b/src/a2a/server/request_handlers/jsonrpc_handler.py index 567c61484..3b4687915 100644 --- a/src/a2a/server/request_handlers/jsonrpc_handler.py +++ b/src/a2a/server/request_handlers/jsonrpc_handler.py @@ -28,6 +28,11 @@ ListTaskPushNotificationConfigRequest, ListTaskPushNotificationConfigResponse, ListTaskPushNotificationConfigSuccessResponse, + ListTasksParams, + ListTasksRequest, + ListTasksResponse, + ListTasksResult, + ListTasksSuccessResponse, Message, SendMessageRequest, SendMessageResponse, @@ -359,6 +364,38 @@ async def on_get_task( root=JSONRPCErrorResponse(id=request.id, error=TaskNotFoundError()) ) + async def list_tasks( + self, + request: ListTasksRequest, + context: ServerCallContext | None = None, + ) -> ListTasksResponse: + """Handles the 'tasks/list' JSON-RPC method. + + Args: + request: The incoming `ListTasksRequest` object. + context: Context provided by the server. + + Returns: + A `ListTasksResponse` object containing the Task or a JSON-RPC error. + """ + try: + result = await self.request_handler.on_list_tasks( + request.params or ListTasksParams(), context + ) + except ServerError as e: + return ListTasksResponse( + root=JSONRPCErrorResponse( + id=request.id, error=e.error if e.error else InternalError() + ) + ) + return prepare_response_object( + request.id, + result, + (ListTasksResult,), + ListTasksSuccessResponse, + ListTasksResponse, + ) + async def list_push_notification_config( self, request: ListTaskPushNotificationConfigRequest, diff --git a/src/a2a/server/request_handlers/request_handler.py b/src/a2a/server/request_handlers/request_handler.py index 7ce76cc90..dc2d308a5 100644 --- a/src/a2a/server/request_handlers/request_handler.py +++ b/src/a2a/server/request_handlers/request_handler.py @@ -7,6 +7,8 @@ DeleteTaskPushNotificationConfigParams, GetTaskPushNotificationConfigParams, ListTaskPushNotificationConfigParams, + ListTasksParams, + ListTasksResult, Message, MessageSendParams, Task, @@ -43,6 +45,23 @@ async def on_get_task( The `Task` object if found, otherwise `None`. """ + @abstractmethod + async def on_list_tasks( + self, params: ListTasksParams, context: ServerCallContext | None = None + ) -> ListTasksResult: + """Handles the tasks/list method. + + Retrieves all task for an agent. Supports filtering, pagination, + ordering, limiting the history length, excluding artifacts, etc. + + Args: + params: Parameters with filtering criteria. + context: Context provided by the server. + + Returns: + The `ListTasksResult` containing the tasks. + """ + @abstractmethod async def on_cancel_task( self, diff --git a/src/a2a/server/request_handlers/response_helpers.py b/src/a2a/server/request_handlers/response_helpers.py index 4c55c4197..0e39b17f3 100644 --- a/src/a2a/server/request_handlers/response_helpers.py +++ b/src/a2a/server/request_handlers/response_helpers.py @@ -18,6 +18,9 @@ JSONRPCErrorResponse, ListTaskPushNotificationConfigResponse, ListTaskPushNotificationConfigSuccessResponse, + ListTasksResponse, + ListTasksResult, + ListTasksSuccessResponse, Message, SendMessageResponse, SendMessageSuccessResponse, @@ -42,6 +45,7 @@ SendStreamingMessageResponse, ListTaskPushNotificationConfigResponse, DeleteTaskPushNotificationConfigResponse, + ListTasksResponse, ) """Type variable for RootModel response types.""" @@ -56,6 +60,7 @@ SendStreamingMessageSuccessResponse, ListTaskPushNotificationConfigSuccessResponse, DeleteTaskPushNotificationConfigSuccessResponse, + ListTasksSuccessResponse, ) """Type variable for SuccessResponse types.""" @@ -69,6 +74,7 @@ | A2AError | JSONRPCError | list[TaskPushNotificationConfig] + | ListTasksResult ) """Type alias for possible event types produced by handlers.""" diff --git a/src/a2a/server/request_handlers/rest_handler.py b/src/a2a/server/request_handlers/rest_handler.py index 59057487c..68f5ebba7 100644 --- a/src/a2a/server/request_handlers/rest_handler.py +++ b/src/a2a/server/request_handlers/rest_handler.py @@ -21,6 +21,7 @@ from a2a.types import ( AgentCard, GetTaskPushNotificationConfigParams, + ListTasksParams, TaskIdParams, TaskNotFoundError, TaskQueryParams, @@ -264,12 +265,12 @@ async def on_get_task( return MessageToDict(proto_utils.ToProto.task(task)) raise ServerError(error=TaskNotFoundError()) - async def list_push_notifications( + async def list_tasks( self, request: Request, context: ServerCallContext, ) -> dict[str, Any]: - """Handles the 'tasks/pushNotificationConfig/list' REST method. + """Handles the 'tasks/list' REST method. This method is currently not implemented. @@ -278,19 +279,21 @@ async def list_push_notifications( context: Context provided by the server. Returns: - A list of `dict` representing the `TaskPushNotificationConfig` objects. + A list of `dict` representing the `Task` objects. Raises: NotImplementedError: This method is not yet implemented. """ - raise NotImplementedError('list notifications not implemented') + params = ListTasksParams.model_validate(request.query_params) + result = await self.request_handler.on_list_tasks(params, context) + return MessageToDict(proto_utils.ToProto.list_tasks_response(result)) - async def list_tasks( + async def list_push_notifications( self, request: Request, context: ServerCallContext, ) -> dict[str, Any]: - """Handles the 'tasks/list' REST method. + """Handles the 'tasks/pushNotificationConfig/list' REST method. This method is currently not implemented. @@ -299,9 +302,9 @@ async def list_tasks( context: Context provided by the server. Returns: - A list of dict representing the`Task` objects. + A list of `dict` representing the `TaskPushNotificationConfig` objects. Raises: NotImplementedError: This method is not yet implemented. """ - raise NotImplementedError('list tasks not implemented') + raise NotImplementedError('list notifications not implemented') diff --git a/src/a2a/server/tasks/database_task_store.py b/src/a2a/server/tasks/database_task_store.py index 07ba7e970..2ec02831c 100644 --- a/src/a2a/server/tasks/database_task_store.py +++ b/src/a2a/server/tasks/database_task_store.py @@ -1,8 +1,17 @@ import logging +from datetime import datetime, timezone + try: - from sqlalchemy import Table, delete, select + from sqlalchemy import ( + Table, + and_, + delete, + func, + or_, + select, + ) from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -21,8 +30,10 @@ from a2a.server.context import ServerCallContext from a2a.server.models import Base, TaskModel, create_task_model -from a2a.server.tasks.task_store import TaskStore -from a2a.types import Task # Task is the Pydantic model +from a2a.server.tasks.task_store import TaskStore, TasksPage +from a2a.types import ListTasksParams, Task +from a2a.utils.constants import DEFAULT_LIST_TASKS_PAGE_SIZE +from a2a.utils.task import decode_page_token, encode_page_token logger = logging.getLogger(__name__) @@ -147,6 +158,91 @@ async def get( logger.debug('Task %s not found in store.', task_id) return None + async def list( + self, params: ListTasksParams, context: ServerCallContext | None = None + ) -> TasksPage: + """Retrieves all tasks from the database.""" + await self._ensure_initialized() + async with self.async_session_maker() as session: + timestamp_col = self.task_model.status['timestamp'].as_string() + base_stmt = select(self.task_model) + + # Add filters + if params.context_id: + base_stmt = base_stmt.where( + self.task_model.context_id == params.context_id + ) + if params.status and params.status != 'unknown': + base_stmt = base_stmt.where( + self.task_model.status['state'].as_string() + == params.status.value + ) + if params.last_updated_after: + last_updated_after_iso = datetime.fromtimestamp( + params.last_updated_after / 1000, tz=timezone.utc + ).isoformat() + base_stmt = base_stmt.where( + timestamp_col >= last_updated_after_iso + ) + + # Get total count + count_stmt = select(func.count()).select_from(base_stmt.alias()) + total_count = (await session.execute(count_stmt)).scalar_one() + + stmt = base_stmt.order_by( + timestamp_col.desc().nulls_last(), + self.task_model.id.desc(), + ) + + # Get paginated results + if params.page_token: + start_task_id = decode_page_token(params.page_token) + start_task = ( + await session.execute( + select(self.task_model).where( + self.task_model.id == start_task_id + ) + ) + ).scalar_one_or_none() + if not start_task: + raise ValueError(f'Invalid page token: {params.page_token}') + if start_task.status.timestamp: + stmt = stmt.where( + or_( + and_( + timestamp_col == start_task.status.timestamp, + self.task_model.id <= start_task.id, + ), + timestamp_col < start_task.status.timestamp, + timestamp_col.is_(None), + ) + ) + else: + stmt = stmt.where( + and_( + timestamp_col.is_(None), + self.task_model.id <= start_task.id, + ) + ) + page_size = params.page_size or DEFAULT_LIST_TASKS_PAGE_SIZE + stmt = stmt.limit(page_size + 1) # Add 1 for next page token + + result = await session.execute(stmt) + tasks_models = result.scalars().all() + tasks = [self._from_orm(task_model) for task_model in tasks_models] + + next_page_token = ( + encode_page_token(tasks[-1].id) + if len(tasks) == page_size + 1 + else None + ) + + return TasksPage( + tasks=tasks[:page_size], + total_size=total_count, + next_page_token=next_page_token, + ) + async def delete( self, task_id: str, context: ServerCallContext | None = None ) -> None: diff --git a/src/a2a/server/tasks/inmemory_task_store.py b/src/a2a/server/tasks/inmemory_task_store.py index 4e192af08..31d42a310 100644 --- a/src/a2a/server/tasks/inmemory_task_store.py +++ b/src/a2a/server/tasks/inmemory_task_store.py @@ -1,9 +1,13 @@ import asyncio import logging +from datetime import datetime, timezone + from a2a.server.context import ServerCallContext -from a2a.server.tasks.task_store import TaskStore -from a2a.types import Task +from a2a.server.tasks.task_store import TaskStore, TasksPage +from a2a.types import ListTasksParams, Task +from a2a.utils.constants import DEFAULT_LIST_TASKS_PAGE_SIZE +from a2a.utils.task import decode_page_token, encode_page_token logger = logging.getLogger(__name__) @@ -43,6 +47,74 @@ async def get( logger.debug('Task %s not found in store.', task_id) return task + async def list( + self, + params: ListTasksParams, + context: ServerCallContext | None = None, + ) -> TasksPage: + """Retrieves a list of tasks from the store.""" + async with self.lock: + tasks = list(self.tasks.values()) + + # Filter tasks + if params.context_id: + tasks = [ + task for task in tasks if task.context_id == params.context_id + ] + if params.status and params.status != 'unknown': + tasks = [ + task for task in tasks if task.status.state == params.status + ] + if params.last_updated_after: + last_updated_after_iso = datetime.fromtimestamp( + params.last_updated_after / 1000, tz=timezone.utc + ).isoformat() + tasks = [ + task + for task in tasks + if ( + task.status.timestamp + and task.status.timestamp >= last_updated_after_iso + ) + ] + + # Order tasks by last update time. To ensure stable sorting, in cases where timestamps are null or not unique, do a second order comparison of IDs. + tasks.sort( + key=lambda task: ( + task.status.timestamp is not None, + task.status.timestamp, + task.id, + ), + reverse=True, + ) + + # Paginate tasks + total_size = len(tasks) + start_idx = 0 + if params.page_token: + start_task_id = decode_page_token(params.page_token) + valid_token = False + for i, task in enumerate(tasks): + if task.id == start_task_id: + start_idx = i + valid_token = True + break + if not valid_token: + raise ValueError(f'Invalid page token: {params.page_token}') + end_idx = start_idx + (params.page_size or DEFAULT_LIST_TASKS_PAGE_SIZE) + next_page_token = ( + encode_page_token(tasks[end_idx].id) + if end_idx < total_size + else None + ) + tasks = tasks[start_idx:end_idx] + + return TasksPage( + next_page_token=next_page_token, + tasks=tasks, + total_size=total_size, + ) + async def delete( self, task_id: str, context: ServerCallContext | None = None ) -> None: diff --git a/src/a2a/server/tasks/task_store.py b/src/a2a/server/tasks/task_store.py index 16b36edb9..48dd3be72 100644 --- a/src/a2a/server/tasks/task_store.py +++ b/src/a2a/server/tasks/task_store.py @@ -1,7 +1,17 @@ from abc import ABC, abstractmethod +from pydantic import BaseModel + from a2a.server.context import ServerCallContext -from a2a.types import Task +from a2a.types import ListTasksParams, Task + + +class TasksPage(BaseModel): + """Page with tasks.""" + + next_page_token: str | None = None + tasks: list[Task] + total_size: int class TaskStore(ABC): @@ -22,6 +32,14 @@ async def get( ) -> Task | None: """Retrieves a task from the store by ID.""" + @abstractmethod + async def list( + self, + params: ListTasksParams, + context: ServerCallContext | None = None, + ) -> TasksPage: + """Retrieves a list of tasks from the store.""" + @abstractmethod async def delete( self, task_id: str, context: ServerCallContext | None = None diff --git a/src/a2a/types.py b/src/a2a/types.py index 918a06b5e..67b940d93 100644 --- a/src/a2a/types.py +++ b/src/a2a/types.py @@ -1271,6 +1271,69 @@ class ListTaskPushNotificationConfigSuccessResponse(A2ABaseModel): """ +class ListTasksParams(A2ABaseModel): + """ + Parameters for listing tasks with optional filtering criteria. + """ + + context_id: str | None = None + """ + Filter tasks by context ID to get tasks from a specific conversation or session. + """ + history_length: int | None = None + """ + Number of recent messages to include in each task's history. Must be non-negative. Defaults to 0 if not specified. + """ + include_artifacts: bool | None = None + """ + Whether to include artifacts in the returned tasks. Defaults to false to reduce payload size. + """ + last_updated_after: int | None = None + """ + Filter tasks updated after this timestamp (milliseconds since epoch). Only tasks with a last updated time greater than or equal to this value will be returned. + """ + metadata: dict[str, Any] | None = None + """ + Request-specific metadata. + """ + page_size: int | None = None + """ + Maximum number of tasks to return. Must be between 1 and 100. Defaults to 50 if not specified. + """ + page_token: str | None = None + """ + Token for pagination. Use the nextPageToken from a previous ListTasksResult response. + """ + status: TaskState | None = None + """ + Filter tasks by their current status state. + """ + + +class ListTasksRequest(A2ABaseModel): + """ + JSON-RPC request model for the 'tasks/list' method. + """ + + id: str | int + """ + A unique identifier established by the client. It must be a String, a Number, or null. + The server must reply with the same value in the response. This property is omitted for notifications. + """ + jsonrpc: Literal['2.0'] = '2.0' + """ + The version of the JSON-RPC protocol. MUST be exactly "2.0". + """ + method: Literal['tasks/list'] = 'tasks/list' + """ + A String containing the name of the method to be invoked. + """ + params: ListTasksParams | None = None + """ + A Structured value that holds the parameter values to be used during the invocation of the method. + """ + + class MessageSendConfiguration(A2ABaseModel): """ Defines configuration options for a `message/send` or `message/stream` request. @@ -1694,6 +1757,7 @@ class A2ARequest( SendMessageRequest | SendStreamingMessageRequest | GetTaskRequest + | ListTasksRequest | CancelTaskRequest | SetTaskPushNotificationConfigRequest | GetTaskPushNotificationConfigRequest @@ -1707,6 +1771,7 @@ class A2ARequest( SendMessageRequest | SendStreamingMessageRequest | GetTaskRequest + | ListTasksRequest | CancelTaskRequest | SetTaskPushNotificationConfigRequest | GetTaskPushNotificationConfigRequest @@ -1936,6 +2001,48 @@ class GetTaskSuccessResponse(A2ABaseModel): """ +class ListTasksResult(A2ABaseModel): + """ + Result object for tasks/list method containing an array of tasks and pagination information. + """ + + next_page_token: str + """ + Token for retrieving the next page. Empty string if no more results. + """ + page_size: int + """ + Maximum number of tasks returned in this response. + """ + tasks: list[Task] + """ + Array of tasks matching the specified criteria. + """ + total_size: int + """ + Total number of tasks available (before pagination). + """ + + +class ListTasksSuccessResponse(A2ABaseModel): + """ + JSON-RPC success response model for the 'tasks/list' method. + """ + + id: str | int | None = None + """ + The identifier established by the client. + """ + jsonrpc: Literal['2.0'] = '2.0' + """ + The version of the JSON-RPC protocol. MUST be exactly "2.0". + """ + result: ListTasksResult + """ + The result object on success. + """ + + class SendMessageSuccessResponse(A2ABaseModel): """ Represents a successful JSON-RPC response for the `message/send` method. @@ -1998,6 +2105,7 @@ class JSONRPCResponse( | SendStreamingMessageSuccessResponse | GetTaskSuccessResponse | CancelTaskSuccessResponse + | ListTasksSuccessResponse | SetTaskPushNotificationConfigSuccessResponse | GetTaskPushNotificationConfigSuccessResponse | ListTaskPushNotificationConfigSuccessResponse @@ -2011,6 +2119,7 @@ class JSONRPCResponse( | SendStreamingMessageSuccessResponse | GetTaskSuccessResponse | CancelTaskSuccessResponse + | ListTasksSuccessResponse | SetTaskPushNotificationConfigSuccessResponse | GetTaskPushNotificationConfigSuccessResponse | ListTaskPushNotificationConfigSuccessResponse @@ -2023,6 +2132,15 @@ class JSONRPCResponse( """ +class ListTasksResponse( + RootModel[JSONRPCErrorResponse | ListTasksSuccessResponse] +): + root: JSONRPCErrorResponse | ListTasksSuccessResponse + """ + JSON-RPC response for the 'tasks/list' method. + """ + + class SendMessageResponse( RootModel[JSONRPCErrorResponse | SendMessageSuccessResponse] ): diff --git a/src/a2a/utils/constants.py b/src/a2a/utils/constants.py index 2935251a5..464b07c99 100644 --- a/src/a2a/utils/constants.py +++ b/src/a2a/utils/constants.py @@ -4,3 +4,5 @@ PREV_AGENT_CARD_WELL_KNOWN_PATH = '/.well-known/agent.json' EXTENDED_AGENT_CARD_PATH = '/agent/authenticatedExtendedCard' DEFAULT_RPC_URL = '/' +DEFAULT_LIST_TASKS_PAGE_SIZE = 50 +"""Default page size for the `tasks/list` method.""" diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index d077d62bf..a467a59f9 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -8,6 +8,7 @@ from typing import Any from google.protobuf import json_format, struct_pb2 +from google.protobuf.timestamp_pb2 import Timestamp from a2a import types from a2a.grpc import a2a_pb2 @@ -568,6 +569,34 @@ def role(cls, role: types.Role) -> a2a_pb2.Role: case _: return a2a_pb2.Role.ROLE_UNSPECIFIED + @classmethod + def list_tasks_request( + cls, params: types.ListTasksParams + ) -> a2a_pb2.ListTasksRequest: + last_updated_time = None + if params.last_updated_after is not None: + last_updated_time = Timestamp() + last_updated_time.FromMilliseconds(params.last_updated_after) + return a2a_pb2.ListTasksRequest( + context_id=params.context_id, + status=cls.task_state(params.status) if params.status else None, + page_size=params.page_size, + page_token=params.page_token, + history_length=params.history_length, + last_updated_time=last_updated_time, + include_artifacts=params.include_artifacts, + ) + + @classmethod + def list_tasks_response( + cls, result: types.ListTasksResult + ) -> a2a_pb2.ListTasksResponse: + return a2a_pb2.ListTasksResponse( + next_page_token=result.next_page_token or '', + tasks=[cls.task(t) for t in result.tasks], + total_size=result.total_size or 0, + ) + class FromProto: """Converts proto types to Python types.""" @@ -799,6 +828,28 @@ def task_id_params( ) return types.TaskIdParams(id=m.group(1)) + @classmethod + def list_tasks_result( + cls, + response: a2a_pb2.ListTasksResponse, + page_size: int, + ) -> types.ListTasksResult: + """Converts a ListTasksResponse to a ListTasksResult. + + Args: + response: The ListTasksResponse to convert. + page_size: The maximum number of tasks returned in this response. + + Returns: + A `ListTasksResult` object. + """ + return types.ListTasksResult( + next_page_token=response.next_page_token, + page_size=page_size, + tasks=[cls.task(t) for t in response.tasks], + total_size=response.total_size, + ) + @classmethod def task_push_notification_config_request( cls, @@ -895,6 +946,22 @@ def task_query_params( metadata=None, ) + @classmethod + def list_tasks_params( + cls, request: a2a_pb2.ListTasksRequest + ) -> types.ListTasksParams: + return types.ListTasksParams( + context_id=request.context_id, + history_length=request.history_length, + include_artifacts=request.include_artifacts, + last_updated_after=request.last_updated_time.ToMilliseconds() + if request.last_updated_time + else None, + page_size=request.page_size, + page_token=request.page_token, + status=cls.task_state(request.status) if request.status else None, + ) + @classmethod def capabilities( cls, capabilities: a2a_pb2.AgentCapabilities diff --git a/src/a2a/utils/task.py b/src/a2a/utils/task.py index d8215cec0..4aac75e9f 100644 --- a/src/a2a/utils/task.py +++ b/src/a2a/utils/task.py @@ -1,7 +1,10 @@ """Utility functions for creating A2A Task objects.""" +import binascii import uuid +from base64 import b64decode, b64encode + from a2a.types import Artifact, Message, Task, TaskState, TaskStatus, TextPart @@ -90,3 +93,39 @@ def apply_history_length(task: Task, history_length: int | None) -> Task: return task.model_copy(update={'history': limited_history}) return task + + +_ENCODING = 'utf-8' + + +def encode_page_token(task_id: str) -> str: + """Encodes page token for tasks pagination. + + Args: + task_id: The ID of the task. + + Returns: + The encoded page token. + """ + return b64encode(task_id.encode(_ENCODING)).decode(_ENCODING) + + +def decode_page_token(page_token: str) -> str: + """Decodes page token for tasks pagination. + + Args: + page_token: The encoded page token. + + Returns: + The decoded task ID. + """ + encoded_str = page_token + missing_padding = len(encoded_str) % 4 + if missing_padding: + encoded_str += '=' * (4 - missing_padding) + print(f'input: {encoded_str}') + try: + decoded = b64decode(encoded_str.encode(_ENCODING)).decode(_ENCODING) + except (binascii.Error, UnicodeDecodeError) as e: + raise ValueError('Token is not a valid base64-encoded cursor.') from e + return decoded diff --git a/tests/client/test_client_factory.py b/tests/client/test_client_factory.py index 16a1433fb..4ddaf8ba8 100644 --- a/tests/client/test_client_factory.py +++ b/tests/client/test_client_factory.py @@ -46,7 +46,7 @@ def test_client_factory_selects_preferred_transport(base_agent_card: AgentCard): assert isinstance(client._transport, JsonRpcTransport) assert client._transport.url == 'http://primary-url.com' - assert ['https://example.com/test-ext/v0'] == client._transport.extensions + assert client._transport.extensions == ['https://example.com/test-ext/v0'] def test_client_factory_selects_secondary_transport_url( @@ -74,7 +74,7 @@ def test_client_factory_selects_secondary_transport_url( assert isinstance(client._transport, RestTransport) assert client._transport.url == 'http://secondary-url.com' - assert ['https://example.com/test-ext/v0'] == client._transport.extensions + assert client._transport.extensions == ['https://example.com/test-ext/v0'] def test_client_factory_server_preference(base_agent_card: AgentCard): diff --git a/tests/client/transports/test_grpc_client.py b/tests/client/transports/test_grpc_client.py index 111e44ba6..99d915207 100644 --- a/tests/client/transports/test_grpc_client.py +++ b/tests/client/transports/test_grpc_client.py @@ -11,6 +11,7 @@ AgentCard, Artifact, GetTaskPushNotificationConfigParams, + ListTasksParams, Message, MessageSendParams, Part, @@ -38,6 +39,7 @@ def mock_grpc_stub() -> AsyncMock: stub.SendMessage = AsyncMock() stub.SendStreamingMessage = MagicMock() stub.GetTask = AsyncMock() + stub.ListTasks = AsyncMock() stub.CancelTask = AsyncMock() stub.CreateTaskPushNotificationConfig = AsyncMock() stub.GetTaskPushNotificationConfig = AsyncMock() @@ -99,6 +101,16 @@ def sample_task() -> Task: ) +@pytest.fixture +def sample_task_2() -> Task: + """Provides a sample Task object.""" + return Task( + id='task-2', + context_id='ctx-2', + status=TaskStatus(state=TaskState.failed), + ) + + @pytest.fixture def sample_message() -> Message: """Provides a sample Message object.""" @@ -321,6 +333,32 @@ async def test_get_task( assert response.id == sample_task.id +@pytest.mark.asyncio +async def test_list_tasks( + grpc_transport: GrpcTransport, + mock_grpc_stub: AsyncMock, + sample_task: Task, + sample_task_2: Task, +): + """Test listing tasks.""" + mock_grpc_stub.ListTasks.return_value = a2a_pb2.ListTasksResponse( + tasks=[ + proto_utils.ToProto.task(t) for t in [sample_task, sample_task_2] + ], + total_size=2, + ) + params = ListTasksParams() + + result = await grpc_transport.list_tasks(params) + + mock_grpc_stub.ListTasks.assert_awaited_once_with( + proto_utils.ToProto.list_tasks_request(params) + ) + assert result.total_size == 2 + assert not result.next_page_token + assert [t.id for t in result.tasks] == [sample_task.id, sample_task_2.id] + + @pytest.mark.asyncio async def test_get_task_with_history( grpc_transport: GrpcTransport, mock_grpc_stub: AsyncMock, sample_task: Task diff --git a/tests/client/transports/test_jsonrpc_client.py b/tests/client/transports/test_jsonrpc_client.py index bd705d93c..29241a5a3 100644 --- a/tests/client/transports/test_jsonrpc_client.py +++ b/tests/client/transports/test_jsonrpc_client.py @@ -23,6 +23,8 @@ AgentCard, AgentSkill, InvalidParamsError, + ListTasksParams, + ListTasksResult, Message, MessageSendParams, PushNotificationConfig, @@ -561,6 +563,42 @@ async def test_get_task_success( sent_payload = mock_send_request.call_args.args[0] assert sent_payload['method'] == 'tasks/get' + @pytest.mark.asyncio + async def test_list_tasks_success( + self, mock_httpx_client: AsyncMock, mock_agent_card: MagicMock + ): + client = JsonRpcTransport( + httpx_client=mock_httpx_client, agent_card=mock_agent_card + ) + params = ListTasksParams() + mock_rpc_response = { + 'id': '123', + 'jsonrpc': '2.0', + 'result': { + 'nextPageToken': '', + 'tasks': [MINIMAL_TASK], + 'pageSize': 10, + 'totalSize': 1, + }, + } + + with patch.object( + client, '_send_request', new_callable=AsyncMock + ) as mock_send_request: + mock_send_request.return_value = mock_rpc_response + response = await client.list_tasks(request=params) + + assert isinstance(response, ListTasksResult) + assert ( + response.model_dump() + == ListTasksResult( + next_page_token='', + page_size=10, + tasks=[Task.model_validate(MINIMAL_TASK)], + total_size=1, + ).model_dump() + ) + @pytest.mark.asyncio async def test_cancel_task_success( 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 04bd10361..ed2b4965d 100644 --- a/tests/client/transports/test_rest_client.py +++ b/tests/client/transports/test_rest_client.py @@ -9,7 +9,7 @@ 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 AgentCard, MessageSendParams @pytest.fixture diff --git a/tests/extensions/test_common.py b/tests/extensions/test_common.py index b3123028a..68b72c68e 100644 --- a/tests/extensions/test_common.py +++ b/tests/extensions/test_common.py @@ -1,4 +1,5 @@ import pytest + from a2a.extensions.common import ( HTTP_EXTENSION_HEADER, find_extension_by_uri, diff --git a/tests/integration/test_client_server_integration.py b/tests/integration/test_client_server_integration.py index e0a564eee..8f3523c57 100644 --- a/tests/integration/test_client_server_integration.py +++ b/tests/integration/test_client_server_integration.py @@ -1,4 +1,5 @@ import asyncio + from collections.abc import AsyncGenerator from typing import NamedTuple from unittest.mock import ANY, AsyncMock, patch @@ -7,6 +8,7 @@ import httpx import pytest import pytest_asyncio + from grpc.aio import Channel from a2a.client import ClientConfig @@ -22,6 +24,8 @@ AgentCard, AgentInterface, GetTaskPushNotificationConfigParams, + ListTasksParams, + ListTasksResult, Message, MessageSendParams, Part, @@ -38,6 +42,7 @@ TransportProtocol, ) + # --- Test Constants --- TASK_FROM_STREAM = Task( @@ -107,6 +112,12 @@ async def stream_side_effect(*args, **kwargs): lambda params, context: params ) handler.on_get_task_push_notification_config.return_value = CALLBACK_CONFIG + handler.on_list_tasks.return_value = ListTasksResult( + tasks=[TASK_FROM_BLOCKING], + next_page_token='', + page_size=50, + total_size=1, + ) async def resubscribe_side_effect(*args, **kwargs): yield RESUBSCRIBE_EVENT @@ -436,6 +447,63 @@ def channel_factory(address: str) -> Channel: await transport.close() +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'transport_setup_fixture', + [ + pytest.param('jsonrpc_setup', id='JSON-RPC'), + pytest.param('rest_setup', id='REST'), + ], +) +async def test_http_transport_list_tasks( + transport_setup_fixture: str, request +) -> None: + transport_setup: TransportSetup = request.getfixturevalue( + transport_setup_fixture + ) + transport = transport_setup.transport + handler = transport_setup.handler + + print(handler.on_list_tasks.call_args) + + params = ListTasksParams() + result = await transport.list_tasks(params) + + handler.on_list_tasks.assert_awaited_once_with(params, ANY) + assert result.next_page_token == '' + assert result.page_size == 50 + assert len(result.tasks) == 1 + assert result.total_size == 1 + + if hasattr(transport, 'close'): + await transport.close() + + +@pytest.mark.asyncio +async def test_grpc_transport_list_tasks( + grpc_server_and_handler: tuple[str, AsyncMock], + agent_card: AgentCard, +) -> None: + server_address, handler = grpc_server_and_handler + agent_card.url = server_address + + 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) + + result = await transport.list_tasks(ListTasksParams()) + + handler.on_list_tasks.assert_awaited_once() + assert result.next_page_token == '' + assert result.page_size == 50 + assert len(result.tasks) == 1 + assert result.total_size == 1 + + await transport.close() + + @pytest.mark.asyncio @pytest.mark.parametrize( 'transport_setup_fixture', diff --git a/tests/server/request_handlers/test_default_request_handler.py b/tests/server/request_handlers/test_default_request_handler.py index 88dd77ab4..e2c5452df 100644 --- a/tests/server/request_handlers/test_default_request_handler.py +++ b/tests/server/request_handlers/test_default_request_handler.py @@ -30,12 +30,15 @@ TaskStore, TaskUpdater, ) +from a2a.server.tasks.task_store import TasksPage from a2a.types import ( + Artifact, DeleteTaskPushNotificationConfigParams, GetTaskPushNotificationConfigParams, InternalError, InvalidParamsError, ListTaskPushNotificationConfigParams, + ListTasksParams, Message, MessageSendConfiguration, MessageSendParams, @@ -53,9 +56,7 @@ TextPart, UnsupportedOperationError, ) -from a2a.utils import ( - new_task, -) +from a2a.utils import new_agent_text_message, new_task class DummyAgentExecutor(AgentExecutor): @@ -145,6 +146,105 @@ async def test_on_get_task_not_found(): mock_task_store.get.assert_awaited_once_with('non_existent_task', context) +@pytest.mark.asyncio +async def test_on_list_tasks_success(): + """Test on_list_tasks successfully returns a page of tasks .""" + mock_task_store = AsyncMock(spec=TaskStore) + mock_page = MagicMock(spec=TasksPage) + mock_page.tasks = [ + create_sample_task(task_id='task1'), + create_sample_task(task_id='task2').model_copy( + update={ + 'artifacts': [ + Artifact( + artifact_id='artifact1', + parts=[Part(root=TextPart(text='Hello world!'))], + name='conversion_result', + ) + ] + } + ), + ] + mock_page.next_page_token = '123' + mock_page.total_size = 2 + mock_task_store.list.return_value = mock_page + request_handler = DefaultRequestHandler( + agent_executor=DummyAgentExecutor(), task_store=mock_task_store + ) + params = ListTasksParams(include_artifacts=True, page_size=10) + context = create_server_call_context() + + result = await request_handler.on_list_tasks(params, context) + + mock_task_store.list.assert_awaited_once_with(params, context) + assert result.tasks == mock_page.tasks + assert result.next_page_token == mock_page.next_page_token + assert result.total_size == mock_page.total_size + assert result.page_size == params.page_size + + +@pytest.mark.asyncio +async def test_on_list_tasks_excludes_artifacts(): + """Test on_list_tasks excludes artifacts from returned tasks.""" + mock_task_store = AsyncMock(spec=TaskStore) + mock_page = MagicMock(spec=TasksPage) + mock_page.tasks = [ + create_sample_task(task_id='task1'), + create_sample_task(task_id='task2').model_copy( + update={ + 'artifacts': [ + Artifact( + artifact_id='artifact1', + parts=[Part(root=TextPart(text='Hello world!'))], + name='conversion_result', + ) + ] + } + ), + ] + mock_page.next_page_token = '123' + mock_page.total_size = 2 + mock_task_store.list.return_value = mock_page + request_handler = DefaultRequestHandler( + agent_executor=DummyAgentExecutor(), task_store=mock_task_store + ) + params = ListTasksParams(include_artifacts=False, page_size=10) + context = create_server_call_context() + + result = await request_handler.on_list_tasks(params, context) + + assert result.tasks[1].artifacts == None + + +@pytest.mark.asyncio +async def test_on_list_tasks_applies_history_length(): + """Test on_list_tasks applies history length filter.""" + mock_task_store = AsyncMock(spec=TaskStore) + mock_page = MagicMock(spec=TasksPage) + history = [ + new_agent_text_message('Hello 1!'), + new_agent_text_message('Hello 2!'), + ] + mock_page.tasks = [ + create_sample_task(task_id='task1'), + create_sample_task(task_id='task2').model_copy( + update={'history': history} + ), + ] + mock_page.next_page_token = '123' + mock_page.total_size = 2 + mock_task_store.list.return_value = mock_page + request_handler = DefaultRequestHandler( + agent_executor=DummyAgentExecutor(), task_store=mock_task_store + ) + params = ListTasksParams(history_length=1, page_size=10) + context = create_server_call_context() + + result = await request_handler.on_list_tasks(params, context) + + assert result.tasks[1].history == [history[1]] + + @pytest.mark.asyncio async def test_on_cancel_task_task_not_found(): """Test on_cancel_task when the task is not found.""" diff --git a/tests/server/request_handlers/test_grpc_handler.py b/tests/server/request_handlers/test_grpc_handler.py index 26f923c14..4ed3358cf 100644 --- a/tests/server/request_handlers/test_grpc_handler.py +++ b/tests/server/request_handlers/test_grpc_handler.py @@ -229,6 +229,42 @@ def modifier(card: types.AgentCard) -> types.AgentCard: assert response.version == sample_agent_card.version +@pytest.mark.asyncio +async def test_list_tasks_success( + grpc_handler: GrpcHandler, + mock_request_handler: AsyncMock, + mock_grpc_context: AsyncMock, +): + """Test successful ListTasks call.""" + mock_request_handler.on_list_tasks.return_value = types.ListTasksResult( + next_page_token='123', + page_size=2, + tasks=[ + types.Task( + id='task-1', + context_id='ctx-1', + status=types.TaskStatus(state=types.TaskState.completed), + ), + types.Task( + id='task-2', + context_id='ctx-1', + status=types.TaskStatus(state=types.TaskState.working), + ), + ], + total_size=10, + ) + + response = await grpc_handler.ListTasks( + a2a_pb2.ListTasksRequest(page_size=2), mock_grpc_context + ) + + mock_request_handler.on_list_tasks.assert_awaited_once() + assert isinstance(response, a2a_pb2.ListTasksResponse) + assert len(response.tasks) == 2 + assert response.tasks[0].id == 'task-1' + assert response.tasks[1].id == 'task-2' + + @pytest.mark.asyncio @pytest.mark.parametrize( 'server_error, grpc_status_code, error_message_part', diff --git a/tests/server/request_handlers/test_jsonrpc_handler.py b/tests/server/request_handlers/test_jsonrpc_handler.py index d1ead0211..608b63492 100644 --- a/tests/server/request_handlers/test_jsonrpc_handler.py +++ b/tests/server/request_handlers/test_jsonrpc_handler.py @@ -48,6 +48,10 @@ ListTaskPushNotificationConfigParams, ListTaskPushNotificationConfigRequest, ListTaskPushNotificationConfigSuccessResponse, + ListTasksParams, + ListTasksRequest, + ListTasksResult, + ListTasksSuccessResponse, Message, MessageSendConfiguration, MessageSendParams, @@ -137,6 +141,35 @@ async def test_on_get_task_not_found(self) -> None: self.assertIsInstance(response.root, JSONRPCErrorResponse) assert response.root.error == TaskNotFoundError() # type: ignore + async def test_on_list_tasks_success(self) -> None: + request_handler = AsyncMock(spec=DefaultRequestHandler) + handler = JSONRPCHandler(self.mock_agent_card, request_handler) + mock_result = ListTasksResult( + next_page_token='123', + page_size=2, + tasks=[ + Task(**MINIMAL_TASK), + Task(**MINIMAL_TASK).model_copy(update={'id': 'task_456'}), + ], + total_size=10, + ) + request_handler.on_list_tasks.return_value = mock_result + request = ListTasksRequest( + id='1', + method='tasks/list', + params=ListTasksParams( + page_size=10, + page_token='token', + ), + ) + call_context = ServerCallContext(state={'foo': 'bar'}) + + response = await handler.list_tasks(request, call_context) + + request_handler.on_list_tasks.assert_awaited_once() + self.assertIsInstance(response.root, ListTasksSuccessResponse) + self.assertEqual(response.root.result, mock_result) + async def test_on_cancel_task_success(self) -> None: mock_agent_executor = AsyncMock(spec=AgentExecutor) mock_task_store = AsyncMock(spec=TaskStore) diff --git a/tests/server/tasks/test_database_task_store.py b/tests/server/tasks/test_database_task_store.py index 87069be46..495d2e4fd 100644 --- a/tests/server/tasks/test_database_task_store.py +++ b/tests/server/tasks/test_database_task_store.py @@ -19,6 +19,7 @@ from a2a.server.tasks.database_task_store import DatabaseTaskStore from a2a.types import ( Artifact, + ListTasksParams, Message, Part, Role, @@ -171,6 +172,216 @@ async def test_get_task(db_store_parameterized: DatabaseTaskStore) -> None: await db_store_parameterized.delete(task_to_save.id) # Cleanup +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'params, expected_ids, total_count, next_page_token', + [ + # No parameters, should return all tasks + ( + ListTasksParams(), + ['task-2', 'task-1', 'task-0', 'task-4', 'task-3'], + 5, + None, + ), + # Unknown context + ( + ListTasksParams(context_id='nonexistent'), + [], + 0, + None, + ), + # Pagination (first page) + ( + ListTasksParams(page_size=2), + ['task-2', 'task-1'], + 5, + 'dGFzay0w', # base64 for 'task-0' + ), + # Pagination (same timestamp) + ( + ListTasksParams( + page_size=2, + page_token='dGFzay0x', # base64 for 'task-1' + ), + ['task-1', 'task-0'], + 5, + 'dGFzay00', # base64 for 'task-4' + ), + # Pagination (final page) + ( + ListTasksParams( + page_size=2, + page_token='dGFzay0z', # base64 for 'task-3' + ), + ['task-3'], + 5, + None, + ), + # Filtering by context_id + ( + ListTasksParams(context_id='context-1'), + ['task-1', 'task-3'], + 2, + None, + ), + # Filtering by status + ( + ListTasksParams(status=TaskState.working), + ['task-1', 'task-3'], + 2, + None, + ), + # Combined filtering (context_id and status) + ( + ListTasksParams(context_id='context-0', status=TaskState.submitted), + ['task-2', 'task-0'], + 2, + None, + ), + # Combined filtering and pagination + ( + ListTasksParams( + context_id='context-0', + page_size=1, + ), + ['task-2'], + 3, + 'dGFzay0w', # base64 for 'task-0' + ), + ], +) +async def test_list_tasks( + db_store_parameterized: DatabaseTaskStore, + params: ListTasksParams, + expected_ids: list[str], + total_count: int, + next_page_token: str, +) -> None: + """Test listing tasks with various filters and pagination.""" + tasks_to_create = [ + MINIMAL_TASK_OBJ.model_copy( + update={ + 'id': 'task-0', + 'context_id': 'context-0', + 'status': TaskStatus( + state=TaskState.submitted, timestamp='2025-01-01T00:00:00Z' + ), + 'kind': 'task', + } + ), + MINIMAL_TASK_OBJ.model_copy( + update={ + 'id': 'task-1', + 'context_id': 'context-1', + 'status': TaskStatus( + state=TaskState.working, timestamp='2025-01-01T00:00:00Z' + ), + 'kind': 'task', + } + ), + MINIMAL_TASK_OBJ.model_copy( + update={ + 'id': 'task-2', + 'context_id': 'context-0', + 'status': TaskStatus( + state=TaskState.submitted, timestamp='2025-01-02T00:00:00Z' + ), + 'kind': 'task', + } + ), + MINIMAL_TASK_OBJ.model_copy( + update={ + 'id': 'task-3', + 'context_id': 'context-1', + 'status': TaskStatus(state=TaskState.working), + 'kind': 'task', + } + ), + MINIMAL_TASK_OBJ.model_copy( + update={ + 'id': 'task-4', + 'context_id': 'context-0', + 'status': TaskStatus(state=TaskState.completed), + 'kind': 'task', + } + ), + ] + for task in tasks_to_create: + await db_store_parameterized.save(task) + + page = await db_store_parameterized.list(params) + + retrieved_ids = [task.id for task in page.tasks] + assert retrieved_ids == expected_ids + assert page.total_size == total_count + assert page.next_page_token == next_page_token + + # Cleanup + for task in tasks_to_create: + await db_store_parameterized.delete(task.id) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'params, expected_error_message', + [ + ( + ListTasksParams( + page_size=2, + page_token='invalid', + ), + 'Token is not a valid base64-encoded cursor.', + ), + ( + ListTasksParams( + page_size=2, + page_token='dGFzay0xMDA=', # base64 for 'task-100' + ), + 'Invalid page token: dGFzay0xMDA=', + ), + ], +) +async def test_list_tasks_fails( + db_store_parameterized: DatabaseTaskStore, + params: ListTasksParams, + expected_error_message: str, +) -> None: + """Test listing tasks with invalid parameters that should fail.""" + tasks_to_create = [ + MINIMAL_TASK_OBJ.model_copy( + update={ + 'id': 'task-0', + 'context_id': 'context-0', + 'status': TaskStatus( + state=TaskState.submitted, timestamp='2025-01-01T00:00:00Z' + ), + 'kind': 'task', + } + ), + MINIMAL_TASK_OBJ.model_copy( + update={ + 'id': 'task-1', + 'context_id': 'context-1', + 'status': TaskStatus( + state=TaskState.working, timestamp='2025-01-01T00:00:00Z' + ), + 'kind': 'task', + } + ), + ] + for task in tasks_to_create: + await db_store_parameterized.save(task) + + with pytest.raises(ValueError) as excinfo: + await db_store_parameterized.list(params) + + assert expected_error_message in str(excinfo.value) + + # Cleanup + for task in tasks_to_create: + await db_store_parameterized.delete(task.id) + + @pytest.mark.asyncio async def test_get_nonexistent_task( db_store_parameterized: DatabaseTaskStore, diff --git a/tests/server/tasks/test_inmemory_task_store.py b/tests/server/tasks/test_inmemory_task_store.py index c41e3559f..ee91b9261 100644 --- a/tests/server/tasks/test_inmemory_task_store.py +++ b/tests/server/tasks/test_inmemory_task_store.py @@ -3,7 +3,7 @@ import pytest from a2a.server.tasks import InMemoryTaskStore -from a2a.types import Task +from a2a.types import ListTasksParams, Task, TaskState, TaskStatus MINIMAL_TASK: dict[str, Any] = { @@ -32,6 +32,217 @@ async def test_in_memory_task_store_get_nonexistent() -> None: assert retrieved_task is None +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'params, expected_ids, total_count, next_page_token', + [ + # No parameters, should return all tasks + ( + ListTasksParams(), + ['task-2', 'task-1', 'task-0', 'task-4', 'task-3'], + 5, + None, + ), + # Unknown context + ( + ListTasksParams(context_id='nonexistent'), + [], + 0, + None, + ), + # Pagination (first page) + ( + ListTasksParams(page_size=2), + ['task-2', 'task-1'], + 5, + 'dGFzay0w', # base64 for 'task-0' + ), + # Pagination (same timestamp) + ( + ListTasksParams( + page_size=2, + page_token='dGFzay0x', # base64 for 'task-1' + ), + ['task-1', 'task-0'], + 5, + 'dGFzay00', # base64 for 'task-4' + ), + # Pagination (final page) + ( + ListTasksParams( + page_size=2, + page_token='dGFzay0z', # base64 for 'task-3' + ), + ['task-3'], + 5, + None, + ), + # Filtering by context_id + ( + ListTasksParams(context_id='context-1'), + ['task-1', 'task-3'], + 2, + None, + ), + # Filtering by status + ( + ListTasksParams(status=TaskState.working), + ['task-1', 'task-3'], + 2, + None, + ), + # Combined filtering (context_id and status) + ( + ListTasksParams(context_id='context-0', status=TaskState.submitted), + ['task-2', 'task-0'], + 2, + None, + ), + # Combined filtering and pagination + ( + ListTasksParams( + context_id='context-0', + page_size=1, + ), + ['task-2'], + 3, + 'dGFzay0w', # base64 for 'task-0' + ), + ], +) +async def test_list_tasks( + params: ListTasksParams, + expected_ids: list[str], + total_count: int, + next_page_token: str, +) -> None: + """Test listing tasks with various filters and pagination.""" + store = InMemoryTaskStore() + task = Task(**MINIMAL_TASK) + tasks_to_create = [ + task.model_copy( + update={ + 'id': 'task-0', + 'context_id': 'context-0', + 'status': TaskStatus( + state=TaskState.submitted, timestamp='2025-01-01T00:00:00Z' + ), + 'kind': 'task', + } + ), + task.model_copy( + update={ + 'id': 'task-1', + 'context_id': 'context-1', + 'status': TaskStatus( + state=TaskState.working, timestamp='2025-01-01T00:00:00Z' + ), + 'kind': 'task', + } + ), + task.model_copy( + update={ + 'id': 'task-2', + 'context_id': 'context-0', + 'status': TaskStatus( + state=TaskState.submitted, timestamp='2025-01-02T00:00:00Z' + ), + 'kind': 'task', + } + ), + task.model_copy( + update={ + 'id': 'task-3', + 'context_id': 'context-1', + 'status': TaskStatus(state=TaskState.working), + 'kind': 'task', + } + ), + task.model_copy( + update={ + 'id': 'task-4', + 'context_id': 'context-0', + 'status': TaskStatus(state=TaskState.completed), + 'kind': 'task', + } + ), + ] + for task in tasks_to_create: + await store.save(task) + + page = await store.list(params) + + retrieved_ids = [task.id for task in page.tasks] + assert retrieved_ids == expected_ids + assert page.total_size == total_count + assert page.next_page_token == next_page_token + + # Cleanup + for task in tasks_to_create: + await store.delete(task.id) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'params, expected_error_message', + [ + ( + ListTasksParams( + page_size=2, + page_token='invalid', + ), + 'Token is not a valid base64-encoded cursor.', + ), + ( + ListTasksParams( + page_size=2, + page_token='dGFzay0xMDA=', # base64 for 'task-100' + ), + 'Invalid page token: dGFzay0xMDA=', + ), + ], +) +async def test_list_tasks_fails( + params: ListTasksParams, expected_error_message: str +) -> None: + """Test listing tasks with invalid parameters that should fail.""" + store = InMemoryTaskStore() + task = Task(**MINIMAL_TASK) + tasks_to_create = [ + task.model_copy( + update={ + 'id': 'task-0', + 'context_id': 'context-0', + 'status': TaskStatus( + state=TaskState.submitted, timestamp='2025-01-01T00:00:00Z' + ), + 'kind': 'task', + } + ), + task.model_copy( + update={ + 'id': 'task-1', + 'context_id': 'context-1', + 'status': TaskStatus( + state=TaskState.working, timestamp='2025-01-01T00:00:00Z' + ), + 'kind': 'task', + } + ), + ] + for task in tasks_to_create: + await store.save(task) + + with pytest.raises(ValueError) as excinfo: + await store.list(params) + + assert expected_error_message in str(excinfo.value) + + # Cleanup + for task in tasks_to_create: + await store.delete(task.id) + + @pytest.mark.asyncio async def test_in_memory_task_store_delete() -> None: """Test deleting a task from the store.""" diff --git a/tests/utils/test_proto_utils.py b/tests/utils/test_proto_utils.py index da54f833f..ccd0def62 100644 --- a/tests/utils/test_proto_utils.py +++ b/tests/utils/test_proto_utils.py @@ -2,6 +2,8 @@ import pytest +from google.protobuf.timestamp_pb2 import Timestamp + from a2a import types from a2a.grpc import a2a_pb2 from a2a.utils import proto_utils @@ -55,6 +57,43 @@ def sample_task(sample_message: types.Message) -> types.Task: ) +@pytest.fixture +def sample_proto_task() -> a2a_pb2.Task: + sample_message = a2a_pb2.Message( + message_id='msg-1', + context_id='ctx-1', + task_id='task-1', + role=a2a_pb2.ROLE_USER, + content=[ + a2a_pb2.Part(text='Hello'), + a2a_pb2.Part( + file=a2a_pb2.FilePart( + file_with_uri='file:///test.txt', + mime_type='text/plain', + name='test.txt', + ) + ), + a2a_pb2.Part(data=a2a_pb2.DataPart(data={'key': 'value'})), + ], + metadata={'source': 'test'}, + ) + return a2a_pb2.Task( + id='task-1', + context_id='ctx-1', + status=a2a_pb2.TaskStatus( + state=a2a_pb2.TASK_STATE_WORKING, + update=sample_message, + ), + artifacts=[ + a2a_pb2.Artifact( + artifact_id='art-1', + parts=[a2a_pb2.Part(text='Artifact content')], + ) + ], + history=[sample_message], + ) + + @pytest.fixture def sample_agent_card() -> types.AgentCard: return types.AgentCard( @@ -127,6 +166,45 @@ class FakePartType: with pytest.raises(ValueError, match='Unsupported part type'): proto_utils.ToProto.part(mock_part) + @pytest.mark.parametrize( + 'params,expected', + [ + pytest.param( + types.ListTasksParams(), + a2a_pb2.ListTasksRequest(), + id='empty', + ), + pytest.param( + types.ListTasksParams( + context_id='ctx-1', + history_length=256, + include_artifacts=True, + last_updated_after=1761042977029, + metadata={'meta': 'data'}, + page_size=16, + page_token='1', + status=types.TaskState.working, + ), + a2a_pb2.ListTasksRequest( + context_id='ctx-1', + history_length=256, + include_artifacts=True, + last_updated_time=Timestamp( + seconds=1761042977, nanos=29000000 + ), + page_size=16, + page_token='1', + status=a2a_pb2.TaskState.TASK_STATE_WORKING, + ), + id='full', + ), + ], + ) + def test_list_tasks_request(self, params, expected): + request = proto_utils.ToProto.list_tasks_request(params) + + assert request == expected + class TestFromProto: def test_part_unsupported_type(self): @@ -143,6 +221,20 @@ def test_task_query_params_invalid_name(self): proto_utils.FromProto.task_query_params(request) assert isinstance(exc_info.value.error, types.InvalidParamsError) + def test_list_tasks_result(self, sample_proto_task): + response = a2a_pb2.ListTasksResponse( + next_page_token='1', + tasks=[sample_proto_task], + total_size=1, + ) + + result = proto_utils.FromProto.list_tasks_result(response, 10) + + assert result.next_page_token == '1' + assert result.page_size == 10 + assert len(result.tasks) == 1 + assert result.total_size == 1 + class TestProtoUtils: def test_roundtrip_message(self, sample_message: types.Message): diff --git a/tests/utils/test_task.py b/tests/utils/test_task.py index cb3dc3868..e0a2f942e 100644 --- a/tests/utils/test_task.py +++ b/tests/utils/test_task.py @@ -6,7 +6,12 @@ import pytest from a2a.types import Artifact, Message, Part, Role, TextPart -from a2a.utils.task import completed_task, new_task +from a2a.utils.task import ( + completed_task, + decode_page_token, + encode_page_token, + new_task, +) class TestTask(unittest.TestCase): @@ -188,6 +193,23 @@ def test_completed_task_invalid_artifact_type(self): history=[], ) + page_token = 'd47a95ba-0f39-4459-965b-3923cdd2ff58' + encoded_page_token = 'ZDQ3YTk1YmEtMGYzOS00NDU5LTk2NWItMzkyM2NkZDJmZjU4' # base64 for 'd47a95ba-0f39-4459-965b-3923cdd2ff58' + + def test_encode_page_token(self): + assert encode_page_token(self.page_token) == self.encoded_page_token + + def test_decode_page_token_succeeds(self): + assert decode_page_token(self.encoded_page_token) == self.page_token + + def test_decode_page_token_fails(self): + with pytest.raises(ValueError) as excinfo: + decode_page_token('invalid') + + assert 'Token is not a valid base64-encoded cursor.' in str( + excinfo.value + ) + if __name__ == '__main__': unittest.main() From 044408fc8157118a908a4fb279f64b646600eaa7 Mon Sep 17 00:00:00 2001 From: Lukasz Kawka Date: Mon, 24 Nov 2025 17:27:20 +0100 Subject: [PATCH 073/384] chore: Merge main into 1.0-dev (#566) Co-authored-by: Iva Sokolaj <102302011+sokoliva@users.noreply.github.com> Co-authored-by: Agent2Agent (A2A) Bot Co-authored-by: Tadaki Asechi <127199356+TadakiAsechi@users.noreply.github.com> Co-authored-by: tadaki Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Co-authored-by: TadakiAsechi Co-authored-by: TadakiAsechi --- CHANGELOG.md | 16 ++++++- src/a2a/client/base_client.py | 13 +++++- src/a2a/utils/proto_utils.py | 2 + tests/client/test_base_client.py | 76 ++++++++++++++++++++++++++++++++ tests/utils/test_proto_utils.py | 28 ++++++++++++ 5 files changed, 133 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b22ca154..e8d10a014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,18 @@ - # Changelog +# 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) + + +### 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) diff --git a/src/a2a/client/base_client.py b/src/a2a/client/base_client.py index a20098be3..e290d6de4 100644 --- a/src/a2a/client/base_client.py +++ b/src/a2a/client/base_client.py @@ -49,6 +49,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, @@ -61,6 +62,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. @@ -68,7 +70,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=( @@ -77,6 +79,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/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index a467a59f9..06ea11209 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -204,6 +204,7 @@ def task(cls, task: types.Task) -> a2a_pb2.Task: if task.history else None ), + metadata=cls.metadata(task.metadata), ) @classmethod @@ -689,6 +690,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/client/test_base_client.py b/tests/client/test_base_client.py index f5ab25432..7aa47902d 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'] diff --git a/tests/utils/test_proto_utils.py b/tests/utils/test_proto_utils.py index ccd0def62..c4b2f7b45 100644 --- a/tests/utils/test_proto_utils.py +++ b/tests/utils/test_proto_utils.py @@ -54,6 +54,7 @@ def sample_task(sample_message: types.Message) -> types.Task: ], ) ], + metadata={'source': 'test'}, ) @@ -600,3 +601,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 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 074/384] 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 d8011cf4d..090ac5415 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 bd705d93c..31747d8e4 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 075/384] 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 66dfd6779..4ee60df7d 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 076/384] 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 090ac5415..6cce1eff6 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 83c267873..948f3f356 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 31747d8e4..d9dbafc84 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 04bd10361..49d20d9da 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 077/384] 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 4ee60df7d..966d9e5a8 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 078/384] 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 6cce1eff6..32cf74f29 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 948f3f356..bdfcc8bac 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 d9dbafc84..edbcd6c79 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 49d20d9da..cd68b4434 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 079/384] 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 966d9e5a8..07631ea66 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 080/384] 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 07631ea66..590bd78eb 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 d4367c378..7f52d33f3 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 d13c5e506..53cdb9f56 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 d89f3bec7..872ac7234 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 775bd7fb8..d7364b840 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 081/384] 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 a016962ca..27b5cb4c3 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 46f7400a9..561a5a45c 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 fac7ecade..c870f3296 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 fd97b4d14..286641a79 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 8f114d95d..0c54a28dc 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 4e27953af..c5edf7a1c 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 32cf74f29..54c758ff4 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 bdfcc8bac..1649be1ca 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 96c1646a7..96acdc1e6 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 8bf01eea9..14ac098d2 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 000000000..6ea8c21b8 --- /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 e0a564eee..e6552fcb9 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 28acd27ce..f3227d327 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 33be1f3f7..f68d5c100 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 000000000..9a843d340 --- /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 082/384] 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 3f9c6fe9c..7c8cb0dcf 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 fabd7270f..e2eb066a5 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 f6599ccae..357fcb02e 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 ed032dcb5..c12e98096 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 0ff966cc3..96ded9580 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 083/384] 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 590bd78eb..966fe3dff 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 084/384] 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 3eca44356..876b6561e 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 5e1b8fd81..c1cbcf051 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 085/384] 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 5cc479ceb..e3bbe2e60 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 086/384] 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 000000000..f87d94506 --- /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 087/384] 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 f13fe3ab6..adb3c5aee 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 e2eb066a5..c3d5762eb 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 c5edf7a1c..6a8b16f92 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 54c758ff4..a565e6404 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 1649be1ca..afc9dd082 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 f87d94506..26f3f106d 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 16a1433fb..c388974b1 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 088/384] 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 966fe3dff..cfbedf4e0 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 089/384] 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 000000000..11bfff2b9 --- /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 090/384] 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 bdd4c5b8b..97bba6b6d 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 decb3b1d3..c6e6da0fa 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 16052ba19..eb5b3d1f8 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 c019afebc..e1adbd346 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 091/384] 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 e1adbd346..641076f2d 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 092/384] 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 97bba6b6d..5ddbfea59 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 eb5b3d1f8..9ef0f12f1 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 641076f2d..1c7521144 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 5003ac402..96abbe5c7 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 093/384] 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 27b5cb4c3..11496c9f7 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 000000000..0f3452b37 --- /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 000000000..e69de29bb diff --git a/tck/sut_agent.py b/tck/sut_agent.py new file mode 100644 index 000000000..525631ca0 --- /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 094/384] 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

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=virtualenv&package-manager=uv&previous-version=20.32.0&new-version=20.36.1)](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 96abbe5c7..cc0edc4bb 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 095/384] 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 4964376ec..d7c24cbf8 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,10 @@ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/a2a-sdk) [![PyPI - Downloads](https://img.shields.io/pypi/dw/a2a-sdk)](https://pypistats.org/packages/a2a-sdk) [![Python Unit Tests](https://github.com/a2aproject/a2a-python/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/a2aproject/a2a-python/actions/workflows/unit-tests.yml) -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/a2aproject/a2a-python) - + + Ask Code Wiki +
A2A Logo From 3dcb84772fdc8a4d3b63b518ed491e5ed3d38d0a Mon Sep 17 00:00:00 2001 From: Ivan Shymko Date: Thu, 22 Jan 2026 17:45:50 +0100 Subject: [PATCH 096/384] 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 a565e6404..a58a7cab7 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 afc9dd082..96df1e023 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 edbcd6c79..0f6bba5b0 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 cd68b4434..c889ebaff 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 097/384] 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 893d2b4b8..c97edb12f 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 098/384] 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

  • make parse_version_to_tuple public (#864) (c969186f)

  • Auto enable mTLS when supported certificates are detected (#869) (f8bf6f96)

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

  • Support for async bidi streaming apis (#836) (9530548)

v2.26.0

2.26.0 (2025-10-08)

Features

  • Add trove classifier for Python 3.14 (#842) (43690de)

... (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

  • Support for async bidi streaming apis (#836) (9530548)

2.26.0 (2025-10-08)

Features

  • Add trove classifier for Python 3.14 (#842) (43690de)

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