feat(my-deepagent): v0.1.0 Step 0~5 — scaffolding through deepagent + OpenRouter
Python rewrite of the agent harness on top of deepagents 0.6.1 + langchain 1.x, replacing the abandoned TS attempt in packages/. 388 unit/integration tests pass. Steps ----- 0. Scaffolding — uv workspace, ruff/mypy/pre-commit/alembic, src/tests/docs trees with docs/schemas/ seeded from my-deepagent-seed/. 1. Core — config (pydantic-settings with MYDEEPAGENT_ env prefix and TOML source), enums (Backend, Capability, RiskLevel, ApprovalDecisionAction, ApprovalState, RunState, RunPhaseState, SessionState, ErrorClass), errors (MyDeepAgentError + BudgetExhaustedError with PEP-3134 cause + context suppression), hash (canonical JSON + sha256). 2. Persona/Workflow/Binding — pydantic v2 schemas with tuple-based deep immutability (post-construction hash drift prevented), YAML loaders, deterministic auto-select (preferred_backends → version → name → hash), override resolution with ineligibility diagnostics, PersonaConsentStore with fcntl.flock + tmp+fsync+rename atomic write. 3. Artifact schema registry — Draft202012Validator, multi-root resolution, structured ValidationFinding output. 4. Persistence — 18 SQLAlchemy 2.0 async ORM models with FK CASCADE/RESTRICT, WAL + busy_timeout + foreign_keys PRAGMA, alembic baseline + ux_active_run_repo_base partial unique index, LangGraph SqliteSaver as context manager only (lifecycle safety). 5. DeepAgent session — build_agent wires Persona → create_deep_agent with LocalShellBackend / FilesystemBackend / StateBackend / CompositeBackend, ChatOpenAI(base_url=openrouter) for openrouter: model strings, and 4 middleware classes (cost / audit-tool / safety-shell / fallback-model). Critical workarounds -------------------- - deepagents 0.6.1 rejects FilesystemPermission together with backends that implement SandboxBackendProtocol (LocalShellBackend). SafetyShellMiddleware enforces destructive-command and secret-path policy at the tool layer instead, and build_agent strips the permissions kwarg when the persona's deepagents_backend is local_shell. - FilesystemOperation in deepagents is Literal['read', 'write'] only; _map_operations collapses our richer schema (read/write/edit/ls) safely. Real OpenRouter smoke --------------------- test_openrouter_deepagents_local_shell_smoke calls DeepSeek via deepagents + LocalShellBackend + SafetyShellMiddleware end-to-end. PASS, ~$0.000001 cost, input=9 / output=1 tokens with content "OK". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
my-deepagent/src/my_deepagent/persistence/__init__.py
Normal file
6
my-deepagent/src/my_deepagent/persistence/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Persistence layer: SQLAlchemy async ORM + LangGraph checkpointer."""
|
||||
|
||||
from .checkpointer import get_checkpointer_ctx
|
||||
from .db import Database
|
||||
|
||||
__all__ = ["Database", "get_checkpointer_ctx"]
|
||||
41
my-deepagent/src/my_deepagent/persistence/checkpointer.py
Normal file
41
my-deepagent/src/my_deepagent/persistence/checkpointer.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""LangGraph SqliteSaver wrapper. Use only as a context manager to ensure connection cleanup.
|
||||
|
||||
``SqliteSaver.from_conn_string`` is a ``@contextmanager`` classmethod that yields
|
||||
a ``SqliteSaver`` instance and closes the underlying sqlite3 connection on exit.
|
||||
Direct manual lifecycle management (entering context without ``with``) leaks connections
|
||||
and is not supported by this module.
|
||||
|
||||
Usage::
|
||||
|
||||
with get_checkpointer_ctx(path) as saver:
|
||||
graph = create_deep_agent(checkpointer=saver)
|
||||
...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_checkpointer_ctx(checkpoints_db_path: Path) -> Iterator[SqliteSaver]:
|
||||
"""Yield a SqliteSaver bound to *checkpoints_db_path*.
|
||||
|
||||
Creates the parent directory and the database file if they do not exist.
|
||||
The underlying sqlite3 connection is closed automatically on context exit.
|
||||
This is the only supported way to obtain a SqliteSaver in this project —
|
||||
direct manual lifecycle management is not provided.
|
||||
|
||||
Args:
|
||||
checkpoints_db_path: Filesystem path for the SQLite checkpoint database.
|
||||
|
||||
Yields:
|
||||
SqliteSaver: Ready-to-use LangGraph checkpoint saver.
|
||||
"""
|
||||
checkpoints_db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with SqliteSaver.from_conn_string(str(checkpoints_db_path)) as saver:
|
||||
yield saver
|
||||
91
my-deepagent/src/my_deepagent/persistence/db.py
Normal file
91
my-deepagent/src/my_deepagent/persistence/db.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Async SQLAlchemy engine + session factory with WAL mode and busy_timeout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from .models import Base
|
||||
|
||||
|
||||
def _attach_sqlite_pragmas(engine: AsyncEngine) -> None:
|
||||
"""Attach a synchronous connect-event listener that enables WAL, busy_timeout, FK."""
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_connection: object, _conn_record: object) -> None:
|
||||
# dbapi_connection is a raw sqlite3.Connection delivered by SQLAlchemy's
|
||||
# pool event callback. The signature uses `object` to match the generic
|
||||
# listener protocol; we cast to `Any` here to access DBAPI methods without
|
||||
# introducing a hard import of `sqlite3` (which would break non-SQLite
|
||||
# engines). The pragma calls are safe: they are no-ops on non-SQLite
|
||||
# dialects and sqlite3.Connection always has `.cursor()`.
|
||||
import sqlite3 # local import to avoid circular or non-SQLite coupling
|
||||
|
||||
conn: sqlite3.Connection = dbapi_connection # type: ignore[assignment]
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
class Database:
|
||||
"""Façade over async engine + session maker.
|
||||
|
||||
Usage::
|
||||
|
||||
db = Database("sqlite+aiosqlite:///path/to/db.sqlite3")
|
||||
await db.init_schema() # dev/test: create all tables directly
|
||||
async with db.session() as s: # production: use alembic upgrade head
|
||||
result = await s.execute(...)
|
||||
await db.dispose()
|
||||
|
||||
For production deployments, call ``alembic upgrade head`` instead of
|
||||
``init_schema`` so that migration history is tracked.
|
||||
"""
|
||||
|
||||
def __init__(self, database_url: str) -> None:
|
||||
self._engine: AsyncEngine = create_async_engine(
|
||||
database_url,
|
||||
# NullPool avoids connection reuse issues in SQLite+aiosqlite tests.
|
||||
poolclass=None, # use the default StaticPool-compatible pool
|
||||
echo=False,
|
||||
)
|
||||
_attach_sqlite_pragmas(self._engine)
|
||||
self._session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
bind=self._engine,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
async def init_schema(self) -> None:
|
||||
"""Create all ORM-defined tables.
|
||||
|
||||
For production, prefer ``alembic upgrade head``.
|
||||
For tests, this is the fastest way to get a clean schema.
|
||||
"""
|
||||
async with self._engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self) -> AsyncIterator[AsyncSession]:
|
||||
"""Yield an async session; commit on success, rollback on exception."""
|
||||
async with self._session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def dispose(self) -> None:
|
||||
"""Dispose the engine connection pool."""
|
||||
await self._engine.dispose()
|
||||
578
my-deepagent/src/my_deepagent/persistence/models.py
Normal file
578
my-deepagent/src/my_deepagent/persistence/models.py
Normal file
@@ -0,0 +1,578 @@
|
||||
"""SQLAlchemy 2.0 async ORM models for my-deepagent persistence layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""SQLAlchemy declarative base for my-deepagent."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# workflow_templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WorkflowTemplateRow(Base):
|
||||
"""Content-addressed workflow template definitions."""
|
||||
|
||||
__tablename__ = "workflow_templates"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
definition: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WorkflowTemplateRow id={self.id!r} name={self.name!r} version={self.version!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# agent_personas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentPersonaRow(Base):
|
||||
"""Content-addressed agent persona definitions."""
|
||||
|
||||
__tablename__ = "agent_personas"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
definition: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AgentPersonaRow id={self.id!r} name={self.name!r} version={self.version!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunRow(Base):
|
||||
"""Top-level run record: one row per deepagent run invocation."""
|
||||
|
||||
__tablename__ = "runs"
|
||||
__table_args__ = (
|
||||
# Partial unique index: at most one active run per (repo_path, base_branch).
|
||||
# An "active" run is any run whose state is not 'completed', 'failed', or 'aborted'.
|
||||
# SQLite partial index uses a WHERE clause; autogenerate cannot detect this,
|
||||
# so it is managed via a manual alembic migration.
|
||||
Index(
|
||||
"ux_active_run_repo_base",
|
||||
"repo_path",
|
||||
"base_branch",
|
||||
unique=True,
|
||||
sqlite_where=text("state NOT IN ('completed', 'failed', 'aborted')"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
# FK to workflow_templates — RESTRICT prevents deleting a template that has runs.
|
||||
template_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("workflow_templates.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
template_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
state: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
repo_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
base_branch: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
worktree_root: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# current_phase_id references run_phases.id; however, runs.current_phase_id and
|
||||
# run_phases.run_id form a circular FK pair. SQLite does not support deferrable
|
||||
# constraints at the column level, and alembic cannot safely manage this circular
|
||||
# dependency. Therefore current_phase_id carries NO ForeignKey constraint in the ORM.
|
||||
# Callers must maintain referential integrity manually (i.e. always point to a valid
|
||||
# run_phases.id that belongs to this run, or NULL).
|
||||
current_phase_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
started_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ended_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
final_report_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
paused_from_state: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
updated_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RunRow id={self.id!r} state={self.state!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_inputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunInputRow(Base):
|
||||
"""Input snapshot for a run (one-to-one with runs)."""
|
||||
|
||||
__tablename__ = "run_inputs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
requirements_md: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
objective: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
input_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RunInputRow id={self.id!r} run_id={self.run_id!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_bindings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunBindingRow(Base):
|
||||
"""Per-role persona binding for a run."""
|
||||
|
||||
__tablename__ = "run_bindings"
|
||||
__table_args__ = (UniqueConstraint("run_id", "role_id", name="uq_run_bindings_run_role"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
role_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# FK to agent_personas — RESTRICT prevents deleting a persona that has bindings.
|
||||
persona_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("agent_personas.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
persona_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
backend: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
binding_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RunBindingRow id={self.id!r} run_id={self.run_id!r} role_id={self.role_id!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_phases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunPhaseRow(Base):
|
||||
"""Per-phase execution record for a run."""
|
||||
|
||||
__tablename__ = "run_phases"
|
||||
__table_args__ = (UniqueConstraint("run_id", "phase_key", name="uq_run_phases_run_phase"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
phase_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
state: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
started_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ended_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RunPhaseRow id={self.id!r} run_id={self.run_id!r} phase_key={self.phase_key!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunEventRow(Base):
|
||||
"""Ordered event stream for a run."""
|
||||
|
||||
__tablename__ = "run_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("run_id", "seq", name="uq_run_events_run_seq"),
|
||||
UniqueConstraint("run_id", "idempotency_key", name="uq_run_events_run_idempotency"),
|
||||
Index("run_events_run_id_ts_idx", "run_id", "ts"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
# phase_id references run_phases.id; CASCADE so events are deleted when a phase is deleted.
|
||||
phase_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("run_phases.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
type: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
ts: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RunEventRow id={self.id!r} run_id={self.run_id!r} seq={self.seq!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# approval_requests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ApprovalRequestRow(Base):
|
||||
"""Human approval gate requests."""
|
||||
|
||||
__tablename__ = "approval_requests"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
# phase_id references run_phases.id; CASCADE so approval requests are deleted with the phase.
|
||||
phase_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("run_phases.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
gate_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
state: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
resolved_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ApprovalRequestRow id={self.id!r} gate_key={self.gate_key!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# approval_decisions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ApprovalDecisionRow(Base):
|
||||
"""Human decisions on approval requests."""
|
||||
|
||||
__tablename__ = "approval_decisions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
approval_request_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("approval_requests.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
action: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decided_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ApprovalDecisionRow id={self.id!r} action={self.action!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# artifacts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ArtifactRow(Base):
|
||||
"""Content-addressed output artifacts from phases."""
|
||||
|
||||
__tablename__ = "artifacts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("run_id", "path", "hash", name="uq_artifacts_run_path_hash"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
# phase_id references run_phases.id; CASCADE so artifacts are deleted with the phase.
|
||||
phase_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("run_phases.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
schema_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
valid: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
validation_error: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ArtifactRow id={self.id!r} path={self.path!r} valid={self.valid!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# interactive_sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InteractiveSessionRow(Base):
|
||||
"""Interactive (non-run) agent sessions."""
|
||||
|
||||
__tablename__ = "interactive_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
# FK to agent_personas — RESTRICT prevents deleting a persona that has interactive sessions.
|
||||
persona_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("agent_personas.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
persona_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
started_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ended_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_message_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
state: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<InteractiveSessionRow id={self.id!r} state={self.state!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tool_calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ToolCallRow(Base):
|
||||
"""Audit log of every tool invocation (run or interactive)."""
|
||||
|
||||
__tablename__ = "tool_calls"
|
||||
__table_args__ = (Index("tool_calls_run_id_ts_idx", "run_id", "ts"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# run_id / phase_id / interactive_session_id: exactly one must be non-NULL per row,
|
||||
# but all three are nullable because tool_calls covers both run and interactive contexts.
|
||||
# CASCADE ensures audit rows are removed when the parent run or session is deleted.
|
||||
run_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
phase_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("run_phases.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
interactive_session_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("interactive_sessions.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
tool_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
args: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
result: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
duration_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
ts: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ToolCallRow id={self.id!r} tool_name={self.tool_name!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# llm_calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LlmCallRow(Base):
|
||||
"""Full LLM call telemetry: tokens, cost, latency, model."""
|
||||
|
||||
__tablename__ = "llm_calls"
|
||||
__table_args__ = (
|
||||
Index("llm_calls_run_id_ts_idx", "run_id", "ts"),
|
||||
Index("llm_calls_interactive_session_id_ts_idx", "interactive_session_id", "ts"),
|
||||
Index("llm_calls_model_ts_idx", "model", "ts"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# run_id / phase_id / interactive_session_id: exactly one must be non-NULL per row,
|
||||
# but all three are nullable because llm_calls covers both run and interactive contexts.
|
||||
# CASCADE ensures telemetry rows are removed when the parent run or session is deleted.
|
||||
run_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
phase_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("run_phases.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
interactive_session_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("interactive_sessions.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
thread_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
persona_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
persona_version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
model: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
role: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
turn_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
cached_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
reasoning_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
cost_usd_input: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
cost_usd_output: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
cost_usd_total: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
latency_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
error_code: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
request_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ts: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<LlmCallRow id={self.id!r} model={self.model!r} status={self.status!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# model_pricing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModelPricingRow(Base):
|
||||
"""Cached model pricing data (fetched from provider APIs)."""
|
||||
|
||||
__tablename__ = "model_pricing"
|
||||
|
||||
model: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
input_per_1k_usd: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
output_per_1k_usd: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
context_length: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
fetched_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
raw_payload: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ModelPricingRow model={self.model!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# budget_ledger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BudgetLedgerRow(Base):
|
||||
"""Per-scope budget tracking (e.g. global, per-run, per-persona)."""
|
||||
|
||||
__tablename__ = "budget_ledger"
|
||||
|
||||
scope: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
spent_usd: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
cap_usd: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
last_updated: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BudgetLedgerRow scope={self.scope!r} spent_usd={self.spent_usd!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# persona_consents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PersonaConsentRow(Base):
|
||||
"""Persisted persona consent decisions (approve/block)."""
|
||||
|
||||
__tablename__ = "persona_consents"
|
||||
|
||||
persona_hash: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
persona_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
persona_version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
decision: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
decided_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PersonaConsentRow persona_hash={self.persona_hash!r} decision={self.decision!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# phase_feedback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PhaseFeedbackRow(Base):
|
||||
"""User feedback on completed phases (reaction + optional comment)."""
|
||||
|
||||
__tablename__ = "phase_feedback"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# CASCADE: feedback is deleted when the run is deleted (audit data follows the run lifecycle).
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
# CASCADE: feedback is deleted when the phase is deleted.
|
||||
phase_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("run_phases.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
reaction: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PhaseFeedbackRow id={self.id!r} run_id={self.run_id!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_commands (schema-only; used in future steps)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunCommandRow(Base):
|
||||
"""Queued commands targeting a run (pause, resume, abort, etc.)."""
|
||||
|
||||
__tablename__ = "run_commands"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
command: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
processed_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RunCommandRow id={self.id!r} run_id={self.run_id!r} command={self.command!r}>"
|
||||
Reference in New Issue
Block a user