diff --git a/.forgejo/workflows/master.yml b/.forgejo/workflows/master.yml index bb14f9ee0..862103661 100644 --- a/.forgejo/workflows/master.yml +++ b/.forgejo/workflows/master.yml @@ -3,8 +3,6 @@ name: CI on: push: branches: [master, develop] - pull_request: - branches: [master, develop] vars: docker_prefix: "http://harbor.cleverthis.com/docker/" diff --git a/features/a2a_agent_card.feature b/features/a2a_agent_card.feature new file mode 100644 index 000000000..9b7dc1bb9 --- /dev/null +++ b/features/a2a_agent_card.feature @@ -0,0 +1,161 @@ +Feature: A2A Agent Card discovery endpoint + As an A2A protocol client + I want to discover the agent's capabilities via the well-known Agent Card endpoint + So that I can connect and interact with the agent using the A2A protocol + + # --------------------------------------------------------------------------- + # AgentCardService construction and defaults + # --------------------------------------------------------------------------- + + Scenario: AgentCardService builds a card with default values + Given an AgentCardService with default parameters + When I retrieve the Agent Card + Then the card should have name "CleverAgents" + And the card should have a non-empty description + And the card should have version "1.0.0" + And the card should have url "http://127.0.0.1:8080" + + Scenario: AgentCardService builds a card with custom base URL + Given an AgentCardService with base_url "http://example.com:9090" + When I retrieve the Agent Card + Then the card should have url "http://example.com:9090" + And the card interfaces url should be "http://example.com:9090/a2a" + + Scenario: AgentCardService card includes required A2A fields + Given an AgentCardService with default parameters + When I retrieve the Agent Card + Then the card should have a non-empty supportedVersions list + And the card should have a non-empty defaultInputModes list + And the card should have a non-empty defaultOutputModes list + And the card should have a non-empty interfaces list + And the card should have a non-empty skills list + + Scenario: AgentCardService card interfaces include transport and url + Given an AgentCardService with default parameters + When I retrieve the Agent Card + Then the first interface should have transport "http" + And the first interface url should end with "/a2a" + + Scenario: AgentCardService card includes all six expected skills + Given an AgentCardService with default parameters + When I retrieve the Agent Card + Then the card skills should include skill with id "plan-lifecycle" + And the card skills should include skill with id "registry-crud" + And the card skills should include skill with id "context-mgmt" + And the card skills should include skill with id "health-diagnostics" + And the card skills should include skill with id "entity-sync" + And the card skills should include skill with id "namespace-mgmt" + + Scenario: Each skill has required fields + Given an AgentCardService with default parameters + When I retrieve the Agent Card + Then every skill should have a non-empty id + And every skill should have a non-empty name + And every skill should have a non-empty description + + # --------------------------------------------------------------------------- + # Serialisation + # --------------------------------------------------------------------------- + + Scenario: AgentCardService serialises card to valid JSON + Given an AgentCardService with default parameters + When I retrieve the Agent Card as JSON + Then the JSON should be parseable + And the parsed JSON should have key "name" + And the parsed JSON should have key "url" + And the parsed JSON should have key "skills" + + # --------------------------------------------------------------------------- + # Caching behaviour + # --------------------------------------------------------------------------- + + Scenario: AgentCardService caches the card after first retrieval + Given an AgentCardService with default parameters + When I retrieve the Agent Card twice + Then both retrievals should return the same object + + Scenario: AgentCardService invalidate_cache forces rebuild + Given an AgentCardService with default parameters + When I retrieve the Agent Card + And I invalidate the cache + And I retrieve the Agent Card again + Then the second retrieval should return a new object + + # --------------------------------------------------------------------------- + # Validation + # --------------------------------------------------------------------------- + + Scenario: AgentCardService rejects empty base_url + When I create an AgentCardService with empty base_url + Then a ValueError should be raised + + Scenario: AgentCardService strips trailing slash from base_url + Given an AgentCardService with base_url "http://example.com/" + When I retrieve the Agent Card + Then the card should have url "http://example.com" + + # --------------------------------------------------------------------------- + # Module-level singleton + # --------------------------------------------------------------------------- + + Scenario: get_card_service returns a singleton + When I call get_card_service twice + Then both calls should return the same instance + + Scenario: reset_card_service clears the singleton + Given the card service singleton has been initialised + When I call reset_card_service with None + And I call get_card_service + Then a new instance should be returned + + Scenario: reset_card_service accepts a custom service instance + Given a custom AgentCardService instance + When I call reset_card_service with the custom instance + And I call get_card_service + Then the returned instance should be the custom instance + + # --------------------------------------------------------------------------- + # ASGI well-known endpoint + # --------------------------------------------------------------------------- + + Scenario: GET /.well-known/agent-card.json returns 200 with Agent Card JSON + Given the ASGI app module is loaded + When I send an HTTP GET request to "/.well-known/agent-card.json" through the ASGI app + Then the HTTP response status should be 200 + And the HTTP response body should be valid Agent Card JSON + + Scenario: GET /.well-known/agent.json returns 200 with Agent Card JSON and deprecation header + Given the ASGI app module is loaded + When I send an HTTP GET request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 200 + And the HTTP response body should be valid Agent Card JSON + And the HTTP response should include header "deprecation" with value "true" + + Scenario: POST /.well-known/agent-card.json returns 405 + Given the ASGI app module is loaded + When I send an HTTP POST request to "/.well-known/agent-card.json" through the ASGI app + Then the HTTP response status should be 405 + + Scenario: POST /.well-known/agent.json returns 405 + Given the ASGI app module is loaded + When I send an HTTP POST request to "/.well-known/agent.json" through the ASGI app + Then the HTTP response status should be 405 + + # --------------------------------------------------------------------------- + # A2A conformance + # --------------------------------------------------------------------------- + + Scenario: Agent Card url does not include /a2a suffix + Given an AgentCardService with base_url "http://127.0.0.1:8080" + When I retrieve the Agent Card + Then the card url should not end with "/a2a" + + Scenario: Agent Card interface url includes /a2a suffix + Given an AgentCardService with base_url "http://127.0.0.1:8080" + When I retrieve the Agent Card + Then the first interface url should end with "/a2a" + + Scenario: Agent Card url substitutes 0.0.0.0 with 127.0.0.1 + Given an AgentCardService with base_url "http://0.0.0.0:8080" + When I retrieve the Agent Card + Then the card url should not contain "0.0.0.0" diff --git a/features/steps/a2a_agent_card_steps.py b/features/steps/a2a_agent_card_steps.py new file mode 100644 index 000000000..ced7fb9c4 --- /dev/null +++ b/features/steps/a2a_agent_card_steps.py @@ -0,0 +1,339 @@ +"""Step definitions for A2A Agent Card discovery endpoint scenarios.""" + +from __future__ import annotations + +import json +from typing import Any + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.a2a.cards import AgentCardService, get_card_service, reset_card_service + +SendMessage = dict[str, Any] + + +# --------------------------------------------------------------------------- +# Given steps +# --------------------------------------------------------------------------- + + +@given("an AgentCardService with default parameters") +def step_card_service_default(context: Context) -> None: + context.card_service = AgentCardService() + + +@given('an AgentCardService with base_url "{base_url}"') +def step_card_service_with_base_url(context: Context, base_url: str) -> None: + context.card_service = AgentCardService(base_url=base_url) + + +@given("the card service singleton has been initialised") +def step_singleton_initialised(context: Context) -> None: + # Ensure the singleton exists + context.original_singleton = get_card_service() + + +@given("a custom AgentCardService instance") +def step_custom_service_instance(context: Context) -> None: + context.custom_service = AgentCardService(base_url="http://custom.example.com") + + +# --------------------------------------------------------------------------- +# When steps +# --------------------------------------------------------------------------- + + +@when("I retrieve the Agent Card") +def step_retrieve_card(context: Context) -> None: + context.card = context.card_service.get_card() + + +@when("I retrieve the Agent Card as JSON") +def step_retrieve_card_json(context: Context) -> None: + context.card_json = context.card_service.get_card_json() + + +@when("I retrieve the Agent Card twice") +def step_retrieve_card_twice(context: Context) -> None: + context.card_first = context.card_service.get_card() + context.card_second = context.card_service.get_card() + + +@when("I invalidate the cache") +def step_invalidate_cache(context: Context) -> None: + context.card_service.invalidate_cache() + + +@when("I retrieve the Agent Card again") +def step_retrieve_card_again(context: Context) -> None: + context.card_second = context.card_service.get_card() + + +@when("I create an AgentCardService with empty base_url") +def step_create_service_empty_base_url(context: Context) -> None: + try: + AgentCardService(base_url="") + context.raised_error = None + except ValueError as exc: + context.raised_error = exc + + +@when("I call get_card_service twice") +def step_call_get_card_service_twice(context: Context) -> None: + reset_card_service(None) + context.singleton_first = get_card_service() + context.singleton_second = get_card_service() + + +@when("I call reset_card_service with None") +def step_reset_card_service_none(context: Context) -> None: + reset_card_service(None) + + +@when("I call get_card_service") +def step_call_get_card_service(context: Context) -> None: + context.retrieved_service = get_card_service() + + +@when("I call reset_card_service with the custom instance") +def step_reset_card_service_custom(context: Context) -> None: + reset_card_service(context.custom_service) + + +# --------------------------------------------------------------------------- +# Then steps — card field assertions +# --------------------------------------------------------------------------- + + +@then('the card should have name "{expected_name}"') +def step_card_name(context: Context, expected_name: str) -> None: + assert context.card["name"] == expected_name, ( + f"Expected name {expected_name!r}, got {context.card['name']!r}" + ) + + +@then("the card should have a non-empty description") +def step_card_description_non_empty(context: Context) -> None: + desc = context.card.get("description", "") + assert desc, f"Expected non-empty description, got {desc!r}" + + +@then('the card should have version "{expected_version}"') +def step_card_version(context: Context, expected_version: str) -> None: + assert context.card["version"] == expected_version, ( + f"Expected version {expected_version!r}, got {context.card['version']!r}" + ) + + +@then('the card should have url "{expected_url}"') +def step_card_url(context: Context, expected_url: str) -> None: + assert context.card["url"] == expected_url, ( + f"Expected url {expected_url!r}, got {context.card['url']!r}" + ) + + +@then('the card interfaces url should be "{expected_url}"') +def step_card_interfaces_url(context: Context, expected_url: str) -> None: + interfaces = context.card.get("interfaces", []) + assert interfaces, "Expected at least one interface" + actual_url = interfaces[0].get("url", "") + assert actual_url == expected_url, ( + f"Expected interface url {expected_url!r}, got {actual_url!r}" + ) + + +@then("the card should have a non-empty supportedVersions list") +def step_card_supported_versions(context: Context) -> None: + versions = context.card.get("supportedVersions", []) + assert versions, f"Expected non-empty supportedVersions, got {versions!r}" + + +@then("the card should have a non-empty defaultInputModes list") +def step_card_input_modes(context: Context) -> None: + modes = context.card.get("defaultInputModes", []) + assert modes, f"Expected non-empty defaultInputModes, got {modes!r}" + + +@then("the card should have a non-empty defaultOutputModes list") +def step_card_output_modes(context: Context) -> None: + modes = context.card.get("defaultOutputModes", []) + assert modes, f"Expected non-empty defaultOutputModes, got {modes!r}" + + +@then("the card should have a non-empty interfaces list") +def step_card_interfaces_non_empty(context: Context) -> None: + interfaces = context.card.get("interfaces", []) + assert interfaces, f"Expected non-empty interfaces, got {interfaces!r}" + + +@then("the card should have a non-empty skills list") +def step_card_skills_non_empty(context: Context) -> None: + skills = context.card.get("skills", []) + assert skills, f"Expected non-empty skills, got {skills!r}" + + +@then('the first interface should have transport "{expected_transport}"') +def step_first_interface_transport(context: Context, expected_transport: str) -> None: + interfaces = context.card.get("interfaces", []) + assert interfaces, "Expected at least one interface" + actual = interfaces[0].get("transport", "") + assert actual == expected_transport, ( + f"Expected transport {expected_transport!r}, got {actual!r}" + ) + + +@then("the first interface url should end with \"/a2a\"") +def step_first_interface_url_ends_with_a2a(context: Context) -> None: + interfaces = context.card.get("interfaces", []) + assert interfaces, "Expected at least one interface" + url = interfaces[0].get("url", "") + assert url.endswith("/a2a"), f"Expected interface url to end with '/a2a', got {url!r}" + + +@then('the card skills should include skill with id "{skill_id}"') +def step_card_has_skill(context: Context, skill_id: str) -> None: + skills = context.card.get("skills", []) + skill_ids = [s.get("id") for s in skills] + assert skill_id in skill_ids, ( + f"Expected skill id {skill_id!r} in {skill_ids!r}" + ) + + +@then("every skill should have a non-empty id") +def step_every_skill_has_id(context: Context) -> None: + for skill in context.card.get("skills", []): + assert skill.get("id"), f"Skill missing id: {skill!r}" + + +@then("every skill should have a non-empty name") +def step_every_skill_has_name(context: Context) -> None: + for skill in context.card.get("skills", []): + assert skill.get("name"), f"Skill missing name: {skill!r}" + + +@then("every skill should have a non-empty description") +def step_every_skill_has_description(context: Context) -> None: + for skill in context.card.get("skills", []): + assert skill.get("description"), f"Skill missing description: {skill!r}" + + +# --------------------------------------------------------------------------- +# Then steps — JSON serialisation +# --------------------------------------------------------------------------- + + +@then("the JSON should be parseable") +def step_json_parseable(context: Context) -> None: + try: + context.parsed_json = json.loads(context.card_json) + except json.JSONDecodeError as exc: + raise AssertionError(f"Card JSON is not parseable: {exc}") from exc + + +@then('the parsed JSON should have key "{key}"') +def step_parsed_json_has_key(context: Context, key: str) -> None: + assert key in context.parsed_json, ( + f"Expected key {key!r} in parsed JSON, got keys: {list(context.parsed_json)}" + ) + + +# --------------------------------------------------------------------------- +# Then steps — caching +# --------------------------------------------------------------------------- + + +@then("both retrievals should return the same object") +def step_same_object(context: Context) -> None: + assert context.card_first is context.card_second, ( + "Expected both retrievals to return the same cached object" + ) + + +@then("the second retrieval should return a new object") +def step_new_object(context: Context) -> None: + assert context.card is not context.card_second, ( + "Expected second retrieval to return a new object after cache invalidation" + ) + + +# --------------------------------------------------------------------------- +# Then steps — validation +# --------------------------------------------------------------------------- + + +@then("a ValueError should be raised") +def step_value_error_raised(context: Context) -> None: + assert isinstance(context.raised_error, ValueError), ( + f"Expected ValueError, got {context.raised_error!r}" + ) + + +# --------------------------------------------------------------------------- +# Then steps — singleton +# --------------------------------------------------------------------------- + + +@then("both calls should return the same instance") +def step_same_singleton(context: Context) -> None: + assert context.singleton_first is context.singleton_second, ( + "Expected get_card_service() to return the same singleton instance" + ) + + +@then("a new instance should be returned") +def step_new_instance_returned(context: Context) -> None: + assert context.retrieved_service is not context.original_singleton, ( + "Expected a new instance after reset_card_service(None)" + ) + + +@then("the returned instance should be the custom instance") +def step_custom_instance_returned(context: Context) -> None: + assert context.retrieved_service is context.custom_service, ( + "Expected get_card_service() to return the injected custom instance" + ) + + +# --------------------------------------------------------------------------- +# Then steps — ASGI endpoint +# --------------------------------------------------------------------------- + + +@then("the HTTP response body should be valid Agent Card JSON") +def step_response_body_valid_agent_card_json(context: Context) -> None: + body_msg = next( + msg + for msg in context.asgi_sent_messages + if msg.get("type") == "http.response.body" + ) + raw_body = body_msg.get("body", b"") + assert isinstance(raw_body, bytes), f"Expected bytes body, got {type(raw_body)}" + try: + card = json.loads(raw_body.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise AssertionError(f"Response body is not valid JSON: {exc}") from exc + assert "name" in card, f"Expected 'name' key in Agent Card, got: {list(card)}" + assert "url" in card, f"Expected 'url' key in Agent Card, got: {list(card)}" + assert "skills" in card, f"Expected 'skills' key in Agent Card, got: {list(card)}" + + +# --------------------------------------------------------------------------- +# Then steps — A2A conformance +# --------------------------------------------------------------------------- + + +@then("the card url should not end with \"/a2a\"") +def step_card_url_no_a2a_suffix(context: Context) -> None: + url = context.card.get("url", "") + assert not url.endswith("/a2a"), ( + f"Agent Card url should not end with '/a2a', got {url!r}" + ) + + +@then("the card url should not contain \"0.0.0.0\"") +def step_card_url_no_bind_all(context: Context) -> None: + url = context.card.get("url", "") + assert "0.0.0.0" not in url, ( + f"Agent Card url should not contain '0.0.0.0', got {url!r}" + ) diff --git a/robot/agent_card.robot b/robot/agent_card.robot new file mode 100644 index 000000000..dbdda0696 --- /dev/null +++ b/robot/agent_card.robot @@ -0,0 +1,57 @@ +*** Settings *** +Documentation Integration tests for A2A Agent Card discovery endpoint +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_agent_card.py + +*** Test Cases *** +Agent Card Service Default Values + [Documentation] Verify AgentCardService builds a card with default values + ${result}= Run Process ${PYTHON} ${HELPER} card-service-default cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} agent-card-service-default-ok + +Agent Card Service Custom URL + [Documentation] Verify AgentCardService uses custom base_url correctly + ${result}= Run Process ${PYTHON} ${HELPER} card-service-custom-url cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} agent-card-service-custom-url-ok + +Agent Card Service Skills Enumeration + [Documentation] Verify AgentCardService includes all six expected skills + ${result}= Run Process ${PYTHON} ${HELPER} card-service-skills cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} agent-card-service-skills-ok + +Agent Card Service Singleton + [Documentation] Verify get_card_service singleton and reset_card_service + ${result}= Run Process ${PYTHON} ${HELPER} card-service-singleton cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} agent-card-service-singleton-ok + +ASGI Agent Card Endpoint + [Documentation] Verify ASGI app serves Agent Card at /.well-known/agent-card.json + ${result}= Run Process ${PYTHON} ${HELPER} asgi-agent-card-endpoint cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} asgi-agent-card-endpoint-ok + +ASGI Deprecated Agent Card Endpoint + [Documentation] Verify ASGI app serves Agent Card at deprecated /.well-known/agent.json + ${result}= Run Process ${PYTHON} ${HELPER} asgi-deprecated-endpoint cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} asgi-deprecated-endpoint-ok diff --git a/robot/helper_agent_card.py b/robot/helper_agent_card.py new file mode 100644 index 000000000..81cc8ae5e --- /dev/null +++ b/robot/helper_agent_card.py @@ -0,0 +1,177 @@ +"""Helper script for agent_card.robot integration tests. + +Each subcommand is a self-contained check that prints a sentinel on success. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from typing import Any + +# Ensure local source tree is importable +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.a2a.asgi import app as asgi_app # noqa: E402 +from cleveragents.a2a.cards import ( # noqa: E402 + AGENT_CARD_WELL_KNOWN_PATH, + PREV_AGENT_CARD_WELL_KNOWN_PATH, + AgentCardService, + get_card_service, + reset_card_service, +) + +# --------------------------------------------------------------------------- +# ASGI helpers +# --------------------------------------------------------------------------- + +SendMessage = dict[str, Any] + + +def _run_asgi_get(path: str) -> tuple[int, dict[str, list[bytes]], bytes]: + """Run a GET request through the ASGI app and return (status, headers, body).""" + sent_messages: list[SendMessage] = [] + + async def receive() -> dict[str, Any]: + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: SendMessage) -> None: + sent_messages.append(message) + + scope: dict[str, Any] = { + "type": "http", + "method": "GET", + "path": path, + "server": ("127.0.0.1", 8080), + "scheme": "http", + } + asyncio.run(asgi_app(scope, receive, send)) + + start = next(m for m in sent_messages if m.get("type") == "http.response.start") + body_msg = next(m for m in sent_messages if m.get("type") == "http.response.body") + status: int = int(start.get("status", 0)) + raw_headers: list[tuple[bytes, bytes]] = start.get("headers", []) + headers: dict[str, list[bytes]] = {} + for k, v in raw_headers: + key = k.lower().decode("utf-8") + headers.setdefault(key, []).append(v) + body: bytes = body_msg.get("body", b"") + return status, headers, body + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + + +def card_service_default() -> None: + """Verify AgentCardService builds a card with default values.""" + svc = AgentCardService() + card = svc.get_card() + assert card["name"] == "CleverAgents", f"Unexpected name: {card['name']}" + assert card["url"] == "http://127.0.0.1:8080", f"Unexpected url: {card['url']}" + assert card["version"] == "1.0.0", f"Unexpected version: {card['version']}" + assert card["skills"], "Expected non-empty skills" + print("agent-card-service-default-ok") + + +def card_service_custom_url() -> None: + """Verify AgentCardService uses custom base_url correctly.""" + svc = AgentCardService(base_url="http://example.com:9090") + card = svc.get_card() + assert card["url"] == "http://example.com:9090", f"Unexpected url: {card['url']}" + interfaces = card.get("interfaces", []) + assert interfaces, "Expected at least one interface" + assert interfaces[0]["url"] == "http://example.com:9090/a2a", ( + f"Unexpected interface url: {interfaces[0]['url']}" + ) + print("agent-card-service-custom-url-ok") + + +def card_service_skills() -> None: + """Verify AgentCardService includes all six expected skills.""" + svc = AgentCardService() + card = svc.get_card() + skill_ids = {s["id"] for s in card["skills"]} + expected = { + "plan-lifecycle", + "registry-crud", + "context-mgmt", + "health-diagnostics", + "entity-sync", + "namespace-mgmt", + } + missing = expected - skill_ids + assert not missing, f"Missing skills: {missing}" + print("agent-card-service-skills-ok") + + +def card_service_singleton() -> None: + """Verify get_card_service returns a singleton and reset_card_service works.""" + reset_card_service(None) + svc1 = get_card_service() + svc2 = get_card_service() + assert svc1 is svc2, "Expected singleton" + + custom = AgentCardService(base_url="http://custom.example.com") + reset_card_service(custom) + svc3 = get_card_service() + assert svc3 is custom, "Expected injected custom service" + + # Restore default singleton + reset_card_service(None) + print("agent-card-service-singleton-ok") + + +def asgi_agent_card_endpoint() -> None: + """Verify ASGI app serves Agent Card at /.well-known/agent-card.json.""" + status, _headers, body = _run_asgi_get(AGENT_CARD_WELL_KNOWN_PATH) + assert status == 200, f"Expected 200, got {status}" + card = json.loads(body.decode("utf-8")) + assert "name" in card, f"Expected 'name' in card, got: {list(card)}" + assert "url" in card, f"Expected 'url' in card, got: {list(card)}" + assert "skills" in card, f"Expected 'skills' in card, got: {list(card)}" + print("asgi-agent-card-endpoint-ok") + + +def asgi_deprecated_endpoint() -> None: + """Verify ASGI app serves Agent Card at deprecated /.well-known/agent.json.""" + status, headers, body = _run_asgi_get(PREV_AGENT_CARD_WELL_KNOWN_PATH) + assert status == 200, f"Expected 200, got {status}" + card = json.loads(body.decode("utf-8")) + assert "name" in card, f"Expected 'name' in card, got: {list(card)}" + deprecation_values = headers.get("deprecation", []) + assert deprecation_values, "Expected Deprecation header" + assert b"true" in deprecation_values, ( + f"Expected Deprecation: true, got: {deprecation_values}" + ) + print("asgi-deprecated-endpoint-ok") + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +_COMMANDS: dict[str, Any] = { + "card-service-default": card_service_default, + "card-service-custom-url": card_service_custom_url, + "card-service-skills": card_service_skills, + "card-service-singleton": card_service_singleton, + "asgi-agent-card-endpoint": asgi_agent_card_endpoint, + "asgi-deprecated-endpoint": asgi_deprecated_endpoint, +} + + +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) + _COMMANDS[sys.argv[1]]() + + +if __name__ == "__main__": + main() diff --git a/src/cleveragents/a2a/__init__.py b/src/cleveragents/a2a/__init__.py index 5a8228fa3..596879242 100644 --- a/src/cleveragents/a2a/__init__.py +++ b/src/cleveragents/a2a/__init__.py @@ -18,10 +18,20 @@ compatibility. :class:`ServerConnectionConfig` validates connection parameters. The :class:`TransportSelector` chooses the appropriate transport based on configuration: stdio for local mode, HTTP for server mode. + +The :class:`AgentCardService` builds and caches the A2A Agent Card JSON +payload served at ``/.well-known/agent-card.json``. Use +:func:`get_card_service` to access the module-level singleton and +:func:`reset_card_service` for test isolation. """ from __future__ import annotations +from cleveragents.a2a.cards import ( + AgentCardService, + get_card_service, + reset_card_service, +) from cleveragents.a2a.clients import ( AuthClient, RemoteExecutionClient, @@ -66,6 +76,7 @@ __all__ = [ "A2aVersion", "A2aVersionMismatchError", "A2aVersionNegotiator", + "AgentCardService", "AuthClient", "RemoteExecutionClient", "ServerClient", @@ -74,4 +85,6 @@ __all__ = [ "StubRemoteExecutionClient", "StubServerClient", "TransportSelector", + "get_card_service", + "reset_card_service", ] diff --git a/src/cleveragents/a2a/asgi.py b/src/cleveragents/a2a/asgi.py index 9bd501502..c54845679 100644 --- a/src/cleveragents/a2a/asgi.py +++ b/src/cleveragents/a2a/asgi.py @@ -7,6 +7,13 @@ This module provides a concrete import target for runtime commands like: It intentionally keeps behavior minimal and dependency-free (no FastAPI/ Starlette dependency) while providing the required health probe endpoint for container and Kubernetes deployments. + +In addition to the health/readiness probes, this module serves the A2A +Agent Card discovery endpoints: + +- ``GET /.well-known/agent-card.json`` — primary discovery path +- ``GET /.well-known/agent.json`` — deprecated alias (returns 301 redirect + with a deprecation warning logged) """ from __future__ import annotations @@ -14,6 +21,12 @@ from __future__ import annotations import logging from collections.abc import Awaitable, Callable +from cleveragents.a2a.cards import ( + AGENT_CARD_WELL_KNOWN_PATH, + PREV_AGENT_CARD_WELL_KNOWN_PATH, + AgentCardService, +) + _logger: logging.Logger = logging.getLogger(__name__) Headers = list[tuple[bytes, bytes]] @@ -21,7 +34,42 @@ SendCallable = Callable[[dict[str, object]], Awaitable[None]] # Paths recognised by this ASGI app. Used to distinguish 404 (unknown # path) from 405 (known path, wrong method) per RFC 9110 S15.5.6. -_KNOWN_PATHS: frozenset[str] = frozenset({"/", "/live", "/ready", "/health"}) +_KNOWN_PATHS: frozenset[str] = frozenset( + { + "/", + "/live", + "/ready", + "/health", + AGENT_CARD_WELL_KNOWN_PATH, + PREV_AGENT_CARD_WELL_KNOWN_PATH, + } +) + + +def _extract_base_url(scope: dict[str, object]) -> str: + """Extract the base server URL from an ASGI HTTP scope. + + Returns a URL of the form ``http[s]://host[:port]`` without a trailing + slash. Substitutes ``127.0.0.1`` for ``0.0.0.0`` so that the Agent + Card URL is always routable from external clients. + """ + scheme = str(scope.get("scheme", "http")) + server = scope.get("server") + if isinstance(server, (list, tuple)) and len(server) >= 2: + host = str(server[0]) + port = int(server[1]) + else: + host = "127.0.0.1" + port = 8080 + + # Replace unroutable bind address with loopback + if host == "0.0.0.0": + host = "127.0.0.1" + + # Omit default ports for cleaner URLs + if (scheme == "http" and port == 80) or (scheme == "https" and port == 443): + return f"{scheme}://{host}" + return f"{scheme}://{host}:{port}" async def _send_response( @@ -61,13 +109,17 @@ async def app( receive: Callable[[], Awaitable[dict[str, object]]], send: SendCallable, ) -> None: - """Serve health and readiness endpoints for Kubernetes probes. + """Serve health, readiness, and Agent Card discovery endpoints. Supported routes: + - ``GET /live`` -> ``200`` with ``{"status":"alive"}`` - ``GET /ready`` -> ``200`` with ``{"status":"ready"}`` - ``GET /health`` -> ``200`` with ``{"status":"ok"}`` (compat alias) - ``GET /`` -> ``200`` with ``{"service":"cleveragents"}`` + - ``GET /.well-known/agent-card.json`` -> ``200`` with Agent Card JSON + - ``GET /.well-known/agent.json`` -> ``200`` with Agent Card JSON + (deprecated alias; logs a warning and adds ``Deprecation: true`` header) - known path, wrong method -> ``405`` - unknown path -> ``404`` """ @@ -121,6 +173,30 @@ async def app( await _send_response(send, status=200, body=b'{"service":"cleveragents"}') return + if method == "GET" and path == AGENT_CARD_WELL_KNOWN_PATH: + base_url = _extract_base_url(scope) + request_svc = AgentCardService(base_url=base_url) + card_bytes = request_svc.get_card_json().encode("utf-8") + await _send_response(send, status=200, body=card_bytes) + return + + if method == "GET" and path == PREV_AGENT_CARD_WELL_KNOWN_PATH: + _logger.warning( + "Deprecated Agent Card path %r requested; use %r instead.", + PREV_AGENT_CARD_WELL_KNOWN_PATH, + AGENT_CARD_WELL_KNOWN_PATH, + ) + base_url = _extract_base_url(scope) + request_svc = AgentCardService(base_url=base_url) + card_bytes = request_svc.get_card_json().encode("utf-8") + await _send_response( + send, + status=200, + body=card_bytes, + headers=[(b"deprecation", b"true")], + ) + return + # Known path with wrong method -> 405 Method Not Allowed (RFC 9110 S15.5.6) if path in _KNOWN_PATHS: await _send_response( diff --git a/src/cleveragents/a2a/cards.py b/src/cleveragents/a2a/cards.py new file mode 100644 index 000000000..b2de8823f --- /dev/null +++ b/src/cleveragents/a2a/cards.py @@ -0,0 +1,218 @@ +"""Agent Card service for A2A protocol discovery. + +Implements the Agent Card discovery endpoint per the A2A specification. +The Agent Card is served at ``/.well-known/agent-card.json`` and describes +the agent's capabilities, supported skills, and connection endpoints. + +The :class:`AgentCardService` builds and caches the Agent Card JSON payload. +A module-level singleton is managed via :func:`get_card_service` and +:func:`reset_card_service` (the latter is provided for test isolation). +""" + +from __future__ import annotations + +import json +import logging +import threading +from typing import Any + +_logger: logging.Logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Agent Card path constants +# --------------------------------------------------------------------------- + +AGENT_CARD_WELL_KNOWN_PATH: str = "/.well-known/agent-card.json" +PREV_AGENT_CARD_WELL_KNOWN_PATH: str = "/.well-known/agent.json" + +# --------------------------------------------------------------------------- +# Default card values +# --------------------------------------------------------------------------- + +_DEFAULT_NAME: str = "CleverAgents" +_DEFAULT_DESCRIPTION: str = ( + "CleverAgents AI assistant — plan, execute, and manage intelligent workflows." +) +_DEFAULT_VERSION: str = "1.0.0" +_DEFAULT_SUPPORTED_VERSIONS: list[str] = ["1.0"] +_DEFAULT_INPUT_MODES: list[str] = ["text"] +_DEFAULT_OUTPUT_MODES: list[str] = ["text"] + +# Skill definitions derived from the A2A facade operation categories. +_DEFAULT_SKILLS: list[dict[str, Any]] = [ + { + "id": "plan-lifecycle", + "name": "Plan Lifecycle", + "description": ( + "Create, execute, apply, cancel, and manage plan lifecycle operations." + ), + "tags": ["plan", "lifecycle"], + }, + { + "id": "registry-crud", + "name": "Registry CRUD", + "description": ( + "List and manage tools, resources, actors, skills, actions, and projects." + ), + "tags": ["registry", "tools", "resources"], + }, + { + "id": "context-mgmt", + "name": "Context Management", + "description": "Show, inspect, simulate, and set execution context.", + "tags": ["context"], + }, + { + "id": "health-diagnostics", + "name": "Health and Diagnostics", + "description": "Check agent health and run diagnostics.", + "tags": ["health", "diagnostics"], + }, + { + "id": "entity-sync", + "name": "Entity Sync", + "description": "Pull, push, and check sync status for entities.", + "tags": ["sync", "entity"], + }, + { + "id": "namespace-mgmt", + "name": "Namespace Management", + "description": "List, show, and manage namespace members.", + "tags": ["namespace"], + }, +] + + +# --------------------------------------------------------------------------- +# AgentCardService +# --------------------------------------------------------------------------- + + +class AgentCardService: + """Service that builds and caches the A2A Agent Card JSON payload. + + The Agent Card describes the agent's identity, capabilities, and + connection endpoints. It is served at the well-known discovery path + ``/.well-known/agent-card.json``. + + Parameters + ---------- + base_url: + The base URL of the server (e.g. ``http://127.0.0.1:8080``). + Must not include a trailing slash. The ``/a2a`` suffix is + appended automatically for the A2A endpoint URL. + name: + Human-readable agent name. + description: + Human-readable agent description. + version: + Agent version string. + """ + + def __init__( + self, + base_url: str = "http://127.0.0.1:8080", + name: str = _DEFAULT_NAME, + description: str = _DEFAULT_DESCRIPTION, + version: str = _DEFAULT_VERSION, + ) -> None: + if not base_url: + raise ValueError("base_url must not be empty") + # Normalise: strip trailing slash + self._base_url: str = base_url.rstrip("/") + self._name: str = name + self._description: str = description + self._version: str = version + self._card_cache: dict[str, Any] | None = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def get_card(self) -> dict[str, Any]: + """Return the Agent Card as a Python dict (cached after first call).""" + if self._card_cache is None: + self._card_cache = self._build_card() + return self._card_cache + + def get_card_json(self) -> str: + """Return the Agent Card serialised as a JSON string.""" + return json.dumps(self.get_card(), separators=(",", ":")) + + def invalidate_cache(self) -> None: + """Invalidate the cached card so it is rebuilt on the next call.""" + self._card_cache = None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _build_card(self) -> dict[str, Any]: + """Build the Agent Card dict from the configured parameters.""" + endpoint_url = f"{self._base_url}/a2a" + card: dict[str, Any] = { + "name": self._name, + "description": self._description, + "url": self._base_url, + "version": self._version, + "supportedVersions": _DEFAULT_SUPPORTED_VERSIONS, + "defaultInputModes": _DEFAULT_INPUT_MODES, + "defaultOutputModes": _DEFAULT_OUTPUT_MODES, + "interfaces": [ + { + "transport": "http", + "url": endpoint_url, + } + ], + "skills": list(_DEFAULT_SKILLS), + } + _logger.debug( + "agent_card.built base_url=%s endpoint_url=%s", + self._base_url, + endpoint_url, + ) + return card + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_singleton_lock: threading.Lock = threading.Lock() +_card_service_singleton: AgentCardService | None = None + + +def get_card_service() -> AgentCardService: + """Return the module-level :class:`AgentCardService` singleton. + + Creates the singleton with default parameters on first call. + Thread-safe. + """ + global _card_service_singleton + if _card_service_singleton is None: + with _singleton_lock: + if _card_service_singleton is None: + _card_service_singleton = AgentCardService() + return _card_service_singleton + + +def reset_card_service(service: AgentCardService | None = None) -> None: + """Replace (or clear) the module-level singleton. + + Intended for test isolation only. Pass a pre-configured + :class:`AgentCardService` instance to inject a test double, or + ``None`` to clear the singleton so it is recreated on the next call + to :func:`get_card_service`. + """ + global _card_service_singleton + with _singleton_lock: + _card_service_singleton = service + + +__all__ = [ + "AGENT_CARD_WELL_KNOWN_PATH", + "PREV_AGENT_CARD_WELL_KNOWN_PATH", + "AgentCardService", + "get_card_service", + "reset_card_service", +]