Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
51 changes: 49 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions src/agent_control_plane/__init__.py
Original file line number Diff line number Diff line change
@@ -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__",
]
264 changes: 264 additions & 0 deletions src/agent_control_plane/client.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions src/agent_control_plane/py.typed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Loading