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>
275 lines
9.5 KiB
Python
275 lines
9.5 KiB
Python
"""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)
|