Files
cleveragents-core/features/steps/agent_card_steps.py
T
freemo d3b9b8c8b8 feat(a2a): Agent Card discovery endpoint
Implement A2A Agent Card discovery per ADR-047 and issue #867:

- AgentCard Pydantic model (agent_card.py) with nested models for
  skills, capabilities, extensions, security schemes, and interfaces.
- Factory function build_agent_card() enumerates supported operations
  from the facade and maps them to A2A skills (plan-lifecycle,
  registry-crud, context-mgmt, entity-sync, namespace-mgmt,
  health-diagnostics).
- Version negotiation: supportedVersions and version fields from
  A2aVersion constants.
- A2A conformance validation (validate_agent_card_conformance) checks
  required fields, version support, and structural integrity.
- Updated ASGI app to build the Agent Card from the facade model
  instead of a raw dict, with conformance validation at startup.
- Behave BDD tests: 34 scenarios covering model construction, field
  validation, conformance checks, serialization, endpoint responses,
  and skill enumeration from facade operations.
- Robot Framework integration tests: 6 test cases for build, conformance,
  endpoint, serialization, skill enumeration, and version info.
- Updated A2A package exports and CHANGELOG.

ISSUES CLOSED: #867
2026-06-11 19:57:39 -04:00

446 lines
15 KiB
Python

"""Step definitions for ``agent_card.feature``.
Tests Agent Card model construction, conformance validation,
serialization, discovery endpoint, and skill enumeration.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from pydantic import ValidationError
from cleveragents.a2a.agent_card import (
AgentCard,
AgentCardExtension,
AgentCardInterface,
AgentCardSecurityScheme,
AgentCardSkill,
agent_card_to_dict,
build_agent_card,
validate_agent_card_conformance,
)
from cleveragents.a2a.facade import A2aLocalFacade
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given("an A2aLocalFacade for agent card testing")
def step_given_facade_for_card(context: Any) -> None:
context.facade = A2aLocalFacade()
@given("an agent card with empty url")
def step_given_card_empty_url(context: Any) -> None:
context.agent_card = AgentCard(
name="Test",
version="1.0",
url="",
)
@given('an agent card with version "{version}"')
def step_given_card_version(context: Any, version: str) -> None:
context.agent_card = AgentCard(
name="Test",
version=version,
url="http://localhost/a2a",
)
@given("an agent card with empty supported versions")
def step_given_card_empty_supported_versions(context: Any) -> None:
context.agent_card = AgentCard(
name="Test",
version="1.0",
url="http://localhost/a2a",
supportedVersions=[],
)
# ---------------------------------------------------------------------------
# When steps — build
# ---------------------------------------------------------------------------
@when("I build an agent card from the facade")
def step_build_card_from_facade(context: Any) -> None:
context.agent_card = build_agent_card(facade=context.facade)
@when("I build an agent card without a facade")
def step_build_card_no_facade(context: Any) -> None:
context.agent_card = build_agent_card()
@when("I try to build an agent card with an empty host")
def step_try_build_card_empty_host(context: Any) -> None:
context.caught_exception = None
try:
build_agent_card(host="")
except ValueError as exc:
context.caught_exception = exc
@when("I try to build an agent card with port {port:d}")
def step_try_build_card_bad_port(context: Any, port: int) -> None:
context.caught_exception = None
try:
build_agent_card(port=port)
except ValueError as exc:
context.caught_exception = exc
# ---------------------------------------------------------------------------
# When steps — model validation
# ---------------------------------------------------------------------------
@when("I try to create an AgentCard with empty name")
def step_try_card_empty_name(context: Any) -> None:
context.caught_exception = None
try:
AgentCard(name="", version="1.0")
except ValidationError as exc:
context.caught_exception = exc
@when("I try to create an AgentCard with empty version")
def step_try_card_empty_version(context: Any) -> None:
context.caught_exception = None
try:
AgentCard(name="Test", version="")
except ValidationError as exc:
context.caught_exception = exc
@when("I try to create an AgentCardSkill with empty id")
def step_try_skill_empty_id(context: Any) -> None:
context.caught_exception = None
try:
AgentCardSkill(id="", name="Test")
except ValidationError as exc:
context.caught_exception = exc
@when("I try to create an AgentCardSkill with empty skill name")
def step_try_skill_empty_name(context: Any) -> None:
context.caught_exception = None
try:
AgentCardSkill(id="test", name="")
except ValidationError as exc:
context.caught_exception = exc
@when("I try to create an AgentCardExtension with empty uri")
def step_try_extension_empty_uri(context: Any) -> None:
context.caught_exception = None
try:
AgentCardExtension(uri="")
except ValidationError as exc:
context.caught_exception = exc
@when("I try to create an AgentCardSecurityScheme with empty type")
def step_try_security_empty_type(context: Any) -> None:
context.caught_exception = None
try:
AgentCardSecurityScheme(type="")
except ValidationError as exc:
context.caught_exception = exc
@when("I try to create an AgentCardInterface with empty protocol")
def step_try_interface_empty_protocol(context: Any) -> None:
context.caught_exception = None
try:
AgentCardInterface(protocol="")
except ValidationError as exc:
context.caught_exception = exc
# ---------------------------------------------------------------------------
# When steps — conformance validation
# ---------------------------------------------------------------------------
@when("I validate the agent card conformance")
def step_validate_conformance(context: Any) -> None:
context.violations = validate_agent_card_conformance(context.agent_card)
@when("I try to validate conformance of a non-AgentCard object")
def step_try_validate_non_card(context: Any) -> None:
context.caught_exception = None
try:
validate_agent_card_conformance("not-a-card") # type: ignore[arg-type]
except TypeError as exc:
context.caught_exception = exc
# ---------------------------------------------------------------------------
# When steps — serialization
# ---------------------------------------------------------------------------
@when("I serialize the agent card to dict")
def step_serialize_card(context: Any) -> None:
context.card_dict = agent_card_to_dict(context.agent_card)
@when("I try to serialize a non-AgentCard object")
def step_try_serialize_non_card(context: Any) -> None:
context.caught_exception = None
try:
agent_card_to_dict("not-a-card") # type: ignore[arg-type]
except TypeError as exc:
context.caught_exception = exc
# ---------------------------------------------------------------------------
# When steps — endpoint
# ---------------------------------------------------------------------------
@given("a running ASGI test client with facade")
def step_given_test_client_with_facade(context: Any) -> None:
from fastapi.testclient import TestClient
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
facade = A2aLocalFacade()
app = create_asgi_app(facade=facade)
context.test_client = TestClient(app)
@when("I request GET /.well-known/agent.json and parse JSON")
def step_get_agent_card_parse(context: Any) -> None:
context.response = context.test_client.get("/.well-known/agent.json")
context.card_json = context.response.json()
# ---------------------------------------------------------------------------
# Then steps — card properties
# ---------------------------------------------------------------------------
@then('the agent card name should be "{expected}"')
def step_check_card_name(context: Any, expected: str) -> None:
assert context.agent_card.name == expected, (
f"Expected name '{expected}', got '{context.agent_card.name}'"
)
@then('the agent card version should be "{expected}"')
def step_check_card_version(context: Any, expected: str) -> None:
assert context.agent_card.version == expected, (
f"Expected version '{expected}', got '{context.agent_card.version}'"
)
@then("the agent card should have a non-empty url")
def step_check_card_url(context: Any) -> None:
assert context.agent_card.url, "Expected non-empty url"
@then("the agent card should have at least {count:d} skill")
@then("the agent card should have at least {count:d} skills")
def step_check_card_skills_count(context: Any, count: int) -> None:
actual = len(context.agent_card.skills)
assert actual >= count, f"Expected at least {count} skills, got {actual}"
@then("the agent card should have {count:d} skills")
def step_check_card_exact_skills(context: Any, count: int) -> None:
actual = len(context.agent_card.skills)
assert actual == count, f"Expected {count} skills, got {actual}"
@then("the agent card should have at least {count:d} extension")
@then("the agent card should have at least {count:d} extensions")
def step_check_card_extensions_count(context: Any, count: int) -> None:
actual = len(context.agent_card.extensions)
assert actual >= count, f"Expected at least {count} extensions, got {actual}"
@then("the agent card should have at least {count:d} security scheme")
@then("the agent card should have at least {count:d} security schemes")
def step_check_card_security_count(context: Any, count: int) -> None:
actual = len(context.agent_card.securitySchemes)
assert actual >= count, f"Expected at least {count} security schemes, got {actual}"
@then("the agent card should have at least {count:d} interface")
@then("the agent card should have at least {count:d} interfaces")
def step_check_card_interfaces_count(context: Any, count: int) -> None:
actual = len(context.agent_card.interfaces)
assert actual >= count, f"Expected at least {count} interfaces, got {actual}"
@then('the first interface protocol should be "{expected}"')
def step_check_first_interface(context: Any, expected: str) -> None:
assert context.agent_card.interfaces, "No interfaces found"
assert context.agent_card.interfaces[0].protocol == expected
@then('the agent card supported versions should contain "{version}"')
def step_check_supported_versions(context: Any, version: str) -> None:
assert version in context.agent_card.supportedVersions, (
f"'{version}' not in {context.agent_card.supportedVersions}"
)
@then("the agent card should list extension operations")
def step_check_extension_ops(context: Any) -> None:
assert context.agent_card.supportedOperations, (
"Expected non-empty supportedOperations"
)
for op in context.agent_card.supportedOperations:
assert op.startswith("_cleveragents/"), f"Operation '{op}' has wrong prefix"
@then('the agent card default input modes should contain "{mode}"')
def step_check_input_modes(context: Any, mode: str) -> None:
assert mode in context.agent_card.defaultInputModes
@then('the agent card default output modes should contain "{mode}"')
def step_check_output_modes(context: Any, mode: str) -> None:
assert mode in context.agent_card.defaultOutputModes
@then("the agent card streaming capability should be false")
def step_check_streaming_false(context: Any) -> None:
assert context.agent_card.capabilities.streaming is False
@then("the agent card push notifications capability should be false")
def step_check_push_false(context: Any) -> None:
assert context.agent_card.capabilities.pushNotifications is False
# ---------------------------------------------------------------------------
# Then steps — validation errors
# ---------------------------------------------------------------------------
@then("a ValueError should be raised for agent card")
def step_check_value_error_card(context: Any) -> None:
assert context.caught_exception is not None, "Expected ValueError"
assert isinstance(context.caught_exception, ValueError)
@then("a validation error should be raised for agent card")
def step_check_validation_error_card(context: Any) -> None:
assert context.caught_exception is not None, "Expected ValidationError"
assert isinstance(context.caught_exception, ValidationError)
@then("a TypeError should be raised for conformance validation")
def step_check_type_error_conformance(context: Any) -> None:
assert context.caught_exception is not None, "Expected TypeError"
assert isinstance(context.caught_exception, TypeError)
@then("a TypeError should be raised for serialization")
def step_check_type_error_serialization(context: Any) -> None:
assert context.caught_exception is not None, "Expected TypeError"
assert isinstance(context.caught_exception, TypeError)
# ---------------------------------------------------------------------------
# Then steps — conformance
# ---------------------------------------------------------------------------
@then("the conformance violations should be empty")
def step_check_no_violations(context: Any) -> None:
assert context.violations == [], (
f"Expected no violations, got: {context.violations}"
)
@then('the conformance violations should include "{text}"')
def step_check_violation_text(context: Any, text: str) -> None:
joined = " | ".join(context.violations)
assert any(text in v for v in context.violations), (
f"Expected violation containing '{text}', got: {joined}"
)
# ---------------------------------------------------------------------------
# Then steps — serialization
# ---------------------------------------------------------------------------
@then('the serialized dict should have key "{key}"')
def step_check_dict_key(context: Any, key: str) -> None:
assert key in context.card_dict, (
f"Key '{key}' not in serialized dict: {list(context.card_dict.keys())}"
)
# ---------------------------------------------------------------------------
# Then steps — JSON endpoint
# ---------------------------------------------------------------------------
@then('the agent card JSON should have key "{key}" with value "{value}"')
def step_check_json_key_value(context: Any, key: str, value: str) -> None:
actual = context.card_json.get(key)
assert actual == value, f"Expected '{key}'='{value}', got '{actual}'"
@then('the agent card JSON "{key}" should be a dict')
def step_check_json_dict(context: Any, key: str) -> None:
val = context.card_json.get(key)
assert isinstance(val, dict), f"Expected dict for '{key}', got {type(val)}"
@then('the agent card JSON "{key}" should be a non-empty list')
def step_check_json_list(context: Any, key: str) -> None:
val = context.card_json.get(key)
assert isinstance(val, list), f"Expected list for '{key}', got {type(val)}"
assert len(val) > 0, f"Expected non-empty list for '{key}'"
@then('all supported operations should start with "_cleveragents/"')
def step_check_ops_prefix(context: Any) -> None:
ops = context.card_json.get("supportedOperations", [])
for op in ops:
assert op.startswith("_cleveragents/"), f"Operation '{op}' has wrong prefix"
# ---------------------------------------------------------------------------
# Then steps — skill enumeration
# ---------------------------------------------------------------------------
@then('the agent card skills should include id "{skill_id}"')
def step_check_skill_id(context: Any, skill_id: str) -> None:
ids = [s.id for s in context.agent_card.skills]
assert skill_id in ids, f"Skill '{skill_id}' not in {ids}"
@given("an agent card with empty default input modes")
def step_given_card_empty_input_modes(context: Any) -> None:
context.agent_card = AgentCard(
name="Test",
version="1.0",
url="http://localhost/a2a",
defaultInputModes=[],
)
@given("an agent card with empty default output modes")
def step_given_card_empty_output_modes(context: Any) -> None:
context.agent_card = AgentCard(
name="Test",
version="1.0",
url="http://localhost/a2a",
defaultOutputModes=[],
)