d4b53bfb08
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
183 lines
8.2 KiB
Python
183 lines
8.2 KiB
Python
"""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()) |