Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/a2a/server/request_handlers/default_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,27 +51,28 @@
SubscribeToTaskRequest,
Task,
TaskPushNotificationConfig,
TaskState,
)
from a2a.utils.errors import (
ExtendedAgentCardNotConfiguredError,
InternalError,
InvalidParamsError,
PushNotificationNotSupportedError,
TaskNotCancelableError,
TaskNotFoundError,
UnsupportedOperationError,
)
from a2a.utils.input_mode_validator import validate_input_modes
from a2a.utils.proto_utils import warn_on_missing_required_fields
from a2a.utils.task import (
apply_history_length,
validate_history_length,
validate_page_size,
)
from a2a.utils.telemetry import SpanKind, trace_class


logger = logging.getLogger(__name__)

Check notice on line 75 in src/a2a/server/request_handlers/default_request_handler.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Copy/pasted code

see src/a2a/server/request_handlers/default_request_handler_v2.py (52-73)

TERMINAL_TASK_STATES = {
TaskState.TASK_STATE_COMPLETED,
Expand Down Expand Up @@ -146,6 +147,14 @@
self._validate_input_modes = validate_input_modes
self.extended_agent_card = extended_agent_card
self.extended_card_modifier = extended_card_modifier
warn_on_missing_required_fields(
agent_card, 'agent_card passed to DefaultRequestHandler:'
)
if extended_agent_card is not None:
warn_on_missing_required_fields(
extended_agent_card,
'extended_agent_card passed to DefaultRequestHandler:',
)
self._request_context_builder = (
request_context_builder
or SimpleRequestContextBuilder(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,27 +49,28 @@
Task,
TaskPushNotificationConfig,
TaskState,
TaskStatusUpdateEvent,
)
from a2a.utils.errors import (
ExtendedAgentCardNotConfiguredError,
InternalError,
InvalidParamsError,
PushNotificationNotSupportedError,
TaskNotCancelableError,
TaskNotFoundError,
UnsupportedOperationError,
)
from a2a.utils.input_mode_validator import validate_input_modes
from a2a.utils.proto_utils import warn_on_missing_required_fields
from a2a.utils.task import (
apply_history_length,
validate_history_length,
validate_page_size,
)
from a2a.utils.telemetry import SpanKind, trace_class


if TYPE_CHECKING:

Check notice on line 73 in src/a2a/server/request_handlers/default_request_handler_v2.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Copy/pasted code

see src/a2a/server/request_handlers/default_request_handler.py (54-75)
from collections.abc import AsyncGenerator, Awaitable, Callable

from a2a.server.agent_execution.active_task import ActiveTask
Expand Down Expand Up @@ -151,6 +152,14 @@
)
warnings.warn(message, stacklevel=2)
logger.warning(message)
warn_on_missing_required_fields(
agent_card, 'agent_card passed to DefaultRequestHandlerV2:'
)
if extended_agent_card is not None:
warn_on_missing_required_fields(
extended_agent_card,
'extended_agent_card passed to DefaultRequestHandlerV2:',
)
self._request_context_builder = (
request_context_builder
or SimpleRequestContextBuilder(
Expand Down
4 changes: 4 additions & 0 deletions src/a2a/server/request_handlers/response_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
UnsupportedOperationError,
VersionNotSupportedError,
)
from a2a.utils.proto_utils import warn_on_missing_required_fields


EXCEPTION_MAP: dict[type[A2AError], type[JSONRPCError]] = {
Expand Down Expand Up @@ -86,6 +87,9 @@

def agent_card_to_dict(card: AgentCard) -> dict[str, Any]:
"""Convert AgentCard to dict and inject backward compatibility fields."""
warn_on_missing_required_fields(
card, 'agent_card passed to agent_card_to_dict:'
)
result = MessageToDict(card)

try:
Expand Down
5 changes: 5 additions & 0 deletions src/a2a/server/routes/agent_card_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from a2a.server.request_handlers.response_helpers import agent_card_to_dict
from a2a.types.a2a_pb2 import AgentCard
from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH
from a2a.utils.proto_utils import warn_on_missing_required_fields


def _etag_for(card_dict: dict[str, Any]) -> str:
Expand Down Expand Up @@ -96,6 +97,10 @@ def create_agent_card_routes(
'It can be installed as part of `a2a-sdk` optional dependencies, `a2a-sdk[http-server]`.'
)

warn_on_missing_required_fields(
agent_card, 'agent_card passed to create_agent_card_routes:'
)

async def _get_agent_card(request: Request) -> Response:
"""Returns the public AgentCard describing this agent's capabilities, supported transports, and skills."""
card_to_serve = agent_card
Expand Down
44 changes: 44 additions & 0 deletions src/a2a/utils/proto_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
This module provides helper functions for common proto type operations.
"""

import logging

from typing import TYPE_CHECKING, Any, TypedDict, cast

from google.api.field_behavior_pb2 import FieldBehavior, field_behavior
Expand Down Expand Up @@ -45,6 +47,9 @@
)


logger = logging.getLogger(__name__)


# Define Event type locally to avoid circular imports
Event = Message | Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent

Expand Down Expand Up @@ -316,6 +321,45 @@ def validate_proto_required_fields(msg: ProtobufMessage) -> None:
)


_SPEC_FIELD_PRESENCE_URL = (
'https://a2a-protocol.org/latest/specification/'
'#57-field-presence-and-optionality'
)


def warn_on_missing_required_fields(msg: ProtobufMessage, source: str) -> bool:
"""Log a warning if fields marked as REQUIRED are missing or empty.

Unlike `validate_proto_required_fields`, this never raises, so it can
surface spec violations without breaking callers that currently rely on
non-compliant messages.

Args:
msg: The Protobuf message to check.
source: Where the message was passed in, used in the log message.

Returns:
True if every REQUIRED field is set, False otherwise. Objects that
are not Protobuf messages (for example test doubles) are not checked.
"""
if not isinstance(msg, ProtobufMessage):
return True
errors = _validate_proto_required_fields_internal(msg)
if not errors:
return True
logger.warning(
'%s %s is not spec-compliant - REQUIRED fields missing or empty: '
'%s. This is allowed and does not raise, but it may not verify or '
'interoperate across SDKs and could be rejected in a future major '
'release. See %s',
source,
msg.DESCRIPTOR.name,
', '.join(err['field'] for err in errors),
_SPEC_FIELD_PRESENCE_URL,
)
return False


def validation_errors_to_bad_request(
errors: list[ValidationDetail],
) -> error_details_pb2.BadRequest:
Expand Down
4 changes: 4 additions & 0 deletions src/a2a/utils/signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from a2a.types import AgentCard, AgentCardSignature
from a2a.utils._jcs import MAX_DEPTH, CanonicalizationError, canonicalize
from a2a.utils.proto_utils import warn_on_missing_required_fields


class SignatureVerificationError(Exception):
Expand Down Expand Up @@ -198,6 +199,9 @@ def _clean_empty(d: Any, depth: int = 1) -> Any:

def _canonicalize_agent_card(agent_card: AgentCard) -> str:
"""Canonicalizes the Agent Card JSON according to RFC 8785 (JCS)."""
warn_on_missing_required_fields(
agent_card, 'agent_card passed to _canonicalize_agent_card:'
)
card_dict = MessageToDict(
agent_card,
)
Expand Down
62 changes: 62 additions & 0 deletions tests/server/request_handlers/test_default_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
from a2a.types.a2a_pb2 import (
AgentCapabilities,
AgentCard,
AgentInterface,
AgentSkill,
Artifact,
CancelTaskRequest,
DeleteTaskPushNotificationConfigRequest,
Expand Down Expand Up @@ -3389,3 +3391,63 @@ async def test_on_message_send_rejects_invalid_push_url(agent_card):
InvalidParamsError, match='Invalid push notification URL'
):
await request_handler.on_message_send(params, context)


def _complete_agent_card() -> AgentCard:
"""Returns an AgentCard with every field the A2A spec marks REQUIRED."""
return AgentCard(
name='complete_agent',
description='An agent card with all required fields.',
supported_interfaces=[
AgentInterface(
url='http://localhost:8000',
protocol_binding='JSONRPC',
protocol_version='1.0',
)
],
version='1.0',
capabilities=AgentCapabilities(),
default_input_modes=['text/plain'],
default_output_modes=['text/plain'],
skills=[
AgentSkill(
id='echo',
name='Echo',
description='Echoes the input.',
tags=['test'],
)
],
)


def test_init_warns_about_incomplete_agent_cards(
agent_card: AgentCard,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Cards missing REQUIRED fields are accepted, but each one is logged."""
with caplog.at_level(logging.WARNING, logger='a2a.utils.proto_utils'):
DefaultRequestHandler(
agent_executor=MockAgentExecutor(),
task_store=InMemoryTaskStore(),
agent_card=agent_card,
extended_agent_card=AgentCard(),
)
messages = [record.getMessage() for record in caplog.records]
assert len(messages) == 2
assert messages[0].startswith('agent_card passed to DefaultRequestHandler:')
assert messages[1].startswith(
'extended_agent_card passed to DefaultRequestHandler:'
)


def test_init_does_not_warn_for_complete_agent_cards(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger='a2a.utils.proto_utils'):
DefaultRequestHandler(
agent_executor=MockAgentExecutor(),
task_store=InMemoryTaskStore(),
agent_card=_complete_agent_card(),
extended_agent_card=_complete_agent_card(),
)
assert caplog.records == []
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
from a2a.types.a2a_pb2 import (
AgentCapabilities,
AgentCard,
AgentInterface,
AgentSkill,
Artifact,
CancelTaskRequest,
DeleteTaskPushNotificationConfigRequest,
Expand Down Expand Up @@ -2528,3 +2530,64 @@ async def send(tag: str, message: Message) -> None:

assert agent.seen_tags == ['request-1', 'request-2']
await handler.aclose()


def _complete_agent_card() -> AgentCard:
"""Returns an AgentCard with every field the A2A spec marks REQUIRED."""
return AgentCard(
name='complete_agent',
description='An agent card with all required fields.',
supported_interfaces=[
AgentInterface(
url='http://localhost:8000',
protocol_binding='JSONRPC',
protocol_version='1.0',
)
],
version='1.0',
capabilities=AgentCapabilities(),
default_input_modes=['text/plain'],
default_output_modes=['text/plain'],
skills=[
AgentSkill(
id='echo',
name='Echo',
description='Echoes the input.',
tags=['test'],
)
],
)


def test_init_warns_about_incomplete_agent_cards(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Cards missing REQUIRED fields are accepted, but each one is logged."""
with caplog.at_level(logging.WARNING, logger='a2a.utils.proto_utils'):
DefaultRequestHandlerV2(
agent_executor=MockAgentExecutor(),
task_store=InMemoryTaskStore(),
agent_card=create_default_agent_card(),
extended_agent_card=AgentCard(),
)
messages = [record.getMessage() for record in caplog.records]
assert len(messages) == 2
assert messages[0].startswith(
'agent_card passed to DefaultRequestHandlerV2:'
)
assert messages[1].startswith(
'extended_agent_card passed to DefaultRequestHandlerV2:'
)


def test_init_does_not_warn_for_complete_agent_cards(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger='a2a.utils.proto_utils'):
DefaultRequestHandlerV2(
agent_executor=MockAgentExecutor(),
task_store=InMemoryTaskStore(),
agent_card=_complete_agent_card(),
extended_agent_card=_complete_agent_card(),
)
assert caplog.records == []
39 changes: 39 additions & 0 deletions tests/server/request_handlers/test_response_helpers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import unittest

from a2a.server.request_handlers.response_helpers import (
Expand All @@ -13,6 +14,7 @@
AgentCapabilities,
AgentCard,
AgentInterface,
AgentSkill,
Task,
TaskState,
TaskStatus,
Expand Down Expand Up @@ -420,3 +422,40 @@ def test_prepare_response_object_with_invalid_response(self) -> None:

if __name__ == '__main__':
unittest.main()


class TestAgentCardToDictRequiredFields(unittest.TestCase):
def test_warns_when_required_fields_are_missing(self) -> None:
with self.assertLogs('a2a.utils.proto_utils', logging.WARNING) as logs:
result = agent_card_to_dict(AgentCard(name='partial'))
self.assertEqual(result['name'], 'partial')
[message] = logs.output
self.assertIn('agent_card passed to agent_card_to_dict:', message)
self.assertIn('description', message)

def test_complete_card_does_not_warn(self) -> None:
card = AgentCard(
name='complete_agent',
description='An agent card with all required fields.',
supported_interfaces=[
AgentInterface(
url='http://localhost:8000',
protocol_binding='JSONRPC',
protocol_version='1.0',
)
],
version='1.0',
capabilities=AgentCapabilities(),
default_input_modes=['text/plain'],
default_output_modes=['text/plain'],
skills=[
AgentSkill(
id='echo',
name='Echo',
description='Echoes the input.',
tags=['test'],
)
],
)
with self.assertNoLogs('a2a.utils.proto_utils', logging.WARNING):
agent_card_to_dict(card)
Loading
Loading