feat(server): implement Agent Card endpoint with correct registry metadata #9600

Closed
HAL9000 wants to merge 1 commits from feat/v3.8.0-agent-card-endpoint into master
5 changed files with 553 additions and 1 deletions
+91
View File
@@ -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"
+190
View File
@@ -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__ = []
+59
View File
@@ -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)
+194
View File
@@ -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",
)
+19 -1
View File
@@ -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(