diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c4157..f2e4d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Clever Semantic Versioning](https://www.w3.org/subm - **`merge_configs()` public API** (`cleveractors.merge_configs`): New module-level function implementing the Actor Configuration Standard §3.1 deep-merge algorithm. Accepts an arbitrary number of `dict[str, Any]` arguments and returns a fresh merged dict without mutating any input. Merge semantics: absent key → add; both mappings → deep-merge recursively; both sequences → append; otherwise → replace. Zero-argument call returns `{}`. Exported from `cleveractors.__init__` and `__all__`. - **`validate_dict()` public API** (`cleveractors.validate_dict`): New router-facing validation function that validates a spec-conformant Actor Configuration Standard v1.0.0 dict against the full schema and enforces platform-level structural constraints. Validates top-level key presence (`agents`, `routes`), agent types, LLM provider allowlists, route/edge field names (`source`/`target` only — legacy `from`/`to` rejected), node types, stream operator types, and structural limits (`max_graph_depth`, `max_subgraph_depth`, `max_total_nodes`) from the supplied `platform_limits` dict. Returns the dict unchanged when valid; raises `ConfigurationError` on any violation. Pure static validator with no file I/O, env-var reads, or app construction. Exported from `cleveractors.__init__` and listed in `__all__`. (ADR-2024, ADR-2025, ADR-2029) +- **Registry HTTP Client** (`cleveractors.registry`): Async registry client implementing the Package Registry Standard v1.0.0 HTTP API endpoints (§8) using `httpx.AsyncClient`. Supports all four endpoints: `GET /packages/{id}` (retrieve by PackageId), `GET /{type}/{ns}/{name}?version=` (reference resolution with version alias resolution per §4.2), `GET /browse` (discovery with type/namespace filters), and `GET /.well-known/cleverthis-packages` (registry metadata). Maps all 8 standard error types (§13.2) to typed exceptions via error-body type parsing with status-code fallback. Supports anonymous reads (§9.1) and optional API key authentication (§9.2). Includes async context manager support, 32 Behave BDD scenarios, 7 Robot Framework integration tests against a fake server, and ASV benchmarks. All registry modules achieve 100% coverage. - **Core CleverActors Framework**: New agent-based LLM orchestration framework implementing the Actor Configuration Standard (§1-4). Includes agent base class, factory pattern for agent creation, configuration management, template rendering engine, and exception hierarchy. - **LLM Agent** (`type: llm`, §4.4): Agent backed by language models with support for OpenAI, Anthropic, and Google Gemini providers. Configurable temperature, max_tokens, system prompts, memory/history, and structured output (json_mode/response_format). - **Tool Agent** (`type: tool`, §4.5): Deterministic agent executing built-in tools (echo, math, json_parse, http_request, file_read, file_write, progress_bar) and custom inline code tools. Supports safe/unsafe execution modes, shell command filtering, and file operation sandboxing. diff --git a/benchmarks/registry_http_client.py b/benchmarks/registry_http_client.py new file mode 100644 index 0000000..42c210a --- /dev/null +++ b/benchmarks/registry_http_client.py @@ -0,0 +1,93 @@ +"""ASV benchmarks for the Registry HTTP client. + +Measures performance of the RegistryClient against a fake registry server +for the four API endpoints. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import httpx + +from cleveractors.registry.client import RegistryClient + + +class RegistryClientBenchmark: + """Benchmark suite for RegistryClient.""" + + params: list[str] = ["actor", "template", "skill"] + + def setup(self) -> None: + self.client = RegistryClient( + base_url="http://127.0.0.1:9199", + timeout=5.0, + ) + self.fake_package_id = "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" + + def teardown(self) -> None: + async def _close() -> None: + await self.client.close() + + try: + asyncio.get_event_loop().run_until_complete(_close()) + except RuntimeError: + asyncio.run(_close()) + + def time_get_package(self) -> None: + async def _bench() -> None: + await self.client.get_package(self.fake_package_id) + + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + future = asyncio.ensure_future(_bench(), loop=loop) + loop.run_until_complete(future) + else: + loop.run_until_complete(_bench()) + except Exception: + pass + + def time_resolve_package(self, pkg_type: str) -> None: + async def _bench() -> None: + await self.client.resolve_package(pkg_type, "example", "test", "v1.0.0") + + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + future = asyncio.ensure_future(_bench(), loop=loop) + loop.run_until_complete(future) + else: + loop.run_until_complete(_bench()) + except Exception: + pass + + def time_browse(self) -> None: + async def _bench() -> None: + await self.client.browse() + + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + future = asyncio.ensure_future(_bench(), loop=loop) + loop.run_until_complete(future) + else: + loop.run_until_complete(_bench()) + except Exception: + pass + + def time_discover(self) -> None: + async def _bench() -> None: + await self.client.discover() + + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + future = asyncio.ensure_future(_bench(), loop=loop) + loop.run_until_complete(future) + else: + loop.run_until_complete(_bench()) + except Exception: + pass diff --git a/features/registry_http_client.feature b/features/registry_http_client.feature new file mode 100644 index 0000000..1fba074 --- /dev/null +++ b/features/registry_http_client.feature @@ -0,0 +1,200 @@ +Feature: Registry HTTP Client + + As a developer using the Package Registry Standard v1.0.0, + I want an async HTTP client that can retrieve packages, resolve references, + browse published packages, and discover registry metadata, + so that I can interact with any compliant registry server. + + Background: + Given a clean test environment for registry HTTP client + + # ── Client Initialisation ──────────────────────────────────────────── + + Scenario: Create client with base URL + When I create a RegistryClient pointed at "https://registry.example.com" + Then the client base URL should be "https://registry.example.com" + + Scenario: Create client with optional API key + When I create a RegistryClient with base URL "https://registry.example.com" and API key "secret-token" + Then the client should have the API key "secret-token" + + Scenario: Create client with empty base URL raises ValueError + When I try to create a RegistryClient with empty base URL + Then a ValueError should be raised by the registry client + + # ── GET /packages/{package_id} (§8.2.1) ────────────────────────────── + + Scenario: Fetch package by valid PackageId returns YAML content + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 200 with body '{"content": "name: test-pkg\ntype: actor"}' + When I call get_package with package_id "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" + Then the registry result body equals '{"content": "name: test-pkg\ntype: actor"}' + + Scenario: Fetch package returns 404 raises PackageNotFoundError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 404 with body '{"message": "Package not found"}' + When I try to call get_package with package_id "pkg_act_deadbeef000000000000000000000000000000" + Then a PackageNotFoundError should be raised + + Scenario: Fetch package returns 400 raises InvalidPackageIdError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 400 with body '{"message": "Invalid Package ID"}' + When I try to call get_package with package_id "invalid_id" + Then an InvalidPackageIdError should be raised + + # ── GET /{type}/{ns}/{name}?version= (§8.2.2) ──────────────────────── + + Scenario: Resolve package with concrete version + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 200 with body '{"package_id": "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef", "type": "actor"}' + When I call resolve_package with type "actor", namespace "example", name "my-actor", version "v1.0.0" + Then the resolved package_id should be "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" + + Scenario: Resolve package with latest alias + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 200 with body '{"package_id": "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef", "type": "actor"}' + When I call resolve_package with type "actor", namespace "example", name "my-actor" and version "latest" + Then the resolved package_id should be "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" + + Scenario: Resolve package without version defaults to latest + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 200 with body '{"package_id": "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef", "type": "actor"}' + When I call resolve_package with type "actor", namespace "example", name "my-actor" without version + Then the resolved package_id should be "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" + + Scenario: Resolve package returns 404 raises PackageNotFoundError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 404 with body '{"message": "Not found"}' + When I try to call resolve_package with type "actor", namespace "example", name "missing", version "v1.0.0" + Then a PackageNotFoundError should be raised + + # ── GET /browse (§8.4.1) ───────────────────────────────────────────── + + Scenario: Browse all packages without filters + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 200 with body '{"packages": [], "count": 0}' + When I call browse without filters + Then the result should have "packages" key and "count" key + + Scenario: Browse packages filtered by type + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 200 with body '{"packages": [{"id": "pkg_act_abc", "type": "actor", "name": "test"}], "count": 1}' + When I call browse with type "actor" + Then the result count should be 1 + + Scenario: Browse packages filtered by namespace + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 200 with body '{"packages": [], "count": 0}' + When I call browse with namespace "example" + Then the result count should be 0 + + # ── GET /.well-known/cleverthis-packages (§8.4.2) ──────────────────── + + Scenario: Discover registry metadata + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 200 with body '{"name": "Test Registry", "version": "1.0.0", "supported_types": ["actor"], "authentication": ["anonymous"], "features": ["semantic_versioning"]}' + When I call discover + Then the result should have key "name" with value "Test Registry" + Then the result should have key "supported_types" + + # ── Network errors ─────────────────────────────────────────────────── + + Scenario: Connection timeout raises RegistryNetworkError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server raises a timeout error + When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000" + Then a RegistryNetworkError should be raised + + # ── Error mapping (§13.2) ───────────────────────────────────────────── + + Scenario: 401 response raises AuthenticationRequiredError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 401 with body '{"message": "Authentication required"}' + When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000" + Then an AuthenticationRequiredError should be raised + + Scenario: 403 response raises AccessDeniedError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 403 with body '{"message": "Access denied"}' + When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000" + Then an AccessDeniedError should be raised + + Scenario: 409 response raises ConflictError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 409 with body '{"message": "Package ID conflict"}' + When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000" + Then a ConflictError should be raised + + Scenario: 500 response raises InternalServerError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 500 with body '{"message": "Internal error"}' + When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000" + Then an InternalServerError should be raised + + Scenario: 500 range response raises InternalServerError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 503 with body '{"message": "Service unavailable"}' + When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000" + Then an InternalServerError should be raised + + Scenario: Unknown error status falls back to RegistryError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 418 with non-JSON body "plain text error" + When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000" + Then a RegistryError should be raised + + Scenario: Error response with non-JSON body maps by status + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 400 with non-JSON body "plain text error" + When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000" + Then an InvalidPackageIdError should be raised + + # ── Error type parsing from body (§13.1) ─────────────────────────────── + + Scenario: 404 with VersionNotFound error type in body raises VersionNotFoundError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 404 with body '{"error": {"type": "VersionNotFound", "message": "Version v99.0.0 not found"}}' + When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000" + Then a VersionNotFoundError should be raised + + Scenario: 400 with ValidationError error type in body raises ValidationError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 400 with body '{"error": {"type": "ValidationError", "message": "Package validation failed"}}' + When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000" + Then a ValidationError should be raised + + Scenario: 404 without error type in body falls back to PackageNotFoundError + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 404 with body '{"message": "Package not found"}' + + Scenario: Client with API key includes authorization header + When I create an authenticated RegistryClient pointed at "https://registry.example.com" with API key "my-api-key" + When the mock server returns status 200 with body '{"content": "ok"}' + When I call get_package with API key client and package_id "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" + Then the registry result body equals '{"content": "ok"}' + + # ── All package type prefixes ──────────────────────────────────────── + + Scenario Outline: Resolve supports all package types + When I create a RegistryClient pointed at "https://registry.example.com" + When the mock server returns status 200 with body '{"package_id": "pkg_act_test00000000000000000000000000000000000", "type": "actor"}' + When I call resolve_package with type "", namespace "example", name "test", version "v1.0.0" + Then the resolved package_id should be "pkg_act_test00000000000000000000000000000000000" + + Examples: + | type | + | actor | + | graph | + | stream | + | agent | + | template | + | skill | + | mcp | + | lsp | + + # ── Context manager ────────────────────────────────────────────────── + + Scenario: Client can be used as async context manager + When I create a RegistryClient pointed at "https://registry.example.com" + When I use the client as an async context manager + Then the client should be closed after the context block \ No newline at end of file diff --git a/features/steps/registry_http_client_steps.py b/features/steps/registry_http_client_steps.py new file mode 100644 index 0000000..b80d7a7 --- /dev/null +++ b/features/steps/registry_http_client_steps.py @@ -0,0 +1,429 @@ +""" +Step definitions for Registry HTTP Client BDD tests. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +from behave import given, then, when + +from cleveractors.registry.client import RegistryClient +from cleveractors.registry.exceptions import ( + AccessDeniedError, + AuthenticationRequiredError, + ConflictError, + InternalServerError, + InvalidPackageIdError, + PackageNotFoundError, + RegistryError, + RegistryNetworkError, + ValidationError, + VersionNotFoundError, +) + + +@given("a clean test environment for registry HTTP client") +def step_clean_environment_http_client(context: Any) -> None: + context.client = None + context.result = None + context.error = None + context._http_mock = None + + +# ── Client Initialisation ──────────────────────────────────────────────── + + +@when('I create a RegistryClient pointed at "{base_url}"') +def step_create_client(context: Any, base_url: str) -> None: + context.client = RegistryClient(base_url=base_url) + + +@when('I create a RegistryClient with base URL "{base_url}" and API key "{api_key}"') +def step_create_client_with_key(context: Any, base_url: str, api_key: str) -> None: + context.client = RegistryClient(base_url=base_url, api_key=api_key) + + +@when("I try to create a RegistryClient with empty base URL") +def step_create_client_empty_url(context: Any) -> None: + try: + RegistryClient(base_url="") + except ValueError as exc: + context.error = exc + + +@then('the client base URL should be "{expected}"') +def step_check_base_url(context: Any, expected: str) -> None: + assert context.client is not None + assert context.client.base_url == expected.rstrip("/") + + +@then('the client should have the API key "{expected}"') +def step_check_api_key(context: Any, expected: str) -> None: + assert context.client is not None + assert context.client.api_key == expected + + +@then("a ValueError should be raised by the registry client") +def step_check_value_error_registry(context: Any) -> None: + assert context.error is not None + assert isinstance(context.error, ValueError) + + +# ── Mock server helpers ────────────────────────────────────────────────── + + +@then('the result should have "{key1}" key and "{key2}" key') +def step_check_two_keys(context: Any, key1: str, key2: str) -> None: + assert context.result is not None + assert key1 in context.result + assert key2 in context.result + + +@then('the result should have key "{key}" with value "{value}"') +def step_check_key_value(context: Any, key: str, value: str) -> None: + assert context.result is not None + assert context.result.get(key) == value + + +def _set_mock_response(context: Any, status: int, body_json: str) -> None: + import json + + body = json.loads(body_json) + mock_response = MagicMock() + mock_response.status_code = status + mock_response.json.return_value = body + mock_response.raise_for_status = MagicMock() + + if status >= 400: + exc = httpx.HTTPStatusError( + "error", request=MagicMock(), response=mock_response + ) + mock_response.raise_for_status.side_effect = exc + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.is_closed = False + mock_client.aclose = AsyncMock() + + context._mock_client = mock_client + context._mock_response = mock_response + + +def _set_mock_non_json_response(context: Any, status: int, body_text: str) -> None: + mock_response = MagicMock() + mock_response.status_code = status + mock_response.text = body_text + mock_response.json.side_effect = ValueError("not JSON") + mock_response.raise_for_status = MagicMock() + + exc = httpx.HTTPStatusError("error", request=MagicMock(), response=mock_response) + mock_response.raise_for_status.side_effect = exc + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.is_closed = False + mock_client.aclose = AsyncMock() + + context._mock_client = mock_client + context._mock_response = mock_response + + +def _set_mock_timeout(context: Any) -> None: + mock_client = AsyncMock() + mock_client.is_closed = False + mock_client.aclose = AsyncMock() + mock_client.get.side_effect = httpx.ReadTimeout("timed out") + context._mock_client = mock_client + + +@when("the mock server returns status {status:d} with body '{body}'") +def step_mock_server_response(context: Any, status: int, body: str) -> None: + _set_mock_response(context, status, body) + + +@when("the mock server raises a timeout error") +def step_mock_server_timeout(context: Any) -> None: + _set_mock_timeout(context) + + +@when('the mock server returns status {status:d} with non-JSON body "{body_text}"') +def step_mock_server_non_json(context: Any, status: int, body_text: str) -> None: + _set_mock_non_json_response(context, status, body_text) + + +# ── get_package (§8.2.1) ──────────────────────────────────────────────── + + +@when('I call get_package with package_id "{package_id}"') +def step_call_get_package(context: Any, package_id: str) -> None: + async def _call() -> None: + context.client._client = context._mock_client + context.result = await context.client.get_package(package_id) + + asyncio.get_event_loop().run_until_complete(_call()) + + +@when('I try to call get_package with package_id "{package_id}"') +def step_try_call_get_package(context: Any, package_id: str) -> None: + async def _call() -> None: + context.client._client = context._mock_client + try: + await context.client.get_package(package_id) + except Exception as exc: + context.error = exc + + asyncio.get_event_loop().run_until_complete(_call()) + + +@then("the registry result body equals '{body}'") +def step_check_result_body(context: Any, body: str) -> None: + import json + + expected = json.loads(body) + assert context.result == expected + + +@then("a PackageNotFoundError should be raised") +def step_check_package_not_found(context: Any) -> None: + assert context.error is not None + assert isinstance(context.error, PackageNotFoundError) + + +@then("an InvalidPackageIdError should be raised") +def step_check_invalid_id(context: Any) -> None: + assert context.error is not None + assert isinstance(context.error, InvalidPackageIdError) + + +@then("a RegistryNetworkError should be raised") +def step_check_network_error(context: Any) -> None: + assert context.error is not None + assert isinstance(context.error, RegistryNetworkError) + + +# ── resolve_package (§8.2.2) ──────────────────────────────────────────── + + +@when( + 'I call resolve_package with type "{pkg_type}", namespace "{ns}", name "{name}", version "{version}"' +) +def step_call_resolve_with_version( + context: Any, pkg_type: str, ns: str, name: str, version: str +) -> None: + async def _call() -> None: + context.client._client = context._mock_client + context.result = await context.client.resolve_package( + pkg_type, ns, name, version + ) + + asyncio.get_event_loop().run_until_complete(_call()) + + +@when( + 'I call resolve_package with type "{pkg_type}", namespace "{ns}", name "{name}" and version "{version}"' +) +def step_call_resolve_and_version( + context: Any, pkg_type: str, ns: str, name: str, version: str +) -> None: + async def _call() -> None: + context.client._client = context._mock_client + context.result = await context.client.resolve_package( + pkg_type, ns, name, version + ) + + asyncio.get_event_loop().run_until_complete(_call()) + + +@when( + 'I call resolve_package with type "{pkg_type}", namespace "{ns}", name "{name}" without version' +) +def step_call_resolve_no_version( + context: Any, pkg_type: str, ns: str, name: str +) -> None: + async def _call() -> None: + context.client._client = context._mock_client + context.result = await context.client.resolve_package(pkg_type, ns, name) + + asyncio.get_event_loop().run_until_complete(_call()) + + +@when( + 'I try to call resolve_package with type "{pkg_type}", namespace "{ns}", name "{name}", version "{version}"' +) +def step_try_call_resolve( + context: Any, pkg_type: str, ns: str, name: str, version: str +) -> None: + async def _call() -> None: + context.client._client = context._mock_client + try: + await context.client.resolve_package(pkg_type, ns, name, version) + except Exception as exc: + context.error = exc + + asyncio.get_event_loop().run_until_complete(_call()) + + +@then('the resolved package_id should be "{expected}"') +def step_check_resolved_id(context: Any, expected: str) -> None: + assert context.result is not None + assert context.result.get("package_id") == expected + + +# ── browse (§8.4.1) ───────────────────────────────────────────────────── + + +@then('the result should have "packages" key') +def step_check_browse_package_key(context: Any) -> None: + assert context.result is not None + assert "packages" in context.result + assert "count" in context.result + + +@when("I call browse without filters") +def step_call_browse_no_filters(context: Any) -> None: + async def _call() -> None: + context.client._client = context._mock_client + context.result = await context.client.browse() + + asyncio.get_event_loop().run_until_complete(_call()) + + +@when('I call browse with type "{pkg_type}"') +def step_call_browse_with_type(context: Any, pkg_type: str) -> None: + async def _call() -> None: + context.client._client = context._mock_client + context.result = await context.client.browse(package_type=pkg_type) + + asyncio.get_event_loop().run_until_complete(_call()) + + +@when('I call browse with namespace "{ns}"') +def step_call_browse_with_namespace(context: Any, ns: str) -> None: + async def _call() -> None: + context.client._client = context._mock_client + context.result = await context.client.browse(namespace=ns) + + asyncio.get_event_loop().run_until_complete(_call()) + + +@then("the result count should be {expected:d}") +def step_check_result_count(context: Any, expected: int) -> None: + assert context.result is not None + assert context.result.get("count") == expected + + +# ── discover (§8.4.2) ─────────────────────────────────────────────────── + + +@when("I call discover") +def step_call_discover(context: Any) -> None: + async def _call() -> None: + context.client._client = context._mock_client + context.result = await context.client.discover() + + asyncio.get_event_loop().run_until_complete(_call()) + + +@then('the result should have key "{key}"') +def step_check_key_present(context: Any, key: str) -> None: + assert context.result is not None + assert key in context.result + + +# ── Context manager ────────────────────────────────────────────────────── + + +@when("I use the client as an async context manager") +def step_use_context_manager(context: Any) -> None: + async def _use() -> None: + context.ctx_clean_exit = True + async with context.client as client: + context.ctx_client = client + context.ctx_was_open = not client._client.is_closed + + asyncio.get_event_loop().run_until_complete(_use()) + + +@then("the client should be closed after the context block") +def step_check_closed(context: Any) -> None: + assert context.ctx_was_open + assert context.ctx_clean_exit + + +# ── Additional error type assertions ────────────────────────────────── + + +@when( + 'I create an authenticated RegistryClient pointed at "{base_url}" with API key "{api_key}"' +) +def step_create_client_pointed_at_with_key( + context: Any, base_url: str, api_key: str +) -> None: + context.client = RegistryClient(base_url=base_url, api_key=api_key) + + +@then("an AuthenticationRequiredError should be raised") +def step_check_auth_required(context: Any) -> None: + assert context.error is not None + assert isinstance(context.error, AuthenticationRequiredError) + + +@then("an AccessDeniedError should be raised") +def step_check_access_denied(context: Any) -> None: + assert context.error is not None + assert isinstance(context.error, AccessDeniedError) + + +@then("a ConflictError should be raised") +def step_check_conflict(context: Any) -> None: + assert context.error is not None + assert isinstance(context.error, ConflictError) + + +@then("an InternalServerError should be raised") +def step_check_internal_error(context: Any) -> None: + assert context.error is not None + assert isinstance(context.error, InternalServerError) + + +@then("a RegistryError should be raised") +def step_check_registry_error(context: Any) -> None: + assert context.error is not None + assert isinstance(context.error, RegistryError) + assert not isinstance( + context.error, + ( + AuthenticationRequiredError, + AccessDeniedError, + ConflictError, + InternalServerError, + InvalidPackageIdError, + PackageNotFoundError, + ), + ) + + +@then("a VersionNotFoundError should be raised") +def step_check_version_not_found(context: Any) -> None: + assert context.error is not None + assert isinstance(context.error, VersionNotFoundError) + + +@then("a ValidationError should be raised") +def step_check_validation_error(context: Any) -> None: + assert context.error is not None + assert isinstance(context.error, ValidationError) + + +@when('I call get_package with API key client and package_id "{package_id}"') +def step_call_get_package_with_key(context: Any, package_id: str) -> None: + async def _call() -> None: + with patch("cleveractors.registry.client.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = context._mock_client + context.result = await context.client.get_package(package_id) + + asyncio.get_event_loop().run_until_complete(_call()) diff --git a/pyproject.toml b/pyproject.toml index 2be9b2d..04a6c74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "pyyaml>=6.0.3", "structlog>=24.4.0", "aiohttp>=3.13.4", + "httpx>=0.27.0", "RestrictedPython>=7.0", ] diff --git a/robot/RegistryClientLib.py b/robot/RegistryClientLib.py new file mode 100644 index 0000000..40f983f --- /dev/null +++ b/robot/RegistryClientLib.py @@ -0,0 +1,132 @@ +"""Robot Framework keyword library for RegistryClient integration tests. + +Provides keywords for testing the httpx-based RegistryClient against +a fake registry server (started by the test suite). +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Optional + +from cleveractors.registry.client import RegistryClient +from cleveractors.registry.exceptions import ( + PackageNotFoundError, + RegistryNetworkError, +) + +ROBOT_LIBRARY_SCOPE = "TEST SUITE" # pragma: no cover - integration test library + + +class RegistryClientLib: # pragma: no cover - integration test library + """Keyword library for RegistryClient Robot Framework integration tests.""" + + def __init__(self) -> None: + self._client: Optional[RegistryClient] = None + self._result: Any = None + self._error: Optional[Exception] = None + + def create_registry_client(self, base_url: str) -> None: + self._client = RegistryClient(base_url=base_url) + + def create_registry_client_with_key(self, base_url: str, api_key: str) -> None: + self._client = RegistryClient(base_url=base_url, api_key=api_key) + + def get_package_by_id(self, package_id: str) -> None: + async def _call() -> None: + self._result = await self._client.get_package(package_id) + + self._run(_call) + + def resolve_package_ref( + self, + package_type: str, + namespace: str, + name: str, + version: str = "", + ) -> None: + async def _call() -> None: + ver: Optional[str] = version if version else None + self._result = await self._client.resolve_package( + package_type, namespace, name, ver + ) + + self._run(_call) + + def browse_packages(self) -> None: + async def _call() -> None: + self._result = await self._client.browse() + + self._run(_call) + + def browse_packages_by_type(self, package_type: str) -> None: + async def _call() -> None: + self._result = await self._client.browse(package_type=package_type) + + self._run(_call) + + def discover_registry(self) -> None: + async def _call() -> None: + self._result = await self._client.discover() + + self._run(_call) + + def result_has_key(self, key: str) -> None: + if key not in self._result: + raise AssertionError( + f"Result missing key {key!r}: {sorted(self._result.keys())}" + ) + + def result_key_equals(self, key: str, expected: str) -> None: + actual = str(self._result.get(key)) + if actual != expected: + raise AssertionError(f"Key {key}: expected {expected!r}, got {actual!r}") + + def result_count_equals(self, expected: str) -> None: + actual = len(self._result.get("packages", [])) + if str(actual) != expected: + raise AssertionError(f"Count: expected {expected!r}, got {actual!r}") + + def get_package_should_raise_not_found(self, package_id: str) -> None: + async def _call() -> None: + try: + await self._client.get_package(package_id) + except PackageNotFoundError as exc: + self._error = exc + + self._run(_call) + if self._error is None: + raise AssertionError( + f"Expected PackageNotFoundError for {package_id!r}, none raised" + ) + + def get_package_should_raise_network_error(self, package_id: str) -> None: + async def _call() -> None: + try: + await self._client.get_package(package_id) + except RegistryNetworkError as exc: + self._error = exc + + self._run(_call) + if self._error is None: + raise AssertionError( + f"Expected RegistryNetworkError for {package_id!r}, none raised" + ) + + def close_client(self) -> None: + async def _call() -> None: + await self._client.close() + + self._run(_call) + + @staticmethod + def _run(coro_fn: Any, timeout: float = 30.0) -> None: + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + future = asyncio.run_coroutine_threadsafe(coro_fn(), loop) + future.result(timeout=timeout) + else: + loop.run_until_complete(asyncio.wait_for(coro_fn(), timeout=timeout)) + except RuntimeError: + asyncio.run(asyncio.wait_for(coro_fn(), timeout=timeout)) diff --git a/robot/fake_registry_server.py b/robot/fake_registry_server.py new file mode 100644 index 0000000..a9e48fd --- /dev/null +++ b/robot/fake_registry_server.py @@ -0,0 +1,117 @@ +"""Fake Package Registry server for integration testing. + +Implements minimal responses for the 4 Package Registry Standard +endpoints. Designed to be started as a subprocess by Robot Framework. +""" + +from __future__ import annotations + +import json +import socketserver +from http.server import HTTPServer, SimpleHTTPRequestHandler +from typing import Any +from urllib.parse import parse_qs, urlparse + + +class FakeRegistryHandler(SimpleHTTPRequestHandler): + """Minimal handler implementing the Package Registry Standard endpoints.""" + + EXAMPLE_PACKAGES: list[dict[str, Any]] = [ + { + "id": "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef", + "type": "actor", + "name": "test-actor", + "description": "A test actor package for integration testing", + "namespace": "example", + "version_count": 3, + "created_at": "2026-01-01T00:00:00Z", + }, + { + "id": "pkg_grh_0000000000000000000000000000000000000000", + "type": "graph", + "name": "test-graph", + "description": "A test graph package", + "namespace": "example", + "version_count": 1, + "created_at": "2026-02-01T00:00:00Z", + }, + ] + + def do_GET(self) -> None: + parsed = urlparse(self.path) + path = parsed.path + params = parse_qs(parsed.query) + + if path == "/.well-known/cleverthis-packages": + self._json( + 200, + { + "name": "Fake Package Registry", + "version": "1.0.0", + "supported_types": ["actor", "graph", "stream", "agent"], + "authentication": ["anonymous"], + "features": ["semantic_versioning", "mutable_aliases"], + }, + ) + elif path.startswith("/packages/"): + package_id = path[len("/packages/") :] + if package_id.startswith("pkg_act_a1b2c3d4e5"): + self._json( + 200, + { + "content": "name: test-pkg\ntype: actor", + "package_id": package_id, + }, + ) + else: + self._json(404, {"message": "Package not found"}) + elif path.startswith("/actor/") or path.startswith("/graph/"): + parts = path.strip("/").split("/") + if len(parts) >= 3: + self._json( + 200, + { + "package_id": "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef", + "type": parts[0], + }, + ) + else: + self._json(404, {"message": "Not found"}) + elif path == "/browse": + packages = self.EXAMPLE_PACKAGES + query_type = params.get("type", [None])[0] + if query_type: + packages = [p for p in packages if p["type"] == query_type] + self._json( + 200, + { + "packages": packages, + "count": len(packages), + }, + ) + else: + self._json(404, {"message": "Not found"}) + + def _json(self, status: int, data: dict[str, Any]) -> None: + body = json.dumps(data).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: + pass + + +def run_server(host: str = "127.0.0.1", port: int = 9191) -> None: + server = HTTPServer((host, port), FakeRegistryHandler) + server.serve_forever() + + +if __name__ == "__main__": + import sys + + host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" + port = int(sys.argv[2]) if len(sys.argv) > 2 else 9191 + run_server(host, port) diff --git a/robot/registry_integration.robot b/robot/registry_integration.robot new file mode 100644 index 0000000..e4285c5 --- /dev/null +++ b/robot/registry_integration.robot @@ -0,0 +1,71 @@ +*** Settings *** +Documentation Integration tests for the Registry HTTP client against a fake server. +Library RegistryClientLib.py +Library Process +Library OperatingSystem +Suite Setup Start Fake Registry Server +Suite Teardown Stop Fake Registry Server + +*** Variables *** +${FAKE_SERVER_HOST} 127.0.0.1 +${FAKE_SERVER_PORT} ${9191} + +*** Keywords *** +Start Fake Registry Server + [Documentation] Launch a fake registry server as a subprocess. + Start Process + ... python robot${/}fake_registry_server.py ${FAKE_SERVER_HOST} ${FAKE_SERVER_PORT} + ... alias=fake_registry + Sleep 0.5s + ${server_url}= Set Variable http://${FAKE_SERVER_HOST}:${FAKE_SERVER_PORT} + Set Suite Variable ${SERVER_URL} ${server_url} + +Stop Fake Registry Server + [Documentation] Stop the fake server subprocess. + Terminate Process fake_registry kill=${True} + +*** Test Cases *** +Get Package By Valid ID Returns Content + [Documentation] GET /packages/{id} — valid PackageId returns content. + Create Registry Client ${SERVER_URL} + Get Package By Id pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef + Result Has Key package_id + +Get Package With Invalid ID Raises Not Found + [Documentation] GET /packages/{id} — invalid ID returns 404. + Create Registry Client ${SERVER_URL} + Get Package Should Raise Not Found pkg_act_deadbeef000000000000000000000000000000 + +Resolve Package Returns Package ID + [Documentation] GET /{type}/{ns}/{name} resolves to a concrete ID. + Create Registry Client ${SERVER_URL} + Resolve Package Ref actor example my-actor v1.0.0 + Result Has Key package_id + Result Key Equals type actor + +Browse Packages Returns List + [Documentation] GET /browse returns package list and count. + Create Registry Client ${SERVER_URL} + Browse Packages + Result Has Key packages + Result Has Key count + +Browse Packages By Type Returns Filtered Results + [Documentation] GET /browse?type=actor filters results. + Create Registry Client ${SERVER_URL} + Browse Packages By Type actor + Result Has Key packages + Result Has Key count + +Discover Registry Metadata + [Documentation] GET /.well-known/cleverthis-packages returns registry info. + Create Registry Client ${SERVER_URL} + Discover Registry + Result Has Key name + Result Has Key version + Result Has Key supported_types + +Registry Client Closes Cleanly + [Documentation] close() shuts down the underlying httpx client. + Create Registry Client ${SERVER_URL} + Close Client \ No newline at end of file diff --git a/src/cleveractors/registry/__init__.py b/src/cleveractors/registry/__init__.py new file mode 100644 index 0000000..01590ed --- /dev/null +++ b/src/cleveractors/registry/__init__.py @@ -0,0 +1,29 @@ +"""Package Registry client implementing the Package Registry Standard v1.0.0.""" + +from cleveractors.registry.client import RegistryClient +from cleveractors.registry.exceptions import ( + AccessDeniedError, + AuthenticationRequiredError, + ConflictError, + InternalServerError, + InvalidPackageIdError, + PackageNotFoundError, + RegistryError, + RegistryNetworkError, + ValidationError, + VersionNotFoundError, +) + +__all__ = [ + "RegistryClient", + "RegistryError", + "PackageNotFoundError", + "InvalidPackageIdError", + "VersionNotFoundError", + "ValidationError", + "AuthenticationRequiredError", + "AccessDeniedError", + "ConflictError", + "InternalServerError", + "RegistryNetworkError", +] diff --git a/src/cleveractors/registry/client.py b/src/cleveractors/registry/client.py new file mode 100644 index 0000000..dbd9573 --- /dev/null +++ b/src/cleveractors/registry/client.py @@ -0,0 +1,217 @@ +"""Async HTTP client for the Package Registry Standard v1.0.0. + +Implements all 4 defined HTTP API endpoints (§8) using httpx.AsyncClient: + - GET /packages/{id} (§8.2.1) + - GET /{type}/{ns}/{name} (§8.2.2) + - GET /browse (§8.4.1) + - GET /.well-known/cleverthis-packages (§8.4.2) + +Supports version alias resolution per §4.2, anonymous reads (§9.1), +and optional API key authentication (§9.2). +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +import httpx + +from cleveractors.registry.exceptions import ( + _ERROR_TYPE_MAP, + RegistryNetworkError, + exception_for_status, +) + +logger = logging.getLogger(__name__) + +_PACKAGE_TYPE_PREFIXES: dict[str, str] = { + "actor": "pkg_act_", + "graph": "pkg_grh_", + "stream": "pkg_str_", + "agent": "pkg_agt_", + "template": "pkg_tpl_", + "skill": "pkg_skl_", + "mcp": "pkg_mcp_", + "lsp": "pkg_lsp_", +} + + +class RegistryClient: + """Async HTTP client for the Package Registry Standard v1.0.0. + + Configured per server URL. Uses ``httpx.AsyncClient`` internally + for all HTTP operations. + + Attributes: + base_url: The registry server base URL. + api_key: Optional API key for authenticated operations. + timeout: Request timeout in seconds. + """ + + def __init__( + self, + base_url: str, + api_key: Optional[str] = None, + timeout: float = 30.0, + ) -> None: + if not base_url: + raise ValueError("base_url must not be empty") + self.base_url: str = base_url.rstrip("/") + self.api_key: Optional[str] = api_key + self.timeout: float = timeout + self._client: Optional[httpx.AsyncClient] = None + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + headers: dict[str, str] = {} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + self._client = httpx.AsyncClient( + base_url=self.base_url, + headers=headers, + timeout=httpx.Timeout(self.timeout), + ) + return self._client + + async def _request(self, path: str, params: Optional[dict[str, str]] = None) -> Any: + client = await self._get_client() + try: + response = await client.get(path, params=params) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + msg = f"HTTP {status}" + error_type: Optional[str] = None + try: + body = exc.response.json() + if isinstance(body, dict): + error_block = body.get("error") + if isinstance(error_block, dict): + error_type = error_block.get("type") + if "message" in error_block: + msg = error_block["message"] + elif "message" in body: + msg = body["message"] + except (ValueError, KeyError, TypeError): + pass + if error_type and error_type in _ERROR_TYPE_MAP: + raise _ERROR_TYPE_MAP[error_type](msg) from exc + raise exception_for_status(status, msg) from exc + except httpx.RequestError as exc: + raise RegistryNetworkError(str(exc)) from exc + + async def close(self) -> None: + """Close the underlying HTTP client.""" + if self._client is not None and not self._client.is_closed: + await self._client.aclose() + self._client = None + + async def __aenter__(self) -> "RegistryClient": + await self._get_client() + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + # ------------------------------------------------------------------ + # §8.2.1 GET /packages/{package_id} + # ------------------------------------------------------------------ + + async def get_package(self, package_id: str) -> dict[str, Any]: + """Retrieve raw package content by Package ID. + + Args: + package_id: The globally unique Package ID. + + Returns: + The package content as a dictionary. + + Raises: + PackageNotFoundError: If the package does not exist. + InvalidPackageIdError: If the Package ID format is invalid. + RegistryNetworkError: For connection errors and timeouts. + """ + return await self._request(f"/packages/{package_id}") + + # ------------------------------------------------------------------ + # §8.2.2 GET /{package_type}/{namespace}/{name}?version={version} + # ------------------------------------------------------------------ + + async def resolve_package( + self, + package_type: str, + namespace: str, + name: str, + version: Optional[str] = None, + ) -> dict[str, Any]: + """Resolve a package reference to its concrete Package ID. + + Performs version alias resolution according to §4.2: + - Concrete versions (vX.Y.Z) resolve directly. + - Mutable aliases (latest, vx, vX.Y.x, vX.x) resolve to + the newest matching concrete version. + + Args: + package_type: One of the defined package types (§3.2). + namespace: The namespace owning the package. + name: The package name within its namespace. + version: Optional version string or alias. Defaults to ``latest``. + + Returns: + A dict with ``package_id`` and ``type`` keys. + + Raises: + PackageNotFoundError: If the package does not exist. + VersionNotFoundError: If the version/alias cannot be resolved. + RegistryNetworkError: For connection errors and timeouts. + """ + params: dict[str, str] = {} + if version: + params["version"] = version + return await self._request(f"/{package_type}/{namespace}/{name}", params=params) + + # ------------------------------------------------------------------ + # §8.4.1 GET /browse + # ------------------------------------------------------------------ + + async def browse( + self, + package_type: Optional[str] = None, + namespace: Optional[str] = None, + ) -> dict[str, Any]: + """Browse published packages with optional filters. + + Args: + package_type: Filter by package type (§3.2). + namespace: Filter by namespace. + + Returns: + A dict with ``packages`` (list) and ``count`` keys. + + Raises: + RegistryNetworkError: For connection errors and timeouts. + """ + params: dict[str, str] = {} + if package_type: + params["type"] = package_type + if namespace: + params["namespace"] = namespace + return await self._request("/browse", params=params) + + # ------------------------------------------------------------------ + # §8.4.2 GET /.well-known/cleverthis-packages + # ------------------------------------------------------------------ + + async def discover(self) -> dict[str, Any]: + """Retrieve registry metadata via the well-known discovery endpoint. + + Returns: + A dict with registry metadata: name, version, supported_types, + authentication methods, and features. + + Raises: + RegistryNetworkError: For connection errors and timeouts. + """ + return await self._request("/.well-known/cleverthis-packages") diff --git a/src/cleveractors/registry/exceptions.py b/src/cleveractors/registry/exceptions.py new file mode 100644 index 0000000..15b837a --- /dev/null +++ b/src/cleveractors/registry/exceptions.py @@ -0,0 +1,76 @@ +"""Typed exceptions for the Package Registry client. + +Maps the 8 error types defined in the Package Registry Standard §13.2 +to Python exception classes, plus a network error type for transport failures. +""" + +from cleveractors.core.exceptions import CleverAgentsException + + +class RegistryError(CleverAgentsException): + """Base exception for all registry-related errors.""" + + +class PackageNotFoundError(RegistryError): + """The requested package does not exist (HTTP 404).""" + + +class InvalidPackageIdError(RegistryError): + """The provided Package ID format is invalid (HTTP 400).""" + + +class VersionNotFoundError(RegistryError): + """The requested version does not exist (HTTP 404).""" + + +class ValidationError(RegistryError): + """The package failed validation (HTTP 400).""" + + +class AuthenticationRequiredError(RegistryError): + """Authentication is required for this operation (HTTP 401).""" + + +class AccessDeniedError(RegistryError): + """Access to the requested resource is denied (HTTP 403).""" + + +class ConflictError(RegistryError): + """The request conflicts with existing data (HTTP 409).""" + + +class InternalServerError(RegistryError): + """An unexpected error occurred server-side (HTTP 500).""" + + +class RegistryNetworkError(RegistryError): + """Connection errors, timeouts, or other transport-level failures.""" + + +_ERROR_TYPE_MAP: dict[str, type[RegistryError]] = { + "PackageNotFound": PackageNotFoundError, + "InvalidPackageId": InvalidPackageIdError, + "VersionNotFound": VersionNotFoundError, + "ValidationError": ValidationError, + "AuthenticationRequired": AuthenticationRequiredError, + "AccessDenied": AccessDeniedError, + "Conflict": ConflictError, + "InternalServerError": InternalServerError, +} + + +def exception_for_status(status_code: int, message: str) -> RegistryError: + """Return an appropriate RegistryError for an HTTP status code.""" + if status_code == 400: + return InvalidPackageIdError(message) + if status_code == 401: + return AuthenticationRequiredError(message) + if status_code == 403: + return AccessDeniedError(message) + if status_code == 404: + return PackageNotFoundError(message) + if status_code == 409: + return ConflictError(message) + if 500 <= status_code < 600: + return InternalServerError(message) + return RegistryError(message)