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:
@@ -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"
|
||||
@@ -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=[],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user