246 lines
9.5 KiB
Python
246 lines
9.5 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
import opensquilla.engine.agent as agent_mod
|
|
from opensquilla.engine import Agent, AgentConfig, ToolResult
|
|
from opensquilla.engine.tool_result_store import (
|
|
ToolResultStore,
|
|
ToolResultStoreBudgetError,
|
|
)
|
|
|
|
_SESSION_ID = "session-1"
|
|
_SESSION_KEY = "agent:main:webchat:session-1"
|
|
|
|
|
|
def _write(store: ToolResultStore, content: str, *, tool_use_id: str = "tool-1", **over: Any):
|
|
kwargs: dict[str, Any] = dict(
|
|
tool_use_id=tool_use_id,
|
|
tool_name="fetch",
|
|
session_id=_SESSION_ID,
|
|
session_key=_SESSION_KEY,
|
|
agent_id="main",
|
|
)
|
|
kwargs.update(over)
|
|
return store.write(content, **kwargs)
|
|
|
|
|
|
def test_identical_content_dedupes_to_one_record(tmp_path: Path) -> None:
|
|
store = ToolResultStore(tmp_path)
|
|
body = "same output\n" + "x" * 2000
|
|
first = _write(store, body, tool_use_id="tool-1")
|
|
second = _write(store, body, tool_use_id="tool-2")
|
|
|
|
# Same content -> same content-addressed handle, and the repeat write reuses the
|
|
# record rather than writing a second directory.
|
|
assert second.handle == first.handle
|
|
sha = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
assert first.handle == f"tr-{sha[:32]}"
|
|
assert len(list(tmp_path.rglob("content.txt"))) == 1
|
|
|
|
|
|
def test_dedup_hit_still_enforces_retention(tmp_path: Path) -> None:
|
|
store = ToolResultStore(tmp_path)
|
|
hot = _write(store, "hot output", tool_use_id="t1")
|
|
victim = _write(store, "stale output", tool_use_id="t2")
|
|
|
|
# Age the victim well past retention.
|
|
victim_content = store._record_dir(victim.handle, session_id=_SESSION_ID) / "content.txt"
|
|
os.utime(victim_content, (1_000.0, 1_000.0))
|
|
|
|
# Re-writing identical "hot output" is a dedup hit; it must still reap the expired
|
|
# victim (retention is not bypassed on hits) while keeping the reused record alive.
|
|
again = _write(store, "hot output", tool_use_id="t3", retention_seconds=1)
|
|
|
|
assert again.handle == hot.handle
|
|
with pytest.raises(FileNotFoundError):
|
|
store.read(victim.handle, session_id=_SESSION_ID)
|
|
assert store.read(hot.handle, session_id=_SESSION_ID).content == "hot output"
|
|
|
|
|
|
def test_dedup_hit_with_zero_retention_returns_usable_handle(tmp_path: Path) -> None:
|
|
store = ToolResultStore(tmp_path)
|
|
_write(store, "recurring output", tool_use_id="t1")
|
|
|
|
# retention_seconds=0 expires everything written before "now"; a repeat write must
|
|
# still return a handle that resolves (the record is re-created, never dangling).
|
|
second = _write(store, "recurring output", tool_use_id="t2", retention_seconds=0)
|
|
|
|
assert store.read(second.handle, session_id=_SESSION_ID).content == "recurring output"
|
|
|
|
|
|
def test_distinct_content_gets_distinct_handles(tmp_path: Path) -> None:
|
|
store = ToolResultStore(tmp_path)
|
|
a = _write(store, "alpha" * 100, tool_use_id="t1")
|
|
b = _write(store, "bravo" * 100, tool_use_id="t2")
|
|
assert a.handle != b.handle
|
|
assert len(list(tmp_path.rglob("content.txt"))) == 2
|
|
|
|
|
|
def test_round_trip_read(tmp_path: Path) -> None:
|
|
store = ToolResultStore(tmp_path)
|
|
record = _write(store, "payload body")
|
|
got = store.read(record.handle, session_id=_SESSION_ID)
|
|
assert got.content == "payload body"
|
|
assert got.sha256 == record.sha256
|
|
|
|
|
|
def test_session_scoped_reads(tmp_path: Path) -> None:
|
|
store = ToolResultStore(tmp_path)
|
|
record = _write(store, "private output")
|
|
with pytest.raises(FileNotFoundError):
|
|
store.read(record.handle, session_id="session-2")
|
|
|
|
|
|
def test_rejects_single_result_over_budget(tmp_path: Path) -> None:
|
|
store = ToolResultStore(tmp_path)
|
|
with pytest.raises(ToolResultStoreBudgetError):
|
|
_write(store, "abcdef", max_bytes=5)
|
|
assert not list(tmp_path.rglob("content.txt"))
|
|
|
|
|
|
def test_prunes_oldest_for_disk_budget(tmp_path: Path) -> None:
|
|
store = ToolResultStore(tmp_path)
|
|
first = _write(store, "a" * 40, tool_use_id="t1", disk_budget_bytes=120)
|
|
second = _write(store, "b" * 40, tool_use_id="t2", disk_budget_bytes=120)
|
|
|
|
# Pin deterministic (but recent, non-expired) ages so eviction order is stable:
|
|
# cleanup orders by the content file's mtime.
|
|
now = time.time()
|
|
first_content = store._record_dir(first.handle, session_id=_SESSION_ID) / "content.txt"
|
|
second_content = store._record_dir(second.handle, session_id=_SESSION_ID) / "content.txt"
|
|
os.utime(first_content, (now - 10, now - 10))
|
|
os.utime(second_content, (now - 5, now - 5))
|
|
|
|
third = _write(store, "c" * 40, tool_use_id="t3", disk_budget_bytes=90)
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
store.read(first.handle, session_id=_SESSION_ID)
|
|
assert store.read(second.handle, session_id=_SESSION_ID).content == "b" * 40
|
|
assert store.read(third.handle, session_id=_SESSION_ID).content == "c" * 40
|
|
|
|
|
|
def test_expired_records_removed_before_write(tmp_path: Path) -> None:
|
|
store = ToolResultStore(tmp_path)
|
|
expired = _write(store, "old output", tool_use_id="t1")
|
|
|
|
# Age the record well past retention by backdating its content file's mtime;
|
|
# cleanup keys on mtime, not the recorded created_at string.
|
|
content = store._record_dir(expired.handle, session_id=_SESSION_ID) / "content.txt"
|
|
os.utime(content, (1_000.0, 1_000.0))
|
|
|
|
_write(store, "new output", tool_use_id="t2", retention_seconds=1)
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
store.read(expired.handle, session_id=_SESSION_ID)
|
|
|
|
|
|
def test_truncated_digest_collision_falls_back_to_random_handle(tmp_path: Path) -> None:
|
|
store = ToolResultStore(tmp_path)
|
|
content = "real content payload"
|
|
sha = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
primary_handle = f"tr-{sha[:32]}"
|
|
|
|
# Simulate the ~2**-64 truncated-digest collision: a *different* payload already
|
|
# occupies the content-addressed directory (its recorded sha256 does not match).
|
|
collision_dir = store._record_dir(primary_handle, session_id=_SESSION_ID)
|
|
collision_dir.mkdir(parents=True, exist_ok=True)
|
|
(collision_dir / "meta.json").write_text(
|
|
json.dumps({"handle": primary_handle, "sha256": "0" * 64, "created_at": ""}),
|
|
encoding="utf-8",
|
|
)
|
|
(collision_dir / "content.txt").write_text("different payload", encoding="utf-8")
|
|
|
|
record = _write(store, content)
|
|
|
|
# Falls back to a fresh random handle; the colliding record is left untouched.
|
|
assert record.handle != primary_handle
|
|
assert store.read(record.handle, session_id=_SESSION_ID).content == content
|
|
assert (collision_dir / "content.txt").read_text(encoding="utf-8") == "different payload"
|
|
|
|
|
|
def test_cleanup_ignores_non_record_directories(tmp_path: Path) -> None:
|
|
store = ToolResultStore(tmp_path)
|
|
record = _write(store, "kept output", tool_use_id="t1")
|
|
|
|
# A stray content.txt under the store tree whose directory is NOT a valid handle
|
|
# must never be enumerated or deleted by retention/budget cleanup.
|
|
stray = tmp_path / "s" / _SESSION_ID / "zz" / "not-a-handle"
|
|
stray.mkdir(parents=True, exist_ok=True)
|
|
(stray / "content.txt").write_text("foreign file", encoding="utf-8")
|
|
os.utime(stray / "content.txt", (1_000.0, 1_000.0)) # ancient -> would expire if scanned
|
|
|
|
# A fresh write with aggressive retention triggers cleanup.
|
|
_write(store, "new output", tool_use_id="t2", retention_seconds=1)
|
|
|
|
assert (stray / "content.txt").read_text(encoding="utf-8") == "foreign file"
|
|
assert store.read(record.handle, session_id=_SESSION_ID).content == "kept output"
|
|
|
|
|
|
class _NoopProvider:
|
|
provider_name = "fake"
|
|
|
|
def chat(self, messages: Any, tools: Any = None, config: Any = None) -> Any: # pragma: no cover
|
|
raise AssertionError("provider should not be used")
|
|
|
|
async def list_models(self) -> list[Any]:
|
|
return []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_projection_dedupes_identical_tool_results(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Real path: the live tokenjuice projection externalizes a large tool result to the
|
|
store, and re-projecting identical content reuses a single record instead of growing
|
|
the store every turn."""
|
|
|
|
def fake_reduce(**kwargs: Any) -> Any:
|
|
return SimpleNamespace(
|
|
inline_text="[tokenjuice]\nreduced",
|
|
raw_chars=len(kwargs["content"]),
|
|
reduced_chars=18,
|
|
ratio=0.1,
|
|
reducer="tests/pytest",
|
|
)
|
|
|
|
monkeypatch.setattr(agent_mod, "reduce_tool_result_with_tokenjuice", fake_reduce, raising=False)
|
|
|
|
store_dir = tmp_path / "tool-results"
|
|
agent = Agent(
|
|
provider=_NoopProvider(),
|
|
config=AgentConfig(
|
|
tool_result_store_dir=str(store_dir),
|
|
tool_result_store_session_id=_SESSION_ID,
|
|
tool_result_store_session_key=_SESSION_KEY,
|
|
tool_result_store_agent_id="main",
|
|
),
|
|
)
|
|
raw = "raw output\n" + ("x" * 8000)
|
|
|
|
first = await agent._canonicalize_tool_result(
|
|
ToolResult(tool_use_id="tool-1", tool_name="exec_command", content=raw)
|
|
)
|
|
second = await agent._canonicalize_tool_result(
|
|
ToolResult(tool_use_id="tool-2", tool_name="exec_command", content=raw)
|
|
)
|
|
|
|
handle_re = re.compile(r"tool_result_handle: (tr-[0-9a-f]{32})")
|
|
m1 = handle_re.search(first.content)
|
|
m2 = handle_re.search(second.content)
|
|
assert m1 is not None and m2 is not None
|
|
# Both projections point at the same content-addressed handle, and only one record
|
|
# exists on disk despite two separate tool results.
|
|
assert m1.group(1) == m2.group(1)
|
|
assert len(list(store_dir.rglob("content.txt"))) == 1
|