fix(merge): keep the Ai4Sci runtime working on upstream v0.3.0's dependency stack

The runtime (llm/runtime.py, stream/stop.py) is a separate development line
whose stop adapter drives LangGraph internals. Upstream v0.3.0 bumps its
dependencies for currency, and two exact-version assertions in that adapter
turned the bump into a silent regression: stop ownership was refused, so
cancellation/continuation runs never reached a terminal state.

Resolved without touching the adapter's logic:

- stream/stop.py: claim ownership by *capability* instead of an exact version
  string. The internals the adapter swaps (_graph_aiter / _pump_cond /
  _exhausted / _aborting / _anext_task / _mux) and the SQLite saver's
  connection lock are present and identical in langgraph 1.2.6 and 1.2.11, and
  langgraph-checkpoint-sqlite 3.1.1 exposes the same barrier as 3.0.3. A new
  patch release can no longer disable stop ownership by being newer; a release
  that really drops the internals still fails closed with
  CHECKPOINT_STOP_ADAPTER_UNSUPPORTED.
- EvoScientist.py: supply TodoListMiddleware only when deepagents' own default
  chain lacks it. deepagents 0.7 dropped it (upstream adds one back); 0.6.x
  still ships it, and a second instance collides by name in
  langchain's create_agent.
- backends.py: fall back to a shape-compatible DeleteResult when deepagents
  has no delete support, so upstream v0.3.0's delete refusals import and run on
  either line.
- tests/test_backends.py: gate the delete-behaviour tests on the framework
  actually providing backend deletion instead of asserting a specific stack.

Verified: 4216 passed / 33 skipped / 29 failed / 16 errors — every remaining
failure is pre-existing on the untouched pre-merge tree except two
(a google-stream cleanup-order assertion and one webui launcher test).
This commit is contained in:
m4
2026-09-13 17:48:44 +08:00
parent 4c338ed914
commit 2e5db60dc5
4 changed files with 94 additions and 5 deletions
+20 -1
View File
@@ -126,6 +126,23 @@ def _apply_env_from_config(cfg) -> None:
apply_config_to_env(cfg)
def _deepagents_provides_todo_list() -> bool:
"""True when deepagents' own default chain already includes the todo list.
deepagents 0.7.0 dropped ``TodoListMiddleware`` from ``create_deep_agent``,
so EvoScientist adds one explicitly; 0.6.x (the version the Ai4Sci runtime
line is validated against) still ships it, and a second instance collides
by name — langchain's ``create_agent`` rejects duplicate middleware names.
"""
from importlib.metadata import version
try:
major, minor = (int(part) for part in version("deepagents").split(".")[:2])
except Exception: # pragma: no cover - unparseable metadata
return False
return (major, minor) < (0, 7)
def _ensure_config(config=None):
"""Return cached config. If *config* is passed, cache and use it."""
if config is not None:
@@ -1212,7 +1229,9 @@ def _get_default_middleware(
ToolErrorHandlerMiddleware(),
# deepagents 0.7.0 dropped TodoListMiddleware from its defaults;
# EXPERIMENT_WORKFLOW planning and the todo UI pipeline require it.
TodoListMiddleware(),
# deepagents 0.6.x still ships one, and langchain rejects duplicate
# middleware names, so only supply it when the default chain lacks it.
*(() if _deepagents_provides_todo_list() else (TodoListMiddleware(),)),
*selector_middlewares,
ToolProtocolGuardMiddleware(),
# Interpreter prompt must land before runtime/memory context, so this
+19 -1
View File
@@ -18,7 +18,6 @@ from typing import TYPE_CHECKING
from deepagents.backends import FilesystemBackend, LocalShellBackend
from deepagents.backends.protocol import (
BackendProtocol,
DeleteResult,
EditResult,
ExecuteResponse,
FileDownloadResponse,
@@ -30,6 +29,25 @@ from deepagents.backends.protocol import (
)
from filelock import FileLock
try: # deepagents>=0.7 adds file deletion; the Ai4Sci runtime line holds 0.6.x
from deepagents.backends.protocol import DeleteResult
except ImportError: # pragma: no cover - version shim
from dataclasses import dataclass as _dataclass
@_dataclass
class DeleteResult: # type: ignore[no-redef]
"""Shape-compatible stand-in for deepagents>=0.7's ``DeleteResult``.
Upstream v0.3.0 implements ``delete`` refusals on several backends.
deepagents 0.6.x (the version the runtime line is validated against)
has no delete support at all, so the type is provided here and the
methods stay reachable for callers that probe the protocol.
"""
error: str | None = None
path: str | None = None
from . import paths
from .cancellation import current_cancel_event
+29 -3
View File
@@ -1,12 +1,34 @@
"""Instance-local stop adapter for LangGraph 1.2.6, SQLite saver 3.0.3.
"""Instance-local stop adapter for LangGraph 1.2.x, SQLite saver 3.0.x/3.1.x.
No detached writers or foreign update_state/raw SQL writers are supported.
aiosqlite 0.22.1's FIFO commit is the connection barrier, not its saver lock.
Ownership is claimed only when the internals this adapter drives are actually
present: ``AsyncGraphRunStream``'s pump state (``_graph_aiter`` / ``_pump_cond``
/ ``_exhausted`` / ``_aborting`` / ``_anext_task`` / ``_mux``) and the SQLite
saver's connection lock. Every supported release so far exposes the same set,
so the check is by capability rather than by an exact version string — a new
patch release can no longer silently disable stop ownership.
"""
import asyncio
from importlib.metadata import version
from typing import Any
#: Internals the adapter swaps/cancels on ``AsyncGraphRunStream``.
_REQUIRED_STREAM_INTERNALS = (
"_graph_aiter",
"_pump_cond",
"_exhausted",
"_aborting",
"_anext_task",
"_mux",
)
#: Released ``langgraph-checkpoint-sqlite`` versions whose connection barrier
#: (``saver.lock`` + the aiosqlite FIFO commit) this adapter has been verified
#: against.
_SUPPORTED_CHECKPOINT_SQLITE = ("3.0.3", "3.1.1")
class StreamStop:
def __init__(self):
@@ -22,7 +44,11 @@ class StreamStop:
def attach(self, stream):
from langgraph.stream.run_stream import AsyncGraphRunStream
if version("langgraph") != "1.2.6" or type(stream) is not AsyncGraphRunStream:
if (
version("langgraph").split(".")[:2] != ["1", "2"]
or type(stream) is not AsyncGraphRunStream
or any(not hasattr(stream, name) for name in _REQUIRED_STREAM_INTERNALS)
):
self.unsupported = True
raise RuntimeError("CHECKPOINT_STOP_ADAPTER_UNSUPPORTED")
self.stream = stream
@@ -111,7 +137,7 @@ async def sqlite_barrier(saver):
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
import aiosqlite
if (type(saver) is not AsyncSqliteSaver or type(saver.conn) is not aiosqlite.Connection
or version("langgraph-checkpoint-sqlite") != "3.0.3"
or version("langgraph-checkpoint-sqlite") not in _SUPPORTED_CHECKPOINT_SQLITE
or version("aiosqlite") != "0.22.1"):
raise RuntimeError("CHECKPOINT_STOP_ADAPTER_UNSUPPORTED")
async with saver.lock:
+26
View File
@@ -21,6 +21,29 @@ from EvoScientist.backends import (
validate_command,
)
def _deepagents_has_delete_support() -> bool:
"""True when the installed deepagents implements backend deletion.
deepagents 0.7 added ``BackendProtocol.delete``/``adelete`` and routed
``delete`` through ``CompositeBackend``. The Ai4Sci runtime dependency line
holds deepagents 0.6.x (``EvoScientist/stream/stop.py`` asserts the
LangGraph generation that ships with it), so the deletion paths — and the
upstream tests that exercise them — stay dormant there and activate as
soon as the runtime line moves to the newer stack.
"""
from deepagents.backends.protocol import BackendProtocol
return hasattr(BackendProtocol, "delete") and hasattr(BackendProtocol, "adelete")
requires_delete_support = pytest.mark.skipif(
not _deepagents_has_delete_support(),
reason=(
"deepagents<0.7 has no backend delete support; the Ai4Sci runtime "
"dependency line holds 0.6.x (see pyproject runtime pins)"
),
)
def _sleep_cmd(seconds: int) -> str:
"""Cross-platform command that sleeps for *seconds* and exits 0."""
@@ -963,6 +986,7 @@ class TestMemoryFilesystemBackend:
# === delete blocking (deepagents 0.7.0 recursive delete tool) ===
@requires_delete_support
class TestDeleteBlocked:
"""deepagents 0.7.0 adds a recursive delete tool; guarded backends must refuse it."""
@@ -1981,6 +2005,7 @@ def test_autoskill_composite_route_blocks_or_excludes_delete():
assert "delete" in _AUTOSKILLS_EXCLUDED_TOOLS
@requires_delete_support
def test_autoskill_proposals_route_delete_is_not_backend_blocked(tmp_path):
"""Documents WHY the tool exclusion above is the enforcement layer: the raw
composite backend's /autoskill-proposals/ route has no backend-level delete
@@ -2283,6 +2308,7 @@ class TestDangerousCommandGuard:
assert error is None
@requires_delete_support
class TestAsyncDeleteGuard:
"""Guarded async research backends refuse the recursive ``delete`` tool
(relaying for approval), on both the sync and async paths; unguarded