forked from cleveragents/cleveragents-core
Merge remote-tracking branch 'origin/feature/m6-acp-stubs' into feature/jeff-combined-day14-batch2
This commit is contained in:
@@ -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)
|
||||
@@ -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 |
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user