feat(agent): search_documents tool — the model can grep the indexed documents for an exact string
This commit is contained in:
@@ -3,21 +3,31 @@
|
||||
``list_catalog`` must order rows by ``(source, path)`` — the same order as
|
||||
``GET /api/docs`` — and ``find_document`` must resolve a hit to the full
|
||||
document row (content included, for the never-truncated read) and return
|
||||
``None`` for unknown ``source``/``path`` pairs.
|
||||
``None`` for unknown ``source``/``path`` pairs. Phase 68: the
|
||||
``search_documents`` tool is pinned here too — its locked parameter
|
||||
shape in ``AGENT_TOOLS``, and a scripted ``ToolCallPiece`` executed
|
||||
through ``run_agent`` against the real DB (``all_documents`` for a
|
||||
whole-KB search, ``find_document`` for a scoped one).
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from copy import deepcopy
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import Document
|
||||
from app.rag import agent
|
||||
from app.rag.agent import AGENT_TOOLS, AgentHolder, run_agent
|
||||
from app.rag.llm import LLMClient, RetryPiece, StreamPiece, ToolCallPiece
|
||||
|
||||
|
||||
def _doc(db: Session, source: str, path: str, title: str, content: str) -> Document:
|
||||
@@ -81,3 +91,152 @@ def test_find_document_none_for_unknown_pairs(kb, db) -> None:
|
||||
assert agent.find_document(db, "Alpha", "nope.md") is None # wrong path
|
||||
assert agent.find_document(db, "Beta", "x.md") is None # wrong source
|
||||
assert agent.find_document(db, "nope", "nope.md") is None # nothing at all
|
||||
|
||||
|
||||
# ---------- search_documents (phase 68) ----------
|
||||
|
||||
|
||||
def test_all_documents_orders_by_source_then_path(kb, db) -> None:
|
||||
_doc(db, "Zeta", "b/second.md", "Zeta B", "ZB")
|
||||
_doc(db, "Zeta", "a/first.md", "Zeta A", "ZA")
|
||||
_doc(db, "Alpha", "c/third.md", "Alpha C", "AC")
|
||||
db.commit()
|
||||
|
||||
docs = agent.all_documents(db)
|
||||
assert [(d.source, d.path) for d in docs] == [
|
||||
("Alpha", "c/third.md"),
|
||||
("Zeta", "a/first.md"),
|
||||
("Zeta", "b/second.md"),
|
||||
]
|
||||
assert [d.content for d in docs] == ["AC", "ZA", "ZB"] # full rows
|
||||
|
||||
|
||||
def test_agent_tools_offers_search_documents_with_locked_shape() -> None:
|
||||
by_name = {t["function"]["name"]: t for t in AGENT_TOOLS}
|
||||
assert list(by_name) == [ # the third tool, in order
|
||||
"list_documents",
|
||||
"read_document",
|
||||
"search_documents",
|
||||
]
|
||||
search = by_name["search_documents"]["function"]["parameters"]
|
||||
assert search["type"] == "object"
|
||||
assert search["required"] == ["pattern"]
|
||||
assert set(search["properties"]) == {"pattern", "source", "path"}
|
||||
assert all(p["type"] == "string" for p in search["properties"].values())
|
||||
|
||||
|
||||
class ScriptedToolLLM:
|
||||
"""One scripted tool-call stream, then one canned answer stream.
|
||||
Records every ``chat_stream`` request's messages and tools."""
|
||||
|
||||
def __init__(self, call: ToolCallPiece) -> None:
|
||||
self.call = call
|
||||
self.requests: list[
|
||||
tuple[list[dict[str, Any]], list[dict[str, Any]] | None]
|
||||
] = []
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||
self.requests.append((deepcopy(messages), deepcopy(tools)))
|
||||
if len(self.requests) == 1:
|
||||
yield self.call
|
||||
else:
|
||||
yield StreamPiece("content", "ans")
|
||||
|
||||
|
||||
def _settings(**kwargs: Any) -> Settings:
|
||||
kwargs.setdefault("_env_file", None)
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
def _run_search(
|
||||
db: Session, arguments: dict[str, Any]
|
||||
) -> tuple[AgentHolder, ScriptedToolLLM]:
|
||||
"""Drive one scripted ``search_documents`` call through ``run_agent``."""
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedToolLLM(
|
||||
ToolCallPiece(id="call_1", name="search_documents", arguments=arguments)
|
||||
)
|
||||
asyncio.run(_consume(llm, db, holder))
|
||||
return holder, llm
|
||||
|
||||
|
||||
async def _consume(
|
||||
llm: ScriptedToolLLM, db: Session, holder: AgentHolder
|
||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
|
||||
async for piece in run_agent(
|
||||
cast("LLMClient", llm),
|
||||
db,
|
||||
system_prompt="SYSTEM_PROMPT",
|
||||
user_message="QUESTION",
|
||||
seed_docs=[],
|
||||
settings=_settings(),
|
||||
holder=holder,
|
||||
):
|
||||
out.append(piece)
|
||||
return out
|
||||
|
||||
|
||||
def test_search_whole_kb_through_run_agent(kb, db) -> None:
|
||||
_doc(db, "Beta", "b/two.md", "Two", "no hit\nNEEDLE in two\nlast")
|
||||
_doc(db, "Alpha", "a/one.md", "One", "first\nneedle in one\nthird")
|
||||
db.commit()
|
||||
|
||||
holder, llm = _run_search(db, {"pattern": "needle"})
|
||||
|
||||
# Offered: the first request carries AGENT_TOOLS (the 3-tool list).
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
# Executed against the real DB: catalog order, grep-style lines.
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"Alpha/a/one.md:2: needle in one\n"
|
||||
"Beta/b/two.md:2: NEEDLE in two"
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
assert holder.read_docs == [] # locked A5: search adds no context
|
||||
|
||||
|
||||
def test_search_scoped_through_run_agent(kb, db) -> None:
|
||||
_doc(db, "Alpha", "a/one.md", "One", "first\nNeedle here\nthird")
|
||||
_doc(db, "Beta", "b/two.md", "Two", "NEEDLE too")
|
||||
db.commit()
|
||||
|
||||
holder, llm = _run_search(
|
||||
db, {"pattern": "needle", "source": "Alpha", "path": "a/one.md"}
|
||||
)
|
||||
|
||||
# Only the named document is searched — the other one's hit is absent.
|
||||
assert llm.requests[1][0][3]["content"] == "Alpha/a/one.md:2: Needle here"
|
||||
assert holder.tool_calls == 1
|
||||
assert holder.read_docs == []
|
||||
|
||||
|
||||
def test_search_scoped_missing_doc_refused_through_run_agent(kb, db) -> None:
|
||||
_doc(db, "Alpha", "a/one.md", "One", "nothing")
|
||||
db.commit()
|
||||
|
||||
holder, llm = _run_search(
|
||||
db, {"pattern": "needle", "source": "Alpha", "path": "ghost.md"}
|
||||
)
|
||||
|
||||
assert (
|
||||
llm.requests[1][0][3]["content"]
|
||||
== "No document at Alpha/ghost.md — check the list_documents output."
|
||||
)
|
||||
assert holder.tool_calls == 0 and holder.read_docs == []
|
||||
|
||||
|
||||
def test_search_no_matches_through_run_agent(kb, db) -> None:
|
||||
_doc(db, "Alpha", "a/one.md", "One", "nothing matching")
|
||||
db.commit()
|
||||
|
||||
holder, llm = _run_search(db, {"pattern": "zebra"})
|
||||
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"No matches for 'zebra' in the knowledge base."
|
||||
)
|
||||
assert holder.tool_calls == 1 # an executed search with zero hits
|
||||
assert holder.read_docs == []
|
||||
|
||||
@@ -298,17 +298,20 @@ def test_ui_chrome_has_no_emoji(client, path: str) -> None:
|
||||
the JS that renders it, and the stylesheet — is emoji-free.
|
||||
|
||||
Phase 37 revision (owner permission 2026-08-26, PLAN §4): the agent's
|
||||
``.tool-call`` line carries two CONTENT marks — 🔎 (list) and 📄
|
||||
``.tool-call`` line carries the CONTENT marks — 🔎 (list) and 📄
|
||||
(read) — the only emoji in the whole frontend, and only as the exact
|
||||
tool-line template strings in app.js. The guard strips precisely
|
||||
those two literals; any other emoji, or those marks anywhere else,
|
||||
still fails."""
|
||||
tool-line template strings in app.js. Phase 68 revision: the
|
||||
``search_documents`` tool line adds the third template literal
|
||||
("🔎 Searching for "). The guard strips precisely those three
|
||||
literals; any other emoji, or those marks anywhere else, still
|
||||
fails."""
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
if path in ("/assets/app.js", "/assets/shared.js"):
|
||||
text = text.replace('"🔎 Listing documents"', "")
|
||||
text = text.replace('"📄 Reading "', "")
|
||||
text = text.replace('"🔎 Searching for "', "")
|
||||
assert _find_emoji(text) == [], f"emoji found in {path}: {_find_emoji(text)!r}"
|
||||
|
||||
|
||||
|
||||
@@ -621,6 +621,65 @@ def test_grounded_turn_streams_tool_frames_and_cites_read_doc(
|
||||
assert "'docs/homelab/backups.md'" in lines[-1]
|
||||
|
||||
|
||||
def test_grounded_turn_streams_search_tool_frames(
|
||||
client, db, seeded_kb: FakeRagLLM
|
||||
) -> None:
|
||||
"""Phase 68: a scripted ``search_documents`` call streams as
|
||||
``{type: "tool", name: "search_documents", argument: <pattern>}`` —
|
||||
the raw pattern is the frame's ``argument`` (the UI renders the
|
||||
"searching for" line from it). A non-string pattern — a model error
|
||||
the backend refuses — yields ``argument: null``. A search adds no
|
||||
source: ``done.sources`` stays the retrieval docs (locked A5)."""
|
||||
scripted = FakeRagLLM(
|
||||
tool_script=[
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="search_documents",
|
||||
arguments={"pattern": "Cilium"},
|
||||
),
|
||||
],
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_2",
|
||||
name="search_documents",
|
||||
arguments={"pattern": 42}, # model error: non-string
|
||||
),
|
||||
],
|
||||
# the answer request still carries the tools (2 rounds < the
|
||||
# default cap of 10); the fake's tool_script is exhausted, so
|
||||
# it falls back to the thinking + answer stream
|
||||
]
|
||||
)
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
types = [f["type"] for f in frames]
|
||||
assert "error" not in types
|
||||
assert len(scripted.seen_tools) == 3 # both searches executed (rounds)
|
||||
|
||||
tool_frames = [f for f in frames if f["type"] == "tool"]
|
||||
assert len(tool_frames) == 2
|
||||
first, second = tool_frames
|
||||
assert set(first) == {"type", "name", "argument"}
|
||||
assert first["name"] == "search_documents"
|
||||
assert first["argument"] == "Cilium" # the raw pattern
|
||||
assert set(second) == {"type", "name", "argument"}
|
||||
assert second["name"] == "search_documents"
|
||||
assert second["argument"] is None # the non-string pattern → null
|
||||
|
||||
# The searches still answered: deltas, then a grounded done.
|
||||
assert [f for f in frames if f["type"] == "delta"]
|
||||
done = frames[-1]
|
||||
assert done["type"] == "done" and done["deflected"] is False
|
||||
paths = [s["path"] for s in done["sources"]]
|
||||
assert "homelab/kubernetes.md" in paths # retrieval docs, unchanged
|
||||
assert "homelab/backups.md" not in paths # a search adds no source
|
||||
|
||||
|
||||
def test_deflected_turn_stays_byte_identical_without_tools(
|
||||
client, db, seeded_kb: FakeRagLLM
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user