feat(server): ASGI endpoint via uvicorn

Implement FastAPI-based ASGI application served by uvicorn for
the CleverAgents server mode. Add health check endpoint, A2A
JSON-RPC routing, configurable host:port binding, and graceful
shutdown handling. Server launches via `agents server start`.

- Add FastAPI ASGI app factory at infrastructure/server/asgi_app.py
  with /.well-known/agent.json (Agent Card), /health (liveness),
  and /a2a (A2A JSON-RPC 2.0 dispatch via A2aLocalFacade)
- Add ServerLifecycle class at infrastructure/server/server_lifecycle.py
  wrapping uvicorn.Server with SIGTERM/SIGINT graceful shutdown
- Add `agents server start` CLI command with --host, --port, --log-level
  options, resolving defaults from Settings
- Add fastapi>=0.115.0 dependency to pyproject.toml (uvicorn already present)
- Add Behave BDD tests (features/server_lifecycle.feature, 20 scenarios)
- Add Robot Framework integration tests (robot/server_lifecycle.robot)
- Update CHANGELOG.md and vulture_whitelist.py

ISSUES CLOSED: #862
This commit is contained in:
2026-03-22 23:50:04 +00:00
parent 2e094ef938
commit ad51fc07e7
28 changed files with 1114 additions and 9 deletions
+6
View File
@@ -44,6 +44,12 @@
configurable singletons in the DI container with provider selection via
`override_providers()`. Includes Behave BDD tests (35 scenarios), Robot
Framework smoke tests, ASV benchmarks, and reference documentation. (#498)
- Added FastAPI-based ASGI server endpoint served by uvicorn for the
CleverAgents server mode. Includes health check endpoint (`/health`),
A2A Agent Card discovery (`/.well-known/agent.json`), A2A JSON-RPC 2.0
routing (`/a2a`), configurable host:port binding via Settings, graceful
shutdown on SIGTERM/SIGINT, and `agents server start` CLI command.
Behave BDD tests and Robot Framework integration tests included. (#862)
- Added BuiltinAdapter class and MCP automatic resource slot creation.
BuiltinAdapter wraps register_file_tools/register_git_tools/register_subplan_tool
into a unified adapter interface. McpAdapter.infer_resource_slots() analyzes
+122
View File
@@ -0,0 +1,122 @@
@phase2 @a2a @server
Feature: ASGI Server Lifecycle
As a platform operator
I want to start, health-check, and shut down the CleverAgents ASGI server
So that the A2A JSON-RPC 2.0 endpoint is available for client communication
# -----------------------------------------------------------------------
# ASGI application creation
# -----------------------------------------------------------------------
Scenario: create_asgi_app returns a FastAPI application
When I create an ASGI application with default settings
Then the ASGI app should be a FastAPI instance
Scenario: create_asgi_app accepts a custom facade
Given an A2aLocalFacade with no services
When I create an ASGI application with that facade
Then the ASGI app should be a FastAPI instance
Scenario: create_asgi_app rejects invalid facade type
When I try to create an ASGI application with facade "not-a-facade"
Then a TypeError should be raised for invalid facade
Scenario: create_asgi_app rejects invalid port
When I try to create an ASGI application with port 0
Then a ValueError should be raised for invalid port
Scenario: create_asgi_app rejects invalid host
When I try to create an ASGI application with host ""
Then a ValueError should be raised for invalid host
# -----------------------------------------------------------------------
# Health check endpoint
# -----------------------------------------------------------------------
Scenario: Health endpoint returns healthy status
Given a running ASGI test client
When I request GET /health
Then the response status code should be 200
And the response body should contain "healthy"
# -----------------------------------------------------------------------
# Agent Card discovery endpoint
# -----------------------------------------------------------------------
Scenario: Agent Card endpoint returns valid agent card
Given a running ASGI test client
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 "url"
# -----------------------------------------------------------------------
# A2A JSON-RPC endpoint
# -----------------------------------------------------------------------
Scenario: A2A endpoint dispatches health check operation
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 "ok"
Scenario: A2A endpoint returns 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
Scenario: A2A endpoint returns 400 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
# -----------------------------------------------------------------------
# ServerLifecycle construction
# -----------------------------------------------------------------------
Scenario: ServerLifecycle accepts valid configuration
When I create a ServerLifecycle with host "127.0.0.1" and port 9000
Then the lifecycle host should be "127.0.0.1"
And the lifecycle port should be 9000
And the lifecycle should not be started
And the lifecycle should not be stopped
Scenario: ServerLifecycle rejects empty host
When I try to create a ServerLifecycle with host ""
Then a ValueError should be raised for invalid host
Scenario: ServerLifecycle rejects invalid port 0
When I try to create a ServerLifecycle with port 0
Then a ValueError should be raised for invalid port
Scenario: ServerLifecycle rejects port above 65535
When I try to create a ServerLifecycle with port 70000
Then a ValueError should be raised for invalid port
Scenario: ServerLifecycle rejects empty log_level
When I try to create a ServerLifecycle with empty log_level
Then a ValueError should be raised for invalid log_level
# -----------------------------------------------------------------------
# Server configuration from Settings
# -----------------------------------------------------------------------
Scenario: Settings provides default server host
Then the default server host from Settings should be "0.0.0.0"
Scenario: Settings provides default server port
Then the default server port from Settings should be 8080
# -----------------------------------------------------------------------
# Graceful shutdown
# -----------------------------------------------------------------------
Scenario: ServerLifecycle request_shutdown sets flag
Given a ServerLifecycle with a mock uvicorn server
When I call request_shutdown on the lifecycle
Then the mock server should_exit flag should be true
Scenario: ServerLifecycle cannot be started twice
Given a ServerLifecycle that has already been started
When I try to start the lifecycle again
Then a RuntimeError should be raised for double start
@@ -127,3 +127,7 @@ def step_cb_error_message_contains(context: Context, fragment: str) -> None:
f"Expected message containing '{fragment}', "
f"got '{context.cb_response.error.message}'"
)
# Restore default step matcher for other step files
use_step_matcher("parse")
@@ -344,3 +344,7 @@ def step_fc_resources_count(context: Context, count: str) -> None:
assert len(resources) == int(count), (
f"Expected {count} resources, got {len(resources)}"
)
# Restore default step matcher for other step files
use_step_matcher("parse")
+4
View File
@@ -486,3 +486,7 @@ def step_create_not_available_default(context: Context) -> None:
# "the error message should contain" is provided by service_steps.py
# Restore default step matcher for other step files
use_step_matcher("parse")
@@ -247,3 +247,7 @@ def step_wired_error_code(context: Context, code: str) -> None:
assert context.wired_response.error.code == code, (
f"Expected error code '{code}', got '{context.wired_response.error.code}'"
)
# Restore default step matcher for other step files
use_step_matcher("parse")
+4 -1
View File
@@ -8,7 +8,7 @@ import time
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
from behave import given, then, when
from behave import given, then, use_step_matcher, when
from behave.runner import Context
from pydantic import ValidationError
from ulid import ULID
@@ -45,6 +45,9 @@ PLAN_ID = "01HXYZ01HXYZ01HXYZ01HXYZ01"
# ---------------------------------------------------------------------------
use_step_matcher("parse")
@given("an async job store is initialised")
def step_async_job_store_initialised(context: Context) -> None:
context.job_store = InMemoryJobStore()
+2 -1
View File
@@ -8,7 +8,7 @@ import time
from pathlib import Path
import parse
from behave import given, register_type, then, when
from behave import given, register_type, then, use_step_matcher, when
from features.steps.provider_stub_utils import (
RecordingProviderStub,
@@ -21,6 +21,7 @@ def _parse_optional_quoted(text: str) -> str:
return text
use_step_matcher("parse")
register_type(OptionalQuoted=_parse_optional_quoted)
@@ -20,7 +20,7 @@ from unittest.mock import patch
import typer
import yaml
from behave import given, then, when
from behave import given, then, use_step_matcher, when
from behave.runner import Context
from rich.console import Console
from typer.testing import CliRunner
@@ -81,6 +81,9 @@ def _teardown_safety_net_temp(context: Context) -> None:
# ===================================================================
use_step_matcher("parse")
@given("a safety-net isolated temp config directory")
def step_sn_temp_dir(context: Context) -> None:
_setup_safety_net_temp(context)
+4
View File
@@ -399,3 +399,7 @@ def step_then_all_confidence_scores(context: Context, score: float) -> None:
assert triple.confidence == score, (
f"Triple {triple!r} has confidence={triple.confidence}, expected {score}"
)
# Restore default step matcher for other step files
use_step_matcher("parse")
@@ -611,3 +611,7 @@ def step_check_retrieved_link_none(context: Context) -> None:
def step_check_retrieved_link_alias(context: Context, expected: str) -> None:
"""Check retrieved link alias."""
assert context.retrieved_link.alias == expected
# Restore default step matcher for other step files
use_step_matcher("parse")
+4
View File
@@ -2738,3 +2738,7 @@ def step_find_node_wrong_root(context: Context) -> None:
context.tree_handle._path_cache[""] = context.tree_handle._element.root
result = context.tree_handle._find_node("WrongRoot/child")
assert result is None, f"Expected None, got {result}"
# Restore default step matcher for other step files
use_step_matcher("parse")
@@ -248,3 +248,7 @@ def step_new_cov_pas_called_with_lifecycle(context: Context) -> None:
context.new_cov_mock_pas_cls.assert_called_once_with(
lifecycle_service=context.new_cov_mock_lifecycle_used,
)
# Restore default step matcher for other step files
use_step_matcher("parse")
@@ -312,3 +312,7 @@ def step_pda_t_no_ghost(context: Context) -> None:
assert "ghost_key =" not in context.pda_console_output, (
f"Did not expect 'ghost_key =' in: {context.pda_console_output}"
)
# Restore default step matcher for other step files
use_step_matcher("parse")
@@ -202,3 +202,7 @@ def step_then_result_contains_schema_view_link(
f"{schema!r} to view containing {view!r}.\n"
f"Triples: {context.triples}"
)
# Restore default step matcher for other step files
use_step_matcher("parse")
@@ -445,3 +445,7 @@ def step_resolve_server_mode(context: Context, mode: str) -> None:
os.environ["HOME"] = old_home
else:
os.environ.pop("HOME", None)
# Restore default step matcher for other step files
use_step_matcher("parse")
+292
View File
@@ -0,0 +1,292 @@
"""Step definitions for ``server_lifecycle.feature``.
Tests the ASGI application factory, health/agent-card endpoints,
A2A JSON-RPC dispatch, ServerLifecycle construction and shutdown,
and Settings-based server configuration.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when # type: ignore[import-untyped]
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.config.settings import Settings
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
from cleveragents.infrastructure.server.server_lifecycle import ServerLifecycle
# ---------------------------------------------------------------------------
# ASGI application creation
# ---------------------------------------------------------------------------
@when("I create an ASGI application with default settings")
def step_create_asgi_default(context: Any) -> None:
context.asgi_app = create_asgi_app()
@given("an A2aLocalFacade with no services")
def step_given_facade(context: Any) -> None:
context.facade = A2aLocalFacade()
@when("I create an ASGI application with that facade")
def step_create_asgi_with_facade(context: Any) -> None:
context.asgi_app = create_asgi_app(facade=context.facade)
@then("the ASGI app should be a FastAPI instance")
def step_check_fastapi_instance(context: Any) -> None:
from fastapi import FastAPI
assert isinstance(context.asgi_app, FastAPI), (
f"Expected FastAPI, got {type(context.asgi_app)}"
)
@when('I try to create an ASGI application with facade "{value}"')
def step_create_asgi_bad_facade(context: Any, value: str) -> None:
context.caught_exception = None
try:
create_asgi_app(facade=value) # type: ignore[arg-type]
except TypeError as exc:
context.caught_exception = exc
@then("a TypeError should be raised for invalid facade")
def step_check_type_error_facade(context: Any) -> None:
assert context.caught_exception is not None, "Expected TypeError"
assert isinstance(context.caught_exception, TypeError)
@when("I try to create an ASGI application with port {port:d}")
def step_create_asgi_bad_port(context: Any, port: int) -> None:
context.caught_exception = None
try:
create_asgi_app(port=port)
except ValueError as exc:
context.caught_exception = exc
@then("a ValueError should be raised for invalid port")
def step_check_value_error_port(context: Any) -> None:
assert context.caught_exception is not None, "Expected ValueError"
assert isinstance(context.caught_exception, ValueError)
@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:
create_asgi_app(host=host)
except ValueError as exc:
context.caught_exception = exc
@when('I try to create an ASGI application with host ""')
def step_create_asgi_empty_host(context: Any) -> None:
context.caught_exception = None
try:
create_asgi_app(host="")
except ValueError as exc:
context.caught_exception = exc
@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"
assert isinstance(context.caught_exception, ValueError)
# ---------------------------------------------------------------------------
# Test client helpers
# ---------------------------------------------------------------------------
@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)
@when("I request GET /health")
def step_get_health(context: Any) -> None:
context.response = context.test_client.get("/health")
@when("I request GET /.well-known/agent.json")
def step_get_agent_card(context: Any) -> None:
context.response = context.test_client.get("/.well-known/agent.json")
@when('I POST a JSON-RPC request to /a2a with operation "{operation}"')
def step_post_a2a(context: Any, operation: str) -> None:
payload = {"operation": 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 operation)
context.response = context.test_client.post("/a2a", json={"bad": "data"})
@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, (
f"Expected {code}, got {context.response.status_code}: {context.response.text}"
)
@then('the response body should contain "{text}"')
def step_check_response_body(context: Any, text: str) -> None:
body = context.response.text
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:
data = context.response.json()
assert data.get("status") == expected_status, (
f"Expected A2A status '{expected_status}', got {data}"
)
# ---------------------------------------------------------------------------
# ServerLifecycle construction
# ---------------------------------------------------------------------------
@when('I create a ServerLifecycle with host "{host}" and port {port:d}')
def step_create_lifecycle(context: Any, host: str, port: int) -> None:
context.lifecycle = ServerLifecycle(host=host, port=port)
@then('the lifecycle host should be "{expected}"')
def step_check_lifecycle_host(context: Any, expected: str) -> None:
assert context.lifecycle.host == expected
@then("the lifecycle port should be {expected:d}")
def step_check_lifecycle_port(context: Any, expected: int) -> None:
assert context.lifecycle.port == expected
@then("the lifecycle should not be started")
def step_check_not_started(context: Any) -> None:
assert not context.lifecycle.is_started
@then("the lifecycle should not be stopped")
def step_check_not_stopped(context: Any) -> None:
assert not context.lifecycle.is_stopped
@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:
ServerLifecycle(host=host)
except ValueError as exc:
context.caught_exception = exc
@when('I try to create a ServerLifecycle with host ""')
def step_create_lifecycle_empty_host(context: Any) -> None:
context.caught_exception = None
try:
ServerLifecycle(host="")
except ValueError as exc:
context.caught_exception = exc
@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
try:
ServerLifecycle(port=port)
except ValueError as exc:
context.caught_exception = exc
@when("I try to create a ServerLifecycle with empty log_level")
def step_create_lifecycle_bad_log_level(context: Any) -> None:
context.caught_exception = None
try:
ServerLifecycle(log_level="")
except ValueError as exc:
context.caught_exception = exc
@then("a ValueError should be raised for invalid log_level")
def step_check_value_error_log_level(context: Any) -> None:
assert context.caught_exception is not None, "Expected ValueError"
assert isinstance(context.caught_exception, ValueError)
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
@then('the default server host from Settings should be "{expected}"')
def step_check_settings_host(context: Any, expected: str) -> None:
settings = Settings()
assert settings.server_host == expected
@then("the default server port from Settings should be {expected:d}")
def step_check_settings_port(context: Any, expected: int) -> None:
settings = Settings()
assert settings.server_port == expected
# ---------------------------------------------------------------------------
# Graceful shutdown
# ---------------------------------------------------------------------------
@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)
mock_server = MagicMock()
mock_server.should_exit = False
lifecycle._server = mock_server
context.lifecycle = lifecycle
context.mock_server = mock_server
@when("I call request_shutdown on the lifecycle")
def step_call_request_shutdown(context: Any) -> None:
context.lifecycle.request_shutdown()
@then("the mock server should_exit flag should be true")
def step_check_should_exit(context: Any) -> None:
assert context.mock_server.should_exit is True
@given("a ServerLifecycle that has already been started")
def step_lifecycle_already_started(context: Any) -> None:
lifecycle = ServerLifecycle(host="127.0.0.1", port=9998)
lifecycle._started = True
context.lifecycle = lifecycle
@when("I try to start the lifecycle again")
def step_try_double_start(context: Any) -> None:
context.caught_exception = None
try:
context.lifecycle.start()
except RuntimeError as exc:
context.caught_exception = exc
@then("a RuntimeError should be raised for double start")
def step_check_runtime_error(context: Any) -> None:
assert context.caught_exception is not None, "Expected RuntimeError"
assert isinstance(context.caught_exception, RuntimeError)
@@ -545,3 +545,7 @@ def _find_result(summary: ValidationSummary, name: str) -> ValidationResult:
available = [r.validation_name for r in summary.results]
msg = f"No result for '{name}', available: {available}"
raise AssertionError(msg)
# Restore default step matcher for other step files
use_step_matcher("parse")
+2
View File
@@ -932,6 +932,8 @@ def security_scan(session: nox.Session):
- Vulture >=80% confidence: hard fail
"""
session.install("-e", ".[dev]")
# Ensure setuptools is available for semgrep's pkg_resources dependency
session.install("setuptools<75")
# Ensure output directory exists (CI starts with a clean checkout)
os.makedirs("build", exist_ok=True)
+2
View File
@@ -26,6 +26,7 @@ dependencies = [
# CLI Framework (ADR-009)
"typer>=0.9.0",
"uvicorn>=0.30.1",
"fastapi>=0.115.0",
"watchdog>=4.0.0",
"faiss-cpu>=1.7.4", # Vector store backend
"rx>=3.2.0", # Reactive streams for routing
@@ -70,6 +71,7 @@ dev = [
# Security scanning
"bandit[toml]>=1.7.5",
"semgrep>=1.60.0",
"setuptools>=70.0.0,<75.0.0", # Required by semgrep (pkg_resources, removed in >=75)
# Dead code detection
"vulture>=2.10",
# Complexity metrics
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Integration test helper for server lifecycle 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 sys
def _test_create_app() -> None:
"""Verify ``create_asgi_app`` returns a FastAPI instance."""
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
app = create_asgi_app()
assert app is not None
print("create-app-ok")
def _test_health_endpoint() -> None:
"""Exercise the /health endpoint via FastAPI TestClient."""
from fastapi.testclient import TestClient
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
app = create_asgi_app()
client = TestClient(app)
resp = client.get("/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "healthy"
print("health-endpoint-ok")
def _test_agent_card() -> None:
"""Exercise the /.well-known/agent.json endpoint."""
from fastapi.testclient import TestClient
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
app = create_asgi_app()
client = TestClient(app)
resp = client.get("/.well-known/agent.json")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "CleverAgents"
assert "url" in data
print("agent-card-ok")
def _test_a2a_dispatch() -> None:
"""Exercise the /a2a POST endpoint with a health check operation."""
from fastapi.testclient import TestClient
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
app = create_asgi_app()
client = TestClient(app)
payload = {"operation": "_cleveragents/health/check", "params": {}}
resp = client.post("/a2a", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
print("a2a-dispatch-ok")
def _test_lifecycle_construction() -> None:
"""Verify ServerLifecycle can be constructed and inspected."""
from cleveragents.infrastructure.server.server_lifecycle import ServerLifecycle
lc = ServerLifecycle(host="127.0.0.1", port=9123)
assert lc.host == "127.0.0.1"
assert lc.port == 9123
assert not lc.is_started
assert not lc.is_stopped
print("lifecycle-construction-ok")
def _test_lifecycle_shutdown() -> None:
"""Verify request_shutdown sets the should_exit flag on a mock server."""
from unittest.mock import MagicMock
from cleveragents.infrastructure.server.server_lifecycle import ServerLifecycle
lc = ServerLifecycle(host="127.0.0.1", port=9124)
mock_server = MagicMock()
mock_server.should_exit = False
lc._server = mock_server
lc.request_shutdown()
assert mock_server.should_exit is True
print("lifecycle-shutdown-ok")
_COMMANDS: dict[str, object] = {
"create-app": _test_create_app,
"health-endpoint": _test_health_endpoint,
"agent-card": _test_agent_card,
"a2a-dispatch": _test_a2a_dispatch,
"lifecycle-construction": _test_lifecycle_construction,
"lifecycle-shutdown": _test_lifecycle_shutdown,
}
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(2)
handler = _COMMANDS[sys.argv[1]]
handler() # type: ignore[operator]
if __name__ == "__main__":
main()
+61
View File
@@ -0,0 +1,61 @@
*** Settings ***
Documentation Integration tests for ASGI server lifecycle
...
... Tests verify that the ASGI application, health endpoint,
... agent card, A2A dispatch, and ServerLifecycle construction
... all work end-to-end through real imports and instantiation.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_server_lifecycle.py
*** Test Cases ***
Create ASGI Application
[Documentation] create_asgi_app returns a FastAPI instance
${result}= Run Process ${PYTHON} ${HELPER} create-app cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} create-app-ok
Health Endpoint Returns Healthy
[Documentation] GET /health returns 200 with status healthy
${result}= Run Process ${PYTHON} ${HELPER} health-endpoint cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} health-endpoint-ok
Agent Card Discovery Endpoint
[Documentation] GET /.well-known/agent.json returns valid agent card
${result}= Run Process ${PYTHON} ${HELPER} agent-card cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} agent-card-ok
A2A JSON-RPC Dispatch
[Documentation] POST /a2a dispatches health check operation via facade
${result}= Run Process ${PYTHON} ${HELPER} a2a-dispatch cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} a2a-dispatch-ok
ServerLifecycle Construction
[Documentation] ServerLifecycle construction sets host, port, and initial state
${result}= Run Process ${PYTHON} ${HELPER} lifecycle-construction cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lifecycle-construction-ok
ServerLifecycle Graceful Shutdown
[Documentation] request_shutdown sets the should_exit flag
${result}= Run Process ${PYTHON} ${HELPER} lifecycle-shutdown cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lifecycle-shutdown-ok
+43 -5
View File
@@ -1,8 +1,8 @@
"""Server connection CLI commands.
"""Server CLI commands — connection management and ASGI server start.
Provides the ``agents server connect`` stub command and server-mode
status helpers. All actual server communication is stubbed out
commands persist configuration and print explicit warnings.
Provides the ``agents server connect`` stub command, the
``agents server start`` command that launches the uvicorn-backed
ASGI server, and server-mode status helpers.
"""
from __future__ import annotations
@@ -20,7 +20,7 @@ from cleveragents.application.container import get_container
from cleveragents.application.services.config_service import ConfigService
from cleveragents.cli.formatting import OutputFormat, format_output
app: Any = typer.Typer(help="Server connection management (stub).")
app: Any = typer.Typer(help="Server management — start, connect, and status.")
console: Console = Console()
# ---------------------------------------------------------------------------
@@ -213,9 +213,47 @@ def server_status(
)
@app.command("start")
def server_start(
host: Annotated[
str | None,
typer.Option("--host", "-H", help="Bind address (default: from settings)"),
] = None,
port: Annotated[
int | None,
typer.Option("--port", "-p", help="Bind port (default: from settings)"),
] = None,
log_level: Annotated[
str | None,
typer.Option("--log-level", help="Uvicorn log level"),
] = None,
) -> None:
"""Start the CleverAgents ASGI server.
Launches a uvicorn server hosting the A2A JSON-RPC 2.0 endpoint.
The server binds to host:port from Settings or CLI overrides and
handles graceful shutdown on SIGTERM/SIGINT.
Examples::
agents server start
agents server start --host 127.0.0.1 --port 9000
agents server start --log-level debug
"""
from cleveragents.infrastructure.server import run_server
console.print("[bold]Starting CleverAgents ASGI server...[/bold]")
run_server(
host=host,
port=port,
log_level=log_level,
)
__all__ = [
"app",
"resolve_server_mode",
"server_connect",
"server_start",
"server_status",
]
+1 -1
View File
@@ -206,7 +206,7 @@ def _register_subcommands() -> None:
app.add_typer(
server_app,
name="server",
help="Server connection management (stub)",
help="Server management — start, connect, and status",
)
app.add_typer(
repo.app,
@@ -0,0 +1,25 @@
"""ASGI server infrastructure for CleverAgents.
Provides the FastAPI-based ASGI application, A2A JSON-RPC 2.0 endpoint
routing, and uvicorn server lifecycle management. The server's sole
client-facing interface is the A2A JSON-RPC 2.0 endpoint there is no
REST API (ADR-048).
Public surface:
- :func:`create_asgi_app` build a configured FastAPI application
- :func:`run_server` launch uvicorn with graceful shutdown
- :class:`ServerLifecycle` start/stop lifecycle manager
"""
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
from cleveragents.infrastructure.server.server_lifecycle import (
ServerLifecycle,
run_server,
)
__all__ = [
"ServerLifecycle",
"create_asgi_app",
"run_server",
]
@@ -0,0 +1,171 @@
"""FastAPI-based ASGI application for the CleverAgents server.
The server's sole client-facing interface is an A2A JSON-RPC 2.0
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
through the single ``/a2a`` POST endpoint. The Agent Card is served
at ``/.well-known/agent.json`` per the A2A specification.
"""
from __future__ import annotations
from typing import Any
import structlog
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
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
# ------------------------------------------------------------------
def create_asgi_app(
*,
facade: A2aLocalFacade | None = None,
host: str = "0.0.0.0",
port: int = 8080,
) -> FastAPI:
"""Create and return a configured FastAPI ASGI application.
Parameters
----------
facade:
The :class:`A2aLocalFacade` used to dispatch A2A operations.
When *None* a default facade with no services is created.
host:
Bind address used to populate the Agent Card URL.
port:
Bind port used to populate the Agent Card URL.
Returns
-------
FastAPI
A fully wired ASGI application ready for ``uvicorn.run()``.
"""
if facade is not None and not isinstance(facade, A2aLocalFacade):
raise TypeError("facade must be an A2aLocalFacade instance or None")
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")
resolved_facade = facade if facade is not None else A2aLocalFacade()
agent_card = _build_agent_card(host, port)
application = FastAPI(
title="CleverAgents A2A Server",
description="A2A JSON-RPC 2.0 endpoint for CleverAgents",
version=A2aVersion.CURRENT,
docs_url=None,
redoc_url=None,
openapi_url=None,
)
# --- Health / Agent Card endpoint ---
@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)
@application.get("/health")
async def health_endpoint() -> JSONResponse:
"""Simple liveness probe."""
return JSONResponse(content={"status": "healthy"})
# --- A2A JSON-RPC 2.0 endpoint ---
@application.post("/a2a")
async def a2a_endpoint(request: Request) -> JSONResponse:
"""Handle an inbound A2A JSON-RPC 2.0 request.
Accepts a JSON body conforming to the :class:`A2aRequest`
schema, dispatches it through the facade, and returns the
:class:`A2aResponse` envelope.
"""
body: dict[str, Any] = await request.json()
try:
a2a_request = A2aRequest(**body)
except Exception as exc:
logger.warning("a2a.server.bad_request", error=str(exc))
return JSONResponse(
status_code=400,
content={
"jsonrpc": "2.0",
"error": {
"code": -32600,
"message": f"Invalid A2A request: {exc}",
},
},
)
try:
response = resolved_facade.dispatch(a2a_request)
return JSONResponse(content=response.model_dump())
except A2aOperationNotFoundError as exc:
logger.warning(
"a2a.server.method_not_found",
operation=a2a_request.operation,
)
return JSONResponse(
status_code=404,
content={
"jsonrpc": "2.0",
"error": {
"code": -32601,
"message": str(exc),
},
},
)
logger.info(
"a2a.server.app_created",
host=host,
port=port,
operations=len(resolved_facade.list_operations()),
)
return application
__all__ = [
"create_asgi_app",
]
@@ -0,0 +1,208 @@
"""Uvicorn server lifecycle management with graceful shutdown.
Provides :class:`ServerLifecycle` which wraps ``uvicorn.Server`` with
SIGTERM / SIGINT handling so that the server shuts down cleanly when
the process is terminated or the user presses Ctrl-C.
The public :func:`run_server` convenience function creates the ASGI
application, configures uvicorn, and blocks until shutdown.
"""
from __future__ import annotations
import signal
import threading
from types import FrameType
import structlog
import uvicorn
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.config.settings import Settings
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
class ServerLifecycle:
"""Manage the lifecycle of the uvicorn ASGI server.
Parameters
----------
host:
Network address to bind to.
port:
TCP port to listen on.
facade:
The A2A facade used for request dispatch. *None* creates a
default facade with no wired services.
log_level:
Uvicorn log level (``"info"``, ``"warning"``, etc.).
"""
def __init__(
self,
*,
host: str = "0.0.0.0",
port: int = 8080,
facade: A2aLocalFacade | None = None,
log_level: str = "info",
) -> None:
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")
if not isinstance(log_level, str) or not log_level:
raise ValueError("log_level must be a non-empty string")
self._host: str = host
self._port: int = port
self._facade: A2aLocalFacade = (
facade if facade is not None else A2aLocalFacade()
)
self._log_level: str = log_level.lower()
self._server: uvicorn.Server | None = None
self._started: bool = False
self._stopped: bool = False
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def host(self) -> str:
"""Bound host address."""
return self._host
@property
def port(self) -> int:
"""Bound port number."""
return self._port
@property
def is_started(self) -> bool:
"""Whether :meth:`start` has been called."""
return self._started
@property
def is_stopped(self) -> bool:
"""Whether the server has stopped."""
return self._stopped
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def start(self) -> None:
"""Build the ASGI app and run uvicorn (blocking).
Installs SIGTERM/SIGINT handlers for graceful shutdown. This
method blocks until the server exits.
"""
if self._started:
raise RuntimeError("Server has already been started")
self._started = True
app = create_asgi_app(
facade=self._facade,
host=self._host,
port=self._port,
)
config = uvicorn.Config(
app=app,
host=self._host,
port=self._port,
log_level=self._log_level,
)
server = uvicorn.Server(config)
self._server = server
logger.info(
"a2a.server.starting",
host=self._host,
port=self._port,
)
# Install signal handlers for graceful shutdown.
self._install_signal_handlers()
server.run()
self._stopped = True
logger.info("a2a.server.stopped")
def request_shutdown(self) -> None:
"""Request a graceful shutdown of the running server.
Safe to call from signal handlers or other threads.
"""
if self._server is not None:
self._server.should_exit = True
logger.info("a2a.server.shutdown_requested")
# ------------------------------------------------------------------
# Signal handling
# ------------------------------------------------------------------
def _install_signal_handlers(self) -> None:
"""Install SIGTERM and SIGINT handlers when running on main thread."""
if threading.current_thread() is not threading.main_thread():
return
def _handle_signal(signum: int, _frame: FrameType | None) -> None:
sig_name = signal.Signals(signum).name
logger.info("a2a.server.signal_received", signal=sig_name)
self.request_shutdown()
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
# ------------------------------------------------------------------
# Convenience runner
# ------------------------------------------------------------------
def run_server(
*,
host: str | None = None,
port: int | None = None,
facade: A2aLocalFacade | None = None,
log_level: str | None = None,
) -> None:
"""Create and run the ASGI server (blocking).
Settings are resolved from :class:`Settings` when parameters are
*None*.
Parameters
----------
host:
Override for ``Settings.server_host``.
port:
Override for ``Settings.server_port``.
facade:
A2A facade for operation dispatch.
log_level:
Override for ``Settings.log_level``.
"""
settings = Settings.get_settings()
resolved_host = host if host is not None else settings.server_host
resolved_port = port if port is not None else settings.server_port
resolved_log_level = log_level if log_level is not None else settings.log_level
lifecycle = ServerLifecycle(
host=resolved_host,
port=resolved_port,
facade=facade,
log_level=resolved_log_level,
)
lifecycle.start()
__all__ = [
"ServerLifecycle",
"run_server",
]
+6
View File
@@ -1190,3 +1190,9 @@ detect_directory_languages # noqa: B018, F821
get_servers_for_language # noqa: B018, F821
restart_server # noqa: B018, F821
_make_runtime_handler # noqa: B018, F821
# ASGI server infrastructure — FastAPI route handlers and ServerLifecycle public API
agent_card_endpoint # noqa: B018, F821
health_endpoint # noqa: B018, F821
a2a_endpoint # noqa: B018, F821
server_start # noqa: B018, F821
run_server # noqa: B018, F821