feat(my-deepagent): v0.1.0 Step 0~5 — scaffolding through deepagent + OpenRouter

Python rewrite of the agent harness on top of deepagents 0.6.1 + langchain 1.x,
replacing the abandoned TS attempt in packages/. 388 unit/integration tests pass.

Steps
-----
0. Scaffolding — uv workspace, ruff/mypy/pre-commit/alembic, src/tests/docs
   trees with docs/schemas/ seeded from my-deepagent-seed/.
1. Core — config (pydantic-settings with MYDEEPAGENT_ env prefix and TOML
   source), enums (Backend, Capability, RiskLevel, ApprovalDecisionAction,
   ApprovalState, RunState, RunPhaseState, SessionState, ErrorClass),
   errors (MyDeepAgentError + BudgetExhaustedError with PEP-3134 cause +
   context suppression), hash (canonical JSON + sha256).
2. Persona/Workflow/Binding — pydantic v2 schemas with tuple-based deep
   immutability (post-construction hash drift prevented), YAML loaders,
   deterministic auto-select (preferred_backends → version → name → hash),
   override resolution with ineligibility diagnostics, PersonaConsentStore
   with fcntl.flock + tmp+fsync+rename atomic write.
3. Artifact schema registry — Draft202012Validator, multi-root resolution,
   structured ValidationFinding output.
4. Persistence — 18 SQLAlchemy 2.0 async ORM models with FK CASCADE/RESTRICT,
   WAL + busy_timeout + foreign_keys PRAGMA, alembic baseline +
   ux_active_run_repo_base partial unique index, LangGraph SqliteSaver as
   context manager only (lifecycle safety).
5. DeepAgent session — build_agent wires Persona → create_deep_agent with
   LocalShellBackend / FilesystemBackend / StateBackend / CompositeBackend,
   ChatOpenAI(base_url=openrouter) for openrouter: model strings, and 4
   middleware classes (cost / audit-tool / safety-shell / fallback-model).

Critical workarounds
--------------------
- deepagents 0.6.1 rejects FilesystemPermission together with backends that
  implement SandboxBackendProtocol (LocalShellBackend). SafetyShellMiddleware
  enforces destructive-command and secret-path policy at the tool layer
  instead, and build_agent strips the permissions kwarg when the persona's
  deepagents_backend is local_shell.
- FilesystemOperation in deepagents is Literal['read', 'write'] only;
  _map_operations collapses our richer schema (read/write/edit/ls) safely.

Real OpenRouter smoke
---------------------
test_openrouter_deepagents_local_shell_smoke calls DeepSeek via deepagents +
LocalShellBackend + SafetyShellMiddleware end-to-end. PASS, ~$0.000001 cost,
input=9 / output=1 tokens with content "OK".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-15 19:40:02 +09:00
parent 1fe59d16ca
commit 17ba5d723b
100 changed files with 12408 additions and 0 deletions

View File

@@ -0,0 +1,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)