From d3b9b8c8b82acd0fbdddbdadbdbe1443f1257008 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Mon, 23 Mar 2026 04:55:44 +0000 Subject: [PATCH] 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 --- features/agent_card.feature | 219 +++++++++ features/steps/agent_card_steps.py | 445 ++++++++++++++++++ robot/agent_card.robot | 61 +++ robot/helper_agent_card.py | 145 ++++++ src/cleveragents/a2a/__init__.py | 17 + src/cleveragents/a2a/agent_card.py | 377 +++++++++++++++ .../infrastructure/server/asgi_app.py | 50 +- 7 files changed, 1287 insertions(+), 27 deletions(-) create mode 100644 features/agent_card.feature create mode 100644 features/steps/agent_card_steps.py create mode 100644 robot/agent_card.robot create mode 100644 robot/helper_agent_card.py create mode 100644 src/cleveragents/a2a/agent_card.py diff --git a/features/agent_card.feature b/features/agent_card.feature new file mode 100644 index 000000000..75bfbf399 --- /dev/null +++ b/features/agent_card.feature @@ -0,0 +1,219 @@ +@phase2 @a2a @agent_card +Feature: A2A Agent Card Discovery + As an A2A-compatible client + I want to discover the agent's capabilities via an Agent Card + So that I can determine which operations the CleverAgents server supports + + # ----------------------------------------------------------------------- + # Agent Card model construction + # ----------------------------------------------------------------------- + + Scenario: Build an Agent Card with a facade + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + Then the agent card name should be "CleverAgents" + And the agent card version should be "1.0" + And the agent card should have a non-empty url + And the agent card should have at least 1 skill + And the agent card should have at least 1 extension + + Scenario: Build an Agent Card without a facade + When I build an agent card without a facade + Then the agent card name should be "CleverAgents" + And the agent card version should be "1.0" + And the agent card should have 0 skills + + Scenario: Agent Card includes supported versions + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + Then the agent card supported versions should contain "1.0" + + Scenario: Agent Card includes supported operations + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + Then the agent card should list extension operations + + Scenario: Agent Card includes security schemes + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + Then the agent card should have at least 1 security scheme + + Scenario: Agent Card includes protocol interfaces + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + Then the agent card should have at least 1 interface + And the first interface protocol should be "jsonrpc" + + Scenario: Agent Card includes default input and output modes + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + Then the agent card default input modes should contain "text" + And the agent card default output modes should contain "text" + + Scenario: Agent Card capabilities reflect current state + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + Then the agent card streaming capability should be false + And the agent card push notifications capability should be false + + # ----------------------------------------------------------------------- + # Agent Card validation + # ----------------------------------------------------------------------- + + Scenario: Build rejects invalid host + When I try to build an agent card with an empty host + Then a ValueError should be raised for agent card + + Scenario: Build rejects invalid port zero + When I try to build an agent card with port 0 + Then a ValueError should be raised for agent card + + Scenario: Build rejects port above 65535 + When I try to build an agent card with port 70000 + Then a ValueError should be raised for agent card + + # ----------------------------------------------------------------------- + # Agent Card model validation + # ----------------------------------------------------------------------- + + Scenario: AgentCard rejects empty name + When I try to create an AgentCard with empty name + Then a validation error should be raised for agent card + + Scenario: AgentCard rejects empty version + When I try to create an AgentCard with empty version + Then a validation error should be raised for agent card + + Scenario: AgentCardSkill rejects empty id + When I try to create an AgentCardSkill with empty id + Then a validation error should be raised for agent card + + Scenario: AgentCardSkill rejects empty name + When I try to create an AgentCardSkill with empty skill name + Then a validation error should be raised for agent card + + Scenario: AgentCardExtension rejects empty uri + When I try to create an AgentCardExtension with empty uri + Then a validation error should be raised for agent card + + Scenario: AgentCardSecurityScheme rejects empty type + When I try to create an AgentCardSecurityScheme with empty type + Then a validation error should be raised for agent card + + Scenario: AgentCardInterface rejects empty protocol + When I try to create an AgentCardInterface with empty protocol + Then a validation error should be raised for agent card + + # ----------------------------------------------------------------------- + # A2A conformance validation + # ----------------------------------------------------------------------- + + Scenario: Valid Agent Card passes conformance validation + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + And I validate the agent card conformance + Then the conformance violations should be empty + + Scenario: Agent Card with empty url fails conformance + Given an agent card with empty url + When I validate the agent card conformance + Then the conformance violations should include "url is required" + + Scenario: Agent Card with unsupported version fails conformance + Given an agent card with version "99.0" + When I validate the agent card conformance + Then the conformance violations should include "not in supported versions" + + Scenario: Agent Card with empty supportedVersions fails conformance + Given an agent card with empty supported versions + When I validate the agent card conformance + Then the conformance violations should include "supportedVersions must not be empty" + + Scenario: Conformance validation rejects non-AgentCard argument + When I try to validate conformance of a non-AgentCard object + Then a TypeError should be raised for conformance validation + + # ----------------------------------------------------------------------- + # Serialization + # ----------------------------------------------------------------------- + + Scenario: Agent Card serializes to dict + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + And I serialize the agent card to dict + Then the serialized dict should have key "name" + And the serialized dict should have key "version" + And the serialized dict should have key "capabilities" + And the serialized dict should have key "skills" + And the serialized dict should have key "supportedVersions" + And the serialized dict should have key "supportedOperations" + + Scenario: agent_card_to_dict rejects non-AgentCard argument + When I try to serialize a non-AgentCard object + Then a TypeError should be raised for serialization + + # ----------------------------------------------------------------------- + # Discovery endpoint (ASGI) + # ----------------------------------------------------------------------- + + Scenario: Discovery endpoint returns Agent Card with skills + Given a running ASGI test client with facade + When I request GET /.well-known/agent.json + Then the response status code should be 200 + And the response body should contain "CleverAgents" + And the response body should contain "skills" + And the response body should contain "supportedVersions" + And the response body should contain "supportedOperations" + + Scenario: Discovery endpoint Agent Card has correct structure + Given a running ASGI test client with facade + When I request GET /.well-known/agent.json and parse JSON + Then the agent card JSON should have key "name" with value "CleverAgents" + And the agent card JSON should have key "version" with value "1.0" + And the agent card JSON "capabilities" should be a dict + And the agent card JSON "skills" should be a non-empty list + And the agent card JSON "supportedVersions" should be a non-empty list + + Scenario: Discovery endpoint Agent Card lists extension operations + Given a running ASGI test client with facade + When I request GET /.well-known/agent.json and parse JSON + Then the agent card JSON "supportedOperations" should be a non-empty list + And all supported operations should start with "_cleveragents/" + + # ----------------------------------------------------------------------- + # Skill enumeration from facade + # ----------------------------------------------------------------------- + + Scenario: Agent Card enumerates plan lifecycle skill + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + Then the agent card skills should include id "plan-lifecycle" + + Scenario: Agent Card enumerates registry skill + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + Then the agent card skills should include id "registry-crud" + + Scenario: Agent Card enumerates context skill + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + Then the agent card skills should include id "context-mgmt" + + Scenario: Agent Card enumerates health skill + Given an A2aLocalFacade for agent card testing + When I build an agent card from the facade + Then the agent card skills should include id "health-diagnostics" + + # ----------------------------------------------------------------------- + # Additional conformance edge cases + # ----------------------------------------------------------------------- + + Scenario: Agent Card with empty defaultInputModes fails conformance + Given an agent card with empty default input modes + When I validate the agent card conformance + Then the conformance violations should include "defaultInputModes must not be empty" + + Scenario: Agent Card with empty defaultOutputModes fails conformance + Given an agent card with empty default output modes + When I validate the agent card conformance + Then the conformance violations should include "defaultOutputModes must not be empty" diff --git a/features/steps/agent_card_steps.py b/features/steps/agent_card_steps.py new file mode 100644 index 000000000..59f55f6f6 --- /dev/null +++ b/features/steps/agent_card_steps.py @@ -0,0 +1,445 @@ +"""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=[], + ) diff --git a/robot/agent_card.robot b/robot/agent_card.robot new file mode 100644 index 000000000..b6eadbd6f --- /dev/null +++ b/robot/agent_card.robot @@ -0,0 +1,61 @@ +*** Settings *** +Documentation Integration tests for A2A Agent Card discovery +... +... Tests verify that the Agent Card model, factory, conformance +... validation, discovery endpoint, skill enumeration, and +... version negotiation all work end-to-end. +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_agent_card.py + +*** Test Cases *** +Build Agent Card From Facade + [Documentation] build_agent_card produces a valid card from a facade + ${result}= Run Process ${PYTHON} ${HELPER} build-card cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} build-card-ok + +Agent Card A2A Conformance Validation + [Documentation] Agent Card passes A2A specification conformance checks + ${result}= Run Process ${PYTHON} ${HELPER} conformance cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} conformance-ok + +Agent Card Discovery Endpoint + [Documentation] /.well-known/agent.json returns full Agent Card with skills + ${result}= Run Process ${PYTHON} ${HELPER} endpoint cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} endpoint-ok + +Agent Card JSON Serialization + [Documentation] Agent Card round-trips through JSON serialization + ${result}= Run Process ${PYTHON} ${HELPER} serialization cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} serialization-ok + +Agent Card Skill Enumeration + [Documentation] Skills are correctly enumerated from facade operations + ${result}= Run Process ${PYTHON} ${HELPER} skill-enumeration cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} skill-enumeration-ok + +Agent Card Version Negotiation Info + [Documentation] Agent Card includes correct version negotiation information + ${result}= Run Process ${PYTHON} ${HELPER} version-info cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} version-info-ok diff --git a/robot/helper_agent_card.py b/robot/helper_agent_card.py new file mode 100644 index 000000000..adfa83937 --- /dev/null +++ b/robot/helper_agent_card.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Integration test helper for Agent Card discovery tests. + +Each sub-command exercises a real import / instantiation path and prints +a unique sentinel on success. Robot tests parse the output for that +sentinel. +""" + +from __future__ import annotations + +import json +import sys + + +def _test_build_card() -> None: + """Build an Agent Card from a facade and verify structure.""" + from cleveragents.a2a.agent_card import build_agent_card + from cleveragents.a2a.facade import A2aLocalFacade + + facade = A2aLocalFacade() + card = build_agent_card(facade=facade) + assert card.name == "CleverAgents" + assert card.version == "1.0" + assert card.url + assert len(card.skills) > 0 + assert len(card.extensions) > 0 + assert len(card.supportedVersions) > 0 + assert len(card.supportedOperations) > 0 + print("build-card-ok") + + +def _test_conformance() -> None: + """Validate Agent Card A2A conformance.""" + from cleveragents.a2a.agent_card import ( + build_agent_card, + validate_agent_card_conformance, + ) + from cleveragents.a2a.facade import A2aLocalFacade + + facade = A2aLocalFacade() + card = build_agent_card(facade=facade) + violations = validate_agent_card_conformance(card) + assert violations == [], f"Conformance violations: {violations}" + print("conformance-ok") + + +def _test_endpoint() -> None: + """Exercise the /.well-known/agent.json endpoint via TestClient.""" + from fastapi.testclient import TestClient + + from cleveragents.a2a.facade import A2aLocalFacade + from cleveragents.infrastructure.server.asgi_app import create_asgi_app + + facade = A2aLocalFacade() + app = create_asgi_app(facade=facade) + client = TestClient(app) + + resp = client.get("/.well-known/agent.json") + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "CleverAgents" + assert "skills" in data + assert "supportedVersions" in data + assert "supportedOperations" in data + assert isinstance(data["skills"], list) + assert len(data["skills"]) > 0 + assert isinstance(data["supportedOperations"], list) + assert len(data["supportedOperations"]) > 0 + # Verify all operations have the correct prefix + for op in data["supportedOperations"]: + assert op.startswith("_cleveragents/"), f"Bad prefix: {op}" + print("endpoint-ok") + + +def _test_serialization() -> None: + """Verify Agent Card serializes to valid JSON dict.""" + from cleveragents.a2a.agent_card import ( + agent_card_to_dict, + build_agent_card, + ) + from cleveragents.a2a.facade import A2aLocalFacade + + facade = A2aLocalFacade() + card = build_agent_card(facade=facade) + data = agent_card_to_dict(card) + # Round-trip through JSON to ensure serializability + serialized = json.dumps(data) + parsed = json.loads(serialized) + assert parsed["name"] == "CleverAgents" + assert "skills" in parsed + assert "supportedOperations" in parsed + print("serialization-ok") + + +def _test_skill_enumeration() -> None: + """Verify skills are correctly enumerated from facade operations.""" + from cleveragents.a2a.agent_card import build_agent_card + from cleveragents.a2a.facade import A2aLocalFacade + + facade = A2aLocalFacade() + card = build_agent_card(facade=facade) + skill_ids = [s.id for s in card.skills] + assert "plan-lifecycle" in skill_ids, f"Missing plan-lifecycle: {skill_ids}" + assert "registry-crud" in skill_ids, f"Missing registry-crud: {skill_ids}" + assert "context-mgmt" in skill_ids, f"Missing context-mgmt: {skill_ids}" + assert "health-diagnostics" in skill_ids, f"Missing health-diagnostics: {skill_ids}" + print("skill-enumeration-ok") + + +def _test_version_info() -> None: + """Verify version negotiation information in Agent Card.""" + from cleveragents.a2a.agent_card import build_agent_card + from cleveragents.a2a.facade import A2aLocalFacade + from cleveragents.a2a.models import A2aVersion + + facade = A2aLocalFacade() + card = build_agent_card(facade=facade) + assert card.version == A2aVersion.CURRENT + assert A2aVersion.CURRENT in card.supportedVersions + for v in A2aVersion.SUPPORTED: + assert v in card.supportedVersions + print("version-info-ok") + + +_COMMANDS: dict[str, object] = { + "build-card": _test_build_card, + "conformance": _test_conformance, + "endpoint": _test_endpoint, + "serialization": _test_serialization, + "skill-enumeration": _test_skill_enumeration, + "version-info": _test_version_info, +} + + +def main() -> None: + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr) + sys.exit(2) + + handler = _COMMANDS[sys.argv[1]] + handler() # type: ignore[operator] + + +if __name__ == "__main__": + main() diff --git a/src/cleveragents/a2a/__init__.py b/src/cleveragents/a2a/__init__.py index 4b743333e..784e3f526 100644 --- a/src/cleveragents/a2a/__init__.py +++ b/src/cleveragents/a2a/__init__.py @@ -22,6 +22,17 @@ configuration: stdio for local mode, HTTP for server mode. from __future__ import annotations +from cleveragents.a2a.agent_card import ( + AgentCard, + AgentCardCapabilities, + AgentCardExtension, + AgentCardInterface, + AgentCardSecurityScheme, + AgentCardSkill, + agent_card_to_dict, + build_agent_card, + validate_agent_card_conformance, +) from cleveragents.a2a.clients import ( AuthClient, RemoteExecutionClient, @@ -83,6 +94,12 @@ __all__ = [ "A2aVersion", "A2aVersionMismatchError", "A2aVersionNegotiator", + "AgentCard", + "AgentCardCapabilities", + "AgentCardExtension", + "AgentCardInterface", + "AgentCardSecurityScheme", + "AgentCardSkill", "AuthClient", "ConflictResolution", "RemoteExecutionClient", diff --git a/src/cleveragents/a2a/agent_card.py b/src/cleveragents/a2a/agent_card.py new file mode 100644 index 000000000..75e3f2ec8 --- /dev/null +++ b/src/cleveragents/a2a/agent_card.py @@ -0,0 +1,377 @@ +"""A2A Agent Card model and factory. + +Provides Pydantic v2 models for the A2A Agent Card specification and a +factory function that builds a card from the facade's supported +operations. The card is served at ``/.well-known/agent.json`` per the +A2A specification (ADR-047). + +The :func:`validate_agent_card_conformance` function validates that a +card satisfies the mandatory fields required by the A2A specification. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, ConfigDict, field_validator + +from cleveragents.a2a.models import A2aVersion + +if TYPE_CHECKING: + from cleveragents.a2a.facade import A2aLocalFacade + +# --------------------------------------------------------------------------- +# Operation-to-skill mapping +# --------------------------------------------------------------------------- + +_SKILL_DEFINITIONS: list[dict[str, str]] = [ + { + "id": "plan-lifecycle", + "name": "Plan Lifecycle Management", + "description": "Create, execute, cancel, and monitor agent plans", + "prefix": "_cleveragents/plan/", + }, + { + "id": "registry-crud", + "name": "Entity Registry Operations", + "description": "List and manage tools, resources, actors, and other entities", + "prefix": "_cleveragents/registry/", + }, + { + "id": "context-mgmt", + "name": "Context Assembly", + "description": "Inspect, set, and simulate project context", + "prefix": "_cleveragents/context/", + }, + { + "id": "entity-sync", + "name": "Entity Synchronization", + "description": "Pull, push, and check sync status across namespaces", + "prefix": "_cleveragents/sync/", + }, + { + "id": "namespace-mgmt", + "name": "Namespace Management", + "description": "List, inspect, and manage namespaces and members", + "prefix": "_cleveragents/namespace/", + }, + { + "id": "health-diagnostics", + "name": "Health and Diagnostics", + "description": "Health checks and diagnostic reporting", + "prefix": "_cleveragents/health/", + }, +] + + +# --------------------------------------------------------------------------- +# Pydantic models +# --------------------------------------------------------------------------- + + +class AgentCardSkill(BaseModel): + """A skill advertised in the Agent Card.""" + + model_config = ConfigDict(strict=False) + + id: str + name: str + description: str = "" + + @field_validator("id", "name") + @classmethod + def _must_be_non_empty(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("field must not be empty") + return value + + +class AgentCardCapabilities(BaseModel): + """Capability flags for the Agent Card.""" + + model_config = ConfigDict(strict=False) + + streaming: bool = False + pushNotifications: bool = False + + +class AgentCardExtension(BaseModel): + """An extension namespace declaration.""" + + model_config = ConfigDict(strict=False) + + uri: str + description: str = "" + + @field_validator("uri") + @classmethod + def _uri_non_empty(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("uri must not be empty") + return value + + +class AgentCardSecurityScheme(BaseModel): + """A security scheme declared by the Agent Card.""" + + model_config = ConfigDict(strict=False) + + type: str + scheme: str = "" + + @field_validator("type") + @classmethod + def _type_non_empty(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("type must not be empty") + return value + + +class AgentCardInterface(BaseModel): + """A protocol interface declared by the Agent Card.""" + + model_config = ConfigDict(strict=False) + + protocol: str + url: str = "" + + @field_validator("protocol") + @classmethod + def _protocol_non_empty(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("protocol must not be empty") + return value + + +class AgentCard(BaseModel): + """A2A Agent Card describing the agent's capabilities. + + Conforms to the A2A specification Agent Card schema. All required + fields (``name``, ``version``, ``url``) are validated on construction. + """ + + model_config = ConfigDict(strict=False) + + name: str + description: str = "" + url: str = "" + version: str = A2aVersion.CURRENT + capabilities: AgentCardCapabilities = AgentCardCapabilities() + skills: list[AgentCardSkill] = [] + extensions: list[AgentCardExtension] = [] + securitySchemes: list[AgentCardSecurityScheme] = [] + interfaces: list[AgentCardInterface] = [] + defaultInputModes: list[str] = ["text"] + defaultOutputModes: list[str] = ["text"] + supportedVersions: list[str] = list(A2aVersion.SUPPORTED) + supportedOperations: list[str] = [] + + @field_validator("name") + @classmethod + def _name_non_empty(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("name must not be empty") + return value + + @field_validator("version") + @classmethod + def _version_non_empty(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("version must not be empty") + return value + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def build_agent_card( + *, + facade: A2aLocalFacade | None = None, + host: str = "0.0.0.0", + port: int = 8080, +) -> AgentCard: + """Build an :class:`AgentCard` from a facade's supported operations. + + Parameters + ---------- + facade: + The facade to introspect for supported operations. When *None* + a minimal card with no skills is returned. + host: + Server bind address used to populate the card URL. + port: + Server bind port used to populate the card URL. + + Returns + ------- + AgentCard + A fully populated Agent Card. + """ + if not isinstance(host, str) or not host: + raise ValueError("host must be a non-empty string") + if not isinstance(port, int) or port < 1 or port > 65535: + raise ValueError("port must be an integer between 1 and 65535") + + base_url = f"http://{host}:{port}/a2a" + + operations: list[str] = [] + if facade is not None: + operations = facade.list_operations() + + # Build skills from operations that match known prefixes + skills: list[AgentCardSkill] = [] + for defn in _SKILL_DEFINITIONS: + prefix = defn["prefix"] + matching = [op for op in operations if op.startswith(prefix)] + if matching: + skills.append( + AgentCardSkill( + id=defn["id"], + name=defn["name"], + description=defn["description"], + ) + ) + + # Also include diagnostics operations in health skill + diag_ops = [op for op in operations if op.startswith("_cleveragents/diagnostics/")] + if diag_ops and not any(s.id == "health-diagnostics" for s in skills): + skills.append( + AgentCardSkill( + id="health-diagnostics", + name="Health and Diagnostics", + description="Health checks and diagnostic reporting", + ) + ) + + # Filter to extension operations only for the supportedOperations list + extension_ops = [op for op in operations if op.startswith("_cleveragents/")] + + extensions: list[AgentCardExtension] = [] + if extension_ops: + extensions.append( + AgentCardExtension( + uri="urn:cleveragents:extensions:v1", + description="CleverAgents platform operations", + ) + ) + + return AgentCard( + name="CleverAgents", + description="AI-powered development assistant (actor-first)", + url=base_url, + version=A2aVersion.CURRENT, + capabilities=AgentCardCapabilities( + streaming=False, + pushNotifications=False, + ), + skills=skills, + extensions=extensions, + securitySchemes=[ + AgentCardSecurityScheme(type="http", scheme="bearer"), + ], + interfaces=[ + AgentCardInterface(protocol="jsonrpc", url=base_url), + ], + defaultInputModes=["text"], + defaultOutputModes=["text"], + supportedVersions=list(A2aVersion.SUPPORTED), + supportedOperations=sorted(extension_ops), + ) + + +# --------------------------------------------------------------------------- +# Conformance validation +# --------------------------------------------------------------------------- + + +def validate_agent_card_conformance(card: AgentCard) -> list[str]: + """Validate that *card* conforms to A2A specification requirements. + + Returns a list of violation descriptions. An empty list means the + card is conformant. + + Parameters + ---------- + card: + The Agent Card to validate. + + Returns + ------- + list[str] + Zero or more violation messages. + """ + if not isinstance(card, AgentCard): + raise TypeError("card must be an AgentCard instance") + + violations: list[str] = [] + + if not card.name or not card.name.strip(): + violations.append("name is required and must not be empty") + + if not card.version or not card.version.strip(): + violations.append("version is required and must not be empty") + + if not card.url or not card.url.strip(): + violations.append("url is required and must not be empty") + + if card.version not in A2aVersion.SUPPORTED: + violations.append( + f"version '{card.version}' is not in supported versions " + f"{list(A2aVersion.SUPPORTED)}" + ) + + if not card.supportedVersions: + violations.append("supportedVersions must not be empty") + + if not card.defaultInputModes: + violations.append("defaultInputModes must not be empty") + + if not card.defaultOutputModes: + violations.append("defaultOutputModes must not be empty") + + # Validate each skill has required fields + for idx, skill in enumerate(card.skills): + if not skill.id or not skill.id.strip(): + violations.append(f"skills[{idx}].id must not be empty") + if not skill.name or not skill.name.strip(): + violations.append(f"skills[{idx}].name must not be empty") + + # Validate each extension has a URI + for idx, ext in enumerate(card.extensions): + if not ext.uri or not ext.uri.strip(): + violations.append(f"extensions[{idx}].uri must not be empty") + + return violations + + +def agent_card_to_dict(card: AgentCard) -> dict[str, Any]: + """Serialize an :class:`AgentCard` to a JSON-compatible dict. + + Parameters + ---------- + card: + The Agent Card to serialize. + + Returns + ------- + dict[str, Any] + A dictionary suitable for JSON serialization. + """ + if not isinstance(card, AgentCard): + raise TypeError("card must be an AgentCard instance") + return card.model_dump() + + +__all__ = [ + "AgentCard", + "AgentCardCapabilities", + "AgentCardExtension", + "AgentCardInterface", + "AgentCardSecurityScheme", + "AgentCardSkill", + "agent_card_to_dict", + "build_agent_card", + "validate_agent_card_conformance", +] diff --git a/src/cleveragents/infrastructure/server/asgi_app.py b/src/cleveragents/infrastructure/server/asgi_app.py index fe56c9960..f3fa7b542 100644 --- a/src/cleveragents/infrastructure/server/asgi_app.py +++ b/src/cleveragents/infrastructure/server/asgi_app.py @@ -20,37 +20,17 @@ import structlog from fastapi import FastAPI, Request from fastapi.responses import JSONResponse +from cleveragents.a2a.agent_card import ( + agent_card_to_dict, + build_agent_card, + validate_agent_card_conformance, +) from cleveragents.a2a.errors import A2aOperationNotFoundError from cleveragents.a2a.facade import A2aLocalFacade from cleveragents.a2a.models import A2aRequest, A2aVersion logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) -# ------------------------------------------------------------------ -# Agent Card (served at /.well-known/agent.json per A2A spec) -# ------------------------------------------------------------------ - -_AGENT_CARD: dict[str, Any] = { - "name": "CleverAgents", - "description": "AI-powered development assistant (actor-first)", - "url": "", - "version": A2aVersion.CURRENT, - "capabilities": { - "streaming": False, - "pushNotifications": False, - }, - "skills": [], - "defaultInputModes": ["text"], - "defaultOutputModes": ["text"], -} - - -def _build_agent_card(host: str, port: int) -> dict[str, Any]: - """Return the Agent Card with the server URL populated.""" - card = dict(_AGENT_CARD) - card["url"] = f"http://{host}:{port}/a2a" - return card - # ------------------------------------------------------------------ # Application factory @@ -88,7 +68,23 @@ def create_asgi_app( raise ValueError("port must be an integer between 1 and 65535") resolved_facade = facade if facade is not None else A2aLocalFacade() - agent_card = _build_agent_card(host, port) + + # Build the Agent Card from the facade's operations + agent_card = build_agent_card( + facade=resolved_facade, + host=host, + port=port, + ) + + # Validate conformance at startup + violations = validate_agent_card_conformance(agent_card) + if violations: + logger.warning( + "a2a.server.agent_card_conformance_violations", + violations=violations, + ) + + agent_card_dict = agent_card_to_dict(agent_card) application = FastAPI( title="CleverAgents A2A Server", @@ -104,7 +100,7 @@ def create_asgi_app( @application.get("/.well-known/agent.json") async def agent_card_endpoint() -> JSONResponse: """Serve the A2A Agent Card for capability discovery.""" - return JSONResponse(content=agent_card) + return JSONResponse(content=agent_card_dict) @application.get("/health") async def health_endpoint() -> JSONResponse: