Skip to content

Commit 068b73f

Browse files
Merge remote-tracking branch 'upstream/main' into fix/keep-follow-up-message-on-current-task
2 parents 99f58a8 + aa8e966 commit 068b73f

50 files changed

Lines changed: 4614 additions & 282 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/actions/spelling/allow.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ opensource
115115
otherurl
116116
outerjoin
117117
pb2
118+
PLW
118119
podman
119120
Podman
120121
poolclass

‎.release-please-manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "1.2.3"
2+
".": "1.3.0"
33
}

‎CHANGELOG.md‎

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

3+
## [1.3.0](https://github.com/a2aproject/a2a-python/compare/v1.2.3...v1.3.0) (2026-10-27)
4+
5+
6+
### Features
7+
8+
* **server:** release parked interrupted tasks after a configurable timeout ([#1338](https://github.com/a2aproject/a2a-python/issues/1338)) ([b22a68d](https://github.com/a2aproject/a2a-python/commit/b22a68d242c32edddaa8953d0040ca82897e0b9b))
9+
10+
11+
### Bug Fixes
12+
13+
* **client:** Create separate httpx per client when none configured ([#1358](https://github.com/a2aproject/a2a-python/issues/1358)) ([d316d0e](https://github.com/a2aproject/a2a-python/commit/d316d0e85e4d229df261d0cee0e9d9c0e5263207))
14+
* **client:** raise A2A errors for malformed JSON-RPC responses ([#1375](https://github.com/a2aproject/a2a-python/issues/1375)) ([7e55413](https://github.com/a2aproject/a2a-python/commit/7e554137514b693cc952e6544a9eaf36c7d56643))
15+
* **client:** rejections from unrecognized fields ([#1354](https://github.com/a2aproject/a2a-python/issues/1354)) ([91929cd](https://github.com/a2aproject/a2a-python/commit/91929cda989caba089584ceafa7796583f25bec4))
16+
* **compat:** keep an unset v0.3 status message and provider as None ([#1362](https://github.com/a2aproject/a2a-python/issues/1362)) ([cdb4799](https://github.com/a2aproject/a2a-python/commit/cdb479957e81600f68f946f8ff0122667673cb35)), closes [#1361](https://github.com/a2aproject/a2a-python/issues/1361)
17+
* **compat:** return A2A error codes from the v0.3 JSON-RPC adapter ([#1369](https://github.com/a2aproject/a2a-python/issues/1369)) ([8e32d53](https://github.com/a2aproject/a2a-python/commit/8e32d53173f8924beb7df0d3210e28a9fe9020f9))
18+
* **compat:** set last_updated when storing a task in v0.3 format ([#1372](https://github.com/a2aproject/a2a-python/issues/1372)) ([30b6d50](https://github.com/a2aproject/a2a-python/commit/30b6d5067a9256447e3d5324163b091fc4bbad74)), closes [#1371](https://github.com/a2aproject/a2a-python/issues/1371)
19+
* **core:** restore structured __repr__ on A2A exceptions ([#1348](https://github.com/a2aproject/a2a-python/issues/1348)) ([79a6f45](https://github.com/a2aproject/a2a-python/commit/79a6f451f49fdeaec099d723542363b5bc6dead7))
20+
* **server:** allow recovery after status event validation fails ([#1365](https://github.com/a2aproject/a2a-python/issues/1365)) ([ca3ef0d](https://github.com/a2aproject/a2a-python/commit/ca3ef0d6c63384228db88d4984536a8189831d08))
21+
* **server:** apply history_length to legacy streaming sends ([#1364](https://github.com/a2aproject/a2a-python/issues/1364)) ([261c074](https://github.com/a2aproject/a2a-python/commit/261c074aa4de522dfa609882666c98c5bd1a52d0))
22+
* **server:** include legacy task states in database list filters ([#1337](https://github.com/a2aproject/a2a-python/issues/1337)) ([e12d494](https://github.com/a2aproject/a2a-python/commit/e12d494b4136351d5b5d07b5c6beae95a500fc4e))
23+
* **server:** record executor failures through the event consumer ([#1366](https://github.com/a2aproject/a2a-python/issues/1366)) ([ad85806](https://github.com/a2aproject/a2a-python/commit/ad85806d9e43249eef5e3ad9724b0a5540cf99fb))
24+
* **server:** reject boolean JSON-RPC request IDs ([#1350](https://github.com/a2aproject/a2a-python/issues/1350)) ([05392b3](https://github.com/a2aproject/a2a-python/commit/05392b373f70fea18349b8c66bfffb669b3b1137))
25+
* skip malformed agent card signature headers ([#1335](https://github.com/a2aproject/a2a-python/issues/1335)) ([b21a144](https://github.com/a2aproject/a2a-python/commit/b21a1445c8e3a4dcd9edfa1d23e7e6af8382d8b7))
26+
27+
28+
### Documentation
29+
30+
* **helpers:** note that whole numbers in a data Part come back as floats ([#1373](https://github.com/a2aproject/a2a-python/issues/1373)) ([654774f](https://github.com/a2aproject/a2a-python/commit/654774f3d43b471e17fc2c6901998c71015ec450))
31+
32+
33+
### Miscellaneous Chores
34+
35+
* release 1.3.0 ([#1389](https://github.com/a2aproject/a2a-python/issues/1389)) ([fb6373f](https://github.com/a2aproject/a2a-python/commit/fb6373f2e4bad3c9e6130325dd2ba092749f8998))
36+
337
## [1.2.3](https://github.com/a2aproject/a2a-python/compare/v1.2.2...v1.2.3) (2026-10-17)
438

539

‎src/a2a/client/base_client.py‎

Lines changed: 150 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
2+
from contextlib import aclosing
23
from typing import Any
34

45
from a2a.client.client import (
@@ -12,6 +13,8 @@
1213
ClientCallInterceptor,
1314
)
1415
from a2a.client.transports.base import ClientTransport
16+
from a2a.plugins.manager import PluginManager, get_global
17+
from a2a.plugins.types import ClientRequest, ClientResponse
1518
from a2a.types.a2a_pb2 import (
1619
AgentCard,
1720
CancelTaskRequest,
@@ -32,20 +35,37 @@
3235

3336

3437
class BaseClient(Client):
35-
"""Base implementation of the A2A client, containing transport-independent logic."""
38+
"""Base implementation of the A2A client, containing transport-independent logic.
39+
40+
Client plugin hooks run around every call: ``before_client_request`` before
41+
the interceptors, ``after_client_request`` after them.
42+
"""
3643

3744
def __init__(
3845
self,
3946
card: AgentCard,
4047
config: ClientConfig,
4148
transport: ClientTransport,
4249
interceptors: list[ClientCallInterceptor],
50+
*,
51+
plugins: PluginManager | None = None,
4352
):
53+
"""Initializes the client.
54+
55+
Args:
56+
card: The card of the agent to call.
57+
config: The client configuration.
58+
transport: The transport used for calls.
59+
interceptors: Interceptors applied to every call.
60+
plugins: Plugin manager whose client hooks run for every call.
61+
Defaults to the global manager at construction time.
62+
"""
4463
super().__init__(interceptors)
4564
self._card = card
4665
self._config = config
4766
self._transport = transport
4867
self._interceptors = interceptors
68+
self._plugins = plugins if plugins is not None else get_global()
4969

5070
async def send_message(
5171
self,
@@ -90,15 +110,20 @@ async def send_message(
90110
yield stream_response
91111
return
92112

93-
async for event in self._execute_stream_with_interceptors(
94-
input_data=request,
95-
method='send_message_streaming',
96-
context=context,
97-
transport_call=lambda req, ctx: (
98-
self._transport.send_message_streaming(req, context=ctx)
99-
),
100-
):
101-
yield event
113+
# aclosing() closes the inner stream (and the transport stream) as
114+
# soon as the caller stops iterating.
115+
async with aclosing(
116+
self._execute_stream_with_interceptors(
117+
input_data=request,
118+
method='send_message_streaming',
119+
context=context,
120+
transport_call=lambda req, ctx: (
121+
self._transport.send_message_streaming(req, context=ctx)
122+
),
123+
)
124+
) as events:
125+
async for event in events:
126+
yield event
102127

103128
def _apply_client_config(self, request: SendMessageRequest) -> None:
104129
request.configuration.return_immediately |= self._config.polling
@@ -324,15 +349,18 @@ async def subscribe(
324349
'client and/or server do not support resubscription.'
325350
)
326351

327-
async for event in self._execute_stream_with_interceptors(
328-
input_data=request,
329-
method='subscribe',
330-
context=context,
331-
transport_call=lambda req, ctx: self._transport.subscribe(
332-
req, context=ctx
333-
),
334-
):
335-
yield event
352+
async with aclosing(
353+
self._execute_stream_with_interceptors(
354+
input_data=request,
355+
method='subscribe',
356+
context=context,
357+
transport_call=lambda req, ctx: self._transport.subscribe(
358+
req, context=ctx
359+
),
360+
)
361+
) as events:
362+
async for event in events:
363+
yield event
336364

337365
async def get_extended_agent_card(
338366
self,
@@ -372,6 +400,19 @@ async def close(self) -> None:
372400
"""Closes the underlying transport."""
373401
await self._transport.close()
374402

403+
def _client_request(
404+
self,
405+
input_data: Any,
406+
method: str,
407+
context: ClientCallContext | None,
408+
) -> ClientRequest:
409+
return ClientRequest(
410+
method=method,
411+
payload=input_data,
412+
agent_card=self._card,
413+
context=context if context is not None else ClientCallContext(),
414+
)
415+
375416
async def _execute_with_interceptors(
376417
self,
377418
input_data: Any,
@@ -380,6 +421,41 @@ async def _execute_with_interceptors(
380421
transport_call: Callable[
381422
[Any, ClientCallContext | None], Awaitable[Any]
382423
],
424+
) -> Any:
425+
"""Runs a unary call through plugins, interceptors and the transport.
426+
427+
``method`` is reported to both plugins and interceptors.
428+
"""
429+
plugins = self._plugins
430+
if not plugins.has_client_hooks:
431+
return await self._call_through_interceptors(
432+
input_data, method, context, transport_call
433+
)
434+
435+
request = self._client_request(input_data, method, context)
436+
await plugins.before_client_request(request)
437+
try:
438+
result = await self._call_through_interceptors(
439+
request.payload, method, request.context, transport_call
440+
)
441+
except Exception as e:
442+
await plugins.after_client_request(
443+
ClientResponse(request.method, None, request.context, error=e)
444+
)
445+
raise
446+
await plugins.after_client_request(
447+
ClientResponse(request.method, result, request.context)
448+
)
449+
return result
450+
451+
async def _call_through_interceptors(
452+
self,
453+
input_data: Any,
454+
method: str,
455+
context: ClientCallContext | None,
456+
transport_call: Callable[
457+
[Any, ClientCallContext | None], Awaitable[Any]
458+
],
383459
) -> Any:
384460
before_args = BeforeArgs(
385461
input=input_data,
@@ -422,8 +498,62 @@ async def _execute_stream_with_interceptors(
422498
transport_call: Callable[
423499
[Any, ClientCallContext | None], AsyncIterator[StreamResponse]
424500
],
425-
) -> AsyncIterator[StreamResponse]:
501+
) -> AsyncGenerator[StreamResponse, None]:
502+
"""Runs a streaming call through plugins, interceptors and the transport.
426503
504+
``method`` is reported to both plugins and interceptors.
505+
"""
506+
plugins = self._plugins
507+
if not plugins.has_client_hooks:
508+
async with aclosing(
509+
self._stream_through_interceptors(
510+
input_data, method, context, transport_call
511+
)
512+
) as events:
513+
async for event in events:
514+
yield event
515+
return
516+
517+
request = self._client_request(input_data, method, context)
518+
await plugins.before_client_request(request)
519+
error: BaseException | None = None
520+
try:
521+
async with aclosing(
522+
self._stream_through_interceptors(
523+
request.payload, method, request.context, transport_call
524+
)
525+
) as events:
526+
async for event in events:
527+
await plugins.after_client_request(
528+
ClientResponse(
529+
request.method,
530+
event,
531+
request.context,
532+
is_stream_event=True,
533+
is_final=False,
534+
)
535+
)
536+
yield event
537+
except Exception as e:
538+
error = e
539+
raise
540+
finally:
541+
# Also runs when the caller stops iterating early.
542+
await plugins.after_client_request(
543+
ClientResponse(
544+
request.method, None, request.context, error=error
545+
)
546+
)
547+
548+
async def _stream_through_interceptors(
549+
self,
550+
input_data: Any,
551+
method: str,
552+
context: ClientCallContext | None,
553+
transport_call: Callable[
554+
[Any, ClientCallContext | None], AsyncIterator[StreamResponse]
555+
],
556+
) -> AsyncGenerator[StreamResponse, None]:
427557
before_args = BeforeArgs(
428558
input=input_data,
429559
method=method,

0 commit comments

Comments
 (0)