Skip to content

Commit 19e48d9

Browse files
authored
Merge branch 'main' into doc/helper-utils
2 parents f432ec7 + 58b4c81 commit 19e48d9

File tree

14 files changed

+793
-276
lines changed

14 files changed

+793
-276
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/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/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

src/a2a/server/tasks/inmemory_task_store.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import logging
33

4+
from a2a.server.context import ServerCallContext
45
from a2a.server.tasks.task_store import TaskStore
56
from a2a.types import Task
67

@@ -21,13 +22,17 @@ def __init__(self) -> None:
2122
self.tasks: dict[str, Task] = {}
2223
self.lock = asyncio.Lock()
2324

24-
async def save(self, task: Task) -> None:
25+
async def save(
26+
self, task: Task, context: ServerCallContext | None = None
27+
) -> None:
2528
"""Saves or updates a task in the in-memory store."""
2629
async with self.lock:
2730
self.tasks[task.id] = task
2831
logger.debug('Task %s saved successfully.', task.id)
2932

30-
async def get(self, task_id: str) -> Task | None:
33+
async def get(
34+
self, task_id: str, context: ServerCallContext | None = None
35+
) -> Task | None:
3136
"""Retrieves a task from the in-memory store by ID."""
3237
async with self.lock:
3338
logger.debug('Attempting to get task with id: %s', task_id)
@@ -38,7 +43,9 @@ async def get(self, task_id: str) -> Task | None:
3843
logger.debug('Task %s not found in store.', task_id)
3944
return task
4045

41-
async def delete(self, task_id: str) -> None:
46+
async def delete(
47+
self, task_id: str, context: ServerCallContext | None = None
48+
) -> None:
4249
"""Deletes a task from the in-memory store by ID."""
4350
async with self.lock:
4451
logger.debug('Attempting to delete task with id: %s', task_id)

src/a2a/server/tasks/task_manager.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22

3+
from a2a.server.context import ServerCallContext
34
from a2a.server.events.event_queue import Event
45
from a2a.server.tasks.task_store import TaskStore
56
from a2a.types import (
@@ -31,6 +32,7 @@ def __init__(
3132
context_id: str | None,
3233
task_store: TaskStore,
3334
initial_message: Message | None,
35+
context: ServerCallContext | None = None,
3436
):
3537
"""Initializes the TaskManager.
3638
@@ -40,6 +42,7 @@ def __init__(
4042
task_store: The `TaskStore` instance for persistence.
4143
initial_message: The `Message` that initiated the task, if any.
4244
Used when creating a new task object.
45+
context: The `ServerCallContext` that this task is produced under.
4346
"""
4447
if task_id is not None and not (isinstance(task_id, str) and task_id):
4548
raise ValueError('Task ID must be a non-empty string')
@@ -49,6 +52,7 @@ def __init__(
4952
self.task_store = task_store
5053
self._initial_message = initial_message
5154
self._current_task: Task | None = None
55+
self._call_context: ServerCallContext | None = context
5256
logger.debug(
5357
'TaskManager initialized with task_id: %s, context_id: %s',
5458
task_id,
@@ -74,7 +78,9 @@ async def get_task(self) -> Task | None:
7478
logger.debug(
7579
'Attempting to get task from store with id: %s', self.task_id
7680
)
77-
self._current_task = await self.task_store.get(self.task_id)
81+
self._current_task = await self.task_store.get(
82+
self.task_id, self._call_context
83+
)
7884
if self._current_task:
7985
logger.debug('Task %s retrieved successfully.', self.task_id)
8086
else:
@@ -167,7 +173,7 @@ async def ensure_task(
167173
logger.debug(
168174
'Attempting to retrieve existing task with id: %s', self.task_id
169175
)
170-
task = await self.task_store.get(self.task_id)
176+
task = await self.task_store.get(self.task_id, self._call_context)
171177

172178
if not task:
173179
logger.info(
@@ -231,7 +237,7 @@ async def _save_task(self, task: Task) -> None:
231237
task: The `Task` object to save.
232238
"""
233239
logger.debug('Saving task with id: %s', task.id)
234-
await self.task_store.save(task)
240+
await self.task_store.save(task, self._call_context)
235241
self._current_task = task
236242
if not self.task_id:
237243
logger.info('New task created with id: %s', task.id)

0 commit comments

Comments
 (0)