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
72 changes: 57 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,62 @@ jobs:
raise
time.sleep(0.5)

request = urllib.request.Request(
"http://127.0.0.1:8000/v1/agent-specs/validate",
data=json.dumps(
{
"agent_id": "ci-smoke-agent",
"version": "1.0.0",
"display_name": "CI Smoke Agent",
"description": "Validates the packaged service contract.",
"entrypoint": "https://agents.example.test/ci-smoke",
}
).encode(),
headers={"Content-Type": "application/json"},
method="POST",
def call(path, method="GET", payload=None):
request = urllib.request.Request(
f"http://127.0.0.1:8000{path}",
data=None if payload is None else json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method=method,
)
with urllib.request.urlopen(request, timeout=2) as response:
return json.load(response)

specification = {
"agent_id": "ci-smoke-agent",
"version": "1.0.0",
"display_name": "CI Smoke Agent",
"description": "Validates the packaged service contract.",
"entrypoint": "https://agents.example.test/ci-smoke",
}
assert call("/v1/agent-specs/validate", "POST", specification)["valid"] is True
registered = call(
"/v1/agents",
"POST",
{"spec": specification, "actor": "ci@example.test"},
)
assert registered["revision"] == 1
activated = call(
"/v1/agents/ci-smoke-agent/status",
"PATCH",
{
"status": "active",
"expected_revision": 1,
"actor": "ci@example.test",
"reason": "Container readiness checks passed.",
},
)
assert activated["status"] == "active"

approval = call(
"/v1/approvals",
"POST",
{
"agent_id": "ci-smoke-agent",
"action": "deployment.promote",
"risk": "high",
"actor": "ci-smoke-agent",
"reason": "Exercise the packaged governance loop.",
},
)
decided = call(
f"/v1/approvals/{approval['request_id']}/decision",
"POST",
{
"decision": "approve",
"actor": "ci-reviewer@example.test",
"reason": "Container smoke evidence passed.",
},
)
with urllib.request.urlopen(request, timeout=2) as response:
assert json.load(response)["valid"] is True
assert decided["status"] == "approved"
assert call("/v1/audit-events")["count"] == 4
PY
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ for public contracts once they are declared stable.
- Initial FastAPI service with liveness, readiness, and `AgentSpec` validation.
- Unit, smoke, lint, type, dependency audit, and container build automation.
- Risk-based review and delivery policy.
- Agent registration and lifecycle status APIs with optimistic revision checks.
- Human approval queue with single-decision enforcement and append-only audit events.
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ The API is then available at `http://127.0.0.1:8000`. Important endpoints:
- `GET /health/live`
- `GET /health/ready`
- `POST /v1/agent-specs/validate`
- `POST /v1/agents` and `PATCH /v1/agents/{agent_id}/status`
- `GET` and `POST /v1/approvals`
- `POST /v1/approvals/{request_id}/decision`
- `GET /v1/audit-events`
- `GET /docs`

Container execution:
Expand All @@ -59,8 +63,13 @@ long-running checks run after merge and on a schedule. See

## Project status

The repository is in foundation stage. Public API compatibility starts with the `v1` schema;
runtime, storage, and workflow adapters are not yet production-ready.
The first governance loop is available: register an agent, activate or pause it with optimistic
revision checks, request and decide human approval, and inspect the resulting audit events.
Public API compatibility starts with the `v1` schema.

The bundled store is intentionally in-memory and intended for development and evaluation. Data
does not survive a process restart and must not be treated as a production system of record.
PostgreSQL persistence, authenticated actor identity, and durable workflows remain planned.

## License

Expand Down
12 changes: 10 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,24 @@ Existing Agent
v
Control Plane API
|- AgentSpec validation
|- agent lifecycle and optimistic revision checks
|- human approval queue and append-only audit events
|- trace and replay (planned)
|- policy and approval (planned)
|- evaluation gates (planned)
`- version promotion (planned)
```

The current code implements the API shell and the first versioned contract. PostgreSQL becomes
The current code implements the API shell, the first versioned contract, and an in-memory
governance loop. The storage protocol is owned by the control plane so PostgreSQL can replace
the development adapter without leaking database types into the public API. PostgreSQL becomes
the source of truth when persistence is introduced. Vector databases remain derived indexes,
not authoritative stores.

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. Audit events are append-only within the store and returned newest first.
Authentication and durable audit retention are required before production use.

## Adapter policy

Temporal, Mem0, DSPy, LangSmith, and other providers must sit behind owned interfaces. A vendor
Expand Down
9 changes: 5 additions & 4 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ Roadmap items advance only when tied to a validated user problem and an acceptan

## Foundation

- Versioned AgentSpec and event contracts.
- API health, readiness, and failure conventions.
- Pull request governance and automated quality gates.
- [x] Versioned AgentSpec and event contracts.
- [x] API health, readiness, and failure conventions.
- [x] Pull request governance and automated quality gates.

## Reliability gateway

- Framework-neutral trace ingestion.
- Run replay and failure classification.
- Tool-call schema validation and risk policy.
- Human approval queue and immutable audit record.
- [x] In-memory human approval queue and append-only audit contract.
- PostgreSQL-backed approval and immutable audit persistence.
- Offline evaluation datasets and version promotion gates.

## Durable operations
Expand Down
129 changes: 126 additions & 3 deletions src/agent_control_plane/api.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,50 @@
"""HTTP surface for the initial control-plane contract."""
"""HTTP surface for the control-plane contract."""

from fastapi import FastAPI
from typing import Annotated, NoReturn
from uuid import UUID

from fastapi import FastAPI, HTTPException, Query, status

from agent_control_plane import __version__
from agent_control_plane.models import (
AgentRecord,
AgentRegistrationRequest,
AgentSpec,
AgentSpecValidationResponse,
AgentStatusUpdate,
ApprovalDecisionRequest,
ApprovalQueueResponse,
ApprovalRecord,
ApprovalRequestCreate,
ApprovalStatus,
AuditEventPage,
HealthResponse,
HealthStatus,
)
from agent_control_plane.store import (
AgentAlreadyExistsError,
AgentNotActiveError,
AgentNotFoundError,
ApprovalAlreadyDecidedError,
ApprovalNotFoundError,
ControlPlaneStore,
InMemoryControlPlaneStore,
InvalidStatusTransitionError,
RevisionConflictError,
)

SERVICE_NAME = "agent-control-plane"


def create_app() -> FastAPI:
def _raise_http_error(status_code: int, code: str, error: Exception) -> NoReturn:
raise HTTPException(
status_code=status_code,
detail={"code": code, "message": str(error)},
) from error


def create_app(store: ControlPlaneStore | None = None) -> FastAPI:
control_plane = store if store is not None else InMemoryControlPlaneStore()
application = FastAPI(
title="Agent Control Plane",
description="Reliability and governance APIs for production AI agents.",
Expand Down Expand Up @@ -41,6 +72,98 @@ async def validate_agent_spec(spec: AgentSpec) -> AgentSpecValidationResponse:
schema_version=spec.schema_version,
)

@application.post(
"/v1/agents",
response_model=AgentRecord,
status_code=status.HTTP_201_CREATED,
tags=["agents"],
)
async def register_agent(request: AgentRegistrationRequest) -> AgentRecord:
try:
return control_plane.register_agent(request)
except AgentAlreadyExistsError as error:
_raise_http_error(status.HTTP_409_CONFLICT, "agent_already_exists", error)

@application.get("/v1/agents", response_model=list[AgentRecord], tags=["agents"])
async def list_agents() -> tuple[AgentRecord, ...]:
return control_plane.list_agents()

@application.get("/v1/agents/{agent_id}", response_model=AgentRecord, tags=["agents"])
async def get_agent(agent_id: str) -> AgentRecord:
try:
return control_plane.get_agent(agent_id)
except AgentNotFoundError as error:
_raise_http_error(status.HTTP_404_NOT_FOUND, "agent_not_found", error)

@application.patch(
"/v1/agents/{agent_id}/status",
response_model=AgentRecord,
tags=["agents"],
)
async def update_agent_status(agent_id: str, update: AgentStatusUpdate) -> AgentRecord:
try:
return control_plane.update_agent_status(agent_id, update)
except AgentNotFoundError as error:
_raise_http_error(status.HTTP_404_NOT_FOUND, "agent_not_found", error)
except RevisionConflictError as error:
_raise_http_error(status.HTTP_409_CONFLICT, "revision_conflict", error)
except InvalidStatusTransitionError as error:
_raise_http_error(status.HTTP_409_CONFLICT, "invalid_status_transition", error)

@application.post(
"/v1/approvals",
response_model=ApprovalRecord,
status_code=status.HTTP_201_CREATED,
tags=["approvals"],
)
async def create_approval(request: ApprovalRequestCreate) -> ApprovalRecord:
try:
return control_plane.create_approval(request)
except AgentNotFoundError as error:
_raise_http_error(status.HTTP_404_NOT_FOUND, "agent_not_found", error)
except AgentNotActiveError as error:
_raise_http_error(status.HTTP_409_CONFLICT, "agent_not_active", error)

@application.get(
"/v1/approvals/{request_id}", response_model=ApprovalRecord, tags=["approvals"]
)
async def get_approval(request_id: UUID) -> ApprovalRecord:
try:
return control_plane.get_approval(request_id)
except ApprovalNotFoundError as error:
_raise_http_error(status.HTTP_404_NOT_FOUND, "approval_not_found", error)

@application.get("/v1/approvals", response_model=ApprovalQueueResponse, tags=["approvals"])
async def list_approvals(
approval_status: Annotated[ApprovalStatus | None, Query(alias="status")] = None,
agent_id: str | None = None,
) -> ApprovalQueueResponse:
items = control_plane.list_approvals(status=approval_status, agent_id=agent_id)
return ApprovalQueueResponse(items=items, count=len(items))

@application.post(
"/v1/approvals/{request_id}/decision",
response_model=ApprovalRecord,
tags=["approvals"],
)
async def decide_approval(
request_id: UUID, decision: ApprovalDecisionRequest
) -> ApprovalRecord:
try:
return control_plane.decide_approval(request_id, decision)
except ApprovalNotFoundError as error:
_raise_http_error(status.HTTP_404_NOT_FOUND, "approval_not_found", error)
except ApprovalAlreadyDecidedError as error:
_raise_http_error(status.HTTP_409_CONFLICT, "approval_already_decided", error)

@application.get("/v1/audit-events", response_model=AuditEventPage, tags=["audit"])
async def list_audit_events(
agent_id: str | None = None,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
) -> AuditEventPage:
items = control_plane.list_audit_events(agent_id=agent_id, limit=limit)
return AuditEventPage(items=items, count=len(items))

return application


Expand Down
Loading