Merge remote-tracking branch 'origin/feature/m6-acp-stubs' into feature/jeff-combined-day14-batch2

This commit is contained in:
2026-02-22 17:05:00 +00:00
13 changed files with 1932 additions and 0 deletions
+124
View File
@@ -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)
+185
View File
@@ -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 |
+286
View File
@@ -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"
+488
View File
@@ -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<services_json>.+)")
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<name>[^"]+)" 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<operation>[^"]+)" with params (?P<params_json>.+)')
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<status>[^"]+)"')
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<key>[^"]+)"')
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<key>[^"]+)" equals "(?P<value>[^"]+)"')
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<operation>[^"]+)"')
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<operation>[^"]+)"')
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<operation>[^"]+)"')
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<count>\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<url>[^"]+)"')
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<event_type>[^"]+)"')
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<count>\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<limit>\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<count>\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<event_type>[^"]+)"')
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<count>\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<endpoint>[^"]+)"')
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<version>[^"]+)"')
def step_negotiate(context: Context, version: str) -> None:
context.negotiated_version = context.negotiator.negotiate(version)
@then(r'the negotiated version should be "(?P<version>[^"]+)"')
def step_negotiated(context: Context, version: str) -> None:
assert context.negotiated_version == version
@when(r'I try to negotiate version "(?P<version>[^"]+)"')
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<version>[^"]+)"')
def step_error_requested_version(context: Context, version: str) -> None:
assert context.caught_error.requested_version == version
@then(r'version "(?P<version>[^"]+)" should be supported')
def step_version_supported(context: Context, version: str) -> None:
assert context.negotiator.is_supported(version) is True
@then(r'version "(?P<version>[^"]+)" 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<version>[^"]+)"')
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<operation>[^"]+)"')
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<version>[^"]+)"')
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<status>[^"]+)" and request_id "(?P<rid>[^"]+)"'
)
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<status>[^"]+)"')
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<code>[^"]+)" and message "(?P<msg>[^"]+)"'
)
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<event_type>[^"]+)"')
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
+49
View File
@@ -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
+124
View File
@@ -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()
+49
View File
@@ -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",
]
+70
View File
@@ -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",
]
+101
View File
@@ -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",
]
+188
View File
@@ -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",
]
+143
View File
@@ -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",
]
+70
View File
@@ -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",
]
+55
View File
@@ -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",
]