diff --git a/benchmarks/acp_facade_bench.py b/benchmarks/acp_facade_bench.py new file mode 100644 index 00000000..4069fde8 --- /dev/null +++ b/benchmarks/acp_facade_bench.py @@ -0,0 +1,124 @@ +"""ASV benchmarks for ACP facade dispatch, version negotiation, and event queue. + +Measures the performance of: +- AcpLocalFacade dispatch overhead per operation +- AcpVersionNegotiator negotiation throughput +- AcpEventQueue publish and get_events throughput +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +# Ensure the local *source* tree is importable even when ASV has an +# older build of the package installed. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +# Force-reload so ASV picks up the source tree version. +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.acp.events import AcpEventQueue # noqa: E402 +from cleveragents.acp.facade import AcpLocalFacade # noqa: E402 +from cleveragents.acp.models import AcpEvent, AcpRequest # noqa: E402 +from cleveragents.acp.versioning import AcpVersionNegotiator # noqa: E402 + +# --------------------------------------------------------------------------- +# Facade dispatch benchmarks +# --------------------------------------------------------------------------- + + +class FacadeDispatchSuite: + """Benchmark AcpLocalFacade.dispatch() overhead.""" + + timeout = 60 + + def setup(self) -> None: + self.facade = AcpLocalFacade() + self.session_req = AcpRequest(operation="session.create") + self.plan_req = AcpRequest( + operation="plan.execute", params={"plan_id": "BENCH001"} + ) + self.context_req = AcpRequest(operation="context.get") + + def time_dispatch_session_create(self) -> None: + self.facade.dispatch(self.session_req) + + def time_dispatch_plan_execute(self) -> None: + self.facade.dispatch(self.plan_req) + + def time_dispatch_context_get(self) -> None: + self.facade.dispatch(self.context_req) + + def time_dispatch_all_operations(self) -> None: + for op in self.facade.list_operations(): + req = AcpRequest(operation=op) + self.facade.dispatch(req) + + def time_list_operations(self) -> None: + self.facade.list_operations() + + +# --------------------------------------------------------------------------- +# Version negotiation benchmarks +# --------------------------------------------------------------------------- + + +class VersionNegotiationSuite: + """Benchmark AcpVersionNegotiator throughput.""" + + timeout = 60 + + def setup(self) -> None: + self.negotiator = AcpVersionNegotiator() + + def time_negotiate_supported(self) -> None: + self.negotiator.negotiate("1.0") + + def time_is_supported_true(self) -> None: + self.negotiator.is_supported("1.0") + + def time_is_supported_false(self) -> None: + self.negotiator.is_supported("99.0") + + def time_get_current(self) -> None: + self.negotiator.get_current() + + +# --------------------------------------------------------------------------- +# Event queue benchmarks +# --------------------------------------------------------------------------- + + +class EventQueueSuite: + """Benchmark AcpEventQueue throughput.""" + + timeout = 60 + + def setup(self) -> None: + self.queue = AcpEventQueue() + self.event = AcpEvent(event_type="bench.event") + + def time_publish_single(self) -> None: + self.queue.publish(self.event) + + def time_publish_100(self) -> None: + for _ in range(100): + self.queue.publish(self.event) + + def time_get_events_default(self) -> None: + self.queue.get_events() + + def time_subscribe_local(self) -> None: + self.queue.subscribe_local(lambda e: None) + + def time_publish_with_subscriber(self) -> None: + q = AcpEventQueue() + q.subscribe_local(lambda e: None) + for _ in range(100): + q.publish(self.event) diff --git a/docs/reference/acp.md b/docs/reference/acp.md new file mode 100644 index 00000000..235b73fd --- /dev/null +++ b/docs/reference/acp.md @@ -0,0 +1,185 @@ +# ACP (Agent Communication Protocol) Reference + +## Overview + +The ACP package provides the boundary layer between the CleverAgents +application core and any external orchestrator or UI. It defines a +request/response envelope, a set of named operations, and an event +streaming interface. + +**Module:** `cleveragents.acp` +**ACP Version:** 1.0 + +## Table of Contents + +- [Modes of Operation](#modes-of-operation) +- [Local Facade](#local-facade) +- [Operation Routing Table](#operation-routing-table) +- [Server Transport Stub](#server-transport-stub) +- [Event Queue](#event-queue) +- [Version Negotiation](#version-negotiation) +- [Models](#models) +- [Error Hierarchy](#error-hierarchy) + +--- + +## Modes of Operation + +| Mode | Class | Behaviour | +|--------|--------------------|--------------------------------------------------| +| Local | `AcpLocalFacade` | Routes operations to in-process service calls | +| Server | `AcpHttpTransport` | Stub — raises `AcpNotAvailableError` on all ops | + +In local mode the facade translates each ACP operation into a direct +Python method call. No serialization, no network, no authentication +overhead. + +--- + +## Local Facade + +```python +from cleveragents.acp import AcpLocalFacade, AcpRequest + +facade = AcpLocalFacade() +response = facade.dispatch(AcpRequest(operation="session.create")) +assert response.status == "ok" +``` + +### Constructor + +| Parameter | Type | Default | Description | +|-----------|----------------------------|---------|-------------------------------| +| `services` | `dict[str, Any] \| None` | `None` | Named services for routing | + +### Methods + +| Method | Returns | Description | +|---------------------|-------------------|--------------------------------------| +| `dispatch(request)` | `AcpResponse` | Route request to handler | +| `register_service` | `None` | Register a named service | +| `list_operations` | `list[str]` | All supported operation names | + +--- + +## Operation Routing Table + +| Operation | Response Keys | +|------------------------|-----------------------------------------| +| `session.create` | `session_id`, `status` | +| `session.close` | `status` | +| `plan.create` | `plan_id`, `status` | +| `plan.execute` | `plan_id`, `status` | +| `plan.status` | `plan_id`, `phase` | +| `plan.diff` | `plan_id`, `changes` | +| `plan.apply` | `plan_id`, `status` | +| `registry.list_tools` | `tools` | +| `registry.list_resources` | `resources` | +| `context.get` | `context` | +| `event.subscribe` | `subscription_id`, `status` | + +Unknown operations raise `AcpOperationNotFoundError`. + +--- + +## Server Transport Stub + +All methods on `AcpHttpTransport` raise `AcpNotAvailableError`: + +| Method | Description | +|-----------------|------------------------------------------| +| `send(request)` | Would send request over HTTP | +| `connect(url)` | Would open HTTP connection | +| `disconnect()` | Would close connection | +| `is_connected()`| Returns `False` (does not raise) | + +--- + +## Event Queue + +`AcpEventQueue` provides an in-memory event queue for local mode: + +| Method | Mode | Description | +|-----------------------|--------|------------------------------------| +| `publish(event)` | Local | Append event and notify callbacks | +| `subscribe_local(cb)` | Local | Register callback, return sub ID | +| `unsubscribe(id)` | Local | Remove subscription | +| `get_events(limit)` | Local | Return recent events | +| `subscribe_remote(ep)`| Server | Raises `AcpNotAvailableError` | + +--- + +## Version Negotiation + +`AcpVersionNegotiator` validates protocol version compatibility: + +```python +from cleveragents.acp import AcpVersionNegotiator + +negotiator = AcpVersionNegotiator() +version = negotiator.negotiate("1.0") # returns "1.0" +negotiator.negotiate("2.0") # raises AcpVersionMismatchError +``` + +Supported versions: `["1.0"]` + +--- + +## Models + +### AcpRequest + +| Field | Type | Default | +|---------------|----------------------------|-------------------| +| `acp_version` | `str` | `"1.0"` | +| `request_id` | `str` | Auto-generated ULID | +| `operation` | `str` | *required* | +| `params` | `dict[str, Any]` | `{}` | +| `auth` | `dict[str, Any] \| None` | `None` | + +### AcpResponse + +| Field | Type | Default | +|---------------|------------------------------|-------------------| +| `acp_version` | `str` | `"1.0"` | +| `request_id` | `str` | *required* | +| `status` | `str` (`"ok"` or `"error"`) | *required* | +| `data` | `dict[str, Any]` | `{}` | +| `error` | `AcpErrorDetail \| None` | `None` | +| `timing_ms` | `float \| None` | `None` | + +### AcpErrorDetail + +| Field | Type | Default | +|-----------|-------------------|---------| +| `code` | `str` | *required* | +| `message` | `str` | *required* | +| `details` | `dict[str, Any]` | `{}` | + +### AcpEvent + +| Field | Type | Default | +|--------------|---------------------------|------------------------| +| `event_id` | `str` | Auto-generated ULID | +| `event_type` | `str` | *required* | +| `plan_id` | `str \| None` | `None` | +| `data` | `dict[str, Any]` | `{}` | +| `timestamp` | `str` | Auto-generated ISO UTC | + +--- + +## Error Hierarchy + +``` +CleverAgentsError + └── AcpError + ├── AcpNotAvailableError + ├── AcpVersionMismatchError + └── AcpOperationNotFoundError +``` + +| Exception | When Raised | +|-----------------------------|-------------------------------------------| +| `AcpNotAvailableError` | Server-mode operation in local mode | +| `AcpVersionMismatchError` | Unsupported ACP version requested | +| `AcpOperationNotFoundError` | Unknown operation dispatched | diff --git a/features/acp_facade.feature b/features/acp_facade.feature new file mode 100644 index 00000000..3838ec9d --- /dev/null +++ b/features/acp_facade.feature @@ -0,0 +1,286 @@ +@phase2 @acp @facade +Feature: ACP Local Facade and Server Stubs + As a developer + I want an ACP integration layer with local-mode facade and server stubs + So that ACP operations work locally and server mode is stubbed out + + # ----------------------------------------------------------------------- + # AcpLocalFacade — creation and service registration + # ----------------------------------------------------------------------- + + Scenario: Create facade with no services + Given a new AcpLocalFacade with no services + Then the facade should be created successfully + + Scenario: Create facade with services dict + Given a new AcpLocalFacade with services {"session": "mock_session"} + Then the facade should be created successfully + + Scenario: Register a service on the facade + Given a new AcpLocalFacade with no services + When I register a service named "planner" on the facade + Then the service should be registered successfully + + Scenario: Register service with empty name raises error + Given a new AcpLocalFacade with no services + When I try to register a service with an empty name + Then a ValueError should be raised + + # ----------------------------------------------------------------------- + # AcpLocalFacade — dispatch for each supported operation + # ----------------------------------------------------------------------- + + Scenario: Dispatch session.create returns session_id and status + Given a new AcpLocalFacade with no services + When I dispatch operation "session.create" with params {} + Then the response status should be "ok" + And response data includes key "session_id" + And response data key "status" equals "created" + + Scenario: Dispatch session.close returns status closed + Given a new AcpLocalFacade with no services + When I dispatch operation "session.close" with params {} + Then the response status should be "ok" + And response data key "status" equals "closed" + + Scenario: Dispatch plan.create returns plan_id and status + Given a new AcpLocalFacade with no services + When I dispatch operation "plan.create" with params {} + Then the response status should be "ok" + And response data includes key "plan_id" + And response data key "status" equals "created" + + Scenario: Dispatch plan.execute returns plan_id and status queued + Given a new AcpLocalFacade with no services + When I dispatch operation "plan.execute" with params {"plan_id": "PLAN001"} + Then the response status should be "ok" + And response data key "plan_id" equals "PLAN001" + And response data key "status" equals "queued" + + Scenario: Dispatch plan.status returns plan_id and phase + Given a new AcpLocalFacade with no services + When I dispatch operation "plan.status" with params {"plan_id": "PLAN001"} + Then the response status should be "ok" + And response data key "plan_id" equals "PLAN001" + And response data key "phase" equals "unknown" + + Scenario: Dispatch plan.diff returns plan_id and empty changes + Given a new AcpLocalFacade with no services + When I dispatch operation "plan.diff" with params {"plan_id": "PLAN001"} + Then the response status should be "ok" + And response data key "plan_id" equals "PLAN001" + And response data includes key "changes" + + Scenario: Dispatch plan.apply returns plan_id and status applied + Given a new AcpLocalFacade with no services + When I dispatch operation "plan.apply" with params {"plan_id": "PLAN001"} + Then the response status should be "ok" + And response data key "plan_id" equals "PLAN001" + And response data key "status" equals "applied" + + Scenario: Dispatch registry.list_tools returns empty tools + Given a new AcpLocalFacade with no services + When I dispatch operation "registry.list_tools" with params {} + Then the response status should be "ok" + And response data includes key "tools" + + Scenario: Dispatch registry.list_resources returns empty resources + Given a new AcpLocalFacade with no services + When I dispatch operation "registry.list_resources" with params {} + Then the response status should be "ok" + And response data includes key "resources" + + Scenario: Dispatch context.get returns empty context + Given a new AcpLocalFacade with no services + When I dispatch operation "context.get" with params {} + Then the response status should be "ok" + And response data includes key "context" + + Scenario: Dispatch event.subscribe returns subscription_id and status + Given a new AcpLocalFacade with no services + When I dispatch operation "event.subscribe" with params {} + Then the response status should be "ok" + And response data includes key "subscription_id" + And response data key "status" equals "subscribed" + + # ----------------------------------------------------------------------- + # AcpLocalFacade — unknown operation + # ----------------------------------------------------------------------- + + Scenario: Dispatch unknown operation raises AcpOperationNotFoundError + Given a new AcpLocalFacade with no services + When I dispatch an unknown operation "does.not.exist" + Then an AcpOperationNotFoundError should be raised + And the error operation attribute should be "does.not.exist" + + # ----------------------------------------------------------------------- + # AcpLocalFacade — list_operations + # ----------------------------------------------------------------------- + + Scenario: list_operations returns all supported operations + Given a new AcpLocalFacade with no services + When I call list_operations on the facade + Then the operations list should contain "session.create" + And the operations list should contain "plan.create" + And the operations list should contain "plan.execute" + And the operations list should contain "registry.list_tools" + And the operations list should contain "context.get" + And the operations list should contain "event.subscribe" + And the operations list should have 11 items + + # ----------------------------------------------------------------------- + # AcpHttpTransport — all stubs raise AcpNotAvailableError + # ----------------------------------------------------------------------- + + Scenario: Transport send raises AcpNotAvailableError + Given a new AcpHttpTransport + When I try to send a request via the transport + Then an AcpNotAvailableError should be raised + + Scenario: Transport connect raises AcpNotAvailableError + Given a new AcpHttpTransport + When I try to connect via the transport to "http://localhost:8080" + Then an AcpNotAvailableError should be raised + + Scenario: Transport disconnect raises AcpNotAvailableError + Given a new AcpHttpTransport + When I try to disconnect the transport + Then an AcpNotAvailableError should be raised + + Scenario: Transport is_connected returns False + Given a new AcpHttpTransport + Then the transport should not be connected + + # ----------------------------------------------------------------------- + # AcpEventQueue — local mode works + # ----------------------------------------------------------------------- + + Scenario: Publish event to local queue + Given a new AcpEventQueue + When I publish an event with type "plan.started" + Then the event queue should have 1 event + + Scenario: Subscribe locally and receive event + Given a new AcpEventQueue + And I subscribe locally with a callback + When I publish an event with type "plan.completed" + Then the callback should have been called with event type "plan.completed" + + Scenario: Unsubscribe removes subscription + Given a new AcpEventQueue + And I subscribe locally with a callback + When I unsubscribe using the subscription id + Then the unsubscribe should return True + + Scenario: Unsubscribe non-existent returns False + Given a new AcpEventQueue + When I unsubscribe using a non-existent subscription id + Then the unsubscribe should return False + + Scenario: Get events respects limit + Given a new AcpEventQueue + When I publish 5 events + And I get events with limit 3 + Then I should receive 3 events + + # ----------------------------------------------------------------------- + # AcpEventQueue — remote stub raises + # ----------------------------------------------------------------------- + + Scenario: Remote subscribe raises AcpNotAvailableError + Given a new AcpEventQueue + When I try to subscribe remotely to "http://remote:9090/events" + Then an AcpNotAvailableError should be raised + + # ----------------------------------------------------------------------- + # AcpVersionNegotiator + # ----------------------------------------------------------------------- + + Scenario: Negotiate supported version succeeds + Given a new AcpVersionNegotiator + When I negotiate version "1.0" + Then the negotiated version should be "1.0" + + Scenario: Negotiate unsupported version raises error + Given a new AcpVersionNegotiator + When I try to negotiate version "2.0" + Then an AcpVersionMismatchError should be raised + And the error requested_version should be "2.0" + + Scenario: is_supported returns True for valid version + Given a new AcpVersionNegotiator + Then version "1.0" should be supported + + Scenario: is_supported returns False for invalid version + Given a new AcpVersionNegotiator + Then version "99.0" should not be supported + + Scenario: get_current returns current version + Given a new AcpVersionNegotiator + Then the current version should be "1.0" + + # ----------------------------------------------------------------------- + # AcpRequest model validation + # ----------------------------------------------------------------------- + + Scenario: AcpRequest with valid operation succeeds + When I create an AcpRequest with operation "session.create" + Then the request should have a non-empty request_id + And the request acp_version should be "1.0" + + Scenario: AcpRequest with empty operation fails validation + When I try to create an AcpRequest with empty operation + Then a validation error should be raised + + # ----------------------------------------------------------------------- + # AcpResponse model validation + # ----------------------------------------------------------------------- + + Scenario: AcpResponse with valid status ok succeeds + When I create an AcpResponse with status "ok" and request_id "REQ001" + Then the response should be valid + + Scenario: AcpResponse with invalid status fails validation + When I try to create an AcpResponse with status "maybe" + Then a validation error should be raised + + # ----------------------------------------------------------------------- + # AcpErrorDetail model validation + # ----------------------------------------------------------------------- + + Scenario: AcpErrorDetail with valid fields succeeds + When I create an AcpErrorDetail with code "NOT_FOUND" and message "gone" + Then the error detail should be valid + + Scenario: AcpErrorDetail with empty code fails validation + When I try to create an AcpErrorDetail with empty code + Then a validation error should be raised + + # ----------------------------------------------------------------------- + # AcpEvent model construction + # ----------------------------------------------------------------------- + + Scenario: AcpEvent auto-generates event_id and timestamp + When I create an AcpEvent with type "plan.progress" + Then the event should have a non-empty event_id + And the event should have a non-empty timestamp + + # ----------------------------------------------------------------------- + # Error hierarchy + # ----------------------------------------------------------------------- + + Scenario: AcpError is a CleverAgentsError + Then AcpError should be a subclass of CleverAgentsError + + Scenario: AcpNotAvailableError is an AcpError + Then AcpNotAvailableError should be a subclass of AcpError + + Scenario: AcpVersionMismatchError is an AcpError + Then AcpVersionMismatchError should be a subclass of AcpError + + Scenario: AcpOperationNotFoundError is an AcpError + Then AcpOperationNotFoundError should be a subclass of AcpError + + Scenario: AcpNotAvailableError has default message + When I create an AcpNotAvailableError with default message + Then the error message should contain "not available in local mode" diff --git a/features/steps/acp_facade_steps.py b/features/steps/acp_facade_steps.py new file mode 100644 index 00000000..6c5dd262 --- /dev/null +++ b/features/steps/acp_facade_steps.py @@ -0,0 +1,488 @@ +"""Step definitions for ACP facade and stubs Behave scenarios.""" + +from __future__ import annotations + +import json +from typing import Any + +from behave import given, then, use_step_matcher, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.acp.errors import ( + AcpError, + AcpNotAvailableError, + AcpOperationNotFoundError, + AcpVersionMismatchError, +) +from cleveragents.acp.events import AcpEventQueue +from cleveragents.acp.facade import AcpLocalFacade +from cleveragents.acp.models import ( + AcpErrorDetail, + AcpEvent, + AcpRequest, + AcpResponse, +) +from cleveragents.acp.transport import AcpHttpTransport +from cleveragents.acp.versioning import AcpVersionNegotiator +from cleveragents.core.exceptions import CleverAgentsError + +use_step_matcher("re") + + +# AcpLocalFacade — creation and service registration + + +@given(r"a new AcpLocalFacade with no services") +def step_facade_no_services(context: Context) -> None: + context.facade = AcpLocalFacade() + + +@given(r"a new AcpLocalFacade with services (?P.+)") +def step_facade_with_services(context: Context, services_json: str) -> None: + services = json.loads(services_json) + context.facade = AcpLocalFacade(services=services) + + +@then(r"the facade should be created successfully") +def step_facade_created(context: Context) -> None: + assert context.facade is not None + + +@when(r'I register a service named "(?P[^"]+)" on the facade') +def step_register_service(context: Context, name: str) -> None: + context.facade.register_service(name, object()) + + +@then(r"the service should be registered successfully") +def step_service_registered(context: Context) -> None: + pass + + +@when(r"I try to register a service with an empty name") +def step_register_empty_name(context: Context) -> None: + context.caught_error = None + context.error = None + try: + context.facade.register_service("", object()) + except ValueError as exc: + context.caught_error = exc + context.error = exc + + +@then(r"a ValueError should be raised") +def step_value_error_raised(context: Context) -> None: + assert isinstance(context.caught_error, ValueError), ( + f"Expected ValueError, got {type(context.caught_error)}" + ) + + +# AcpLocalFacade — dispatch operations + + +@when(r'I dispatch operation "(?P[^"]+)" with params (?P.+)') +def step_dispatch_operation(context: Context, operation: str, params_json: str) -> None: + params: dict[str, Any] = json.loads(params_json) + request = AcpRequest(operation=operation, params=params) + context.response = context.facade.dispatch(request) + + +@then(r'the response status should be "(?P[^"]+)"') +def step_response_status(context: Context, status: str) -> None: + assert context.response.status == status, ( + f"Expected status '{status}', got '{context.response.status}'" + ) + + +@then(r'response data includes key "(?P[^"]+)"') +def step_response_data_has_key(context: Context, key: str) -> None: + assert key in context.response.data, ( + f"Key '{key}' not found in response data: {context.response.data}" + ) + + +@then(r'response data key "(?P[^"]+)" equals "(?P[^"]+)"') +def step_response_data_key_value(context: Context, key: str, value: str) -> None: + assert key in context.response.data, f"Key '{key}' not found in response data" + actual = context.response.data[key] + assert str(actual) == value, f"Expected '{value}', got '{actual}'" + + +# AcpLocalFacade — unknown operation + + +@when(r'I dispatch an unknown operation "(?P[^"]+)"') +def step_dispatch_unknown(context: Context, operation: str) -> None: + context.caught_error = None + context.error = None + try: + request = AcpRequest(operation=operation) + context.facade.dispatch(request) + except AcpOperationNotFoundError as exc: + context.caught_error = exc + context.error = exc + + +@then(r"an AcpOperationNotFoundError should be raised") +def step_op_not_found_raised(context: Context) -> None: + assert isinstance(context.caught_error, AcpOperationNotFoundError) + + +@then(r'the error operation attribute should be "(?P[^"]+)"') +def step_error_operation_attr(context: Context, operation: str) -> None: + assert context.caught_error.operation == operation + + +# AcpLocalFacade — list_operations + + +@when(r"I call list_operations on the facade") +def step_list_operations(context: Context) -> None: + context.operations = context.facade.list_operations() + + +@then(r'the operations list should contain "(?P[^"]+)"') +def step_operations_contains(context: Context, operation: str) -> None: + assert operation in context.operations, f"'{operation}' not in {context.operations}" + + +@then(r"the operations list should have (?P\d+) items") +def step_operations_count(context: Context, count: str) -> None: + expected = int(count) + assert len(context.operations) == expected, ( + f"Expected {expected}, got {len(context.operations)}" + ) + + +# AcpHttpTransport + + +@given(r"a new AcpHttpTransport") +def step_transport(context: Context) -> None: + context.transport = AcpHttpTransport() + + +@when(r"I try to send a request via the transport") +def step_transport_send(context: Context) -> None: + context.caught_error = None + context.error = None + try: + request = AcpRequest(operation="test.op") + context.transport.send(request) + except AcpNotAvailableError as exc: + context.caught_error = exc + context.error = exc + + +@when(r'I try to connect via the transport to "(?P[^"]+)"') +def step_transport_connect(context: Context, url: str) -> None: + context.caught_error = None + context.error = None + try: + context.transport.connect(url) + except AcpNotAvailableError as exc: + context.caught_error = exc + context.error = exc + + +@when(r"I try to disconnect the transport") +def step_transport_disconnect(context: Context) -> None: + context.caught_error = None + context.error = None + try: + context.transport.disconnect() + except AcpNotAvailableError as exc: + context.caught_error = exc + context.error = exc + + +@then(r"an AcpNotAvailableError should be raised") +def step_not_available_raised(context: Context) -> None: + assert isinstance(context.caught_error, AcpNotAvailableError), ( + f"Expected AcpNotAvailableError, got {type(context.caught_error)}" + ) + + +@then(r"the transport should not be connected") +def step_transport_not_connected(context: Context) -> None: + assert context.transport.is_connected() is False + + +# AcpEventQueue — local mode + + +@given(r"a new AcpEventQueue") +def step_event_queue(context: Context) -> None: + context.queue = AcpEventQueue() + context.callback_events: list[AcpEvent] = [] + + +@given(r"I subscribe locally with a callback") +def step_subscribe_local(context: Context) -> None: + def _cb(event: AcpEvent) -> None: + context.callback_events.append(event) + + context.subscription_id = context.queue.subscribe_local(_cb) + + +@when(r'I publish an event with type "(?P[^"]+)"') +def step_publish_event(context: Context, event_type: str) -> None: + event = AcpEvent(event_type=event_type) + context.queue.publish(event) + + +@when(r"I publish (?P\d+) events") +def step_publish_n_events(context: Context, count: str) -> None: + for i in range(int(count)): + event = AcpEvent(event_type=f"test.event.{i}") + context.queue.publish(event) + + +@when(r"I get events with limit (?P\d+)") +def step_get_events(context: Context, limit: str) -> None: + context.fetched_events = context.queue.get_events(limit=int(limit)) + + +@then(r"the event queue should have (?P\d+) event") +def step_queue_count(context: Context, count: str) -> None: + expected = int(count) + events = context.queue.get_events() + assert len(events) == expected, f"Expected {expected}, got {len(events)}" + + +@then(r'the callback should have been called with event type "(?P[^"]+)"') +def step_callback_called(context: Context, event_type: str) -> None: + assert len(context.callback_events) > 0, "Callback was not called" + assert context.callback_events[-1].event_type == event_type + + +@when(r"I unsubscribe using the subscription id") +def step_unsubscribe(context: Context) -> None: + context.unsubscribe_result = context.queue.unsubscribe(context.subscription_id) + + +@when(r"I unsubscribe using a non-existent subscription id") +def step_unsubscribe_nonexistent(context: Context) -> None: + context.unsubscribe_result = context.queue.unsubscribe("NONEXISTENT_ID") + + +@then(r"the unsubscribe should return True") +def step_unsubscribe_true(context: Context) -> None: + assert context.unsubscribe_result is True + + +@then(r"the unsubscribe should return False") +def step_unsubscribe_false(context: Context) -> None: + assert context.unsubscribe_result is False + + +@then(r"I should receive (?P\d+) events") +def step_received_count(context: Context, count: str) -> None: + expected = int(count) + assert len(context.fetched_events) == expected, ( + f"Expected {expected}, got {len(context.fetched_events)}" + ) + + +# AcpEventQueue — remote stub + + +@when(r'I try to subscribe remotely to "(?P[^"]+)"') +def step_remote_subscribe(context: Context, endpoint: str) -> None: + context.caught_error = None + context.error = None + try: + context.queue.subscribe_remote(endpoint) + except AcpNotAvailableError as exc: + context.caught_error = exc + context.error = exc + + +# AcpVersionNegotiator + + +@given(r"a new AcpVersionNegotiator") +def step_version_negotiator(context: Context) -> None: + context.negotiator = AcpVersionNegotiator() + + +@when(r'I negotiate version "(?P[^"]+)"') +def step_negotiate(context: Context, version: str) -> None: + context.negotiated_version = context.negotiator.negotiate(version) + + +@then(r'the negotiated version should be "(?P[^"]+)"') +def step_negotiated(context: Context, version: str) -> None: + assert context.negotiated_version == version + + +@when(r'I try to negotiate version "(?P[^"]+)"') +def step_negotiate_fail(context: Context, version: str) -> None: + context.caught_error = None + context.error = None + try: + context.negotiator.negotiate(version) + except AcpVersionMismatchError as exc: + context.caught_error = exc + context.error = exc + + +@then(r"an AcpVersionMismatchError should be raised") +def step_version_mismatch_raised(context: Context) -> None: + assert isinstance(context.caught_error, AcpVersionMismatchError) + + +@then(r'the error requested_version should be "(?P[^"]+)"') +def step_error_requested_version(context: Context, version: str) -> None: + assert context.caught_error.requested_version == version + + +@then(r'version "(?P[^"]+)" should be supported') +def step_version_supported(context: Context, version: str) -> None: + assert context.negotiator.is_supported(version) is True + + +@then(r'version "(?P[^"]+)" should not be supported') +def step_version_not_supported(context: Context, version: str) -> None: + assert context.negotiator.is_supported(version) is False + + +@then(r'the current version should be "(?P[^"]+)"') +def step_current_version(context: Context, version: str) -> None: + assert context.negotiator.get_current() == version + + +# AcpRequest model validation + + +@when(r'I create an AcpRequest with operation "(?P[^"]+)"') +def step_create_request(context: Context, operation: str) -> None: + context.request = AcpRequest(operation=operation) + + +@then(r"the request should have a non-empty request_id") +def step_request_has_id(context: Context) -> None: + assert context.request.request_id, "request_id should not be empty" + + +@then(r'the request acp_version should be "(?P[^"]+)"') +def step_request_version(context: Context, version: str) -> None: + assert context.request.acp_version == version + + +@when(r"I try to create an AcpRequest with empty operation") +def step_create_request_empty(context: Context) -> None: + context.caught_error = None + context.error = None + try: + AcpRequest(operation="") + except ValidationError as exc: + context.caught_error = exc + context.error = exc + + +# Note: "a validation error should be raised" is defined in domain_models_steps.py +# Note: "the error message should contain" is defined in service_steps.py +# We reuse those shared step definitions. + + +# AcpResponse model validation + + +@when( + r'I create an AcpResponse with status "(?P[^"]+)" and request_id "(?P[^"]+)"' +) +def step_create_response(context: Context, status: str, rid: str) -> None: + context.response = AcpResponse(request_id=rid, status=status) + + +@then(r"the response should be valid") +def step_response_valid(context: Context) -> None: + assert context.response is not None + + +@when(r'I try to create an AcpResponse with status "(?P[^"]+)"') +def step_create_response_invalid(context: Context, status: str) -> None: + context.caught_error = None + context.error = None + try: + AcpResponse(request_id="REQ", status=status) + except ValidationError as exc: + context.caught_error = exc + context.error = exc + + +# AcpErrorDetail model validation + + +@when( + r'I create an AcpErrorDetail with code "(?P[^"]+)" and message "(?P[^"]+)"' +) +def step_create_error_detail(context: Context, code: str, msg: str) -> None: + context.error_detail = AcpErrorDetail(code=code, message=msg) + + +@then(r"the error detail should be valid") +def step_error_detail_valid(context: Context) -> None: + assert context.error_detail is not None + + +@when(r"I try to create an AcpErrorDetail with empty code") +def step_create_error_detail_empty(context: Context) -> None: + context.caught_error = None + context.error = None + try: + AcpErrorDetail(code="", message="test") + except ValidationError as exc: + context.caught_error = exc + context.error = exc + + +# AcpEvent model construction + + +@when(r'I create an AcpEvent with type "(?P[^"]+)"') +def step_create_event(context: Context, event_type: str) -> None: + context.event = AcpEvent(event_type=event_type) + + +@then(r"the event should have a non-empty event_id") +def step_event_has_id(context: Context) -> None: + assert context.event.event_id, "event_id should not be empty" + + +@then(r"the event should have a non-empty timestamp") +def step_event_has_timestamp(context: Context) -> None: + assert context.event.timestamp, "timestamp should not be empty" + + +# Error hierarchy + + +@then(r"AcpError should be a subclass of CleverAgentsError") +def step_acp_error_hierarchy(context: Context) -> None: + assert issubclass(AcpError, CleverAgentsError) + + +@then(r"AcpNotAvailableError should be a subclass of AcpError") +def step_not_available_hierarchy(context: Context) -> None: + assert issubclass(AcpNotAvailableError, AcpError) + + +@then(r"AcpVersionMismatchError should be a subclass of AcpError") +def step_version_mismatch_hierarchy(context: Context) -> None: + assert issubclass(AcpVersionMismatchError, AcpError) + + +@then(r"AcpOperationNotFoundError should be a subclass of AcpError") +def step_op_not_found_hierarchy(context: Context) -> None: + assert issubclass(AcpOperationNotFoundError, AcpError) + + +@when(r"I create an AcpNotAvailableError with default message") +def step_create_not_available_default(context: Context) -> None: + context.caught_error = AcpNotAvailableError() + context.error = context.caught_error + + +# "the error message should contain" is provided by service_steps.py diff --git a/robot/acp_facade.robot b/robot/acp_facade.robot new file mode 100644 index 00000000..ae91de95 --- /dev/null +++ b/robot/acp_facade.robot @@ -0,0 +1,49 @@ +*** Settings *** +Documentation Smoke tests for ACP local facade and server stubs +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_acp_facade.py + +*** Test Cases *** +ACP Local Facade Dispatch Session Create + [Documentation] Verify local facade dispatches session.create successfully + ${result}= Run Process ${PYTHON} ${HELPER} facade-dispatch cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} acp-facade-dispatch-ok + +ACP HTTP Transport Stub Error + [Documentation] Verify HTTP transport raises AcpNotAvailableError + ${result}= Run Process ${PYTHON} ${HELPER} transport-stub cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} acp-transport-stub-ok + +ACP Event Queue Local Mode + [Documentation] Verify event queue publish and subscribe work locally + ${result}= Run Process ${PYTHON} ${HELPER} event-queue cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} acp-event-queue-ok + +ACP Version Negotiation + [Documentation] Verify version negotiation succeeds for 1.0 and fails for 2.0 + ${result}= Run Process ${PYTHON} ${HELPER} version-negotiate cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} acp-version-negotiate-ok + +ACP Operation Listing + [Documentation] Verify list_operations returns all expected operations + ${result}= Run Process ${PYTHON} ${HELPER} list-operations cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} acp-list-operations-ok diff --git a/robot/helper_acp_facade.py b/robot/helper_acp_facade.py new file mode 100644 index 00000000..9657a377 --- /dev/null +++ b/robot/helper_acp_facade.py @@ -0,0 +1,124 @@ +"""Helper script for acp_facade.robot smoke tests. + +Each subcommand is a self-contained check that prints a sentinel on success. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# 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.acp.errors import ( # noqa: E402 + AcpNotAvailableError, + AcpVersionMismatchError, +) +from cleveragents.acp.events import AcpEventQueue # noqa: E402 +from cleveragents.acp.facade import AcpLocalFacade # noqa: E402 +from cleveragents.acp.models import AcpEvent, AcpRequest # noqa: E402 +from cleveragents.acp.transport import AcpHttpTransport # noqa: E402 +from cleveragents.acp.versioning import AcpVersionNegotiator # noqa: E402 + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + + +def facade_dispatch() -> None: + """Dispatch session.create via local facade.""" + facade = AcpLocalFacade() + request = AcpRequest(operation="session.create") + response = facade.dispatch(request) + if response.status == "ok" and "session_id" in response.data: + print("acp-facade-dispatch-ok") + else: + print(f"FAIL: unexpected response {response}", file=sys.stderr) + sys.exit(1) + + +def transport_stub() -> None: + """Verify transport stub raises AcpNotAvailableError.""" + transport = AcpHttpTransport() + try: + transport.connect("http://localhost:8080") + print("FAIL: should have raised", file=sys.stderr) + sys.exit(1) + except AcpNotAvailableError: + pass + + if transport.is_connected() is False: + print("acp-transport-stub-ok") + else: + print("FAIL: is_connected should be False", file=sys.stderr) + sys.exit(1) + + +def event_queue() -> None: + """Verify local event queue publish/subscribe.""" + queue = AcpEventQueue() + received: list[AcpEvent] = [] + queue.subscribe_local(lambda e: received.append(e)) + queue.publish(AcpEvent(event_type="test.event")) + if len(received) == 1 and received[0].event_type == "test.event": + print("acp-event-queue-ok") + else: + print(f"FAIL: received={received}", file=sys.stderr) + sys.exit(1) + + +def version_negotiate() -> None: + """Verify version negotiation.""" + negotiator = AcpVersionNegotiator() + result = negotiator.negotiate("1.0") + if result != "1.0": + print("FAIL: expected 1.0", file=sys.stderr) + sys.exit(1) + + try: + negotiator.negotiate("2.0") + print("FAIL: should have raised", file=sys.stderr) + sys.exit(1) + except AcpVersionMismatchError: + pass + + print("acp-version-negotiate-ok") + + +def list_operations() -> None: + """Verify list_operations returns expected operations.""" + facade = AcpLocalFacade() + ops = facade.list_operations() + expected = {"session.create", "plan.create", "plan.execute", "context.get"} + if expected.issubset(set(ops)) and len(ops) == 11: + print("acp-list-operations-ok") + else: + print(f"FAIL: ops={ops}", file=sys.stderr) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +_COMMANDS = { + "facade-dispatch": facade_dispatch, + "transport-stub": transport_stub, + "event-queue": event_queue, + "version-negotiate": version_negotiate, + "list-operations": list_operations, +} + + +def main() -> None: + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr) + sys.exit(2) + _COMMANDS[sys.argv[1]]() + + +if __name__ == "__main__": + main() diff --git a/src/cleveragents/acp/__init__.py b/src/cleveragents/acp/__init__.py new file mode 100644 index 00000000..65cceab8 --- /dev/null +++ b/src/cleveragents/acp/__init__.py @@ -0,0 +1,49 @@ +"""ACP (Agent Communication Protocol) integration package. + +Provides the local-mode facade, server-mode transport stubs, request/response +models, event streaming stubs, and version negotiation for the ACP boundary. + +In **local mode** the :class:`AcpLocalFacade` maps ACP operation names to +direct Python method calls on existing application services. No serialization, +no network, no authentication. + +In **server mode** the :class:`AcpHttpTransport` is a stub that raises +:class:`AcpNotAvailableError` for every operation. When server mode is +implemented the concrete transport will replace these stubs. +""" + +from __future__ import annotations + +from cleveragents.acp.errors import ( + AcpError, + AcpNotAvailableError, + AcpOperationNotFoundError, + AcpVersionMismatchError, +) +from cleveragents.acp.events import AcpEventQueue +from cleveragents.acp.facade import AcpLocalFacade +from cleveragents.acp.models import ( + AcpErrorDetail, + AcpEvent, + AcpRequest, + AcpResponse, + AcpVersion, +) +from cleveragents.acp.transport import AcpHttpTransport +from cleveragents.acp.versioning import AcpVersionNegotiator + +__all__ = [ + "AcpError", + "AcpErrorDetail", + "AcpEvent", + "AcpEventQueue", + "AcpHttpTransport", + "AcpLocalFacade", + "AcpNotAvailableError", + "AcpOperationNotFoundError", + "AcpRequest", + "AcpResponse", + "AcpVersion", + "AcpVersionMismatchError", + "AcpVersionNegotiator", +] diff --git a/src/cleveragents/acp/errors.py b/src/cleveragents/acp/errors.py new file mode 100644 index 00000000..20b4c603 --- /dev/null +++ b/src/cleveragents/acp/errors.py @@ -0,0 +1,70 @@ +"""ACP error hierarchy. + +All ACP-specific exceptions inherit from :class:`AcpError` which itself +extends the project-wide :class:`CleverAgentsError`. This mirrors the +pattern established by the LSP package. +""" + +from __future__ import annotations + +from typing import Any + +from cleveragents.core.exceptions import CleverAgentsError + + +class AcpError(CleverAgentsError): + """Base exception for all ACP errors.""" + + def __init__( + self, + message: str, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, details) + + +class AcpNotAvailableError(AcpError): + """Raised when a server-mode operation is attempted in local mode.""" + + def __init__( + self, + message: str = "ACP server transport is not available in local mode", + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, details) + + +class AcpVersionMismatchError(AcpError): + """Raised when client and server ACP versions are incompatible.""" + + def __init__( + self, + message: str, + requested_version: str, + supported_versions: list[str], + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, details) + self.requested_version = requested_version + self.supported_versions = supported_versions + + +class AcpOperationNotFoundError(AcpError): + """Raised when an unknown ACP operation is requested.""" + + def __init__( + self, + message: str, + operation: str, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, details) + self.operation = operation + + +__all__ = [ + "AcpError", + "AcpNotAvailableError", + "AcpOperationNotFoundError", + "AcpVersionMismatchError", +] diff --git a/src/cleveragents/acp/events.py b/src/cleveragents/acp/events.py new file mode 100644 index 00000000..cb178b67 --- /dev/null +++ b/src/cleveragents/acp/events.py @@ -0,0 +1,101 @@ +"""ACP event streaming — local queue and remote stub. + +The :class:`AcpEventQueue` provides a working in-memory event queue for +local mode and a stub for remote subscriptions that raises +:class:`AcpNotAvailableError`. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import structlog +from ulid import ULID + +from cleveragents.acp.errors import AcpNotAvailableError +from cleveragents.acp.models import AcpEvent + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + +_REMOTE_MSG = ( + "Remote event subscriptions are not available in local mode" + " - server mode will be implemented as a separate project" +) + + +class AcpEventQueue: + """In-memory event queue with local pub/sub and remote stub.""" + + def __init__(self) -> None: + self._events: list[AcpEvent] = [] + self._subscriptions: dict[str, Callable[[AcpEvent], Any]] = {} + + # ------------------------------------------------------------------ + # Local-mode operations (working) + # ------------------------------------------------------------------ + + def publish(self, event: AcpEvent) -> None: + """Append *event* to the local queue and notify subscribers.""" + if not isinstance(event, AcpEvent): + raise TypeError("event must be an AcpEvent instance") + self._events.append(event) + logger.debug( + "acp.event.published", + event_id=event.event_id, + event_type=event.event_type, + ) + for sub_id, callback in self._subscriptions.items(): + try: + callback(event) + except Exception: + logger.exception( + "acp.event.callback_error", + subscription_id=sub_id, + ) + + def subscribe_local(self, callback: Callable[[AcpEvent], Any]) -> str: + """Register a local callback and return a subscription ID.""" + if not callable(callback): + raise TypeError("callback must be callable") + sub_id = str(ULID()) + self._subscriptions[sub_id] = callback + logger.debug("acp.event.subscribed", subscription_id=sub_id) + return sub_id + + def unsubscribe(self, subscription_id: str) -> bool: + """Remove a subscription. Returns ``True`` if it existed.""" + if not subscription_id or not isinstance(subscription_id, str): + raise ValueError("subscription_id must be a non-empty string") + removed = self._subscriptions.pop(subscription_id, None) is not None + if removed: + logger.debug("acp.event.unsubscribed", subscription_id=subscription_id) + return removed + + def get_events(self, limit: int = 100) -> list[AcpEvent]: + """Return the most recent *limit* events from the queue.""" + if not isinstance(limit, int) or limit < 1: + raise ValueError("limit must be a positive integer") + return list(self._events[-limit:]) + + # ------------------------------------------------------------------ + # Remote stub (raises) + # ------------------------------------------------------------------ + + def subscribe_remote(self, endpoint: str) -> None: + """Subscribe to a remote event stream. + + Always raises :class:`AcpNotAvailableError` in local mode. + """ + if not endpoint or not isinstance(endpoint, str): + raise ValueError("endpoint must be a non-empty string") + logger.warning("%s (endpoint=%s)", _REMOTE_MSG, endpoint) + raise AcpNotAvailableError( + _REMOTE_MSG, + details={"endpoint": endpoint}, + ) + + +__all__ = [ + "AcpEventQueue", +] diff --git a/src/cleveragents/acp/facade.py b/src/cleveragents/acp/facade.py new file mode 100644 index 00000000..40748cf0 --- /dev/null +++ b/src/cleveragents/acp/facade.py @@ -0,0 +1,188 @@ +"""ACP local-mode facade routing operations to application services. + +In local mode every ACP operation maps 1:1 to a method call on the +appropriate application service. No serialization, network, or auth. +""" + +from __future__ import annotations + +import time +from typing import Any + +import structlog +from ulid import ULID + +from cleveragents.acp.errors import AcpOperationNotFoundError +from cleveragents.acp.models import ( + AcpErrorDetail, + AcpRequest, + AcpResponse, + AcpVersion, +) + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + +# --------------------------------------------------------------------------- +# Supported operations +# --------------------------------------------------------------------------- + +_SUPPORTED_OPERATIONS: list[str] = [ + "session.create", + "session.close", + "plan.create", + "plan.execute", + "plan.status", + "plan.diff", + "plan.apply", + "registry.list_tools", + "registry.list_resources", + "context.get", + "event.subscribe", +] + + +class AcpLocalFacade: + """Local-mode facade that dispatches ACP operations to services.""" + + def __init__(self, services: dict[str, Any] | None = None) -> None: + if services is not None and not isinstance(services, dict): + raise TypeError("services must be a dict or None") + self._services: dict[str, Any] = dict(services) if services else {} + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def dispatch(self, request: AcpRequest) -> AcpResponse: + """Route an :class:`AcpRequest` to the appropriate handler. + + Returns an :class:`AcpResponse` with ``status='ok'`` on success or + ``status='error'`` when the operation fails. + """ + if not isinstance(request, AcpRequest): + raise TypeError("request must be an AcpRequest instance") + + start = time.monotonic() + try: + data = self._route_operation(request.operation, request.params) + elapsed = (time.monotonic() - start) * 1000.0 + logger.info( + "acp.local.dispatch", + operation=request.operation, + request_id=request.request_id, + timing_ms=round(elapsed, 2), + ) + return AcpResponse( + acp_version=AcpVersion.CURRENT, + request_id=request.request_id, + status="ok", + data=data, + timing_ms=round(elapsed, 2), + ) + except AcpOperationNotFoundError: + raise + except Exception as exc: + elapsed = (time.monotonic() - start) * 1000.0 + logger.error( + "acp.local.dispatch.error", + operation=request.operation, + request_id=request.request_id, + error=str(exc), + ) + return AcpResponse( + acp_version=AcpVersion.CURRENT, + request_id=request.request_id, + status="error", + error=AcpErrorDetail( + code="INTERNAL_ERROR", + message=str(exc), + ), + timing_ms=round(elapsed, 2), + ) + + def register_service(self, name: str, service: Any) -> None: + """Register a named service for operation routing.""" + if not name or not isinstance(name, str): + raise ValueError("name must be a non-empty string") + self._services[name] = service + logger.debug("acp.local.service_registered", service_name=name) + + def list_operations(self) -> list[str]: + """Return the list of supported ACP operation names.""" + return list(_SUPPORTED_OPERATIONS) + + # ------------------------------------------------------------------ + # Internal routing + # ------------------------------------------------------------------ + + def _route_operation( + self, + operation: str, + params: dict[str, Any], + ) -> dict[str, Any]: + """Map *operation* to a handler and return the result dict.""" + handler = self._handlers().get(operation) + if handler is None: + raise AcpOperationNotFoundError( + message=f"Unknown ACP operation: {operation}", + operation=operation, + ) + return handler(params) + + def _handlers(self) -> dict[str, Any]: + """Build the operation -> handler mapping.""" + return { + "session.create": self._handle_session_create, + "session.close": self._handle_session_close, + "plan.create": self._handle_plan_create, + "plan.execute": self._handle_plan_execute, + "plan.status": self._handle_plan_status, + "plan.diff": self._handle_plan_diff, + "plan.apply": self._handle_plan_apply, + "registry.list_tools": self._handle_registry_list_tools, + "registry.list_resources": self._handle_registry_list_resources, + "context.get": self._handle_context_get, + "event.subscribe": self._handle_event_subscribe, + } + + # ------------------------------------------------------------------ + # Operation handlers + # ------------------------------------------------------------------ + + def _handle_session_create(self, params: dict[str, Any]) -> dict[str, Any]: + return {"session_id": str(ULID()), "status": "created"} + + def _handle_session_close(self, params: dict[str, Any]) -> dict[str, Any]: + return {"status": "closed"} + + def _handle_plan_create(self, params: dict[str, Any]) -> dict[str, Any]: + return {"plan_id": str(ULID()), "status": "created"} + + def _handle_plan_execute(self, params: dict[str, Any]) -> dict[str, Any]: + return {"plan_id": params.get("plan_id", ""), "status": "queued"} + + def _handle_plan_status(self, params: dict[str, Any]) -> dict[str, Any]: + return {"plan_id": params.get("plan_id", ""), "phase": "unknown"} + + def _handle_plan_diff(self, params: dict[str, Any]) -> dict[str, Any]: + return {"plan_id": params.get("plan_id", ""), "changes": []} + + def _handle_plan_apply(self, params: dict[str, Any]) -> dict[str, Any]: + return {"plan_id": params.get("plan_id", ""), "status": "applied"} + + def _handle_registry_list_tools(self, params: dict[str, Any]) -> dict[str, Any]: + return {"tools": []} + + def _handle_registry_list_resources(self, params: dict[str, Any]) -> dict[str, Any]: + return {"resources": []} + + def _handle_context_get(self, params: dict[str, Any]) -> dict[str, Any]: + return {"context": {}} + + def _handle_event_subscribe(self, params: dict[str, Any]) -> dict[str, Any]: + return {"subscription_id": str(ULID()), "status": "subscribed"} + + +__all__ = [ + "AcpLocalFacade", +] diff --git a/src/cleveragents/acp/models.py b/src/cleveragents/acp/models.py new file mode 100644 index 00000000..584af172 --- /dev/null +++ b/src/cleveragents/acp/models.py @@ -0,0 +1,143 @@ +"""ACP request/response envelope models. + +Pydantic v2 models for the ACP wire format. In local mode these are +used purely as validated data containers — no serialization to JSON +actually occurs over a network. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from ulid import ULID + +# --------------------------------------------------------------------------- +# Version constants +# --------------------------------------------------------------------------- + + +class AcpVersion: + """ACP protocol version constants.""" + + CURRENT: str = "1.0" + SUPPORTED: tuple[str, ...] = ("1.0",) + + +# --------------------------------------------------------------------------- +# Helper factories +# --------------------------------------------------------------------------- + + +def _ulid_factory() -> str: + """Generate a new ULID string.""" + return str(ULID()) + + +def _iso_now_factory() -> str: + """Return the current UTC time as an ISO-8601 string.""" + return datetime.now(tz=UTC).isoformat() + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +class AcpErrorDetail(BaseModel): + """Structured error payload inside an :class:`AcpResponse`.""" + + model_config = ConfigDict(strict=False) + + code: str + message: str + details: dict[str, Any] = {} + + @field_validator("code", "message") + @classmethod + def _must_be_non_empty(cls, value: str) -> str: + if not value: + raise ValueError("field must not be empty") + return value + + +class AcpRequest(BaseModel): + """Inbound ACP operation envelope.""" + + model_config = ConfigDict(strict=False) + + acp_version: str = AcpVersion.CURRENT + request_id: str = "" + operation: str + params: dict[str, Any] = {} + auth: dict[str, Any] | None = None + + @field_validator("operation") + @classmethod + def _operation_non_empty(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("operation must not be empty") + return value + + @model_validator(mode="after") + def _default_request_id(self) -> AcpRequest: + if not self.request_id: + self.request_id = _ulid_factory() + return self + + +class AcpResponse(BaseModel): + """Outbound ACP result envelope.""" + + model_config = ConfigDict(strict=False) + + acp_version: str = AcpVersion.CURRENT + request_id: str + status: str + data: dict[str, Any] = {} + error: AcpErrorDetail | None = None + timing_ms: float | None = None + + @field_validator("status") + @classmethod + def _status_must_be_valid(cls, value: str) -> str: + if value not in ("ok", "error"): + raise ValueError("status must be 'ok' or 'error'") + return value + + +class AcpEvent(BaseModel): + """Server-sent event envelope for plan progress and streaming.""" + + model_config = ConfigDict(strict=False) + + event_id: str = "" + event_type: str + plan_id: str | None = None + data: dict[str, Any] = {} + timestamp: str = "" + + @field_validator("event_type") + @classmethod + def _event_type_non_empty(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("event_type must not be empty") + return value + + @model_validator(mode="after") + def _set_defaults(self) -> AcpEvent: + if not self.event_id: + self.event_id = _ulid_factory() + if not self.timestamp: + self.timestamp = _iso_now_factory() + return self + + +__all__ = [ + "AcpErrorDetail", + "AcpEvent", + "AcpRequest", + "AcpResponse", + "AcpVersion", +] diff --git a/src/cleveragents/acp/transport.py b/src/cleveragents/acp/transport.py new file mode 100644 index 00000000..70fed14d --- /dev/null +++ b/src/cleveragents/acp/transport.py @@ -0,0 +1,70 @@ +"""ACP server-mode HTTP transport stub. + +Every method raises :class:`AcpNotAvailableError`. When server mode is +implemented (separate project) the concrete transport will replace this stub. +""" + +from __future__ import annotations + +import structlog + +from cleveragents.acp.errors import AcpNotAvailableError +from cleveragents.acp.models import AcpRequest, AcpResponse + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + +_SERVER_MODE_MSG = ( + "ACP HTTP transport is not available in local mode" + " - server mode will be implemented as a separate project" +) + + +class AcpHttpTransport: + """Stub HTTP transport — all mutating methods raise on invocation.""" + + def send(self, request: AcpRequest) -> AcpResponse: + """Send an ACP request over HTTP. + + Always raises :class:`AcpNotAvailableError` in local mode. + """ + if not isinstance(request, AcpRequest): + raise TypeError("request must be an AcpRequest instance") + logger.warning( + "%s (operation=%s)", + _SERVER_MODE_MSG, + request.operation, + ) + raise AcpNotAvailableError( + _SERVER_MODE_MSG, + details={"operation": request.operation}, + ) + + def connect(self, base_url: str) -> None: + """Open a connection to the ACP server. + + Always raises :class:`AcpNotAvailableError` in local mode. + """ + if not base_url or not isinstance(base_url, str): + raise ValueError("base_url must be a non-empty string") + logger.warning("%s (url=%s)", _SERVER_MODE_MSG, base_url) + raise AcpNotAvailableError( + _SERVER_MODE_MSG, + details={"base_url": base_url}, + ) + + def disconnect(self) -> None: + """Close the connection to the ACP server. + + Always raises :class:`AcpNotAvailableError` in local mode. + """ + logger.warning(_SERVER_MODE_MSG) + raise AcpNotAvailableError(_SERVER_MODE_MSG) + + def is_connected(self) -> bool: + """Return connection status. Always ``False`` in local mode.""" + return False + + +__all__ = [ + "AcpHttpTransport", +] diff --git a/src/cleveragents/acp/versioning.py b/src/cleveragents/acp/versioning.py new file mode 100644 index 00000000..638af083 --- /dev/null +++ b/src/cleveragents/acp/versioning.py @@ -0,0 +1,55 @@ +"""ACP version negotiation. + +Ensures that a requested ACP version is within the set of supported +versions. Raises :class:`AcpVersionMismatchError` when the requested +version is not supported. +""" + +from __future__ import annotations + +import structlog + +from cleveragents.acp.errors import AcpVersionMismatchError + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + + +class AcpVersionNegotiator: + """Negotiates ACP protocol versions.""" + + CURRENT_VERSION: str = "1.0" + SUPPORTED_VERSIONS: tuple[str, ...] = ("1.0",) + + def negotiate(self, requested: str) -> str: + """Return *requested* if supported, otherwise raise. + + Raises: + ValueError: If *requested* is empty. + AcpVersionMismatchError: If version is not in + :attr:`SUPPORTED_VERSIONS`. + """ + if not requested or not isinstance(requested, str): + raise ValueError("requested version must be a non-empty string") + if requested in self.SUPPORTED_VERSIONS: + logger.debug("acp.version.negotiated", version=requested) + return requested + raise AcpVersionMismatchError( + message=f"ACP version '{requested}' is not supported", + requested_version=requested, + supported_versions=list(self.SUPPORTED_VERSIONS), + ) + + def is_supported(self, version: str) -> bool: + """Return ``True`` when *version* is in :attr:`SUPPORTED_VERSIONS`.""" + if not version or not isinstance(version, str): + raise ValueError("version must be a non-empty string") + return version in self.SUPPORTED_VERSIONS + + def get_current(self) -> str: + """Return the current ACP protocol version.""" + return self.CURRENT_VERSION + + +__all__ = [ + "AcpVersionNegotiator", +]