test: cover stop contract, execution adapters, checkpointer race and runtime identity
Docker / build (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Build / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Build / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""B01 real factory contracts. Run with --noconftest in an isolated env."""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def embedding(tmp_path, monkeypatch):
|
||||
assert os.environ.get("PYTHON_DOTENV_DISABLED") == "1"
|
||||
assert "unified-execution" in os.environ["EVOSCIENTIST_HOME"]
|
||||
from EvoScientist.config.settings import EvoScientistConfig
|
||||
from EvoScientist.llm.contracts import AgentModelSet, WebHostContext
|
||||
from EvoScientist.workspace_files import ScopedFilesystemBackend
|
||||
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||
|
||||
runtime = importlib.import_module("EvoScientist.EvoScientist")
|
||||
web = importlib.import_module("EvoScientist.web_runtime")
|
||||
monkeypatch.setattr(runtime, "_load_mcp_tools_cached", lambda **kw: {})
|
||||
monkeypatch.setattr(runtime, "_load_mcp_config_once", lambda: ("b01-empty", {}))
|
||||
|
||||
class Model(FakeMessagesListChatModel):
|
||||
def bind_tools(self, tools, **kwargs):
|
||||
return self
|
||||
|
||||
def build(saver, tenant, responses, backend=None):
|
||||
root = tmp_path / tenant
|
||||
root.mkdir(exist_ok=True)
|
||||
memory = tmp_path / (tenant + "-memory")
|
||||
memory.mkdir(exist_ok=True)
|
||||
backend = backend or ScopedFilesystemBackend(root)
|
||||
model = Model(responses=responses)
|
||||
cfg = EvoScientistConfig(
|
||||
openai_api_key="b01-not-a-real-key-" + tenant,
|
||||
enable_async_subagents=False, enable_scheduler=False,
|
||||
memory_workers_enabled=False, auto_approve=False,
|
||||
)
|
||||
_, revision = web.web_tool_registry_manifest()
|
||||
graph = web.create_web_agent(
|
||||
snapshot=None,
|
||||
host=WebHostContext(
|
||||
workspace_dir=str(root), memory_dir=str(memory),
|
||||
workspace_backend=backend, checkpointer=saver,
|
||||
tool_registry_revision=revision, tool_selector_threshold=10000,
|
||||
),
|
||||
model_set=AgentModelSet(model, model, model), config=cfg,
|
||||
)
|
||||
return graph, root, backend
|
||||
|
||||
return build, runtime
|
||||
|
||||
|
||||
def _run_config(tenant):
|
||||
# Host-owned identity: SQLite does not isolate by user_id automatically.
|
||||
identity = json.dumps([tenant, "same-conversation"], separators=(",", ":"))
|
||||
return {"configurable": {"thread_id": identity, "ai4sci_run_id": tenant}}
|
||||
|
||||
|
||||
def _call(name, args, ident):
|
||||
from langchain_core.messages import AIMessage
|
||||
return AIMessage(content="", tool_calls=[
|
||||
{"name": name, "args": args, "id": ident, "type": "tool_call"}
|
||||
])
|
||||
|
||||
|
||||
def test_explicit_web_factory_preserves_environment_and_globals(embedding, tmp_path):
|
||||
from EvoScientist import paths
|
||||
from langchain_core.messages import AIMessage
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
build, runtime = embedding
|
||||
before_env = dict(os.environ)
|
||||
names = ("_config", "_chat_model", "_chat_model_key", "_EvoScientist_agent")
|
||||
before_globals = {name: vars(runtime).get(name) for name in names}
|
||||
before_paths = (paths.WORKSPACE_ROOT, paths._active_workspace)
|
||||
|
||||
async def scenario():
|
||||
async with AsyncSqliteSaver.from_conn_string(str(tmp_path / "pure.sqlite")) as saver:
|
||||
await saver.setup()
|
||||
for tenant in ("alice", "bob"):
|
||||
graph, _, _ = build(saver, tenant, [AIMessage(content="ok")])
|
||||
assert hasattr(graph, "ainvoke")
|
||||
changed = {key for key in before_env.keys() | os.environ.keys()
|
||||
if before_env.get(key) != os.environ.get(key)}
|
||||
assert not changed, f"factory changed environment keys: {sorted(changed)}"
|
||||
assert all(vars(runtime).get(n) is value for n, value in before_globals.items())
|
||||
assert (paths.WORKSPACE_ROOT, paths._active_workspace) == before_paths
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_two_tenants_real_file_tools_and_sqlite_reopen(embedding, tmp_path):
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
build, _ = embedding
|
||||
configs = {tenant: _run_config(tenant) for tenant in ("alice", "bob")}
|
||||
assert configs["alice"]["configurable"]["thread_id"] != configs["bob"]["configurable"]["thread_id"]
|
||||
|
||||
async def scenario():
|
||||
db = str(tmp_path / "persistent.sqlite")
|
||||
async with AsyncSqliteSaver.from_conn_string(db) as saver:
|
||||
await saver.setup()
|
||||
graphs = {}
|
||||
for tenant in configs:
|
||||
responses = [
|
||||
_call("write_file", {"file_path": "/workspace/probe.txt", "content": tenant + "_ONLY"}, "write"),
|
||||
_call("read_file", {"file_path": "/workspace/probe.txt"}, "read"),
|
||||
_call("read_file", {"file_path": "/workspace/../bob/probe.txt"}, "escape"),
|
||||
AIMessage(content="done"),
|
||||
]
|
||||
graphs[tenant], _, _ = build(saver, tenant, responses)
|
||||
results = await asyncio.gather(*[
|
||||
graphs[t].ainvoke({"messages": [HumanMessage(content="file probe")]}, configs[t])
|
||||
for t in configs
|
||||
])
|
||||
for tenant, result in zip(configs, results):
|
||||
tools = {m.tool_call_id: m for m in result["messages"] if isinstance(m, ToolMessage)}
|
||||
assert (tmp_path / tenant / "probe.txt").read_text() == tenant + "_ONLY"
|
||||
assert tenant + "_ONLY" in str(tools["read"].content)
|
||||
assert "_ONLY" not in str(tools["escape"].content)
|
||||
assert "error" in str(tools["escape"].content).lower()
|
||||
other = "bob" if tenant == "alice" else "alice"
|
||||
assert other + "_ONLY" not in str(result["messages"])
|
||||
assert (tmp_path / "persistent.sqlite").stat().st_size > 0
|
||||
async with AsyncSqliteSaver.from_conn_string(db) as reopened:
|
||||
await reopened.setup()
|
||||
for tenant in configs:
|
||||
graph, _, backend = build(reopened, tenant, [AIMessage(content="restored")])
|
||||
state = await graph.aget_state(configs[tenant])
|
||||
assert tenant + "_ONLY" in str(state.values["messages"])
|
||||
other = "bob" if tenant == "alice" else "alice"
|
||||
assert other + "_ONLY" not in str(state.values["messages"])
|
||||
history = [item async for item in graph.aget_state_history(configs[tenant])]
|
||||
assert len(history) > 1
|
||||
assert all(other + "_ONLY" not in str(s.values) for s in history)
|
||||
assert backend.write("/workspace/../outside.txt", "forbidden").error
|
||||
assert not (tmp_path / "outside.txt").exists()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_web_manual_execute_interrupts_before_backend(embedding, tmp_path):
|
||||
from deepagents.backends.protocol import SandboxBackendProtocol, ExecuteResponse
|
||||
from EvoScientist.workspace_files import ScopedFilesystemBackend
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
from langgraph.types import Command
|
||||
|
||||
calls = []
|
||||
|
||||
class ProbeBackend(ScopedFilesystemBackend, SandboxBackendProtocol):
|
||||
@property
|
||||
def id(self):
|
||||
return "b01-no-shell"
|
||||
|
||||
def execute(self, command, *, timeout=None):
|
||||
# No shell or process is ever launched by this test capability.
|
||||
assert command == "b01-probe"
|
||||
calls.append(command)
|
||||
return ExecuteResponse(output="probe-ok", exit_code=0)
|
||||
|
||||
build, _ = embedding
|
||||
|
||||
async def scenario():
|
||||
async with AsyncSqliteSaver.from_conn_string(str(tmp_path / "review.sqlite")) as saver:
|
||||
await saver.setup()
|
||||
graph, _, _ = build(saver, "alice", [
|
||||
_call("execute", {"command": "b01-probe"}, "execute"), AIMessage(content="done")
|
||||
], ProbeBackend(tmp_path / "alice"))
|
||||
config = _run_config("alice")
|
||||
result = await graph.ainvoke({"messages": [HumanMessage(content="probe")]}, config)
|
||||
assert result.get("__interrupt__")
|
||||
assert calls == []
|
||||
state = await graph.aget_state(config)
|
||||
assert state.values["_verified_review_mode"]["mode"] == "manual"
|
||||
await graph.ainvoke(Command(resume={"decisions": [{"type": "approve"}]}), config)
|
||||
assert calls == ["b01-probe"]
|
||||
|
||||
asyncio.run(scenario())
|
||||
Reference in New Issue
Block a user