ddf337fc40
Docker / build (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
Build / build (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
langgraph_dev/http.py - _compatible_checkpoint/_history_admission 支持带锚点准入:以承载 __interrupt__ 的检查点 为父状态读取并校验中断标识;payload 保留锚点作为父状态 - 不带锚点时行为与原来完全一致(兼容性只靠这一条) middleware/dynamic_review.py - _abort_requested/_finish_turn + hook_config(can_jump_to=["end"]):在库消费中断后结束本轮 (工具不执行、不再发起模型调用);无标记时完全休眠 随本批一并落定(早前改动):EvoScientist.py / workspace_scope.py 的 HITL 装配收敛与工作区范围, 以及对应测试调整。
558 lines
19 KiB
Python
558 lines
19 KiB
Python
"""Conversation-scoped workspace resolution and DeepAgents backend factory."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import shutil
|
|
import threading
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from deepagents.backends.protocol import (
|
|
EditResult,
|
|
ExecuteResponse,
|
|
FileDownloadResponse,
|
|
FileUploadResponse,
|
|
GlobResult,
|
|
GrepResult,
|
|
LsResult,
|
|
ReadResult,
|
|
SandboxBackendProtocol,
|
|
WriteResult,
|
|
)
|
|
from langchain.tools import ToolRuntime
|
|
|
|
from . import paths
|
|
from .scope_registry import (
|
|
ScopeAccessError,
|
|
ScopeRecord,
|
|
deployment_id_for_workspace,
|
|
get_scope_registry,
|
|
)
|
|
|
|
IsolationMode = str
|
|
|
|
|
|
def workspace_isolation_mode() -> IsolationMode:
|
|
value = os.getenv("EVOSCIENTIST_WORKSPACE_ISOLATION", "optional").strip().lower()
|
|
if value not in {"legacy", "optional", "required"}:
|
|
raise RuntimeError("EVOSCIENTIST_WORKSPACE_ISOLATION must be legacy, optional or required")
|
|
return value
|
|
|
|
|
|
def is_required() -> bool:
|
|
return workspace_isolation_mode() == "required"
|
|
|
|
|
|
def verify_required_executor() -> None:
|
|
"""Fail startup unless the pinned native executor passes its real preflight."""
|
|
|
|
from .native_sandbox import ensure_native_sandbox_ready
|
|
|
|
ensure_native_sandbox_ready()
|
|
|
|
|
|
def current_deployment_id() -> str:
|
|
return deployment_id_for_workspace(paths.WORKSPACE_ROOT)
|
|
|
|
|
|
def conversation_root(scope_id: str, workspace_root: Path | None = None) -> Path:
|
|
scope = str(uuid.UUID(scope_id))
|
|
# The deploy process supplies an absolute workspace root. This helper is
|
|
# called from synchronous DeepAgents backend factories on the ASGI loop.
|
|
root = (workspace_root or paths.WORKSPACE_ROOT).expanduser()
|
|
return root / ".evoscientist" / "conversations" / scope
|
|
|
|
|
|
def conversation_files_dir(scope_id: str, workspace_root: Path | None = None) -> Path:
|
|
return conversation_root(scope_id, workspace_root) / "files"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ScopeContext:
|
|
deployment_id: str
|
|
scope_id: str
|
|
owner_id: str
|
|
thread_id: str
|
|
revision: int
|
|
files_dir: Path
|
|
runtime_dir: Path
|
|
primary_thread_id: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _RuntimeScopeConfig:
|
|
"""Untrusted runtime identifiers parsed without filesystem or Registry I/O."""
|
|
|
|
scope_id: str
|
|
owner_id: str
|
|
thread_id: str
|
|
deployment_id: str | None
|
|
|
|
|
|
def _configurable(runtime: ToolRuntime[Any, Any] | Any | None) -> dict[str, Any]:
|
|
"""Return the active runnable config, with a non-graph fallback.
|
|
|
|
``ToolRuntime`` deliberately does not expose ``RunnableConfig`` during a
|
|
graph execution. LangGraph keeps it in a context variable instead. The
|
|
fallback preserves direct callers and unit tests that supply a lightweight
|
|
runtime object outside a runnable context.
|
|
"""
|
|
|
|
config: Any = None
|
|
try:
|
|
from langgraph.config import get_config
|
|
|
|
config = get_config()
|
|
except (ImportError, LookupError, RuntimeError):
|
|
pass
|
|
if not isinstance(config, dict) and runtime is not None:
|
|
config = getattr(runtime, "config", None) or {}
|
|
if not isinstance(config, dict):
|
|
return {}
|
|
configurable = config.get("configurable") or {}
|
|
return dict(configurable) if isinstance(configurable, dict) else {}
|
|
|
|
|
|
def _required_string(configurable: dict[str, Any], key: str) -> str:
|
|
value = configurable.get(key)
|
|
if not isinstance(value, str) or not value:
|
|
raise ScopeAccessError(f"missing {key}")
|
|
return value
|
|
|
|
|
|
def _runtime_scope_config(
|
|
runtime: ToolRuntime[Any, Any] | Any | None,
|
|
*,
|
|
kind: str,
|
|
) -> _RuntimeScopeConfig | None:
|
|
"""Parse scope identifiers without treating config as an authorization grant."""
|
|
|
|
configurable = _configurable(runtime)
|
|
scope_id = configurable.get("workspace_scope_id")
|
|
owner_id = configurable.get("workspace_scope_owner_id")
|
|
thread_id = configurable.get("thread_id")
|
|
|
|
if scope_id is None and owner_id is None:
|
|
if workspace_isolation_mode() == "required":
|
|
raise ScopeAccessError(f"{kind} requires a workspace scope")
|
|
return None
|
|
if (
|
|
not isinstance(scope_id, str)
|
|
or not isinstance(owner_id, str)
|
|
or not isinstance(thread_id, str)
|
|
):
|
|
raise ScopeAccessError(f"{kind} has an incomplete workspace scope")
|
|
try:
|
|
canonical_scope_id = str(uuid.UUID(scope_id))
|
|
canonical_owner_id = str(uuid.UUID(owner_id))
|
|
except ValueError as exc:
|
|
raise ScopeAccessError(f"{kind} has an invalid workspace scope") from exc
|
|
deployment_id = configurable.get("workspace_deployment_id")
|
|
if deployment_id is not None and (
|
|
not isinstance(deployment_id, str) or not deployment_id
|
|
):
|
|
raise ScopeAccessError(f"{kind} has an invalid workspace deployment")
|
|
return _RuntimeScopeConfig(
|
|
scope_id=canonical_scope_id,
|
|
owner_id=canonical_owner_id,
|
|
thread_id=thread_id,
|
|
deployment_id=deployment_id,
|
|
)
|
|
|
|
|
|
def _validated_scope_directories(scope_id: str) -> tuple[Path, Path]:
|
|
"""Return canonical private directories after preventing symlink escape.
|
|
|
|
This function intentionally resolves paths and must run only from a
|
|
filesystem-operation worker, never from the runtime backend factory.
|
|
"""
|
|
|
|
conversations_dir = (
|
|
paths.WORKSPACE_ROOT.expanduser() / ".evoscientist" / "conversations"
|
|
).resolve(strict=True)
|
|
scope_root = (conversations_dir / scope_id).resolve(strict=True)
|
|
files_dir = (scope_root / "files").resolve(strict=True)
|
|
runtime_dir = (scope_root / "runtime").resolve(strict=True)
|
|
if (
|
|
scope_root.parent != conversations_dir
|
|
or files_dir.parent != scope_root
|
|
or runtime_dir.parent != scope_root
|
|
):
|
|
raise ScopeAccessError("workspace directory escapes its scope")
|
|
if not files_dir.is_dir() or not runtime_dir.is_dir():
|
|
raise ScopeAccessError("workspace directory is missing")
|
|
return files_dir, runtime_dir
|
|
|
|
|
|
def _resolve_scope_context(config: _RuntimeScopeConfig | None) -> ScopeContext | None:
|
|
"""Validate parsed scope identifiers against the active registry."""
|
|
|
|
if config is None:
|
|
return None
|
|
deployment_id = config.deployment_id or current_deployment_id()
|
|
registry = get_scope_registry(paths.WORKSPACE_ROOT)
|
|
if registry.active_lock(deployment_id, "workspace-cutover") is not None:
|
|
raise ScopeAccessError("workspace cutover is in progress")
|
|
record = registry.assert_runtime(
|
|
deployment_id, config.scope_id, config.thread_id, config.owner_id
|
|
)
|
|
files_dir, runtime_dir = _validated_scope_directories(record.scope_id)
|
|
return ScopeContext(
|
|
deployment_id=deployment_id,
|
|
scope_id=record.scope_id,
|
|
owner_id=config.owner_id,
|
|
thread_id=config.thread_id,
|
|
revision=record.revision,
|
|
files_dir=files_dir,
|
|
runtime_dir=runtime_dir,
|
|
primary_thread_id=record.primary_thread_id,
|
|
)
|
|
|
|
|
|
def require_scoped_runtime(
|
|
runtime: ToolRuntime[Any, Any] | Any | None,
|
|
*,
|
|
kind: str = "tool",
|
|
) -> ScopeContext | None:
|
|
"""Resolve and validate a runtime scope.
|
|
|
|
``optional`` retains legacy CLI compatibility when no scope has been
|
|
injected. ``required`` never falls back to ``WORKSPACE_ROOT``.
|
|
"""
|
|
|
|
config = _runtime_scope_config(runtime, kind=kind)
|
|
return _resolve_scope_context(config)
|
|
|
|
|
|
def provision_conversation_scope(
|
|
thread_id: str,
|
|
*,
|
|
deployment_id: str | None = None,
|
|
scope_id: str | None = None,
|
|
workspace_root: Path | None = None,
|
|
lock_operation_id: str | None = None,
|
|
) -> ScopeRecord:
|
|
"""Create the registry mapping and private directory for a primary thread."""
|
|
|
|
root = (workspace_root or paths.WORKSPACE_ROOT).expanduser()
|
|
deployment_id = deployment_id or deployment_id_for_workspace(root)
|
|
registry = get_scope_registry(root)
|
|
record = registry.provision(
|
|
deployment_id,
|
|
thread_id,
|
|
scope_id=scope_id,
|
|
lock_operation_id=lock_operation_id,
|
|
)
|
|
root = conversation_root(record.scope_id, root)
|
|
try:
|
|
(root / "files").mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
(root / "runtime").mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
for directory in (root, root / "files", root / "runtime"):
|
|
try:
|
|
directory.chmod(0o700)
|
|
except OSError:
|
|
pass
|
|
except OSError:
|
|
# Keep the durable reservation for the recovery job; it is safer than
|
|
# silently falling back to the shared deployment root.
|
|
raise
|
|
return record
|
|
|
|
|
|
def delete_conversation_scope(
|
|
thread_id: str,
|
|
*,
|
|
deployment_id: str | None = None,
|
|
workspace_root: Path | None = None,
|
|
) -> ScopeRecord:
|
|
"""Idempotently revoke and remove the conversation scope for one Thread."""
|
|
|
|
root = (workspace_root or paths.WORKSPACE_ROOT).expanduser()
|
|
deployment_id = deployment_id or deployment_id_for_workspace(root)
|
|
registry = get_scope_registry(root)
|
|
record = registry.get_by_thread(deployment_id, thread_id)
|
|
if record.state == "deleted":
|
|
return record
|
|
if record.state != "deleting":
|
|
record = registry.transition_scope(
|
|
deployment_id,
|
|
record.scope_id,
|
|
expected_revision=record.revision,
|
|
state="deleting",
|
|
)
|
|
|
|
conversations = (root / ".evoscientist" / "conversations").resolve()
|
|
scope_root = conversation_root(record.scope_id, root)
|
|
if scope_root.parent.resolve() != conversations:
|
|
raise ScopeAccessError("workspace directory escapes its conversations root")
|
|
if scope_root.exists():
|
|
shutil.rmtree(scope_root)
|
|
return registry.transition_scope(
|
|
deployment_id,
|
|
record.scope_id,
|
|
expected_revision=record.revision,
|
|
state="deleted",
|
|
)
|
|
|
|
|
|
def _build_backend(root_dir: Path, runtime_dir: Path, *, dangerous: bool) -> Any:
|
|
from deepagents.backends import CompositeBackend
|
|
|
|
from .backends import (
|
|
CustomSandboxBackend,
|
|
MemoryFilesystemBackend,
|
|
MergedSkillsBackend,
|
|
)
|
|
from .EvoScientist import SKILLS_DIR
|
|
|
|
cfg_timeout = int(os.getenv("EVOSCIENTIST_SANDBOX_EXECUTE_TIMEOUT", "300"))
|
|
ws_backend: Any
|
|
web_full = os.getenv("EVOSCIENTIST_DEPLOY_MODE", "").lower() == "full"
|
|
if is_required() or web_full:
|
|
from .native_sandbox import NativeWorkspaceBackend
|
|
|
|
ws_backend = NativeWorkspaceBackend(
|
|
root_dir, runtime_dir, timeout=cfg_timeout
|
|
)
|
|
else:
|
|
ws_backend = CustomSandboxBackend(
|
|
root_dir=str(root_dir),
|
|
virtual_mode=True,
|
|
timeout=cfg_timeout,
|
|
dangerous=dangerous,
|
|
)
|
|
return CompositeBackend(
|
|
default=ws_backend,
|
|
routes={
|
|
"/skills/": MergedSkillsBackend(
|
|
primary_dir=str(paths.USER_SKILLS_DIR),
|
|
global_dir=str(paths.GLOBAL_SKILLS_DIR),
|
|
secondary_dir=SKILLS_DIR,
|
|
),
|
|
"/memories/": MemoryFilesystemBackend(
|
|
root_dir=str(paths.MEMORIES_DIR), virtual_mode=True
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
class DeferredScopedBackend(SandboxBackendProtocol):
|
|
"""Resolve the scoped filesystem backend only from a worker thread.
|
|
|
|
DeepAgents invokes its deprecated backend factory from async middleware.
|
|
Its concrete filesystem backends synchronously call ``Path.resolve()`` in
|
|
their constructors, so doing that work in the factory makes every run fail
|
|
under LangGraph's blocking-call detector. This proxy itself is I/O-free;
|
|
the inherited async methods dispatch the synchronous operations to a
|
|
thread, where Registry validation and concrete backend construction occur.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config: _RuntimeScopeConfig | None,
|
|
*,
|
|
dangerous: bool,
|
|
legacy_backend: Callable[[], Any] | None = None,
|
|
allow_unscoped_legacy: bool = False,
|
|
) -> None:
|
|
self._config = config
|
|
self._dangerous = dangerous
|
|
self._legacy_backend = legacy_backend
|
|
self._allow_unscoped_legacy = allow_unscoped_legacy
|
|
self._backend: Any | None = None
|
|
self._backend_key: tuple[str, str, str, int] | None = None
|
|
self._lock = threading.RLock()
|
|
|
|
def _scope_config(self) -> _RuntimeScopeConfig | None:
|
|
"""Return this proxy's scope identity, resolving it lazily if needed.
|
|
|
|
deepagents 0.7 removed backend factories: the middleware only accepts
|
|
an initialized ``BackendProtocol``, so a deploy-scoped proxy is built
|
|
before any run exists and must read its scope from the active runnable
|
|
config on each operation. ``asyncio.to_thread`` copies the contextvars
|
|
context, so the runnable config remains readable from the worker
|
|
threads the inherited async methods dispatch into.
|
|
"""
|
|
|
|
if self._config is not None:
|
|
return self._config
|
|
return _runtime_scope_config(None, kind="filesystem backend")
|
|
|
|
@property
|
|
def id(self) -> str:
|
|
# This is queried while composing the model request; do not initialize
|
|
# the real backend or touch the Registry here.
|
|
config = self._config
|
|
if config is None:
|
|
try:
|
|
config = _runtime_scope_config(None, kind="filesystem backend")
|
|
except ScopeAccessError:
|
|
config = None
|
|
if config is None:
|
|
return "scope-deferred"
|
|
return f"scope-{config.scope_id[:8]}-{config.owner_id[:8]}"
|
|
|
|
def _delegate(self) -> Any:
|
|
"""Validate the current scope and return a concrete backend.
|
|
|
|
Every operation enters here, so a deleted scope or stale owner cannot
|
|
keep using a backend constructed before the lifecycle transition.
|
|
"""
|
|
|
|
# ``asyncio.to_thread`` copies the contextvars context, so the active
|
|
# runnable config is still readable here even though the inherited
|
|
# async methods dispatch this work to a worker thread. Registry
|
|
# validation therefore stays off the Agent event loop.
|
|
config = self._scope_config()
|
|
if config is None:
|
|
if not self._allow_unscoped_legacy or self._legacy_backend is None:
|
|
raise ScopeAccessError(
|
|
"deployed graph runs require a workspace scope"
|
|
)
|
|
return self._legacy_backend()
|
|
context = _resolve_scope_context(config)
|
|
if context is None:
|
|
raise ScopeAccessError("scoped backend lost its workspace scope")
|
|
if (is_required() or os.getenv("EVOSCIENTIST_DEPLOY_MODE", "").lower() == "full") and self._dangerous:
|
|
raise ScopeAccessError(
|
|
"dangerous_mode is incompatible with required isolation"
|
|
)
|
|
key = (context.scope_id, context.owner_id, context.thread_id, context.revision)
|
|
with self._lock:
|
|
if self._backend is None or self._backend_key != key:
|
|
self._backend = _build_backend(
|
|
context.files_dir,
|
|
context.runtime_dir,
|
|
dangerous=self._dangerous,
|
|
)
|
|
self._backend_key = key
|
|
return self._backend
|
|
|
|
def ls(self, path: str) -> LsResult:
|
|
return self._delegate().ls(path)
|
|
|
|
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult:
|
|
return self._delegate().read(file_path, offset, limit)
|
|
|
|
def grep(
|
|
self, pattern: str, path: str | None = None, glob: str | None = None
|
|
) -> GrepResult:
|
|
return self._delegate().grep(pattern, path, glob)
|
|
|
|
def glob(self, pattern: str, path: str | None = None) -> GlobResult:
|
|
return self._delegate().glob(pattern, path)
|
|
|
|
def write(self, file_path: str, content: str) -> WriteResult:
|
|
return self._delegate().write(file_path, content)
|
|
|
|
def edit(
|
|
self,
|
|
file_path: str,
|
|
old_string: str,
|
|
new_string: str,
|
|
replace_all: bool = False,
|
|
) -> EditResult:
|
|
return self._delegate().edit(file_path, old_string, new_string, replace_all)
|
|
|
|
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
return self._delegate().upload_files(files)
|
|
|
|
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
|
return self._delegate().download_files(paths)
|
|
|
|
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
|
return self._delegate().execute(command, timeout=timeout)
|
|
|
|
async def aexecute(
|
|
self, command: str, *, timeout: int | None = None
|
|
) -> ExecuteResponse:
|
|
backend = await asyncio.to_thread(self._delegate)
|
|
return await backend.aexecute(command, timeout=timeout)
|
|
|
|
|
|
def create_workspace_backend(
|
|
runtime: ToolRuntime[Any, Any],
|
|
*,
|
|
legacy_backend: Callable[[], Any],
|
|
dangerous: bool = False,
|
|
allow_unscoped_legacy: bool = True,
|
|
) -> Any:
|
|
"""Return a backend handle without blocking the Agent event loop."""
|
|
|
|
config = _runtime_scope_config(runtime, kind="filesystem backend")
|
|
if config is None:
|
|
if not allow_unscoped_legacy:
|
|
raise ScopeAccessError(
|
|
"deployed graph runs require a workspace scope"
|
|
)
|
|
return legacy_backend()
|
|
if (is_required() or os.getenv("EVOSCIENTIST_DEPLOY_MODE", "").lower() == "full") and dangerous:
|
|
raise ScopeAccessError("dangerous_mode is incompatible with required isolation")
|
|
return DeferredScopedBackend(config, dangerous=dangerous)
|
|
|
|
|
|
def create_workspace_backend_factory(
|
|
legacy_backend: Callable[[], Any],
|
|
*,
|
|
dangerous: bool = False,
|
|
allow_unscoped_legacy: bool = True,
|
|
) -> Callable[[ToolRuntime[Any, Any]], Any]:
|
|
def factory(runtime: ToolRuntime[Any, Any]) -> Any:
|
|
return create_workspace_backend(
|
|
runtime,
|
|
legacy_backend=legacy_backend,
|
|
dangerous=dangerous,
|
|
allow_unscoped_legacy=allow_unscoped_legacy,
|
|
)
|
|
|
|
return factory
|
|
|
|
|
|
def create_deferred_scoped_backend(
|
|
legacy_backend: Callable[[], Any],
|
|
*,
|
|
dangerous: bool = False,
|
|
allow_unscoped_legacy: bool = False,
|
|
) -> Any:
|
|
"""Return a deploy-scoped backend INSTANCE for deepagents >= 0.7.
|
|
|
|
deepagents 0.7 removed backend factories: ``FilesystemMiddleware`` now
|
|
rejects any ``backend`` that is callable without being a
|
|
``BackendProtocol`` instance. Return an instance instead and let it read
|
|
the run's workspace scope from the active runnable config on every
|
|
operation. Unscoped runs stay fail-closed unless the caller explicitly
|
|
allows the legacy backend.
|
|
"""
|
|
|
|
if (
|
|
is_required()
|
|
or os.getenv("EVOSCIENTIST_DEPLOY_MODE", "").lower() == "full"
|
|
) and dangerous:
|
|
raise ScopeAccessError(
|
|
"dangerous_mode is incompatible with required isolation"
|
|
)
|
|
return DeferredScopedBackend(
|
|
None,
|
|
dangerous=dangerous,
|
|
legacy_backend=legacy_backend,
|
|
allow_unscoped_legacy=allow_unscoped_legacy,
|
|
)
|
|
|
|
|
|
def workspace_metadata(record: ScopeRecord) -> dict[str, str | int]:
|
|
"""Metadata mirrored onto the LangGraph primary thread by trusted callers."""
|
|
|
|
return {
|
|
"workspace_schema_version": 1,
|
|
"workspace_scope_id": record.scope_id,
|
|
"workspace_status": record.state,
|
|
"workspace_scope_owner_id": record.primary_owner_id,
|
|
"workspace_scope_revision": record.revision,
|
|
"workspace_deployment_id": record.deployment_id,
|
|
}
|