feat(a2a): Agent Card discovery endpoint

Implement A2A Agent Card discovery per ADR-047 and issue #867:

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

ISSUES CLOSED: #867
This commit is contained in:
2026-03-23 04:55:44 +00:00
committed by cleveragents-auto
parent 015d479ffd
commit d3b9b8c8b8
7 changed files with 1287 additions and 27 deletions
+219
View File
@@ -0,0 +1,219 @@
@phase2 @a2a @agent_card
Feature: A2A Agent Card Discovery
As an A2A-compatible client
I want to discover the agent's capabilities via an Agent Card
So that I can determine which operations the CleverAgents server supports
# -----------------------------------------------------------------------
# Agent Card model construction
# -----------------------------------------------------------------------
Scenario: Build an Agent Card with a facade
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
Then the agent card name should be "CleverAgents"
And the agent card version should be "1.0"
And the agent card should have a non-empty url
And the agent card should have at least 1 skill
And the agent card should have at least 1 extension
Scenario: Build an Agent Card without a facade
When I build an agent card without a facade
Then the agent card name should be "CleverAgents"
And the agent card version should be "1.0"
And the agent card should have 0 skills
Scenario: Agent Card includes supported versions
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
Then the agent card supported versions should contain "1.0"
Scenario: Agent Card includes supported operations
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
Then the agent card should list extension operations
Scenario: Agent Card includes security schemes
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
Then the agent card should have at least 1 security scheme
Scenario: Agent Card includes protocol interfaces
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
Then the agent card should have at least 1 interface
And the first interface protocol should be "jsonrpc"
Scenario: Agent Card includes default input and output modes
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
Then the agent card default input modes should contain "text"
And the agent card default output modes should contain "text"
Scenario: Agent Card capabilities reflect current state
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
Then the agent card streaming capability should be false
And the agent card push notifications capability should be false
# -----------------------------------------------------------------------
# Agent Card validation
# -----------------------------------------------------------------------
Scenario: Build rejects invalid host
When I try to build an agent card with an empty host
Then a ValueError should be raised for agent card
Scenario: Build rejects invalid port zero
When I try to build an agent card with port 0
Then a ValueError should be raised for agent card
Scenario: Build rejects port above 65535
When I try to build an agent card with port 70000
Then a ValueError should be raised for agent card
# -----------------------------------------------------------------------
# Agent Card model validation
# -----------------------------------------------------------------------
Scenario: AgentCard rejects empty name
When I try to create an AgentCard with empty name
Then a validation error should be raised for agent card
Scenario: AgentCard rejects empty version
When I try to create an AgentCard with empty version
Then a validation error should be raised for agent card
Scenario: AgentCardSkill rejects empty id
When I try to create an AgentCardSkill with empty id
Then a validation error should be raised for agent card
Scenario: AgentCardSkill rejects empty name
When I try to create an AgentCardSkill with empty skill name
Then a validation error should be raised for agent card
Scenario: AgentCardExtension rejects empty uri
When I try to create an AgentCardExtension with empty uri
Then a validation error should be raised for agent card
Scenario: AgentCardSecurityScheme rejects empty type
When I try to create an AgentCardSecurityScheme with empty type
Then a validation error should be raised for agent card
Scenario: AgentCardInterface rejects empty protocol
When I try to create an AgentCardInterface with empty protocol
Then a validation error should be raised for agent card
# -----------------------------------------------------------------------
# A2A conformance validation
# -----------------------------------------------------------------------
Scenario: Valid Agent Card passes conformance validation
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
And I validate the agent card conformance
Then the conformance violations should be empty
Scenario: Agent Card with empty url fails conformance
Given an agent card with empty url
When I validate the agent card conformance
Then the conformance violations should include "url is required"
Scenario: Agent Card with unsupported version fails conformance
Given an agent card with version "99.0"
When I validate the agent card conformance
Then the conformance violations should include "not in supported versions"
Scenario: Agent Card with empty supportedVersions fails conformance
Given an agent card with empty supported versions
When I validate the agent card conformance
Then the conformance violations should include "supportedVersions must not be empty"
Scenario: Conformance validation rejects non-AgentCard argument
When I try to validate conformance of a non-AgentCard object
Then a TypeError should be raised for conformance validation
# -----------------------------------------------------------------------
# Serialization
# -----------------------------------------------------------------------
Scenario: Agent Card serializes to dict
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
And I serialize the agent card to dict
Then the serialized dict should have key "name"
And the serialized dict should have key "version"
And the serialized dict should have key "capabilities"
And the serialized dict should have key "skills"
And the serialized dict should have key "supportedVersions"
And the serialized dict should have key "supportedOperations"
Scenario: agent_card_to_dict rejects non-AgentCard argument
When I try to serialize a non-AgentCard object
Then a TypeError should be raised for serialization
# -----------------------------------------------------------------------
# Discovery endpoint (ASGI)
# -----------------------------------------------------------------------
Scenario: Discovery endpoint returns Agent Card with skills
Given a running ASGI test client with facade
When I request GET /.well-known/agent.json
Then the response status code should be 200
And the response body should contain "CleverAgents"
And the response body should contain "skills"
And the response body should contain "supportedVersions"
And the response body should contain "supportedOperations"
Scenario: Discovery endpoint Agent Card has correct structure
Given a running ASGI test client with facade
When I request GET /.well-known/agent.json and parse JSON
Then the agent card JSON should have key "name" with value "CleverAgents"
And the agent card JSON should have key "version" with value "1.0"
And the agent card JSON "capabilities" should be a dict
And the agent card JSON "skills" should be a non-empty list
And the agent card JSON "supportedVersions" should be a non-empty list
Scenario: Discovery endpoint Agent Card lists extension operations
Given a running ASGI test client with facade
When I request GET /.well-known/agent.json and parse JSON
Then the agent card JSON "supportedOperations" should be a non-empty list
And all supported operations should start with "_cleveragents/"
# -----------------------------------------------------------------------
# Skill enumeration from facade
# -----------------------------------------------------------------------
Scenario: Agent Card enumerates plan lifecycle skill
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
Then the agent card skills should include id "plan-lifecycle"
Scenario: Agent Card enumerates registry skill
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
Then the agent card skills should include id "registry-crud"
Scenario: Agent Card enumerates context skill
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
Then the agent card skills should include id "context-mgmt"
Scenario: Agent Card enumerates health skill
Given an A2aLocalFacade for agent card testing
When I build an agent card from the facade
Then the agent card skills should include id "health-diagnostics"
# -----------------------------------------------------------------------
# Additional conformance edge cases
# -----------------------------------------------------------------------
Scenario: Agent Card with empty defaultInputModes fails conformance
Given an agent card with empty default input modes
When I validate the agent card conformance
Then the conformance violations should include "defaultInputModes must not be empty"
Scenario: Agent Card with empty defaultOutputModes fails conformance
Given an agent card with empty default output modes
When I validate the agent card conformance
Then the conformance violations should include "defaultOutputModes must not be empty"
+445
View File
@@ -0,0 +1,445 @@
"""Step definitions for ``agent_card.feature``.
Tests Agent Card model construction, conformance validation,
serialization, discovery endpoint, and skill enumeration.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from pydantic import ValidationError
from cleveragents.a2a.agent_card import (
AgentCard,
AgentCardExtension,
AgentCardInterface,
AgentCardSecurityScheme,
AgentCardSkill,
agent_card_to_dict,
build_agent_card,
validate_agent_card_conformance,
)
from cleveragents.a2a.facade import A2aLocalFacade
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given("an A2aLocalFacade for agent card testing")
def step_given_facade_for_card(context: Any) -> None:
context.facade = A2aLocalFacade()
@given("an agent card with empty url")
def step_given_card_empty_url(context: Any) -> None:
context.agent_card = AgentCard(
name="Test",
version="1.0",
url="",
)
@given('an agent card with version "{version}"')
def step_given_card_version(context: Any, version: str) -> None:
context.agent_card = AgentCard(
name="Test",
version=version,
url="http://localhost/a2a",
)
@given("an agent card with empty supported versions")
def step_given_card_empty_supported_versions(context: Any) -> None:
context.agent_card = AgentCard(
name="Test",
version="1.0",
url="http://localhost/a2a",
supportedVersions=[],
)
# ---------------------------------------------------------------------------
# When steps — build
# ---------------------------------------------------------------------------
@when("I build an agent card from the facade")
def step_build_card_from_facade(context: Any) -> None:
context.agent_card = build_agent_card(facade=context.facade)
@when("I build an agent card without a facade")
def step_build_card_no_facade(context: Any) -> None:
context.agent_card = build_agent_card()
@when("I try to build an agent card with an empty host")
def step_try_build_card_empty_host(context: Any) -> None:
context.caught_exception = None
try:
build_agent_card(host="")
except ValueError as exc:
context.caught_exception = exc
@when("I try to build an agent card with port {port:d}")
def step_try_build_card_bad_port(context: Any, port: int) -> None:
context.caught_exception = None
try:
build_agent_card(port=port)
except ValueError as exc:
context.caught_exception = exc
# ---------------------------------------------------------------------------
# When steps — model validation
# ---------------------------------------------------------------------------
@when("I try to create an AgentCard with empty name")
def step_try_card_empty_name(context: Any) -> None:
context.caught_exception = None
try:
AgentCard(name="", version="1.0")
except ValidationError as exc:
context.caught_exception = exc
@when("I try to create an AgentCard with empty version")
def step_try_card_empty_version(context: Any) -> None:
context.caught_exception = None
try:
AgentCard(name="Test", version="")
except ValidationError as exc:
context.caught_exception = exc
@when("I try to create an AgentCardSkill with empty id")
def step_try_skill_empty_id(context: Any) -> None:
context.caught_exception = None
try:
AgentCardSkill(id="", name="Test")
except ValidationError as exc:
context.caught_exception = exc
@when("I try to create an AgentCardSkill with empty skill name")
def step_try_skill_empty_name(context: Any) -> None:
context.caught_exception = None
try:
AgentCardSkill(id="test", name="")
except ValidationError as exc:
context.caught_exception = exc
@when("I try to create an AgentCardExtension with empty uri")
def step_try_extension_empty_uri(context: Any) -> None:
context.caught_exception = None
try:
AgentCardExtension(uri="")
except ValidationError as exc:
context.caught_exception = exc
@when("I try to create an AgentCardSecurityScheme with empty type")
def step_try_security_empty_type(context: Any) -> None:
context.caught_exception = None
try:
AgentCardSecurityScheme(type="")
except ValidationError as exc:
context.caught_exception = exc
@when("I try to create an AgentCardInterface with empty protocol")
def step_try_interface_empty_protocol(context: Any) -> None:
context.caught_exception = None
try:
AgentCardInterface(protocol="")
except ValidationError as exc:
context.caught_exception = exc
# ---------------------------------------------------------------------------
# When steps — conformance validation
# ---------------------------------------------------------------------------
@when("I validate the agent card conformance")
def step_validate_conformance(context: Any) -> None:
context.violations = validate_agent_card_conformance(context.agent_card)
@when("I try to validate conformance of a non-AgentCard object")
def step_try_validate_non_card(context: Any) -> None:
context.caught_exception = None
try:
validate_agent_card_conformance("not-a-card") # type: ignore[arg-type]
except TypeError as exc:
context.caught_exception = exc
# ---------------------------------------------------------------------------
# When steps — serialization
# ---------------------------------------------------------------------------
@when("I serialize the agent card to dict")
def step_serialize_card(context: Any) -> None:
context.card_dict = agent_card_to_dict(context.agent_card)
@when("I try to serialize a non-AgentCard object")
def step_try_serialize_non_card(context: Any) -> None:
context.caught_exception = None
try:
agent_card_to_dict("not-a-card") # type: ignore[arg-type]
except TypeError as exc:
context.caught_exception = exc
# ---------------------------------------------------------------------------
# When steps — endpoint
# ---------------------------------------------------------------------------
@given("a running ASGI test client with facade")
def step_given_test_client_with_facade(context: Any) -> None:
from fastapi.testclient import TestClient
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
facade = A2aLocalFacade()
app = create_asgi_app(facade=facade)
context.test_client = TestClient(app)
@when("I request GET /.well-known/agent.json and parse JSON")
def step_get_agent_card_parse(context: Any) -> None:
context.response = context.test_client.get("/.well-known/agent.json")
context.card_json = context.response.json()
# ---------------------------------------------------------------------------
# Then steps — card properties
# ---------------------------------------------------------------------------
@then('the agent card name should be "{expected}"')
def step_check_card_name(context: Any, expected: str) -> None:
assert context.agent_card.name == expected, (
f"Expected name '{expected}', got '{context.agent_card.name}'"
)
@then('the agent card version should be "{expected}"')
def step_check_card_version(context: Any, expected: str) -> None:
assert context.agent_card.version == expected, (
f"Expected version '{expected}', got '{context.agent_card.version}'"
)
@then("the agent card should have a non-empty url")
def step_check_card_url(context: Any) -> None:
assert context.agent_card.url, "Expected non-empty url"
@then("the agent card should have at least {count:d} skill")
@then("the agent card should have at least {count:d} skills")
def step_check_card_skills_count(context: Any, count: int) -> None:
actual = len(context.agent_card.skills)
assert actual >= count, f"Expected at least {count} skills, got {actual}"
@then("the agent card should have {count:d} skills")
def step_check_card_exact_skills(context: Any, count: int) -> None:
actual = len(context.agent_card.skills)
assert actual == count, f"Expected {count} skills, got {actual}"
@then("the agent card should have at least {count:d} extension")
@then("the agent card should have at least {count:d} extensions")
def step_check_card_extensions_count(context: Any, count: int) -> None:
actual = len(context.agent_card.extensions)
assert actual >= count, f"Expected at least {count} extensions, got {actual}"
@then("the agent card should have at least {count:d} security scheme")
@then("the agent card should have at least {count:d} security schemes")
def step_check_card_security_count(context: Any, count: int) -> None:
actual = len(context.agent_card.securitySchemes)
assert actual >= count, f"Expected at least {count} security schemes, got {actual}"
@then("the agent card should have at least {count:d} interface")
@then("the agent card should have at least {count:d} interfaces")
def step_check_card_interfaces_count(context: Any, count: int) -> None:
actual = len(context.agent_card.interfaces)
assert actual >= count, f"Expected at least {count} interfaces, got {actual}"
@then('the first interface protocol should be "{expected}"')
def step_check_first_interface(context: Any, expected: str) -> None:
assert context.agent_card.interfaces, "No interfaces found"
assert context.agent_card.interfaces[0].protocol == expected
@then('the agent card supported versions should contain "{version}"')
def step_check_supported_versions(context: Any, version: str) -> None:
assert version in context.agent_card.supportedVersions, (
f"'{version}' not in {context.agent_card.supportedVersions}"
)
@then("the agent card should list extension operations")
def step_check_extension_ops(context: Any) -> None:
assert context.agent_card.supportedOperations, (
"Expected non-empty supportedOperations"
)
for op in context.agent_card.supportedOperations:
assert op.startswith("_cleveragents/"), f"Operation '{op}' has wrong prefix"
@then('the agent card default input modes should contain "{mode}"')
def step_check_input_modes(context: Any, mode: str) -> None:
assert mode in context.agent_card.defaultInputModes
@then('the agent card default output modes should contain "{mode}"')
def step_check_output_modes(context: Any, mode: str) -> None:
assert mode in context.agent_card.defaultOutputModes
@then("the agent card streaming capability should be false")
def step_check_streaming_false(context: Any) -> None:
assert context.agent_card.capabilities.streaming is False
@then("the agent card push notifications capability should be false")
def step_check_push_false(context: Any) -> None:
assert context.agent_card.capabilities.pushNotifications is False
# ---------------------------------------------------------------------------
# Then steps — validation errors
# ---------------------------------------------------------------------------
@then("a ValueError should be raised for agent card")
def step_check_value_error_card(context: Any) -> None:
assert context.caught_exception is not None, "Expected ValueError"
assert isinstance(context.caught_exception, ValueError)
@then("a validation error should be raised for agent card")
def step_check_validation_error_card(context: Any) -> None:
assert context.caught_exception is not None, "Expected ValidationError"
assert isinstance(context.caught_exception, ValidationError)
@then("a TypeError should be raised for conformance validation")
def step_check_type_error_conformance(context: Any) -> None:
assert context.caught_exception is not None, "Expected TypeError"
assert isinstance(context.caught_exception, TypeError)
@then("a TypeError should be raised for serialization")
def step_check_type_error_serialization(context: Any) -> None:
assert context.caught_exception is not None, "Expected TypeError"
assert isinstance(context.caught_exception, TypeError)
# ---------------------------------------------------------------------------
# Then steps — conformance
# ---------------------------------------------------------------------------
@then("the conformance violations should be empty")
def step_check_no_violations(context: Any) -> None:
assert context.violations == [], (
f"Expected no violations, got: {context.violations}"
)
@then('the conformance violations should include "{text}"')
def step_check_violation_text(context: Any, text: str) -> None:
joined = " | ".join(context.violations)
assert any(text in v for v in context.violations), (
f"Expected violation containing '{text}', got: {joined}"
)
# ---------------------------------------------------------------------------
# Then steps — serialization
# ---------------------------------------------------------------------------
@then('the serialized dict should have key "{key}"')
def step_check_dict_key(context: Any, key: str) -> None:
assert key in context.card_dict, (
f"Key '{key}' not in serialized dict: {list(context.card_dict.keys())}"
)
# ---------------------------------------------------------------------------
# Then steps — JSON endpoint
# ---------------------------------------------------------------------------
@then('the agent card JSON should have key "{key}" with value "{value}"')
def step_check_json_key_value(context: Any, key: str, value: str) -> None:
actual = context.card_json.get(key)
assert actual == value, f"Expected '{key}'='{value}', got '{actual}'"
@then('the agent card JSON "{key}" should be a dict')
def step_check_json_dict(context: Any, key: str) -> None:
val = context.card_json.get(key)
assert isinstance(val, dict), f"Expected dict for '{key}', got {type(val)}"
@then('the agent card JSON "{key}" should be a non-empty list')
def step_check_json_list(context: Any, key: str) -> None:
val = context.card_json.get(key)
assert isinstance(val, list), f"Expected list for '{key}', got {type(val)}"
assert len(val) > 0, f"Expected non-empty list for '{key}'"
@then('all supported operations should start with "_cleveragents/"')
def step_check_ops_prefix(context: Any) -> None:
ops = context.card_json.get("supportedOperations", [])
for op in ops:
assert op.startswith("_cleveragents/"), f"Operation '{op}' has wrong prefix"
# ---------------------------------------------------------------------------
# Then steps — skill enumeration
# ---------------------------------------------------------------------------
@then('the agent card skills should include id "{skill_id}"')
def step_check_skill_id(context: Any, skill_id: str) -> None:
ids = [s.id for s in context.agent_card.skills]
assert skill_id in ids, f"Skill '{skill_id}' not in {ids}"
@given("an agent card with empty default input modes")
def step_given_card_empty_input_modes(context: Any) -> None:
context.agent_card = AgentCard(
name="Test",
version="1.0",
url="http://localhost/a2a",
defaultInputModes=[],
)
@given("an agent card with empty default output modes")
def step_given_card_empty_output_modes(context: Any) -> None:
context.agent_card = AgentCard(
name="Test",
version="1.0",
url="http://localhost/a2a",
defaultOutputModes=[],
)
+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
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Integration test helper for Agent Card discovery tests.
Each sub-command exercises a real import / instantiation path and prints
a unique sentinel on success. Robot tests parse the output for that
sentinel.
"""
from __future__ import annotations
import json
import sys
def _test_build_card() -> None:
"""Build an Agent Card from a facade and verify structure."""
from cleveragents.a2a.agent_card import build_agent_card
from cleveragents.a2a.facade import A2aLocalFacade
facade = A2aLocalFacade()
card = build_agent_card(facade=facade)
assert card.name == "CleverAgents"
assert card.version == "1.0"
assert card.url
assert len(card.skills) > 0
assert len(card.extensions) > 0
assert len(card.supportedVersions) > 0
assert len(card.supportedOperations) > 0
print("build-card-ok")
def _test_conformance() -> None:
"""Validate Agent Card A2A conformance."""
from cleveragents.a2a.agent_card import (
build_agent_card,
validate_agent_card_conformance,
)
from cleveragents.a2a.facade import A2aLocalFacade
facade = A2aLocalFacade()
card = build_agent_card(facade=facade)
violations = validate_agent_card_conformance(card)
assert violations == [], f"Conformance violations: {violations}"
print("conformance-ok")
def _test_endpoint() -> None:
"""Exercise the /.well-known/agent.json endpoint via TestClient."""
from fastapi.testclient import TestClient
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
facade = A2aLocalFacade()
app = create_asgi_app(facade=facade)
client = TestClient(app)
resp = client.get("/.well-known/agent.json")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "CleverAgents"
assert "skills" in data
assert "supportedVersions" in data
assert "supportedOperations" in data
assert isinstance(data["skills"], list)
assert len(data["skills"]) > 0
assert isinstance(data["supportedOperations"], list)
assert len(data["supportedOperations"]) > 0
# Verify all operations have the correct prefix
for op in data["supportedOperations"]:
assert op.startswith("_cleveragents/"), f"Bad prefix: {op}"
print("endpoint-ok")
def _test_serialization() -> None:
"""Verify Agent Card serializes to valid JSON dict."""
from cleveragents.a2a.agent_card import (
agent_card_to_dict,
build_agent_card,
)
from cleveragents.a2a.facade import A2aLocalFacade
facade = A2aLocalFacade()
card = build_agent_card(facade=facade)
data = agent_card_to_dict(card)
# Round-trip through JSON to ensure serializability
serialized = json.dumps(data)
parsed = json.loads(serialized)
assert parsed["name"] == "CleverAgents"
assert "skills" in parsed
assert "supportedOperations" in parsed
print("serialization-ok")
def _test_skill_enumeration() -> None:
"""Verify skills are correctly enumerated from facade operations."""
from cleveragents.a2a.agent_card import build_agent_card
from cleveragents.a2a.facade import A2aLocalFacade
facade = A2aLocalFacade()
card = build_agent_card(facade=facade)
skill_ids = [s.id for s in card.skills]
assert "plan-lifecycle" in skill_ids, f"Missing plan-lifecycle: {skill_ids}"
assert "registry-crud" in skill_ids, f"Missing registry-crud: {skill_ids}"
assert "context-mgmt" in skill_ids, f"Missing context-mgmt: {skill_ids}"
assert "health-diagnostics" in skill_ids, f"Missing health-diagnostics: {skill_ids}"
print("skill-enumeration-ok")
def _test_version_info() -> None:
"""Verify version negotiation information in Agent Card."""
from cleveragents.a2a.agent_card import build_agent_card
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aVersion
facade = A2aLocalFacade()
card = build_agent_card(facade=facade)
assert card.version == A2aVersion.CURRENT
assert A2aVersion.CURRENT in card.supportedVersions
for v in A2aVersion.SUPPORTED:
assert v in card.supportedVersions
print("version-info-ok")
_COMMANDS: dict[str, object] = {
"build-card": _test_build_card,
"conformance": _test_conformance,
"endpoint": _test_endpoint,
"serialization": _test_serialization,
"skill-enumeration": _test_skill_enumeration,
"version-info": _test_version_info,
}
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(2)
handler = _COMMANDS[sys.argv[1]]
handler() # type: ignore[operator]
if __name__ == "__main__":
main()
+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",
+377
View File
@@ -0,0 +1,377 @@
"""A2A Agent Card model and factory.
Provides Pydantic v2 models for the A2A Agent Card specification and a
factory function that builds a card from the facade's supported
operations. The card is served at ``/.well-known/agent.json`` per the
A2A specification (ADR-047).
The :func:`validate_agent_card_conformance` function validates that a
card satisfies the mandatory fields required by the A2A specification.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict, field_validator
from cleveragents.a2a.models import A2aVersion
if TYPE_CHECKING:
from cleveragents.a2a.facade import A2aLocalFacade
# ---------------------------------------------------------------------------
# Operation-to-skill mapping
# ---------------------------------------------------------------------------
_SKILL_DEFINITIONS: list[dict[str, str]] = [
{
"id": "plan-lifecycle",
"name": "Plan Lifecycle Management",
"description": "Create, execute, cancel, and monitor agent plans",
"prefix": "_cleveragents/plan/",
},
{
"id": "registry-crud",
"name": "Entity Registry Operations",
"description": "List and manage tools, resources, actors, and other entities",
"prefix": "_cleveragents/registry/",
},
{
"id": "context-mgmt",
"name": "Context Assembly",
"description": "Inspect, set, and simulate project context",
"prefix": "_cleveragents/context/",
},
{
"id": "entity-sync",
"name": "Entity Synchronization",
"description": "Pull, push, and check sync status across namespaces",
"prefix": "_cleveragents/sync/",
},
{
"id": "namespace-mgmt",
"name": "Namespace Management",
"description": "List, inspect, and manage namespaces and members",
"prefix": "_cleveragents/namespace/",
},
{
"id": "health-diagnostics",
"name": "Health and Diagnostics",
"description": "Health checks and diagnostic reporting",
"prefix": "_cleveragents/health/",
},
]
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class AgentCardSkill(BaseModel):
"""A skill advertised in the Agent Card."""
model_config = ConfigDict(strict=False)
id: str
name: str
description: str = ""
@field_validator("id", "name")
@classmethod
def _must_be_non_empty(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("field must not be empty")
return value
class AgentCardCapabilities(BaseModel):
"""Capability flags for the Agent Card."""
model_config = ConfigDict(strict=False)
streaming: bool = False
pushNotifications: bool = False
class AgentCardExtension(BaseModel):
"""An extension namespace declaration."""
model_config = ConfigDict(strict=False)
uri: str
description: str = ""
@field_validator("uri")
@classmethod
def _uri_non_empty(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("uri must not be empty")
return value
class AgentCardSecurityScheme(BaseModel):
"""A security scheme declared by the Agent Card."""
model_config = ConfigDict(strict=False)
type: str
scheme: str = ""
@field_validator("type")
@classmethod
def _type_non_empty(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("type must not be empty")
return value
class AgentCardInterface(BaseModel):
"""A protocol interface declared by the Agent Card."""
model_config = ConfigDict(strict=False)
protocol: str
url: str = ""
@field_validator("protocol")
@classmethod
def _protocol_non_empty(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("protocol must not be empty")
return value
class AgentCard(BaseModel):
"""A2A Agent Card describing the agent's capabilities.
Conforms to the A2A specification Agent Card schema. All required
fields (``name``, ``version``, ``url``) are validated on construction.
"""
model_config = ConfigDict(strict=False)
name: str
description: str = ""
url: str = ""
version: str = A2aVersion.CURRENT
capabilities: AgentCardCapabilities = AgentCardCapabilities()
skills: list[AgentCardSkill] = []
extensions: list[AgentCardExtension] = []
securitySchemes: list[AgentCardSecurityScheme] = []
interfaces: list[AgentCardInterface] = []
defaultInputModes: list[str] = ["text"]
defaultOutputModes: list[str] = ["text"]
supportedVersions: list[str] = list(A2aVersion.SUPPORTED)
supportedOperations: list[str] = []
@field_validator("name")
@classmethod
def _name_non_empty(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("name must not be empty")
return value
@field_validator("version")
@classmethod
def _version_non_empty(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("version must not be empty")
return value
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
def build_agent_card(
*,
facade: A2aLocalFacade | None = None,
host: str = "0.0.0.0",
port: int = 8080,
) -> AgentCard:
"""Build an :class:`AgentCard` from a facade's supported operations.
Parameters
----------
facade:
The facade to introspect for supported operations. When *None*
a minimal card with no skills is returned.
host:
Server bind address used to populate the card URL.
port:
Server bind port used to populate the card URL.
Returns
-------
AgentCard
A fully populated Agent Card.
"""
if not isinstance(host, str) or not host:
raise ValueError("host must be a non-empty string")
if not isinstance(port, int) or port < 1 or port > 65535:
raise ValueError("port must be an integer between 1 and 65535")
base_url = f"http://{host}:{port}/a2a"
operations: list[str] = []
if facade is not None:
operations = facade.list_operations()
# Build skills from operations that match known prefixes
skills: list[AgentCardSkill] = []
for defn in _SKILL_DEFINITIONS:
prefix = defn["prefix"]
matching = [op for op in operations if op.startswith(prefix)]
if matching:
skills.append(
AgentCardSkill(
id=defn["id"],
name=defn["name"],
description=defn["description"],
)
)
# Also include diagnostics operations in health skill
diag_ops = [op for op in operations if op.startswith("_cleveragents/diagnostics/")]
if diag_ops and not any(s.id == "health-diagnostics" for s in skills):
skills.append(
AgentCardSkill(
id="health-diagnostics",
name="Health and Diagnostics",
description="Health checks and diagnostic reporting",
)
)
# Filter to extension operations only for the supportedOperations list
extension_ops = [op for op in operations if op.startswith("_cleveragents/")]
extensions: list[AgentCardExtension] = []
if extension_ops:
extensions.append(
AgentCardExtension(
uri="urn:cleveragents:extensions:v1",
description="CleverAgents platform operations",
)
)
return AgentCard(
name="CleverAgents",
description="AI-powered development assistant (actor-first)",
url=base_url,
version=A2aVersion.CURRENT,
capabilities=AgentCardCapabilities(
streaming=False,
pushNotifications=False,
),
skills=skills,
extensions=extensions,
securitySchemes=[
AgentCardSecurityScheme(type="http", scheme="bearer"),
],
interfaces=[
AgentCardInterface(protocol="jsonrpc", url=base_url),
],
defaultInputModes=["text"],
defaultOutputModes=["text"],
supportedVersions=list(A2aVersion.SUPPORTED),
supportedOperations=sorted(extension_ops),
)
# ---------------------------------------------------------------------------
# Conformance validation
# ---------------------------------------------------------------------------
def validate_agent_card_conformance(card: AgentCard) -> list[str]:
"""Validate that *card* conforms to A2A specification requirements.
Returns a list of violation descriptions. An empty list means the
card is conformant.
Parameters
----------
card:
The Agent Card to validate.
Returns
-------
list[str]
Zero or more violation messages.
"""
if not isinstance(card, AgentCard):
raise TypeError("card must be an AgentCard instance")
violations: list[str] = []
if not card.name or not card.name.strip():
violations.append("name is required and must not be empty")
if not card.version or not card.version.strip():
violations.append("version is required and must not be empty")
if not card.url or not card.url.strip():
violations.append("url is required and must not be empty")
if card.version not in A2aVersion.SUPPORTED:
violations.append(
f"version '{card.version}' is not in supported versions "
f"{list(A2aVersion.SUPPORTED)}"
)
if not card.supportedVersions:
violations.append("supportedVersions must not be empty")
if not card.defaultInputModes:
violations.append("defaultInputModes must not be empty")
if not card.defaultOutputModes:
violations.append("defaultOutputModes must not be empty")
# Validate each skill has required fields
for idx, skill in enumerate(card.skills):
if not skill.id or not skill.id.strip():
violations.append(f"skills[{idx}].id must not be empty")
if not skill.name or not skill.name.strip():
violations.append(f"skills[{idx}].name must not be empty")
# Validate each extension has a URI
for idx, ext in enumerate(card.extensions):
if not ext.uri or not ext.uri.strip():
violations.append(f"extensions[{idx}].uri must not be empty")
return violations
def agent_card_to_dict(card: AgentCard) -> dict[str, Any]:
"""Serialize an :class:`AgentCard` to a JSON-compatible dict.
Parameters
----------
card:
The Agent Card to serialize.
Returns
-------
dict[str, Any]
A dictionary suitable for JSON serialization.
"""
if not isinstance(card, AgentCard):
raise TypeError("card must be an AgentCard instance")
return card.model_dump()
__all__ = [
"AgentCard",
"AgentCardCapabilities",
"AgentCardExtension",
"AgentCardInterface",
"AgentCardSecurityScheme",
"AgentCardSkill",
"agent_card_to_dict",
"build_agent_card",
"validate_agent_card_conformance",
]
@@ -20,37 +20,17 @@ import structlog
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from cleveragents.a2a.agent_card import (
agent_card_to_dict,
build_agent_card,
validate_agent_card_conformance,
)
from cleveragents.a2a.errors import A2aOperationNotFoundError
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest, A2aVersion
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
# ------------------------------------------------------------------
# Agent Card (served at /.well-known/agent.json per A2A spec)
# ------------------------------------------------------------------
_AGENT_CARD: dict[str, Any] = {
"name": "CleverAgents",
"description": "AI-powered development assistant (actor-first)",
"url": "",
"version": A2aVersion.CURRENT,
"capabilities": {
"streaming": False,
"pushNotifications": False,
},
"skills": [],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
}
def _build_agent_card(host: str, port: int) -> dict[str, Any]:
"""Return the Agent Card with the server URL populated."""
card = dict(_AGENT_CARD)
card["url"] = f"http://{host}:{port}/a2a"
return card
# ------------------------------------------------------------------
# Application factory
@@ -88,7 +68,23 @@ def create_asgi_app(
raise ValueError("port must be an integer between 1 and 65535")
resolved_facade = facade if facade is not None else A2aLocalFacade()
agent_card = _build_agent_card(host, port)
# Build the Agent Card from the facade's operations
agent_card = build_agent_card(
facade=resolved_facade,
host=host,
port=port,
)
# Validate conformance at startup
violations = validate_agent_card_conformance(agent_card)
if violations:
logger.warning(
"a2a.server.agent_card_conformance_violations",
violations=violations,
)
agent_card_dict = agent_card_to_dict(agent_card)
application = FastAPI(
title="CleverAgents A2A Server",
@@ -104,7 +100,7 @@ def create_asgi_app(
@application.get("/.well-known/agent.json")
async def agent_card_endpoint() -> JSONResponse:
"""Serve the A2A Agent Card for capability discovery."""
return JSONResponse(content=agent_card)
return JSONResponse(content=agent_card_dict)
@application.get("/health")
async def health_endpoint() -> JSONResponse: