Files
2026-07-13 13:12:33 +08:00

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