From 075ec9f4059d4f807d640a8e8b7936f31179b9d9 Mon Sep 17 00:00:00 2001 From: HAL9000 Date: Wed, 15 Apr 2026 00:19:34 +0000 Subject: [PATCH] feat(server): implement Agent Card endpoint with correct registry metadata Implemented a new Agent Card endpoint and supporting models and tests. - Added agent_card.py with Pydantic schemas: - AgentCard - AgentCapability - ExtensionMethod - ProviderInfo - RegistryMetadata - Updated asgi.py: - Expose the /.well-known/agent.json endpoint - Return a complete Agent Card JSON document with server metadata - Enforce security headers (X-Content-Type-Options, Cache-Control) - Created comprehensive BDD tests (a2a_agent_card_endpoint.feature): - Valid JSON response - Server identification (name, version, description) - Provider information - Server capabilities (4 capabilities) - A2A extension methods (4 methods) - Registry metadata (namespaces and resource types) - A2A protocol version - Security headers - HTTP method restrictions (405 for non-GET) - Created step definitions (a2a_agent_card_steps.py) for all scenarios - Updated existing ASGI app steps to support JSON parsing and additional HTTP methods (PUT, DELETE) All code follows CONTRIBUTING.md requirements: - Full static typing with no type: ignore comments - BDD-only testing approach (Behave/Gherkin) - Proper code organization in src/cleveragents/a2a/ - Tests in features/ directory - Linting and type checking pass ISSUES CLOSED: #9539 --- features/a2a_agent_card_endpoint.feature | 91 +++++++++++ features/steps/a2a_agent_card_steps.py | 190 ++++++++++++++++++++++ features/steps/asgi_app_steps.py | 59 +++++++ src/cleveragents/a2a/agent_card.py | 194 +++++++++++++++++++++++ src/cleveragents/a2a/asgi.py | 20 ++- 5 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 features/a2a_agent_card_endpoint.feature create mode 100644 features/steps/a2a_agent_card_steps.py create mode 100644 src/cleveragents/a2a/agent_card.py diff --git a/features/a2a_agent_card_endpoint.feature b/features/a2a_agent_card_endpoint.feature new file mode 100644 index 000000000..eafa66aaa --- /dev/null +++ b/features/a2a_agent_card_endpoint.feature @@ -0,0 +1,91 @@ +Feature: Agent Card endpoint for A2A protocol compliance + As an A2A-compliant client + I want to discover server capabilities and metadata + So that I can auto-configure myself against the CleverAgents server + + Scenario: Agent Card endpoint returns valid JSON + Given the ASGI app module is loaded + When I send an HTTP GET request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 200 + And the HTTP response content-type should be "application/json" + + Scenario: Agent Card includes server identification + Given the ASGI app module is loaded + When I send an HTTP GET request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 200 + And the Agent Card should have name "CleverAgents" + And the Agent Card should have version "1.0.0" + And the Agent Card should have description containing "AI-powered" + + Scenario: Agent Card includes provider information + Given the ASGI app module is loaded + When I send an HTTP GET request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 200 + And the Agent Card provider should have name "CleverThis Inc." + And the Agent Card provider should have email "support@cleverthis.com" + And the Agent Card provider should have url "https://cleverthis.com" + + Scenario: Agent Card includes server capabilities + Given the ASGI app module is loaded + When I send an HTTP GET request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 200 + And the Agent Card should have at least 4 capabilities + And the Agent Card should include capability "actor_management" + And the Agent Card should include capability "skill_management" + And the Agent Card should include capability "action_management" + And the Agent Card should include capability "project_management" + + Scenario: Agent Card includes A2A extension methods + Given the ASGI app module is loaded + When I send an HTTP GET request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 200 + And the Agent Card should have at least 4 extension methods + And the Agent Card should include extension method "_cleveragents/actor/list" + And the Agent Card should include extension method "_cleveragents/skill/list" + And the Agent Card should include extension method "_cleveragents/action/list" + And the Agent Card should include extension method "_cleveragents/project/list" + + Scenario: Agent Card includes registry metadata + Given the ASGI app module is loaded + When I send an HTTP GET request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 200 + And the Agent Card registry should have at least 2 namespaces + And the Agent Card registry should include namespace "default" + And the Agent Card registry should include namespace "system" + And the Agent Card registry should have at least 6 resource types + And the Agent Card registry should include resource type "actor" + And the Agent Card registry should include resource type "skill" + And the Agent Card registry should include resource type "action" + And the Agent Card registry should include resource type "project" + + Scenario: Agent Card includes A2A protocol version + Given the ASGI app module is loaded + When I send an HTTP GET request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 200 + And the Agent Card should have a2a_version "2.0" + + Scenario: Agent Card endpoint returns proper security headers + Given the ASGI app module is loaded + When I send an HTTP GET request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 200 + And the HTTP response should include a content-length header + And the HTTP response should include header "x-content-type-options" with value "nosniff" + And the HTTP response should include header "cache-control" with value "no-store" + + Scenario: Agent Card endpoint is not accessible via POST + Given the ASGI app module is loaded + When I send an HTTP POST request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 405 + And the HTTP response should include an Allow header with value "GET" + + Scenario: Agent Card endpoint is not accessible via PUT + Given the ASGI app module is loaded + When I send an HTTP PUT request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 405 + And the HTTP response should include an Allow header with value "GET" + + Scenario: Agent Card endpoint is not accessible via DELETE + Given the ASGI app module is loaded + When I send an HTTP DELETE request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 405 + And the HTTP response should include an Allow header with value "GET" diff --git a/features/steps/a2a_agent_card_steps.py b/features/steps/a2a_agent_card_steps.py new file mode 100644 index 000000000..ceb972d1c --- /dev/null +++ b/features/steps/a2a_agent_card_steps.py @@ -0,0 +1,190 @@ +"""Step definitions for Agent Card endpoint scenarios.""" + +from __future__ import annotations + +import json +from typing import Any + +from behave import then +from behave.runner import Context + +SendMessage = dict[str, Any] + + +def _parse_response(context: Context) -> None: + """Parse the HTTP response and extract body as JSON if applicable.""" + start = next( + msg + for msg in context.asgi_sent_messages + if msg.get("type") == "http.response.start" + ) + body_msg = next( + msg + for msg in context.asgi_sent_messages + if msg.get("type") == "http.response.body" + ) + + context.http_status = start.get("status") + context.http_headers = start.get("headers", []) + context.http_body_bytes = body_msg.get("body", b"") + + # Try to parse as JSON + try: + context.http_body_json = json.loads(context.http_body_bytes.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + context.http_body_json = None + + +@then('the HTTP response content-type should be "{content_type}"') +def step_http_content_type(context: Context, content_type: str) -> None: + headers: list[tuple[bytes, bytes]] = context.http_headers + content_types = [ + v.decode("utf-8") for k, v in headers if k.lower() == b"content-type" + ] + assert content_types, f"Expected content-type header, but none found in: {headers}" + assert content_type in content_types, ( + f"Expected content-type {content_type!r}, got {content_types!r}" + ) + + +@then('the Agent Card should have name "{name}"') +def step_agent_card_name(context: Context, name: str) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + assert context.http_body_json.get("name") == name, ( + f"Expected Agent Card name {name!r}, got {context.http_body_json.get('name')!r}" + ) + + +@then('the Agent Card should have version "{version}"') +def step_agent_card_version(context: Context, version: str) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + assert context.http_body_json.get("version") == version, ( + f"Expected Agent Card version {version!r}, got {context.http_body_json.get('version')!r}" + ) + + +@then('the Agent Card should have description containing "{text}"') +def step_agent_card_description(context: Context, text: str) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + description = context.http_body_json.get("description", "") + assert text in description, ( + f"Expected description to contain {text!r}, got {description!r}" + ) + + +@then('the Agent Card provider should have name "{name}"') +def step_agent_card_provider_name(context: Context, name: str) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + provider = context.http_body_json.get("provider", {}) + assert provider.get("name") == name, ( + f"Expected provider name {name!r}, got {provider.get('name')!r}" + ) + + +@then('the Agent Card provider should have email "{email}"') +def step_agent_card_provider_email(context: Context, email: str) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + provider = context.http_body_json.get("provider", {}) + assert provider.get("email") == email, ( + f"Expected provider email {email!r}, got {provider.get('email')!r}" + ) + + +@then('the Agent Card provider should have url "{url}"') +def step_agent_card_provider_url(context: Context, url: str) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + provider = context.http_body_json.get("provider", {}) + assert provider.get("url") == url, ( + f"Expected provider url {url!r}, got {provider.get('url')!r}" + ) + + +@then("the Agent Card should have at least {count:d} capabilities") +def step_agent_card_capabilities_count(context: Context, count: int) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + capabilities = context.http_body_json.get("capabilities", []) + assert len(capabilities) >= count, ( + f"Expected at least {count} capabilities, got {len(capabilities)}" + ) + + +@then('the Agent Card should include capability "{capability}"') +def step_agent_card_includes_capability(context: Context, capability: str) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + capabilities = context.http_body_json.get("capabilities", []) + capability_names = [c.get("name") for c in capabilities] + assert capability in capability_names, ( + f"Expected capability {capability!r}, got {capability_names!r}" + ) + + +@then("the Agent Card should have at least {count:d} extension methods") +def step_agent_card_extension_methods_count(context: Context, count: int) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + methods = context.http_body_json.get("extension_methods", []) + assert len(methods) >= count, ( + f"Expected at least {count} extension methods, got {len(methods)}" + ) + + +@then('the Agent Card should include extension method "{method}"') +def step_agent_card_includes_extension_method(context: Context, method: str) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + methods = context.http_body_json.get("extension_methods", []) + method_names = [m.get("method") for m in methods] + assert method in method_names, ( + f"Expected extension method {method!r}, got {method_names!r}" + ) + + +@then("the Agent Card registry should have at least {count:d} namespaces") +def step_agent_card_registry_namespaces_count(context: Context, count: int) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + registry = context.http_body_json.get("registry", {}) + namespaces = registry.get("namespaces", []) + assert len(namespaces) >= count, ( + f"Expected at least {count} namespaces, got {len(namespaces)}" + ) + + +@then('the Agent Card registry should include namespace "{namespace}"') +def step_agent_card_registry_includes_namespace(context: Context, namespace: str) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + registry = context.http_body_json.get("registry", {}) + namespaces = registry.get("namespaces", []) + assert namespace in namespaces, ( + f"Expected namespace {namespace!r}, got {namespaces!r}" + ) + + +@then("the Agent Card registry should have at least {count:d} resource types") +def step_agent_card_registry_resource_types_count(context: Context, count: int) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + registry = context.http_body_json.get("registry", {}) + resource_types = registry.get("resource_types", []) + assert len(resource_types) >= count, ( + f"Expected at least {count} resource types, got {len(resource_types)}" + ) + + +@then('the Agent Card registry should include resource type "{resource_type}"') +def step_agent_card_registry_includes_resource_type( + context: Context, resource_type: str +) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + registry = context.http_body_json.get("registry", {}) + resource_types = registry.get("resource_types", []) + assert resource_type in resource_types, ( + f"Expected resource type {resource_type!r}, got {resource_types!r}" + ) + + +@then('the Agent Card should have a2a_version "{version}"') +def step_agent_card_a2a_version(context: Context, version: str) -> None: + assert context.http_body_json is not None, "Response body is not valid JSON" + assert context.http_body_json.get("a2a_version") == version, ( + f"Expected a2a_version {version!r}, got {context.http_body_json.get('a2a_version')!r}" + ) + + +__all__ = [] diff --git a/features/steps/asgi_app_steps.py b/features/steps/asgi_app_steps.py index f7bc3f3de..aba67f7ae 100644 --- a/features/steps/asgi_app_steps.py +++ b/features/steps/asgi_app_steps.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import logging from collections import deque from typing import Any @@ -33,6 +34,7 @@ def step_send_http_request(context: Context, path: str) -> None: scope = {"type": "http", "method": "GET", "path": path} asyncio.run(context.asgi_app(scope, receive, send)) context.asgi_sent_messages = sent_messages + _parse_response(context) @when('I send an HTTP POST request to "{path}" through the ASGI app') @@ -48,6 +50,31 @@ def step_send_http_post_request(context: Context, path: str) -> None: scope = {"type": "http", "method": "POST", "path": path} asyncio.run(context.asgi_app(scope, receive, send)) context.asgi_sent_messages = sent_messages + _parse_response(context) + + +def _parse_response(context: Context) -> None: + """Parse the HTTP response and extract body as JSON if applicable.""" + start = next( + msg + for msg in context.asgi_sent_messages + if msg.get("type") == "http.response.start" + ) + body_msg = next( + msg + for msg in context.asgi_sent_messages + if msg.get("type") == "http.response.body" + ) + + context.http_status = start.get("status") + context.http_headers = start.get("headers", []) + context.http_body_bytes = body_msg.get("body", b"") + + # Try to parse as JSON + try: + context.http_body_json = json.loads(context.http_body_bytes.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + context.http_body_json = None @then("the HTTP response status should be {status:d}") @@ -281,3 +308,35 @@ class _capture_log_records: def __exit__(self, *_args: object) -> None: if self._handler is not None: self._logger.removeHandler(self._handler) + + +@when('I send an HTTP PUT request to "{path}" through the ASGI app') +def step_send_http_put_request(context: Context, path: str) -> None: + sent_messages: list[SendMessage] = [] + + async def receive() -> dict[str, Any]: + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: SendMessage) -> None: + sent_messages.append(message) + + scope = {"type": "http", "method": "PUT", "path": path} + asyncio.run(context.asgi_app(scope, receive, send)) + context.asgi_sent_messages = sent_messages + _parse_response(context) + + +@when('I send an HTTP DELETE request to "{path}" through the ASGI app') +def step_send_http_delete_request(context: Context, path: str) -> None: + sent_messages: list[SendMessage] = [] + + async def receive() -> dict[str, Any]: + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: SendMessage) -> None: + sent_messages.append(message) + + scope = {"type": "http", "method": "DELETE", "path": path} + asyncio.run(context.asgi_app(scope, receive, send)) + context.asgi_sent_messages = sent_messages + _parse_response(context) diff --git a/src/cleveragents/a2a/agent_card.py b/src/cleveragents/a2a/agent_card.py new file mode 100644 index 000000000..085ef60fc --- /dev/null +++ b/src/cleveragents/a2a/agent_card.py @@ -0,0 +1,194 @@ +"""Agent Card model for A2A protocol compliance. + +The Agent Card is a standard A2A protocol document that describes the +capabilities and metadata of a CleverAgents server. It is served at the +well-known endpoint `/.well-known/agent.json` and allows A2A-compliant +clients to auto-discover and configure themselves against the server. + +Reference: A2A Protocol Specification - Agent Card +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from cleveragents import __version__ + + +class AgentCapability(BaseModel): + """Describes a single capability of the agent.""" + + model_config = ConfigDict(strict=False) + + name: str = Field(..., description="Name of the capability") + description: str = Field( + default="", description="Human-readable description of the capability" + ) + version: str = Field(default="1.0.0", description="Version of the capability") + + +class ExtensionMethod(BaseModel): + """Describes a supported A2A extension method.""" + + model_config = ConfigDict(strict=False) + + method: str = Field( + ..., description="Full method name (e.g., _cleveragents/actor/list)" + ) + description: str = Field( + default="", description="Human-readable description of the method" + ) + params: dict[str, Any] = Field( + default_factory=dict, description="Expected parameters for the method" + ) + + +class RegistryMetadata(BaseModel): + """Describes registry metadata available on the server.""" + + model_config = ConfigDict(strict=False) + + namespaces: list[str] = Field( + default_factory=list, description="Available namespaces in the registry" + ) + resource_types: list[str] = Field( + default_factory=list, description="Available resource types in the registry" + ) + + +class ProviderInfo(BaseModel): + """Information about the provider/organization running the server.""" + + model_config = ConfigDict(strict=False) + + name: str = Field( + default="CleverThis Inc.", description="Provider organization name" + ) + email: str = Field( + default="support@cleverthis.com", description="Provider contact email" + ) + url: str = Field( + default="https://cleverthis.com", description="Provider website URL" + ) + + +class AgentCard(BaseModel): + """Complete Agent Card document for A2A protocol compliance. + + This model represents the complete metadata about a CleverAgents server + that is served at `/.well-known/agent.json`. It includes server + capabilities, supported A2A methods, registry metadata, and provider info. + """ + + model_config = ConfigDict(strict=False) + + # Server identification + name: str = Field(default="CleverAgents", description="Name of the agent/server") + version: str = Field( + default=__version__, description="Version of the CleverAgents server" + ) + description: str = Field( + default="AI-powered development assistant with A2A protocol support", + description="Human-readable description of the server", + ) + + # Provider information + provider: ProviderInfo = Field( + default_factory=ProviderInfo, description="Information about the provider" + ) + + # Capabilities + capabilities: list[AgentCapability] = Field( + default_factory=list, description="List of server capabilities" + ) + + # A2A extension methods + extension_methods: list[ExtensionMethod] = Field( + default_factory=list, description="Supported A2A extension methods" + ) + + # Registry metadata + registry: RegistryMetadata = Field( + default_factory=RegistryMetadata, description="Registry metadata" + ) + + # Protocol version + a2a_version: str = Field( + default="2.0", description="A2A protocol version supported" + ) + + +def create_default_agent_card() -> AgentCard: + """Create a default Agent Card with standard CleverAgents capabilities. + + Returns: + AgentCard: A fully populated Agent Card with default values. + """ + return AgentCard( + name="CleverAgents", + version=__version__, + description="AI-powered development assistant with A2A protocol support", + provider=ProviderInfo( + name="CleverThis Inc.", + email="support@cleverthis.com", + url="https://cleverthis.com", + ), + capabilities=[ + AgentCapability( + name="actor_management", + description="Manage and execute actors", + version="1.0.0", + ), + AgentCapability( + name="skill_management", + description="Manage and execute skills", + version="1.0.0", + ), + AgentCapability( + name="action_management", + description="Manage and execute actions", + version="1.0.0", + ), + AgentCapability( + name="project_management", + description="Manage projects and workspaces", + version="1.0.0", + ), + ], + extension_methods=[ + ExtensionMethod( + method="_cleveragents/actor/list", + description="List all available actors", + params={}, + ), + ExtensionMethod( + method="_cleveragents/skill/list", + description="List all available skills", + params={}, + ), + ExtensionMethod( + method="_cleveragents/action/list", + description="List all available actions", + params={}, + ), + ExtensionMethod( + method="_cleveragents/project/list", + description="List all available projects", + params={}, + ), + ], + registry=RegistryMetadata( + namespaces=["default", "system"], + resource_types=[ + "actor", + "skill", + "action", + "project", + "plan", + "resource", + ], + ), + a2a_version="2.0", + ) diff --git a/src/cleveragents/a2a/asgi.py b/src/cleveragents/a2a/asgi.py index 9bd501502..7202ff41c 100644 --- a/src/cleveragents/a2a/asgi.py +++ b/src/cleveragents/a2a/asgi.py @@ -11,9 +11,12 @@ for container and Kubernetes deployments. from __future__ import annotations +import json import logging from collections.abc import Awaitable, Callable +from cleveragents.a2a.agent_card import create_default_agent_card + _logger: logging.Logger = logging.getLogger(__name__) Headers = list[tuple[bytes, bytes]] @@ -21,7 +24,15 @@ SendCallable = Callable[[dict[str, object]], Awaitable[None]] # Paths recognised by this ASGI app. Used to distinguish 404 (unknown # path) from 405 (known path, wrong method) per RFC 9110 S15.5.6. -_KNOWN_PATHS: frozenset[str] = frozenset({"/", "/live", "/ready", "/health"}) +_KNOWN_PATHS: frozenset[str] = frozenset( + { + "/", + "/live", + "/ready", + "/health", + "/.well-known/agent.json", + } +) async def _send_response( @@ -68,6 +79,7 @@ async def app( - ``GET /ready`` -> ``200`` with ``{"status":"ready"}`` - ``GET /health`` -> ``200`` with ``{"status":"ok"}`` (compat alias) - ``GET /`` -> ``200`` with ``{"service":"cleveragents"}`` + - ``GET /.well-known/agent.json`` -> ``200`` with Agent Card JSON - known path, wrong method -> ``405`` - unknown path -> ``404`` """ @@ -121,6 +133,12 @@ async def app( await _send_response(send, status=200, body=b'{"service":"cleveragents"}') return + if method == "GET" and path == "/.well-known/agent.json": + agent_card = create_default_agent_card() + body = json.dumps(agent_card.model_dump(), indent=2).encode("utf-8") + await _send_response(send, status=200, body=body) + return + # Known path with wrong method -> 405 Method Not Allowed (RFC 9110 S15.5.6) if path in _KNOWN_PATHS: await _send_response( -- 2.52.0