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:
3
my-deepagent/src/my_deepagent/__init__.py
Normal file
3
my-deepagent/src/my_deepagent/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""my-deepagent: workflow harness + persona library + OpenRouter on top of deepagents."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
150
my-deepagent/src/my_deepagent/artifact_schema.py
Normal file
150
my-deepagent/src/my_deepagent/artifact_schema.py
Normal 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)
|
||||
404
my-deepagent/src/my_deepagent/binding.py
Normal file
404
my-deepagent/src/my_deepagent/binding.py
Normal 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"
|
||||
0
my-deepagent/src/my_deepagent/cli/__init__.py
Normal file
0
my-deepagent/src/my_deepagent/cli/__init__.py
Normal file
1
my-deepagent/src/my_deepagent/cli/doctor.py
Normal file
1
my-deepagent/src/my_deepagent/cli/doctor.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CLI doctor command for environment diagnostics. Implemented in Step 12."""
|
||||
1
my-deepagent/src/my_deepagent/cli/interactive.py
Normal file
1
my-deepagent/src/my_deepagent/cli/interactive.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CLI interactive subcommand. Implemented in Step 10."""
|
||||
1
my-deepagent/src/my_deepagent/cli/main.py
Normal file
1
my-deepagent/src/my_deepagent/cli/main.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Typer CLI entry point. Filled in Step 6."""
|
||||
1
my-deepagent/src/my_deepagent/cli/run.py
Normal file
1
my-deepagent/src/my_deepagent/cli/run.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CLI run command implementation. Implemented in Step 6."""
|
||||
1
my-deepagent/src/my_deepagent/cli/seed.py
Normal file
1
my-deepagent/src/my_deepagent/cli/seed.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CLI seed command for importing persona/workflow YAML assets. Implemented in Step 6."""
|
||||
1
my-deepagent/src/my_deepagent/cli/stats.py
Normal file
1
my-deepagent/src/my_deepagent/cli/stats.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CLI stats command for usage summary. Implemented in Step 12."""
|
||||
109
my-deepagent/src/my_deepagent/config.py
Normal file
109
my-deepagent/src/my_deepagent/config.py
Normal 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
|
||||
1
my-deepagent/src/my_deepagent/engine.py
Normal file
1
my-deepagent/src/my_deepagent/engine.py
Normal file
@@ -0,0 +1 @@
|
||||
"""LangGraph run engine orchestrator. Implemented in Step 7."""
|
||||
92
my-deepagent/src/my_deepagent/enums.py
Normal file
92
my-deepagent/src/my_deepagent/enums.py
Normal 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"
|
||||
79
my-deepagent/src/my_deepagent/errors.py
Normal file
79
my-deepagent/src/my_deepagent/errors.py
Normal 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
|
||||
28
my-deepagent/src/my_deepagent/hash.py
Normal file
28
my-deepagent/src/my_deepagent/hash.py
Normal 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()
|
||||
0
my-deepagent/src/my_deepagent/i18n/__init__.py
Normal file
0
my-deepagent/src/my_deepagent/i18n/__init__.py
Normal file
0
my-deepagent/src/my_deepagent/i18n/en.toml
Normal file
0
my-deepagent/src/my_deepagent/i18n/en.toml
Normal file
0
my-deepagent/src/my_deepagent/i18n/ko.toml
Normal file
0
my-deepagent/src/my_deepagent/i18n/ko.toml
Normal file
1
my-deepagent/src/my_deepagent/interactive.py
Normal file
1
my-deepagent/src/my_deepagent/interactive.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Interactive REPL loop for TUI sessions. Implemented in Step 10."""
|
||||
73
my-deepagent/src/my_deepagent/middleware/audit.py
Normal file
73
my-deepagent/src/my_deepagent/middleware/audit.py
Normal 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,
|
||||
}
|
||||
)
|
||||
87
my-deepagent/src/my_deepagent/middleware/cost.py
Normal file
87
my-deepagent/src/my_deepagent/middleware/cost.py
Normal 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,
|
||||
}
|
||||
)
|
||||
47
my-deepagent/src/my_deepagent/middleware/fallback.py
Normal file
47
my-deepagent/src/my_deepagent/middleware/fallback.py
Normal 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
|
||||
126
my-deepagent/src/my_deepagent/middleware/safety.py
Normal file
126
my-deepagent/src/my_deepagent/middleware/safety.py
Normal 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/)"
|
||||
),
|
||||
)
|
||||
1
my-deepagent/src/my_deepagent/monitoring/langsmith.py
Normal file
1
my-deepagent/src/my_deepagent/monitoring/langsmith.py
Normal file
@@ -0,0 +1 @@
|
||||
"""LangSmith tracing integration helpers. Implemented in Step 12."""
|
||||
99
my-deepagent/src/my_deepagent/monitoring/pricing.py
Normal file
99
my-deepagent/src/my_deepagent/monitoring/pricing.py
Normal 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
|
||||
1
my-deepagent/src/my_deepagent/monitoring/stats.py
Normal file
1
my-deepagent/src/my_deepagent/monitoring/stats.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Run statistics aggregation and reporting. Implemented in Step 12."""
|
||||
6
my-deepagent/src/my_deepagent/persistence/__init__.py
Normal file
6
my-deepagent/src/my_deepagent/persistence/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Persistence layer: SQLAlchemy async ORM + LangGraph checkpointer."""
|
||||
|
||||
from .checkpointer import get_checkpointer_ctx
|
||||
from .db import Database
|
||||
|
||||
__all__ = ["Database", "get_checkpointer_ctx"]
|
||||
41
my-deepagent/src/my_deepagent/persistence/checkpointer.py
Normal file
41
my-deepagent/src/my_deepagent/persistence/checkpointer.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""LangGraph SqliteSaver wrapper. Use only as a context manager to ensure connection cleanup.
|
||||
|
||||
``SqliteSaver.from_conn_string`` is a ``@contextmanager`` classmethod that yields
|
||||
a ``SqliteSaver`` instance and closes the underlying sqlite3 connection on exit.
|
||||
Direct manual lifecycle management (entering context without ``with``) leaks connections
|
||||
and is not supported by this module.
|
||||
|
||||
Usage::
|
||||
|
||||
with get_checkpointer_ctx(path) as saver:
|
||||
graph = create_deep_agent(checkpointer=saver)
|
||||
...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_checkpointer_ctx(checkpoints_db_path: Path) -> Iterator[SqliteSaver]:
|
||||
"""Yield a SqliteSaver bound to *checkpoints_db_path*.
|
||||
|
||||
Creates the parent directory and the database file if they do not exist.
|
||||
The underlying sqlite3 connection is closed automatically on context exit.
|
||||
This is the only supported way to obtain a SqliteSaver in this project —
|
||||
direct manual lifecycle management is not provided.
|
||||
|
||||
Args:
|
||||
checkpoints_db_path: Filesystem path for the SQLite checkpoint database.
|
||||
|
||||
Yields:
|
||||
SqliteSaver: Ready-to-use LangGraph checkpoint saver.
|
||||
"""
|
||||
checkpoints_db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with SqliteSaver.from_conn_string(str(checkpoints_db_path)) as saver:
|
||||
yield saver
|
||||
91
my-deepagent/src/my_deepagent/persistence/db.py
Normal file
91
my-deepagent/src/my_deepagent/persistence/db.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Async SQLAlchemy engine + session factory with WAL mode and busy_timeout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from .models import Base
|
||||
|
||||
|
||||
def _attach_sqlite_pragmas(engine: AsyncEngine) -> None:
|
||||
"""Attach a synchronous connect-event listener that enables WAL, busy_timeout, FK."""
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_connection: object, _conn_record: object) -> None:
|
||||
# dbapi_connection is a raw sqlite3.Connection delivered by SQLAlchemy's
|
||||
# pool event callback. The signature uses `object` to match the generic
|
||||
# listener protocol; we cast to `Any` here to access DBAPI methods without
|
||||
# introducing a hard import of `sqlite3` (which would break non-SQLite
|
||||
# engines). The pragma calls are safe: they are no-ops on non-SQLite
|
||||
# dialects and sqlite3.Connection always has `.cursor()`.
|
||||
import sqlite3 # local import to avoid circular or non-SQLite coupling
|
||||
|
||||
conn: sqlite3.Connection = dbapi_connection # type: ignore[assignment]
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
class Database:
|
||||
"""Façade over async engine + session maker.
|
||||
|
||||
Usage::
|
||||
|
||||
db = Database("sqlite+aiosqlite:///path/to/db.sqlite3")
|
||||
await db.init_schema() # dev/test: create all tables directly
|
||||
async with db.session() as s: # production: use alembic upgrade head
|
||||
result = await s.execute(...)
|
||||
await db.dispose()
|
||||
|
||||
For production deployments, call ``alembic upgrade head`` instead of
|
||||
``init_schema`` so that migration history is tracked.
|
||||
"""
|
||||
|
||||
def __init__(self, database_url: str) -> None:
|
||||
self._engine: AsyncEngine = create_async_engine(
|
||||
database_url,
|
||||
# NullPool avoids connection reuse issues in SQLite+aiosqlite tests.
|
||||
poolclass=None, # use the default StaticPool-compatible pool
|
||||
echo=False,
|
||||
)
|
||||
_attach_sqlite_pragmas(self._engine)
|
||||
self._session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
bind=self._engine,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
async def init_schema(self) -> None:
|
||||
"""Create all ORM-defined tables.
|
||||
|
||||
For production, prefer ``alembic upgrade head``.
|
||||
For tests, this is the fastest way to get a clean schema.
|
||||
"""
|
||||
async with self._engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self) -> AsyncIterator[AsyncSession]:
|
||||
"""Yield an async session; commit on success, rollback on exception."""
|
||||
async with self._session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def dispose(self) -> None:
|
||||
"""Dispose the engine connection pool."""
|
||||
await self._engine.dispose()
|
||||
578
my-deepagent/src/my_deepagent/persistence/models.py
Normal file
578
my-deepagent/src/my_deepagent/persistence/models.py
Normal file
@@ -0,0 +1,578 @@
|
||||
"""SQLAlchemy 2.0 async ORM models for my-deepagent persistence layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""SQLAlchemy declarative base for my-deepagent."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# workflow_templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WorkflowTemplateRow(Base):
|
||||
"""Content-addressed workflow template definitions."""
|
||||
|
||||
__tablename__ = "workflow_templates"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
definition: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WorkflowTemplateRow id={self.id!r} name={self.name!r} version={self.version!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# agent_personas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentPersonaRow(Base):
|
||||
"""Content-addressed agent persona definitions."""
|
||||
|
||||
__tablename__ = "agent_personas"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
definition: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AgentPersonaRow id={self.id!r} name={self.name!r} version={self.version!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunRow(Base):
|
||||
"""Top-level run record: one row per deepagent run invocation."""
|
||||
|
||||
__tablename__ = "runs"
|
||||
__table_args__ = (
|
||||
# Partial unique index: at most one active run per (repo_path, base_branch).
|
||||
# An "active" run is any run whose state is not 'completed', 'failed', or 'aborted'.
|
||||
# SQLite partial index uses a WHERE clause; autogenerate cannot detect this,
|
||||
# so it is managed via a manual alembic migration.
|
||||
Index(
|
||||
"ux_active_run_repo_base",
|
||||
"repo_path",
|
||||
"base_branch",
|
||||
unique=True,
|
||||
sqlite_where=text("state NOT IN ('completed', 'failed', 'aborted')"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
# FK to workflow_templates — RESTRICT prevents deleting a template that has runs.
|
||||
template_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("workflow_templates.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
template_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
state: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
repo_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
base_branch: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
worktree_root: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# current_phase_id references run_phases.id; however, runs.current_phase_id and
|
||||
# run_phases.run_id form a circular FK pair. SQLite does not support deferrable
|
||||
# constraints at the column level, and alembic cannot safely manage this circular
|
||||
# dependency. Therefore current_phase_id carries NO ForeignKey constraint in the ORM.
|
||||
# Callers must maintain referential integrity manually (i.e. always point to a valid
|
||||
# run_phases.id that belongs to this run, or NULL).
|
||||
current_phase_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
started_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ended_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
final_report_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
paused_from_state: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
updated_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RunRow id={self.id!r} state={self.state!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_inputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunInputRow(Base):
|
||||
"""Input snapshot for a run (one-to-one with runs)."""
|
||||
|
||||
__tablename__ = "run_inputs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
requirements_md: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
objective: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
input_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RunInputRow id={self.id!r} run_id={self.run_id!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_bindings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunBindingRow(Base):
|
||||
"""Per-role persona binding for a run."""
|
||||
|
||||
__tablename__ = "run_bindings"
|
||||
__table_args__ = (UniqueConstraint("run_id", "role_id", name="uq_run_bindings_run_role"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
role_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# FK to agent_personas — RESTRICT prevents deleting a persona that has bindings.
|
||||
persona_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("agent_personas.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
persona_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
backend: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
binding_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RunBindingRow id={self.id!r} run_id={self.run_id!r} role_id={self.role_id!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_phases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunPhaseRow(Base):
|
||||
"""Per-phase execution record for a run."""
|
||||
|
||||
__tablename__ = "run_phases"
|
||||
__table_args__ = (UniqueConstraint("run_id", "phase_key", name="uq_run_phases_run_phase"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
phase_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
state: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
started_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ended_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RunPhaseRow id={self.id!r} run_id={self.run_id!r} phase_key={self.phase_key!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunEventRow(Base):
|
||||
"""Ordered event stream for a run."""
|
||||
|
||||
__tablename__ = "run_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("run_id", "seq", name="uq_run_events_run_seq"),
|
||||
UniqueConstraint("run_id", "idempotency_key", name="uq_run_events_run_idempotency"),
|
||||
Index("run_events_run_id_ts_idx", "run_id", "ts"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
# phase_id references run_phases.id; CASCADE so events are deleted when a phase is deleted.
|
||||
phase_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("run_phases.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
type: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
ts: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RunEventRow id={self.id!r} run_id={self.run_id!r} seq={self.seq!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# approval_requests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ApprovalRequestRow(Base):
|
||||
"""Human approval gate requests."""
|
||||
|
||||
__tablename__ = "approval_requests"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
# phase_id references run_phases.id; CASCADE so approval requests are deleted with the phase.
|
||||
phase_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("run_phases.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
gate_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
state: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
resolved_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ApprovalRequestRow id={self.id!r} gate_key={self.gate_key!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# approval_decisions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ApprovalDecisionRow(Base):
|
||||
"""Human decisions on approval requests."""
|
||||
|
||||
__tablename__ = "approval_decisions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
approval_request_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("approval_requests.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
action: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decided_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ApprovalDecisionRow id={self.id!r} action={self.action!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# artifacts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ArtifactRow(Base):
|
||||
"""Content-addressed output artifacts from phases."""
|
||||
|
||||
__tablename__ = "artifacts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("run_id", "path", "hash", name="uq_artifacts_run_path_hash"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
# phase_id references run_phases.id; CASCADE so artifacts are deleted with the phase.
|
||||
phase_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("run_phases.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
schema_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
valid: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
validation_error: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ArtifactRow id={self.id!r} path={self.path!r} valid={self.valid!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# interactive_sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InteractiveSessionRow(Base):
|
||||
"""Interactive (non-run) agent sessions."""
|
||||
|
||||
__tablename__ = "interactive_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
# FK to agent_personas — RESTRICT prevents deleting a persona that has interactive sessions.
|
||||
persona_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("agent_personas.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
persona_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
started_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ended_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_message_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
state: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<InteractiveSessionRow id={self.id!r} state={self.state!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tool_calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ToolCallRow(Base):
|
||||
"""Audit log of every tool invocation (run or interactive)."""
|
||||
|
||||
__tablename__ = "tool_calls"
|
||||
__table_args__ = (Index("tool_calls_run_id_ts_idx", "run_id", "ts"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# run_id / phase_id / interactive_session_id: exactly one must be non-NULL per row,
|
||||
# but all three are nullable because tool_calls covers both run and interactive contexts.
|
||||
# CASCADE ensures audit rows are removed when the parent run or session is deleted.
|
||||
run_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
phase_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("run_phases.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
interactive_session_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("interactive_sessions.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
tool_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
args: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
result: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
duration_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
ts: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ToolCallRow id={self.id!r} tool_name={self.tool_name!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# llm_calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LlmCallRow(Base):
|
||||
"""Full LLM call telemetry: tokens, cost, latency, model."""
|
||||
|
||||
__tablename__ = "llm_calls"
|
||||
__table_args__ = (
|
||||
Index("llm_calls_run_id_ts_idx", "run_id", "ts"),
|
||||
Index("llm_calls_interactive_session_id_ts_idx", "interactive_session_id", "ts"),
|
||||
Index("llm_calls_model_ts_idx", "model", "ts"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# run_id / phase_id / interactive_session_id: exactly one must be non-NULL per row,
|
||||
# but all three are nullable because llm_calls covers both run and interactive contexts.
|
||||
# CASCADE ensures telemetry rows are removed when the parent run or session is deleted.
|
||||
run_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
phase_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("run_phases.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
interactive_session_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("interactive_sessions.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
thread_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
persona_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
persona_version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
model: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
role: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
turn_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
cached_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
reasoning_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
cost_usd_input: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
cost_usd_output: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
cost_usd_total: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
latency_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
error_code: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
request_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ts: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<LlmCallRow id={self.id!r} model={self.model!r} status={self.status!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# model_pricing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModelPricingRow(Base):
|
||||
"""Cached model pricing data (fetched from provider APIs)."""
|
||||
|
||||
__tablename__ = "model_pricing"
|
||||
|
||||
model: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
input_per_1k_usd: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
output_per_1k_usd: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
context_length: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
fetched_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
raw_payload: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ModelPricingRow model={self.model!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# budget_ledger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BudgetLedgerRow(Base):
|
||||
"""Per-scope budget tracking (e.g. global, per-run, per-persona)."""
|
||||
|
||||
__tablename__ = "budget_ledger"
|
||||
|
||||
scope: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
spent_usd: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
cap_usd: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
last_updated: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BudgetLedgerRow scope={self.scope!r} spent_usd={self.spent_usd!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# persona_consents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PersonaConsentRow(Base):
|
||||
"""Persisted persona consent decisions (approve/block)."""
|
||||
|
||||
__tablename__ = "persona_consents"
|
||||
|
||||
persona_hash: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
persona_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
persona_version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
decision: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
decided_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PersonaConsentRow persona_hash={self.persona_hash!r} decision={self.decision!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# phase_feedback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PhaseFeedbackRow(Base):
|
||||
"""User feedback on completed phases (reaction + optional comment)."""
|
||||
|
||||
__tablename__ = "phase_feedback"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# CASCADE: feedback is deleted when the run is deleted (audit data follows the run lifecycle).
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
# CASCADE: feedback is deleted when the phase is deleted.
|
||||
phase_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("run_phases.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
reaction: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PhaseFeedbackRow id={self.id!r} run_id={self.run_id!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_commands (schema-only; used in future steps)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunCommandRow(Base):
|
||||
"""Queued commands targeting a run (pause, resume, abort, etc.)."""
|
||||
|
||||
__tablename__ = "run_commands"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
command: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
processed_at: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RunCommandRow id={self.id!r} run_id={self.run_id!r} command={self.command!r}>"
|
||||
154
my-deepagent/src/my_deepagent/persona.py
Normal file
154
my-deepagent/src/my_deepagent/persona.py
Normal 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
|
||||
1
my-deepagent/src/my_deepagent/prompt_envelope.py
Normal file
1
my-deepagent/src/my_deepagent/prompt_envelope.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Prompt envelope builder for LangChain messages. Implemented in Step 5."""
|
||||
0
my-deepagent/src/my_deepagent/py.typed
Normal file
0
my-deepagent/src/my_deepagent/py.typed
Normal file
1
my-deepagent/src/my_deepagent/run_event.py
Normal file
1
my-deepagent/src/my_deepagent/run_event.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Run event types for streaming progress. Implemented in Step 4."""
|
||||
1
my-deepagent/src/my_deepagent/safety.py
Normal file
1
my-deepagent/src/my_deepagent/safety.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Safety gate for destructive command classification. Implemented in Step 11."""
|
||||
274
my-deepagent/src/my_deepagent/session.py
Normal file
274
my-deepagent/src/my_deepagent/session.py
Normal 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)
|
||||
1
my-deepagent/src/my_deepagent/slash.py
Normal file
1
my-deepagent/src/my_deepagent/slash.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Slash command registry and dispatcher. Implemented in Step 10."""
|
||||
0
my-deepagent/src/my_deepagent/tui/__init__.py
Normal file
0
my-deepagent/src/my_deepagent/tui/__init__.py
Normal file
1
my-deepagent/src/my_deepagent/tui/approval.py
Normal file
1
my-deepagent/src/my_deepagent/tui/approval.py
Normal file
@@ -0,0 +1 @@
|
||||
"""TUI approval dialog for human-in-the-loop actions. Implemented in Step 7."""
|
||||
1
my-deepagent/src/my_deepagent/tui/render.py
Normal file
1
my-deepagent/src/my_deepagent/tui/render.py
Normal file
@@ -0,0 +1 @@
|
||||
"""TUI Rich panel and table renderers. Implemented in Step 10."""
|
||||
1
my-deepagent/src/my_deepagent/tui/stream.py
Normal file
1
my-deepagent/src/my_deepagent/tui/stream.py
Normal file
@@ -0,0 +1 @@
|
||||
"""TUI streaming output renderer for run events. Implemented in Step 10."""
|
||||
127
my-deepagent/src/my_deepagent/workflow.py
Normal file
127
my-deepagent/src/my_deepagent/workflow.py
Normal 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
|
||||
Reference in New Issue
Block a user