From cd9989de024d598e1cf2d516345f1c182bb2dad3 Mon Sep 17 00:00:00 2001 From: Ryan Williams Date: Mon, 1 Jun 2026 22:05:47 -0700 Subject: [PATCH] feat: table renames, cp.run() context manager, adoption tiers (0.22.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename four SQL tables: control_sessions→agent_runs, control_events→audit_events, action_proposals→proposals, command_ledger→idempotency_ledger. Python class names unchanged; migration SQL in docs/compatibility.md. - Add cp.run() context manager (async + sync + resilient variants) yielding RunHandle. Opens/activates/closes session automatically; tags written to close payload; aborts with error repr on exception. Mirrors token_budget_tracker() ergonomics. - Add SyncControlPlane.activate_session() used by ControlPlaneFacade.run(). - Export RunHandle from top-level package. - Add examples/audit_trail.py (Tier 2 runnable demo). - README: adoption tiers section (Tier 1/2/3) with per-tier example pointers. - Bump version to 0.22.0. --- CHANGELOG.md | 38 +++++++++++ README.md | 31 +++++++++ docs/compatibility.md | 35 ++++++++++ examples/audit_trail.py | 74 +++++++++++++++++++++ pyproject.toml | 4 +- src/agent_control_plane/__init__.py | 2 + src/agent_control_plane/async_facade.py | 39 +++++++++++ src/agent_control_plane/async_resilient.py | 18 +++++ src/agent_control_plane/models/reference.py | 24 +++---- src/agent_control_plane/resilient.py | 21 +++++- src/agent_control_plane/sync.py | 49 ++++++++++++++ src/agent_control_plane/types/run_handle.py | 23 +++++++ 12 files changed, 342 insertions(+), 16 deletions(-) create mode 100644 examples/audit_trail.py create mode 100644 src/agent_control_plane/types/run_handle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ea30f..11b5068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4c256b5..08f09a3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) diff --git a/docs/compatibility.md b/docs/compatibility.md index 3c1d426..b2268ed 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -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`.** diff --git a/examples/audit_trail.py b/examples/audit_trail.py new file mode 100644 index 0000000..6a6e511 --- /dev/null +++ b/examples/audit_trail.py @@ -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()) diff --git a/pyproject.toml b/pyproject.toml index b0b0037..bc0a9a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" }] @@ -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 diff --git a/src/agent_control_plane/__init__.py b/src/agent_control_plane/__init__.py index a9fb686..de481a6 100644 --- a/src/agent_control_plane/__init__.py +++ b/src/agent_control_plane/__init__.py @@ -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 ( @@ -529,6 +530,7 @@ def get_version() -> str: "SessionCheckpoint", "SessionEventBudgetServices", "SessionLifecycleResult", + "RunHandle", "SessionManager", "SessionHealth", "SessionRepository", diff --git a/src/agent_control_plane/async_facade.py b/src/agent_control_plane/async_facade.py index 385c821..a5bcf65 100644 --- a/src/agent_control_plane/async_facade.py +++ b/src/agent_control_plane/async_facade.py @@ -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 @@ -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) diff --git a/src/agent_control_plane/async_resilient.py b/src/agent_control_plane/async_resilient.py index fe6f7f2..14dfb87 100644 --- a/src/agent_control_plane/async_resilient.py +++ b/src/agent_control_plane/async_resilient.py @@ -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") @@ -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) diff --git a/src/agent_control_plane/models/reference.py b/src/agent_control_plane/models/reference.py index 3136c87..fcebd82 100644 --- a/src/agent_control_plane/models/reference.py +++ b/src/agent_control_plane/models/reference.py @@ -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) @@ -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): @@ -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, ) @@ -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): diff --git a/src/agent_control_plane/resilient.py b/src/agent_control_plane/resilient.py index 54e42de..2fda7c7 100644 --- a/src/agent_control_plane/resilient.py +++ b/src/agent_control_plane/resilient.py @@ -8,7 +8,8 @@ from __future__ import annotations import logging -from collections.abc import Mapping +from collections.abc import Iterator, Mapping +from contextlib import contextmanager from datetime import datetime from decimal import Decimal from typing import Any, TypeVar @@ -53,6 +54,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") @@ -572,3 +574,20 @@ def setup(self) -> None: def close(self) -> None: self._facade.close() + + @contextmanager + def run( + self, + name: str, + *, + max_cost: Decimal = Decimal("10000"), + max_action_count: int = 50, + execution_mode: ExecutionMode = ExecutionMode.DRY_RUN, + ) -> Iterator[RunHandle]: + with self._facade.run( + name, + max_cost=max_cost, + max_action_count=max_action_count, + execution_mode=execution_mode, + ) as handle: + yield handle diff --git a/src/agent_control_plane/sync.py b/src/agent_control_plane/sync.py index ece243d..118ee8d 100644 --- a/src/agent_control_plane/sync.py +++ b/src/agent_control_plane/sync.py @@ -53,6 +53,7 @@ from agent_control_plane.types.ids import AgentId, IdempotencyKey, ResourceId 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 CMD_OPEN_SESSION: Final[str] = "open_session" @@ -332,6 +333,16 @@ def emit_app_event( metadata=merged, ) + def activate_session(self, session_id: UUID) -> SessionLifecycleResult: + with self.session_scope() as db: + uow = self._uow_factory(db) + uow.session_repo.update_session(session_id, status=SessionStatus.ACTIVE, updated_at=datetime.now(UTC)) + uow.commit() + session = uow.session_repo.get_session(session_id) + if session is None: + raise ValueError(f"Session not found after activation: {session_id}") + return SessionLifecycleResult(session=session) + def complete_session(self, session_id: UUID) -> SessionLifecycleResult: with self.session_scope() as db: uow = self._uow_factory(db) @@ -1185,3 +1196,41 @@ def setup(self) -> None: def close(self) -> None: self._cp.close() + + @contextmanager + def run( + self, + name: str, + *, + max_cost: Decimal = Decimal("10000"), + max_action_count: int = 50, + execution_mode: ExecutionMode = ExecutionMode.DRY_RUN, + ) -> Iterator[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: closes the session with SESSION_ABORTED and re-raises. + """ + session_id = self.sessions.open_session( + name, + max_cost=max_cost, + max_action_count=max_action_count, + execution_mode=execution_mode, + ) + self._cp.activate_session(session_id) + handle = RunHandle(session_id=session_id) + try: + yield handle + self.sessions.close_session( + session_id, + final_event_kind=EventKind.EXECUTION_COMPLETED, + payload=handle._tags or None, + ) + except Exception as exc: + self.sessions.close_session( + session_id, + final_event_kind=EventKind.SESSION_ABORTED, + payload={"abort_reason": repr(exc), **handle._tags}, + ) + raise diff --git a/src/agent_control_plane/types/run_handle.py b/src/agent_control_plane/types/run_handle.py new file mode 100644 index 0000000..ef28fcd --- /dev/null +++ b/src/agent_control_plane/types/run_handle.py @@ -0,0 +1,23 @@ +"""RunHandle — the value yielded by cp.run() context managers.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from uuid import UUID + + +@dataclass +class RunHandle: + """Handle for an in-progress tracked agent run. + + Yielded by ``cp.run("name")`` context managers. Collects tags that are + written into the session's close payload so they appear in the audit log. + """ + + session_id: UUID + _tags: dict[str, Any] = field(default_factory=dict, init=False, repr=False) + + def tag(self, **metadata: Any) -> None: + """Attach key/value metadata to this run (accumulated until close).""" + self._tags.update(metadata)