Files
EvoScientist-Multi/tests/test_code_interpreter_middleware.py
T
jfilipiuk 952e68efe3 fix: scope quickjs snapshot to turn to keep checkpoints small (#316)
* fix: scope quickjs snapshot to turn to keep checkpoints small

* fix: strip _quickjs_snapshot_payload from state/history responses instead of dropping mode=thread

* fix: recurse strip into nested subgraph StateSnapshot

* fix: drop conditional-snapshot gate that leaked repl slots

* fix: assert LangGraph state-shape invariants at import time

* refactor: discover graphs to filter from langgraph.json

* test: assert copy() preserves subclass; iterate langgraph.json for subagent coverage

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-07-13 12:24:52 +00:00

339 lines
13 KiB
Python

"""Regression tests for the code_interpreter PTC allowlist and the
``EvoCodeInterpreterMiddleware`` subclass shape.
langchain-quickjs >=0.3 reserves the ``task`` sub-agent dispatch tool as the
top-level REPL global and raises ``ValueError`` if ``task`` appears in the
``ptc`` allowlist. EvoScientist must therefore keep ``task`` out of the
allowlist (``task()`` stays reachable as the REPL global, with responseSchema).
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from EvoScientist.middleware.code_interpreter import (
_DEFAULT_PTC_ALLOWLIST,
create_code_interpreter_middleware,
)
def test_task_excluded_from_ptc_allowlist():
# Exposing `task` via ptc raises on quickjs >=0.3 — it is the REPL global.
assert "task" not in _DEFAULT_PTC_ALLOWLIST
def test_async_dispatch_tools_remain_in_allowlist():
# Guard against over-deletion: async fan-out is the killer PTC use case.
for name in ("start_async_task", "check_async_task", "list_async_tasks"):
assert name in _DEFAULT_PTC_ALLOWLIST
def test_filter_tools_for_ptc_accepts_default_allowlist():
# End-to-end guard: the live quickjs filter must accept our allowlist even
# when a `task` tool is present in the agent toolset.
_ptc = pytest.importorskip("langchain_quickjs._ptc")
from langchain_core.tools import tool
@tool
def task(description: str) -> str:
"""dummy sub-agent dispatch tool"""
return "ok"
_ptc.filter_tools_for_ptc(
[task], _DEFAULT_PTC_ALLOWLIST, self_tool_name="code_interpreter"
)
def test_create_code_interpreter_middleware_builds():
assert create_code_interpreter_middleware() is not None
def test_middleware_uses_thread_mode():
"""Upstream ``mode="thread"`` (the default) preserves cross-turn REPL
state as ``langchain-ai/deepagents#3064`` shipped it. The wire-cost
bloat that motivated the earlier ``mode="turn"`` regression guard is
fixed at the API serialization layer (``EvoFilteredGraph`` in
``EvoScientist/langgraph_dev/main_graph.py``), not by revoking the
persistence feature.
"""
mw = create_code_interpreter_middleware()
assert mw._mode == "thread"
def test_after_agent_evicts_slot_on_untouched_turn():
"""Regression guard against reintroducing a conditional-snapshot gate
that skips ``after_agent`` on untouched turns.
Upstream ``after_agent`` in ``langchain_quickjs/middleware.py`` performs
two things: snapshot the REPL AND evict the slot (``finally:
self._registry.evict(thread_id)``). ``before_agent`` restores the REPL
on any turn that follows a touched one via ``self._registry.get`` —
which is get-or-create. So if ``after_agent`` returns early without
evicting, one ``ThreadWorker`` + QuickJS Runtime leaks per persistent
``thread_id`` that ever went touched → quiet.
Fix: don't override ``after_agent`` / ``aafter_agent`` at all — inherit
upstream's unconditional snapshot+evict behavior. This test creates a
slot the way ``before_agent`` would, calls ``after_agent`` with an
untouched-state input, and asserts the slot was evicted.
"""
mw = create_code_interpreter_middleware()
tid = mw._fallback_thread_id
# Simulate the slot creation that ``before_agent`` performs when it sees
# a prior turn's snapshot payload in state.
mw._registry.get(tid)
assert len(mw._registry._slots) == 1
# Untouched-turn state: no ``code_interpreter`` tool call between the
# last ``HumanMessage`` and end. Under the earlier buggy gate this
# returned ``{}`` without evicting — leaking the slot created above.
untouched_state = {
"_quickjs_snapshot_payload": b"payload-from-prior-turn",
"messages": [
HumanMessage(content="thanks"),
AIMessage(content="you're welcome"),
],
}
mw.after_agent(untouched_state, runtime=None)
assert len(mw._registry._slots) == 0, (
"after_agent must evict the slot even on untouched turns, because "
"before_agent already restored a REPL that owns a ThreadWorker + "
"QuickJS Runtime. Skipping eviction leaks those resources."
)
def test_evo_filtered_graph_strips_private_snapshot_field():
"""The ``StateSnapshot`` returned by ``EvoScientist_agent.get_state`` must
not contain ``_quickjs_snapshot_payload`` in either ``values`` (the
materialized channel payload) or ``metadata['writes']`` (the raw write
records surfaced by ``get_state_history``).
"""
from EvoScientist.langgraph_dev.main_graph import _EvoFilteredGraph, _strip_private
snap = MagicMock()
snap.values = {
"messages": ["m1"],
"_quickjs_snapshot_payload": b"x" * 100,
"skills_metadata": [],
}
snap.metadata = {
"source": "loop",
"step": 42,
"writes": {
"CodeInterpreterMiddleware.after_agent": {
"_quickjs_snapshot_payload": ("snap", b"y" * 1_400_000),
"messages": [],
},
"model": {"messages": ["m1"]},
},
"parents": {},
}
_strip_private(snap)
snap._replace.assert_called_once()
kwargs = snap._replace.call_args.kwargs
assert "_quickjs_snapshot_payload" not in kwargs["values"]
assert "messages" in kwargs["values"]
assert "skills_metadata" in kwargs["values"]
scrubbed_writes = kwargs["metadata"]["writes"]
assert (
"_quickjs_snapshot_payload"
not in scrubbed_writes["CodeInterpreterMiddleware.after_agent"]
)
assert "messages" in scrubbed_writes["CodeInterpreterMiddleware.after_agent"]
assert scrubbed_writes["model"] == {"messages": ["m1"]}
# Non-writes metadata keys are preserved.
assert kwargs["metadata"]["source"] == "loop"
assert kwargs["metadata"]["step"] == 42
# Sanity: the class exists and inherits from CompiledStateGraph.
from langgraph.graph.state import CompiledStateGraph
assert issubclass(_EvoFilteredGraph, CompiledStateGraph)
def test_strip_private_handles_missing_metadata_writes():
"""``metadata['writes']`` can be missing or ``None`` on some snapshots
(e.g. initial state). The filter must not crash and must still strip
values.
"""
from EvoScientist.langgraph_dev.main_graph import _strip_private
snap = MagicMock()
snap.values = {"_quickjs_snapshot_payload": b"x", "messages": []}
snap.metadata = {"source": "input", "step": -1, "writes": None}
snap.tasks = ()
_strip_private(snap)
kwargs = snap._replace.call_args.kwargs
assert "_quickjs_snapshot_payload" not in kwargs["values"]
# writes was None, metadata passes through unchanged.
assert kwargs["metadata"]["writes"] is None
def test_strip_private_scrubs_task_result_snapshot_blob():
"""``tasks[*].result`` is where ``after_agent``'s return dict lands.
When the middleware snapshots, ``result`` carries
``{"_quickjs_snapshot_payload": ("snap", ~1.4 MB bytes)}``. Verified
on live history: this is the dominant per-response leak, larger than
``values`` and ``metadata.writes`` combined for anchor checkpoints.
"""
from EvoScientist.langgraph_dev.main_graph import _strip_private
class FakeTask:
def __init__(self, id_, result):
self.id = id_
self.name = "CodeInterpreterMiddleware.after_agent"
self.result = result
def _replace(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
return self
leaking_task = FakeTask(
"t1", {"_quickjs_snapshot_payload": ("snap", b"z" * 1_400_000), "messages": []}
)
clean_task = FakeTask("t2", {"messages": ["hi"]})
snap = MagicMock()
snap.values = {}
snap.metadata = {"source": "loop", "step": 5}
snap.tasks = (leaking_task, clean_task)
_strip_private(snap)
kwargs = snap._replace.call_args.kwargs
tasks_after = kwargs["tasks"]
assert "_quickjs_snapshot_payload" not in tasks_after[0].result
assert "messages" in tasks_after[0].result
# Clean task is passed through untouched.
assert tasks_after[1] is clean_task
def test_agent_uses_filtered_graph_class():
"""The ``__class__`` swap in ``main_graph.py`` is the load-bearing wiring
that makes ``_strip_private`` reach the langgraph-api endpoints.
``_strip_private`` and ``_EvoFilteredGraph`` in isolation don't prove the
swap ran; every other test in this file passes even if someone drops the
swap line. This asserts the compiled agent is actually the filtered
subclass at module-load time, and that the subclass survives
``Pregel.copy(update=...)`` — the call langgraph-api makes in
``get_graph`` before yielding the graph to endpoint handlers.
"""
from EvoScientist.langgraph_dev.main_graph import (
EvoScientist_agent,
_EvoFilteredGraph,
)
assert isinstance(EvoScientist_agent, _EvoFilteredGraph)
assert isinstance(EvoScientist_agent.copy(update={}), _EvoFilteredGraph)
def test_all_registered_graphs_use_filtered_graph_class():
"""Every graph registered in ``langgraph.json`` (main + all subagents)
gets the ``__class__`` swap via ``_apply_filter_to_all_registered_graphs``.
Iterating the config directly matches the auto-detect refactor: adding
a new subagent to ``langgraph.json`` should not require a corresponding
test update.
Subagents get ``create_code_interpreter_middleware`` unconditionally
(``EvoScientist.py:_build_middleware_stack``), so they can touch the
QuickJS REPL and write ``_quickjs_snapshot_payload`` on their own
checkpoint namespace. Async subagents also get their own ``thread_id``
and their ``/threads/{id}/state`` endpoint runs on their own compiled
graph — without the swap on those graphs, our filter would miss that
endpoint entirely.
"""
import json
from importlib import import_module
from pathlib import Path
# Import triggers ``main_graph``'s swap loop.
from EvoScientist.langgraph_dev import main_graph
from EvoScientist.langgraph_dev.main_graph import _EvoFilteredGraph
config_path = Path(main_graph.__file__).parent / "langgraph.json"
config = json.loads(config_path.read_text())
for name, path in config["graphs"].items():
module_path, attr = path.rsplit(":", 1)
graph = getattr(import_module(module_path), attr)
assert isinstance(graph, _EvoFilteredGraph), (
f"graph {name!r} ({path}) did not receive the class swap"
)
def test_strip_private_recurses_into_nested_subgraph_state():
"""When ``subgraphs=True``, ``PregelTask.state`` holds a nested
``StateSnapshot`` for the subgraph. Its ``values`` (and its own nested
tasks) can carry ``_quickjs_snapshot_payload`` just like the parent.
Recursion covers the compound leak path CodeRabbit flagged.
"""
from langgraph.types import StateSnapshot
from EvoScientist.langgraph_dev.main_graph import _strip_private
nested_snap = StateSnapshot(
values={"_quickjs_snapshot_payload": b"n" * 1_400_000, "messages": []},
next=(),
config={},
metadata={"source": "loop", "step": 3},
created_at="2026-07-01T12:00:00Z",
parent_config=None,
tasks=(),
interrupts=(),
)
class FakeTask:
def __init__(self, state):
self.id = "sub-1"
self.name = "subgraph"
self.result = None
self.state = state
def _replace(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
return self
task_with_nested = FakeTask(nested_snap)
task_with_config_state = FakeTask({"configurable": {"thread_id": "t"}})
snap = MagicMock()
snap.values = {}
snap.metadata = {"source": "loop", "step": 5}
snap.tasks = (task_with_nested, task_with_config_state)
_strip_private(snap)
kwargs = snap._replace.call_args.kwargs
tasks_after = kwargs["tasks"]
# Nested StateSnapshot got recursively scrubbed.
assert "_quickjs_snapshot_payload" not in tasks_after[0].state.values
assert "messages" in tasks_after[0].state.values
# A dict (RunnableConfig-shaped) state passes through unchanged — we only
# recurse into ``StateSnapshot`` instances.
assert tasks_after[1].state == {"configurable": {"thread_id": "t"}}
def test_strip_private_scrubs_delta_counters():
"""``metadata['counters_since_delta_snapshot']`` is a small
``{channel: [count, superstep]}`` bookkeeping map. Not a size problem,
but leaks the channel name — strip for consistency with the private
annotation.
"""
from EvoScientist.langgraph_dev.main_graph import _strip_private
snap = MagicMock()
snap.values = {}
snap.metadata = {
"source": "loop",
"step": 5,
"counters_since_delta_snapshot": {
"_quickjs_snapshot_payload": [1, 14],
"messages": [3, 14],
},
}
snap.tasks = ()
_strip_private(snap)
kwargs = snap._replace.call_args.kwargs
counters = kwargs["metadata"]["counters_since_delta_snapshot"]
assert "_quickjs_snapshot_payload" not in counters
assert "messages" in counters