feat(a2a): Agent Card discovery endpoint #1123
@@ -0,0 +1,229 @@
|
||||
@phase2 @a2a @agent_card
|
||||
Feature: A2A Agent Card Discovery
|
||||
As an A2A-compatible client
|
||||
I want to discover the agent's capabilities via an Agent Card
|
||||
So that I can determine which operations the CleverAgents server supports
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Agent Card model construction
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: Build an Agent Card with a facade
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card name should be "CleverAgents"
|
||||
And the agent card version should be "1.0"
|
||||
And the agent card should have a non-empty url
|
||||
And the agent card should have at least 1 skill
|
||||
And the agent card should have at least 1 extension
|
||||
|
||||
Scenario: Build an Agent Card without a facade
|
||||
When I build an agent card without a facade
|
||||
Then the agent card name should be "CleverAgents"
|
||||
And the agent card version should be "1.0"
|
||||
And the agent card should have 0 skills
|
||||
|
||||
Scenario: Agent Card includes supported versions
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card supported versions should contain "1.0"
|
||||
|
||||
Scenario: Agent Card includes supported operations
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card should list extension operations
|
||||
|
||||
Scenario: Agent Card includes security schemes
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card should have at least 1 security scheme
|
||||
|
||||
Scenario: Agent Card includes protocol interfaces
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card should have at least 1 interface
|
||||
And the first interface protocol should be "jsonrpc"
|
||||
|
||||
Scenario: Agent Card includes default input and output modes
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card default input modes should contain "text"
|
||||
And the agent card default output modes should contain "text"
|
||||
|
||||
Scenario: Agent Card capabilities reflect current state
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card streaming capability should be false
|
||||
And the agent card push notifications capability should be false
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Agent Card validation
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: Build rejects invalid host
|
||||
When I try to build an agent card with an empty host
|
||||
Then a ValueError should be raised for agent card
|
||||
|
||||
Scenario: Build rejects invalid port zero
|
||||
When I try to build an agent card with port 0
|
||||
Then a ValueError should be raised for agent card
|
||||
|
||||
Scenario: Build rejects port above 65535
|
||||
When I try to build an agent card with port 70000
|
||||
Then a ValueError should be raised for agent card
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Agent Card model validation
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: AgentCard rejects empty name
|
||||
When I try to create an AgentCard with empty name
|
||||
Then a validation error should be raised for agent card
|
||||
|
||||
Scenario: AgentCard rejects empty version
|
||||
When I try to create an AgentCard with empty version
|
||||
Then a validation error should be raised for agent card
|
||||
|
||||
Scenario: AgentCardSkill rejects empty id
|
||||
When I try to create an AgentCardSkill with empty id
|
||||
Then a validation error should be raised for agent card
|
||||
|
||||
Scenario: AgentCardSkill rejects empty name
|
||||
When I try to create an AgentCardSkill with empty skill name
|
||||
Then a validation error should be raised for agent card
|
||||
|
||||
Scenario: AgentCardExtension rejects empty uri
|
||||
When I try to create an AgentCardExtension with empty uri
|
||||
Then a validation error should be raised for agent card
|
||||
|
||||
Scenario: AgentCardSecurityScheme rejects empty type
|
||||
When I try to create an AgentCardSecurityScheme with empty type
|
||||
Then a validation error should be raised for agent card
|
||||
|
||||
Scenario: AgentCardInterface rejects empty protocol
|
||||
When I try to create an AgentCardInterface with empty protocol
|
||||
Then a validation error should be raised for agent card
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# A2A conformance validation
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: Valid Agent Card passes conformance validation
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
And I validate the agent card conformance
|
||||
Then the conformance violations should be empty
|
||||
|
||||
Scenario: Agent Card with empty url fails conformance
|
||||
Given an agent card with empty url
|
||||
When I validate the agent card conformance
|
||||
Then the conformance violations should include "url is required"
|
||||
|
||||
Scenario: Agent Card with unsupported version fails conformance
|
||||
Given an agent card with version "99.0"
|
||||
When I validate the agent card conformance
|
||||
Then the conformance violations should include "not in supported versions"
|
||||
|
||||
Scenario: Agent Card with empty supportedVersions fails conformance
|
||||
Given an agent card with empty supported versions
|
||||
When I validate the agent card conformance
|
||||
Then the conformance violations should include "supportedVersions must not be empty"
|
||||
|
||||
Scenario: Conformance validation rejects non-AgentCard argument
|
||||
When I try to validate conformance of a non-AgentCard object
|
||||
Then a TypeError should be raised for conformance validation
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Serialization
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: Agent Card serializes to dict
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
And I serialize the agent card to dict
|
||||
Then the serialized dict should have key "name"
|
||||
And the serialized dict should have key "version"
|
||||
And the serialized dict should have key "capabilities"
|
||||
And the serialized dict should have key "skills"
|
||||
And the serialized dict should have key "supportedVersions"
|
||||
And the serialized dict should have key "supportedOperations"
|
||||
|
||||
Scenario: agent_card_to_dict rejects non-AgentCard argument
|
||||
When I try to serialize a non-AgentCard object
|
||||
Then a TypeError should be raised for serialization
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Discovery endpoint (ASGI)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: Discovery endpoint returns Agent Card with skills
|
||||
Given a running ASGI test client with facade
|
||||
When I request GET /.well-known/agent.json
|
||||
Then the response status code should be 200
|
||||
And the response body should contain "CleverAgents"
|
||||
And the response body should contain "skills"
|
||||
And the response body should contain "supportedVersions"
|
||||
And the response body should contain "supportedOperations"
|
||||
|
||||
Scenario: Discovery endpoint Agent Card has correct structure
|
||||
Given a running ASGI test client with facade
|
||||
When I request GET /.well-known/agent.json and parse JSON
|
||||
Then the agent card JSON should have key "name" with value "CleverAgents"
|
||||
And the agent card JSON should have key "version" with value "1.0"
|
||||
And the agent card JSON "capabilities" should be a dict
|
||||
And the agent card JSON "skills" should be a non-empty list
|
||||
And the agent card JSON "supportedVersions" should be a non-empty list
|
||||
|
||||
Scenario: Discovery endpoint Agent Card lists extension operations
|
||||
Given a running ASGI test client with facade
|
||||
When I request GET /.well-known/agent.json and parse JSON
|
||||
Then the agent card JSON "supportedOperations" should be a non-empty list
|
||||
And all supported operations should start with "_cleveragents/"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Skill enumeration from facade
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: Agent Card enumerates plan lifecycle skill
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card skills should include id "plan-lifecycle"
|
||||
|
||||
Scenario: Agent Card enumerates registry skill
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card skills should include id "registry-crud"
|
||||
|
||||
Scenario: Agent Card enumerates context skill
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card skills should include id "context-mgmt"
|
||||
|
||||
Scenario: Agent Card enumerates health skill
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card skills should include id "health-diagnostics"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Additional conformance edge cases
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: Agent Card with empty defaultInputModes fails conformance
|
||||
Given an agent card with empty default input modes
|
||||
When I validate the agent card conformance
|
||||
Then the conformance violations should include "defaultInputModes must not be empty"
|
||||
|
||||
Scenario: Agent Card with empty defaultOutputModes fails conformance
|
||||
Given an agent card with empty default output modes
|
||||
When I validate the agent card conformance
|
||||
Then the conformance violations should include "defaultOutputModes must not be empty"
|
||||
|
||||
Scenario: Agent Card enumerates entity sync skill
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card skills should include id "entity-sync"
|
||||
|
||||
Scenario: Agent Card enumerates namespace management skill
|
||||
Given an A2aLocalFacade for agent card testing
|
||||
When I build an agent card from the facade
|
||||
Then the agent card skills should include id "namespace-mgmt"
|
||||
@@ -58,17 +58,31 @@ Feature: ASGI Server Lifecycle
|
||||
Given a running ASGI test client
|
||||
When I POST a JSON-RPC request to /a2a with operation "_cleveragents/health/check"
|
||||
Then the response status code should be 200
|
||||
And the A2A response status should be "healthy"
|
||||
And the A2A response result status should be "healthy"
|
||||
|
||||
Scenario: A2A endpoint returns error for unknown operation
|
||||
Scenario: A2A endpoint returns HTTP 200 with JSON-RPC error for unknown operation
|
||||
Given a running ASGI test client
|
||||
When I POST a JSON-RPC request to /a2a with operation "nonexistent.operation"
|
||||
Then the response status code should be 404
|
||||
Then the response status code should be 200
|
||||
And the response body should contain "-32601"
|
||||
|
||||
Scenario: A2A endpoint returns 400 for malformed request
|
||||
Scenario: A2A endpoint returns HTTP 200 with JSON-RPC error for malformed request
|
||||
Given a running ASGI test client
|
||||
When I POST a malformed JSON body to /a2a
|
||||
Then the response status code should be 400
|
||||
Then the response status code should be 200
|
||||
And the response body should contain "-32600"
|
||||
|
||||
Scenario: A2A endpoint returns -32700 parse error for invalid JSON bytes
|
||||
Given a running ASGI test client
|
||||
When I POST invalid JSON bytes to /a2a
|
||||
Then the response status code should be 200
|
||||
And the response body should contain "-32700"
|
||||
|
||||
Scenario: A2A endpoint returns -32603 internal error when dispatch raises
|
||||
Given a running ASGI test client with a failing facade
|
||||
When I POST a JSON-RPC request to /a2a with operation "_cleveragents/health/check"
|
||||
Then the response status code should be 200
|
||||
And the response body should contain "-32603"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# ServerLifecycle construction
|
||||
@@ -143,6 +157,11 @@ Feature: ASGI Server Lifecycle
|
||||
And I invoke the installed SIGTERM handler
|
||||
Then the mocked server should_exit should be true
|
||||
|
||||
Scenario: ServerLifecycle request_shutdown is a no-op when server not started
|
||||
Given a fresh ServerLifecycle
|
||||
When I call request_shutdown on the lifecycle
|
||||
Then the lifecycle should not be started
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# run_server convenience function
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
"""Step definitions for ``agent_card.feature``.
|
||||
|
||||
Tests Agent Card model construction, conformance validation,
|
||||
serialization, discovery endpoint, and skill enumeration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from behave.runner import Context
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cleveragents.a2a.agent_card import (
|
||||
AgentCard,
|
||||
AgentCardExtension,
|
||||
AgentCardInterface,
|
||||
AgentCardSecurityScheme,
|
||||
AgentCardSkill,
|
||||
agent_card_to_dict,
|
||||
build_agent_card,
|
||||
validate_agent_card_conformance,
|
||||
)
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("an A2aLocalFacade for agent card testing")
|
||||
def step_given_facade_for_card(context: Context) -> None:
|
||||
context.facade = A2aLocalFacade()
|
||||
|
||||
|
||||
@given("an agent card with empty url")
|
||||
def step_given_card_empty_url(context: Context) -> None:
|
||||
context.agent_card = AgentCard(
|
||||
name="Test",
|
||||
version="1.0",
|
||||
url="",
|
||||
)
|
||||
|
||||
|
||||
@given('an agent card with version "{version}"')
|
||||
def step_given_card_version(context: Context, version: str) -> None:
|
||||
context.agent_card = AgentCard(
|
||||
name="Test",
|
||||
version=version,
|
||||
url="http://localhost/a2a",
|
||||
)
|
||||
|
||||
|
||||
@given("an agent card with empty supported versions")
|
||||
def step_given_card_empty_supported_versions(context: Context) -> None:
|
||||
context.agent_card = AgentCard(
|
||||
name="Test",
|
||||
version="1.0",
|
||||
url="http://localhost/a2a",
|
||||
supportedVersions=[],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps -- build
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I build an agent card from the facade")
|
||||
def step_build_card_from_facade(context: Context) -> None:
|
||||
context.agent_card = build_agent_card(facade=context.facade)
|
||||
|
||||
|
||||
@when("I build an agent card without a facade")
|
||||
def step_build_card_no_facade(context: Context) -> None:
|
||||
context.agent_card = build_agent_card()
|
||||
|
||||
|
||||
@when("I try to build an agent card with an empty host")
|
||||
def step_try_build_card_empty_host(context: Context) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
build_agent_card(host="")
|
||||
except ValueError as exc:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
@when("I try to build an agent card with port {port:d}")
|
||||
def step_try_build_card_bad_port(context: Context, port: int) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
build_agent_card(port=port)
|
||||
except ValueError as exc:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps -- model validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I try to create an AgentCard with empty name")
|
||||
def step_try_card_empty_name(context: Context) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
AgentCard(name="", version="1.0")
|
||||
except ValidationError as exc:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
@when("I try to create an AgentCard with empty version")
|
||||
def step_try_card_empty_version(context: Context) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
AgentCard(name="Test", version="")
|
||||
except ValidationError as exc:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
@when("I try to create an AgentCardSkill with empty id")
|
||||
def step_try_skill_empty_id(context: Context) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
AgentCardSkill(id="", name="Test")
|
||||
except ValidationError as exc:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
@when("I try to create an AgentCardSkill with empty skill name")
|
||||
def step_try_skill_empty_name(context: Context) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
AgentCardSkill(id="test", name="")
|
||||
except ValidationError as exc:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
@when("I try to create an AgentCardExtension with empty uri")
|
||||
def step_try_extension_empty_uri(context: Context) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
AgentCardExtension(uri="")
|
||||
except ValidationError as exc:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
@when("I try to create an AgentCardSecurityScheme with empty type")
|
||||
def step_try_security_empty_type(context: Context) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
AgentCardSecurityScheme(type="")
|
||||
except ValidationError as exc:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
@when("I try to create an AgentCardInterface with empty protocol")
|
||||
def step_try_interface_empty_protocol(context: Context) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
AgentCardInterface(protocol="")
|
||||
except ValidationError as exc:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps -- conformance validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I validate the agent card conformance")
|
||||
def step_validate_conformance(context: Context) -> None:
|
||||
context.violations = validate_agent_card_conformance(context.agent_card)
|
||||
|
||||
|
||||
@when("I try to validate conformance of a non-AgentCard object")
|
||||
def step_try_validate_non_card(context: Context) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
validate_agent_card_conformance("not-a-card") # type: ignore[arg-type]
|
||||
except TypeError as exc:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps -- serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I serialize the agent card to dict")
|
||||
def step_serialize_card(context: Context) -> None:
|
||||
context.card_dict = agent_card_to_dict(context.agent_card)
|
||||
|
||||
|
||||
@when("I try to serialize a non-AgentCard object")
|
||||
def step_try_serialize_non_card(context: Context) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
agent_card_to_dict("not-a-card") # type: ignore[arg-type]
|
||||
except TypeError as exc:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps -- endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a running ASGI test client with facade")
|
||||
def step_given_test_client_with_facade(context: Context) -> None:
|
||||
facade = A2aLocalFacade()
|
||||
app = create_asgi_app(facade=facade)
|
||||
context.test_client = TestClient(app)
|
||||
|
||||
|
||||
@when("I request GET /.well-known/agent.json and parse JSON")
|
||||
def step_get_agent_card_parse(context: Context) -> None:
|
||||
context.response = context.test_client.get("/.well-known/agent.json")
|
||||
context.card_json = context.response.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps -- card properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the agent card name should be "{expected}"')
|
||||
def step_check_card_name(context: Context, expected: str) -> None:
|
||||
assert context.agent_card.name == expected, (
|
||||
f"Expected name '{expected}', got '{context.agent_card.name}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the agent card version should be "{expected}"')
|
||||
def step_check_card_version(context: Context, expected: str) -> None:
|
||||
assert context.agent_card.version == expected, (
|
||||
f"Expected version '{expected}', got '{context.agent_card.version}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the agent card should have a non-empty url")
|
||||
def step_check_card_url(context: Context) -> None:
|
||||
assert context.agent_card.url, "Expected non-empty url"
|
||||
|
||||
|
||||
@then("the agent card should have at least {count:d} skill")
|
||||
@then("the agent card should have at least {count:d} skills")
|
||||
def step_check_card_skills_count(context: Context, count: int) -> None:
|
||||
actual = len(context.agent_card.skills)
|
||||
assert actual >= count, f"Expected at least {count} skills, got {actual}"
|
||||
|
||||
|
||||
@then("the agent card should have {count:d} skills")
|
||||
def step_check_card_exact_skills(context: Context, count: int) -> None:
|
||||
actual = len(context.agent_card.skills)
|
||||
assert actual == count, f"Expected {count} skills, got {actual}"
|
||||
|
||||
|
||||
@then("the agent card should have at least {count:d} extension")
|
||||
@then("the agent card should have at least {count:d} extensions")
|
||||
def step_check_card_extensions_count(context: Context, count: int) -> None:
|
||||
actual = len(context.agent_card.extensions)
|
||||
assert actual >= count, f"Expected at least {count} extensions, got {actual}"
|
||||
|
||||
|
||||
@then("the agent card should have at least {count:d} security scheme")
|
||||
@then("the agent card should have at least {count:d} security schemes")
|
||||
def step_check_card_security_count(context: Context, count: int) -> None:
|
||||
actual = len(context.agent_card.securitySchemes)
|
||||
assert actual >= count, f"Expected at least {count} security schemes, got {actual}"
|
||||
|
||||
|
||||
@then("the agent card should have at least {count:d} interface")
|
||||
@then("the agent card should have at least {count:d} interfaces")
|
||||
def step_check_card_interfaces_count(context: Context, count: int) -> None:
|
||||
actual = len(context.agent_card.interfaces)
|
||||
assert actual >= count, f"Expected at least {count} interfaces, got {actual}"
|
||||
|
||||
|
||||
@then('the first interface protocol should be "{expected}"')
|
||||
def step_check_first_interface(context: Context, expected: str) -> None:
|
||||
assert context.agent_card.interfaces, "No interfaces found"
|
||||
assert context.agent_card.interfaces[0].protocol == expected
|
||||
|
||||
|
||||
@then('the agent card supported versions should contain "{version}"')
|
||||
def step_check_supported_versions(context: Context, version: str) -> None:
|
||||
assert version in context.agent_card.supportedVersions, (
|
||||
f"'{version}' not in {context.agent_card.supportedVersions}"
|
||||
)
|
||||
|
||||
|
||||
@then("the agent card should list extension operations")
|
||||
def step_check_extension_ops(context: Context) -> None:
|
||||
assert context.agent_card.supportedOperations, (
|
||||
"Expected non-empty supportedOperations"
|
||||
)
|
||||
for op in context.agent_card.supportedOperations:
|
||||
assert op.startswith("_cleveragents/"), f"Operation '{op}' has wrong prefix"
|
||||
|
||||
|
||||
@then('the agent card default input modes should contain "{mode}"')
|
||||
def step_check_input_modes(context: Context, mode: str) -> None:
|
||||
assert mode in context.agent_card.defaultInputModes
|
||||
|
||||
|
||||
@then('the agent card default output modes should contain "{mode}"')
|
||||
def step_check_output_modes(context: Context, mode: str) -> None:
|
||||
assert mode in context.agent_card.defaultOutputModes
|
||||
|
||||
|
||||
@then("the agent card streaming capability should be false")
|
||||
def step_check_streaming_false(context: Context) -> None:
|
||||
assert context.agent_card.capabilities.streaming is False
|
||||
|
||||
|
||||
@then("the agent card push notifications capability should be false")
|
||||
def step_check_push_false(context: Context) -> None:
|
||||
assert context.agent_card.capabilities.pushNotifications is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps -- validation errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("a ValueError should be raised for agent card")
|
||||
def step_check_value_error_card(context: Context) -> None:
|
||||
assert context.caught_exception is not None, "Expected ValueError"
|
||||
assert isinstance(context.caught_exception, ValueError)
|
||||
|
||||
|
||||
@then("a validation error should be raised for agent card")
|
||||
def step_check_validation_error_card(context: Context) -> None:
|
||||
assert context.caught_exception is not None, "Expected ValidationError"
|
||||
assert isinstance(context.caught_exception, ValidationError)
|
||||
|
||||
|
||||
@then("a TypeError should be raised for conformance validation")
|
||||
def step_check_type_error_conformance(context: Context) -> None:
|
||||
assert context.caught_exception is not None, "Expected TypeError"
|
||||
assert isinstance(context.caught_exception, TypeError)
|
||||
|
||||
|
||||
@then("a TypeError should be raised for serialization")
|
||||
def step_check_type_error_serialization(context: Context) -> None:
|
||||
assert context.caught_exception is not None, "Expected TypeError"
|
||||
assert isinstance(context.caught_exception, TypeError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps -- conformance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the conformance violations should be empty")
|
||||
def step_check_no_violations(context: Context) -> None:
|
||||
assert context.violations == [], (
|
||||
f"Expected no violations, got: {context.violations}"
|
||||
)
|
||||
|
||||
|
||||
@then('the conformance violations should include "{text}"')
|
||||
def step_check_violation_text(context: Context, text: str) -> None:
|
||||
joined = " | ".join(context.violations)
|
||||
assert any(text in v for v in context.violations), (
|
||||
f"Expected violation containing '{text}', got: {joined}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps -- serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the serialized dict should have key "{key}"')
|
||||
def step_check_dict_key(context: Context, key: str) -> None:
|
||||
assert key in context.card_dict, (
|
||||
f"Key '{key}' not in serialized dict: {list(context.card_dict.keys())}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps -- JSON endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the agent card JSON should have key "{key}" with value "{value}"')
|
||||
def step_check_json_key_value(context: Context, key: str, value: str) -> None:
|
||||
actual = context.card_json.get(key)
|
||||
assert actual == value, f"Expected '{key}'='{value}', got '{actual}'"
|
||||
|
||||
|
||||
@then('the agent card JSON "{key}" should be a dict')
|
||||
def step_check_json_dict(context: Context, key: str) -> None:
|
||||
val = context.card_json.get(key)
|
||||
assert isinstance(val, dict), f"Expected dict for '{key}', got {type(val)}"
|
||||
|
||||
|
||||
@then('the agent card JSON "{key}" should be a non-empty list')
|
||||
def step_check_json_list(context: Context, key: str) -> None:
|
||||
val = context.card_json.get(key)
|
||||
assert isinstance(val, list), f"Expected list for '{key}', got {type(val)}"
|
||||
assert len(val) > 0, f"Expected non-empty list for '{key}'"
|
||||
|
||||
|
||||
@then('all supported operations should start with "_cleveragents/"')
|
||||
def step_check_ops_prefix(context: Context) -> None:
|
||||
ops = context.card_json.get("supportedOperations", [])
|
||||
for op in ops:
|
||||
assert op.startswith("_cleveragents/"), f"Operation '{op}' has wrong prefix"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps -- skill enumeration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the agent card skills should include id "{skill_id}"')
|
||||
def step_check_skill_id(context: Context, skill_id: str) -> None:
|
||||
ids = [s.id for s in context.agent_card.skills]
|
||||
assert skill_id in ids, f"Skill '{skill_id}' not in {ids}"
|
||||
|
||||
|
||||
@given("an agent card with empty default input modes")
|
||||
def step_given_card_empty_input_modes(context: Context) -> None:
|
||||
context.agent_card = AgentCard(
|
||||
name="Test",
|
||||
version="1.0",
|
||||
url="http://localhost/a2a",
|
||||
defaultInputModes=[],
|
||||
)
|
||||
|
||||
|
||||
@given("an agent card with empty default output modes")
|
||||
def step_given_card_empty_output_modes(context: Context) -> None:
|
||||
context.agent_card = AgentCard(
|
||||
name="Test",
|
||||
version="1.0",
|
||||
url="http://localhost/a2a",
|
||||
defaultOutputModes=[],
|
||||
)
|
||||
@@ -8,10 +8,13 @@ and Settings-based server configuration.
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import signal
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, use_step_matcher, when # type: ignore[import-untyped]
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from behave.runner import Context
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.config.settings import Settings
|
||||
@@ -77,10 +80,7 @@ def step_check_value_error_port(context: Any) -> None:
|
||||
assert isinstance(context.caught_exception, ValueError)
|
||||
|
||||
|
||||
use_step_matcher("re")
|
||||
|
||||
|
||||
@when('I try to create an ASGI application with host "(?P<host>[^"]*)"')
|
||||
@when('I try to create an ASGI application with host "{host}"')
|
||||
def step_create_asgi_bad_host(context: Any, host: str) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
@@ -98,9 +98,6 @@ def step_create_asgi_empty_host(context: Any) -> None:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
use_step_matcher("parse")
|
||||
|
||||
|
||||
@then("a ValueError should be raised for invalid host")
|
||||
def step_check_value_error_host(context: Any) -> None:
|
||||
assert context.caught_exception is not None, "Expected ValueError"
|
||||
@@ -114,8 +111,6 @@ def step_check_value_error_host(context: Any) -> None:
|
||||
|
||||
@given("a running ASGI test client")
|
||||
def step_given_test_client(context: Any) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app = create_asgi_app()
|
||||
context.test_client = TestClient(app)
|
||||
|
||||
@@ -132,19 +127,36 @@ def step_get_agent_card(context: Any) -> None:
|
||||
|
||||
@when('I POST a JSON-RPC request to /a2a with operation "{operation}"')
|
||||
def step_post_a2a(context: Any, operation: str) -> None:
|
||||
# JSON-RPC 2.0 wire format: use "method" field
|
||||
payload = {"jsonrpc": "2.0", "method": operation, "params": {}}
|
||||
context.response = context.test_client.post("/a2a", json=payload)
|
||||
|
||||
|
||||
@when("I POST a malformed JSON body to /a2a")
|
||||
def step_post_a2a_malformed(context: Any) -> None:
|
||||
# Send a body that cannot be parsed into A2aRequest (missing method)
|
||||
context.response = context.test_client.post("/a2a", json={"bad": "data"})
|
||||
|
||||
|
||||
@when("I POST invalid JSON bytes to /a2a")
|
||||
def step_post_a2a_invalid_bytes(context: Context) -> None:
|
||||
# Send raw bytes that cannot be parsed as JSON to trigger the -32700 Parse error path
|
||||
context.response = context.test_client.post(
|
||||
"/a2a",
|
||||
content=b"not-valid-json",
|
||||
content=b"not valid json {{{",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
@given("a running ASGI test client with a failing facade")
|
||||
def step_given_failing_facade_client(context: Context) -> None:
|
||||
# Monkey-patch dispatch on a real facade instance to raise an unexpected error
|
||||
facade = A2aLocalFacade()
|
||||
facade.dispatch = MagicMock(side_effect=RuntimeError("unexpected!"))
|
||||
app = create_asgi_app(facade=facade)
|
||||
context.test_client = TestClient(app)
|
||||
|
||||
|
||||
@then("the response status code should be {code:d}")
|
||||
def step_check_status_code(context: Any, code: int) -> None:
|
||||
assert context.response.status_code == code, (
|
||||
@@ -158,8 +170,8 @@ def step_check_response_body(context: Any, text: str) -> None:
|
||||
assert text in body, f"Expected '{text}' in response body: {body}"
|
||||
|
||||
|
||||
@then('the A2A response status should be "{expected_status}"')
|
||||
def step_check_a2a_status(context: Any, expected_status: str) -> None:
|
||||
@then('the A2A response result status should be "{expected_status}"')
|
||||
def step_check_a2a_result_status(context: Any, expected_status: str) -> None:
|
||||
data = context.response.json()
|
||||
result = data.get("result") or {}
|
||||
assert result.get("status") == expected_status, (
|
||||
@@ -197,10 +209,7 @@ def step_check_not_stopped(context: Any) -> None:
|
||||
assert not context.lifecycle.is_stopped
|
||||
|
||||
|
||||
use_step_matcher("re")
|
||||
|
||||
|
||||
@when('I try to create a ServerLifecycle with host "(?P<host>[^"]*)"')
|
||||
@when('I try to create a ServerLifecycle with host "{host}"')
|
||||
def step_create_lifecycle_bad_host(context: Any, host: str) -> None:
|
||||
context.caught_exception = None
|
||||
try:
|
||||
@@ -218,9 +227,6 @@ def step_create_lifecycle_empty_host(context: Any) -> None:
|
||||
context.caught_exception = exc
|
||||
|
||||
|
||||
use_step_matcher("parse")
|
||||
|
||||
|
||||
@when("I try to create a ServerLifecycle with port {port:d}")
|
||||
def step_create_lifecycle_bad_port(context: Any, port: int) -> None:
|
||||
context.caught_exception = None
|
||||
@@ -267,6 +273,29 @@ def step_check_settings_port(context: Any, expected: int) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a fresh ServerLifecycle")
|
||||
def step_fresh_lifecycle(context: Context) -> None:
|
||||
context.lifecycle = ServerLifecycle(host="127.0.0.1", port=9990)
|
||||
|
||||
|
||||
@when("I start the lifecycle with mocked uvicorn")
|
||||
def step_start_lifecycle_mocked(context: Context) -> None:
|
||||
original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||
original_sigint = signal.getsignal(signal.SIGINT)
|
||||
try:
|
||||
server_patch = patch(
|
||||
"cleveragents.infrastructure.server.server_lifecycle.uvicorn.Server"
|
||||
)
|
||||
with server_patch as mock_server_cls:
|
||||
mock_server = MagicMock()
|
||||
mock_server.run.return_value = None
|
||||
mock_server_cls.return_value = mock_server
|
||||
context.lifecycle.start()
|
||||
finally:
|
||||
signal.signal(signal.SIGTERM, original_sigterm)
|
||||
signal.signal(signal.SIGINT, original_sigint)
|
||||
|
||||
|
||||
@given("a ServerLifecycle with a mock uvicorn server")
|
||||
def step_lifecycle_with_mock_server(context: Any) -> None:
|
||||
lifecycle = ServerLifecycle(host="127.0.0.1", port=9999)
|
||||
@@ -316,8 +345,6 @@ def step_check_runtime_error(context: Any) -> None:
|
||||
|
||||
@given("a ServerLifecycle with mocked uvicorn server run")
|
||||
def step_lifecycle_mocked_uvicorn(context: Any) -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
lifecycle = ServerLifecycle(host="127.0.0.1", port=9997)
|
||||
# Patch uvicorn.Server so .run() is a no-op and Config is a mock
|
||||
mock_server_instance = MagicMock()
|
||||
@@ -372,8 +399,6 @@ def step_check_uvicorn_run_called(context: Any) -> None:
|
||||
|
||||
@given("a ServerLifecycle with mocked uvicorn and signal handlers")
|
||||
def step_lifecycle_mocked_with_signals(context: Any) -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
lifecycle = ServerLifecycle(host="127.0.0.1", port=9996)
|
||||
mock_server_instance = MagicMock()
|
||||
mock_server_instance.run = MagicMock()
|
||||
@@ -428,8 +453,6 @@ def step_check_mock_exit_true(context: Any) -> None:
|
||||
|
||||
@when("I call run_server with mocked ServerLifecycle")
|
||||
def step_run_server_mocked(context: Any) -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
mock_lifecycle_cls = MagicMock()
|
||||
mock_lifecycle_instance = MagicMock()
|
||||
mock_lifecycle_cls.return_value = mock_lifecycle_instance
|
||||
@@ -464,8 +487,6 @@ def step_check_start_called(context: Any) -> None:
|
||||
|
||||
@when('I call run_server with host "{host}" and port {port:d} using mocked lifecycle')
|
||||
def step_run_server_with_overrides(context: Any, host: str, port: int) -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
mock_lifecycle_cls = MagicMock()
|
||||
mock_lifecycle_instance = MagicMock()
|
||||
mock_lifecycle_cls.return_value = mock_lifecycle_instance
|
||||
@@ -508,8 +529,6 @@ def step_install_signals_non_main(context: Any) -> None:
|
||||
result = {}
|
||||
|
||||
def run_in_thread() -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
mock_sig = MagicMock()
|
||||
with patch(
|
||||
"cleveragents.infrastructure.server.server_lifecycle.signal.signal",
|
||||
|
||||
@@ -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,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Integration test helper for Agent Card discovery tests.
|
||||
|
||||
Each sub-command exercises a real import / instantiation path and prints
|
||||
a unique sentinel on success. Robot tests parse the output for that
|
||||
sentinel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
def _test_build_card() -> None:
|
||||
"""Build an Agent Card from a facade and verify structure."""
|
||||
from cleveragents.a2a.agent_card import build_agent_card
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
|
||||
facade = A2aLocalFacade()
|
||||
card = build_agent_card(facade=facade)
|
||||
assert card.name == "CleverAgents"
|
||||
assert card.version == "1.0"
|
||||
assert card.url
|
||||
assert len(card.skills) > 0
|
||||
assert len(card.extensions) > 0
|
||||
assert len(card.supportedVersions) > 0
|
||||
assert len(card.supportedOperations) > 0
|
||||
print("build-card-ok")
|
||||
|
||||
|
||||
def _test_conformance() -> None:
|
||||
"""Validate Agent Card A2A conformance."""
|
||||
from cleveragents.a2a.agent_card import (
|
||||
build_agent_card,
|
||||
validate_agent_card_conformance,
|
||||
)
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
|
||||
facade = A2aLocalFacade()
|
||||
card = build_agent_card(facade=facade)
|
||||
violations = validate_agent_card_conformance(card)
|
||||
assert violations == [], f"Conformance violations: {violations}"
|
||||
print("conformance-ok")
|
||||
|
||||
|
||||
def _test_endpoint() -> None:
|
||||
"""Exercise the /.well-known/agent.json endpoint via TestClient."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
|
||||
|
||||
facade = A2aLocalFacade()
|
||||
app = create_asgi_app(facade=facade)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/.well-known/agent.json")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "CleverAgents"
|
||||
assert "skills" in data
|
||||
assert "supportedVersions" in data
|
||||
assert "supportedOperations" in data
|
||||
assert isinstance(data["skills"], list)
|
||||
assert len(data["skills"]) > 0
|
||||
assert isinstance(data["supportedOperations"], list)
|
||||
assert len(data["supportedOperations"]) > 0
|
||||
# Verify all operations have the correct prefix
|
||||
for op in data["supportedOperations"]:
|
||||
assert op.startswith("_cleveragents/"), f"Bad prefix: {op}"
|
||||
print("endpoint-ok")
|
||||
|
||||
|
||||
def _test_serialization() -> None:
|
||||
"""Verify Agent Card serializes to valid JSON dict."""
|
||||
from cleveragents.a2a.agent_card import (
|
||||
agent_card_to_dict,
|
||||
build_agent_card,
|
||||
)
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
|
||||
facade = A2aLocalFacade()
|
||||
card = build_agent_card(facade=facade)
|
||||
data = agent_card_to_dict(card)
|
||||
# Round-trip through JSON to ensure serializability
|
||||
serialized = json.dumps(data)
|
||||
parsed = json.loads(serialized)
|
||||
assert parsed["name"] == "CleverAgents"
|
||||
assert "skills" in parsed
|
||||
assert "supportedOperations" in parsed
|
||||
print("serialization-ok")
|
||||
|
||||
|
||||
def _test_skill_enumeration() -> None:
|
||||
"""Verify skills are correctly enumerated from facade operations."""
|
||||
from cleveragents.a2a.agent_card import build_agent_card
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
|
||||
facade = A2aLocalFacade()
|
||||
card = build_agent_card(facade=facade)
|
||||
skill_ids = [s.id for s in card.skills]
|
||||
assert "plan-lifecycle" in skill_ids, f"Missing plan-lifecycle: {skill_ids}"
|
||||
assert "registry-crud" in skill_ids, f"Missing registry-crud: {skill_ids}"
|
||||
assert "context-mgmt" in skill_ids, f"Missing context-mgmt: {skill_ids}"
|
||||
assert "health-diagnostics" in skill_ids, f"Missing health-diagnostics: {skill_ids}"
|
||||
print("skill-enumeration-ok")
|
||||
|
||||
|
||||
def _test_version_info() -> None:
|
||||
"""Verify version negotiation information in Agent Card."""
|
||||
from cleveragents.a2a.agent_card import build_agent_card
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import A2aVersion
|
||||
|
||||
facade = A2aLocalFacade()
|
||||
card = build_agent_card(facade=facade)
|
||||
assert card.version == A2aVersion.CURRENT
|
||||
assert A2aVersion.CURRENT in card.supportedVersions
|
||||
for v in A2aVersion.SUPPORTED:
|
||||
assert v in card.supportedVersions
|
||||
print("version-info-ok")
|
||||
|
||||
|
||||
_COMMANDS: dict[str, Callable[[], None]] = {
|
||||
"build-card": _test_build_card,
|
||||
"conformance": _test_conformance,
|
||||
"endpoint": _test_endpoint,
|
||||
"serialization": _test_serialization,
|
||||
"skill-enumeration": _test_skill_enumeration,
|
||||
"version-info": _test_version_info,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
||||
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
handler = _COMMANDS[sys.argv[1]]
|
||||
handler()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -9,6 +9,7 @@ sentinel.
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
def _test_create_app() -> None:
|
||||
@@ -59,11 +60,18 @@ def _test_a2a_dispatch() -> None:
|
||||
|
||||
app = create_asgi_app()
|
||||
client = TestClient(app)
|
||||
payload = {"jsonrpc": "2.0", "method": "_cleveragents/health/check", "params": {}}
|
||||
# JSON-RPC 2.0 wire format: use "method" field
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "_cleveragents/health/check",
|
||||
"params": {},
|
||||
}
|
||||
resp = client.post("/a2a", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["result"]["status"] == "healthy"
|
||||
# JSON-RPC 2.0 response: result is nested under "result"
|
||||
result = data.get("result") or {}
|
||||
assert result.get("status") == "healthy", f"Unexpected response: {data}"
|
||||
print("a2a-dispatch-ok")
|
||||
|
||||
|
||||
@@ -94,7 +102,7 @@ def _test_lifecycle_shutdown() -> None:
|
||||
print("lifecycle-shutdown-ok")
|
||||
|
||||
|
||||
_COMMANDS: dict[str, object] = {
|
||||
_COMMANDS: dict[str, Callable[[], None]] = {
|
||||
"create-app": _test_create_app,
|
||||
"health-endpoint": _test_health_endpoint,
|
||||
"agent-card": _test_agent_card,
|
||||
@@ -110,7 +118,7 @@ def main() -> None:
|
||||
sys.exit(2)
|
||||
|
||||
handler = _COMMANDS[sys.argv[1]]
|
||||
handler() # type: ignore[operator]
|
||||
handler()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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,384 @@
|
||||
"""A2A Agent Card model and factory.
|
||||
|
||||
Provides Pydantic v2 models for the A2A Agent Card specification and a
|
||||
factory function that builds a card from the facade's supported
|
||||
operations. The card is served at ``/.well-known/agent.json`` per the
|
||||
A2A specification (ADR-047).
|
||||
|
||||
The :func:`validate_agent_card_conformance` function validates that a
|
||||
card satisfies the mandatory fields required by the A2A specification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from cleveragents.a2a.models import A2aVersion
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Operation-to-skill mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SKILL_DEFINITIONS: list[dict[str, str]] = [
|
||||
{
|
||||
"id": "plan-lifecycle",
|
||||
"name": "Plan Lifecycle Management",
|
||||
"description": "Create, execute, cancel, and monitor agent plans",
|
||||
"prefix": "_cleveragents/plan/",
|
||||
},
|
||||
{
|
||||
"id": "registry-crud",
|
||||
"name": "Entity Registry Operations",
|
||||
"description": "List and manage tools, resources, actors, and other entities",
|
||||
"prefix": "_cleveragents/registry/",
|
||||
},
|
||||
{
|
||||
"id": "context-mgmt",
|
||||
"name": "Context Assembly",
|
||||
"description": "Inspect, set, and simulate project context",
|
||||
"prefix": "_cleveragents/context/",
|
||||
},
|
||||
{
|
||||
"id": "entity-sync",
|
||||
"name": "Entity Synchronization",
|
||||
"description": "Pull, push, and check sync status across namespaces",
|
||||
"prefix": "_cleveragents/sync/",
|
||||
},
|
||||
{
|
||||
"id": "namespace-mgmt",
|
||||
"name": "Namespace Management",
|
||||
"description": "List, inspect, and manage namespaces and members",
|
||||
"prefix": "_cleveragents/namespace/",
|
||||
},
|
||||
{
|
||||
"id": "health-diagnostics",
|
||||
"name": "Health and Diagnostics",
|
||||
"description": "Health checks and diagnostic reporting",
|
||||
"prefix": "_cleveragents/health/",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentCardSkill(BaseModel):
|
||||
"""A skill advertised in the Agent Card."""
|
||||
|
||||
model_config = ConfigDict(strict=False)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
|
||||
@field_validator("id", "name")
|
||||
@classmethod
|
||||
def _must_be_non_empty(cls, value: str) -> str:
|
||||
if not value or not value.strip():
|
||||
raise ValueError("field must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
class AgentCardCapabilities(BaseModel):
|
||||
"""Capability flags for the Agent Card."""
|
||||
|
||||
model_config = ConfigDict(strict=False)
|
||||
|
||||
streaming: bool = False
|
||||
pushNotifications: bool = False
|
||||
|
||||
|
||||
class AgentCardExtension(BaseModel):
|
||||
"""An extension namespace declaration."""
|
||||
|
||||
model_config = ConfigDict(strict=False)
|
||||
|
||||
uri: str
|
||||
description: str = ""
|
||||
|
||||
@field_validator("uri")
|
||||
@classmethod
|
||||
def _uri_non_empty(cls, value: str) -> str:
|
||||
if not value or not value.strip():
|
||||
raise ValueError("uri must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
class AgentCardSecurityScheme(BaseModel):
|
||||
"""A security scheme declared by the Agent Card."""
|
||||
|
||||
model_config = ConfigDict(strict=False)
|
||||
|
||||
type: str
|
||||
scheme: str = ""
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def _type_non_empty(cls, value: str) -> str:
|
||||
if not value or not value.strip():
|
||||
raise ValueError("type must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
class AgentCardInterface(BaseModel):
|
||||
"""A protocol interface declared by the Agent Card."""
|
||||
|
||||
model_config = ConfigDict(strict=False)
|
||||
|
||||
protocol: str
|
||||
url: str = ""
|
||||
|
||||
@field_validator("protocol")
|
||||
@classmethod
|
||||
def _protocol_non_empty(cls, value: str) -> str:
|
||||
if not value or not value.strip():
|
||||
raise ValueError("protocol must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
class AgentCard(BaseModel):
|
||||
"""A2A Agent Card describing the agent's capabilities.
|
||||
|
||||
Conforms to the A2A specification Agent Card schema. All required
|
||||
fields (``name``, ``version``, ``url``) are validated on construction.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=False)
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
url: str = ""
|
||||
version: str = A2aVersion.CURRENT
|
||||
capabilities: AgentCardCapabilities = AgentCardCapabilities()
|
||||
skills: list[AgentCardSkill] = []
|
||||
extensions: list[AgentCardExtension] = []
|
||||
securitySchemes: list[AgentCardSecurityScheme] = []
|
||||
interfaces: list[AgentCardInterface] = []
|
||||
defaultInputModes: list[str] = ["text"]
|
||||
defaultOutputModes: list[str] = ["text"]
|
||||
supportedVersions: list[str] = list(A2aVersion.SUPPORTED)
|
||||
supportedOperations: list[str] = []
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _name_non_empty(cls, value: str) -> str:
|
||||
if not value or not value.strip():
|
||||
raise ValueError("name must not be empty")
|
||||
return value
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def _version_non_empty(cls, value: str) -> str:
|
||||
if not value or not value.strip():
|
||||
raise ValueError("version must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_agent_card(
|
||||
*,
|
||||
facade: A2aLocalFacade | None = None,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8080,
|
||||
) -> AgentCard:
|
||||
"""Build an :class:`AgentCard` from a facade's supported operations.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
facade:
|
||||
The facade to introspect for supported operations. When *None*
|
||||
a minimal card with no skills is returned.
|
||||
host:
|
||||
Server bind address used to populate the card URL.
|
||||
port:
|
||||
Server bind port used to populate the card URL.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AgentCard
|
||||
A fully populated Agent Card.
|
||||
"""
|
||||
if not isinstance(host, str) or not host:
|
||||
raise ValueError("host must be a non-empty string")
|
||||
if not isinstance(port, int) or port < 1 or port > 65535:
|
||||
raise ValueError("port must be an integer between 1 and 65535")
|
||||
|
||||
# Per A2A spec: AgentCard.url is the base server URL (no path suffix).
|
||||
# When host is 0.0.0.0 (bind-all), substitute 127.0.0.1 for the card URL
|
||||
# since 0.0.0.0 is not a reachable address for clients.
|
||||
card_host = "127.0.0.1" if host == "0.0.0.0" else host
|
||||
server_url = f"http://{card_host}:{port}"
|
||||
endpoint_url = f"http://{card_host}:{port}/a2a"
|
||||
|
||||
operations: list[str] = []
|
||||
if facade is not None:
|
||||
operations = facade.list_operations()
|
||||
|
||||
# Build skills from operations that match known prefixes
|
||||
skills: list[AgentCardSkill] = []
|
||||
for defn in _SKILL_DEFINITIONS:
|
||||
prefix = defn["prefix"]
|
||||
matching = [op for op in operations if op.startswith(prefix)]
|
||||
if matching:
|
||||
skills.append(
|
||||
AgentCardSkill(
|
||||
id=defn["id"],
|
||||
name=defn["name"],
|
||||
description=defn["description"],
|
||||
)
|
||||
)
|
||||
|
||||
# Also include diagnostics operations in health skill
|
||||
diag_ops = [op for op in operations if op.startswith("_cleveragents/diagnostics/")]
|
||||
if diag_ops and not any(
|
||||
s.id == "health-diagnostics" for s in skills
|
||||
): # pragma: no cover
|
||||
skills.append(
|
||||
AgentCardSkill(
|
||||
id="health-diagnostics",
|
||||
name="Health and Diagnostics",
|
||||
description="Health checks and diagnostic reporting",
|
||||
)
|
||||
)
|
||||
|
||||
# Filter to extension operations only for the supportedOperations list
|
||||
extension_ops = [op for op in operations if op.startswith("_cleveragents/")]
|
||||
|
||||
extensions: list[AgentCardExtension] = []
|
||||
if extension_ops:
|
||||
extensions.append(
|
||||
AgentCardExtension(
|
||||
uri="urn:cleveragents:extensions:v1",
|
||||
description="CleverAgents platform operations",
|
||||
)
|
||||
)
|
||||
|
||||
return AgentCard(
|
||||
name="CleverAgents",
|
||||
description="AI-powered development assistant (actor-first)",
|
||||
url=server_url,
|
||||
version=A2aVersion.CURRENT,
|
||||
capabilities=AgentCardCapabilities(
|
||||
streaming=False,
|
||||
pushNotifications=False,
|
||||
),
|
||||
skills=skills,
|
||||
extensions=extensions,
|
||||
securitySchemes=[
|
||||
AgentCardSecurityScheme(type="http", scheme="bearer"),
|
||||
],
|
||||
interfaces=[
|
||||
AgentCardInterface(protocol="jsonrpc", url=endpoint_url),
|
||||
],
|
||||
defaultInputModes=["text"],
|
||||
defaultOutputModes=["text"],
|
||||
supportedVersions=list(A2aVersion.SUPPORTED),
|
||||
supportedOperations=sorted(extension_ops),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conformance validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def validate_agent_card_conformance(card: AgentCard) -> list[str]:
|
||||
"""Validate that *card* conforms to A2A specification requirements.
|
||||
|
||||
Returns a list of violation descriptions. An empty list means the
|
||||
card is conformant.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
card:
|
||||
The Agent Card to validate.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
Zero or more violation messages.
|
||||
"""
|
||||
if not isinstance(card, AgentCard):
|
||||
raise TypeError("card must be an AgentCard instance")
|
||||
|
||||
violations: list[str] = []
|
||||
|
||||
if not card.name or not card.name.strip():
|
||||
violations.append("name is required and must not be empty")
|
||||
|
||||
if not card.version or not card.version.strip():
|
||||
violations.append("version is required and must not be empty")
|
||||
|
||||
if not card.url or not card.url.strip():
|
||||
violations.append("url is required and must not be empty")
|
||||
|
||||
if card.version not in A2aVersion.SUPPORTED:
|
||||
violations.append(
|
||||
f"version '{card.version}' is not in supported versions "
|
||||
f"{list(A2aVersion.SUPPORTED)}"
|
||||
)
|
||||
|
||||
if not card.supportedVersions:
|
||||
violations.append("supportedVersions must not be empty")
|
||||
|
||||
if not card.defaultInputModes:
|
||||
violations.append("defaultInputModes must not be empty")
|
||||
|
||||
if not card.defaultOutputModes:
|
||||
violations.append("defaultOutputModes must not be empty")
|
||||
|
||||
# Validate each skill has required fields
|
||||
for idx, skill in enumerate(card.skills):
|
||||
if not skill.id or not skill.id.strip():
|
||||
violations.append(f"skills[{idx}].id must not be empty")
|
||||
if not skill.name or not skill.name.strip():
|
||||
violations.append(f"skills[{idx}].name must not be empty")
|
||||
|
||||
# Validate each extension has a URI
|
||||
for idx, ext in enumerate(card.extensions):
|
||||
if not ext.uri or not ext.uri.strip():
|
||||
violations.append(f"extensions[{idx}].uri must not be empty")
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def agent_card_to_dict(card: AgentCard) -> dict[str, Any]:
|
||||
"""Serialize an :class:`AgentCard` to a JSON-compatible dict.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
card:
|
||||
The Agent Card to serialize.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[str, Any]
|
||||
A dictionary suitable for JSON serialization.
|
||||
"""
|
||||
if not isinstance(card, AgentCard):
|
||||
raise TypeError("card must be an AgentCard instance")
|
||||
return card.model_dump()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentCard",
|
||||
"AgentCardCapabilities",
|
||||
"AgentCardExtension",
|
||||
"AgentCardInterface",
|
||||
"AgentCardSecurityScheme",
|
||||
"AgentCardSkill",
|
||||
"agent_card_to_dict",
|
||||
"build_agent_card",
|
||||
"validate_agent_card_conformance",
|
||||
]
|
||||
@@ -26,15 +26,15 @@ JSONRPC_VERSION: str = "2.0"
|
||||
|
||||
|
||||
class A2aVersion:
|
||||
"""A2A protocol version constants.
|
||||
"""CleverAgents A2A API version constants.
|
||||
|
||||
Kept for backward compatibility — the wire format now uses the
|
||||
JSON-RPC 2.0 ``jsonrpc`` field with value ``"2.0"`` rather than
|
||||
a proprietary ``a2a_version`` field.
|
||||
These identify the CleverAgents extension API version (used in the
|
||||
Agent Card ``version`` field), distinct from the JSON-RPC protocol
|
||||
version (``JSONRPC_VERSION = "2.0"``) used in request/response envelopes.
|
||||
"""
|
||||
|
||||
CURRENT: str = JSONRPC_VERSION
|
||||
SUPPORTED: tuple[str, ...] = (JSONRPC_VERSION,)
|
||||
CURRENT: str = "1.0"
|
||||
SUPPORTED: tuple[str, ...] = ("1.0",)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,52 +5,31 @@ endpoint (ADR-048). This module builds the FastAPI application,
|
||||
wires the health-check / Agent Card discovery endpoint, and exposes
|
||||
the A2A JSON-RPC dispatch route.
|
||||
|
||||
All A2A operations — both standard (``message/send``,
|
||||
``message/stream``) and ``_cleveragents/`` extension methods — flow
|
||||
All A2A operations -- both standard (``message/send``,
|
||||
``message/stream``) and ``_cleveragents/`` extension methods -- flow
|
||||
through the single ``/a2a`` POST endpoint. The Agent Card is served
|
||||
at ``/.well-known/agent.json`` per the A2A specification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from cleveragents.a2a.agent_card import (
|
||||
agent_card_to_dict,
|
||||
build_agent_card,
|
||||
validate_agent_card_conformance,
|
||||
)
|
||||
from cleveragents.a2a.errors import A2aOperationNotFoundError
|
||||
from cleveragents.a2a.facade import A2aLocalFacade
|
||||
from cleveragents.a2a.models import A2aRequest, A2aVersion
|
||||
|
||||
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Agent Card (served at /.well-known/agent.json per A2A spec)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_AGENT_CARD: dict[str, Any] = {
|
||||
"name": "CleverAgents",
|
||||
"description": "AI-powered development assistant (actor-first)",
|
||||
"url": "",
|
||||
"version": A2aVersion.CURRENT,
|
||||
"capabilities": {
|
||||
"streaming": False,
|
||||
"pushNotifications": False,
|
||||
},
|
||||
"skills": [],
|
||||
"defaultInputModes": ["text"],
|
||||
"defaultOutputModes": ["text"],
|
||||
}
|
||||
|
||||
|
||||
def _build_agent_card(host: str, port: int) -> dict[str, Any]:
|
||||
"""Return the Agent Card with the server URL populated."""
|
||||
card = dict(_AGENT_CARD)
|
||||
card["url"] = f"http://{host}:{port}/a2a"
|
||||
return card
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Application factory
|
||||
@@ -88,7 +67,23 @@ def create_asgi_app(
|
||||
raise ValueError("port must be an integer between 1 and 65535")
|
||||
|
||||
resolved_facade = facade if facade is not None else A2aLocalFacade()
|
||||
agent_card = _build_agent_card(host, port)
|
||||
|
||||
# Build the Agent Card from the facade's operations
|
||||
agent_card = build_agent_card(
|
||||
facade=resolved_facade,
|
||||
host=host,
|
||||
port=port,
|
||||
)
|
||||
|
||||
# Validate conformance at startup
|
||||
violations = validate_agent_card_conformance(agent_card)
|
||||
if violations:
|
||||
logger.warning(
|
||||
"a2a.server.agent_card_conformance_violations",
|
||||
violations=violations,
|
||||
)
|
||||
|
||||
agent_card_dict = agent_card_to_dict(agent_card)
|
||||
|
||||
application = FastAPI(
|
||||
title="CleverAgents A2A Server",
|
||||
@@ -104,7 +99,7 @@ def create_asgi_app(
|
||||
@application.get("/.well-known/agent.json")
|
||||
async def agent_card_endpoint() -> JSONResponse:
|
||||
"""Serve the A2A Agent Card for capability discovery."""
|
||||
return JSONResponse(content=agent_card)
|
||||
return JSONResponse(content=agent_card_dict)
|
||||
|
||||
@application.get("/health")
|
||||
async def health_endpoint() -> JSONResponse:
|
||||
@@ -120,56 +115,82 @@ def create_asgi_app(
|
||||
Accepts a JSON body conforming to the :class:`A2aRequest`
|
||||
schema, dispatches it through the facade, and returns the
|
||||
:class:`A2aResponse` envelope.
|
||||
|
||||
Per JSON-RPC 2.0 over HTTP convention, all responses use HTTP
|
||||
200 regardless of protocol-level errors. Error codes are
|
||||
expressed via the ``error`` field in the JSON body.
|
||||
"""
|
||||
# Step 1: Parse the raw JSON body.
|
||||
# Malformed JSON -> -32700 Parse error.
|
||||
try:
|
||||
body: dict[str, Any] = await request.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("a2a.server.parse_error", error=str(exc))
|
||||
except Exception:
|
||||
logger.warning("a2a.server.parse_error")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
status_code=200,
|
||||
content={
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {
|
||||
"code": -32700,
|
||||
"message": "Parse error: request body is not valid JSON",
|
||||
"message": "Parse error",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Accept "operation" as an alias for "method" (proprietary wire format).
|
||||
if "method" not in body and "operation" in body:
|
||||
body = {**body, "method": body["operation"]}
|
||||
|
||||
# Step 2: Validate the body against the A2aRequest schema.
|
||||
# Invalid schema -> -32600 Invalid Request.
|
||||
request_id: Any = body.get("id") if isinstance(body, dict) else None
|
||||
try:
|
||||
a2a_request = A2aRequest(**body)
|
||||
except Exception as exc:
|
||||
logger.warning("a2a.server.bad_request", error=str(exc))
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
status_code=200,
|
||||
content={
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {
|
||||
"code": -32600,
|
||||
"message": f"Invalid A2A request: {exc}",
|
||||
"message": "Invalid request body",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Step 3: Dispatch the request through the facade.
|
||||
try:
|
||||
response = resolved_facade.dispatch(a2a_request)
|
||||
return JSONResponse(content=response.model_dump(exclude_none=True))
|
||||
except A2aOperationNotFoundError as exc:
|
||||
except A2aOperationNotFoundError:
|
||||
logger.warning(
|
||||
"a2a.server.method_not_found",
|
||||
operation=a2a_request.method,
|
||||
method=a2a_request.method,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
status_code=200,
|
||||
content={
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": str(exc),
|
||||
"message": "Method not found",
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"a2a.server.internal_error",
|
||||
error=str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {
|
||||
"code": -32603,
|
||||
"message": "Internal error",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -164,7 +164,7 @@ class ServerLifecycle:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_server(
|
||||
def run_server( # pragma: no cover
|
||||
*,
|
||||
host: str | None = None,
|
||||
port: int | None = None,
|
||||
|
||||
Reference in New Issue
Block a user