diff --git a/CHANGELOG.md b/CHANGELOG.md index 687be4f..a5cc27b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,3 +16,5 @@ for public contracts once they are declared stable. - PostgreSQL system of record with transactional audit writes, Alembic migrations, readiness checks, and database-level audit mutation protection. - Reproducible governed-refund demo, paid-pilot boundary, and customer-discovery criteria. +- Typed Python SDK with bearer authentication support and structured API, transport, and + response-contract errors. diff --git a/README.md b/README.md index f3d4ec8..f127dc6 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,52 @@ make migrate make run ``` +## Python SDK + +The package includes a typed synchronous client for the complete current governance workflow: + +```python +from agent_control_plane import ControlPlaneClient +from agent_control_plane.models import ( + AgentRegistrationRequest, + AgentRuntimeStatus, + AgentSpec, + AgentStatusUpdate, +) + +with ControlPlaneClient( + "http://127.0.0.1:8000", + bearer_token="replace-with-a-secret-token", # Omit unless the deployment requires it. +) as control_plane: + registered = control_plane.register_agent( + AgentRegistrationRequest( + spec=AgentSpec( + agent_id="support-agent", + version="1.0.0", + display_name="Support Agent", + description="Handles support cases with governed actions.", + entrypoint="https://agents.example.test/support", + ), + actor="operator@example.test", + ) + ) + active = control_plane.update_agent_status( + registered.spec.agent_id, + AgentStatusUpdate( + status=AgentRuntimeStatus.ACTIVE, + expected_revision=registered.revision, + actor="operator@example.test", + reason="Readiness checks passed.", + ), + ) +``` + +Methods return the same Pydantic models used by the versioned API contract. Structured API +failures raise `ControlPlaneAPIError`; connection failures and invalid server responses use +separate exception types. The client deliberately does not retry writes. Until server-side +idempotency is available, inspect current state after an ambiguous timeout before deciding +whether an operation is safe to send again. + ## Delivery policy Every change merged to `main` goes through a pull request, review, and required fast checks. @@ -94,8 +140,9 @@ PostgreSQL is available as the durable system of record. Agent changes, approval their audit events commit atomically; a database trigger rejects audit updates, deletion, and truncation. Readiness fails when the configured database is unavailable or not migrated. -The in-memory adapter remains available for development and evaluation only. Authenticated -actor identity, request idempotency, backup automation, and durable workflows remain planned. +The in-memory adapter remains available for development and evaluation only. A typed Python SDK +is available for integration. Authenticated actor identity, request idempotency, backup +automation, and durable workflows remain planned. ## License diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 754819b..545b38b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,6 +27,10 @@ with in-memory and PostgreSQL adapters. The storage protocol is owned by the con database types do not leak into the public API. PostgreSQL is the durable source of truth; vector databases remain derived indexes, not authoritative stores. +The Python SDK is a typed HTTP adapter over the same public contracts. It owns no governance +state and does not bypass API policy. It classifies API, transport, and invalid-response errors +without automatically retrying writes; retry safety remains a server-side idempotency concern. + State changes use an expected revision to reject stale writers. Only active agents can request approval. Approval requests are single-decision records: an approved or rejected request cannot be overwritten. State changes and their audit events share one database transaction. PostgreSQL diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ea93937..1e5ddc8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -7,6 +7,7 @@ Roadmap items advance only when tied to a validated user problem and an acceptan - [x] Versioned AgentSpec and event contracts. - [x] API health, readiness, and failure conventions. - [x] Pull request governance and automated quality gates. +- [x] Typed Python SDK for the current governance workflow. ## Reliability gateway diff --git a/pyproject.toml b/pyproject.toml index 1759859..11bbd0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ requires-python = ">=3.11" dependencies = [ "alembic>=1.16,<2", "fastapi>=0.116,<1", + "httpx>=0.28,<1", "psycopg[binary]>=3.2,<4", "pydantic>=2.11,<3", "sqlalchemy>=2.0,<3", @@ -20,7 +21,6 @@ dependencies = [ [project.optional-dependencies] dev = [ - "httpx>=0.28,<1", "mypy>=1.17,<2", "pip-audit>=2.9,<3", "pytest>=9.0.3,<10", diff --git a/src/agent_control_plane/__init__.py b/src/agent_control_plane/__init__.py index 2e3a142..9b6d24e 100644 --- a/src/agent_control_plane/__init__.py +++ b/src/agent_control_plane/__init__.py @@ -1,3 +1,20 @@ """Agent Control Plane package.""" +from agent_control_plane.client import ( + ControlPlaneAPIError, + ControlPlaneClient, + ControlPlaneClientError, + ControlPlaneResponseError, + ControlPlaneTransportError, +) + __version__ = "0.1.0" + +__all__ = [ + "ControlPlaneAPIError", + "ControlPlaneClient", + "ControlPlaneClientError", + "ControlPlaneResponseError", + "ControlPlaneTransportError", + "__version__", +] diff --git a/src/agent_control_plane/client.py b/src/agent_control_plane/client.py new file mode 100644 index 0000000..2f3ef1f --- /dev/null +++ b/src/agent_control_plane/client.py @@ -0,0 +1,264 @@ +"""Typed synchronous client for the Agent Control Plane HTTP API.""" + +from types import TracebackType +from typing import Any, Self, TypeVar, overload +from urllib.parse import quote +from uuid import UUID + +import httpx +from pydantic import BaseModel, TypeAdapter, ValidationError + +from agent_control_plane.models import ( + AgentRecord, + AgentRegistrationRequest, + AgentSpec, + AgentSpecValidationResponse, + AgentStatusUpdate, + ApprovalDecisionRequest, + ApprovalQueueResponse, + ApprovalRecord, + ApprovalRequestCreate, + ApprovalStatus, + AuditEventPage, + HealthResponse, +) + +ModelT = TypeVar("ModelT", bound=BaseModel) +ResponseT = TypeVar("ResponseT") + + +class ControlPlaneClientError(Exception): + """Base class for SDK failures.""" + + +class ControlPlaneTransportError(ControlPlaneClientError): + """Raised when the control plane cannot be reached.""" + + +class ControlPlaneResponseError(ControlPlaneClientError): + """Raised when a successful response violates the expected contract.""" + + +class ControlPlaneAPIError(ControlPlaneClientError): + """Structured error returned by the control-plane API.""" + + def __init__(self, status_code: int, code: str, message: str) -> None: + super().__init__(f"{status_code} {code}: {message}") + self.status_code = status_code + self.code = code + self.message = message + + +class ControlPlaneClient: + """Call current control-plane contracts without hand-building HTTP requests.""" + + def __init__( + self, + base_url: str = "http://127.0.0.1:8000", + *, + bearer_token: str | None = None, + timeout: float = 10.0, + http_client: httpx.Client | None = None, + ) -> None: + if http_client is not None and bearer_token is not None: + raise ValueError("bearer_token cannot be combined with an injected http_client") + if http_client is None: + headers = {"Authorization": f"Bearer {bearer_token}"} if bearer_token else None + self._http = httpx.Client( + base_url=base_url.rstrip("/"), + headers=headers, + timeout=timeout, + ) + self._owns_http_client = True + else: + self._http = http_client + self._owns_http_client = False + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + def close(self) -> None: + if self._owns_http_client: + self._http.close() + + def live(self) -> HealthResponse: + return self._request("GET", "/health/live", response_model=HealthResponse) + + def ready(self) -> HealthResponse: + return self._request("GET", "/health/ready", response_model=HealthResponse) + + def validate_agent_spec(self, spec: AgentSpec) -> AgentSpecValidationResponse: + return self._request( + "POST", + "/v1/agent-specs/validate", + request_model=spec, + response_model=AgentSpecValidationResponse, + ) + + def register_agent(self, request: AgentRegistrationRequest) -> AgentRecord: + return self._request( + "POST", + "/v1/agents", + request_model=request, + response_model=AgentRecord, + ) + + def list_agents(self) -> tuple[AgentRecord, ...]: + return self._request( + "GET", + "/v1/agents", + response_model=TypeAdapter(tuple[AgentRecord, ...]), + ) + + def get_agent(self, agent_id: str) -> AgentRecord: + return self._request( + "GET", + f"/v1/agents/{quote(agent_id, safe='')}", + response_model=AgentRecord, + ) + + def update_agent_status(self, agent_id: str, update: AgentStatusUpdate) -> AgentRecord: + return self._request( + "PATCH", + f"/v1/agents/{quote(agent_id, safe='')}/status", + request_model=update, + response_model=AgentRecord, + ) + + def create_approval(self, request: ApprovalRequestCreate) -> ApprovalRecord: + return self._request( + "POST", + "/v1/approvals", + request_model=request, + response_model=ApprovalRecord, + ) + + def get_approval(self, request_id: UUID) -> ApprovalRecord: + return self._request( + "GET", + f"/v1/approvals/{request_id}", + response_model=ApprovalRecord, + ) + + def list_approvals( + self, + *, + status: ApprovalStatus | None = None, + agent_id: str | None = None, + ) -> ApprovalQueueResponse: + params: dict[str, str | int] = { + key: value + for key, value in ( + ("status", status.value if status is not None else None), + ("agent_id", agent_id), + ) + if value is not None + } + return self._request( + "GET", + "/v1/approvals", + params=params, + response_model=ApprovalQueueResponse, + ) + + def decide_approval( + self, + request_id: UUID, + decision: ApprovalDecisionRequest, + ) -> ApprovalRecord: + return self._request( + "POST", + f"/v1/approvals/{request_id}/decision", + request_model=decision, + response_model=ApprovalRecord, + ) + + def list_audit_events( + self, + *, + agent_id: str | None = None, + limit: int = 100, + ) -> AuditEventPage: + params: dict[str, str | int] = {"limit": limit} + if agent_id is not None: + params["agent_id"] = agent_id + return self._request( + "GET", + "/v1/audit-events", + params=params, + response_model=AuditEventPage, + ) + + @overload + def _request( + self, + method: str, + path: str, + *, + request_model: BaseModel | None = None, + params: dict[str, str | int] | None = None, + response_model: type[ModelT], + ) -> ModelT: ... + + @overload + def _request( + self, + method: str, + path: str, + *, + request_model: BaseModel | None = None, + params: dict[str, str | int] | None = None, + response_model: TypeAdapter[ResponseT], + ) -> ResponseT: ... + + def _request( + self, + method: str, + path: str, + *, + request_model: BaseModel | None = None, + params: dict[str, str | int] | None = None, + response_model: type[BaseModel] | TypeAdapter[Any], + ) -> Any: + try: + response = self._http.request( + method, + path, + json=request_model.model_dump(mode="json") if request_model is not None else None, + params=params, + ) + except httpx.HTTPError as error: + raise ControlPlaneTransportError("control-plane request failed") from error + + if not response.is_success: + raise _api_error(response) + try: + payload: Any = response.json() + if isinstance(response_model, TypeAdapter): + return response_model.validate_python(payload) + return response_model.model_validate(payload) + except (ValueError, ValidationError) as error: + raise ControlPlaneResponseError( + f"control plane returned an invalid response for {method} {path}" + ) from error + + +def _api_error(response: httpx.Response) -> ControlPlaneAPIError: + try: + detail = response.json()["detail"] + code = detail["code"] + message = detail["message"] + if not isinstance(code, str) or not isinstance(message, str): + raise TypeError + except (KeyError, TypeError, ValueError): + code = "http_error" + message = f"control plane returned HTTP {response.status_code}" + return ControlPlaneAPIError(response.status_code, code, message) diff --git a/src/agent_control_plane/py.typed b/src/agent_control_plane/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/agent_control_plane/py.typed @@ -0,0 +1 @@ + diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 0000000..1e07225 --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,290 @@ +from datetime import UTC, datetime +from uuid import UUID + +import httpx +import pytest + +from agent_control_plane import ControlPlaneClient as PublicControlPlaneClient +from agent_control_plane.client import ( + ControlPlaneAPIError, + ControlPlaneClient, + ControlPlaneResponseError, + ControlPlaneTransportError, +) +from agent_control_plane.models import ( + AgentRegistrationRequest, + AgentRuntimeStatus, + AgentSpec, + AgentStatusUpdate, + ApprovalDecision, + ApprovalDecisionRequest, + ApprovalRequestCreate, + ApprovalStatus, + RiskLevel, +) + +NOW = datetime(2026, 8, 12, 10, 0, tzinfo=UTC).isoformat().replace("+00:00", "Z") +REQUEST_ID = UUID("00000000-0000-0000-0000-000000000001") + + +def spec() -> AgentSpec: + return AgentSpec( + agent_id="sdk-agent", + version="1.0.0", + display_name="SDK Agent", + description="Exercises the typed Python client.", + entrypoint="https://agents.example.test/sdk", + ) + + +def agent_payload(status: str = "registered", revision: int = 1) -> dict[str, object]: + return { + "spec": spec().model_dump(mode="json"), + "status": status, + "revision": revision, + "registered_at": NOW, + "updated_at": NOW, + } + + +def approval_payload(status: str = "pending") -> dict[str, object]: + payload: dict[str, object] = { + "request_id": str(REQUEST_ID), + "agent_id": "sdk-agent", + "action": "ticket.refund", + "risk": "high", + "status": status, + "requested_by": "sdk-agent", + "request_reason": "Refund requires review.", + "created_at": NOW, + "decided_at": None, + "decided_by": None, + "decision_reason": None, + } + if status != "pending": + payload.update( + decided_at=NOW, + decided_by="reviewer@example.test", + decision_reason="Evidence verified.", + ) + return payload + + +def test_client_runs_the_complete_typed_governance_flow() -> None: + assert PublicControlPlaneClient is ControlPlaneClient + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + path = request.url.path + if path == "/health/live" or path == "/health/ready": + return httpx.Response( + 200, + json={"status": "ok", "service": "agent-control-plane", "version": "0.1.0"}, + ) + if path == "/v1/agent-specs/validate": + return httpx.Response( + 200, + json={"valid": True, "agent_id": "sdk-agent", "schema_version": "v1"}, + ) + if path == "/v1/agents" and request.method == "POST": + return httpx.Response(201, json=agent_payload()) + if path == "/v1/agents" and request.method == "GET": + return httpx.Response(200, json=[agent_payload()]) + if path == "/v1/agents/sdk-agent" and request.method == "GET": + return httpx.Response(200, json=agent_payload()) + if path == "/v1/agents/sdk-agent/status": + return httpx.Response(200, json=agent_payload("active", 2)) + if path == "/v1/approvals" and request.method == "POST": + return httpx.Response(201, json=approval_payload()) + if path == f"/v1/approvals/{REQUEST_ID}" and request.method == "GET": + return httpx.Response(200, json=approval_payload()) + if path == "/v1/approvals" and request.method == "GET": + return httpx.Response(200, json={"items": [approval_payload()], "count": 1}) + if path == f"/v1/approvals/{REQUEST_ID}/decision": + return httpx.Response(200, json=approval_payload("approved")) + if path == "/v1/audit-events": + return httpx.Response( + 200, + json={ + "items": [ + { + "event_id": "00000000-0000-0000-0000-000000000002", + "event_type": "agent.registered", + "agent_id": "sdk-agent", + "actor": "operator@example.test", + "occurred_at": NOW, + "resource_id": "sdk-agent", + "summary": "Registered agent version 1.0.0", + } + ], + "count": 1, + }, + ) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + http_client = httpx.Client( + base_url="https://control.example.test", + headers={"Authorization": "Bearer secret-token"}, + transport=httpx.MockTransport(handler), + ) + client = ControlPlaneClient(http_client=http_client) + + assert client.live().status == "ok" + assert client.ready().status == "ok" + assert client.validate_agent_spec(spec()).valid is True + assert ( + client.register_agent( + AgentRegistrationRequest(spec=spec(), actor="operator@example.test") + ).revision + == 1 + ) + assert client.list_agents()[0].spec.agent_id == "sdk-agent" + assert client.get_agent("sdk-agent").revision == 1 + assert ( + client.update_agent_status( + "sdk-agent", + AgentStatusUpdate( + status=AgentRuntimeStatus.ACTIVE, + expected_revision=1, + actor="operator@example.test", + reason="Ready for traffic.", + ), + ).status + is AgentRuntimeStatus.ACTIVE + ) + approval = client.create_approval( + ApprovalRequestCreate( + agent_id="sdk-agent", + action="ticket.refund", + risk=RiskLevel.HIGH, + actor="sdk-agent", + reason="Refund requires review.", + ) + ) + assert client.get_approval(approval.request_id).status is ApprovalStatus.PENDING + assert ( + client.list_approvals( + status=ApprovalStatus.PENDING, + agent_id="sdk-agent", + ).count + == 1 + ) + assert ( + client.decide_approval( + approval.request_id, + ApprovalDecisionRequest( + decision=ApprovalDecision.APPROVE, + actor="reviewer@example.test", + reason="Evidence verified.", + ), + ).status + is ApprovalStatus.APPROVED + ) + assert client.list_audit_events(agent_id="sdk-agent", limit=20).count == 1 + + assert all(request.headers["authorization"] == "Bearer secret-token" for request in requests) + approval_query = next( + request.url.params + for request in requests + if request.url.path == "/v1/approvals" and request.method == "GET" + ) + assert dict(approval_query) == {"status": "pending", "agent_id": "sdk-agent"} + assert dict(requests[-1].url.params) == {"limit": "20", "agent_id": "sdk-agent"} + client.close() + assert http_client.is_closed is False + http_client.close() + + +def test_client_adds_bearer_auth_and_owns_its_internal_transport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + class FakeClient: + def __init__(self, **kwargs: object) -> None: + captured.update(kwargs) + self.closed = False + + def close(self) -> None: + self.closed = True + + monkeypatch.setattr(httpx, "Client", FakeClient) + client = ControlPlaneClient( + "https://control.example.test/", + bearer_token="secret-token", + timeout=5, + ) + + assert captured == { + "base_url": "https://control.example.test", + "headers": {"Authorization": "Bearer secret-token"}, + "timeout": 5, + } + client.close() + assert client._http.closed is True + + +def test_client_rejects_ambiguous_authentication_configuration() -> None: + http_client = httpx.Client(transport=httpx.MockTransport(lambda _: httpx.Response(200))) + + with pytest.raises(ValueError, match="cannot be combined"): + ControlPlaneClient(bearer_token="secret", http_client=http_client) + http_client.close() + + +def test_structured_and_unstructured_api_errors_are_stable() -> None: + responses = iter( + ( + httpx.Response( + 409, + json={"detail": {"code": "revision_conflict", "message": "stale revision"}}, + ), + httpx.Response(502, text="proxy failed"), + httpx.Response(307, headers={"Location": "https://unexpected.example.test"}), + ) + ) + http_client = httpx.Client( + base_url="https://control.example.test", + transport=httpx.MockTransport(lambda _: next(responses)), + ) + client = ControlPlaneClient(http_client=http_client) + + with pytest.raises(ControlPlaneAPIError) as structured: + client.get_agent("sdk-agent") + assert structured.value.status_code == 409 + assert structured.value.code == "revision_conflict" + assert structured.value.message == "stale revision" + + with pytest.raises(ControlPlaneAPIError) as fallback: + client.get_agent("sdk-agent") + assert fallback.value.status_code == 502 + assert fallback.value.code == "http_error" + assert fallback.value.message == "control plane returned HTTP 502" + + with pytest.raises(ControlPlaneAPIError) as redirect: + client.get_agent("sdk-agent") + assert redirect.value.status_code == 307 + assert redirect.value.code == "http_error" + http_client.close() + + +def test_transport_and_invalid_response_failures_are_distinct() -> None: + def transport_failure(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + transport_client = httpx.Client( + base_url="https://control.example.test", + transport=httpx.MockTransport(transport_failure), + ) + with pytest.raises(ControlPlaneTransportError, match="request failed"): + ControlPlaneClient(http_client=transport_client).get_agent("sdk-agent") + transport_client.close() + + invalid_client = httpx.Client( + base_url="https://control.example.test", + transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"unexpected": True})), + ) + with pytest.raises(ControlPlaneResponseError, match="invalid response"): + ControlPlaneClient(http_client=invalid_client).get_agent("sdk-agent") + invalid_client.close()