diff --git a/features/agent_card.feature b/features/agent_card.feature new file mode 100644 index 000000000..aff27a37b --- /dev/null +++ b/features/agent_card.feature @@ -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" diff --git a/features/server_lifecycle.feature b/features/server_lifecycle.feature index 5f147466e..d4341e27f 100644 --- a/features/server_lifecycle.feature +++ b/features/server_lifecycle.feature @@ -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 # ----------------------------------------------------------------------- diff --git a/features/steps/agent_card_steps.py b/features/steps/agent_card_steps.py new file mode 100644 index 000000000..d371fc536 --- /dev/null +++ b/features/steps/agent_card_steps.py @@ -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=[], + ) diff --git a/features/steps/server_lifecycle_steps.py b/features/steps/server_lifecycle_steps.py index b9ab23004..284c49b25 100644 --- a/features/steps/server_lifecycle_steps.py +++ b/features/steps/server_lifecycle_steps.py @@ -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[^"]*)"') +@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[^"]*)"') +@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", diff --git a/robot/agent_card.robot b/robot/agent_card.robot new file mode 100644 index 000000000..b6eadbd6f --- /dev/null +++ b/robot/agent_card.robot @@ -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 diff --git a/robot/helper_agent_card.py b/robot/helper_agent_card.py new file mode 100644 index 000000000..e4609be72 --- /dev/null +++ b/robot/helper_agent_card.py @@ -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() diff --git a/robot/helper_server_lifecycle.py b/robot/helper_server_lifecycle.py index b412ad5e8..b77331cd8 100644 --- a/robot/helper_server_lifecycle.py +++ b/robot/helper_server_lifecycle.py @@ -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__": diff --git a/src/cleveragents/a2a/__init__.py b/src/cleveragents/a2a/__init__.py index 4b743333e..784e3f526 100644 --- a/src/cleveragents/a2a/__init__.py +++ b/src/cleveragents/a2a/__init__.py @@ -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", diff --git a/src/cleveragents/a2a/agent_card.py b/src/cleveragents/a2a/agent_card.py new file mode 100644 index 000000000..b825ba8ba --- /dev/null +++ b/src/cleveragents/a2a/agent_card.py @@ -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", +] diff --git a/src/cleveragents/a2a/models.py b/src/cleveragents/a2a/models.py index 36b2a7b3d..a36e8971a 100644 --- a/src/cleveragents/a2a/models.py +++ b/src/cleveragents/a2a/models.py @@ -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",) # --------------------------------------------------------------------------- diff --git a/src/cleveragents/infrastructure/server/asgi_app.py b/src/cleveragents/infrastructure/server/asgi_app.py index fe56c9960..bf8897495 100644 --- a/src/cleveragents/infrastructure/server/asgi_app.py +++ b/src/cleveragents/infrastructure/server/asgi_app.py @@ -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", }, }, ) diff --git a/src/cleveragents/infrastructure/server/server_lifecycle.py b/src/cleveragents/infrastructure/server/server_lifecycle.py index e808e1839..b22f6bfe0 100644 --- a/src/cleveragents/infrastructure/server/server_lifecycle.py +++ b/src/cleveragents/infrastructure/server/server_lifecycle.py @@ -164,7 +164,7 @@ class ServerLifecycle: # ------------------------------------------------------------------ -def run_server( +def run_server( # pragma: no cover *, host: str | None = None, port: int | None = None,