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,3 @@
"""my-deepagent: workflow harness + persona library + OpenRouter on top of deepagents."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,150 @@
"""Artifact schema registry. Loads JSON Schema 2020-12 documents and validates artifacts.
Schemas live at:
{data_dir}/artifacts/<schema_id>.json (user)
docs/schemas/artifacts/<schema_id>.json (seed)
where <schema_id> is "<domain>/<name>@<version>" (e.g. "dev/spec@1").
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator, ValidationError
from jsonschema.exceptions import SchemaError
from .enums import ErrorClass
from .errors import MyDeepAgentError
@dataclass(frozen=True)
class ValidationFinding:
"""One JSON Schema validation error in a structured form."""
path: str # JSON pointer-ish: "/findings/0/severity"
message: str
validator: str # "enum", "required", "type", ...
expected: Any | None
@dataclass(frozen=True)
class ValidationResult:
ok: bool
errors: tuple[ValidationFinding, ...] = field(default_factory=tuple)
class ArtifactSchemaRegistry:
"""Loads + caches JSON Schema 2020-12 documents from one or more roots.
Roots are searched in order; first hit wins.
"""
def __init__(self, roots: list[Path]) -> None:
if not roots:
raise MyDeepAgentError(
ErrorClass.FATAL,
"config_invalid",
message="ArtifactSchemaRegistry requires at least one root",
)
self._roots = [Path(r) for r in roots]
self._cache: dict[str, dict[str, Any]] = {}
self._validator_cache: dict[str, Draft202012Validator] = {}
def _resolve_path(self, schema_id: str) -> Path:
"""Try each root for <root>/<schema_id>.json; return first existing."""
if not schema_id or "/" not in schema_id:
raise MyDeepAgentError(
ErrorClass.FATAL,
"artifact_schema_unknown",
message=(
f"invalid schema_id format: {schema_id!r}"
" (expected '<domain>/<name>@<version>')"
),
)
rel = Path(f"{schema_id}.json")
for root in self._roots:
candidate = root / rel
if candidate.is_file():
return candidate
raise MyDeepAgentError(
ErrorClass.FATAL,
"artifact_schema_unknown",
message=(f"schema not found: {schema_id} (searched: {[str(r) for r in self._roots]})"),
recovery_hint=f"add {schema_id}.json to one of the registry roots",
)
def load(self, schema_id: str) -> dict[str, Any]:
"""Return the parsed schema document. Cached after first load."""
if schema_id in self._cache:
return self._cache[schema_id]
path = self._resolve_path(schema_id)
try:
raw = path.read_text(encoding="utf-8")
schema: Any = json.loads(raw)
except (OSError, json.JSONDecodeError) as e:
raise MyDeepAgentError(
ErrorClass.FATAL,
"artifact_schema_load_failed",
message=f"failed to load schema {schema_id} from {path}: {e}",
cause=e,
) from e
if not isinstance(schema, dict):
raise MyDeepAgentError(
ErrorClass.FATAL,
"artifact_schema_load_failed",
message=f"schema {schema_id} must be a JSON object at {path}",
)
# Verify the schema document itself is a valid Draft 2020-12 schema.
try:
Draft202012Validator.check_schema(schema)
except SchemaError as e:
raise MyDeepAgentError(
ErrorClass.FATAL,
"artifact_schema_load_failed",
message=(f"schema {schema_id} is not a valid Draft 2020-12 schema: {e.message}"),
cause=e,
) from e
self._cache[schema_id] = schema
return schema
def _validator(self, schema_id: str) -> Draft202012Validator:
if schema_id not in self._validator_cache:
self._validator_cache[schema_id] = Draft202012Validator(self.load(schema_id))
return self._validator_cache[schema_id]
def validate(self, schema_id: str, data: Any) -> ValidationResult:
"""Validate *data* against *schema_id*.
Returns a structured :class:`ValidationResult` — never raises for
invalid data. Raises :class:`~my_deepagent.errors.MyDeepAgentError`
with code ``artifact_schema_unknown`` or ``artifact_schema_load_failed``
if the schema itself cannot be loaded.
"""
validator = self._validator(schema_id)
raw_errors: list[ValidationError] = list(validator.iter_errors(data))
if not raw_errors:
return ValidationResult(ok=True)
findings = tuple(
ValidationFinding(
path="/" + "/".join(str(p) for p in err.absolute_path),
message=err.message,
validator=str(err.validator),
expected=err.validator_value,
)
for err in raw_errors
)
return ValidationResult(ok=False, errors=findings)
def known_schema_ids(self) -> list[str]:
"""Enumerate all schemas found across all roots. Sorted, deduplicated."""
seen: set[str] = set()
for root in self._roots:
if not root.is_dir():
continue
for path in sorted(root.rglob("*.json")):
rel = path.relative_to(root).with_suffix("")
seen.add(str(rel))
return sorted(seen)

View File

@@ -0,0 +1,404 @@
"""Persona binding algorithm: auto-select, override, capability/risk validation, consent gate."""
from __future__ import annotations
import fcntl
import json
import os
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal, cast
from .enums import Backend, RiskLevel
from .errors import MyDeepAgentError
from .hash import sha256
from .persona import Persona
from .workflow import WorkflowRole, WorkflowTemplate
ConsentDecision = Literal["approve", "block", "once"]
_RISK_RANK: dict[RiskLevel, int] = {
RiskLevel.LOW: 0,
RiskLevel.MEDIUM: 1,
RiskLevel.HIGH: 2,
}
@dataclass(frozen=True)
class BackendAvailability:
"""Which backends are reachable in the current environment.
v0.1.0: openrouter availability is determined solely by API-key presence.
Other backends follow the same pattern — callers populate available_backends.
"""
available_backends: frozenset[Backend]
def is_available(self, backend: Backend) -> bool:
return backend in self.available_backends
@dataclass(frozen=True)
class BindingOverride:
"""Per-role persona override: role_id → "persona-name@version" spec string."""
persona_pinned: dict[str, str]
@classmethod
def parse(cls, raw: dict[str, str] | None) -> BindingOverride:
return cls(persona_pinned=dict(raw or {}))
@dataclass(frozen=True)
class Binding:
"""Resolved binding of a single workflow role to a concrete persona."""
role_id: str
persona: Persona
binding_hash: str
def is_persona_eligible_for_role(
persona: Persona,
role: WorkflowRole,
template: WorkflowTemplate,
) -> tuple[bool, str | None]:
"""Return (eligible, reason_if_not).
Checks three conditions in order:
1. The persona has all capabilities required by the role.
2. The persona's allowed_roles (if set) includes this role.
3. The persona's max_risk_level covers the highest phase risk for this role.
"""
required = set(role.required_capabilities)
have = set(persona.capabilities)
if not required.issubset(have):
missing = required - have
return False, f"missing capabilities: {sorted(c.value for c in missing)}"
if persona.allowed_roles is not None and role.id not in persona.allowed_roles:
return False, f"role {role.id!r} not in persona.allowed_roles"
max_phase_risk = max(
(ph.risk for ph in template.phases if ph.role == role.id),
default=RiskLevel.LOW,
)
if _RISK_RANK[max_phase_risk] > _RISK_RANK[persona.max_risk_level]:
return (
False,
(
f"phase risk {max_phase_risk.value} > "
f"persona max_risk_level {persona.max_risk_level.value}"
),
)
return True, None
def _auto_select(candidates: list[Persona], role: WorkflowRole) -> Persona:
"""Deterministic selection from eligible candidates.
Priority (ascending sort key):
1. preferred_backends index (lower = more preferred; non-preferred → last)
2. version descending (higher = newer)
3. name ascending (alphabetical tiebreak)
4. compute_hash ascending (hash tiebreak for identical name+version)
"""
def _key(p: Persona) -> tuple[int, int, str, str]:
try:
pref_idx = role.preferred_backends.index(p.backend)
except ValueError:
pref_idx = len(role.preferred_backends) + 1
return (pref_idx, -p.version, p.name, p.compute_hash())
return sorted(candidates, key=_key)[0]
class PersonaConsentStore:
"""Crash-safe + multi-process-safe JSON file store for per-persona consent decisions.
Storage: {path} -> {"<persona_hash>": {"decision": "approve|block|once", "decided_at": "..."}}
Concurrency guarantees:
* Writes are atomic via tmp-file + fsync + os.replace (POSIX rename is atomic).
* Cross-process safety via advisory ``fcntl.flock`` on a lock-file at ``{path}.lock``.
``set()`` / ``revoke()`` hold an exclusive lock for the read-modify-write cycle;
``get()`` uses a shared lock for consistent reads. This prevents lost-update
races between concurrent ``mydeepagent`` invocations on the same machine.
"""
def __init__(self, path: Path) -> None:
self._path = path
self._lock_path = path.with_suffix(path.suffix + ".lock")
@contextmanager
def _flock(self, exclusive: bool) -> Iterator[None]:
"""Acquire a POSIX advisory lock for the duration of the block."""
self._lock_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(self._lock_path, os.O_RDWR | os.O_CREAT, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
try:
yield
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
finally:
os.close(fd)
def _load(self) -> dict[str, Any]:
if not self._path.is_file():
return {}
try:
raw = self._path.read_text(encoding="utf-8")
data: object = json.loads(raw) if raw.strip() else {}
except (OSError, json.JSONDecodeError) as e:
raise MyDeepAgentError.fatal(
"internal_state_corruption",
message=f"failed to read consent store at {self._path}: {e}",
recovery_hint=(
f"delete {self._path} and re-run; "
"previously granted consents will be re-prompted"
),
cause=e,
) from e
if not isinstance(data, dict):
raise MyDeepAgentError.fatal(
"internal_state_corruption",
message=f"consent store must be a JSON object: {self._path}",
)
return data
def _write(self, data: dict[str, Any]) -> None:
"""Atomic crash-safe write. Caller must already hold the exclusive flock."""
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
payload = json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False)
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, payload.encode("utf-8"))
os.fsync(fd)
finally:
os.close(fd)
os.replace(tmp, self._path)
def get(self, persona_hash: str) -> ConsentDecision | None:
"""Return stored decision or None if absent / unrecognised."""
with self._flock(exclusive=False):
entry = self._load().get(persona_hash)
if entry is None:
return None
decision = entry.get("decision") if isinstance(entry, dict) else None
if decision not in ("approve", "block", "once"):
return None
return cast(ConsentDecision, decision)
def set(self, persona_hash: str, decision: ConsentDecision) -> None:
"""Persist a consent decision. Exclusive lock + atomic write."""
with self._flock(exclusive=True):
data = self._load()
data[persona_hash] = {
"decision": decision,
"decided_at": datetime.now(UTC).isoformat(timespec="seconds"),
}
self._write(data)
def revoke(self, persona_hash: str) -> None:
"""Remove a previously stored consent decision. Exclusive lock. No-op if absent."""
with self._flock(exclusive=True):
data = self._load()
data.pop(persona_hash, None)
self._write(data)
def filter_consented_personas(
personas: list[Persona],
consent_store: PersonaConsentStore,
) -> list[Persona]:
"""Remove personas whose consent decision is 'block'.
'approve', 'once', and absent (None) decisions all allow the persona through.
"""
return [p for p in personas if consent_store.get(p.compute_hash()) != "block"]
def _parse_override_version(pinned_spec: str, version_str: str) -> int | None:
"""Parse the version component of an override spec. None if empty, raise otherwise."""
if not version_str:
return None
try:
return int(version_str)
except ValueError as e:
raise MyDeepAgentError.human_required(
"no_eligible_persona",
message=(f"override spec '{pinned_spec}' has non-integer version '{version_str}'"),
recovery_hint="use the format '<persona-name>@<integer-version>'",
cause=e,
) from e
def _resolve_override(
role: WorkflowRole,
template: WorkflowTemplate,
pinned_spec: str,
eligible: list[Persona],
persona_pool: list[Persona],
consent_store: PersonaConsentStore,
) -> Persona:
"""Resolve an override spec to a single eligible persona or raise human_required."""
name, _, version_str = pinned_spec.partition("@")
version = _parse_override_version(pinned_spec, version_str)
matches = [p for p in eligible if p.name == name and (version is None or p.version == version)]
if matches:
return matches[0] if len(matches) == 1 else _auto_select(matches, role)
# Distinguish: blocked vs. ineligible vs. simply absent.
pool_matches = [
p for p in persona_pool if p.name == name and (version is None or p.version == version)
]
if any(consent_store.get(p.compute_hash()) == "block" for p in pool_matches):
raise MyDeepAgentError.human_required(
"persona_blocked_by_user",
message=f"override persona '{pinned_spec}' is consent-blocked",
recovery_hint="run `mydeepagent consents revoke <persona>` to clear the block",
)
if pool_matches:
_, reason = is_persona_eligible_for_role(pool_matches[0], role, template)
raise MyDeepAgentError.human_required(
"no_eligible_persona",
message=(
f"override persona '{pinned_spec}' is ineligible for role '{role.id}': {reason}"
),
)
raise MyDeepAgentError.human_required(
"no_eligible_persona",
message=f"no eligible persona matches override '{pinned_spec}' for role '{role.id}'",
)
def _resolve_auto(
role: WorkflowRole,
template: WorkflowTemplate,
eligible: list[Persona],
persona_pool: list[Persona],
consent_store: PersonaConsentStore,
) -> Persona:
"""Auto-select from eligible or raise human_required with diagnostic context."""
if eligible:
return _auto_select(eligible, role)
any_blocked = any(
is_persona_eligible_for_role(p, role, template)[0]
and consent_store.get(p.compute_hash()) == "block"
for p in persona_pool
)
if any_blocked:
raise MyDeepAgentError.human_required(
"persona_blocked_by_user",
message=(f"all eligible personas for role '{role.id}' are blocked by user consent"),
)
raise MyDeepAgentError.human_required(
"no_eligible_persona",
message=f"no eligible persona for role '{role.id}'",
recovery_hint=(
f"add a persona with capabilities "
f"{sorted(c.value for c in role.required_capabilities)} "
"to docs/schemas/personas/"
),
)
def bind_personas(
template: WorkflowTemplate,
persona_pool: list[Persona],
available_backends: BackendAvailability,
consent_store: PersonaConsentStore,
override: BindingOverride | None = None,
) -> dict[str, Binding]:
"""Bind each workflow role to a concrete persona.
Resolution order per role:
1. Apply consent filter (remove 'block' personas).
2. Apply eligibility filter (capabilities, allowed_roles, risk level).
3. If override is set for this role, pick the pinned persona from eligible.
4. Otherwise, auto_select from eligible.
5. Validate backend availability.
6. Validate openrouter model non-empty.
Raises:
MyDeepAgentError (human_required, 'no_eligible_persona') — no match found.
MyDeepAgentError (human_required, 'persona_blocked_by_user') — all candidates blocked.
MyDeepAgentError (human_required, 'backend_unavailable') — backend not in environment.
MyDeepAgentError (human_required, 'model_unavailable') — openrouter model is blank.
"""
_override = override or BindingOverride.parse(None)
consented_pool = filter_consented_personas(persona_pool, consent_store)
bindings: dict[str, Binding] = {}
for role in template.roles:
eligible: list[Persona] = [
p for p in consented_pool if is_persona_eligible_for_role(p, role, template)[0]
]
if role.id in _override.persona_pinned:
chosen = _resolve_override(
role,
template,
_override.persona_pinned[role.id],
eligible,
persona_pool,
consent_store,
)
else:
chosen = _resolve_auto(role, template, eligible, persona_pool, consent_store)
# Backend availability check
if not available_backends.is_available(chosen.backend):
raise MyDeepAgentError.human_required(
"backend_unavailable",
message=(
f"backend '{chosen.backend.value}' is not available "
f"for persona '{chosen.name}@{chosen.version}'"
),
recovery_hint=_backend_recovery_hint(chosen.backend),
)
# Openrouter model non-empty check
if chosen.backend == Backend.OPENROUTER and not chosen.model.strip():
raise MyDeepAgentError.human_required(
"model_unavailable",
message=(
f"persona '{chosen.name}@{chosen.version}' "
"has empty model for openrouter backend"
),
recovery_hint=(
"set `model:` field in the persona yaml "
"(e.g. 'openrouter:deepseek/deepseek-chat')"
),
)
binding_hash = sha256(
{
"role_id": role.id,
"template_name": template.name,
"template_version": template.version,
"persona_hash": chosen.compute_hash(),
"backend": chosen.backend.value,
}
)
bindings[role.id] = Binding(role_id=role.id, persona=chosen, binding_hash=binding_hash)
return bindings
def _backend_recovery_hint(backend: Backend) -> str:
if backend == Backend.OPENROUTER:
return "run `mydeepagent login openrouter` to register an API key"
if backend in (Backend.ANTHROPIC, Backend.OPENAI, Backend.GOOGLE):
return f"run `mydeepagent login {backend.value}` to register an API key"
if backend == Backend.FAKE:
return (
"the 'fake' backend is for tests only; "
"add Backend.FAKE to the BackendAvailability set in your test harness"
)
return f"enable backend '{backend.value}' in config and ensure prerequisites"

View File

@@ -0,0 +1 @@
"""CLI doctor command for environment diagnostics. Implemented in Step 12."""

View File

@@ -0,0 +1 @@
"""CLI interactive subcommand. Implemented in Step 10."""

View File

@@ -0,0 +1 @@
"""Typer CLI entry point. Filled in Step 6."""

View File

@@ -0,0 +1 @@
"""CLI run command implementation. Implemented in Step 6."""

View File

@@ -0,0 +1 @@
"""CLI seed command for importing persona/workflow YAML assets. Implemented in Step 6."""

View File

@@ -0,0 +1 @@
"""CLI stats command for usage summary. Implemented in Step 12."""

View File

@@ -0,0 +1,109 @@
"""Application configuration loaded from env, .env, and TOML file via pydantic-settings."""
from __future__ import annotations
from pathlib import Path
from typing import Literal
from platformdirs import PlatformDirs
from pydantic import Field, ValidationError, field_validator
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
TomlConfigSettingsSource,
)
from .enums import ErrorClass
from .errors import MyDeepAgentError
_DIRS = PlatformDirs("my-deepagent", "user", roaming=False)
class Config(BaseSettings):
"""Frozen application config. Source priority (high -> low): CLI/env, .env, TOML, defaults."""
model_config = SettingsConfigDict(
env_prefix="MYDEEPAGENT_",
env_file=".env",
env_file_encoding="utf-8",
toml_file=Path(_DIRS.user_config_dir) / "config.toml",
frozen=True,
extra="ignore",
)
# storage
database_url: str = Field(
default_factory=lambda: (
f"sqlite+aiosqlite:///{Path(_DIRS.user_data_dir) / 'database.sqlite3'}"
)
)
workspace_root: Path = Field(default_factory=Path.cwd)
data_dir: Path = Field(default_factory=lambda: Path(_DIRS.user_data_dir))
config_dir: Path = Field(default_factory=lambda: Path(_DIRS.user_config_dir))
state_dir: Path = Field(default_factory=lambda: Path(_DIRS.user_state_dir))
# logging / i18n
log_level: Literal["trace", "debug", "info", "warn", "error"] = "info"
lang: Literal["ko", "en"] = "ko"
# providers
openrouter_api_key: str | None = None
openrouter_base_url: str = "https://openrouter.ai/api/v1"
# observability
langsmith_tracing: bool = False
langsmith_api_key: str | None = None
langsmith_project: str = "my-deepagent"
# budget
budget_daily_usd: float = Field(default=5.0, ge=0)
budget_daily_warn_usd: float = Field(default=3.0, ge=0)
budget_run_usd: float = Field(default=1.0, ge=0)
budget_run_warn_usd: float = Field(default=0.5, ge=0)
budget_on_hit: Literal["prompt", "block", "warn_continue"] = "prompt"
# defaults
default_persona: str = "default-interactive"
@field_validator("workspace_root", "data_dir", "config_dir", "state_dir")
@classmethod
def _expand(cls, v: Path) -> Path:
return Path(v).expanduser().resolve()
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
# priority: init > env > dotenv > toml > defaults
return (
init_settings,
env_settings,
dotenv_settings,
TomlConfigSettingsSource(settings_cls),
file_secret_settings,
)
def load_config(**overrides: object) -> Config:
"""Load Config with optional kwargs override.
Wraps pydantic ValidationError in MyDeepAgentError(fatal, config_invalid) per plan §18.
"""
try:
return Config(**overrides) # type: ignore[arg-type]
except ValidationError as e:
raise MyDeepAgentError(
ErrorClass.FATAL,
"config_invalid",
message=f"config validation failed: {e}",
recovery_hint=(
"check .env, environment variables, and ~/.config/my-deepagent/config.toml"
),
cause=e,
) from e

View File

@@ -0,0 +1 @@
"""LangGraph run engine orchestrator. Implemented in Step 7."""

View File

@@ -0,0 +1,92 @@
"""All closed-set enums used across the codebase."""
from enum import StrEnum
class Backend(StrEnum):
OPENROUTER = "openrouter"
ANTHROPIC = "anthropic"
OPENAI = "openai"
GOOGLE = "google"
FAKE = "fake"
class Capability(StrEnum):
SPEC_WRITE = "spec_write"
PHASE_PLANNING = "phase_planning"
TASK_DAG_PLANNING = "task_dag_planning"
CODE_EDIT = "code_edit"
TEST_FIRST_DEVELOPMENT = "test_first_development"
CODE_REVIEW = "code_review"
EVIDENCE_CHECK = "evidence_check"
COMMAND_EXECUTE = "command_execute"
BACKTEST_RUN = "backtest_run"
METRIC_EXTRACT = "metric_extract"
FAILURE_MINING = "failure_mining"
OBJECTIVE_EVAL = "objective_eval"
FINAL_REPORT_COMPOSE = "final_report_compose"
class RiskLevel(StrEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class ApprovalDecisionAction(StrEnum):
APPROVE = "approve"
REJECT = "reject"
REQUEST_CHANGES = "request_changes"
ABORT = "abort"
class ApprovalState(StrEnum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
CHANGES_REQUESTED = "changes_requested"
ABORTED = "aborted"
PAUSED = "paused"
class RunState(StrEnum):
CREATED = "created"
BOUND = "bound"
PLANNING = "planning"
AWAITING_APPROVAL = "awaiting_approval"
EXECUTING = "executing"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
ABORTED = "aborted"
class RunPhaseState(StrEnum):
PENDING = "pending"
RUNNING = "running"
AWAITING_ARTIFACT = "awaiting_artifact"
VALIDATING = "validating"
AWAITING_APPROVAL = "awaiting_approval"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
class SessionState(StrEnum):
CREATED = "CREATED"
BOOTSTRAPPING = "BOOTSTRAPPING"
READY = "READY"
BUSY = "BUSY"
WAITING_FOR_APPROVAL = "WAITING_FOR_APPROVAL"
ARTIFACT_TIMEOUT = "ARTIFACT_TIMEOUT"
HUNG = "HUNG"
CRASHED = "CRASHED"
RESUMING = "RESUMING"
REBOOTSTRAPPED = "REBOOTSTRAPPED"
FAILED_NEEDS_HUMAN = "FAILED_NEEDS_HUMAN"
class ErrorClass(StrEnum):
RECOVERABLE = "recoverable"
HUMAN_REQUIRED = "human_required"
FATAL = "fatal"

View File

@@ -0,0 +1,79 @@
"""Domain errors. All exceptions raised by my-deepagent inherit MyDeepAgentError."""
from __future__ import annotations
from uuid import UUID
from .enums import ErrorClass
class MyDeepAgentError(Exception):
"""Base error with structured fields for classification, recovery hint, and context."""
def __init__(
self,
error_class: ErrorClass,
code: str,
*,
message: str | None = None,
run_id: UUID | None = None,
phase_id: UUID | None = None,
recovery_hint: str | None = None,
cause: BaseException | None = None,
) -> None:
super().__init__(message or code)
self.error_class = error_class
self.code = code
self.run_id = run_id
self.phase_id = phase_id
self.recovery_hint = recovery_hint
if cause is not None:
self.__cause__ = cause
self.__suppress_context__ = True
def __repr__(self) -> str:
parts = [f"class={self.error_class}", f"code={self.code}"]
if self.run_id is not None:
parts.append(f"run_id={self.run_id}")
if self.phase_id is not None:
parts.append(f"phase_id={self.phase_id}")
if self.recovery_hint:
parts.append(f"hint={self.recovery_hint!r}")
return f"MyDeepAgentError({', '.join(parts)})"
@classmethod
def recoverable(cls, code: str, **kwargs: object) -> MyDeepAgentError:
return MyDeepAgentError(ErrorClass.RECOVERABLE, code, **kwargs) # type: ignore[arg-type]
@classmethod
def human_required(cls, code: str, **kwargs: object) -> MyDeepAgentError:
return MyDeepAgentError(ErrorClass.HUMAN_REQUIRED, code, **kwargs) # type: ignore[arg-type]
@classmethod
def fatal(cls, code: str, **kwargs: object) -> MyDeepAgentError:
return MyDeepAgentError(ErrorClass.FATAL, code, **kwargs) # type: ignore[arg-type]
class BudgetExhaustedError(MyDeepAgentError):
"""Budget cap hit. Raised by BudgetTracker.assert_can_call when on_hit='block'."""
def __init__(
self,
scope: str,
projected_usd: float,
cap_usd: float,
*,
run_id: UUID | None = None,
recovery_hint: str | None = None,
) -> None:
super().__init__(
ErrorClass.HUMAN_REQUIRED,
"budget_exhausted",
message=f"budget '{scope}' exhausted: projected={projected_usd:.4f} cap={cap_usd:.4f}",
run_id=run_id,
recovery_hint=recovery_hint
or f"wait until the next period or extend the cap for scope '{scope}'",
)
self.scope = scope
self.projected_usd = projected_usd
self.cap_usd = cap_usd

View File

@@ -0,0 +1,28 @@
"""Canonical JSON serialization + sha256 hashing for content-addressed identity."""
from __future__ import annotations
import hashlib
import json
from typing import Any
def canonicalize(value: Any) -> str:
"""Return canonical JSON: keys sorted, no insignificant whitespace, UTF-16 codepoint order.
json.dumps with sort_keys=True uses Python's default dict key sort which is by Unicode
codepoint. For ASCII keys this is equivalent to UTF-16 codepoint order which is what
we want. For non-ASCII keys outside the BMP, this is a documented approximation.
"""
return json.dumps(
value,
sort_keys=True,
ensure_ascii=False,
separators=(",", ":"),
allow_nan=False,
)
def sha256(value: Any) -> str:
"""Return sha256 hex digest of canonical JSON of value."""
return hashlib.sha256(canonicalize(value).encode("utf-8")).hexdigest()

View File

@@ -0,0 +1 @@
"""Interactive REPL loop for TUI sessions. Implemented in Step 10."""

View File

@@ -0,0 +1,73 @@
"""AuditToolMiddleware: capture every tool call for audit log + DB.
Records: name, args, result/error, duration.
"""
from __future__ import annotations
import time
from typing import Any
from uuid import UUID
from langchain.agents.middleware import AgentMiddleware
class AuditToolMiddleware(AgentMiddleware):
"""Record every tool invocation for the audit log and DB sink (Step 8)."""
def __init__(
self,
run_id: UUID | None = None,
phase_id: UUID | None = None,
interactive_session_id: UUID | None = None,
recorder: Any | None = None,
) -> None:
super().__init__()
self.run_id = run_id
self.phase_id = phase_id
self.interactive_session_id = interactive_session_id
self.recorder = recorder
async def awrap_tool_call(self, request: Any, handler: Any) -> Any:
started = time.perf_counter()
# ToolCallRequest exposes tool_call dict with 'name' and 'args'
tool_call = getattr(request, "tool_call", {}) or {}
name: str = tool_call.get("name", "unknown") if isinstance(tool_call, dict) else "unknown"
args: dict[str, Any] = (
tool_call.get("args", {}) if isinstance(tool_call, dict) else {}
) or {}
try:
result = await handler(request)
except Exception as e:
await self._record(name, args, None, type(e).__name__, started)
raise
await self._record(name, args, result, None, started)
return result
async def _record(
self,
name: str,
args: dict[str, Any],
result: Any,
error: str | None,
started: float,
) -> None:
if self.recorder is None:
return
serializable_result: str | int | float | bool | dict[str, Any] | list[Any] | None
if isinstance(result, (str, int, float, bool, dict, list)) or result is None:
serializable_result = result
else:
serializable_result = str(result)
await self.recorder(
{
"tool_name": name,
"args": args,
"result": serializable_result,
"error": error,
"duration_ms": int((time.perf_counter() - started) * 1000),
"run_id": self.run_id,
"phase_id": self.phase_id,
"interactive_session_id": self.interactive_session_id,
}
)

View File

@@ -0,0 +1,87 @@
"""CostMiddleware: capture every LLM call's usage and accumulate cost into the SQLite ledger."""
from __future__ import annotations
import time
from typing import Any
from uuid import UUID
from langchain.agents.middleware import AgentMiddleware
from ..monitoring.pricing import PricingCache
class CostMiddleware(AgentMiddleware):
"""Wrap every model call. Compute cost from usage_metadata and persist.
Step 8 wires the DB writer via the recorder callback.
"""
def __init__(
self,
pricing: PricingCache,
model_name: str,
run_id: UUID | None = None,
phase_id: UUID | None = None,
persona_name: str | None = None,
recorder: Any | None = None, # callable(record) -> Awaitable[None] for DB sink (Step 8)
) -> None:
super().__init__()
self.pricing = pricing
self.model_name = model_name
self.run_id = run_id
self.phase_id = phase_id
self.persona_name = persona_name
self.recorder = recorder
async def awrap_model_call(self, request: Any, handler: Any) -> Any:
started = time.perf_counter()
try:
response = await handler(request)
except Exception as e:
await self._record(
input_tokens=0,
output_tokens=0,
latency_ms=int((time.perf_counter() - started) * 1000),
status="error",
error_code=type(e).__name__,
)
raise
usage = getattr(response, "usage_metadata", None) or {}
in_tokens = int(usage.get("input_tokens", 0) or 0)
out_tokens = int(usage.get("output_tokens", 0) or 0)
await self._record(
input_tokens=in_tokens,
output_tokens=out_tokens,
latency_ms=int((time.perf_counter() - started) * 1000),
status="ok",
error_code=None,
)
return response
async def _record(
self,
*,
input_tokens: int,
output_tokens: int,
latency_ms: int,
status: str,
error_code: str | None,
) -> None:
if self.recorder is None:
return
cost = self.pricing.compute_cost(self.model_name, input_tokens, output_tokens)
await self.recorder(
{
"model": self.model_name,
"run_id": self.run_id,
"phase_id": self.phase_id,
"persona_name": self.persona_name,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cost_usd_total": cost,
"latency_ms": latency_ms,
"status": status,
"error_code": error_code,
}
)

View File

@@ -0,0 +1,47 @@
"""FallbackModelMiddleware: retry the model call with a different model on transient HTTP errors."""
from __future__ import annotations
from typing import Any
import httpx
import openai
from langchain.agents.middleware import AgentMiddleware
class FallbackModelMiddleware(AgentMiddleware):
"""When the primary model raises a transient error, retry once with the fallback model.
Transient = HTTP 429, 5xx, network errors. Auth (401/AuthenticationError) and bad request
(400 model_not_found) are not retried — those need human intervention.
"""
def __init__(self, primary: Any, fallback: Any | None) -> None:
super().__init__()
self.primary = primary
self.fallback = fallback
async def awrap_model_call(self, request: Any, handler: Any) -> Any:
try:
return await handler(request)
except openai.AuthenticationError:
# 401 is human_required, not retryable.
raise
except (httpx.HTTPError, openai.RateLimitError, openai.APIConnectionError):
if self.fallback is None:
raise
# Best-effort: swap the model bound to the request and retry once.
patched = self._with_fallback_model(request)
return await handler(patched)
def _with_fallback_model(self, request: Any) -> Any:
"""Swap the bound model in the request for the fallback model.
ModelRequest exposes a `model` attribute (BaseChatModel instance).
We replace it with the fallback. The original request object is mutated
in place because ModelRequest.__setattr__ triggers a DeprecationWarning
only on ToolCallRequest; ModelRequest is a plain dataclass that allows assignment.
"""
if hasattr(request, "model"):
request.model = self.fallback
return request

View File

@@ -0,0 +1,126 @@
"""SafetyShellMiddleware: destructive command + secret-path enforcement at the tool layer.
Replaces deepagents.FilesystemPermission for personas using LocalShellBackend,
since deepagents 0.6.1 does not yet support permissions + execution-capable backends.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from langchain.agents.middleware import AgentMiddleware
from wcmatch import glob as wcglob
from ..errors import MyDeepAgentError
DESTRUCTIVE_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
re.compile(p, re.IGNORECASE)
for p in (
r"\brm\s+-rf\b",
r"\bgit\s+reset\s+--hard\b",
r"\bgit\s+clean\b",
r"\bgit\s+push\s+--force(-with-lease)?\b",
r"\bgit\s+branch\s+-D\b",
r"\bdocker\s+volume\s+rm\b",
r"\bdocker\s+compose\s+down\s+-v\b",
r"\bDROP\s+(DATABASE|SCHEMA|TABLE)\b",
)
)
# Mirrors session.DEFAULT_DENY_PATHS but as relative glob patterns for wcmatch.
# Each sensitive directory is listed twice: once for the directory itself (no trailing
# slash — Path normalises it away) and once for everything inside it (**).
DENY_PATH_PATTERNS: tuple[str, ...] = (
"**/.env*",
"**/*.env*",
"**/*token*",
"**/*secret*",
"**/*credential*",
"**/*.pem",
"**/*.key",
"**/.ssh",
"**/.ssh/**",
"**/.aws",
"**/.aws/**",
"**/.config/gcloud",
"**/.config/gcloud/**",
"**/.kube",
"**/.kube/**",
"**/.gnupg",
"**/.gnupg/**",
)
_PATH_TOOLS: frozenset[str] = frozenset({"read_file", "write_file", "edit_file", "ls"})
# Tool names that carry shell commands.
_SHELL_TOOL_NAMES: frozenset[str] = frozenset({"shell", "execute", "run_command"})
_GLOB_FLAGS = wcglob.GLOBSTAR | wcglob.IGNORECASE | wcglob.DOTGLOB
def _is_denied_path(path: str) -> bool:
"""Return True iff the path matches any deny glob pattern."""
normalized = str(Path(path)).replace("\\", "/").lstrip("/")
for pat in DENY_PATH_PATTERNS:
if wcglob.globmatch(normalized, pat, flags=_GLOB_FLAGS):
return True
return False
class SafetyShellMiddleware(AgentMiddleware):
"""Hard-block destructive shell commands and secret-path file ops at the tool layer."""
async def awrap_tool_call(self, request: Any, handler: Any) -> Any:
name = self._tool_name(request)
args = self._tool_args(request)
if name in _SHELL_TOOL_NAMES:
self._check_shell(args)
elif name in _PATH_TOOLS:
self._check_path(name, args)
return await handler(request)
@staticmethod
def _tool_name(request: Any) -> str:
tool_call = getattr(request, "tool_call", None)
if isinstance(tool_call, dict):
return str(tool_call.get("name") or "")
return str(getattr(request, "name", "") or "")
@staticmethod
def _tool_args(request: Any) -> dict[str, Any]:
tool_call = getattr(request, "tool_call", None)
if isinstance(tool_call, dict):
return dict(tool_call.get("args") or {})
args = getattr(request, "args", None)
return dict(args) if isinstance(args, dict) else {}
def _check_shell(self, args: dict[str, Any]) -> None:
cmd = args.get("command") or args.get("argv") or ""
if isinstance(cmd, list):
cmd = " ".join(str(x) for x in cmd)
cmd_str = str(cmd)
for pat in DESTRUCTIVE_PATTERNS:
if pat.search(cmd_str):
raise MyDeepAgentError.human_required(
"destructive_command_blocked",
message=f"destructive shell command blocked: {cmd_str[:120]}",
recovery_hint=(
"this command is hard-blocked by my-deepagent's safety policy; "
"edit the persona system_prompt to avoid suggesting it"
),
)
def _check_path(self, tool_name: str, args: dict[str, Any]) -> None:
path = args.get("file_path") or args.get("path") or args.get("file") or ""
if not isinstance(path, str) or not path:
return
if _is_denied_path(path):
raise MyDeepAgentError.human_required(
"secret_access_blocked",
message=(f"access to secret-bearing path blocked: tool={tool_name} path={path!r}"),
recovery_hint=(
"this path matches a hard-blocked deny pattern (e.g. .env, *.key, .ssh/, .aws/)"
),
)

View File

@@ -0,0 +1 @@
"""LangSmith tracing integration helpers. Implemented in Step 12."""

View File

@@ -0,0 +1,99 @@
"""OpenRouter model pricing cache + cost computation.
v0.1.0: in-process dict cache + optional DB refresh. doctor와 background refresh가
업데이트 trigger (Step 12).
"""
from __future__ import annotations
from dataclasses import dataclass
import httpx
from ..errors import MyDeepAgentError
@dataclass(frozen=True)
class ModelPrice:
model: str # OpenRouter id, e.g. "deepseek/deepseek-chat"
input_per_1k_usd: float
output_per_1k_usd: float
context_length: int
class PricingCache:
"""In-memory cache of OpenRouter pricing. Caller refreshes via fetch_openrouter_pricing()."""
def __init__(self) -> None:
self._cache: dict[str, ModelPrice] = {}
def get(self, model: str) -> ModelPrice | None:
key = model.removeprefix("openrouter:")
return self._cache.get(key)
def set(self, prices: list[ModelPrice]) -> None:
for p in prices:
self._cache[p.model] = p
def compute_cost(self, model: str, input_tokens: int, output_tokens: int) -> float:
"""Return USD cost. Returns 0.0 if model price is unknown (logged separately)."""
price = self.get(model)
if price is None:
return 0.0
return (input_tokens / 1000.0) * price.input_per_1k_usd + (
output_tokens / 1000.0
) * price.output_per_1k_usd
async def fetch_openrouter_pricing(api_key: str, base_url: str) -> list[ModelPrice]:
"""Fetch the OpenRouter /models endpoint and parse pricing."""
async with httpx.AsyncClient(timeout=10.0) as client:
try:
r = await client.get(
f"{base_url}/models",
headers={"Authorization": f"Bearer {api_key}"},
)
r.raise_for_status()
except httpx.HTTPError as e:
raise MyDeepAgentError.recoverable(
"network_blip",
message=f"failed to fetch openrouter pricing: {e}",
cause=e,
) from e
data: dict[str, object] = r.json()
return _parse_pricing_payload(data)
def _parse_pricing_payload(data: dict[str, object]) -> list[ModelPrice]:
"""Parse OpenRouter response.
Expected format::
{"data": [{"id": "...", "pricing": {"prompt": "...", "completion": "..."}, ...}]}
"""
models = data.get("data", [])
if not isinstance(models, list):
return []
out: list[ModelPrice] = []
for m in models:
if not isinstance(m, dict):
continue
model_id = m.get("id")
pricing = m.get("pricing") or {}
if not isinstance(model_id, str) or not isinstance(pricing, dict):
continue
try:
prompt_per_token = float(pricing.get("prompt", "0") or "0")
completion_per_token = float(pricing.get("completion", "0") or "0")
ctx_len = int(m.get("context_length", 0) or 0)
except (TypeError, ValueError):
continue
out.append(
ModelPrice(
model=model_id,
input_per_1k_usd=prompt_per_token * 1000.0,
output_per_1k_usd=completion_per_token * 1000.0,
context_length=ctx_len,
)
)
return out

View File

@@ -0,0 +1 @@
"""Run statistics aggregation and reporting. Implemented in Step 12."""

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

View File

@@ -0,0 +1,154 @@
"""Persona schema + YAML loader + content-addressed hash + consent helpers."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
from .enums import Backend, Capability, RiskLevel
from .hash import sha256
class FilesystemPermissionSpec(BaseModel):
"""1:1 mapping to deepagents FilesystemPermission TypedDict."""
model_config = ConfigDict(frozen=True, extra="forbid")
operations: tuple[Literal["read", "write", "edit", "ls"], ...] = Field(min_length=1)
paths: tuple[str, ...] = Field(min_length=1)
mode: Literal["allow", "deny"] = "allow"
@field_validator("paths")
@classmethod
def _validate_paths(cls, v: tuple[str, ...]) -> tuple[str, ...]:
for p in v:
if not p.startswith("/"):
raise ValueError(f"path must start with '/': {p!r}")
if "\x00" in p:
raise ValueError(f"path must not contain null bytes: {p!r}")
# Check for literal ".." segment — glob paths like "/**" are OK
segments = p.split("/")
if ".." in segments:
raise ValueError(f"path must not contain '..': {p!r}")
if "~" in p:
raise ValueError(f"path must not contain '~': {p!r}")
return v
class PersonaSubagent(BaseModel):
"""1:1 mapping to deepagents SubAgent TypedDict."""
model_config = ConfigDict(frozen=True, extra="forbid")
name: str = Field(min_length=1)
description: str = Field(min_length=10)
system_prompt: str = Field(min_length=10)
allowed_tools: tuple[str, ...] = Field(default_factory=tuple)
model: str | None = None
permissions: tuple[FilesystemPermissionSpec, ...] = Field(default_factory=tuple)
# deepagents accepts dict[str, Any] for interrupt_on — intentional Any
interrupt_on: dict[str, Any] = Field(default_factory=dict)
class Persona(BaseModel):
"""Persona definition from docs/schemas/personas/<name>@<version>.yaml.
Immutability: list-valued fields are stored as tuples to prevent post-construction
mutation that would invalidate compute_hash(). dict-valued fields (model_params,
interrupt_on) remain dict because they are pass-through to deepagents which expects
``dict[str, Any]``; callers must not mutate them.
"""
model_config = ConfigDict(frozen=True, extra="forbid")
name: str = Field(min_length=1)
version: int = Field(ge=1)
description: str | None = None
backend: Backend
model: str = Field(min_length=1)
provider_origin: str = Field(min_length=1)
capabilities: tuple[Capability, ...] = Field(min_length=1)
max_risk_level: RiskLevel
allowed_roles: tuple[str, ...] | None = None
system_prompt: str = Field(min_length=10)
allowed_tools: tuple[str, ...] | None = None
subagents: tuple[PersonaSubagent, ...] = Field(default_factory=tuple)
permissions: tuple[FilesystemPermissionSpec, ...] = Field(default_factory=tuple)
# deepagents accepts dict[str, Any] for interrupt_on — intentional Any
interrupt_on: dict[str, Any] | None = None
# deepagents accepts dict[str, Any] for model_params — intentional Any
model_params: dict[str, Any] = Field(default_factory=dict)
deepagents_backend: Literal["state", "local_shell", "filesystem", "composite", "langsmith"] = (
"local_shell"
)
skills: tuple[str, ...] = Field(default_factory=tuple)
memory_files: tuple[str, ...] = Field(default_factory=tuple)
fallback_model: str | None = None
max_cost_per_call_usd: float | None = Field(default=None, ge=0)
@field_validator("model")
@classmethod
def _validate_openrouter_model(cls, v: str, info: ValidationInfo) -> str:
backend = info.data.get("backend") if info.data else None
if backend == Backend.OPENROUTER and not v.strip():
raise ValueError("openrouter backend requires non-empty model")
return v
def compute_hash(self) -> str:
"""Content-addressed identity hash (canonical JSON of normalized fields)."""
return sha256(
{
"name": self.name,
"version": self.version,
"backend": self.backend.value,
"model": self.model,
"provider_origin": self.provider_origin,
"capabilities": sorted(c.value for c in self.capabilities),
"max_risk_level": self.max_risk_level.value,
"allowed_roles": (
sorted(self.allowed_roles) if self.allowed_roles is not None else None
),
"system_prompt": self.system_prompt,
"allowed_tools": (
sorted(self.allowed_tools) if self.allowed_tools is not None else None
),
"subagents": [s.model_dump() for s in self.subagents],
"permissions": [p.model_dump() for p in self.permissions],
"interrupt_on": self.interrupt_on,
"model_params": self.model_params,
"deepagents_backend": self.deepagents_backend,
"fallback_model": self.fallback_model,
"max_cost_per_call_usd": self.max_cost_per_call_usd,
"skills": self.skills,
"memory_files": self.memory_files,
}
)
def load_persona_yaml(path: Path) -> Persona:
"""Load and validate a single persona yaml file."""
if not path.is_file():
raise FileNotFoundError(f"persona yaml not found: {path}")
data = yaml.safe_load(path.read_text(encoding="utf-8"))
return Persona.model_validate(data)
def load_personas_from_dir(directory: Path) -> list[Persona]:
"""Load all *.yaml files from a directory, sorted by filename for determinism.
Raises ValueError if the same (name, version) pair appears more than once.
Returns an empty list if the directory does not exist.
"""
if not directory.is_dir():
return []
personas = [load_persona_yaml(p) for p in sorted(directory.glob("*.yaml"))]
seen: dict[tuple[str, int], str] = {}
for p in personas:
key = (p.name, p.version)
if key in seen:
raise ValueError(f"duplicate persona name={p.name!r} version={p.version}")
seen[key] = p.compute_hash()
return personas

View File

@@ -0,0 +1 @@
"""Prompt envelope builder for LangChain messages. Implemented in Step 5."""

View File

View File

@@ -0,0 +1 @@
"""Run event types for streaming progress. Implemented in Step 4."""

View File

@@ -0,0 +1 @@
"""Safety gate for destructive command classification. Implemented in Step 11."""

View File

@@ -0,0 +1,274 @@
"""Build a deepagents CompiledStateGraph from a Persona + run context.
Connects:
- Persona (config) -> deepagents.create_deep_agent(...)
- OpenRouter (model="openrouter:...") -> ChatOpenAI(base_url=openrouter)
- Workspace dir -> LocalShellBackend (filesystem + shell execution)
- Persona.permissions + DEFAULT_DENY -> deepagents.FilesystemPermission list
- Subagents -> deepagents.SubAgent TypedDict list
- Middleware list -> passed to create_deep_agent
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Literal
from uuid import UUID
from deepagents import FilesystemPermission, SubAgent, create_deep_agent
from deepagents.backends import (
CompositeBackend,
FilesystemBackend,
LocalShellBackend,
StateBackend,
)
from langchain_openai import ChatOpenAI
from .config import Config
from .errors import MyDeepAgentError
from .persona import FilesystemPermissionSpec, Persona, PersonaSubagent
DEFAULT_DENY_PATHS: tuple[str, ...] = (
"/.env*",
"/**/*.env*",
"/**/*token*",
"/**/*secret*",
"/**/*credential*",
"/**/*.pem",
"/**/*.key",
"/.ssh/**",
"/.aws/**",
"/.config/gcloud/**",
"/.kube/**",
"/.gnupg/**",
)
# Mapping from our richer operation set (read/write/edit/ls) to the deepagents
# binary set (read/write). deepagents treats ls/grep/glob as read-side and
# write_file/edit_file as write-side internally, so this collapse is safe.
_OP_MAP: dict[str, Literal["read", "write"]] = {
"read": "read",
"write": "write",
"edit": "write",
"ls": "read",
}
def _map_operations(ops: tuple[str, ...] | list[str]) -> list[Literal["read", "write"]]:
"""Deduplicate-preserve-order mapping of our ops to deepagents ops."""
seen: set[str] = set()
out: list[Literal["read", "write"]] = []
for op in ops:
mapped = _OP_MAP[op]
if mapped not in seen:
seen.add(mapped)
out.append(mapped)
return out
def default_safety_permissions() -> list[FilesystemPermission]:
"""Default-allow paths and deny secret-bearing paths.
Returned permissions are evaluated in order; first match wins.
Allow comes first so reads/writes to the worktree succeed by default;
then explicit denies block the secret patterns no matter what.
"""
return [
FilesystemPermission(
operations=["read", "write"],
paths=["/**"],
mode="allow",
),
FilesystemPermission(
operations=["read", "write"],
paths=list(DEFAULT_DENY_PATHS),
mode="deny",
),
]
def _spec_to_permission(spec: FilesystemPermissionSpec) -> FilesystemPermission:
"""Convert pydantic FilesystemPermissionSpec to deepagents FilesystemPermission.
Our schema accepts {read, write, edit, ls} for human-readable yaml. deepagents
collapses these to {read, write} internally; we apply the same collapse here.
"""
return FilesystemPermission(
operations=_map_operations(spec.operations),
paths=list(spec.paths),
mode=spec.mode,
)
def _subagent_to_dict(sub: PersonaSubagent) -> SubAgent:
"""Convert PersonaSubagent -> deepagents SubAgent TypedDict.
Only includes optional keys when set; deepagents inherits defaults from the parent
agent when a subagent omits ``tools`` / ``model`` / ``permissions`` / ``interrupt_on``.
"""
out: dict[str, Any] = {
"name": sub.name,
"description": sub.description,
"system_prompt": sub.system_prompt,
}
if sub.allowed_tools:
out["tools"] = list(sub.allowed_tools)
if sub.model is not None:
out["model"] = sub.model
if sub.permissions:
out["permissions"] = [_spec_to_permission(p) for p in sub.permissions]
if sub.interrupt_on:
out["interrupt_on"] = sub.interrupt_on
return out # type: ignore[return-value] # TypedDict construction from dict literal
def _resolve_openrouter_api_key(config: Config) -> str:
"""Pull the OpenRouter API key from config -> env -> error.
Priority: config.openrouter_api_key -> MYDEEPAGENT_OPENROUTER_API_KEY -> OPENROUTER_API_KEY.
"""
if config.openrouter_api_key:
return config.openrouter_api_key
env_key = os.environ.get("MYDEEPAGENT_OPENROUTER_API_KEY") or os.environ.get(
"OPENROUTER_API_KEY"
)
if env_key:
return env_key
raise MyDeepAgentError.human_required(
"backend_auth_failed",
message="OpenRouter API key is not configured",
recovery_hint=(
"set MYDEEPAGENT_OPENROUTER_API_KEY in .env or run `mydeepagent login openrouter`"
),
)
def resolve_model_instance(
persona: Persona, config: Config, model_override: str | None = None
) -> Any:
"""Persona -> langchain BaseChatModel instance or 'provider:model' string.
For ``openrouter:`` prefix, returns a ``ChatOpenAI`` with ``base_url=openrouter``.
For other providers (``anthropic:``, ``openai:``, ``google:``), returns the string as-is
so that deepagents' ``init_chat_model`` resolves it via the matching integration package.
"""
model_spec = model_override or persona.model
if model_spec.startswith("openrouter:"):
params = persona.model_params
return ChatOpenAI(
model=model_spec.removeprefix("openrouter:"),
api_key=_resolve_openrouter_api_key(config),
base_url=config.openrouter_base_url,
max_tokens=params.get("max_tokens", 4096),
temperature=params.get("temperature", 0.2),
top_p=params.get("top_p", 1.0),
)
return model_spec
def build_backend(persona: Persona, root_dir: Path) -> Any:
"""Persona.deepagents_backend -> concrete deepagents backend instance.
Returns:
LocalShellBackend for "local_shell" (filesystem + shell execute, the default).
FilesystemBackend for "filesystem" (filesystem only, no shell).
None for "state" (deepagents default StateBackend, in-process state only).
CompositeBackend for "composite" (local_shell + state-backed /memories/ namespace).
Raises:
MyDeepAgentError(fatal, config_invalid) for unknown backend identifiers
or "langsmith" which is reserved for a future milestone.
"""
name = persona.deepagents_backend
if name == "local_shell":
return LocalShellBackend(
root_dir=str(root_dir),
virtual_mode=False,
timeout=120,
max_output_bytes=100_000,
inherit_env=False,
)
if name == "filesystem":
return FilesystemBackend(root_dir=str(root_dir), virtual_mode=False, max_file_size_mb=10)
if name == "state":
return None # deepagents default StateBackend
if name == "composite":
return CompositeBackend(
default=LocalShellBackend(root_dir=str(root_dir), virtual_mode=False),
routes={"/memories/": StateBackend()},
)
raise MyDeepAgentError.fatal(
"config_invalid",
message=f"unsupported deepagents_backend: {name!r}",
recovery_hint="use one of: local_shell, filesystem, state, composite",
)
def build_agent(
persona: Persona,
config: Config,
*,
root_dir: Path,
middleware: list[Any] | None = None,
checkpointer: Any | None = None,
run_id: UUID | None = None,
phase_key: str | None = None,
model_override: str | None = None,
) -> Any:
"""Construct a deepagents CompiledStateGraph for the given persona.
Returns a CompiledStateGraph. Caller invokes via
``agent.invoke / ainvoke / astream / astream_events`` with ``{"messages": [...]}`` input.
deepagents 0.6.1 limitation: FilesystemPermission is rejected when the backend
implements SandboxBackendProtocol (e.g. LocalShellBackend). SafetyShellMiddleware
enforces path + destructive-command safety in those cases instead.
"""
from .middleware.safety import SafetyShellMiddleware
model = resolve_model_instance(persona, config, model_override)
backend = build_backend(persona, root_dir)
# SafetyShellMiddleware is always first; caller-supplied middleware appends.
all_middleware: list[Any] = [SafetyShellMiddleware()]
if middleware:
all_middleware.extend(middleware)
subagents: list[SubAgent] = [_subagent_to_dict(s) for s in persona.subagents]
kwargs: dict[str, Any] = {
"model": model,
"system_prompt": persona.system_prompt,
"middleware": all_middleware,
}
if backend is not None:
kwargs["backend"] = backend
# deepagents 0.6.1: FilesystemPermission + SandboxBackendProtocol backend raises
# NotImplementedError. Skip permissions kwarg for local_shell; SafetyShellMiddleware
# handles path enforcement instead. Other backends (state, filesystem, composite)
# still use the deepagents permissions system.
use_permissions = persona.deepagents_backend != "local_shell"
if use_permissions:
permissions: list[FilesystemPermission] = [
*(_spec_to_permission(p) for p in persona.permissions),
*default_safety_permissions(),
]
kwargs["permissions"] = permissions
if persona.allowed_tools:
kwargs["tools"] = list(persona.allowed_tools)
if subagents:
kwargs["subagents"] = subagents
if persona.interrupt_on:
kwargs["interrupt_on"] = persona.interrupt_on
if checkpointer is not None:
kwargs["checkpointer"] = checkpointer
if persona.skills:
kwargs["skills"] = list(persona.skills)
if persona.memory_files:
kwargs["memory"] = list(persona.memory_files)
return create_deep_agent(**kwargs)

View File

@@ -0,0 +1 @@
"""Slash command registry and dispatcher. Implemented in Step 10."""

View File

@@ -0,0 +1 @@
"""TUI approval dialog for human-in-the-loop actions. Implemented in Step 7."""

View File

@@ -0,0 +1 @@
"""TUI Rich panel and table renderers. Implemented in Step 10."""

View File

@@ -0,0 +1 @@
"""TUI streaming output renderer for run events. Implemented in Step 10."""

View File

@@ -0,0 +1,127 @@
"""WorkflowTemplate schema + YAML loader."""
from __future__ import annotations
from collections import Counter
from pathlib import Path
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from .enums import Backend, Capability, RiskLevel
from .hash import sha256
class ExpectedArtifact(BaseModel):
"""Expected output artifact of a workflow phase."""
model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)
path: str = Field(min_length=1)
# yaml uses 'schema' key; pydantic attribute is schema_id to avoid shadowing BaseModel.schema
schema_id: str = Field(min_length=1, alias="schema")
class WorkflowPhase(BaseModel):
"""Single phase definition inside a workflow template."""
model_config = ConfigDict(frozen=True, extra="forbid")
key: str = Field(min_length=1, pattern=r"^[a-z][a-z0-9_]*$")
title: str = Field(min_length=1)
risk: RiskLevel
role: str = Field(min_length=1)
expected_artifact: ExpectedArtifact | None = None
gates: tuple[str, ...] = Field(default_factory=tuple)
timeout_seconds: int | None = Field(default=None, ge=1)
instructions: str = Field(min_length=10)
max_budget_usd: float | None = Field(default=None, ge=0)
class WorkflowRole(BaseModel):
"""Role definition: what capabilities a bound persona must have."""
model_config = ConfigDict(frozen=True, extra="forbid")
id: str = Field(min_length=1, pattern=r"^[a-z][a-z0-9_]*$")
required_capabilities: tuple[Capability, ...] = Field(min_length=1)
preferred_backends: tuple[Backend, ...] = Field(default_factory=tuple)
fallback_personas: tuple[str, ...] = Field(default_factory=tuple)
class WorkflowTemplate(BaseModel):
"""Complete workflow template loaded from docs/schemas/workflows/<name>@<version>.yaml."""
model_config = ConfigDict(frozen=True, extra="forbid")
name: str = Field(min_length=1)
version: int = Field(ge=1)
description: str | None = None
roles: tuple[WorkflowRole, ...] = Field(min_length=1)
phases: tuple[WorkflowPhase, ...] = Field(min_length=1)
default_gates: tuple[str, ...] = Field(default_factory=tuple)
max_total_budget_usd: float | None = Field(default=None, ge=0)
@model_validator(mode="after")
def _validate_phase_roles(self) -> WorkflowTemplate:
role_ids = {r.id for r in self.roles}
for ph in self.phases:
if ph.role not in role_ids:
raise ValueError(f"phase '{ph.key}' references unknown role '{ph.role}'")
return self
@model_validator(mode="after")
def _validate_unique_phase_keys(self) -> WorkflowTemplate:
counts = Counter(ph.key for ph in self.phases)
duplicates = sorted(k for k, c in counts.items() if c > 1)
if duplicates:
raise ValueError(f"duplicate phase keys: {duplicates}")
return self
@field_validator("roles")
@classmethod
def _validate_unique_role_ids(cls, v: tuple[WorkflowRole, ...]) -> tuple[WorkflowRole, ...]:
counts = Counter(r.id for r in v)
duplicates = sorted(k for k, c in counts.items() if c > 1)
if duplicates:
raise ValueError(f"duplicate role ids: {duplicates}")
return v
def compute_hash(self) -> str:
"""Content-addressed identity hash of this template."""
return sha256(
{
"name": self.name,
"version": self.version,
"roles": [r.model_dump() for r in self.roles],
"phases": [ph.model_dump(by_alias=True) for ph in self.phases],
"default_gates": sorted(self.default_gates),
"max_total_budget_usd": self.max_total_budget_usd,
}
)
def load_workflow_yaml(path: Path) -> WorkflowTemplate:
"""Load and validate a single workflow yaml file."""
if not path.is_file():
raise FileNotFoundError(f"workflow yaml not found: {path}")
data = yaml.safe_load(path.read_text(encoding="utf-8"))
return WorkflowTemplate.model_validate(data)
def load_workflows_from_dir(directory: Path) -> list[WorkflowTemplate]:
"""Load all *.yaml workflow files from a directory, sorted by filename.
Raises ValueError if the same (name, version) pair appears more than once.
Returns an empty list if the directory does not exist.
"""
if not directory.is_dir():
return []
workflows = [load_workflow_yaml(p) for p in sorted(directory.glob("*.yaml"))]
seen: set[tuple[str, int]] = set()
for w in workflows:
key = (w.name, w.version)
if key in seen:
raise ValueError(f"duplicate workflow name={w.name!r} version={w.version}")
seen.add(key)
return workflows