feat(a2a): Agent Card discovery endpoint #10935
@@ -3,8 +3,6 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
vars:
|
||||
docker_prefix: "http://harbor.cleverthis.com/docker/"
|
||||
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
HAL9001
commented
NON-BLOCKING — Docstring says The module docstring states:
But the implementation returns HTTP 200 with the Agent Card JSON body and a Fix: Update the module docstring to accurately describe the behaviour: **NON-BLOCKING — Docstring says `301 redirect`, implementation returns `200`**
The module docstring states:
> `GET /.well-known/agent.json` — deprecated alias (returns 301 redirect with a deprecation warning logged)
But the implementation returns HTTP 200 with the Agent Card JSON body and a `Deprecation: true` header — not a 301 redirect. This is a reasonable design choice (avoids extra round-trips for A2A clients), but the docstring is incorrect and will mislead future developers.
**Fix:** Update the module docstring to accurately describe the behaviour:
```
- ``GET /.well-known/agent.json`` — deprecated alias (returns 200 with Agent Card JSON and ``Deprecation: true`` response header)
```
|
||||
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")],
|
||||
|
HAL9001
commented
BLOCKING — Per-request This creates a brand-new Suggestion: a simpler approach — cache the card keyed by base URL in the ASGI handler, avoiding redundant rebuilds across requests for the same server URL. **BLOCKING — Per-request `AgentCardService` instantiation bypasses the singleton**
```python
request_svc = AgentCardService(base_url=base_url)
card_bytes = request_svc.get_card_json().encode("utf-8")
```
This creates a brand-new `AgentCardService` on every HTTP request, discarding the per-instance cache immediately after use. The `get_card_service()` singleton was purpose-built for this use case. Fix: use `get_card_service()` for a default/static base URL, or, if the base URL must come from each request scope, accept that per-request instantiation is necessary and remove `get_card_service` from `__init__.py`/`__all__` since it would then be unused. If the singleton is kept, consider calling `svc.invalidate_cache()` and rebuilding with the request-scoped URL when needed.
Suggestion: a simpler approach — cache the card keyed by base URL in the ASGI handler, avoiding redundant rebuilds across requests for the same server URL.
|
||||
)
|
||||
return
|
||||
|
||||
# Known path with wrong method -> 405 Method Not Allowed (RFC 9110 S15.5.6)
|
||||
if path in _KNOWN_PATHS:
|
||||
await _send_response(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
BLOCKING — Missing ASGI step definitions (root cause of CI unit_tests failure)
This step definitions file is missing all Given/When/Then step implementations needed by the 4 ASGI endpoint scenarios in
features/a2a_agent_card.feature:The
@then("the HTTP response body should be valid Agent Card JSON")step at line 303 referencescontext.asgi_sent_messagesbut there is no step that sets this attribute. Behave will report these as undefined steps, causing theCI / unit_testsgate to fail.Fix: Add the missing step implementations. They can be modelled on the
_run_asgi_get()helper inrobot/helper_agent_card.py— set up an async ASGI call, collect the sent messages intocontext.asgi_sent_messages, and extract status/headers for the assertion steps.