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:
chungyeong
2026-05-15 19:40:02 +09:00
parent 1fe59d16ca
commit 17ba5d723b
100 changed files with 12408 additions and 0 deletions

View 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"]

View 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

View 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()

View 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}>"