mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
dfaaad563c
A tool result stays whole in its own tool message; a request-prep marker names a document_id (the message's tool_call_id). read_document, search_document, document_info, and list_documents resolve that id back to the message body, so reading and searching a capped result needs no re-run. The store binds on the host instance and reads the agent's live history, which carries whole results and re-reads whatever a resume reloaded. Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
159 lines
5.6 KiB
Python
159 lines
5.6 KiB
Python
"""Document tools: read/search/list the tool results the request-prep pass capped."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from typing import TYPE_CHECKING
|
|
|
|
import msgspec
|
|
|
|
from plyngent.lmproto.openai_compatible.model import (
|
|
AnyChatMessage,
|
|
AssistantChatMessage,
|
|
AssistantFunctionTool,
|
|
AssistantFunctionToolCall,
|
|
ToolChatMessage,
|
|
UserChatMessage,
|
|
)
|
|
from plyngent.tools.context import InstanceState, bind_instance
|
|
from plyngent.tools.documents import DocumentStore
|
|
from plyngent.tools.file import document_info, list_documents, read_document, search_document
|
|
from tests.test_tools.helpers import call_async
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Generator, Sequence
|
|
|
|
|
|
def _history(text: str, *, tool_call_id: str = "call-1", tool_name: str = "some_tool") -> list[AnyChatMessage]:
|
|
return [
|
|
UserChatMessage(content="go"),
|
|
AssistantChatMessage(
|
|
content="",
|
|
tool_calls=[
|
|
AssistantFunctionToolCall(
|
|
id=tool_call_id,
|
|
function=AssistantFunctionTool(name=tool_name, arguments="{}"),
|
|
)
|
|
],
|
|
),
|
|
ToolChatMessage(content=text, tool_call_id=tool_call_id),
|
|
]
|
|
|
|
|
|
@contextmanager
|
|
def bound_document(
|
|
text: str,
|
|
*,
|
|
tool_call_id: str = "call-1",
|
|
tool_name: str = "some_tool",
|
|
) -> Generator[DocumentStore]:
|
|
"""Bind an instance whose document store reads one tool result."""
|
|
messages = _history(text, tool_call_id=tool_call_id, tool_name=tool_name)
|
|
instance = InstanceState()
|
|
store = DocumentStore(lambda: messages)
|
|
instance.extras["documents"] = store
|
|
with bind_instance(instance):
|
|
yield store
|
|
|
|
|
|
_TEN_LINES = "".join(f"line {i}\n" for i in range(1, 11))
|
|
|
|
|
|
async def test_read_document_without_store(workspace: object) -> None:
|
|
del workspace
|
|
out = await call_async(read_document, "call-1")
|
|
assert out.startswith("error:")
|
|
|
|
|
|
async def test_read_document_unknown_id() -> None:
|
|
with bound_document(_TEN_LINES):
|
|
out = await call_async(read_document, "nope")
|
|
assert out.startswith("error:")
|
|
assert "unknown document" in out
|
|
|
|
|
|
async def test_read_document_window_and_range() -> None:
|
|
with bound_document(_TEN_LINES):
|
|
window = await call_async(read_document, "call-1", offset=0, limit=3)
|
|
whole = await call_async(read_document, "call-1")
|
|
mid = await call_async(read_document, "call-1", offset=2, limit=2)
|
|
assert window == "L1-3\nline 1\nline 2\nline 3\n[... more: next offset=3]"
|
|
assert whole == f"L1-10\n{_TEN_LINES}"
|
|
assert "next offset" not in whole
|
|
assert mid.startswith("L3-4\nline 3\nline 4\n")
|
|
|
|
|
|
async def test_read_document_numbered() -> None:
|
|
with bound_document(_TEN_LINES):
|
|
out = await call_async(read_document, "call-1", offset=1, limit=2, with_lineno=True)
|
|
assert out.startswith(" 2|line 2\n 3|line 3\n")
|
|
|
|
|
|
async def test_read_document_cut_names_next_offset() -> None:
|
|
text = "".join(f"a very long line number {i}\n" for i in range(1, 21))
|
|
with bound_document(text):
|
|
out = await call_async(read_document, "call-1", max_chars=40)
|
|
assert "next offset=" in out
|
|
offset = int(out.rsplit("next offset=", 1)[1].split("]", 1)[0])
|
|
nxt = await call_async(read_document, "call-1", offset=offset)
|
|
assert nxt.startswith(f"L{offset + 1}-")
|
|
|
|
|
|
async def test_read_document_offset_past_end() -> None:
|
|
with bound_document(_TEN_LINES):
|
|
out = await call_async(read_document, "call-1", offset=99)
|
|
assert out == ""
|
|
|
|
|
|
async def test_search_document_hits_and_miss() -> None:
|
|
with bound_document(_TEN_LINES):
|
|
hits = await call_async(search_document, "call-1", r"line \d+")
|
|
miss = await call_async(search_document, "call-1", "absent")
|
|
assert hits.splitlines()[0] == "L1: line 1"
|
|
assert "L10: line 10" in hits
|
|
assert miss == "(no matches)"
|
|
|
|
|
|
async def test_search_document_context_and_cap() -> None:
|
|
with bound_document(_TEN_LINES):
|
|
block = await call_async(search_document, "call-1", "line 5", context_lines=1)
|
|
capped = await call_async(search_document, "call-1", r"line \d+", max_matches=2)
|
|
assert block == "L4: line 4\nL5: line 5\nL6: line 6"
|
|
assert "...[truncated at 2 matches]" in capped
|
|
|
|
|
|
async def test_search_document_invalid_regex() -> None:
|
|
with bound_document(_TEN_LINES):
|
|
out = await call_async(search_document, "call-1", "(")
|
|
assert out.startswith("error: invalid regex")
|
|
|
|
|
|
async def test_document_info_and_list() -> None:
|
|
with bound_document(_TEN_LINES, tool_name="run_argv"):
|
|
info = await call_async(document_info, "call-1")
|
|
listing = await call_async(list_documents)
|
|
assert "tool=run_argv" in info
|
|
assert "lines=10" in info
|
|
assert "first=line 1" in info
|
|
assert "last=line 10" in info
|
|
assert "document_id=call-1 tool=run_argv" in listing
|
|
assert "chars=" in listing
|
|
|
|
|
|
async def test_list_documents_without_store(workspace: object) -> None:
|
|
del workspace
|
|
assert (await call_async(list_documents)).startswith("error:")
|
|
|
|
|
|
async def test_document_survives_serialization_roundtrip() -> None:
|
|
"""A resume reloads messages from JSON; the store still resolves the id."""
|
|
messages: Sequence[AnyChatMessage] = _history(_TEN_LINES)
|
|
reloaded: list[AnyChatMessage] = [
|
|
msgspec.convert(msgspec.to_builtins(message), type=AnyChatMessage) for message in messages
|
|
]
|
|
instance = InstanceState()
|
|
instance.extras["documents"] = DocumentStore(lambda: reloaded)
|
|
with bind_instance(instance):
|
|
out = await call_async(read_document, "call-1", offset=0, limit=1)
|
|
assert out.startswith("L1-1\nline 1\n")
|