feat: prepare EvoScientist 0.3.0
Docker / build (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
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
Docker / build (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
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
Add bounded document ingestion, controlled web search, recoverable session support, subagent timeouts, and the native sandbox runtime contract. Unify package versioning and add release-focused regression coverage.
This commit is contained in:
@@ -15,6 +15,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from EvoScientist.llm.contracts import EvoRuntimeError
|
||||
from EvoScientist.llm.errors import (
|
||||
AgentControlError,
|
||||
ModelToolProtocolError,
|
||||
@@ -169,6 +170,16 @@ class TestNormalize:
|
||||
|
||||
assert _normalize(req, error) is None
|
||||
|
||||
def test_stable_runtime_error_passes_through(self):
|
||||
req = _request(_openai_model())
|
||||
error = EvoRuntimeError(
|
||||
"UPSTREAM_RATE_LIMITED",
|
||||
"模型服务请求频率超限,请稍后重试或切换模型。",
|
||||
details=({"http_status": 429},),
|
||||
)
|
||||
|
||||
assert _normalize(req, error) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_provider_error — used by tool selector to distinguish provider
|
||||
@@ -397,6 +408,23 @@ class TestMiddleware:
|
||||
assert excinfo.value.code == "MODEL_TOOL_PROTOCOL_INVALID"
|
||||
assert excinfo.value.fallbackable is True
|
||||
|
||||
def test_awrap_preserves_stable_runtime_error_identity(self):
|
||||
raised = EvoRuntimeError(
|
||||
"UPSTREAM_RATE_LIMITED",
|
||||
"模型服务请求频率超限,请稍后重试或切换模型。",
|
||||
details=({"http_status": 429},),
|
||||
)
|
||||
|
||||
async def handler(_req):
|
||||
raise raised
|
||||
|
||||
req = _request(_openai_model())
|
||||
with pytest.raises(EvoRuntimeError) as excinfo:
|
||||
self._run_awrap(ErrorNormalizationMiddleware(), req, handler)
|
||||
|
||||
assert excinfo.value is raised
|
||||
assert excinfo.value.code == "UPSTREAM_RATE_LIMITED"
|
||||
|
||||
def test_awrap_wraps_any_exception_from_recognized_model(self):
|
||||
"""Any exception raised inside a call to a provider-recognized
|
||||
model gets wrapped — including builtins like ``RuntimeError``.
|
||||
|
||||
@@ -4,6 +4,7 @@ import httpx
|
||||
import pytest
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from EvoScientist.llm.contracts import EvoRuntimeError
|
||||
from EvoScientist.llm.gateway_proxy import GatewayProxyChatModel
|
||||
|
||||
|
||||
@@ -43,6 +44,18 @@ class _FakeClient:
|
||||
return _FakeStream(self._lines)
|
||||
|
||||
|
||||
def test_runtime_error_repr_preserves_only_stable_code():
|
||||
error = EvoRuntimeError(
|
||||
"UPSTREAM_RATE_LIMITED",
|
||||
"safe display message",
|
||||
details=({"provider_request": "must-not-persist"},),
|
||||
)
|
||||
|
||||
assert repr(error) == "EvoRuntimeError(code='UPSTREAM_RATE_LIMITED')"
|
||||
assert "safe display message" not in repr(error)
|
||||
assert "must-not-persist" not in repr(error)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_astream_yields_chunks_from_sse(monkeypatch):
|
||||
model = GatewayProxyChatModel(
|
||||
@@ -52,7 +65,7 @@ async def test_astream_yields_chunks_from_sse(monkeypatch):
|
||||
)
|
||||
msg = {"type": "AIMessageChunk", "data": {"content": "hello"}}
|
||||
lines = [
|
||||
f'data: {json.dumps({"delta": {"message": msg}})}\n',
|
||||
f"data: {json.dumps({'delta': {'message': msg}})}\n",
|
||||
'data: {"delta": {"message": {"type": "AIMessageChunk", "data": {"content": " world"}}}}\n',
|
||||
"data: [DONE]\n",
|
||||
]
|
||||
@@ -88,7 +101,7 @@ async def test_astream_roundtrips_streaming_tool_call_chunks(monkeypatch):
|
||||
],
|
||||
},
|
||||
}
|
||||
lines = [f'data: {json.dumps({"delta": {"message": msg}})}\n', "data: [DONE]\n"]
|
||||
lines = [f"data: {json.dumps({'delta': {'message': msg}})}\n", "data: [DONE]\n"]
|
||||
fake = _FakeClient(lines)
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: fake)
|
||||
|
||||
@@ -114,3 +127,24 @@ async def test_astream_raises_on_missing_done(monkeypatch):
|
||||
|
||||
with pytest.raises(RuntimeError, match="AI4SCI_MODEL_STREAM_INCOMPLETE"):
|
||||
_ = [c async for c in model._astream([HumanMessage(content="hi")])]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_astream_projects_gateway_error_frame(monkeypatch):
|
||||
model = GatewayProxyChatModel(
|
||||
gateway_url="http://gw",
|
||||
run_id="run-1",
|
||||
envelope_signature="sig",
|
||||
)
|
||||
lines = [
|
||||
'data: {"type":"error","code":"UPSTREAM_RATE_LIMITED",'
|
||||
'"status":429,"message":"模型服务请求频率超限,请稍后重试或切换模型。"}\n'
|
||||
]
|
||||
fake = _FakeClient(lines)
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: fake)
|
||||
|
||||
with pytest.raises(EvoRuntimeError) as exc_info:
|
||||
_ = [c async for c in model._astream([HumanMessage(content="hi")])]
|
||||
|
||||
assert exc_info.value.code == "UPSTREAM_RATE_LIMITED"
|
||||
assert exc_info.value.details == ({"http_status": 429},)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -21,6 +23,18 @@ def _installation(tmp_path: Path) -> sandbox.NativeSandboxInstallation:
|
||||
)
|
||||
|
||||
|
||||
def test_existing_read_paths_resolve_and_deduplicate_symlink_aliases(tmp_path: Path):
|
||||
usr = tmp_path / "usr"
|
||||
usr.mkdir()
|
||||
bin_alias = tmp_path / "bin"
|
||||
bin_alias.symlink_to(usr, target_is_directory=True)
|
||||
missing = tmp_path / "missing"
|
||||
|
||||
paths = sandbox._existing_resolved_paths((str(usr), str(bin_alias), str(missing)))
|
||||
|
||||
assert paths == (str(usr.resolve()),)
|
||||
|
||||
|
||||
def test_policy_denies_root_and_only_writes_scope_and_command_tmp(tmp_path: Path):
|
||||
files = tmp_path / "files"
|
||||
command_tmp = tmp_path / "runtime" / "tmp" / "run"
|
||||
@@ -38,6 +52,7 @@ def test_policy_denies_root_and_only_writes_scope_and_command_tmp(tmp_path: Path
|
||||
"/dev/null",
|
||||
]
|
||||
assert policy["filesystem"]["denyWrite"] == [
|
||||
str(files / "uploads"),
|
||||
"/tmp/claude",
|
||||
"/private/tmp/claude",
|
||||
"/dev/tty",
|
||||
@@ -50,6 +65,48 @@ def test_policy_denies_root_and_only_writes_scope_and_command_tmp(tmp_path: Path
|
||||
assert "control" not in json.dumps(policy)
|
||||
|
||||
|
||||
def test_weaker_nested_mode_requires_explicit_environment_opt_in(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.delenv("EVOSCIENTIST_NATIVE_SANDBOX_WEAKER_NESTED", raising=False)
|
||||
files = tmp_path / "files"
|
||||
command_tmp = tmp_path / "runtime" / "tmp" / "run"
|
||||
files.mkdir()
|
||||
command_tmp.mkdir(parents=True)
|
||||
installation = _installation(tmp_path)
|
||||
|
||||
assert sandbox._sandbox_settings(installation, files, command_tmp)[
|
||||
"enableWeakerNestedSandbox"
|
||||
] is False
|
||||
|
||||
monkeypatch.setenv("EVOSCIENTIST_NATIVE_SANDBOX_WEAKER_NESTED", "true")
|
||||
assert sandbox._sandbox_settings(installation, files, command_tmp)[
|
||||
"enableWeakerNestedSandbox"
|
||||
] is True
|
||||
|
||||
|
||||
def test_network_preflight_probe_accepts_kernel_denied_unix_socket(monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self, family=None, *_args):
|
||||
if family == 1:
|
||||
raise PermissionError("blocked by seccomp")
|
||||
|
||||
def connect_ex(self, _address):
|
||||
return 1
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
fake_socket = types.SimpleNamespace(
|
||||
AF_UNIX=1,
|
||||
socket=lambda family=None, *args: FakeSocket(family, *args),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "socket", fake_socket)
|
||||
|
||||
namespace: dict[str, object] = {}
|
||||
exec(sandbox._network_preflight_probe(1234, Path("/blocked.sock")), namespace)
|
||||
|
||||
|
||||
def test_clean_environment_does_not_inherit_secrets(tmp_path: Path, monkeypatch):
|
||||
command_tmp = tmp_path / "tmp"
|
||||
(command_tmp / "home").mkdir(parents=True)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import runpy
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
PATCH_SCRIPT = Path(__file__).parents[1] / "runtime" / "native-sandbox" / "patch_merged_usr.py"
|
||||
|
||||
|
||||
def test_patch_skips_only_symlink_aliases_covered_by_read_allow(tmp_path: Path):
|
||||
namespace = runpy.run_path(str(PATCH_SCRIPT))
|
||||
patch_file = cast(Callable[[Path], None], namespace["patch_file"])
|
||||
|
||||
source = tmp_path / "linux-sandbox-utils.js"
|
||||
source.write_text(
|
||||
"""function pushReadDenyDirMounts(args, normalizedPath, allowedWritePaths, readAllowPaths) {
|
||||
const denySep = normalizedPath === '/' ? '/' : normalizedPath + '/';
|
||||
args.push('--tmpfs', normalizedPath);
|
||||
for (const writePath of allowedWritePaths) {
|
||||
if (writePath.startsWith(denySep) || writePath === normalizedPath) {
|
||||
args.push('--bind', writePath, writePath);
|
||||
}
|
||||
}
|
||||
for (const allowPath of readAllowPaths) {
|
||||
if (allowPath.startsWith(denySep) || allowPath === normalizedPath) {
|
||||
if (!fs.existsSync(allowPath)) {
|
||||
continue;
|
||||
}
|
||||
if (allowedWritePaths.some(w => (w.startsWith(denySep) || w === normalizedPath) &&
|
||||
(allowPath === w || allowPath.startsWith(w + '/')))) {
|
||||
continue;
|
||||
}
|
||||
args.push('--ro-bind', allowPath, allowPath);
|
||||
logForDebugging(`[Sandbox Linux] Re-allowed read access within denied region: ${allowPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const rootSkip = new Set(['proc', 'dev', 'sys']);
|
||||
for (const p of readConfig?.denyOnly || []) {
|
||||
if (normalizePathForSandbox(p) === '/') {
|
||||
for (const child of fs.readdirSync('/')) {
|
||||
if (!rootSkip.has(child))
|
||||
readDenyPaths.push('/' + child);
|
||||
}
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
patch_file(source)
|
||||
patched = source.read_text(encoding="utf-8")
|
||||
|
||||
assert "isSymbolicLink()" in patched
|
||||
assert "readAllowPaths.some" in patched
|
||||
assert "resolved.startsWith(allowPath + '/')" in patched
|
||||
assert "args.push('--remount-ro', normalizedPath)" in patched
|
||||
assert "!allowedWritePaths.includes(normalizedPath)" in patched
|
||||
with pytest.raises(RuntimeError, match="already patched"):
|
||||
patch_file(source)
|
||||
@@ -38,6 +38,23 @@ class TestGetSystemPrompt:
|
||||
result = get_system_prompt()
|
||||
assert "Shell Execution Guidelines" in result
|
||||
|
||||
def test_contains_safe_archive_and_sqlite_programming_contracts(self):
|
||||
result = get_system_prompt(native_web_sandbox=True)
|
||||
|
||||
assert "never use `extractall`" in result
|
||||
assert "mode=ro&immutable=1" in result
|
||||
assert "PRAGMA query_only=ON" in result
|
||||
assert "Never execute files extracted from an archive" in result
|
||||
assert "Do not modify original files under `uploads/`" in result
|
||||
|
||||
def test_distinguishes_live_web_search_from_local_memory_search(self):
|
||||
result = get_system_prompt(native_web_sandbox=True)
|
||||
|
||||
assert "tavily_search" in result
|
||||
assert "search_observations" in result
|
||||
assert "local memory" in result.lower()
|
||||
assert "Do not use `execute`, `curl`, or `httpx`" in result
|
||||
|
||||
def test_contains_delegation(self):
|
||||
result = get_system_prompt()
|
||||
assert "Sub-Agent Delegation" in result
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from EvoScientist.llm.contracts import EvoRuntimeError
|
||||
from EvoScientist.middleware import recoverable_tools
|
||||
|
||||
|
||||
@@ -51,3 +55,36 @@ def test_evomemory_never_falls_back_to_parent_model_proxy(monkeypatch):
|
||||
|
||||
assert proxy is None
|
||||
assert metadata["run_kind"] == "evomemory_turn_worker"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_effect_gateway_error_preserves_machine_code(monkeypatch):
|
||||
class Client:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return None
|
||||
|
||||
async def post(self, url, json):
|
||||
return httpx.Response(
|
||||
409,
|
||||
json={"detail": {"code": "RUN_FENCE_LOST"}},
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(recoverable_tools.httpx, "AsyncClient", lambda **_: Client())
|
||||
|
||||
with pytest.raises(EvoRuntimeError) as exc_info:
|
||||
await recoverable_tools._post(
|
||||
{
|
||||
"gateway_url": "http://gateway",
|
||||
"run_id": "run-1",
|
||||
"envelope_signature": "signature",
|
||||
},
|
||||
"prepare",
|
||||
{},
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "RUN_FENCE_LOST"
|
||||
assert repr(exc_info.value) == "EvoRuntimeError(code='RUN_FENCE_LOST')"
|
||||
|
||||
@@ -22,13 +22,19 @@ from EvoScientist.sessions import (
|
||||
delete_thread,
|
||||
find_similar_threads,
|
||||
generate_thread_id,
|
||||
get_aggregated_storage_stats,
|
||||
get_db_path,
|
||||
get_most_recent,
|
||||
get_thread_messages,
|
||||
get_thread_metadata,
|
||||
list_all_session_db_paths,
|
||||
list_all_thread_ids,
|
||||
list_threads,
|
||||
prune_all_stale_threads,
|
||||
prune_thread_history,
|
||||
resolve_thread_id_prefix,
|
||||
thread_exists,
|
||||
vacuum_db,
|
||||
)
|
||||
|
||||
|
||||
@@ -2919,5 +2925,172 @@ class TestRestoreWebuiThreadsToGlobalStore(unittest.IsolatedAsyncioTestCase):
|
||||
assert restore_called, "_restore_webui_threads_to_global_store must be called"
|
||||
|
||||
|
||||
def _uuid6_from_unix(ts_unix: float) -> str:
|
||||
"""Build a UUIDv6 (time-ordered checkpoint id) from a unix timestamp.
|
||||
|
||||
Production checkpoint ids are UUIDv6, so lexicographic order matches
|
||||
insertion order and the timestamp is recoverable from the id itself.
|
||||
"""
|
||||
greg = int((ts_unix + 12219292800) * 10_000_000) & ((1 << 60) - 1)
|
||||
high48, low12 = greg >> 12, greg & 0xFFF
|
||||
rand = uuid.uuid4().int & ((1 << 62) - 1)
|
||||
value = (high48 << 80) | (0x6 << 76) | (low12 << 64) | (0b10 << 62) | rand
|
||||
return str(uuid.UUID(int=value))
|
||||
|
||||
|
||||
class TestPruneFunctions(unittest.IsolatedAsyncioTestCase):
|
||||
"""Tests for the prune/vacuum API used by the gateway timer and admin routes."""
|
||||
|
||||
async def asyncSetUp(self):
|
||||
import time
|
||||
|
||||
import aiosqlite
|
||||
|
||||
self._tmpdir = tempfile.mkdtemp()
|
||||
self.db_path = os.path.join(self._tmpdir, "prune_test.db")
|
||||
now = time.time()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as conn:
|
||||
await conn.execute("""
|
||||
CREATE TABLE checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
parent_checkpoint_id TEXT,
|
||||
type TEXT,
|
||||
checkpoint BLOB,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
|
||||
)
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE TABLE writes (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
idx INTEGER NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
type TEXT,
|
||||
value BLOB,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
|
||||
)
|
||||
""")
|
||||
await self._insert_thread(conn, "old_thread", 5, now - 10 * 86400)
|
||||
await self._insert_thread(conn, "new_thread", 3, now - 60)
|
||||
await self._insert_thread(
|
||||
conn, "other", 4, now - 10 * 86400, agent="OtherAgent"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
async def asyncTearDown(self):
|
||||
try:
|
||||
os.unlink(self.db_path)
|
||||
os.rmdir(self._tmpdir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def _insert_thread(self, conn, tid, count, ts_base, agent=AGENT_NAME):
|
||||
serde = JsonPlusSerializer()
|
||||
ctype, cblob = serde.dumps_typed(
|
||||
{"channel_values": {"messages": [HumanMessage(content=f"seed-{tid}")]}}
|
||||
)
|
||||
prev = None
|
||||
for i in range(count):
|
||||
cid = _uuid6_from_unix(ts_base + i)
|
||||
await conn.execute(
|
||||
"INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id,"
|
||||
" parent_checkpoint_id, type, checkpoint, metadata)"
|
||||
" VALUES (?, '', ?, ?, ?, ?, ?)",
|
||||
(tid, cid, prev, ctype, cblob, json.dumps({"agent_name": agent})),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO writes (thread_id, checkpoint_ns, checkpoint_id,"
|
||||
" task_id, idx, channel, type, value)"
|
||||
" VALUES (?, '', ?, 'task', 0, 'ch', 'str', ?)",
|
||||
(tid, cid, b"x"),
|
||||
)
|
||||
prev = cid
|
||||
|
||||
async def _count(self, tid, table="checkpoints"):
|
||||
import aiosqlite
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as conn:
|
||||
async with conn.execute(
|
||||
f"SELECT COUNT(*) FROM {table} WHERE thread_id = ?", (tid,)
|
||||
) as cur:
|
||||
return (await cur.fetchone())[0]
|
||||
|
||||
async def test_prune_thread_history(self):
|
||||
result = await prune_thread_history(
|
||||
"old_thread", keep_last=2, db_path=self.db_path
|
||||
)
|
||||
# keep_last=2 anchors + 1 snapshot-seed ancestor preserved
|
||||
assert result == {"deleted_checkpoints": 2, "deleted_writes": 2}
|
||||
assert await self._count("old_thread") == 3
|
||||
assert await self._count("old_thread", "writes") == 3
|
||||
|
||||
async def test_prune_thread_history_other_agent_untouched(self):
|
||||
result = await prune_thread_history("other", keep_last=1, db_path=self.db_path)
|
||||
assert result == {"deleted_checkpoints": 0, "deleted_writes": 0}
|
||||
assert await self._count("other") == 4
|
||||
|
||||
async def test_prune_all_stale_threads(self):
|
||||
result = await prune_all_stale_threads(
|
||||
max_age_hours=24, keep_last=2, db_path=self.db_path
|
||||
)
|
||||
assert result["databases_processed"] == 1
|
||||
assert result["threads_pruned"] == 1
|
||||
assert result["total_deleted_checkpoints"] == 2
|
||||
assert result["total_deleted_writes"] == 2
|
||||
# fresh thread and foreign-agent thread untouched
|
||||
assert await self._count("new_thread") == 3
|
||||
assert await self._count("other") == 4
|
||||
|
||||
async def test_prune_all_stale_threads_none_stale(self):
|
||||
result = await prune_all_stale_threads(
|
||||
max_age_hours=24 * 365, keep_last=2, db_path=self.db_path
|
||||
)
|
||||
assert result["threads_pruned"] == 0
|
||||
assert result["total_deleted_checkpoints"] == 0
|
||||
assert await self._count("old_thread") == 5
|
||||
|
||||
async def test_list_all_thread_ids(self):
|
||||
ids = await list_all_thread_ids(db_path=self.db_path)
|
||||
assert sorted(ids) == ["new_thread", "old_thread"]
|
||||
|
||||
async def test_vacuum_db(self):
|
||||
result = await vacuum_db(db_path=self.db_path)
|
||||
assert result["size_after_bytes"] > 0
|
||||
assert result["size_before_bytes"] >= result["size_after_bytes"]
|
||||
|
||||
async def test_get_aggregated_storage_stats(self):
|
||||
with patch(
|
||||
"EvoScientist.sessions.get_db_path",
|
||||
return_value=_mock_path(self.db_path),
|
||||
):
|
||||
stats = await get_aggregated_storage_stats()
|
||||
assert stats["thread_count"] == 2
|
||||
assert stats["checkpoint_count"] == 8
|
||||
assert stats["thread_depth"]["max"] == 5
|
||||
assert stats["thread_depth"]["min"] == 3
|
||||
|
||||
async def test_list_all_session_db_paths(self):
|
||||
with patch(
|
||||
"EvoScientist.sessions.get_db_path",
|
||||
return_value=_mock_path(self.db_path),
|
||||
):
|
||||
paths = list_all_session_db_paths()
|
||||
assert len(paths) == 1
|
||||
assert str(paths[0]) == self.db_path
|
||||
|
||||
missing = os.path.join(self._tmpdir, "nope.db")
|
||||
with patch(
|
||||
"EvoScientist.sessions.get_db_path",
|
||||
return_value=_mock_path(missing),
|
||||
):
|
||||
assert list_all_session_db_paths() == []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import ToolMessage
|
||||
|
||||
from EvoScientist.middleware.subagent_timeout import SubagentTimeoutMiddleware
|
||||
|
||||
|
||||
def _request(name: str = "task"):
|
||||
request = MagicMock()
|
||||
request.tool_call = {"id": "call-1", "name": name, "args": {}}
|
||||
return request
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subagent_timeout_returns_stable_tool_error():
|
||||
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
|
||||
|
||||
async def handler(_request):
|
||||
await asyncio.sleep(1)
|
||||
return ToolMessage(content="late", tool_call_id="call-1", name="task")
|
||||
|
||||
result = await middleware.awrap_tool_call(_request(), handler)
|
||||
|
||||
assert isinstance(result, ToolMessage)
|
||||
assert result.status == "error"
|
||||
assert result.name == "task"
|
||||
assert result.additional_kwargs["error_code"] == "SUBAGENT_TIMEOUT"
|
||||
assert "SUBAGENT_TIMEOUT" in result.content
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subagent_timeout_passes_success_through():
|
||||
middleware = SubagentTimeoutMiddleware(timeout_seconds=1)
|
||||
expected = ToolMessage(content="done", tool_call_id="call-1", name="task")
|
||||
|
||||
async def handler(_request):
|
||||
return expected
|
||||
|
||||
assert await middleware.awrap_tool_call(_request(), handler) is expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subagent_timeout_does_not_bound_other_tools():
|
||||
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
|
||||
expected = ToolMessage(content="done", tool_call_id="call-1", name="read_file")
|
||||
|
||||
async def handler(_request):
|
||||
await asyncio.sleep(0.02)
|
||||
return expected
|
||||
|
||||
assert await middleware.awrap_tool_call(_request("read_file"), handler) is expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subagent_internal_timeout_error_is_not_reclassified():
|
||||
middleware = SubagentTimeoutMiddleware(timeout_seconds=1)
|
||||
|
||||
async def handler(_request):
|
||||
raise TimeoutError("provider timed out immediately")
|
||||
|
||||
with pytest.raises(TimeoutError, match="provider timed out immediately"):
|
||||
await middleware.awrap_tool_call(_request(), handler)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_parent_cancellation_cancels_subagent_handler():
|
||||
middleware = SubagentTimeoutMiddleware(timeout_seconds=10)
|
||||
handler_cancelled = asyncio.Event()
|
||||
|
||||
async def handler(_request) -> ToolMessage:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
handler_cancelled.set()
|
||||
return ToolMessage(content="done", tool_call_id="call-1", name="task")
|
||||
|
||||
invocation = asyncio.create_task(
|
||||
middleware.awrap_tool_call(_request(), handler)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
invocation.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await invocation
|
||||
assert handler_cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_parent_cancellation_wins_over_handler_cleanup_error():
|
||||
middleware = SubagentTimeoutMiddleware(timeout_seconds=10)
|
||||
|
||||
async def handler(_request) -> ToolMessage:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError as exc:
|
||||
raise RuntimeError("cleanup failed") from exc
|
||||
return ToolMessage(content="done", tool_call_id="call-1", name="task")
|
||||
|
||||
invocation = asyncio.create_task(
|
||||
middleware.awrap_tool_call(_request(), handler)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
invocation.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await invocation
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_wins_over_handler_cleanup_error():
|
||||
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
|
||||
|
||||
async def handler(_request) -> ToolMessage:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError as exc:
|
||||
raise RuntimeError("cleanup failed") from exc
|
||||
return ToolMessage(content="done", tool_call_id="call-1", name="task")
|
||||
|
||||
result = await middleware.awrap_tool_call(_request(), handler)
|
||||
|
||||
assert isinstance(result, ToolMessage)
|
||||
assert result.additional_kwargs["error_code"] == "SUBAGENT_TIMEOUT"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_parent_cancellation_during_deadline_cleanup_is_not_swallowed():
|
||||
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
|
||||
cleanup_started = asyncio.Event()
|
||||
release_cleanup = asyncio.Event()
|
||||
|
||||
async def handler(_request) -> ToolMessage:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
cleanup_started.set()
|
||||
await release_cleanup.wait()
|
||||
raise
|
||||
return ToolMessage(content="done", tool_call_id="call-1", name="task")
|
||||
|
||||
invocation = asyncio.create_task(
|
||||
middleware.awrap_tool_call(_request(), handler)
|
||||
)
|
||||
await cleanup_started.wait()
|
||||
invocation.cancel()
|
||||
release_cleanup.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await invocation
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_parent_cancellation_after_cleanup_before_timeout_return_wins(monkeypatch):
|
||||
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
|
||||
|
||||
async def handler(_request) -> ToolMessage:
|
||||
await asyncio.Event().wait()
|
||||
return ToolMessage(content="done", tool_call_id="call-1", name="task")
|
||||
|
||||
async def finish_cleanup_then_cancel_parent(_task):
|
||||
current = asyncio.current_task()
|
||||
assert current is not None
|
||||
current.cancel()
|
||||
|
||||
monkeypatch.setattr(middleware, "_cancel_task", finish_cleanup_then_cancel_parent)
|
||||
|
||||
invocation = asyncio.create_task(
|
||||
middleware.awrap_tool_call(_request(), handler)
|
||||
)
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await invocation
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tavily_search_keeps_indexed_summary_when_source_fetch_fails(monkeypatch):
|
||||
from EvoScientist.tools import search
|
||||
|
||||
class _Client:
|
||||
def search(self, *_args, **_kwargs):
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"title": "AIR staff profile",
|
||||
"url": "https://air.cas.cn/example",
|
||||
"content": "Indexed staff-profile summary.",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
recorded: list[tuple[str, str]] = []
|
||||
|
||||
async def fetch_failed(_url: str, timeout: float = 10.0) -> str:
|
||||
return "Error fetching content from https://air.cas.cn/example: DNS failed"
|
||||
|
||||
async def record(service: str, action: str) -> None:
|
||||
recorded.append((service, action))
|
||||
|
||||
monkeypatch.setattr(search, "_get_tavily_client", lambda: _Client())
|
||||
monkeypatch.setattr(search, "fetch_webpage_content", fetch_failed)
|
||||
monkeypatch.setattr("EvoScientist.runtime_integrations.record_service_usage", record)
|
||||
|
||||
result = await search.tavily_search.ainvoke({"query": "高铭 空天院"})
|
||||
|
||||
assert "Indexed staff-profile summary." in result
|
||||
assert "https://air.cas.cn/example" in result
|
||||
assert "Tavily-indexed summary" in result
|
||||
assert recorded == [("tavily", "search")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tavily_search_bounds_fetched_page_content(monkeypatch):
|
||||
from EvoScientist.tools import search
|
||||
|
||||
class _Client:
|
||||
def search(self, *_args, **_kwargs):
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"title": f"Result {index}",
|
||||
"url": f"https://example.com/{index}",
|
||||
"content": f"Indexed summary {index}",
|
||||
}
|
||||
for index in range(3)
|
||||
]
|
||||
}
|
||||
|
||||
async def huge_page(_url: str, timeout: float = 10.0) -> str:
|
||||
return "page-content " * 10_000
|
||||
|
||||
monkeypatch.setattr(search, "_get_tavily_client", lambda: _Client())
|
||||
monkeypatch.setattr(search, "fetch_webpage_content", huge_page)
|
||||
|
||||
result = await search.tavily_search.ainvoke({"query": "bounded search"})
|
||||
|
||||
assert len(result) <= search.MAX_SEARCH_RESULT_CHARS
|
||||
for index in range(3):
|
||||
assert f"https://example.com/{index}" in result
|
||||
assert "[page content truncated]" in result
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tavily_search_preserves_every_result_url_under_total_budget(monkeypatch):
|
||||
from EvoScientist.tools import search
|
||||
|
||||
class _Client:
|
||||
def search(self, *_args, **_kwargs):
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"title": f"Result {index} " + ("very-long-title " * 800),
|
||||
"url": f"https://example.com/result-{index}",
|
||||
"content": f"Indexed summary {index}",
|
||||
}
|
||||
for index in range(3)
|
||||
]
|
||||
}
|
||||
|
||||
async def page(_url: str, timeout: float = 10.0) -> str:
|
||||
return "page-content " * 1_000
|
||||
|
||||
monkeypatch.setattr(search, "_get_tavily_client", lambda: _Client())
|
||||
monkeypatch.setattr(search, "fetch_webpage_content", page)
|
||||
|
||||
result = await search.tavily_search.ainvoke({"query": "preserve urls"})
|
||||
|
||||
assert len(result) <= search.MAX_SEARCH_RESULT_CHARS
|
||||
for index in range(3):
|
||||
assert f"https://example.com/result-{index}" in result
|
||||
assert "[search result content truncated to preserve all titles and URLs]" in result
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tavily_search_bounds_maliciously_long_url(monkeypatch):
|
||||
from EvoScientist.tools import search
|
||||
|
||||
long_url = "https://example.com/" + ("a" * 20_000)
|
||||
|
||||
class _Client:
|
||||
def search(self, *_args, **_kwargs):
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"title": "Long URL result",
|
||||
"url": long_url,
|
||||
"content": "Indexed summary",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async def page(_url: str, timeout: float = 10.0) -> str:
|
||||
return "page"
|
||||
|
||||
monkeypatch.setattr(search, "_get_tavily_client", lambda: _Client())
|
||||
monkeypatch.setattr(search, "fetch_webpage_content", page)
|
||||
|
||||
result = await search.tavily_search.ainvoke({"query": "long url"})
|
||||
|
||||
assert len(result) <= search.MAX_SEARCH_RESULT_CHARS
|
||||
assert "https://example.com/" in result
|
||||
assert "[URL truncated]" in result
|
||||
@@ -6,6 +6,184 @@ from EvoScientist.llm.contracts import EvoRuntimeError
|
||||
from EvoScientist.web_runtime import _ToolRegistryFenceMiddleware
|
||||
|
||||
|
||||
def test_web_registry_describes_tavily_as_controlled_live_search(monkeypatch):
|
||||
monkeypatch.setenv("TAVILY_API_KEY", "test-key")
|
||||
|
||||
from EvoScientist.web_runtime import web_tool_registry_manifest
|
||||
|
||||
manifest, _revision = web_tool_registry_manifest()
|
||||
tavily = next(item for item in manifest if item["name"] == "tavily_search")
|
||||
|
||||
assert "live public web" in tavily["description"].lower()
|
||||
assert "execute" in tavily["description"].lower()
|
||||
|
||||
|
||||
def test_base_kwargs_install_tavily_on_main_agent(monkeypatch):
|
||||
import EvoScientist.EvoScientist as agent_module
|
||||
|
||||
monkeypatch.setenv("TAVILY_API_KEY", "test-key")
|
||||
monkeypatch.setattr(agent_module, "_ensure_general_purpose_subagent", lambda _subs: None)
|
||||
monkeypatch.setattr(agent_module, "_inject_subagent_middleware", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(agent_module, "_maybe_swap_async_subagents", lambda subs, *_args, **_kwargs: subs)
|
||||
monkeypatch.setattr(agent_module, "_configured_system_prompt", lambda _cfg: "prompt")
|
||||
monkeypatch.setattr("EvoScientist.utils.load_subagents", lambda *_args, **_kwargs: [])
|
||||
|
||||
kwargs = agent_module._build_base_kwargs(
|
||||
object(),
|
||||
[],
|
||||
cfg=object(),
|
||||
chat_model=object(),
|
||||
workspace_dir="/workspace",
|
||||
)
|
||||
|
||||
assert "tavily_search" in {getattr(tool, "name", "") for tool in kwargs["tools"]}
|
||||
|
||||
|
||||
def _stub_agent_build(monkeypatch, agent_module, subagents=None):
|
||||
monkeypatch.setattr(agent_module, "_ensure_general_purpose_subagent", lambda _subs: None)
|
||||
monkeypatch.setattr(agent_module, "_inject_subagent_middleware", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(agent_module, "_maybe_swap_async_subagents", lambda subs, *_args, **_kwargs: subs)
|
||||
monkeypatch.setattr(agent_module, "_configured_system_prompt", lambda _cfg: "prompt")
|
||||
monkeypatch.setattr(
|
||||
"EvoScientist.utils.load_subagents",
|
||||
lambda *_args, **_kwargs: list(subagents or []),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reserved_name",
|
||||
[
|
||||
"skill_manager",
|
||||
"execute",
|
||||
"start_async_task",
|
||||
"check_async_task",
|
||||
"update_async_task",
|
||||
"cancel_async_task",
|
||||
"list_async_tasks",
|
||||
],
|
||||
)
|
||||
def test_mcp_cannot_override_reserved_tool(monkeypatch, reserved_name):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import EvoScientist.EvoScientist as agent_module
|
||||
|
||||
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"_load_mcp_tools_cached",
|
||||
lambda **_kwargs: {"main": [SimpleNamespace(name=reserved_name)]},
|
||||
)
|
||||
_stub_agent_build(monkeypatch, agent_module)
|
||||
|
||||
with pytest.raises(EvoRuntimeError, match="TOOL_REGISTRY_CONFLICT"):
|
||||
agent_module.load_mcp_and_build_kwargs(
|
||||
object(),
|
||||
[],
|
||||
cfg=object(),
|
||||
chat_model=object(),
|
||||
workspace_dir="/workspace",
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_cannot_duplicate_existing_subagent_tool(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import EvoScientist.EvoScientist as agent_module
|
||||
|
||||
existing = SimpleNamespace(name="shared_search")
|
||||
injected = SimpleNamespace(name="shared_search")
|
||||
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"_load_mcp_tools_cached",
|
||||
lambda **_kwargs: {"research-agent": [injected]},
|
||||
)
|
||||
_stub_agent_build(
|
||||
monkeypatch,
|
||||
agent_module,
|
||||
subagents=[{"name": "research-agent", "tools": [existing]}],
|
||||
)
|
||||
|
||||
with pytest.raises(EvoRuntimeError, match="TOOL_REGISTRY_CONFLICT"):
|
||||
agent_module.load_mcp_and_build_kwargs(
|
||||
object(),
|
||||
[],
|
||||
cfg=object(),
|
||||
chat_model=object(),
|
||||
workspace_dir="/workspace",
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_cannot_override_builtin_tavily(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import EvoScientist.EvoScientist as agent_module
|
||||
|
||||
monkeypatch.setenv("TAVILY_API_KEY", "test-key")
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"_load_mcp_tools_cached",
|
||||
lambda **_kwargs: {"main": [SimpleNamespace(name="tavily_search")]},
|
||||
)
|
||||
monkeypatch.setattr(agent_module, "_ensure_general_purpose_subagent", lambda _subs: None)
|
||||
monkeypatch.setattr(agent_module, "_inject_subagent_middleware", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(agent_module, "_maybe_swap_async_subagents", lambda subs, *_args, **_kwargs: subs)
|
||||
monkeypatch.setattr(agent_module, "_configured_system_prompt", lambda _cfg: "prompt")
|
||||
monkeypatch.setattr("EvoScientist.utils.load_subagents", lambda *_args, **_kwargs: [])
|
||||
|
||||
with pytest.raises(EvoRuntimeError, match="TOOL_REGISTRY_CONFLICT"):
|
||||
agent_module.load_mcp_and_build_kwargs(
|
||||
object(),
|
||||
[],
|
||||
cfg=object(),
|
||||
chat_model=object(),
|
||||
workspace_dir="/workspace",
|
||||
)
|
||||
|
||||
|
||||
def test_same_mcp_tool_can_be_routed_to_multiple_agents(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import EvoScientist.EvoScientist as agent_module
|
||||
|
||||
shared_main = SimpleNamespace(name="shared_search")
|
||||
shared_research = SimpleNamespace(name="shared_search")
|
||||
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"_load_mcp_tools_cached",
|
||||
lambda **_kwargs: {
|
||||
"main": [shared_main],
|
||||
"research-agent": [shared_research],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(agent_module, "_ensure_general_purpose_subagent", lambda _subs: None)
|
||||
monkeypatch.setattr(agent_module, "_inject_subagent_middleware", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(agent_module, "_maybe_swap_async_subagents", lambda subs, *_args, **_kwargs: subs)
|
||||
monkeypatch.setattr(agent_module, "_configured_system_prompt", lambda _cfg: "prompt")
|
||||
monkeypatch.setattr(
|
||||
"EvoScientist.utils.load_subagents",
|
||||
lambda *_args, **_kwargs: [
|
||||
{"name": "research-agent", "tools": []}
|
||||
],
|
||||
)
|
||||
|
||||
kwargs = agent_module.load_mcp_and_build_kwargs(
|
||||
object(),
|
||||
[],
|
||||
cfg=object(),
|
||||
chat_model=object(),
|
||||
workspace_dir="/workspace",
|
||||
)
|
||||
|
||||
assert shared_main in kwargs["tools"]
|
||||
research = next(
|
||||
subagent for subagent in kwargs["subagents"]
|
||||
if subagent["name"] == "research-agent"
|
||||
)
|
||||
assert shared_research in research["tools"]
|
||||
|
||||
|
||||
def test_tool_dispatch_fence_rejects_changed_registry(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"EvoScientist.web_runtime.web_tool_registry_manifest",
|
||||
|
||||
+374
-16
@@ -2,10 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import sqlite3
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from deepagents.backends.protocol import ExecuteResponse
|
||||
from PIL import Image
|
||||
|
||||
from EvoScientist.native_sandbox import (
|
||||
NativeSandboxExecutor,
|
||||
@@ -27,6 +31,19 @@ from EvoScientist.workspace_scope import (
|
||||
)
|
||||
|
||||
|
||||
def test_extracted_documents_route_through_deepagents_as_text():
|
||||
import deepagents.middleware.filesystem as filesystem_middleware
|
||||
|
||||
from EvoScientist.llm.patches import _patch_deepagents_extracted_document_text
|
||||
|
||||
_patch_deepagents_extracted_document_text()
|
||||
|
||||
get_file_type = vars(filesystem_middleware)["_get_file_type"]
|
||||
assert get_file_type("/workspace/report.pptx") == "text"
|
||||
assert get_file_type("/workspace/report.pdf") == "text"
|
||||
assert get_file_type("/workspace/image.png") == "image"
|
||||
|
||||
|
||||
def test_normalize_workspace_path_is_strict():
|
||||
assert normalize_workspace_path("/workspace") == ()
|
||||
assert normalize_workspace_path("/workspace/reports/a.txt") == (
|
||||
@@ -82,25 +99,366 @@ def test_root_lists_workspace_namespace(tmp_path: Path):
|
||||
]
|
||||
|
||||
|
||||
def test_docx_and_unknown_binary_read_with_base64_contract(tmp_path: Path):
|
||||
def test_docx_read_extracts_text_instead_of_returning_base64(tmp_path: Path):
|
||||
backend = ScopedFilesystemBackend(tmp_path)
|
||||
docx = b"PK\x03\x04\x00word/document.xml"
|
||||
unknown = b"custom\x00binary"
|
||||
backend.upload_files(
|
||||
[
|
||||
("/workspace/input.docx", docx),
|
||||
("/workspace/payload.custom", unknown),
|
||||
]
|
||||
docx = tmp_path / "input.docx"
|
||||
with zipfile.ZipFile(docx, "w") as archive:
|
||||
archive.writestr(
|
||||
"word/document.xml",
|
||||
"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body><w:p><w:r><w:t>有界文档内容</w:t></w:r></w:p></w:body>
|
||||
</w:document>""",
|
||||
)
|
||||
|
||||
result = backend.read("/workspace/input.docx")
|
||||
|
||||
assert result.error is None
|
||||
assert result.file_data is not None
|
||||
assert result.file_data["encoding"] == "utf-8"
|
||||
assert "有界文档内容" in result.file_data["content"]
|
||||
assert "base64" not in result.file_data["content"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "kind", "expected"),
|
||||
[
|
||||
("archive.zip", "archive", "list"),
|
||||
("results.sqlite", "database", "mode=ro"),
|
||||
("program.exe", "executable", "must not be executed"),
|
||||
("payload.custom", "binary", "unsupported"),
|
||||
],
|
||||
)
|
||||
def test_non_document_binary_returns_bounded_processing_guidance(
|
||||
tmp_path: Path, filename: str, kind: str, expected: str
|
||||
):
|
||||
if filename.endswith(".zip"):
|
||||
with zipfile.ZipFile(tmp_path / filename, "w") as archive:
|
||||
archive.writestr("notes.txt", "hello")
|
||||
elif filename.endswith(".sqlite"):
|
||||
connection = sqlite3.connect(tmp_path / filename)
|
||||
connection.execute("CREATE TABLE results(id INTEGER PRIMARY KEY, value TEXT)")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
elif filename.endswith(".exe"):
|
||||
(tmp_path / filename).write_bytes(b"MZ\x00binary payload")
|
||||
else:
|
||||
(tmp_path / filename).write_bytes(b"custom\x00binary payload")
|
||||
|
||||
result = ScopedFilesystemBackend(tmp_path).read(f"/workspace/{filename}")
|
||||
|
||||
assert result.file_data is None
|
||||
assert result.error is not None
|
||||
assert "BINARY_PROCESSING_REQUIRED" in result.error or "UNSUPPORTED_BINARY_FILE" in result.error
|
||||
assert f'"kind": "{kind}"' in result.error
|
||||
assert expected.lower() in result.error.lower()
|
||||
assert len(result.error) < 4000
|
||||
|
||||
|
||||
def test_image_read_keeps_base64_media_contract(tmp_path: Path):
|
||||
buffer = BytesIO()
|
||||
Image.new("RGB", (32, 24), (1, 2, 3)).save(buffer, "PNG")
|
||||
raw = buffer.getvalue()
|
||||
(tmp_path / "image.png").write_bytes(raw)
|
||||
|
||||
result = ScopedFilesystemBackend(tmp_path).read("/workspace/image.png")
|
||||
|
||||
assert result.error is None
|
||||
assert result.file_data is not None
|
||||
assert result.file_data["encoding"] == "base64"
|
||||
|
||||
|
||||
def test_large_image_is_downsampled_before_base64_delivery(tmp_path: Path):
|
||||
Image.new("RGB", (3000, 1200), (1, 2, 3)).save(tmp_path / "large.png", "PNG")
|
||||
|
||||
result = ScopedFilesystemBackend(tmp_path).read("/workspace/large.png")
|
||||
|
||||
assert result.error is None
|
||||
assert result.file_data is not None
|
||||
decoded = base64.standard_b64decode(result.file_data["content"])
|
||||
image = Image.open(BytesIO(decoded))
|
||||
image.load()
|
||||
assert image.size == (2048, 819)
|
||||
assert image.format == "JPEG"
|
||||
|
||||
|
||||
def test_corrupt_image_returns_error_instead_of_base64(tmp_path: Path):
|
||||
(tmp_path / "broken.png").write_bytes(b"\x89PNG\r\nnot-decodable")
|
||||
|
||||
result = ScopedFilesystemBackend(tmp_path).read("/workspace/broken.png")
|
||||
|
||||
assert result.file_data is None
|
||||
assert result.error is not None
|
||||
assert "IMAGE_PROCESSING_FAILED" in result.error
|
||||
|
||||
|
||||
def test_image_pixel_budget_is_enforced_before_model_delivery(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
import EvoScientist.document_extract as document_extract
|
||||
|
||||
Image.new("RGB", (100, 100), (1, 2, 3)).save(tmp_path / "pixels.png", "PNG")
|
||||
monkeypatch.setattr(document_extract, "MAX_IMAGE_PIXELS", 9_999)
|
||||
|
||||
result = ScopedFilesystemBackend(tmp_path).read("/workspace/pixels.png")
|
||||
|
||||
assert result.file_data is None
|
||||
assert result.error is not None
|
||||
assert "IMAGE_PIXEL_BUDGET_EXCEEDED" in result.error
|
||||
|
||||
|
||||
def test_multiframe_image_is_reduced_to_first_frame(tmp_path: Path):
|
||||
frames = [Image.new("RGB", (20, 10), color) for color in ((255, 0, 0), (0, 255, 0))]
|
||||
frames[0].save(
|
||||
tmp_path / "animated.gif",
|
||||
format="GIF",
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
duration=100,
|
||||
loop=0,
|
||||
)
|
||||
|
||||
assert backend.read("/workspace/input.docx").file_data == {
|
||||
"content": base64.standard_b64encode(docx).decode("ascii"),
|
||||
"encoding": "base64",
|
||||
}
|
||||
assert backend.read("/workspace/payload.custom").file_data == {
|
||||
"content": base64.standard_b64encode(unknown).decode("ascii"),
|
||||
"encoding": "base64",
|
||||
}
|
||||
result = ScopedFilesystemBackend(tmp_path).read("/workspace/animated.gif")
|
||||
|
||||
assert result.error is None
|
||||
assert result.file_data is not None
|
||||
decoded = base64.standard_b64decode(result.file_data["content"])
|
||||
image = Image.open(BytesIO(decoded))
|
||||
image.load()
|
||||
assert getattr(image, "n_frames", 1) == 1
|
||||
|
||||
|
||||
def test_pptx_read_extracts_slide_text_without_base64(tmp_path: Path):
|
||||
with zipfile.ZipFile(tmp_path / "deck.pptx", "w") as archive:
|
||||
archive.writestr(
|
||||
"ppt/slides/slide1.xml",
|
||||
"""<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||||
<p:cSld><a:t>总体技术架构</a:t><a:t>核心能力说明</a:t></p:cSld>
|
||||
</p:sld>""",
|
||||
)
|
||||
|
||||
result = ScopedFilesystemBackend(tmp_path).read("/workspace/deck.pptx")
|
||||
|
||||
assert result.error is None
|
||||
assert result.file_data is not None
|
||||
assert result.file_data["encoding"] == "utf-8"
|
||||
assert "## Slide 1" in result.file_data["content"]
|
||||
assert "总体技术架构" in result.file_data["content"]
|
||||
|
||||
|
||||
def test_document_output_is_bounded_and_returns_continuation_hint(tmp_path: Path):
|
||||
long_text = "\n".join(f"第{i:05d}行-" + "x" * 80 for i in range(2000))
|
||||
with zipfile.ZipFile(tmp_path / "long.docx", "w") as archive:
|
||||
paragraphs = "".join(
|
||||
f"<w:p><w:r><w:t>{line}</w:t></w:r></w:p>"
|
||||
for line in long_text.splitlines()
|
||||
)
|
||||
archive.writestr(
|
||||
"word/document.xml",
|
||||
f"""<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>{paragraphs}</w:body></w:document>""",
|
||||
)
|
||||
|
||||
result = ScopedFilesystemBackend(tmp_path).read(
|
||||
"/workspace/long.docx", offset=0, limit=2000
|
||||
)
|
||||
|
||||
assert result.error is None
|
||||
assert result.file_data is not None
|
||||
content = result.file_data["content"]
|
||||
assert len(content) < 51_000
|
||||
assert "DOCUMENT_OUTPUT_TRUNCATED" in content
|
||||
assert "use offset=" in content
|
||||
|
||||
|
||||
def test_corrupt_document_does_not_fall_back_to_base64(tmp_path: Path):
|
||||
(tmp_path / "broken.pptx").write_bytes(b"PK\x03\x04not-a-real-presentation")
|
||||
|
||||
result = ScopedFilesystemBackend(tmp_path).read("/workspace/broken.pptx")
|
||||
|
||||
assert result.file_data is None
|
||||
assert result.error is not None
|
||||
assert "DOCUMENT_EXTRACTION_FAILED" in result.error
|
||||
assert "base64" not in result.error.lower()
|
||||
|
||||
|
||||
def test_ooxml_member_expansion_budget_blocks_compression_bomb(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
import EvoScientist.document_extract as document_extract
|
||||
|
||||
with zipfile.ZipFile(
|
||||
tmp_path / "bomb.docx", "w", compression=zipfile.ZIP_DEFLATED
|
||||
) as archive:
|
||||
archive.writestr(
|
||||
"word/document.xml",
|
||||
"""<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body><w:p><w:r><w:t>expanded content</w:t></w:r></w:p></w:body>
|
||||
</w:document>""",
|
||||
)
|
||||
monkeypatch.setattr(document_extract, "MAX_OOXML_MEMBER_BYTES", 16)
|
||||
|
||||
result = ScopedFilesystemBackend(tmp_path).read("/workspace/bomb.docx")
|
||||
|
||||
assert result.file_data is None
|
||||
assert result.error is not None
|
||||
assert "DOCUMENT_RESOURCE_LIMIT" in result.error
|
||||
|
||||
|
||||
@pytest.mark.parametrize("member", ["../word/document.xml", "/word/document.xml"])
|
||||
def test_ooxml_rejects_unsafe_member_paths(tmp_path: Path, member: str):
|
||||
with zipfile.ZipFile(tmp_path / "unsafe.docx", "w") as archive:
|
||||
archive.writestr(member, "content")
|
||||
archive.writestr(
|
||||
"word/document.xml",
|
||||
"""<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body><w:p><w:r><w:t>safe</w:t></w:r></w:p></w:body></w:document>""",
|
||||
)
|
||||
|
||||
result = ScopedFilesystemBackend(tmp_path).read("/workspace/unsafe.docx")
|
||||
|
||||
assert result.file_data is None
|
||||
assert result.error is not None
|
||||
assert "DOCUMENT_RESOURCE_LIMIT" in result.error
|
||||
|
||||
|
||||
def test_ooxml_rejects_duplicate_member_names(tmp_path: Path):
|
||||
def write_duplicate_document(path: Path) -> None:
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
for text in ("first", "second"):
|
||||
archive.writestr(
|
||||
"word/document.xml",
|
||||
f"""<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:body></w:document>""",
|
||||
)
|
||||
|
||||
with pytest.warns(UserWarning, match="Duplicate name"):
|
||||
write_duplicate_document(tmp_path / "duplicate.docx")
|
||||
|
||||
result = ScopedFilesystemBackend(tmp_path).read("/workspace/duplicate.docx")
|
||||
|
||||
assert result.file_data is None
|
||||
assert result.error is not None
|
||||
assert "DOCUMENT_RESOURCE_LIMIT" in result.error
|
||||
|
||||
|
||||
def test_oversized_document_is_rejected_before_opening_content(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
from EvoScientist.document_extract import MAX_DOCUMENT_BYTES
|
||||
|
||||
path = tmp_path / "oversized.pdf"
|
||||
path.write_bytes(b"%PDF")
|
||||
original_entry = RootedWorkspace.entry
|
||||
|
||||
def oversized_entry(self, virtual_path):
|
||||
entry = original_entry(self, virtual_path)
|
||||
return type(entry)(entry.virtual_path, entry.is_dir, MAX_DOCUMENT_BYTES + 1, entry.modified_at)
|
||||
|
||||
monkeypatch.setattr(RootedWorkspace, "entry", oversized_entry)
|
||||
|
||||
result = ScopedFilesystemBackend(tmp_path).read("/workspace/oversized.pdf")
|
||||
|
||||
assert result.file_data is None
|
||||
assert result.error is not None
|
||||
assert "DOCUMENT_TOO_LARGE" in result.error
|
||||
|
||||
|
||||
def test_external_document_converter_timeout_is_structured(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
import subprocess
|
||||
|
||||
import EvoScientist.document_extract as document_extract
|
||||
|
||||
def timeout(*args, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd="anydoc", timeout=60)
|
||||
|
||||
monkeypatch.setattr(document_extract.subprocess, "run", timeout)
|
||||
|
||||
with pytest.raises(
|
||||
document_extract.DocumentExtractionError,
|
||||
match="DOCUMENT_CONVERSION_TIMEOUT",
|
||||
):
|
||||
document_extract.extract_document_bytes(b"%PDF-minimal", "sample.pdf")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", ["report.pptx", "archive.zip", "results.sqlite"])
|
||||
def test_text_write_cannot_create_or_corrupt_binary_container(
|
||||
tmp_path: Path, filename: str
|
||||
):
|
||||
backend = ScopedFilesystemBackend(tmp_path)
|
||||
|
||||
created = backend.write(f"/workspace/{filename}", "extracted text")
|
||||
|
||||
assert created.error is not None
|
||||
assert "binary container" in created.error.lower()
|
||||
assert not (tmp_path / filename).exists()
|
||||
|
||||
|
||||
def test_text_edit_cannot_modify_existing_binary_container(tmp_path: Path):
|
||||
source = b"PK\x03\x04original-container"
|
||||
(tmp_path / "report.pptx").write_bytes(source)
|
||||
backend = ScopedFilesystemBackend(tmp_path)
|
||||
|
||||
edited = backend.edit(
|
||||
"/workspace/report.pptx", "original", "replacement"
|
||||
)
|
||||
|
||||
assert edited.error is not None
|
||||
assert "binary container" in edited.error.lower()
|
||||
assert (tmp_path / "report.pptx").read_bytes() == source
|
||||
|
||||
|
||||
def test_uploaded_text_is_read_only_to_file_tools(tmp_path: Path):
|
||||
uploads = tmp_path / "uploads"
|
||||
uploads.mkdir()
|
||||
source = uploads / "notes.txt"
|
||||
source.write_text("original", encoding="utf-8")
|
||||
backend = ScopedFilesystemBackend(tmp_path)
|
||||
|
||||
written = backend.write("/workspace/uploads/new.txt", "new")
|
||||
edited = backend.edit("/workspace/uploads/notes.txt", "original", "changed")
|
||||
|
||||
assert written.error is not None
|
||||
assert "uploads" in written.error.lower()
|
||||
assert edited.error is not None
|
||||
assert "uploads" in edited.error.lower()
|
||||
assert not (uploads / "new.txt").exists()
|
||||
assert source.read_text(encoding="utf-8") == "original"
|
||||
|
||||
|
||||
def test_utf8_sample_boundary_cut_is_not_binary(tmp_path: Path):
|
||||
# Regression (2026-08-22): the 8192-byte UTF-8 probe can split a multi-byte
|
||||
# CJK character at the sample boundary (req_v13.md cut at 8190/8191 split a
|
||||
# 3-byte char). That raised UnicodeDecodeError -> misclassified as binary
|
||||
# -> read_file returned a base64 file media block -> providers without file
|
||||
# input replaced it with a placeholder -> the model retried forever.
|
||||
backend = ScopedFilesystemBackend(tmp_path)
|
||||
# 8190 ASCII bytes + one 3-byte CJK char, so the sample cuts mid-character.
|
||||
content = ("a" * 8190 + "\u6e56" + "more text").encode("utf-8")
|
||||
assert len(content) > 8192
|
||||
assert content[8190:8193] == "\u6e56".encode("utf-8")
|
||||
backend.upload_files([("/workspace/cjk.md", content)])
|
||||
|
||||
result = backend.read("/workspace/cjk.md")
|
||||
assert result.file_data is not None
|
||||
assert result.file_data["encoding"] == "utf-8"
|
||||
assert result.file_data["content"].startswith("a" * 10)
|
||||
|
||||
|
||||
def test_mid_sample_invalid_bytes_still_binary():
|
||||
from EvoScientist.workspace_files import _is_binary_file
|
||||
|
||||
# Invalid bytes well inside the sample are genuine garbage, not a cut.
|
||||
assert _is_binary_file("/workspace/bad.raw", b"ok\xffi\xffd\xefmore")
|
||||
# A boundary cut (error in the last 4 bytes that decodes clean when the
|
||||
# dangling suffix is dropped) is text.
|
||||
cut = ("a" * 8190 + "\u6e56").encode("utf-8")[:8192]
|
||||
assert not _is_binary_file("/workspace/cut.md", cut)
|
||||
# Same shape but the prefix itself is invalid -> stays binary.
|
||||
assert _is_binary_file("/workspace/bad.md", b"\xff" * 8192)
|
||||
|
||||
|
||||
def test_symlink_targets_and_parents_are_rejected(tmp_path: Path):
|
||||
|
||||
Reference in New Issue
Block a user