Skip to content

Commit 671aee0

Browse files
authored
Merge branch 'main' into fix-proto-utils-metadata-serialization
2 parents 37eb3d0 + 58b4c81 commit 671aee0

File tree

18 files changed

+817
-290
lines changed

18 files changed

+817
-290
lines changed

.github/CODEOWNERS

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
# For syntax help see:
55
# https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax
66

7-
* @a2aproject/google-a2a-eng
7+
* @a2aproject/google-a2a-eng
8+
src/a2a/types.py @a2a-bot

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Changelog
22

3+
## [0.3.4](https://github.com/a2aproject/a2a-python/compare/v0.3.3...v0.3.4) (2025-09-02)
4+
5+
6+
### Features
7+
8+
* Add `ServerCallContext` into task store operations ([#443](https://github.com/a2aproject/a2a-python/issues/443)) ([e3e5c4b](https://github.com/a2aproject/a2a-python/commit/e3e5c4b7dcb5106e943b9aeb8e761ed23cc166a2))
9+
* 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))
10+
11+
12+
### Bug Fixes
13+
14+
* 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))
15+
* handle concurrent task completion during cancellation ([#449](https://github.com/a2aproject/a2a-python/issues/449)) ([f4c9c18](https://github.com/a2aproject/a2a-python/commit/f4c9c18cfef3ccab1ac7bb30cc7f8293cf3e3ef6))
16+
* 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))
17+
* 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))
18+
* 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))
19+
20+
21+
### Performance Improvements
22+
23+
* 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))
24+
325
## [0.3.3](https://github.com/a2aproject/a2a-python/compare/v0.3.2...v0.3.3) (2025-08-22)
426

527

src/a2a/grpc/a2a_pb2.py

Lines changed: 103 additions & 103 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/a2a/grpc/a2a_pb2.pyi

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,16 @@ class TaskStatus(_message.Message):
8484
def __init__(self, state: _Optional[_Union[TaskState, str]] = ..., update: _Optional[_Union[Message, _Mapping]] = ..., timestamp: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ...
8585

8686
class Part(_message.Message):
87-
__slots__ = ("text", "file", "data")
87+
__slots__ = ("text", "file", "data", "metadata")
8888
TEXT_FIELD_NUMBER: _ClassVar[int]
8989
FILE_FIELD_NUMBER: _ClassVar[int]
9090
DATA_FIELD_NUMBER: _ClassVar[int]
91+
METADATA_FIELD_NUMBER: _ClassVar[int]
9192
text: str
9293
file: FilePart
9394
data: DataPart
94-
def __init__(self, text: _Optional[str] = ..., file: _Optional[_Union[FilePart, _Mapping]] = ..., data: _Optional[_Union[DataPart, _Mapping]] = ...) -> None: ...
95+
metadata: _struct_pb2.Struct
96+
def __init__(self, text: _Optional[str] = ..., file: _Optional[_Union[FilePart, _Mapping]] = ..., data: _Optional[_Union[DataPart, _Mapping]] = ..., metadata: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
9597

9698
class FilePart(_message.Message):
9799
__slots__ = ("file_with_uri", "file_with_bytes", "mime_type", "name")

src/a2a/server/apps/jsonrpc/jsonrpc_app.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -219,14 +219,6 @@ def __init__( # noqa: PLR0913
219219
extended_agent_card=extended_agent_card,
220220
extended_card_modifier=extended_card_modifier,
221221
)
222-
if (
223-
self.agent_card.supports_authenticated_extended_card
224-
and self.extended_agent_card is None
225-
and self.extended_card_modifier is None
226-
):
227-
logger.error(
228-
'AgentCard.supports_authenticated_extended_card is True, but no extended_agent_card was provided. The /agent/authenticatedExtendedCard endpoint will return 404.'
229-
)
230222
self._context_builder = context_builder or DefaultCallContextBuilder()
231223

232224
def _generate_error_response(

src/a2a/server/apps/rest/rest_adapter.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,6 @@ def __init__( # noqa: PLR0913
9494
self.handler = RESTHandler(
9595
agent_card=agent_card, request_handler=http_handler
9696
)
97-
if (
98-
self.agent_card.supports_authenticated_extended_card
99-
and self.extended_agent_card is None
100-
and self.extended_card_modifier is None
101-
):
102-
logger.error(
103-
'AgentCard.supports_authenticated_extended_card is True, but no extended_agent_card was provided. The /agent/authenticatedExtendedCard endpoint will return 404.'
104-
)
10597
self._context_builder = context_builder or DefaultCallContextBuilder()
10698

10799
@rest_error_handler
@@ -190,9 +182,9 @@ async def handle_authenticated_agent_card(
190182

191183
if self.extended_card_modifier:
192184
context = self._context_builder.build(request)
193-
# If no base extended card is provided, pass the public card to the modifier
194-
base_card = card_to_serve if card_to_serve else self.agent_card
195-
card_to_serve = self.extended_card_modifier(base_card, context)
185+
card_to_serve = self.extended_card_modifier(card_to_serve, context)
186+
elif self.card_modifier:
187+
card_to_serve = self.card_modifier(card_to_serve)
196188

197189
return card_to_serve.model_dump(mode='json', exclude_none=True)
198190

src/a2a/server/events/event_consumer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ async def consume_all(self) -> AsyncGenerator[Event]:
133133
# continue polling until there is a final event
134134
continue
135135
except asyncio.TimeoutError: # pyright: ignore [reportUnusedExcept]
136-
# This class was made an alias of build-in TimeoutError after 3.11
136+
# This class was made an alias of built-in TimeoutError after 3.11
137137
continue
138138
except (QueueClosed, asyncio.QueueEmpty):
139139
# Confirm that the queue is closed, e.g. we aren't on

src/a2a/server/request_handlers/default_request_handler.py

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class DefaultRequestHandler(RequestHandler):
6767
"""
6868

6969
_running_agents: dict[str, asyncio.Task]
70+
_background_tasks: set[asyncio.Task]
7071

7172
def __init__( # noqa: PLR0913
7273
self,
@@ -102,14 +103,17 @@ def __init__( # noqa: PLR0913
102103
# TODO: Likely want an interface for managing this, like AgentExecutionManager.
103104
self._running_agents = {}
104105
self._running_agents_lock = asyncio.Lock()
106+
# Tracks background tasks (e.g., deferred cleanups) to avoid orphaning
107+
# asyncio tasks and to surface unexpected exceptions.
108+
self._background_tasks = set()
105109

106110
async def on_get_task(
107111
self,
108112
params: TaskQueryParams,
109113
context: ServerCallContext | None = None,
110114
) -> Task | None:
111115
"""Default handler for 'tasks/get'."""
112-
task: Task | None = await self.task_store.get(params.id)
116+
task: Task | None = await self.task_store.get(params.id, context)
113117
if not task:
114118
raise ServerError(error=TaskNotFoundError())
115119

@@ -141,7 +145,7 @@ async def on_cancel_task(
141145
142146
Attempts to cancel the task managed by the `AgentExecutor`.
143147
"""
144-
task: Task | None = await self.task_store.get(params.id)
148+
task: Task | None = await self.task_store.get(params.id, context)
145149
if not task:
146150
raise ServerError(error=TaskNotFoundError())
147151

@@ -158,6 +162,7 @@ async def on_cancel_task(
158162
context_id=task.context_id,
159163
task_store=self.task_store,
160164
initial_message=None,
165+
context=context,
161166
)
162167
result_aggregator = ResultAggregator(task_manager)
163168

@@ -180,14 +185,21 @@ async def on_cancel_task(
180185

181186
consumer = EventConsumer(queue)
182187
result = await result_aggregator.consume_all(consumer)
183-
if isinstance(result, Task):
184-
return result
188+
if not isinstance(result, Task):
189+
raise ServerError(
190+
error=InternalError(
191+
message='Agent did not return valid response for cancel'
192+
)
193+
)
185194

186-
raise ServerError(
187-
error=InternalError(
188-
message='Agent did not return valid response for cancel'
195+
if result.status.state != TaskState.canceled:
196+
raise ServerError(
197+
error=TaskNotCancelableError(
198+
message=f'Task cannot be canceled - current state: {result.status.state}'
199+
)
189200
)
190-
)
201+
202+
return result
191203

192204
async def _run_event_stream(
193205
self, request: RequestContext, queue: EventQueue
@@ -217,6 +229,7 @@ async def _setup_message_execution(
217229
context_id=params.message.context_id,
218230
task_store=self.task_store,
219231
initial_message=params.message,
232+
context=context,
220233
)
221234
task: Task | None = await task_manager.get_task()
222235

@@ -346,10 +359,11 @@ async def push_notification_callback() -> None:
346359
raise
347360
finally:
348361
if interrupted_or_non_blocking:
349-
# TODO: Track this disconnected cleanup task.
350-
asyncio.create_task( # noqa: RUF006
362+
cleanup_task = asyncio.create_task(
351363
self._cleanup_producer(producer_task, task_id)
352364
)
365+
cleanup_task.set_name(f'cleanup_producer:{task_id}')
366+
self._track_background_task(cleanup_task)
353367
else:
354368
await self._cleanup_producer(producer_task, task_id)
355369

@@ -385,7 +399,11 @@ async def on_message_send_stream(
385399
)
386400
yield event
387401
finally:
388-
await self._cleanup_producer(producer_task, task_id)
402+
cleanup_task = asyncio.create_task(
403+
self._cleanup_producer(producer_task, task_id)
404+
)
405+
cleanup_task.set_name(f'cleanup_producer:{task_id}')
406+
self._track_background_task(cleanup_task)
389407

390408
async def _register_producer(
391409
self, task_id: str, producer_task: asyncio.Task
@@ -394,6 +412,29 @@ async def _register_producer(
394412
async with self._running_agents_lock:
395413
self._running_agents[task_id] = producer_task
396414

415+
def _track_background_task(self, task: asyncio.Task) -> None:
416+
"""Tracks a background task and logs exceptions on completion.
417+
418+
This avoids unreferenced tasks (and associated lint warnings) while
419+
ensuring any exceptions are surfaced in logs.
420+
"""
421+
self._background_tasks.add(task)
422+
423+
def _on_done(completed: asyncio.Task) -> None:
424+
try:
425+
# Retrieve result to raise exceptions, if any
426+
completed.result()
427+
except asyncio.CancelledError:
428+
name = completed.get_name()
429+
logger.debug('Background task %s cancelled', name)
430+
except Exception:
431+
name = completed.get_name()
432+
logger.exception('Background task %s failed', name)
433+
finally:
434+
self._background_tasks.discard(completed)
435+
436+
task.add_done_callback(_on_done)
437+
397438
async def _cleanup_producer(
398439
self,
399440
producer_task: asyncio.Task,
@@ -417,7 +458,7 @@ async def on_set_task_push_notification_config(
417458
if not self._push_config_store:
418459
raise ServerError(error=UnsupportedOperationError())
419460

420-
task: Task | None = await self.task_store.get(params.task_id)
461+
task: Task | None = await self.task_store.get(params.task_id, context)
421462
if not task:
422463
raise ServerError(error=TaskNotFoundError())
423464

@@ -440,7 +481,7 @@ async def on_get_task_push_notification_config(
440481
if not self._push_config_store:
441482
raise ServerError(error=UnsupportedOperationError())
442483

443-
task: Task | None = await self.task_store.get(params.id)
484+
task: Task | None = await self.task_store.get(params.id, context)
444485
if not task:
445486
raise ServerError(error=TaskNotFoundError())
446487

@@ -469,7 +510,7 @@ async def on_resubscribe_to_task(
469510
Allows a client to re-attach to a running streaming task's event stream.
470511
Requires the task and its queue to still be active.
471512
"""
472-
task: Task | None = await self.task_store.get(params.id)
513+
task: Task | None = await self.task_store.get(params.id, context)
473514
if not task:
474515
raise ServerError(error=TaskNotFoundError())
475516

@@ -485,6 +526,7 @@ async def on_resubscribe_to_task(
485526
context_id=task.context_id,
486527
task_store=self.task_store,
487528
initial_message=None,
529+
context=context,
488530
)
489531

490532
result_aggregator = ResultAggregator(task_manager)
@@ -509,7 +551,7 @@ async def on_list_task_push_notification_config(
509551
if not self._push_config_store:
510552
raise ServerError(error=UnsupportedOperationError())
511553

512-
task: Task | None = await self.task_store.get(params.id)
554+
task: Task | None = await self.task_store.get(params.id, context)
513555
if not task:
514556
raise ServerError(error=TaskNotFoundError())
515557

@@ -536,7 +578,7 @@ async def on_delete_task_push_notification_config(
536578
if not self._push_config_store:
537579
raise ServerError(error=UnsupportedOperationError())
538580

539-
task: Task | None = await self.task_store.get(params.id)
581+
task: Task | None = await self.task_store.get(params.id, context)
540582
if not task:
541583
raise ServerError(error=TaskNotFoundError())
542584

src/a2a/server/request_handlers/jsonrpc_handler.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def __init__(
6666
[AgentCard, ServerCallContext], AgentCard
6767
]
6868
| None = None,
69+
card_modifier: Callable[[AgentCard], AgentCard] | None = None,
6970
):
7071
"""Initializes the JSONRPCHandler.
7172
@@ -76,11 +77,14 @@ def __init__(
7677
extended_card_modifier: An optional callback to dynamically modify
7778
the extended agent card before it is served. It receives the
7879
call context.
80+
card_modifier: An optional callback to dynamically modify the public
81+
agent card before it is served.
7982
"""
8083
self.agent_card = agent_card
8184
self.request_handler = request_handler
8285
self.extended_agent_card = extended_agent_card
8386
self.extended_card_modifier = extended_card_modifier
87+
self.card_modifier = card_modifier
8488

8589
async def on_message_send(
8690
self,
@@ -425,14 +429,10 @@ async def get_authenticated_extended_card(
425429
Returns:
426430
A `GetAuthenticatedExtendedCardResponse` object containing the config or a JSON-RPC error.
427431
"""
428-
if (
429-
self.extended_agent_card is None
430-
and self.extended_card_modifier is None
431-
):
432-
return GetAuthenticatedExtendedCardResponse(
433-
root=JSONRPCErrorResponse(
434-
id=request.id,
435-
error=AuthenticatedExtendedCardNotConfiguredError(),
432+
if not self.agent_card.supports_authenticated_extended_card:
433+
raise ServerError(
434+
error=AuthenticatedExtendedCardNotConfiguredError(
435+
message='Authenticated card not supported'
436436
)
437437
)
438438

@@ -443,6 +443,8 @@ async def get_authenticated_extended_card(
443443
card_to_serve = base_card
444444
if self.extended_card_modifier and context:
445445
card_to_serve = self.extended_card_modifier(base_card, context)
446+
elif self.card_modifier:
447+
card_to_serve = self.card_modifier(base_card)
446448

447449
return GetAuthenticatedExtendedCardResponse(
448450
root=GetAuthenticatedExtendedCardSuccessResponse(

src/a2a/server/tasks/database_task_store.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"or 'pip install a2a-sdk[sql]'"
2020
) from e
2121

22+
from a2a.server.context import ServerCallContext
2223
from a2a.server.models import Base, TaskModel, create_task_model
2324
from a2a.server.tasks.task_store import TaskStore
2425
from a2a.types import Task # Task is the Pydantic model
@@ -119,15 +120,19 @@ def _from_orm(self, task_model: TaskModel) -> Task:
119120
# Pydantic's model_validate will parse the nested dicts/lists from JSON
120121
return Task.model_validate(task_data_from_db)
121122

122-
async def save(self, task: Task) -> None:
123+
async def save(
124+
self, task: Task, context: ServerCallContext | None = None
125+
) -> None:
123126
"""Saves or updates a task in the database."""
124127
await self._ensure_initialized()
125128
db_task = self._to_orm(task)
126129
async with self.async_session_maker.begin() as session:
127130
await session.merge(db_task)
128131
logger.debug('Task %s saved/updated successfully.', task.id)
129132

130-
async def get(self, task_id: str) -> Task | None:
133+
async def get(
134+
self, task_id: str, context: ServerCallContext | None = None
135+
) -> Task | None:
131136
"""Retrieves a task from the database by ID."""
132137
await self._ensure_initialized()
133138
async with self.async_session_maker() as session:
@@ -142,7 +147,9 @@ async def get(self, task_id: str) -> Task | None:
142147
logger.debug('Task %s not found in store.', task_id)
143148
return None
144149

145-
async def delete(self, task_id: str) -> None:
150+
async def delete(
151+
self, task_id: str, context: ServerCallContext | None = None
152+
) -> None:
146153
"""Deletes a task from the database by ID."""
147154
await self._ensure_initialized()
148155

0 commit comments

Comments
 (0)