Skip to content
Closed
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
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,44 @@

## [Unreleased]

## [0.22.0] - 2026-06-01

### Added

- **`cp.run()` context manager** — new `RunHandle`-yielding context manager on
`AsyncControlPlaneFacade`, `AsyncResilientControlPlane`, `ControlPlaneFacade`, and
`ResilientControlPlane`. Opens a session, activates it, and closes it on exit.
Tags accumulated via `handle.tag(**metadata)` are written into the session's close
payload. On exception: closes with `SESSION_ABORTED` (error repr in payload) and
re-raises. Mirrors `token_budget_tracker()` ergonomics for the audit-trail use case.
- **`SyncControlPlane.activate_session()`** — explicit session activation for the sync
path, used internally by `ControlPlaneFacade.run()`.
- **`RunHandle`** exported from the top-level `agent_control_plane` package.
- **`examples/audit_trail.py`** — runnable Tier 2 example showing `cp.run()` with cost
recording, tagging, and the error/abort path.

### Changed (breaking)

- **Table renames** — four SQL table names changed to remove implementation-noise prefixes.
Python class names are unchanged; only the `__tablename__` values differ.

| Old | New |
|---|---|
| `control_sessions` | `agent_runs` |
| `control_events` | `audit_events` |
| `action_proposals` | `proposals` |
| `command_ledger` | `idempotency_ledger` |

New deployments (`create_tables()`) get the new names automatically. Existing deployments
must run `ALTER TABLE … RENAME TO …` before upgrading — see
[docs/compatibility.md](docs/compatibility.md) for the exact SQL.

### Docs

- README: new **Adoption tiers** section (Tier 1: cost tracking, Tier 2: audit trail,
Tier 3: full governance) with runnable example pointers for each tier.
- `docs/compatibility.md`: 0.22.0 migration section with SQLite + Postgres rename SQL.

## [0.21.0] - 2026-06-01

### Security
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,36 @@ Less useful:
- One-off demos with no side effects.
- Prompt/tooling projects that do not need governance.

## Adoption tiers

Pick the tier that matches your use case — each builds on the previous one.

### Tier 1 — Cost tracking only (5 minutes)

Token budget enforcement and per-call usage ledger. No sessions, no governance. Works standalone.

```bash
uv run python examples/tenant_budget_tracking.py
```

### Tier 2 — Audit trail (add sessions)

Wrap agent runs in a tracked session. Records cost, duration, and outcome without any approval gates.

```bash
uv run python examples/audit_trail.py
```

### Tier 3 — Full governance

Policy enforcement, human approval gates, kill switch, multi-agent revocation.

```bash
uv run python examples/quickstart_sync.py
```

---

## Install

```bash
Expand Down Expand Up @@ -146,6 +176,7 @@ Notes:

## Examples

- Audit trail (Tier 2): [examples/audit_trail.py](examples/audit_trail.py)
- Sync quickstart: [examples/quickstart_sync.py](examples/quickstart_sync.py)
- Async quickstart: [examples/quickstart.py](examples/quickstart.py)
- Single-agent continuous loop: [examples/single_agent_continuous_loop.py](examples/single_agent_continuous_loop.py)
Expand Down
35 changes: 35 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,41 @@ an `import agent_control_plane`.

## Breaking changes

### 0.22.0 — table renames

Four tables were renamed to remove implementation-noise prefixes. The Python class names
(`ControlSession`, `ControlEvent`, `ActionProposalRow`, `CommandLedger`) are unchanged —
only the SQL table names changed.

| Old table | New table |
|---|---|
| `control_sessions` | `agent_runs` |
| `control_events` | `audit_events` |
| `action_proposals` | `proposals` |
| `command_ledger` | `idempotency_ledger` |

**New deployments** (`create_tables()` / `Base.metadata.create_all`) get the new names automatically.

**Existing deployments** must run the following migrations before upgrading:

```sql
-- SQLite
ALTER TABLE control_sessions RENAME TO agent_runs;
ALTER TABLE control_events RENAME TO audit_events;
ALTER TABLE action_proposals RENAME TO proposals;
ALTER TABLE command_ledger RENAME TO idempotency_ledger;
```

```sql
-- PostgreSQL (same syntax)
ALTER TABLE control_sessions RENAME TO agent_runs;
ALTER TABLE control_events RENAME TO audit_events;
ALTER TABLE action_proposals RENAME TO proposals;
ALTER TABLE command_ledger RENAME TO idempotency_ledger;
```

---

### 0.21.0 — security defaults tightened (three breaking changes)

**`McpGatewayConfig.auto_create_sessions` is now `False`.**
Expand Down
74 changes: 74 additions & 0 deletions examples/audit_trail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Tier 2 — audit trail with session tracking.

Records that an agent run happened, how long it took, and what it cost.
No policy engine, no approval gates, no kill switch — just open a run,
do work, and close it. The session row and its events form the audit record.

Shows:
1. cp.run() async context manager — open, activate, close in one call.
2. run.tag() — attach metadata written into the close payload.
3. token_budget_tracker() — record token usage against the session.
4. Error path — exception aborts the session and re-raises.

Run:
uv run python examples/audit_trail.py
"""

from __future__ import annotations

import asyncio
import tempfile
from pathlib import Path

from agent_control_plane import (
ControlPlaneSetup,
IdentityContext,
ModelId,
OrgId,
TokenUsage,
)
from agent_control_plane.types.enums import ExecutionMode


async def simulate_work(cp, session_id, *, input_tokens: int, output_tokens: int) -> None:
"""Record token usage against an open session."""
async with cp.token_budget_tracker() as tracker:
await tracker.record_usage(
session_id,
IdentityContext(org_id=OrgId("default")),
TokenUsage(
model_id=ModelId("claude-sonnet-4-6"),
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=input_tokens + output_tokens,
estimated_cost_usd=round((input_tokens * 3 + output_tokens * 15) / 1_000_000, 6),
),
)


async def main() -> None:
tmp = Path(tempfile.mkdtemp())
db_url = f"sqlite+aiosqlite:///{tmp}/audit.db"
cp = ControlPlaneSetup(db_url).build_async()

print("=== Successful run ===")
async with cp.run("editorial-generation:run-1", execution_mode=ExecutionMode.LIVE) as run:
run.tag(tenant="acme", model="claude-sonnet-4-6", pipeline_version="1.2")
await simulate_work(cp, run.session_id, input_tokens=2000, output_tokens=800)
print(f" session_id={run.session_id}")
print(" run closed: COMPLETED")

print("\n=== Failed run ===")
try:
async with cp.run("editorial-generation:run-2", execution_mode=ExecutionMode.LIVE) as run:
run.tag(tenant="acme", pipeline_version="1.2")
raise RuntimeError("upstream API timeout")
except RuntimeError as e:
print(f" caught: {e}")
print(" run closed: ABORTED (abort_reason in close payload)")

await cp.close()


if __name__ == "__main__":
asyncio.run(main())
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "agent-control-plane"
version = "0.21.0"
version = "0.22.0"
description = "Embeddable governance framework for agentic AI — approval gates, policy engine, budget tracking, kill switches, event sourcing, crash recovery"
readme = "README.md"
authors = [{ name = "Ryan Williams" }]
Expand Down Expand Up @@ -84,7 +84,7 @@ max-line-length = 120
[tool.pylint.design]
max-args = 8
max-positional-arguments = 5
max-public-methods = 20
max-public-methods = 21
max-attributes = 12
max-locals = 15
max-returns = 6
Expand Down
2 changes: 2 additions & 0 deletions src/agent_control_plane/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@
StateChangePage,
)
from agent_control_plane.types.risk import RiskPattern, SessionRiskEscalation, SessionRiskState
from agent_control_plane.types.run_handle import RunHandle
from agent_control_plane.types.sessions import BudgetInfo, KillSwitchResult, SessionCreate, SessionState, SessionSummary
from agent_control_plane.types.steering import SteeringContext
from agent_control_plane.types.token_governance import (
Expand Down Expand Up @@ -529,6 +530,7 @@ def get_version() -> str:
"SessionCheckpoint",
"SessionEventBudgetServices",
"SessionLifecycleResult",
"RunHandle",
"SessionManager",
"SessionHealth",
"SessionRepository",
Expand Down
39 changes: 39 additions & 0 deletions src/agent_control_plane/async_facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
from agent_control_plane.types.ids import AgentId, IdempotencyKey
from agent_control_plane.types.proposals import ActionProposal
from agent_control_plane.types.query import Page, SessionHealth, StateChange, StateChangePage
from agent_control_plane.types.run_handle import RunHandle
from agent_control_plane.types.sessions import SessionState


Expand Down Expand Up @@ -1239,6 +1240,44 @@ async def token_budget_tracker(self) -> AsyncIterator[Any]:
await db.rollback()
raise

@asynccontextmanager
async def run(
self,
name: str,
*,
max_cost: Decimal = Decimal("10000"),
max_action_count: int = 50,
execution_mode: ExecutionMode = ExecutionMode.DRY_RUN,
) -> AsyncIterator[RunHandle]:
"""Open a tracked agent run and yield a handle for tagging.

Opens a session, activates it, and closes it on exit. Tags accumulated
via ``handle.tag()`` are written into the session's close payload.
On exception: aborts the session with the error as the abort reason.
"""
session_id = await self.sessions.open_session(
name,
max_cost=max_cost,
max_action_count=max_action_count,
execution_mode=execution_mode,
)
await self.lifecycle.activate_session(session_id)
handle = RunHandle(session_id=session_id)
try:
yield handle
await self.sessions.close_session(
session_id,
final_event_kind=EventKind.EXECUTION_COMPLETED,
payload=handle._tags or None,
)
except Exception as exc:
await self.sessions.close_session(
session_id,
final_event_kind=EventKind.SESSION_ABORTED,
payload={"abort_reason": repr(exc), **handle._tags},
)
raise

async def create_policy(self, **fields: Any) -> UUID:
async with self.session_scope() as db:
uow = self._uow_factory(db)
Expand Down
18 changes: 18 additions & 0 deletions src/agent_control_plane/async_resilient.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
from agent_control_plane.types.ids import AgentId, IdempotencyKey
from agent_control_plane.types.proposals import ActionProposal
from agent_control_plane.types.query import Page, SessionHealth, StateChangePage
from agent_control_plane.types.run_handle import RunHandle
from agent_control_plane.types.sessions import SessionState

T = TypeVar("T")
Expand Down Expand Up @@ -650,5 +651,22 @@ async def token_budget_tracker(self) -> AsyncIterator[Any]:
async with self._facade.token_budget_tracker() as tracker:
yield tracker

@asynccontextmanager
async def run(
self,
name: str,
*,
max_cost: Decimal = Decimal("10000"),
max_action_count: int = 50,
execution_mode: ExecutionMode = ExecutionMode.DRY_RUN,
) -> AsyncIterator[RunHandle]:
async with self._facade.run(
name,
max_cost=max_cost,
max_action_count=max_action_count,
execution_mode=execution_mode,
) as handle:
yield handle

async def create_policy(self, **fields: Any) -> UUID:
return await self._facade.create_policy(**fields)
24 changes: 11 additions & 13 deletions src/agent_control_plane/models/reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class PolicySnapshotRow(Base, PolicySnapshotMixin):


class ControlSession(Base, ControlSessionMixin):
__tablename__ = "control_sessions"
__tablename__ = "agent_runs"

id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid4)

Expand All @@ -54,29 +54,29 @@ class SessionSeqCounter(Base, SessionSeqCounterMixin):
__tablename__ = "session_seq_counters"

id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid4)
session_id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("control_sessions.id"), nullable=False)
session_id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("agent_runs.id"), nullable=False)


class ControlEvent(Base, ControlEventMixin):
__tablename__ = "control_events"
__tablename__ = "audit_events"

id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid4)
session_id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("control_sessions.id"), nullable=False)
session_id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("agent_runs.id"), nullable=False)


class ActionProposalRow(Base, ActionProposalMixin):
__tablename__ = "action_proposals"
__tablename__ = "proposals"

id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid4)
session_id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("control_sessions.id"), nullable=False)
session_id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("agent_runs.id"), nullable=False)


class ApprovalTicketRow(Base, ApprovalTicketMixin):
__tablename__ = "approval_tickets"

id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid4)
session_id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("control_sessions.id"), nullable=False)
proposal_id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("action_proposals.id"), nullable=False)
session_id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("agent_runs.id"), nullable=False)
proposal_id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("proposals.id"), nullable=False)


class AgentRecord(Base, AgentMixin):
Expand All @@ -99,13 +99,13 @@ class AgentSessionRevocationRow(Base, AgentSessionRevocationMixin):


class CommandLedger(Base, CommandLedgerMixin):
__tablename__ = "command_ledger"
__tablename__ = "idempotency_ledger"
__table_args__ = (UniqueConstraint("command_id", "operation", name="uq_command_ledger_command_operation"),)

id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid4)
session_id: Mapped[UUID | None] = mapped_column(
Uuid(as_uuid=True),
ForeignKey("control_sessions.id"),
ForeignKey("agent_runs.id"),
nullable=True,
)

Expand All @@ -120,9 +120,7 @@ class TokenUsageLedgerRow(Base, TokenUsageLedgerMixin):
__tablename__ = "token_usage_ledger"

id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid4)
session_id: Mapped[UUID | None] = mapped_column(
Uuid(as_uuid=True), ForeignKey("control_sessions.id"), nullable=True
)
session_id: Mapped[UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("agent_runs.id"), nullable=True)


class TokenBudgetStateRow(Base, TokenBudgetStateMixin):
Expand Down
Loading
Loading