Skip to content

Commit e868958

Browse files
authored
Merge branch 'main' into kthota/deprecated-agent-card-path
2 parents 36b9f1a + 4edc67d commit e868958

File tree

10 files changed

+243
-104
lines changed

10 files changed

+243
-104
lines changed

.github/actions/spelling/allow.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ INR
4545
isready
4646
JPY
4747
JSONRPCt
48+
JWS
4849
kwarg
4950
langgraph
5051
lifecycles

.github/workflows/update-a2a-types.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ jobs:
4747
token: ${{ secrets.A2A_BOT_PAT }}
4848
committer: a2a-bot <[email protected]>
4949
author: a2a-bot <[email protected]>
50-
commit-message: 'feat(spec): Update A2A types from specification 🤖'
51-
title: 'feat(spec): Update A2A types from specification 🤖'
50+
commit-message: '${{ github.event.client_payload.message }}'
51+
title: '${{ github.event.client_payload.message }}'
5252
body: |
53-
This PR updates `src/a2a/types.py` based on the latest `specification/json/a2a.json` from [a2aproject/A2A](https://github.com/a2aproject/A2A/commit/${{ github.event.client_payload.sha }}).
53+
Commit: https://github.com/a2aproject/A2A/commit/${{ github.event.client_payload.sha }}
5454
branch: auto-update-a2a-types-${{ github.event.client_payload.sha }}
5555
base: main
5656
labels: |

src/a2a/_base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def __setattr__(self, name: str, value: Any) -> None:
6464
# Get the map and find the corresponding snake_case field name.
6565
field_name = type(self)._get_alias_map().get(name) # noqa: SLF001
6666

67-
if field_name:
67+
if field_name and field_name != name:
6868
# An alias was used, issue a warning.
6969
warnings.warn(
7070
(
@@ -83,7 +83,7 @@ def __getattr__(self, name: str) -> Any:
8383
# Get the map and find the corresponding snake_case field name.
8484
field_name = type(self)._get_alias_map().get(name) # noqa: SLF001
8585

86-
if field_name:
86+
if field_name and field_name != name:
8787
# An alias was used, issue a warning.
8888
warnings.warn(
8989
(

src/a2a/grpc/a2a_pb2.py

Lines changed: 84 additions & 78 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: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ class AgentInterface(_message.Message):
202202
def __init__(self, url: _Optional[str] = ..., transport: _Optional[str] = ...) -> None: ...
203203

204204
class AgentCard(_message.Message):
205-
__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")
205+
__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")
206206
class SecuritySchemesEntry(_message.Message):
207207
__slots__ = ("key", "value")
208208
KEY_FIELD_NUMBER: _ClassVar[int]
@@ -226,6 +226,7 @@ class AgentCard(_message.Message):
226226
DEFAULT_OUTPUT_MODES_FIELD_NUMBER: _ClassVar[int]
227227
SKILLS_FIELD_NUMBER: _ClassVar[int]
228228
SUPPORTS_AUTHENTICATED_EXTENDED_CARD_FIELD_NUMBER: _ClassVar[int]
229+
SIGNATURES_FIELD_NUMBER: _ClassVar[int]
229230
protocol_version: str
230231
name: str
231232
description: str
@@ -242,7 +243,8 @@ class AgentCard(_message.Message):
242243
default_output_modes: _containers.RepeatedScalarFieldContainer[str]
243244
skills: _containers.RepeatedCompositeFieldContainer[AgentSkill]
244245
supports_authenticated_extended_card: bool
245-
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 = ...) -> None: ...
246+
signatures: _containers.RepeatedCompositeFieldContainer[AgentCardSignature]
247+
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: ...
246248

247249
class AgentProvider(_message.Message):
248250
__slots__ = ("url", "organization")
@@ -292,6 +294,16 @@ class AgentSkill(_message.Message):
292294
output_modes: _containers.RepeatedScalarFieldContainer[str]
293295
def __init__(self, id: _Optional[str] = ..., name: _Optional[str] = ..., description: _Optional[str] = ..., tags: _Optional[_Iterable[str]] = ..., examples: _Optional[_Iterable[str]] = ..., input_modes: _Optional[_Iterable[str]] = ..., output_modes: _Optional[_Iterable[str]] = ...) -> None: ...
294296

297+
class AgentCardSignature(_message.Message):
298+
__slots__ = ("protected", "signature", "header")
299+
PROTECTED_FIELD_NUMBER: _ClassVar[int]
300+
SIGNATURE_FIELD_NUMBER: _ClassVar[int]
301+
HEADER_FIELD_NUMBER: _ClassVar[int]
302+
protected: str
303+
signature: str
304+
header: _struct_pb2.Struct
305+
def __init__(self, protected: _Optional[str] = ..., signature: _Optional[str] = ..., header: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
306+
295307
class TaskPushNotificationConfig(_message.Message):
296308
__slots__ = ("name", "push_notification_config")
297309
NAME_FIELD_NUMBER: _ClassVar[int]

src/a2a/server/request_handlers/default_request_handler.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,12 +280,18 @@ async def on_message_send(
280280
consumer = EventConsumer(queue)
281281
producer_task.add_done_callback(consumer.agent_task_callback)
282282

283-
interrupted = False
283+
blocking = True # Default to blocking behavior
284+
if params.configuration and params.configuration.blocking is False:
285+
blocking = False
286+
287+
interrupted_or_non_blocking = False
284288
try:
285289
(
286290
result,
287-
interrupted,
288-
) = await result_aggregator.consume_and_break_on_interrupt(consumer)
291+
interrupted_or_non_blocking,
292+
) = await result_aggregator.consume_and_break_on_interrupt(
293+
consumer, blocking=blocking
294+
)
289295
if not result:
290296
raise ServerError(error=InternalError())
291297

@@ -300,7 +306,7 @@ async def on_message_send(
300306
logger.error(f'Agent execution failed. Error: {e}')
301307
raise
302308
finally:
303-
if interrupted:
309+
if interrupted_or_non_blocking:
304310
# TODO: Track this disconnected cleanup task.
305311
asyncio.create_task( # noqa: RUF006
306312
self._cleanup_producer(producer_task, task_id)

src/a2a/server/tasks/result_aggregator.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,19 @@ async def consume_all(
9292
return await self.task_manager.get_task()
9393

9494
async def consume_and_break_on_interrupt(
95-
self, consumer: EventConsumer
95+
self, consumer: EventConsumer, blocking: bool = True
9696
) -> tuple[Task | Message | None, bool]:
9797
"""Processes the event stream until completion or an interruptable state is encountered.
9898
99-
Interruptable states currently include `TaskState.auth_required`.
99+
If `blocking` is False, it returns after the first event that creates a Task or Message.
100+
If `blocking` is True, it waits for completion unless an `auth_required`
101+
state is encountered, which is always an interruption.
100102
If interrupted, consumption continues in a background task.
101103
102104
Args:
103105
consumer: The `EventConsumer` to read events from.
106+
blocking: If `False`, the method returns as soon as a task/message
107+
is available. If `True`, it waits for a terminal state.
104108
105109
Returns:
106110
A tuple containing:
@@ -117,10 +121,15 @@ async def consume_and_break_on_interrupt(
117121
self._message = event
118122
return event, False
119123
await self.task_manager.process(event)
120-
if (
124+
125+
should_interrupt = False
126+
is_auth_required = (
121127
isinstance(event, Task | TaskStatusUpdateEvent)
122128
and event.status.state == TaskState.auth_required
123-
):
129+
)
130+
131+
# Always interrupt on auth_required, as it needs external action.
132+
if is_auth_required:
124133
# auth-required is a special state: the message should be
125134
# escalated back to the caller, but the agent is expected to
126135
# continue producing events once the authorization is received
@@ -130,6 +139,16 @@ async def consume_and_break_on_interrupt(
130139
logger.debug(
131140
'Encountered an auth-required task: breaking synchronous message/send flow.'
132141
)
142+
should_interrupt = True
143+
# For non-blocking calls, interrupt as soon as a task is available.
144+
elif not blocking:
145+
logger.debug(
146+
'Non-blocking call: returning task after first event.'
147+
)
148+
should_interrupt = True
149+
150+
if should_interrupt:
151+
# Continue consuming the rest of the events in the background.
133152
# TODO: We should track all outstanding tasks to ensure they eventually complete.
134153
asyncio.create_task(self._continue_consuming(event_stream)) # noqa: RUF006
135154
interrupted = True

src/a2a/types.py

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,27 @@ class APIKeySecurityScheme(A2ABaseModel):
4848
"""
4949

5050

51+
class AgentCardSignature(A2ABaseModel):
52+
"""
53+
AgentCardSignature represents a JWS signature of an AgentCard.
54+
This follows the JSON format of an RFC 7515 JSON Web Signature (JWS).
55+
"""
56+
57+
header: dict[str, Any] | None = None
58+
"""
59+
The unprotected JWS header values.
60+
"""
61+
protected: str
62+
"""
63+
The protected JWS header for the signature. This is a Base64url-encoded
64+
JSON object, as per RFC 7515.
65+
"""
66+
signature: str
67+
"""
68+
The computed signature, Base64url-encoded.
69+
"""
70+
71+
5172
class AgentExtension(A2ABaseModel):
5273
"""
5374
A declaration of a protocol extension supported by an Agent.
@@ -75,16 +96,23 @@ class AgentExtension(A2ABaseModel):
7596
class AgentInterface(A2ABaseModel):
7697
"""
7798
Declares a combination of a target URL and a transport protocol for interacting with the agent.
99+
This allows agents to expose the same functionality over multiple transport mechanisms.
78100
"""
79101

80-
transport: str
102+
transport: str = Field(..., examples=['JSONRPC', 'GRPC', 'HTTP+JSON'])
81103
"""
82-
The transport protocol supported at this URL. This is a string to allow for future
83-
extension. Core supported transports include 'JSONRPC', 'GRPC', and 'HTTP+JSON'.
104+
The transport protocol supported at this URL.
84105
"""
85-
url: str
106+
url: str = Field(
107+
...,
108+
examples=[
109+
'https://api.example.com/a2a/v1',
110+
'https://grpc.example.com/a2a',
111+
'https://rest.example.com/v1',
112+
],
113+
)
86114
"""
87-
The URL where this interface is available.
115+
The URL where this interface is available. Must be a valid absolute HTTPS URL in production.
88116
"""
89117

90118

@@ -928,6 +956,16 @@ class TextPart(A2ABaseModel):
928956
"""
929957

930958

959+
class TransportProtocol(str, Enum):
960+
"""
961+
Supported A2A transport protocols.
962+
"""
963+
964+
jsonrpc = 'JSONRPC'
965+
grpc = 'GRPC'
966+
http_json = 'HTTP+JSON'
967+
968+
931969
class UnsupportedOperationError(A2ABaseModel):
932970
"""
933971
An A2A-specific error indicating that the requested operation is not supported by the agent.
@@ -1615,7 +1653,16 @@ class AgentCard(A2ABaseModel):
16151653
additional_interfaces: list[AgentInterface] | None = None
16161654
"""
16171655
A list of additional supported interfaces (transport and URL combinations).
1618-
A client can use any of these to communicate with the agent.
1656+
This allows agents to expose multiple transports, potentially at different URLs.
1657+
1658+
Best practices:
1659+
- SHOULD include all supported transports for completeness
1660+
- SHOULD include an entry matching the main 'url' and 'preferredTransport'
1661+
- MAY reuse URLs if multiple transports are available at the same endpoint
1662+
- MUST accurately declare the transport available at each URL
1663+
1664+
Clients can select any interface from this list based on their transport capabilities
1665+
and preferences. This enables transport negotiation and fallback scenarios.
16191666
"""
16201667
capabilities: AgentCapabilities
16211668
"""
@@ -1650,9 +1697,16 @@ class AgentCard(A2ABaseModel):
16501697
"""
16511698
A human-readable name for the agent.
16521699
"""
1653-
preferred_transport: str | None = None
1700+
preferred_transport: str | None = Field(
1701+
default='JSONRPC', examples=['JSONRPC', 'GRPC', 'HTTP+JSON']
1702+
)
16541703
"""
1655-
The transport protocol for the preferred endpoint. Defaults to 'JSONRPC' if not specified.
1704+
The transport protocol for the preferred endpoint (the main 'url' field).
1705+
If not specified, defaults to 'JSONRPC'.
1706+
1707+
IMPORTANT: The transport specified here MUST be available at the main 'url'.
1708+
This creates a binding between the main URL and its supported transport protocol.
1709+
Clients should prefer this transport and URL combination when both are supported.
16561710
"""
16571711
protocol_version: str | None = '0.2.6'
16581712
"""
@@ -1672,6 +1726,10 @@ class AgentCard(A2ABaseModel):
16721726
A declaration of the security schemes available to authorize requests. The key is the
16731727
scheme name. Follows the OpenAPI 3.0 Security Scheme Object.
16741728
"""
1729+
signatures: list[AgentCardSignature] | None = None
1730+
"""
1731+
JSON Web Signatures computed for this AgentCard.
1732+
"""
16751733
skills: list[AgentSkill]
16761734
"""
16771735
The set of skills, or distinct capabilities, that the agent can perform.
@@ -1681,9 +1739,10 @@ class AgentCard(A2ABaseModel):
16811739
If true, the agent can provide an extended agent card with additional details
16821740
to authenticated users. Defaults to false.
16831741
"""
1684-
url: str
1742+
url: str = Field(..., examples=['https://api.example.com/a2a/v1'])
16851743
"""
16861744
The preferred endpoint URL for interacting with the agent.
1745+
This URL MUST support the transport specified by 'preferredTransport'.
16871746
"""
16881747
version: str = Field(..., examples=['1.0.0'])
16891748
"""

tests/server/tasks/test_result_aggregator.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,44 @@ async def raiser_gen_interrupt():
384384
)
385385
self.mock_task_manager.get_task.assert_not_called()
386386

387+
@patch('asyncio.create_task')
388+
async def test_consume_and_break_non_blocking(
389+
self, mock_create_task: MagicMock
390+
):
391+
"""Test that with blocking=False, the method returns after the first event."""
392+
first_event = create_sample_task('non_blocking_task')
393+
event_after = create_sample_message('should be consumed later')
394+
395+
async def mock_consume_generator():
396+
yield first_event
397+
yield event_after
398+
399+
self.mock_event_consumer.consume_all.return_value = (
400+
mock_consume_generator()
401+
)
402+
# After processing `first_event`, the current result will be that task.
403+
self.aggregator.task_manager.get_task.return_value = first_event
404+
405+
self.aggregator._continue_consuming = AsyncMock()
406+
mock_create_task.side_effect = lambda coro: asyncio.ensure_future(coro)
407+
408+
(
409+
result,
410+
interrupted,
411+
) = await self.aggregator.consume_and_break_on_interrupt(
412+
self.mock_event_consumer, blocking=False
413+
)
414+
415+
self.assertEqual(result, first_event)
416+
self.assertTrue(interrupted)
417+
self.mock_task_manager.process.assert_called_once_with(first_event)
418+
mock_create_task.assert_called_once()
419+
# The background task should be created with the remaining stream
420+
self.aggregator._continue_consuming.assert_called_once()
421+
self.assertIsInstance(
422+
self.aggregator._continue_consuming.call_args[0][0], AsyncIterator
423+
)
424+
387425
@patch('asyncio.create_task') # To verify _continue_consuming is called
388426
async def test_continue_consuming_processes_remaining_events(
389427
self, mock_create_task: MagicMock

tests/test_types.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1552,15 +1552,13 @@ def test_camelCase() -> None:
15521552
)
15531553

15541554
# Test setting an attribute via camelCase alias
1555-
# We expect a DeprecationWarning with a specific message
15561555
with pytest.warns(
15571556
DeprecationWarning,
15581557
match="Setting field 'supportsAuthenticatedExtendedCard'",
15591558
):
15601559
agent_card.supportsAuthenticatedExtendedCard = False
15611560

15621561
# Test getting an attribute via camelCase alias
1563-
# We expect another DeprecationWarning with a specific message
15641562
with pytest.warns(
15651563
DeprecationWarning, match="Accessing field 'defaultInputModes'"
15661564
):

0 commit comments

Comments
 (0)