Files
dev-puppeteer/my-deepagent/src/my_deepagent/binding.py
chungyeong 17ba5d723b 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>
2026-05-15 19:40:02 +09:00

405 lines
15 KiB
Python

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