Skip to content

Commit 726d184

Browse files
authored
Merge branch 'main' into chore/improve-coverage-grpc-client
2 parents 3e1e6f7 + a3e7e1e commit 726d184

21 files changed

+574
-203
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ __pycache__
66
.pytest_cache
77
.ruff_cache
88
.venv
9+
test_venv/
910
coverage.xml
1011
.nox
11-
spec.json
12+
spec.json

.ruff.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ ignore = [
3030
"ANN003",
3131
"ANN401",
3232
"TRY003",
33-
"G004",
3433
"TRY201",
3534
"FIX002",
3635
]

CHANGELOG.md

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

3+
## [0.3.2](https://github.com/a2aproject/a2a-python/compare/v0.3.1...v0.3.2) (2025-08-20)
4+
5+
6+
### Bug Fixes
7+
8+
* Add missing mime_type and name in proto conversion utils ([#408](https://github.com/a2aproject/a2a-python/issues/408)) ([72b2ee7](https://github.com/a2aproject/a2a-python/commit/72b2ee75dccfc8399edaa0837a025455b4b53a17))
9+
* Add name field to FilePart protobuf message ([#403](https://github.com/a2aproject/a2a-python/issues/403)) ([1dbe33d](https://github.com/a2aproject/a2a-python/commit/1dbe33d5cf2c74019b72c709f3427aeba54bf4e3))
10+
* Client hangs when implementing `AgentExecutor` and `await`ing twice in execute method ([#379](https://github.com/a2aproject/a2a-python/issues/379)) ([c147a83](https://github.com/a2aproject/a2a-python/commit/c147a83d3098e5ab2cd5b695a3bd71e17bf13b4c))
11+
* **grpc:** Update `CreateTaskPushNotificationConfig` endpoint to `/v1/{parent=tasks/*/pushNotificationConfigs}` ([#415](https://github.com/a2aproject/a2a-python/issues/415)) ([73dddc3](https://github.com/a2aproject/a2a-python/commit/73dddc3a3dc0b073d5559b3d0ec18ff4d20b6f7d))
12+
* make `event_consumer` tolerant to closed queues on py3.13 ([#407](https://github.com/a2aproject/a2a-python/issues/407)) ([a371461](https://github.com/a2aproject/a2a-python/commit/a371461c3b77aa9643c3a3378bb4405356863bff))
13+
* non-blocking `send_message` server handler not invoke push notification ([#394](https://github.com/a2aproject/a2a-python/issues/394)) ([db82a65](https://github.com/a2aproject/a2a-python/commit/db82a6582821a37aa8033d7db426557909ab10c6))
14+
* **proto:** Add `icon_url` to `a2a.proto` ([#416](https://github.com/a2aproject/a2a-python/issues/416)) ([00703e3](https://github.com/a2aproject/a2a-python/commit/00703e3df45ea7708613791ec35e843591333eca))
15+
* **spec:** Suggest Unique Identifier fields to be UUID ([#405](https://github.com/a2aproject/a2a-python/issues/405)) ([da14cea](https://github.com/a2aproject/a2a-python/commit/da14cea950f1af486e7891fa49199249d29b6f37))
16+
317
## [0.3.1](https://github.com/a2aproject/a2a-python/compare/v0.3.0...v0.3.1) (2025-08-13)
418

519

src/a2a/client/auth/interceptor.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ async def intercept(
6262
):
6363
headers['Authorization'] = f'Bearer {credential}'
6464
logger.debug(
65-
f"Added Bearer token for scheme '{scheme_name}' (type: {scheme_def.type})."
65+
"Added Bearer token for scheme '%s' (type: %s).",
66+
scheme_name,
67+
scheme_def.type,
6668
)
6769
http_kwargs['headers'] = headers
6870
return request_payload, http_kwargs
@@ -74,7 +76,9 @@ async def intercept(
7476
):
7577
headers['Authorization'] = f'Bearer {credential}'
7678
logger.debug(
77-
f"Added Bearer token for scheme '{scheme_name}' (type: {scheme_def.type})."
79+
"Added Bearer token for scheme '%s' (type: %s).",
80+
scheme_name,
81+
scheme_def.type,
7882
)
7983
http_kwargs['headers'] = headers
8084
return request_payload, http_kwargs
@@ -83,7 +87,8 @@ async def intercept(
8387
case APIKeySecurityScheme(in_=In.header):
8488
headers[scheme_def.name] = credential
8589
logger.debug(
86-
f"Added API Key Header for scheme '{scheme_name}'."
90+
"Added API Key Header for scheme '%s'.",
91+
scheme_name,
8792
)
8893
http_kwargs['headers'] = headers
8994
return request_payload, http_kwargs

src/a2a/grpc/a2a_pb2.py

Lines changed: 83 additions & 83 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: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class SendMessageConfiguration(_message.Message):
5555
push_notification: PushNotificationConfig
5656
history_length: int
5757
blocking: bool
58-
def __init__(self, accepted_output_modes: _Optional[_Iterable[str]] = ..., push_notification: _Optional[_Union[PushNotificationConfig, _Mapping]] = ..., history_length: _Optional[int] = ..., blocking: bool = ...) -> None: ...
58+
def __init__(self, accepted_output_modes: _Optional[_Iterable[str]] = ..., push_notification: _Optional[_Union[PushNotificationConfig, _Mapping]] = ..., history_length: _Optional[int] = ..., blocking: _Optional[bool] = ...) -> None: ...
5959

6060
class Task(_message.Message):
6161
__slots__ = ("id", "context_id", "status", "artifacts", "history", "metadata")
@@ -157,7 +157,7 @@ class TaskStatusUpdateEvent(_message.Message):
157157
status: TaskStatus
158158
final: bool
159159
metadata: _struct_pb2.Struct
160-
def __init__(self, task_id: _Optional[str] = ..., context_id: _Optional[str] = ..., status: _Optional[_Union[TaskStatus, _Mapping]] = ..., final: bool = ..., metadata: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
160+
def __init__(self, task_id: _Optional[str] = ..., context_id: _Optional[str] = ..., status: _Optional[_Union[TaskStatus, _Mapping]] = ..., final: _Optional[bool] = ..., metadata: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
161161

162162
class TaskArtifactUpdateEvent(_message.Message):
163163
__slots__ = ("task_id", "context_id", "artifact", "append", "last_chunk", "metadata")
@@ -173,7 +173,7 @@ class TaskArtifactUpdateEvent(_message.Message):
173173
append: bool
174174
last_chunk: bool
175175
metadata: _struct_pb2.Struct
176-
def __init__(self, task_id: _Optional[str] = ..., context_id: _Optional[str] = ..., artifact: _Optional[_Union[Artifact, _Mapping]] = ..., append: bool = ..., last_chunk: bool = ..., metadata: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
176+
def __init__(self, task_id: _Optional[str] = ..., context_id: _Optional[str] = ..., artifact: _Optional[_Union[Artifact, _Mapping]] = ..., append: _Optional[bool] = ..., last_chunk: _Optional[bool] = ..., metadata: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
177177

178178
class PushNotificationConfig(_message.Message):
179179
__slots__ = ("id", "url", "token", "authentication")
@@ -204,7 +204,7 @@ class AgentInterface(_message.Message):
204204
def __init__(self, url: _Optional[str] = ..., transport: _Optional[str] = ...) -> None: ...
205205

206206
class AgentCard(_message.Message):
207-
__slots__ = ("protocol_version", "name", "description", "url", "preferred_transport", "additional_interfaces", "provider", "version", "documentation_url", "capabilities", "security_schemes", "security", "default_input_modes", "default_output_modes", "skills", "supports_authenticated_extended_card", "signatures")
207+
__slots__ = ("protocol_version", "name", "description", "url", "preferred_transport", "additional_interfaces", "provider", "version", "documentation_url", "capabilities", "security_schemes", "security", "default_input_modes", "default_output_modes", "skills", "supports_authenticated_extended_card", "signatures", "icon_url")
208208
class SecuritySchemesEntry(_message.Message):
209209
__slots__ = ("key", "value")
210210
KEY_FIELD_NUMBER: _ClassVar[int]
@@ -229,6 +229,7 @@ class AgentCard(_message.Message):
229229
SKILLS_FIELD_NUMBER: _ClassVar[int]
230230
SUPPORTS_AUTHENTICATED_EXTENDED_CARD_FIELD_NUMBER: _ClassVar[int]
231231
SIGNATURES_FIELD_NUMBER: _ClassVar[int]
232+
ICON_URL_FIELD_NUMBER: _ClassVar[int]
232233
protocol_version: str
233234
name: str
234235
description: str
@@ -246,7 +247,8 @@ class AgentCard(_message.Message):
246247
skills: _containers.RepeatedCompositeFieldContainer[AgentSkill]
247248
supports_authenticated_extended_card: bool
248249
signatures: _containers.RepeatedCompositeFieldContainer[AgentCardSignature]
249-
def __init__(self, protocol_version: _Optional[str] = ..., name: _Optional[str] = ..., description: _Optional[str] = ..., url: _Optional[str] = ..., preferred_transport: _Optional[str] = ..., additional_interfaces: _Optional[_Iterable[_Union[AgentInterface, _Mapping]]] = ..., provider: _Optional[_Union[AgentProvider, _Mapping]] = ..., version: _Optional[str] = ..., documentation_url: _Optional[str] = ..., capabilities: _Optional[_Union[AgentCapabilities, _Mapping]] = ..., security_schemes: _Optional[_Mapping[str, SecurityScheme]] = ..., security: _Optional[_Iterable[_Union[Security, _Mapping]]] = ..., default_input_modes: _Optional[_Iterable[str]] = ..., default_output_modes: _Optional[_Iterable[str]] = ..., skills: _Optional[_Iterable[_Union[AgentSkill, _Mapping]]] = ..., supports_authenticated_extended_card: bool = ..., signatures: _Optional[_Iterable[_Union[AgentCardSignature, _Mapping]]] = ...) -> None: ...
250+
icon_url: str
251+
def __init__(self, protocol_version: _Optional[str] = ..., name: _Optional[str] = ..., description: _Optional[str] = ..., url: _Optional[str] = ..., preferred_transport: _Optional[str] = ..., additional_interfaces: _Optional[_Iterable[_Union[AgentInterface, _Mapping]]] = ..., provider: _Optional[_Union[AgentProvider, _Mapping]] = ..., version: _Optional[str] = ..., documentation_url: _Optional[str] = ..., capabilities: _Optional[_Union[AgentCapabilities, _Mapping]] = ..., security_schemes: _Optional[_Mapping[str, SecurityScheme]] = ..., security: _Optional[_Iterable[_Union[Security, _Mapping]]] = ..., default_input_modes: _Optional[_Iterable[str]] = ..., default_output_modes: _Optional[_Iterable[str]] = ..., skills: _Optional[_Iterable[_Union[AgentSkill, _Mapping]]] = ..., supports_authenticated_extended_card: _Optional[bool] = ..., signatures: _Optional[_Iterable[_Union[AgentCardSignature, _Mapping]]] = ..., icon_url: _Optional[str] = ...) -> None: ...
250252

251253
class AgentProvider(_message.Message):
252254
__slots__ = ("url", "organization")
@@ -264,7 +266,7 @@ class AgentCapabilities(_message.Message):
264266
streaming: bool
265267
push_notifications: bool
266268
extensions: _containers.RepeatedCompositeFieldContainer[AgentExtension]
267-
def __init__(self, streaming: bool = ..., push_notifications: bool = ..., extensions: _Optional[_Iterable[_Union[AgentExtension, _Mapping]]] = ...) -> None: ...
269+
def __init__(self, streaming: _Optional[bool] = ..., push_notifications: _Optional[bool] = ..., extensions: _Optional[_Iterable[_Union[AgentExtension, _Mapping]]] = ...) -> None: ...
268270

269271
class AgentExtension(_message.Message):
270272
__slots__ = ("uri", "description", "required", "params")
@@ -276,7 +278,7 @@ class AgentExtension(_message.Message):
276278
description: str
277279
required: bool
278280
params: _struct_pb2.Struct
279-
def __init__(self, uri: _Optional[str] = ..., description: _Optional[str] = ..., required: bool = ..., params: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
281+
def __init__(self, uri: _Optional[str] = ..., description: _Optional[str] = ..., required: _Optional[bool] = ..., params: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
280282

281283
class AgentSkill(_message.Message):
282284
__slots__ = ("id", "name", "description", "tags", "examples", "input_modes", "output_modes", "security")

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

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,15 @@
2828
GetTaskPushNotificationConfigRequest,
2929
GetTaskRequest,
3030
InternalError,
31+
InvalidParamsError,
3132
InvalidRequestError,
3233
JSONParseError,
3334
JSONRPCError,
3435
JSONRPCErrorResponse,
3536
JSONRPCRequest,
3637
JSONRPCResponse,
3738
ListTaskPushNotificationConfigRequest,
39+
MethodNotFoundError,
3840
SendMessageRequest,
3941
SendStreamingMessageRequest,
4042
SendStreamingMessageResponse,
@@ -89,6 +91,8 @@
8991
Response = Any
9092
HTTP_413_REQUEST_ENTITY_TOO_LARGE = Any
9193

94+
MAX_CONTENT_LENGTH = 1_000_000
95+
9296

9397
class StarletteUserProxy(A2AUser):
9498
"""Adapts the Starlette User class to the A2A user representation."""
@@ -151,6 +155,25 @@ class JSONRPCApplication(ABC):
151155
(SSE).
152156
"""
153157

158+
# Method-to-model mapping for centralized routing
159+
A2ARequestModel = (
160+
SendMessageRequest
161+
| SendStreamingMessageRequest
162+
| GetTaskRequest
163+
| CancelTaskRequest
164+
| SetTaskPushNotificationConfigRequest
165+
| GetTaskPushNotificationConfigRequest
166+
| ListTaskPushNotificationConfigRequest
167+
| DeleteTaskPushNotificationConfigRequest
168+
| TaskResubscriptionRequest
169+
| GetAuthenticatedExtendedCardRequest
170+
)
171+
172+
METHOD_TO_MODEL: dict[str, type[A2ARequestModel]] = {
173+
model.model_fields['method'].default: model
174+
for model in A2ARequestModel.__args__
175+
}
176+
154177
def __init__( # noqa: PLR0913
155178
self,
156179
agent_card: AgentCard,
@@ -233,9 +256,13 @@ def _generate_error_response(
233256
)
234257
logger.log(
235258
log_level,
236-
f'Request Error (ID: {request_id}): '
237-
f"Code={error_resp.error.code}, Message='{error_resp.error.message}'"
238-
f'{", Data=" + str(error_resp.error.data) if error_resp.error.data else ""}',
259+
"Request Error (ID: %s): Code=%s, Message='%s'%s",
260+
request_id,
261+
error_resp.error.code,
262+
error_resp.error.message,
263+
', Data=' + str(error_resp.error.data)
264+
if error_resp.error.data
265+
else '',
239266
)
240267
return JSONResponse(
241268
error_resp.model_dump(mode='json', exclude_none=True),
@@ -267,17 +294,60 @@ async def _handle_requests(self, request: Request) -> Response: # noqa: PLR0911
267294
body = await request.json()
268295
if isinstance(body, dict):
269296
request_id = body.get('id')
297+
# Ensure request_id is valid for JSON-RPC response (str/int/None only)
298+
if request_id is not None and not isinstance(
299+
request_id, str | int
300+
):
301+
request_id = None
302+
# Treat very large payloads as invalid request (-32600) before routing
303+
with contextlib.suppress(Exception):
304+
content_length = int(request.headers.get('content-length', '0'))
305+
if content_length and content_length > MAX_CONTENT_LENGTH:
306+
return self._generate_error_response(
307+
request_id,
308+
A2AError(
309+
root=InvalidRequestError(
310+
message='Payload too large'
311+
)
312+
),
313+
)
314+
logger.debug('Request body: %s', body)
315+
# 1) Validate base JSON-RPC structure only (-32600 on failure)
316+
try:
317+
base_request = JSONRPCRequest.model_validate(body)
318+
except ValidationError as e:
319+
logger.exception('Failed to validate base JSON-RPC request')
320+
return self._generate_error_response(
321+
request_id,
322+
A2AError(
323+
root=InvalidRequestError(data=json.loads(e.json()))
324+
),
325+
)
270326

271-
# First, validate the basic JSON-RPC structure. This is crucial
272-
# because the A2ARequest model is a discriminated union where some
273-
# request types have default values for the 'method' field
274-
JSONRPCRequest.model_validate(body)
327+
# 2) Route by method name; unknown -> -32601, known -> validate params (-32602 on failure)
328+
method = base_request.method
275329

276-
a2a_request = A2ARequest.model_validate(body)
330+
model_class = self.METHOD_TO_MODEL.get(method)
331+
if not model_class:
332+
return self._generate_error_response(
333+
request_id, A2AError(root=MethodNotFoundError())
334+
)
335+
try:
336+
specific_request = model_class.model_validate(body)
337+
except ValidationError as e:
338+
logger.exception('Failed to validate base JSON-RPC request')
339+
return self._generate_error_response(
340+
request_id,
341+
A2AError(
342+
root=InvalidParamsError(data=json.loads(e.json()))
343+
),
344+
)
277345

346+
# 3) Build call context and wrap the request for downstream handling
278347
call_context = self._context_builder.build(request)
279348

280-
request_id = a2a_request.root.id
349+
request_id = specific_request.id
350+
a2a_request = A2ARequest(root=specific_request)
281351
request_obj = a2a_request.root
282352

283353
if isinstance(
@@ -301,12 +371,6 @@ async def _handle_requests(self, request: Request) -> Response: # noqa: PLR0911
301371
return self._generate_error_response(
302372
None, A2AError(root=JSONParseError(message=str(e)))
303373
)
304-
except ValidationError as e:
305-
traceback.print_exc()
306-
return self._generate_error_response(
307-
request_id,
308-
A2AError(root=InvalidRequestError(data=json.loads(e.json()))),
309-
)
310374
except HTTPException as e:
311375
if e.status_code == HTTP_413_REQUEST_ENTITY_TOO_LARGE:
312376
return self._generate_error_response(
@@ -422,7 +486,7 @@ async def _process_non_streaming_request(
422486
)
423487
case _:
424488
logger.error(
425-
f'Unhandled validated request type: {type(request_obj)}'
489+
'Unhandled validated request type: %s', type(request_obj)
426490
)
427491
error = UnsupportedOperationError(
428492
message=f'Request type {type(request_obj).__name__} is unknown.'
@@ -497,8 +561,10 @@ async def _handle_get_agent_card(self, request: Request) -> JSONResponse:
497561
"""
498562
if request.url.path == PREV_AGENT_CARD_WELL_KNOWN_PATH:
499563
logger.warning(
500-
f"Deprecated agent card endpoint '{PREV_AGENT_CARD_WELL_KNOWN_PATH}' accessed. "
501-
f"Please use '{AGENT_CARD_WELL_KNOWN_PATH}' instead. This endpoint will be removed in a future version."
564+
"Deprecated agent card endpoint '%s' accessed. "
565+
"Please use '%s' instead. This endpoint will be removed in a future version.",
566+
PREV_AGENT_CARD_WELL_KNOWN_PATH,
567+
AGENT_CARD_WELL_KNOWN_PATH,
502568
)
503569

504570
card_to_serve = self.agent_card

src/a2a/server/events/event_consumer.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async def consume_one(self) -> Event:
6262
InternalError(message='Agent did not return any response')
6363
) from e
6464

65-
logger.debug(f'Dequeued event of type: {type(event)} in consume_one.')
65+
logger.debug('Dequeued event of type: %s in consume_one.', type(event))
6666

6767
self.queue.task_done()
6868

@@ -95,7 +95,7 @@ async def consume_all(self) -> AsyncGenerator[Event]:
9595
self.queue.dequeue_event(), timeout=self._timeout
9696
)
9797
logger.debug(
98-
f'Dequeued event of type: {type(event)} in consume_all.'
98+
'Dequeued event of type: %s in consume_all.', type(event)
9999
)
100100
self.queue.task_done()
101101
logger.debug(
@@ -125,7 +125,7 @@ async def consume_all(self) -> AsyncGenerator[Event]:
125125
# other part is waiting for an event or a closed queue.
126126
if is_final_event:
127127
logger.debug('Stopping event consumption in consume_all.')
128-
await self.queue.close()
128+
await self.queue.close(True)
129129
yield event
130130
break
131131
yield event

0 commit comments

Comments
 (0)