chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"""The compaction-triggered tool-result store scan runs OFF the event loop.
|
||||
|
||||
``ToolResultStore.write`` does a store-wide ``rglob`` (the #305 scan). The
|
||||
budget-compaction assembly path calls it synchronously; the async wrapper must
|
||||
run that whole assembly in a worker thread so the O(store) filesystem scan never
|
||||
blocks the gateway event loop (issue #305 completeness).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.engine import Agent, AgentConfig
|
||||
from opensquilla.engine import tool_result_store as trs_module
|
||||
from opensquilla.provider import ContentBlockToolResult, ContentBlockToolUse, Message
|
||||
|
||||
|
||||
class _CapturingProvider:
|
||||
provider_name = "fake"
|
||||
|
||||
async def list_models(self): # pragma: no cover - not used
|
||||
return []
|
||||
|
||||
|
||||
def _agent_with_store(tmp_path: Path) -> Agent:
|
||||
return Agent(
|
||||
provider=_CapturingProvider(),
|
||||
config=AgentConfig(
|
||||
context_window_tokens=200,
|
||||
tool_result_store_dir=str(tmp_path / "store"),
|
||||
tool_result_store_session_id="sid-a",
|
||||
tool_result_store_session_key="agent:main:webchat:a",
|
||||
tool_result_store_agent_id="main",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _bulky_messages() -> list[Message]:
|
||||
raw = "compaction bulky output\n" + ("x" * 8000)
|
||||
return [
|
||||
Message(
|
||||
role="assistant",
|
||||
content=[ContentBlockToolUse(id="tool-1", name="execute_code", input={})],
|
||||
),
|
||||
Message(
|
||||
role="user",
|
||||
content=[ContentBlockToolResult(tool_use_id="tool-1", content=raw)],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_scan_runs_off_event_loop_thread(tmp_path: Path, monkeypatch) -> None:
|
||||
loop_thread_id = threading.get_ident()
|
||||
scan_thread_ids: list[int] = []
|
||||
|
||||
original_iter = trs_module.ToolResultStore._iter_record_stats
|
||||
|
||||
def _record_thread(self): # type: ignore[no-untyped-def]
|
||||
scan_thread_ids.append(threading.get_ident())
|
||||
return original_iter(self)
|
||||
|
||||
monkeypatch.setattr(trs_module.ToolResultStore, "_iter_record_stats", _record_thread)
|
||||
|
||||
agent = _agent_with_store(tmp_path)
|
||||
messages = _bulky_messages()
|
||||
|
||||
result_messages, _ = await agent._provider_request_messages_with_sanitize_async(
|
||||
messages,
|
||||
request_context_message=None,
|
||||
request_context_insert_index=0,
|
||||
runtime_context_message=Message(role="user", content="[Runtime context]"),
|
||||
runtime_context_insert_index=len(messages),
|
||||
)
|
||||
|
||||
# The store scan must have happened (compaction stored a snapshot) and never
|
||||
# on the event-loop thread.
|
||||
assert scan_thread_ids, "expected the compaction path to write a snapshot (store scan)"
|
||||
assert loop_thread_id not in scan_thread_ids
|
||||
# The projection replaced the bulky content (sanity: the assembly ran).
|
||||
projected = next(
|
||||
block
|
||||
for message in result_messages
|
||||
if isinstance(message.content, list)
|
||||
for block in message.content
|
||||
if isinstance(block, ContentBlockToolResult) and block.tool_use_id == "tool-1"
|
||||
)
|
||||
assert len(projected.content) < 8000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_wrapper_matches_sync_result(tmp_path: Path) -> None:
|
||||
# The off-loop wrapper must produce the same assembly as the sync path.
|
||||
agent = _agent_with_store(tmp_path)
|
||||
messages = _bulky_messages()
|
||||
|
||||
sync_messages = agent._provider_request_messages(
|
||||
[m for m in messages],
|
||||
request_context_message=None,
|
||||
request_context_insert_index=0,
|
||||
runtime_context_message=Message(role="user", content="[Runtime context]"),
|
||||
runtime_context_insert_index=len(messages),
|
||||
)
|
||||
async_messages = await agent._provider_request_messages_async(
|
||||
[m for m in messages],
|
||||
request_context_message=None,
|
||||
request_context_insert_index=0,
|
||||
runtime_context_message=Message(role="user", content="[Runtime context]"),
|
||||
runtime_context_insert_index=len(messages),
|
||||
)
|
||||
|
||||
def _tool_text(msgs):
|
||||
for message in msgs:
|
||||
if isinstance(message.content, list):
|
||||
for block in message.content:
|
||||
if isinstance(block, ContentBlockToolResult) and block.tool_use_id == "tool-1":
|
||||
return block.content
|
||||
return None
|
||||
|
||||
assert _tool_text(sync_messages) == _tool_text(async_messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapper_does_not_block_a_concurrent_loop_task(tmp_path: Path, monkeypatch) -> None:
|
||||
# While the off-loop assembly runs (simulated slow scan), a concurrent loop
|
||||
# task must keep making progress.
|
||||
original_iter = trs_module.ToolResultStore._iter_record_stats
|
||||
|
||||
def _slow_iter(self): # type: ignore[no-untyped-def]
|
||||
import time
|
||||
|
||||
time.sleep(0.2) # blocking sleep — would stall the loop if run on it
|
||||
return original_iter(self)
|
||||
|
||||
monkeypatch.setattr(trs_module.ToolResultStore, "_iter_record_stats", _slow_iter)
|
||||
|
||||
agent = _agent_with_store(tmp_path)
|
||||
ticks = 0
|
||||
|
||||
async def _ticker():
|
||||
nonlocal ticks
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.01)
|
||||
ticks += 1
|
||||
|
||||
ticker = asyncio.create_task(_ticker())
|
||||
await agent._provider_request_messages_with_sanitize_async(
|
||||
_bulky_messages(),
|
||||
request_context_message=None,
|
||||
request_context_insert_index=0,
|
||||
runtime_context_message=Message(role="user", content="[Runtime context]"),
|
||||
runtime_context_insert_index=2,
|
||||
)
|
||||
await ticker
|
||||
# The ticker advanced during the blocking scan → the scan did not run on the loop.
|
||||
assert ticks >= 10
|
||||
Reference in New Issue
Block a user