Files
agent-desktop/tests/unit/agents/context/test_recall_tool.py
T

604 lines
18 KiB
Python

# -*- coding: utf-8 -*-
# pylint: disable=redefined-outer-name,protected-access
"""Unit tests for the structured ``recall_history`` tool.
The point of this tool is that the common recall ops (expand / search /
recall_tool) run in-process with bound parameters — no sandbox, no approval —
so fold stubs and the eviction index stay readable on platforms where the
sandboxed REPL can't run. These tests pin the op semantics, the
failure-vs-empty observation shapes (same discipline as the REPL's), and the
no-sandbox registration contract.
"""
import asyncio
import threading
from pathlib import Path
import pytest
from agentscope.message import ToolResultState
from pineagents.agents.context.scroll.history import HistoryStore
from pineagents.agents.context.scroll.memoryspace import MemorySpace
from pineagents.agents.context.scroll.recall_tool import (
RECALL_PAGE_METADATA_KEY,
RecallLoopGuard,
_render_page,
make_recall_history,
)
from pineagents.agents.context.types import LogEntry
@pytest.fixture
def history_db(tmp_path: Path) -> Path:
"""A durable store with a past turn, a tool result, and an active turn."""
h = HistoryStore(tmp_path / "history.db")
h.append(
session_id="s1",
agent_id="ag1",
dedup_key="u1",
entry=LogEntry(kind="context_msg", role="user", content="hello there"),
)
h.append(
session_id="s1",
agent_id="ag1",
dedup_key="m1",
entry=LogEntry(
kind="model_turn",
role="assistant",
content="the flight is AA231",
headline="flight AA231",
),
)
h.append(
session_id="s1",
agent_id="ag1",
dedup_key="t1",
entry=LogEntry(
kind="tool_result",
role="assistant",
name="grep",
tool_call_id="call_abc",
content="RESULT-FULL",
),
)
# The active turn: a later user request (search must never surface it).
h.append(
session_id="s1",
agent_id="ag1",
dedup_key="u2",
entry=LogEntry(
kind="context_msg",
role="user",
content="what was the flight again",
),
)
h.close()
return tmp_path / "history.db"
@pytest.fixture
def tool(history_db: Path):
return make_recall_history(
history_db_path=str(history_db),
session_id="s1",
agent_id="ag1",
)
def _text(chunk) -> str:
return chunk.content[0].text
async def test_expand_returns_full_turns(tool):
chunk = await tool(op="expand", lo=1, hi=3)
assert chunk.state == ToolResultState.SUCCESS
text = _text(chunk)
assert "hello there" in text
assert "the flight is AA231" in text
assert "RESULT-FULL" in text
assert "seq=1" in text
async def test_search_finds_evicted_turn_not_active_turn(tool):
chunk = await tool(op="search", query="flight", k=10)
assert chunk.state == ToolResultState.SUCCESS
text = _text(chunk)
assert "the flight is AA231" in text
# The active turn (latest user request) is excluded from hits.
assert "what was the flight again" not in text
async def test_recall_tool_by_call_id(tool):
chunk = await tool(op="recall_tool", tool_call_id="call_abc")
assert chunk.state == ToolResultState.SUCCESS
assert "RESULT-FULL" in _text(chunk)
async def test_duplicate_recall_is_blocked_only_within_current_turn(
history_db: Path,
):
guard = RecallLoopGuard()
guard.begin_turn("user-1")
guarded_tool = make_recall_history(
history_db_path=str(history_db),
session_id="s1",
agent_id="ag1",
loop_guard=guard,
)
first = await guarded_tool(op="expand", lo=1, hi=3)
duplicate = await guarded_tool(op="expand", lo=1, hi=3)
narrower = await guarded_tool(op="expand", lo=1, hi=2)
assert first.state == ToolResultState.SUCCESS
assert duplicate.state == ToolResultState.ERROR
assert "RECALL LOOP BLOCKED" in _text(duplicate)
assert narrower.state == ToolResultState.SUCCESS
guard.begin_turn("user-2")
next_turn = await guarded_tool(op="expand", lo=1, hi=3)
assert next_turn.state == ToolResultState.SUCCESS
async def test_concurrent_duplicate_recall_executes_query_once(
history_db: Path,
monkeypatch,
):
guard = RecallLoopGuard()
guard.begin_turn("user-1")
guarded_tool = make_recall_history(
history_db_path=str(history_db),
session_id="s1",
agent_id="ag1",
loop_guard=guard,
)
started = threading.Event()
release = threading.Event()
calls = 0
original_expand = MemorySpace.expand
def blocking_expand(self, lo, hi):
nonlocal calls
calls += 1
started.set()
assert release.wait(timeout=5)
return original_expand(self, lo, hi)
monkeypatch.setattr(MemorySpace, "expand", blocking_expand)
first_task = asyncio.create_task(
guarded_tool(op="expand", lo=1, hi=3),
)
assert await asyncio.to_thread(started.wait, 5)
duplicate = await guarded_tool(op="expand", lo=1, hi=3)
release.set()
first = await first_task
completed_duplicate = await guarded_tool(op="expand", lo=1, hi=3)
assert first.state == ToolResultState.SUCCESS
assert duplicate.state == ToolResultState.ERROR
assert "already running" in _text(duplicate)
assert completed_duplicate.state == ToolResultState.ERROR
assert calls == 1
async def test_large_recall_is_cursor_paginated(
tmp_path: Path,
):
history = HistoryStore(tmp_path / "large-history.db")
history.append(
session_id="old",
agent_id="ag1",
dedup_key="large",
entry=LogEntry(
kind="model_turn",
role="assistant",
content="line of history\n" * 5000,
),
)
history.close()
guard = RecallLoopGuard()
guard.begin_turn("user-1")
bounded_tool = make_recall_history(
history_db_path=str(tmp_path / "large-history.db"),
session_id="current",
agent_id="ag1",
loop_guard=guard,
page_max_bytes=1024,
)
chunk = await bounded_tool(op="expand", lo=1, hi=1)
assert len(_text(chunk).encode("utf-8")) <= 1024
assert "[recall page incomplete]" in _text(chunk)
page = chunk.metadata[RECALL_PAGE_METADATA_KEY]
assert page["next_cursor"]
duplicate = await bounded_tool(op="expand", lo=1, hi=1)
assert duplicate.state == ToolResultState.ERROR
assert "RECALL LOOP BLOCKED" in _text(duplicate)
pages = 1
while page["next_cursor"]:
chunk = await bounded_tool(
op="expand",
lo=1,
hi=1,
cursor=page["next_cursor"],
)
pages += 1
assert len(_text(chunk).encode("utf-8")) <= 1024
page = chunk.metadata[RECALL_PAGE_METADATA_KEY]
assert pages < 200
assert pages > 1
assert page["complete"] is True
assert "[recall page complete]" in _text(chunk)
def test_render_page_with_long_utf8_label_always_advances():
rows = [
{
"seq": 1,
"kind": "model_turn",
"role": "assistant",
"content": "page content " * 200,
},
]
label = "搜索" * 100
_, first = _render_page(
rows,
label=label,
cursor=None,
max_bytes=1000,
request_fingerprint="request",
)
_, second = _render_page(
rows,
label=label,
cursor=first["next_cursor"],
max_bytes=1000,
request_fingerprint="request",
)
assert first["next_cursor"] is not None
assert second["next_cursor"] != first["next_cursor"]
def test_render_page_fails_when_byte_limit_cannot_make_progress():
rows = [{"seq": 1, "kind": "model_turn", "content": "content"}]
with pytest.raises(ValueError, match="too small to make progress"):
_render_page(
rows,
label="搜索" * 100,
cursor=None,
max_bytes=100,
request_fingerprint="request",
)
async def test_large_historical_tool_result_exposes_artifact_on_first_page(
tmp_path: Path,
):
artifact = tmp_path / "original-tool-output.txt"
artifact.write_text(
"original result with final sentinel",
encoding="utf-8",
)
history = HistoryStore(tmp_path / "artifact-history.db")
history.append(
session_id="old",
agent_id="ag1",
dedup_key="large-tool",
entry=LogEntry(
kind="tool_result",
role="assistant",
name="shell",
tool_call_id="call-large",
content="preview line\n" * 5000,
metadata={
"qwenpaw_truncation": {
"0": {
"file_path": str(artifact),
"start_line": 37,
},
},
},
),
)
history.close()
bounded_tool = make_recall_history(
history_db_path=str(tmp_path / "artifact-history.db"),
session_id="current",
agent_id="ag1",
page_max_bytes=1024,
)
chunk = await bounded_tool(
op="recall_tool",
tool_call_id="call-large",
)
assert f"file_path={str(artifact)!r}" in _text(chunk)
assert "start_line=37" in _text(chunk)
async def test_cursor_is_bound_to_original_search_arguments(tmp_path: Path):
db_path = tmp_path / "fingerprint-history.db"
history = HistoryStore(db_path)
history.append(
session_id="old",
agent_id="ag1",
dedup_key="large-search-row",
entry=LogEntry(
kind="model_turn",
role="assistant",
content="alpha beta evidence\n" * 500,
),
)
history.close()
bounded_tool = make_recall_history(
history_db_path=str(db_path),
session_id="current",
agent_id="ag1",
page_max_bytes=1024,
)
first = await bounded_tool(op="search", query="alpha", k=10)
cursor = first.metadata[RECALL_PAGE_METADATA_KEY]["next_cursor"]
assert cursor.startswith("v1.")
continuation = await bounded_tool(
op="search",
query="alpha",
k=10,
cursor=cursor,
)
assert continuation.state == ToolResultState.SUCCESS
changed_query = await bounded_tool(
op="search",
query="beta",
k=10,
cursor=cursor,
)
assert changed_query.state == ToolResultState.ERROR
assert "different recall request" in _text(changed_query)
changed_k = await bounded_tool(
op="search",
query="alpha",
k=20,
cursor=cursor,
)
assert changed_k.state == ToolResultState.ERROR
assert "different recall request" in _text(changed_k)
async def test_cursor_detects_result_snapshot_drift(tmp_path: Path):
db_path = tmp_path / "snapshot-history.db"
history = HistoryStore(db_path)
history.append(
session_id="old",
agent_id="ag1",
dedup_key="first-result",
entry=LogEntry(
kind="model_turn",
role="assistant",
content="snapshotneedle\n" * 500,
),
)
history.close()
guard = RecallLoopGuard()
guard.begin_turn("user-1")
bounded_tool = make_recall_history(
history_db_path=str(db_path),
session_id="current",
agent_id="ag1",
loop_guard=guard,
page_max_bytes=1024,
)
first = await bounded_tool(op="search", query="snapshotneedle", k=10)
cursor = first.metadata[RECALL_PAGE_METADATA_KEY]["next_cursor"]
history = HistoryStore(db_path)
history.append(
session_id="old",
agent_id="ag1",
dedup_key="new-result",
entry=LogEntry(
kind="model_turn",
role="assistant",
content="new snapshotneedle result",
),
)
history.close()
drifted = await bounded_tool(
op="search",
query="snapshotneedle",
k=10,
cursor=cursor,
)
assert drifted.state == ToolResultState.ERROR
assert "results changed since the previous page" in _text(drifted)
restarted = await bounded_tool(
op="search",
query="snapshotneedle",
k=10,
)
assert restarted.state == ToolResultState.SUCCESS
assert restarted.metadata[RECALL_PAGE_METADATA_KEY]["total_rows"] == 2
def test_old_completion_cannot_block_same_request_in_new_turn():
guard = RecallLoopGuard()
payload = {"lo": 1, "hi": 3}
guard.begin_turn("user-1")
old_generation, notice = guard.claim("expand", payload)
assert old_generation is not None
assert notice is None
guard.begin_turn("user-2")
new_generation, notice = guard.claim("expand", payload)
assert new_generation is not None
assert notice is None
guard.finish("expand", payload, old_generation, block=True)
assert guard.is_blocked("expand", payload) is False
guard.finish("expand", payload, new_generation, block=True)
assert guard.is_blocked("expand", payload) is True
async def test_recall_queries_run_outside_event_loop(tool, monkeypatch):
event_loop_thread = threading.get_ident()
query_threads: list[int] = []
original_expand = MemorySpace.expand
def tracked_expand(self, lo, hi):
query_threads.append(threading.get_ident())
return original_expand(self, lo, hi)
monkeypatch.setattr(MemorySpace, "expand", tracked_expand)
chunk = await tool(op="expand", lo=1, hi=3)
assert chunk.state == ToolResultState.SUCCESS
assert query_threads
assert all(thread_id != event_loop_thread for thread_id in query_threads)
async def test_empty_span_reads_as_genuine_absence(tool):
chunk = await tool(op="expand", lo=900, hi=905)
# Empty is a successful read, worded as evidence of absence — the
# opposite shape from a failure.
assert chunk.state == ToolResultState.SUCCESS
text = _text(chunk)
assert text.startswith("0 rows")
assert "genuinely holds nothing" in text
assert "RECALL FAILED" not in text
async def test_unknown_op_fails_loudly(tool):
chunk = await tool(op="everything")
assert chunk.state == ToolResultState.ERROR
assert _text(chunk).startswith("RECALL FAILED")
async def test_unknown_op_observation_is_byte_bounded(history_db: Path):
bounded_tool = make_recall_history(
history_db_path=str(history_db),
session_id="s1",
agent_id="ag1",
page_max_bytes=1024,
)
chunk = await bounded_tool(op="" * 50_000)
assert chunk.state == ToolResultState.ERROR
assert len(_text(chunk).encode("utf-8")) <= 1024
assert "recall observation truncated" in _text(chunk)
assert chunk.metadata == {}
async def test_empty_search_observation_is_byte_bounded(
history_db: Path,
monkeypatch,
):
monkeypatch.setattr(MemorySpace, "search", lambda *_args, **_kwargs: [])
bounded_tool = make_recall_history(
history_db_path=str(history_db),
session_id="s1",
agent_id="ag1",
page_max_bytes=1024,
)
chunk = await bounded_tool(op="search", query="q" * 50_000)
assert chunk.state == ToolResultState.SUCCESS
assert len(_text(chunk).encode("utf-8")) <= 1024
assert "recall observation truncated" in _text(chunk)
page = chunk.metadata[RECALL_PAGE_METADATA_KEY]
assert page["next_cursor"] is None
assert page["complete"] is True
assert set(chunk.metadata) == {RECALL_PAGE_METADATA_KEY}
async def test_execution_error_observation_is_byte_bounded(
history_db: Path,
monkeypatch,
):
def raise_large_error(*_args, **_kwargs):
raise ValueError("x" * 50_000)
monkeypatch.setattr(MemorySpace, "expand", raise_large_error)
bounded_tool = make_recall_history(
history_db_path=str(history_db),
session_id="s1",
agent_id="ag1",
page_max_bytes=1024,
)
chunk = await bounded_tool(op="expand", lo=1, hi=1)
assert chunk.state == ToolResultState.ERROR
assert len(_text(chunk).encode("utf-8")) <= 1024
assert "recall observation truncated" in _text(chunk)
assert chunk.metadata == {}
async def test_missing_params_fail_loudly(tool):
for kwargs in (
{"op": "expand"}, # no lo/hi
{"op": "search"}, # no query
{"op": "recall_tool"}, # no tool_call_id
):
chunk = await tool(**kwargs)
assert chunk.state == ToolResultState.ERROR
assert _text(chunk).startswith("RECALL FAILED")
async def test_invalid_cursor_fails_instead_of_skipping_history(tool):
chunk = await tool(op="expand", lo=1, hi=3, cursor="999:0")
assert chunk.state == ToolResultState.ERROR
assert "exact value returned by recall_history" in _text(chunk)
async def test_broken_db_is_a_failure_not_an_empty_history(tmp_path: Path):
"""An unreadable store must produce RECALL FAILED, never '0 rows'."""
bad = tmp_path / "not-a-db"
bad.write_text("garbage", encoding="utf-8")
tool = make_recall_history(
history_db_path=str(bad),
session_id="s1",
agent_id="ag1",
)
chunk = await tool(op="expand", lo=1, hi=1)
assert chunk.state == ToolResultState.ERROR
assert "RECALL FAILED" in _text(chunk)
def test_descriptor_needs_no_sandbox(tool):
"""The registration contract this tool exists for: in-process, async,
and — unlike the REPL — no sandbox requirement, so governance never
routes it through SANDBOX_FALLBACK / approval."""
desc = tool._tool_descriptor
assert desc.name == "recall_history"
assert desc.requires_sandbox == ()
assert desc.async_execution is True
def test_governance_registers_internal_type():
"""RecallHistory is an internal governance type: policy Phase 0 allows it
outright — no deep scan, no sandbox fallback, no approval prompt."""
from pineagents.governance.tool_registry import DEFAULT_REGISTRY
assert DEFAULT_REGISTRY.get_type("RecallHistory") == "internal"
assert (
DEFAULT_REGISTRY.python_to_policy_name("recall_history")
== "RecallHistory"
)