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

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:
m4
2026-09-03 06:55:56 +08:00
parent ce0bec1f91
commit c683f6e739
35 changed files with 2806 additions and 67 deletions
+173
View File
@@ -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()