Skip to content

Commit cba5fc2

Browse files
authored
Merge branch '1.0-dev' into ishymko/integration-package-test
2 parents 7659b8a + ab762f0 commit cba5fc2

22 files changed

Lines changed: 536 additions & 124 deletions

docs/migrations/v1_0/database/zero_downtime.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Enable the v0.3 conversion utilities in your application entry point (e.g., `mai
6262

6363
```python
6464
from a2a.server.tasks import DatabaseTaskStore, DatabasePushNotificationConfigStore
65-
from a2a.compat.v0_3.conversions import (
65+
from a2a.compat.v0_3.model_conversions import (
6666
core_to_compat_task_model,
6767
core_to_compat_push_notification_config_model,
6868
)
@@ -126,7 +126,7 @@ This allows v1.0 instances to read *all* existing data regardless of when it was
126126

127127
## 🧩 Resources
128128
- **[a2a-db CLI](../../../../src/a2a/migrations/README.md)**: The primary tool for executing schema migrations.
129-
- **[Compatibility Conversions](../../../../src/a2a/compat/v0_3/conversions.py)**: Source for classes like `core_to_compat_task_model` used in Step 2.
129+
- **[Compatibility Conversions](../../../../src/a2a/compat/v0_3/model_conversions.py)**: Source for model conversion functions `core_to_compat_task_model` and `core_to_compat_push_notification_config_model` used in Step 2.
130130
- **[Task Store Implementation](../../../../src/a2a/server/tasks/database_task_store.py)**: The `DatabaseTaskStore` which handles the version-aware read/write logic.
131131
- **[Push Notification Store Implementation](../../../../src/a2a/server/tasks/database_push_notification_config_store.py)**: The `DatabasePushNotificationConfigStore` which handles the version-aware read/write logic.
132132

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ indent-width = 4 # Google Style Guide §3.4: 4 spaces
199199
target-version = "py310" # Minimum Python version
200200

201201
[tool.ruff.lint]
202+
preview = true
203+
explicit-preview-rules = true
202204
ignore = [
203205
"COM812", # Trailing comma missing.
204206
"FBT001", # Boolean positional arg in function definition

src/a2a/client/transports/grpc.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@
4747
TaskPushNotificationConfig,
4848
)
4949
from a2a.utils.constants import PROTOCOL_VERSION_CURRENT, VERSION_HEADER
50-
from a2a.utils.errors import A2A_REASON_TO_ERROR
50+
from a2a.utils.errors import A2A_REASON_TO_ERROR, A2AError
51+
from a2a.utils.proto_utils import bad_request_to_validation_errors
5152
from a2a.utils.telemetry import SpanKind, trace_class
5253

5354

@@ -61,17 +62,23 @@ def _map_grpc_error(e: grpc.aio.AioRpcError) -> NoReturn:
6162

6263
# Use grpc_status to cleanly extract the rich Status from the call
6364
status = rpc_status.from_call(cast('grpc.Call', e))
65+
data = None
6466

6567
if status is not None:
68+
exception_cls: type[A2AError] | None = None
6669
for detail in status.details:
6770
if detail.Is(error_details_pb2.ErrorInfo.DESCRIPTOR):
6871
error_info = error_details_pb2.ErrorInfo()
6972
detail.Unpack(error_info)
70-
7173
if error_info.domain == 'a2a-protocol.org':
7274
exception_cls = A2A_REASON_TO_ERROR.get(error_info.reason)
73-
if exception_cls:
74-
raise exception_cls(status.message) from e
75+
elif detail.Is(error_details_pb2.BadRequest.DESCRIPTOR):
76+
bad_request = error_details_pb2.BadRequest()
77+
detail.Unpack(bad_request)
78+
data = {'errors': bad_request_to_validation_errors(bad_request)}
79+
80+
if exception_cls:
81+
raise exception_cls(status.message, data=data) from e
7582

7683
raise A2AClientError(f'gRPC Error {e.code().name}: {e.details()}') from e
7784

src/a2a/client/transports/jsonrpc.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,9 +318,10 @@ def _create_jsonrpc_error(self, error_dict: dict[str, Any]) -> Exception:
318318
"""Creates the appropriate A2AError from a JSON-RPC error dictionary."""
319319
code = error_dict.get('code')
320320
message = error_dict.get('message', str(error_dict))
321+
data = error_dict.get('data')
321322

322323
if isinstance(code, int) and code in _JSON_RPC_ERROR_CODE_TO_A2A_ERROR:
323-
return _JSON_RPC_ERROR_CODE_TO_A2A_ERROR[code](message)
324+
return _JSON_RPC_ERROR_CODE_TO_A2A_ERROR[code](message, data=data)
324325

325326
# Fallback to general A2AClientError
326327
return A2AClientError(f'JSON-RPC Error {code}: {message}')

src/a2a/compat/v0_3/conversions.py

Lines changed: 1 addition & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
import base64
22

3-
from typing import TYPE_CHECKING, Any
4-
5-
6-
if TYPE_CHECKING:
7-
from cryptography.fernet import Fernet
3+
from typing import Any
84

95
from google.protobuf.json_format import MessageToDict, ParseDict
106

117
from a2a.compat.v0_3 import types as types_v03
128
from a2a.compat.v0_3.versions import is_legacy_version
13-
from a2a.server.models import PushNotificationConfigModel, TaskModel
149
from a2a.types import a2a_pb2 as pb2_v10
1510
from a2a.utils import constants, errors
1611

@@ -1378,77 +1373,3 @@ def to_compat_get_extended_agent_card_request(
13781373
) -> types_v03.GetAuthenticatedExtendedCardRequest:
13791374
"""Convert get extended agent card request to v0.3 compat type."""
13801375
return types_v03.GetAuthenticatedExtendedCardRequest(id=request_id)
1381-
1382-
1383-
def core_to_compat_task_model(task: pb2_v10.Task, owner: str) -> TaskModel:
1384-
"""Converts a 1.0 core Task to a TaskModel using v0.3 JSON structure."""
1385-
compat_task = to_compat_task(task)
1386-
data = compat_task.model_dump(mode='json')
1387-
1388-
return TaskModel(
1389-
id=task.id,
1390-
context_id=task.context_id,
1391-
owner=owner,
1392-
status=data.get('status'),
1393-
history=data.get('history'),
1394-
artifacts=data.get('artifacts'),
1395-
task_metadata=data.get('metadata'),
1396-
protocol_version='0.3',
1397-
)
1398-
1399-
1400-
def compat_task_model_to_core(task_model: TaskModel) -> pb2_v10.Task:
1401-
"""Converts a TaskModel with v0.3 structure to a 1.0 core Task."""
1402-
compat_task = types_v03.Task(
1403-
id=task_model.id,
1404-
context_id=task_model.context_id,
1405-
status=types_v03.TaskStatus.model_validate(task_model.status),
1406-
artifacts=(
1407-
[types_v03.Artifact.model_validate(a) for a in task_model.artifacts]
1408-
if task_model.artifacts
1409-
else []
1410-
),
1411-
history=(
1412-
[types_v03.Message.model_validate(h) for h in task_model.history]
1413-
if task_model.history
1414-
else []
1415-
),
1416-
metadata=task_model.task_metadata,
1417-
)
1418-
return to_core_task(compat_task)
1419-
1420-
1421-
def core_to_compat_push_notification_config_model(
1422-
task_id: str,
1423-
config: pb2_v10.TaskPushNotificationConfig,
1424-
owner: str,
1425-
fernet: 'Fernet | None' = None,
1426-
) -> PushNotificationConfigModel:
1427-
"""Converts a 1.0 core TaskPushNotificationConfig to a PushNotificationConfigModel using v0.3 JSON structure."""
1428-
compat_config = to_compat_push_notification_config(config)
1429-
1430-
json_payload = compat_config.model_dump_json().encode('utf-8')
1431-
data_to_store = fernet.encrypt(json_payload) if fernet else json_payload
1432-
1433-
return PushNotificationConfigModel(
1434-
task_id=task_id,
1435-
config_id=config.id,
1436-
owner=owner,
1437-
config_data=data_to_store,
1438-
protocol_version='0.3',
1439-
)
1440-
1441-
1442-
def compat_push_notification_config_model_to_core(
1443-
model_instance: str, task_id: str
1444-
) -> pb2_v10.TaskPushNotificationConfig:
1445-
"""Converts a PushNotificationConfigModel with v0.3 structure back to a 1.0 core TaskPushNotificationConfig."""
1446-
inner_config = types_v03.PushNotificationConfig.model_validate_json(
1447-
model_instance
1448-
)
1449-
return to_core_task_push_notification_config(
1450-
types_v03.TaskPushNotificationConfig(
1451-
task_id=task_id,
1452-
push_notification_config=inner_config,
1453-
)
1454-
)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Database model conversions for v0.3 compatibility."""
2+
3+
from typing import TYPE_CHECKING
4+
5+
6+
if TYPE_CHECKING:
7+
from cryptography.fernet import Fernet
8+
9+
10+
from a2a.compat.v0_3 import types as types_v03
11+
from a2a.compat.v0_3.conversions import (
12+
to_compat_push_notification_config,
13+
to_compat_task,
14+
to_core_task,
15+
to_core_task_push_notification_config,
16+
)
17+
from a2a.server.models import PushNotificationConfigModel, TaskModel
18+
from a2a.types import a2a_pb2 as pb2_v10
19+
20+
21+
def core_to_compat_task_model(task: pb2_v10.Task, owner: str) -> TaskModel:
22+
"""Converts a 1.0 core Task to a TaskModel using v0.3 JSON structure."""
23+
compat_task = to_compat_task(task)
24+
data = compat_task.model_dump(mode='json')
25+
26+
return TaskModel(
27+
id=task.id,
28+
context_id=task.context_id,
29+
owner=owner,
30+
status=data.get('status'),
31+
history=data.get('history'),
32+
artifacts=data.get('artifacts'),
33+
task_metadata=data.get('metadata'),
34+
protocol_version='0.3',
35+
)
36+
37+
38+
def compat_task_model_to_core(task_model: TaskModel) -> pb2_v10.Task:
39+
"""Converts a TaskModel with v0.3 structure to a 1.0 core Task."""
40+
compat_task = types_v03.Task(
41+
id=task_model.id,
42+
context_id=task_model.context_id,
43+
status=types_v03.TaskStatus.model_validate(task_model.status),
44+
artifacts=(
45+
[types_v03.Artifact.model_validate(a) for a in task_model.artifacts]
46+
if task_model.artifacts
47+
else []
48+
),
49+
history=(
50+
[types_v03.Message.model_validate(h) for h in task_model.history]
51+
if task_model.history
52+
else []
53+
),
54+
metadata=task_model.task_metadata,
55+
)
56+
return to_core_task(compat_task)
57+
58+
59+
def core_to_compat_push_notification_config_model(
60+
task_id: str,
61+
config: pb2_v10.TaskPushNotificationConfig,
62+
owner: str,
63+
fernet: 'Fernet | None' = None,
64+
) -> PushNotificationConfigModel:
65+
"""Converts a 1.0 core TaskPushNotificationConfig to a PushNotificationConfigModel using v0.3 JSON structure."""
66+
compat_config = to_compat_push_notification_config(config)
67+
68+
json_payload = compat_config.model_dump_json().encode('utf-8')
69+
data_to_store = fernet.encrypt(json_payload) if fernet else json_payload
70+
71+
return PushNotificationConfigModel(
72+
task_id=task_id,
73+
config_id=config.id,
74+
owner=owner,
75+
config_data=data_to_store,
76+
protocol_version='0.3',
77+
)
78+
79+
80+
def compat_push_notification_config_model_to_core(
81+
model_instance: str, task_id: str
82+
) -> pb2_v10.TaskPushNotificationConfig:
83+
"""Converts a PushNotificationConfigModel with v0.3 structure back to a 1.0 core TaskPushNotificationConfig."""
84+
inner_config = types_v03.PushNotificationConfig.model_validate_json(
85+
model_instance
86+
)
87+
return to_core_task_push_notification_config(
88+
types_v03.TaskPushNotificationConfig(
89+
task_id=task_id,
90+
push_notification_config=inner_config,
91+
)
92+
)

src/a2a/server/request_handlers/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
from a2a.server.request_handlers.default_request_handler import (
66
DefaultRequestHandler,
77
)
8-
from a2a.server.request_handlers.request_handler import RequestHandler
8+
from a2a.server.request_handlers.request_handler import (
9+
RequestHandler,
10+
validate_request_params,
11+
)
912
from a2a.server.request_handlers.response_helpers import (
1013
build_error_response,
1114
prepare_response_object,
@@ -43,4 +46,5 @@ def __init__(self, *args, **kwargs):
4346
'RequestHandler',
4447
'build_error_response',
4548
'prepare_response_object',
49+
'validate_request_params',
4650
]

src/a2a/server/request_handlers/default_request_handler.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
InMemoryQueueManager,
1919
QueueManager,
2020
)
21-
from a2a.server.request_handlers.request_handler import RequestHandler
21+
from a2a.server.request_handlers.request_handler import (
22+
RequestHandler,
23+
validate_request_params,
24+
)
2225
from a2a.server.tasks import (
2326
PushNotificationConfigStore,
2427
PushNotificationEvent,
@@ -118,6 +121,7 @@ def __init__( # noqa: PLR0913
118121
# asyncio tasks and to surface unexpected exceptions.
119122
self._background_tasks = set()
120123

124+
@validate_request_params
121125
async def on_get_task(
122126
self,
123127
params: GetTaskRequest,
@@ -133,6 +137,7 @@ async def on_get_task(
133137

134138
return apply_history_length(task, params)
135139

140+
@validate_request_params
136141
async def on_list_tasks(
137142
self,
138143
params: ListTasksRequest,
@@ -154,6 +159,7 @@ async def on_list_tasks(
154159

155160
return page
156161

162+
@validate_request_params
157163
async def on_cancel_task(
158164
self,
159165
params: CancelTaskRequest,
@@ -317,6 +323,7 @@ async def _send_push_notification_if_needed(
317323
):
318324
await self._push_sender.send_notification(task_id, event)
319325

326+
@validate_request_params
320327
async def on_message_send(
321328
self,
322329
params: SendMessageRequest,
@@ -386,6 +393,7 @@ async def push_notification_callback(event: Event) -> None:
386393

387394
return result
388395

396+
@validate_request_params
389397
async def on_message_send_stream(
390398
self,
391399
params: SendMessageRequest,
@@ -474,6 +482,7 @@ async def _cleanup_producer(
474482
async with self._running_agents_lock:
475483
self._running_agents.pop(task_id, None)
476484

485+
@validate_request_params
477486
async def on_create_task_push_notification_config(
478487
self,
479488
params: TaskPushNotificationConfig,
@@ -499,6 +508,7 @@ async def on_create_task_push_notification_config(
499508

500509
return params
501510

511+
@validate_request_params
502512
async def on_get_task_push_notification_config(
503513
self,
504514
params: GetTaskPushNotificationConfigRequest,
@@ -530,6 +540,7 @@ async def on_get_task_push_notification_config(
530540

531541
raise InternalError(message='Push notification config not found')
532542

543+
@validate_request_params
533544
async def on_subscribe_to_task(
534545
self,
535546
params: SubscribeToTaskRequest,
@@ -572,6 +583,7 @@ async def on_subscribe_to_task(
572583
async for event in result_aggregator.consume_and_emit(consumer):
573584
yield event
574585

586+
@validate_request_params
575587
async def on_list_task_push_notification_configs(
576588
self,
577589
params: ListTaskPushNotificationConfigsRequest,
@@ -597,6 +609,7 @@ async def on_list_task_push_notification_configs(
597609
configs=push_notification_config_list
598610
)
599611

612+
@validate_request_params
600613
async def on_delete_task_push_notification_config(
601614
self,
602615
params: DeleteTaskPushNotificationConfigRequest,

0 commit comments

Comments
 (0)