Phase 72 (72_teaching_refusals) — completed under the 2026-09-04 controlled methodology (owner directive: stop clearing/re-importing the homelab KB per iteration; measure tool-calling accuracy on a controlled fixture KB, target >90%). Real-model gate verdicts (live, configured chat model 'lite', fixture KB): - Controlled fixture battery (the new methodology's pass condition — contract accuracy >= 90%): PASS, 4 consecutive runs: gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 8/11 executed (73%) contract 11/11 (100%) 2026-09-04 (wall 43.4s) gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 8/13 executed (62%) contract 12/13 (92%) 2026-09-04 (wall 50.6s) gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 7/11 executed (64%) contract 11/11 (100%) 2026-09-04 (wall 46.8s) gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 9/15 executed (60%) contract 14/15 (93%) 2026-09-04 (wall 54.8s) - Locked derived battery (phase-72 task 05, executed >= 90% bar, run unchanged on the same fixture KB): gate: lite FAIL turns=10 answered=10 caps=0 tool-turns=10 calls 5/15 executed (33%) contract 12/15 (80%) 2026-09-04 (wall 47.7s) The teaching works — every bare-path trap self-corrects in exactly one round, zero cap hits, zero repeat loops, 10/10 answered. The locked executed bar is blocked by ALREADY_IN_CONTEXT dedupe refusals on the corrected re-reads (the trap question seeds its target, so the correct combined-form read is refused for redundancy) — a copy-invariant model behavior (five copy variants, 0/15 re-reads flipped, 2026-09-03 -> 04) and an app-semantics decision for the owner (TOOL_CALLING_TESTING.md sections 5 and 7), not a copy lever. Copy changes this phase owns (unit pins updated to follow): - app/rag/agent.py: ls teaching refusals (path-like scope -> document-path line; unknown source -> no-source line with the source-name parenthetical), read/grep 'did you mean source/path?' teaching (find_path_candidates: exact or suffix path match, catalog order, cap 3), ALREADY_IN_CONTEXT naming the correct action (answer from the text already in the prompt), read tool description front-loaded with the do-not-read rule (the 2026-09-04 controlled telemetry: the re-read is the only remaining refusal class; contract accuracy 92-100% across runs) - app/rag/prompts.py: TOOLS_SECTION states the document-identity contract up front (ls path = source name; read/grep = combined source/path including the source name; do-not-read for <documents> documents placed next to the read teaching; one-call-per-reply and never-repeat rules) - tests: refusal pins (unit + integration), new dedicated E2E suite tests/e2e/test_tool_path_teaching.py (mock misuse flow, green in isolation), regression suites green in isolation (harness_aligned_tools, agent_document_tools, agent_unlimited_tools, search_tool, chat_rag). Gates: uv run pytest green (1501); coverage TOTAL 99% (>90%); ruff + pyright clean. Carries the still-uncommitted phase-71 todo/ -> complete/ move and both phases' .agent/reports/ (AGENTS.md 8).
496 lines
18 KiB
Python
496 lines
18 KiB
Python
"""Integration: the agent DB accessors against real Postgres (phase 37;
|
|
the harness-aligned ``ls``/``read``/``grep`` surface, phase 70).
|
|
|
|
``list_catalog`` must order rows by ``(source, path)`` — the same order
|
|
as ``GET /api/docs`` — ``list_source_names`` must resolve the
|
|
registered source names (the scoped ``ls`` join), and ``find_document``
|
|
must resolve a hit to the full document row (content included, for the
|
|
never-truncated read) and return ``None`` for unknown pairs. Phase 70:
|
|
the ``ls``/``read``/``grep`` tools are pinned here too — the locked
|
|
parameter shape in ``AGENT_TOOLS``, and scripted ``ToolCallPiece``s
|
|
executed through ``run_agent`` against the real DB: ``ls`` scoped to a
|
|
registered source name (unknown name → refusal), ``read`` on the
|
|
canonical combined ``source/path`` form (first-slash split; a bare
|
|
source name and an unknown identity get the no-document refusal), and
|
|
``grep`` (``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 AsyncIterator, Iterator
|
|
from copy import deepcopy
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
import pytest
|
|
from sqlalchemy import delete, text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import Settings
|
|
from app.models import Document, GitSource
|
|
from app.rag import agent
|
|
from app.rag.agent import AGENT_TOOLS, AgentHolder, run_agent
|
|
from app.rag.llm import LLMClient, RetryPiece, StreamPiece, ToolCallPiece
|
|
|
|
if TYPE_CHECKING:
|
|
from app.rag.scaffolding import ScaffoldingFilter
|
|
|
|
|
|
def _doc(db: Session, source: str, path: str, title: str, content: str) -> Document:
|
|
doc = Document(
|
|
id=uuid.uuid4(),
|
|
source=source,
|
|
path=path,
|
|
full_path=f"/tmp/{source}/{path}",
|
|
title=title,
|
|
content=content,
|
|
content_hash="0" * 64,
|
|
)
|
|
db.add(doc)
|
|
return doc
|
|
|
|
|
|
@pytest.fixture()
|
|
def kb(db) -> Iterator[None]:
|
|
"""Fresh documents table (chunks first — the FK) for these accessors."""
|
|
db.execute(text("TRUNCATE chunks, documents"))
|
|
db.commit()
|
|
yield
|
|
db.execute(text("TRUNCATE chunks, documents"))
|
|
db.commit()
|
|
|
|
|
|
@pytest.fixture()
|
|
def src(db) -> Iterator[GitSource]:
|
|
"""One registered git source — the scoped ``ls`` source-name check
|
|
reads the real registry, so the row is inserted and deleted around
|
|
the tests (``repo_name`` resolves the URL to ``Homelab``)."""
|
|
row = GitSource(url="https://github.com/reese/Homelab.git", kind="git")
|
|
db.add(row)
|
|
db.commit()
|
|
yield row
|
|
db.execute(delete(GitSource).where(GitSource.id == row.id))
|
|
db.commit()
|
|
|
|
|
|
def test_list_catalog_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()
|
|
|
|
assert agent.list_catalog(db) == [
|
|
("Alpha", "c/third.md", "Alpha C"),
|
|
("Zeta", "a/first.md", "Zeta A"),
|
|
("Zeta", "b/second.md", "Zeta B"),
|
|
]
|
|
|
|
|
|
def test_list_catalog_is_empty_without_rows(kb, db) -> None:
|
|
assert agent.list_catalog(db) == []
|
|
|
|
|
|
def test_list_source_names_resolves_registry_rows(db) -> None:
|
|
"""The real registry: git names resolve through the import pipeline's
|
|
``repo_name`` (trailing ``.git`` stripped); a second row resolving to
|
|
the same name (the phase-69 sibling case) is listed once."""
|
|
a = GitSource(url="https://github.com/reese/Homelab.git", kind="git")
|
|
b = GitSource(url="https://github.com/reese/Homelab", kind="git") # sibling
|
|
c = GitSource(url="https://e.com/deployments", kind="git")
|
|
db.add_all([a, b, c])
|
|
db.commit()
|
|
try:
|
|
assert agent.list_source_names(db).count("Homelab") == 1 # deduped
|
|
assert "deployments" in agent.list_source_names(db)
|
|
finally:
|
|
db.execute(delete(GitSource).where(GitSource.id.in_([a.id, b.id, c.id])))
|
|
db.commit()
|
|
|
|
|
|
def test_find_document_hit_returns_full_row(kb, db) -> None:
|
|
created = _doc(db, "Alpha", "deep/nested/doc.md", "The Doc", "FULL-TEXT")
|
|
db.commit()
|
|
|
|
found = agent.find_document(db, "Alpha", "deep/nested/doc.md")
|
|
assert found is not None
|
|
assert found.id == created.id
|
|
assert found.source == "Alpha"
|
|
assert found.path == "deep/nested/doc.md"
|
|
assert found.title == "The Doc"
|
|
assert found.content == "FULL-TEXT" # the read tool feeds this, untruncated
|
|
|
|
|
|
def test_find_document_none_for_unknown_pairs(kb, db) -> None:
|
|
_doc(db, "Alpha", "x.md", "X", "X-CONTENT")
|
|
db.commit()
|
|
|
|
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
|
|
|
|
|
|
# ---------- AGENT_TOOLS surface (phase 70: ls / read / grep) ----------
|
|
|
|
|
|
def test_agent_tools_offers_the_harness_aligned_surface() -> None:
|
|
by_name = {t["function"]["name"]: t for t in AGENT_TOOLS}
|
|
assert list(by_name) == [ # the harness order, phase 70
|
|
"ls",
|
|
"read",
|
|
"grep",
|
|
]
|
|
ls = by_name["ls"]["function"]["parameters"]
|
|
assert ls["type"] == "object"
|
|
assert ls["required"] == [] # path is optional
|
|
assert set(ls["properties"]) == {"path"}
|
|
read = by_name["read"]["function"]["parameters"]
|
|
assert read["type"] == "object"
|
|
assert read["required"] == ["path"]
|
|
assert set(read["properties"]) == {"path"}
|
|
grep = by_name["grep"]["function"]["parameters"]
|
|
assert grep["type"] == "object"
|
|
assert grep["required"] == ["pattern"]
|
|
assert set(grep["properties"]) == {"pattern", "path"}
|
|
assert all(p["type"] == "string" for p in grep["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,
|
|
scaffolding: ScaffoldingFilter | None = None, # phase 71 pass-through
|
|
) -> 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]
|
|
|
|
|
|
class ScriptedToolCallsLLM:
|
|
"""N scripted tool-call rounds (one ``ToolCallPiece`` each), then one
|
|
canned answer; records every request's messages and tools (the
|
|
phase-72 task-02 two-round self-correction cases: the refusal round,
|
|
then the corrected call)."""
|
|
|
|
def __init__(self, calls: list[ToolCallPiece]) -> None:
|
|
self.calls = calls
|
|
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,
|
|
scaffolding: ScaffoldingFilter | None = None,
|
|
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
|
self.requests.append((deepcopy(messages), deepcopy(tools)))
|
|
index = len(self.requests) - 1
|
|
if index < len(self.calls):
|
|
yield self.calls[index]
|
|
else:
|
|
yield StreamPiece("content", "ans")
|
|
|
|
|
|
def _run_call(
|
|
db: Session, name: str, arguments: dict[str, Any]
|
|
) -> tuple[AgentHolder, ScriptedToolLLM]:
|
|
"""Drive one scripted tool call through ``run_agent``."""
|
|
holder = AgentHolder()
|
|
llm = ScriptedToolLLM(ToolCallPiece(id="call_1", name=name, arguments=arguments))
|
|
asyncio.run(_consume(cast("LLMClient", llm), db, holder))
|
|
return holder, llm
|
|
|
|
|
|
async def _consume(
|
|
llm: LLMClient, db: Session, holder: AgentHolder
|
|
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
|
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
|
|
async for piece in run_agent(
|
|
llm,
|
|
db,
|
|
system_prompt="SYSTEM_PROMPT",
|
|
user_message="QUESTION",
|
|
seed_docs=[],
|
|
settings=_settings(),
|
|
holder=holder,
|
|
):
|
|
out.append(piece)
|
|
return out
|
|
|
|
|
|
# ---------- ls (scoped through the real registry) ----------
|
|
|
|
|
|
def test_ls_scoped_to_registered_source_through_run_agent(kb, src, db) -> None:
|
|
_doc(db, "Homelab", "a.md", "A", "A-CONTENT")
|
|
_doc(db, "Other", "b.md", "B", "B-CONTENT")
|
|
db.commit()
|
|
|
|
holder, llm = _run_call(db, "ls", {"path": "Homelab"})
|
|
|
|
# Offered: the first request carries AGENT_TOOLS (the 3-tool list).
|
|
assert llm.requests[0][1] == AGENT_TOOLS
|
|
# Executed against the real DB: the listing filtered to the source.
|
|
assert llm.requests[1][0][3]["content"] == (
|
|
"1 documents:\nsource: Homelab | path: a.md | title: A"
|
|
)
|
|
assert holder.tool_calls == 1
|
|
assert holder.read_docs == []
|
|
|
|
|
|
def test_ls_scoped_unknown_source_refused_through_run_agent(kb, src, db) -> None:
|
|
"""Phase 72: the no-source refusal now carries the teaching
|
|
parenthetical — the prefix byte-identical to the pre-phase-72 line;
|
|
still not counted."""
|
|
_doc(db, "Homelab", "a.md", "A", "A-CONTENT")
|
|
db.commit()
|
|
|
|
holder, llm = _run_call(db, "ls", {"path": "Ghost"})
|
|
|
|
assert (
|
|
llm.requests[1][0][3]["content"]
|
|
== agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="Ghost")
|
|
)
|
|
assert holder.tool_calls == 0 and holder.read_docs == []
|
|
|
|
|
|
def test_ls_path_like_scope_teaching_refusal_through_run_agent(kb, src, db) -> None:
|
|
"""Phase 72: a ``/``-containing ``path`` is a document path, not a
|
|
source name — the ``LS_PATH_NOT_A_SOURCE`` teaching line (no
|
|
registry lookup needed), not counted, the tools stay offered on the
|
|
next request."""
|
|
_doc(db, "Homelab", "a.md", "A", "A-CONTENT")
|
|
db.commit()
|
|
|
|
holder, llm = _run_call(db, "ls", {"path": "app/rag/importer.py"})
|
|
|
|
assert (
|
|
llm.requests[1][0][3]["content"]
|
|
== agent.LS_PATH_NOT_A_SOURCE.format(path="app/rag/importer.py")
|
|
)
|
|
assert holder.tool_calls == 0 and holder.read_docs == []
|
|
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
|
|
|
|
|
# ---------- read (the canonical combined source/path form) ----------
|
|
|
|
|
|
def test_read_combined_path_through_run_agent(kb, db) -> None:
|
|
"""The combined ``source/path`` identity resolves at the FIRST slash
|
|
against the REAL table (a path with further slashes included): the
|
|
read executes, the holder records the row, the result header carries
|
|
the true source/path."""
|
|
created = _doc(db, "Alpha", "deep/nested/doc.md", "The Doc", "FULL-TEXT")
|
|
db.commit()
|
|
|
|
holder, llm = _run_call(db, "read", {"path": "Alpha/deep/nested/doc.md"})
|
|
|
|
assert llm.requests[1][0][3]["content"] == (
|
|
"Document Alpha/deep/nested/doc.md:\nFULL-TEXT"
|
|
)
|
|
assert holder.tool_calls == 1
|
|
assert holder.read_docs == [created]
|
|
|
|
|
|
def test_read_bare_source_name_refused_through_run_agent(kb, db) -> None:
|
|
"""A bare source name (no '/') can never be a document — the
|
|
no-document refusal echoing the argument as passed; the old
|
|
split-teaching refusal is gone (phase 70)."""
|
|
_doc(db, "Alpha", "deep/nested/doc.md", "The Doc", "FULL-TEXT")
|
|
db.commit()
|
|
|
|
holder, llm = _run_call(db, "read", {"path": "Alpha"})
|
|
|
|
assert (
|
|
llm.requests[1][0][3]["content"] == "No document at 'Alpha' — check the ls output."
|
|
)
|
|
assert holder.tool_calls == 0 and holder.read_docs == []
|
|
|
|
|
|
def test_read_unknown_combined_path_refused_through_run_agent(kb, db) -> None:
|
|
"""A combined identity that matches NOTHING — not a document and not
|
|
any indexed document's ``path`` (zero candidates) — gets today's
|
|
no-document refusal byte-identical (the argument echoed as passed —
|
|
the model sees its own form)."""
|
|
_doc(db, "Alpha", "x.md", "X", "X-CONTENT")
|
|
db.commit()
|
|
|
|
holder, llm = _run_call(db, "read", {"path": "Alpha/nope/deep.md"})
|
|
|
|
assert (
|
|
llm.requests[1][0][3]["content"]
|
|
== "No document at 'Alpha/nope/deep.md' — check the ls output."
|
|
)
|
|
assert holder.tool_calls == 0 and holder.read_docs == []
|
|
|
|
|
|
def test_read_bare_path_single_source_suggestion_then_corrected_read(kb, db) -> None:
|
|
"""Phase 72, task 02: a bare path under ONE source (exact ``path``
|
|
match, the source prefix missing) → the single-identity suggestion
|
|
(a refusal — not counted); the scripted corrected call (round 2, the
|
|
suggested combined identity) then succeeds against real Postgres —
|
|
the two-round self-correction."""
|
|
created = _doc(db, "Alpha", "deep/nested/doc.md", "The Doc", "FULL-TEXT")
|
|
db.commit()
|
|
|
|
llm = ScriptedToolCallsLLM(
|
|
[
|
|
ToolCallPiece(
|
|
id="call_1", name="read", arguments={"path": "deep/nested/doc.md"}
|
|
),
|
|
ToolCallPiece(
|
|
id="call_2",
|
|
name="read",
|
|
arguments={"path": "Alpha/deep/nested/doc.md"},
|
|
),
|
|
]
|
|
)
|
|
holder = AgentHolder()
|
|
asyncio.run(_consume(cast("LLMClient", llm), db, holder))
|
|
|
|
# Round 1: the bare path resolves to no combined identity, but it IS
|
|
# the indexed document's path — the refusal names the one combined
|
|
# identity to use (not counted, the tools stay offered).
|
|
assert llm.requests[1][0][3]["content"] == (
|
|
"No document at 'deep/nested/doc.md' — "
|
|
"did you mean 'Alpha/deep/nested/doc.md'?"
|
|
)
|
|
assert llm.requests[1][1] == AGENT_TOOLS
|
|
# Round 2: the corrected combined identity succeeds — the full
|
|
# content, the holder records the row, and it counts.
|
|
assert llm.requests[2][0][5]["content"] == (
|
|
"Document Alpha/deep/nested/doc.md:\nFULL-TEXT"
|
|
)
|
|
assert llm.requests[2][1] == AGENT_TOOLS
|
|
assert holder.read_docs == [created]
|
|
assert holder.tool_calls == 1 # only the corrected read executed
|
|
|
|
|
|
def test_read_bare_path_two_sources_one_of_suggestion_then_corrected_read(
|
|
kb, db,
|
|
) -> None:
|
|
"""Phase 72, task 02: the same bare path under TWO sources → the
|
|
``one of`` line (up to ``SUGGESTION_LIMIT`` identities, catalog
|
|
order — Alpha before Beta); the scripted corrected call (round 2,
|
|
the first suggested identity) then succeeds."""
|
|
a = _doc(db, "Alpha", "shared/x.md", "Alpha X", "A-TEXT")
|
|
_doc(db, "Beta", "shared/x.md", "Beta X", "B-TEXT")
|
|
db.commit()
|
|
|
|
llm = ScriptedToolCallsLLM(
|
|
[
|
|
ToolCallPiece(id="call_1", name="read", arguments={"path": "shared/x.md"}),
|
|
ToolCallPiece(
|
|
id="call_2", name="read", arguments={"path": "Alpha/shared/x.md"}
|
|
),
|
|
]
|
|
)
|
|
holder = AgentHolder()
|
|
asyncio.run(_consume(cast("LLMClient", llm), db, holder))
|
|
|
|
assert llm.requests[1][0][3]["content"] == (
|
|
"No document at 'shared/x.md' — did you mean one of: "
|
|
"'Alpha/shared/x.md', 'Beta/shared/x.md'?"
|
|
)
|
|
assert llm.requests[2][0][5]["content"] == "Document Alpha/shared/x.md:\nA-TEXT"
|
|
assert holder.read_docs == [a]
|
|
assert holder.tool_calls == 1 # only the corrected read executed
|
|
|
|
|
|
# ---------- grep (the phase-68 A5 contract under the new name) ----------
|
|
|
|
|
|
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_grep_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_call(db, "grep", {"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: grep adds no context
|
|
|
|
|
|
def test_grep_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_call(db, "grep", {"pattern": "needle", "path": "Alpha/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_grep_scoped_missing_doc_refused_through_run_agent(kb, db) -> None:
|
|
"""A scoped ``grep`` miss that matches no indexed document's ``path``
|
|
(zero candidates) keeps today's line byte-identical — a refusal,
|
|
not counted."""
|
|
_doc(db, "Alpha", "a/one.md", "One", "nothing")
|
|
db.commit()
|
|
|
|
holder, llm = _run_call(db, "grep", {"pattern": "needle", "path": "Alpha/ghost.md"})
|
|
|
|
assert (
|
|
llm.requests[1][0][3]["content"]
|
|
== "No document at 'Alpha/ghost.md' — check the ls output."
|
|
)
|
|
assert holder.tool_calls == 0 and holder.read_docs == []
|
|
|
|
|
|
def test_grep_no_matches_through_run_agent(kb, db) -> None:
|
|
_doc(db, "Alpha", "a/one.md", "One", "nothing matching")
|
|
db.commit()
|
|
|
|
holder, llm = _run_call(db, "grep", {"pattern": "zebra"})
|
|
|
|
assert llm.requests[1][0][3]["content"] == (
|
|
"No matches for 'zebra' in the knowledge base."
|
|
)
|
|
assert holder.tool_calls == 1 # an executed grep with zero hits
|
|
assert holder.read_docs == []
|