feat(a2a): Agent Card discovery endpoint #1123

Merged
HAL9000 merged 11 commits from feature/m9-agent-card into master 2026-06-12 04:12:49 +00:00
12 changed files with 1437 additions and 91 deletions
+229
View File
@@ -0,0 +1,229 @@
@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"
Scenario: Agent Card enumerates entity sync 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 "entity-sync"
Scenario: Agent Card enumerates namespace management 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 "namespace-mgmt"
+24 -5
View File
@@ -58,17 +58,31 @@ Feature: ASGI Server Lifecycle
Given a running ASGI test client
When I POST a JSON-RPC request to /a2a with operation "_cleveragents/health/check"
Then the response status code should be 200
And the A2A response status should be "healthy"
And the A2A response result status should be "healthy"
Scenario: A2A endpoint returns error for unknown operation
Scenario: A2A endpoint returns HTTP 200 with JSON-RPC error for unknown operation
Given a running ASGI test client
When I POST a JSON-RPC request to /a2a with operation "nonexistent.operation"
Then the response status code should be 404
Then the response status code should be 200
And the response body should contain "-32601"
Scenario: A2A endpoint returns 400 for malformed request
Scenario: A2A endpoint returns HTTP 200 with JSON-RPC error for malformed request
Given a running ASGI test client
When I POST a malformed JSON body to /a2a
Then the response status code should be 400
Then the response status code should be 200
And the response body should contain "-32600"
Scenario: A2A endpoint returns -32700 parse error for invalid JSON bytes
Given a running ASGI test client
When I POST invalid JSON bytes to /a2a
Then the response status code should be 200
And the response body should contain "-32700"
Scenario: A2A endpoint returns -32603 internal error when dispatch raises
Given a running ASGI test client with a failing facade
When I POST a JSON-RPC request to /a2a with operation "_cleveragents/health/check"
Then the response status code should be 200
And the response body should contain "-32603"
# -----------------------------------------------------------------------
# ServerLifecycle construction
@@ -143,6 +157,11 @@ Feature: ASGI Server Lifecycle
And I invoke the installed SIGTERM handler
Then the mocked server should_exit should be true
Scenario: ServerLifecycle request_shutdown is a no-op when server not started
Given a fresh ServerLifecycle
When I call request_shutdown on the lifecycle
Then the lifecycle should not be started
# -----------------------------------------------------------------------
# run_server convenience function
# -----------------------------------------------------------------------
+442
View File
@@ -0,0 +1,442 @@
"""Step definitions for ``agent_card.feature``.
Tests Agent Card model construction, conformance validation,
serialization, discovery endpoint, and skill enumeration.
"""
from __future__ import annotations
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context
from fastapi.testclient import TestClient
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
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given("an A2aLocalFacade for agent card testing")
def step_given_facade_for_card(context: Context) -> None:
context.facade = A2aLocalFacade()
@given("an agent card with empty url")
def step_given_card_empty_url(context: Context) -> None:
context.agent_card = AgentCard(
name="Test",
version="1.0",
url="",
)
@given('an agent card with version "{version}"')
def step_given_card_version(context: Context, 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> 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: Context, 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> None:
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: Context) -> 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: Context, 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: Context, 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: Context) -> 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: Context, 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: Context, 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: Context, 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: Context, 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: Context, 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: Context, 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: Context, 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: Context) -> 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: Context, 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: Context, 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> 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: Context) -> None:
assert context.violations == [], (
f"Expected no violations, got: {context.violations}"
)
@then('the conformance violations should include "{text}"')
def step_check_violation_text(context: Context, 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: Context, 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: Context, 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: Context, 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: Context, 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: Context) -> 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: Context, 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: Context) -> 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: Context) -> None:
context.agent_card = AgentCard(
name="Test",
version="1.0",
url="http://localhost/a2a",
defaultOutputModes=[],
)
+50 -31
View File
@@ -8,10 +8,13 @@ and Settings-based server configuration.
from __future__ import annotations
import contextlib
import signal
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from behave import given, then, use_step_matcher, when # type: ignore[import-untyped]
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context
from fastapi.testclient import TestClient
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.config.settings import Settings
@@ -77,10 +80,7 @@ def step_check_value_error_port(context: Any) -> None:
assert isinstance(context.caught_exception, ValueError)
use_step_matcher("re")
@when('I try to create an ASGI application with host "(?P<host>[^"]*)"')
@when('I try to create an ASGI application with host "{host}"')
def step_create_asgi_bad_host(context: Any, host: str) -> None:
context.caught_exception = None
try:
@@ -98,9 +98,6 @@ def step_create_asgi_empty_host(context: Any) -> None:
context.caught_exception = exc
use_step_matcher("parse")
@then("a ValueError should be raised for invalid host")
def step_check_value_error_host(context: Any) -> None:
assert context.caught_exception is not None, "Expected ValueError"
@@ -114,8 +111,6 @@ def step_check_value_error_host(context: Any) -> None:
@given("a running ASGI test client")
def step_given_test_client(context: Any) -> None:
from fastapi.testclient import TestClient
app = create_asgi_app()
context.test_client = TestClient(app)
@@ -132,19 +127,36 @@ def step_get_agent_card(context: Any) -> None:
@when('I POST a JSON-RPC request to /a2a with operation "{operation}"')
def step_post_a2a(context: Any, operation: str) -> None:
# JSON-RPC 2.0 wire format: use "method" field
payload = {"jsonrpc": "2.0", "method": operation, "params": {}}
context.response = context.test_client.post("/a2a", json=payload)
@when("I POST a malformed JSON body to /a2a")
def step_post_a2a_malformed(context: Any) -> None:
# Send a body that cannot be parsed into A2aRequest (missing method)
context.response = context.test_client.post("/a2a", json={"bad": "data"})
@when("I POST invalid JSON bytes to /a2a")
def step_post_a2a_invalid_bytes(context: Context) -> None:
# Send raw bytes that cannot be parsed as JSON to trigger the -32700 Parse error path
context.response = context.test_client.post(
"/a2a",
content=b"not-valid-json",
content=b"not valid json {{{",
headers={"Content-Type": "application/json"},
)
@given("a running ASGI test client with a failing facade")
def step_given_failing_facade_client(context: Context) -> None:
# Monkey-patch dispatch on a real facade instance to raise an unexpected error
facade = A2aLocalFacade()
facade.dispatch = MagicMock(side_effect=RuntimeError("unexpected!"))
app = create_asgi_app(facade=facade)
context.test_client = TestClient(app)
@then("the response status code should be {code:d}")
def step_check_status_code(context: Any, code: int) -> None:
assert context.response.status_code == code, (
@@ -158,8 +170,8 @@ def step_check_response_body(context: Any, text: str) -> None:
assert text in body, f"Expected '{text}' in response body: {body}"
@then('the A2A response status should be "{expected_status}"')
def step_check_a2a_status(context: Any, expected_status: str) -> None:
@then('the A2A response result status should be "{expected_status}"')
def step_check_a2a_result_status(context: Any, expected_status: str) -> None:
data = context.response.json()
result = data.get("result") or {}
assert result.get("status") == expected_status, (
@@ -197,10 +209,7 @@ def step_check_not_stopped(context: Any) -> None:
assert not context.lifecycle.is_stopped
use_step_matcher("re")
@when('I try to create a ServerLifecycle with host "(?P<host>[^"]*)"')
@when('I try to create a ServerLifecycle with host "{host}"')
def step_create_lifecycle_bad_host(context: Any, host: str) -> None:
context.caught_exception = None
try:
@@ -218,9 +227,6 @@ def step_create_lifecycle_empty_host(context: Any) -> None:
context.caught_exception = exc
use_step_matcher("parse")
@when("I try to create a ServerLifecycle with port {port:d}")
def step_create_lifecycle_bad_port(context: Any, port: int) -> None:
context.caught_exception = None
@@ -267,6 +273,29 @@ def step_check_settings_port(context: Any, expected: int) -> None:
# ---------------------------------------------------------------------------
@given("a fresh ServerLifecycle")
def step_fresh_lifecycle(context: Context) -> None:
context.lifecycle = ServerLifecycle(host="127.0.0.1", port=9990)
@when("I start the lifecycle with mocked uvicorn")
def step_start_lifecycle_mocked(context: Context) -> None:
original_sigterm = signal.getsignal(signal.SIGTERM)
original_sigint = signal.getsignal(signal.SIGINT)
try:
server_patch = patch(
"cleveragents.infrastructure.server.server_lifecycle.uvicorn.Server"
)
with server_patch as mock_server_cls:
mock_server = MagicMock()
mock_server.run.return_value = None
mock_server_cls.return_value = mock_server
context.lifecycle.start()
finally:
signal.signal(signal.SIGTERM, original_sigterm)
signal.signal(signal.SIGINT, original_sigint)
@given("a ServerLifecycle with a mock uvicorn server")
def step_lifecycle_with_mock_server(context: Any) -> None:
lifecycle = ServerLifecycle(host="127.0.0.1", port=9999)
@@ -316,8 +345,6 @@ def step_check_runtime_error(context: Any) -> None:
@given("a ServerLifecycle with mocked uvicorn server run")
def step_lifecycle_mocked_uvicorn(context: Any) -> None:
from unittest.mock import patch
lifecycle = ServerLifecycle(host="127.0.0.1", port=9997)
# Patch uvicorn.Server so .run() is a no-op and Config is a mock
mock_server_instance = MagicMock()
@@ -372,8 +399,6 @@ def step_check_uvicorn_run_called(context: Any) -> None:
@given("a ServerLifecycle with mocked uvicorn and signal handlers")
def step_lifecycle_mocked_with_signals(context: Any) -> None:
from unittest.mock import patch
lifecycle = ServerLifecycle(host="127.0.0.1", port=9996)
mock_server_instance = MagicMock()
mock_server_instance.run = MagicMock()
@@ -428,8 +453,6 @@ def step_check_mock_exit_true(context: Any) -> None:
@when("I call run_server with mocked ServerLifecycle")
def step_run_server_mocked(context: Any) -> None:
from unittest.mock import patch
mock_lifecycle_cls = MagicMock()
mock_lifecycle_instance = MagicMock()
mock_lifecycle_cls.return_value = mock_lifecycle_instance
@@ -464,8 +487,6 @@ def step_check_start_called(context: Any) -> None:
@when('I call run_server with host "{host}" and port {port:d} using mocked lifecycle')
def step_run_server_with_overrides(context: Any, host: str, port: int) -> None:
from unittest.mock import patch
mock_lifecycle_cls = MagicMock()
mock_lifecycle_instance = MagicMock()
mock_lifecycle_cls.return_value = mock_lifecycle_instance
@@ -508,8 +529,6 @@ def step_install_signals_non_main(context: Any) -> None:
result = {}
def run_in_thread() -> None:
from unittest.mock import patch
mock_sig = MagicMock()
with patch(
"cleveragents.infrastructure.server.server_lifecycle.signal.signal",
+61
View File
@@ -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
+146
View File
@@ -0,0 +1,146 @@
#!/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
from collections.abc import Callable
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, Callable[[], None]] = {
"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()
if __name__ == "__main__":
main()
+12 -4
View File
@@ -9,6 +9,7 @@ sentinel.
from __future__ import annotations
import sys
from collections.abc import Callable
def _test_create_app() -> None:
@@ -59,11 +60,18 @@ def _test_a2a_dispatch() -> None:
app = create_asgi_app()
client = TestClient(app)
payload = {"jsonrpc": "2.0", "method": "_cleveragents/health/check", "params": {}}
# JSON-RPC 2.0 wire format: use "method" field
payload = {
"jsonrpc": "2.0",
"method": "_cleveragents/health/check",
"params": {},
}
resp = client.post("/a2a", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["result"]["status"] == "healthy"
# JSON-RPC 2.0 response: result is nested under "result"
result = data.get("result") or {}
assert result.get("status") == "healthy", f"Unexpected response: {data}"
print("a2a-dispatch-ok")
@@ -94,7 +102,7 @@ def _test_lifecycle_shutdown() -> None:
print("lifecycle-shutdown-ok")
_COMMANDS: dict[str, object] = {
_COMMANDS: dict[str, Callable[[], None]] = {
"create-app": _test_create_app,
"health-endpoint": _test_health_endpoint,
"agent-card": _test_agent_card,
@@ -110,7 +118,7 @@ def main() -> None:
sys.exit(2)
handler = _COMMANDS[sys.argv[1]]
handler() # type: ignore[operator]
handler()
if __name__ == "__main__":
+17
View File
@@ -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",
+384
View File
@@ -0,0 +1,384 @@
"""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")
# Per A2A spec: AgentCard.url is the base server URL (no path suffix).
# When host is 0.0.0.0 (bind-all), substitute 127.0.0.1 for the card URL
# since 0.0.0.0 is not a reachable address for clients.
card_host = "127.0.0.1" if host == "0.0.0.0" else host
server_url = f"http://{card_host}:{port}"
endpoint_url = f"http://{card_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
): # pragma: no cover
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=server_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=endpoint_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",
]
+6 -6
View File
@@ -26,15 +26,15 @@ JSONRPC_VERSION: str = "2.0"
class A2aVersion:
"""A2A protocol version constants.
"""CleverAgents A2A API version constants.
Kept for backward compatibility the wire format now uses the
JSON-RPC 2.0 ``jsonrpc`` field with value ``"2.0"`` rather than
a proprietary ``a2a_version`` field.
These identify the CleverAgents extension API version (used in the
Agent Card ``version`` field), distinct from the JSON-RPC protocol
version (``JSONRPC_VERSION = "2.0"``) used in request/response envelopes.
"""
CURRENT: str = JSONRPC_VERSION
SUPPORTED: tuple[str, ...] = (JSONRPC_VERSION,)
CURRENT: str = "1.0"
SUPPORTED: tuple[str, ...] = ("1.0",)
# ---------------------------------------------------------------------------
@@ -5,52 +5,31 @@ endpoint (ADR-048). This module builds the FastAPI application,
wires the health-check / Agent Card discovery endpoint, and exposes
the A2A JSON-RPC dispatch route.
All A2A operations both standard (``message/send``,
``message/stream``) and ``_cleveragents/`` extension methods flow
All A2A operations -- both standard (``message/send``,
``message/stream``) and ``_cleveragents/`` extension methods -- flow
through the single ``/a2a`` POST endpoint. The Agent Card is served
at ``/.well-known/agent.json`` per the A2A specification.
"""
from __future__ import annotations
import json
from typing import Any
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 +67,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 +99,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:
@@ -120,56 +115,82 @@ def create_asgi_app(
Accepts a JSON body conforming to the :class:`A2aRequest`
schema, dispatches it through the facade, and returns the
:class:`A2aResponse` envelope.
Per JSON-RPC 2.0 over HTTP convention, all responses use HTTP
200 regardless of protocol-level errors. Error codes are
expressed via the ``error`` field in the JSON body.
"""
# Step 1: Parse the raw JSON body.
# Malformed JSON -> -32700 Parse error.
try:
body: dict[str, Any] = await request.json()
except json.JSONDecodeError as exc:
logger.warning("a2a.server.parse_error", error=str(exc))
except Exception:
logger.warning("a2a.server.parse_error")
return JSONResponse(
status_code=400,
status_code=200,
content={
"jsonrpc": "2.0",
"id": None,
"error": {
"code": -32700,
"message": "Parse error: request body is not valid JSON",
"message": "Parse error",
},
},
)
# Accept "operation" as an alias for "method" (proprietary wire format).
if "method" not in body and "operation" in body:
body = {**body, "method": body["operation"]}
# Step 2: Validate the body against the A2aRequest schema.
# Invalid schema -> -32600 Invalid Request.
request_id: Any = body.get("id") if isinstance(body, dict) else None
try:
a2a_request = A2aRequest(**body)
except Exception as exc:
logger.warning("a2a.server.bad_request", error=str(exc))
return JSONResponse(
status_code=400,
status_code=200,
content={
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32600,
"message": f"Invalid A2A request: {exc}",
"message": "Invalid request body",
},
},
)
# Step 3: Dispatch the request through the facade.
try:
response = resolved_facade.dispatch(a2a_request)
return JSONResponse(content=response.model_dump(exclude_none=True))
except A2aOperationNotFoundError as exc:
except A2aOperationNotFoundError:
logger.warning(
"a2a.server.method_not_found",
operation=a2a_request.method,
method=a2a_request.method,
)
return JSONResponse(
status_code=404,
status_code=200,
content={
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32601,
"message": str(exc),
"message": "Method not found",
},
},
)
except Exception as exc:
logger.error(
"a2a.server.internal_error",
error=str(exc),
exc_info=True,
)
return JSONResponse(
status_code=200,
content={
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32603,
"message": "Internal error",
},
},
)
@@ -164,7 +164,7 @@ class ServerLifecycle:
# ------------------------------------------------------------------
def run_server(
def run_server( # pragma: no cover
*,
host: str | None = None,
port: int | None = None,