All gates green and verified. Final report: **Phase 141 final verification pass — complete** - **Verified (tasks 01–04 already in `complete/`):** `AgentHolder.read_coverage` span recording (all 4 read outcomes, refusals record nothing); coverage dedupe matrix (range read of uncovered lines proceeds, fully-covered re-read → `ALREADY_IN_CONTEXT` with a now-true claim, partial plain re-read → `REREAD_PARTIAL` naming merged spans, `REREAD_TRUNCATED` retired, fast path retired per task 02); `TOOLS_SECTION` re-read sentence re-cut to coverage-true form (prompt lock re-pinned); owner-scenario integration repro (pure + truncated variants) and mock `read two ranges` flow + dedicated E2E — all match the task specs exactly; no code defects found. - **Tests/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → exit 0, TOTAL 99% (5498 stmts, `app/rag/agent.py` 99%) — run twice, both green; `uv run pytest tests/e2e/test_read_range_dedupe.py -v --no-cov` → 2 passed in isolation; `uv run ruff check . && uv run pyright` → All checks passed / 0 errors. - **Completion criteria:** (1) two different ranges both reach the model, re-covered re-read refused truthfully — ✅ (integration + E2E); (2) partial plain re-read gets the `offset`/`limit` teaching, false claim gone both directions — ✅; (3) plain-read + phase-131 result shapes byte-identical, existing pins pass unchanged — ✅; (4) all gate commands green, >90% — ✅; (5) no behavior change in completed phases (grep/ls, truncation notice, `read_docs`/`read_truncations` signals) — ✅ (whole project suite green). - **Documentation:** README.md — fixed pre-existing phase-140 gap: `BOR_PDF_PICTORIAL` / `BOR_PDF_PICTORIAL_MIN_CHARS` were in `.env.example`/Settings but undocumented (README claims every `.env.example` var is covered); added the pictorial-PDF-pass bullet to the PDF section + two config-reference rows. AGENTS.md — verified consistent (rules, quick-ref commands, phase refs all check out) — no changes needed. - **Notable:** overview's "fast path survives for holder-state rows" note is superseded by task 02's authoritative decision (every dedupe row resolves — "full" needs the true line count); implementation follows task 02. No phase files touched; changes left in the working tree for the harness commit. - **Next pending phase:** `142_kb_overview_freshness`.
4928 lines
202 KiB
Python
4928 lines
202 KiB
Python
"""Unit: the grounded-turn agent loop (phase 37, ``app.rag.agent``; the
|
||
harness-aligned ``ls``/``read``/``grep`` surface, phase 70).
|
||
|
||
A scripted fake LLM (canned stream sequences) + monkeypatched
|
||
``ls_top`` / ``ls_folder`` / ``list_source_names`` / ``find_document`` /
|
||
``all_documents`` — no database, no network. Covers the loop mechanics:
|
||
the ls → read (combined ``source/path``) → answer happy path (event
|
||
order, holder state, the tools staying offered on every request —
|
||
phase 45 removed the per-tool budgets, the assistant/tool message
|
||
history), the phase-94 drill-down ``ls`` (no-arg top level = the
|
||
registered sources with counts + stored summaries in the pinned
|
||
``{N} sources:`` template, a source scope = its root folder —
|
||
subfolders + capped file lines in the pinned folder template —, a
|
||
``source/folder`` scope = one level deeper, a registered source with 0
|
||
documents → the ``… — 0 documents, 0 folders:`` header counted, an
|
||
unknown-source refusal that counts nothing, the NOT-A-FOLDER teaching
|
||
with the parent's subfolders, the 50-file cap + grep-pointer note),
|
||
``read`` on the canonical combined form (split at the
|
||
FIRST slash, full content, the bare-source-name refusal, the
|
||
already-in-context dedupe, missing-args refusals), the phase-131
|
||
(task 02) range-read execution (the optional ``offset``/``limit``:
|
||
:func:`agent._read_range_args` validation — whole ints ≥ 1, bools
|
||
excluded — with the pinned :data:`agent.BAD_RANGE_ARGS` echo, the
|
||
six-row COVERAGE dedupe matrix (phase 141, task 02 — the rework of
|
||
the phase-131 pair-set matrix: a range read PROCEEDS unless the
|
||
requested lines are FULLY covered by earlier reads — the owner's
|
||
stuck-scenario fix, including the phase-131 truncated-then-range row;
|
||
a plain re-read of a PARTIALLY-covered doc gets the pinned
|
||
:data:`agent.REREAD_PARTIAL` teaching naming the covered span(s);
|
||
re-reads of fully-covered lines stay ``ALREADY_IN_CONTEXT`` — the
|
||
claim is then true; the raw-arg fast path is RETIRED — every dedupe
|
||
row resolves the document first; the task-03 SCENARIO edge: two
|
||
adjacent range reads covering half a 200-line doc — the merged span
|
||
named in the plain re-read's teaching, the rest of the doc still
|
||
readable),
|
||
:data:`agent.OFFSET_PAST_END` (the true line count named), the slice
|
||
math + the ``lines: a-b of N`` header shape (plain reads byte-
|
||
identical to pre-phase-131), the cap on the slice with the slice-
|
||
length tuple ``chars_total``), the phase-68 ``grep``
|
||
contract under its new name (the locked A5 pins: fixed substring,
|
||
case-insensitive, 20-cap in catalog order, 200-char lines, locator-only
|
||
— ``read_docs`` untouched, no-match lines counted), the round cap
|
||
forcing a final no-tools answer, the kill switch
|
||
(``agent_max_rounds=0`` single-call path), and the phase-67 per-round
|
||
retries (a dead-then-recovered round restarts before its first piece
|
||
with a ``RetryPiece``; a mid-stream drop stays terminal — locked A2;
|
||
the forced final no-tools call retries too; ``llm_retries=0`` is one
|
||
plain attempt; retries are invisible to the round cap; consumer abandon
|
||
mid-retry-sleep leaks nothing).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import uuid
|
||
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Sequence
|
||
from copy import deepcopy
|
||
from datetime import UTC, datetime
|
||
from typing import TYPE_CHECKING, Any, cast
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
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,
|
||
READ_TRUNCATION_NOTICE,
|
||
AgentHolder,
|
||
MalformedReplyError,
|
||
run_agent,
|
||
)
|
||
from app.rag.llm import (
|
||
LLMClient,
|
||
LLMError,
|
||
RetryPiece,
|
||
StreamPiece,
|
||
ToolCallPiece,
|
||
ToolResultPiece,
|
||
)
|
||
from app.rag.prompts import TOOLS_SECTION, _base, build_deflect_prompt, build_high_prompt
|
||
from app.rag.retriever import TRUNCATION_MARKER
|
||
|
||
if TYPE_CHECKING:
|
||
from app.rag.scaffolding import ScaffoldingFilter
|
||
|
||
|
||
def _settings(**kwargs: Any) -> Settings:
|
||
kwargs.setdefault("_env_file", None)
|
||
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||
|
||
|
||
#: The fixture documents' fixed creation date (phase 106, D5): the
|
||
#: agent formats ``doc.created_at`` on the ``read`` result's second
|
||
#: line — the detached fixture rows carry it exactly as the NOT NULL
|
||
#: DB column guarantees it for real rows.
|
||
_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||
|
||
|
||
def _doc(source: str, path: str, title: str = "Title", content: str = "CONTENT") -> Document:
|
||
return Document(
|
||
id=uuid.uuid4(),
|
||
source=source,
|
||
path=path,
|
||
full_path=f"/tmp/{path}",
|
||
title=title,
|
||
content=content,
|
||
content_hash="0" * 64,
|
||
created_at=_FIXTURE_CREATED_AT,
|
||
)
|
||
|
||
|
||
class ScriptedLLM:
|
||
"""Canned stream sequences; records every ``chat_stream`` request so
|
||
the tests can assert on the messages and the ``tools`` passthrough.
|
||
Phase 71: when the caller passes a ``ScaffoldingFilter``, the canned
|
||
content pieces are fed through it exactly like
|
||
``LLMClient.chat_stream`` (an empty clean result yields nothing; the
|
||
held tail is flushed on normal completion) — so a scaffolding-only
|
||
canned round streams no content pieces and leaves ``stripped_chars``
|
||
behind for the recovery policy to key on."""
|
||
|
||
def __init__(self, *streams: list[StreamPiece | ToolCallPiece]) -> None:
|
||
self.streams: list[list[StreamPiece | ToolCallPiece]] = list(streams)
|
||
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), tools))
|
||
if not self.streams:
|
||
raise AssertionError("ScriptedLLM ran out of canned streams")
|
||
pieces = self.streams.pop(0)
|
||
if scaffolding is None:
|
||
for piece in pieces:
|
||
yield piece
|
||
return
|
||
for piece in pieces:
|
||
if isinstance(piece, StreamPiece) and piece.kind == "content":
|
||
cleaned = scaffolding.feed(piece.text)
|
||
if cleaned:
|
||
yield StreamPiece("content", cleaned)
|
||
else:
|
||
yield piece
|
||
tail = scaffolding.flush()
|
||
if tail:
|
||
yield StreamPiece("content", tail)
|
||
|
||
|
||
def _mock_session() -> Session:
|
||
"""A minimal mock Session for unit tests (DB accessors are monkeypatched).
|
||
|
||
The mock works as a context manager: ``__enter__`` returns itself so
|
||
``with db_factory() as tool_db:`` binds *tool_db* to the same mock.
|
||
"""
|
||
mock = cast("Session", MagicMock())
|
||
mock.__enter__ = MagicMock(return_value=mock)
|
||
mock.__exit__ = MagicMock(return_value=False)
|
||
mock.scalar = MagicMock(return_value=None)
|
||
mock.execute = MagicMock(return_value=MagicMock(scalars=MagicMock(return_value=[])))
|
||
return mock
|
||
|
||
|
||
async def _run(
|
||
llm: ScriptedLLM | FailingLLM,
|
||
holder: AgentHolder,
|
||
settings: Settings,
|
||
seed_docs: list[Document] | None = None,
|
||
history: Sequence[dict[str, Any]] = (),
|
||
db_factory: Callable[[], Session] | None = None,
|
||
) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
||
"""Consume one ``run_agent`` turn; *history* (phase 74) is the
|
||
client's prior turns spliced between system and user (default
|
||
``()`` — the pre-phase-74 two-message request). Phase 95: the loop
|
||
may also yield a ``ToolResultPiece`` (a truncated ``read``)."""
|
||
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
|
||
factory = db_factory or (lambda: _mock_session())
|
||
async for piece in run_agent(
|
||
cast("LLMClient", llm),
|
||
factory,
|
||
system_prompt="SYSTEM_PROMPT",
|
||
user_message="QUESTION",
|
||
seed_docs=seed_docs or [],
|
||
settings=settings,
|
||
holder=holder,
|
||
history=history,
|
||
):
|
||
out.append(piece)
|
||
return out
|
||
|
||
|
||
# ---------- AGENT_TOOLS shape (phase 70: ls / read / grep) ----------
|
||
|
||
|
||
def test_agent_tools_names_and_parameters() -> None:
|
||
by_name = {t["function"]["name"]: t for t in AGENT_TOOLS}
|
||
assert len(AGENT_TOOLS) == 3 # ls / read / grep (phase 70)
|
||
assert set(by_name) == {"ls", "read", "grep"}
|
||
# The phase-37/68 names exist nowhere in the tool surface.
|
||
assert not set(by_name) & {"list_documents", "read_document", "search_documents"}
|
||
assert all(t["type"] == "function" for t in AGENT_TOOLS)
|
||
ls = by_name["ls"]["function"]
|
||
# Task 05 (live gate iteration 2): the one-call-at-a-time discipline
|
||
# clause (the harness prior batches calls; the loop executes one
|
||
# per round — the extras count as unexecuted in the gate).
|
||
# Phase 94 (task 03): the description is the drill-down tree
|
||
# contract (pinned copy — the tool-surface revision, owner
|
||
# permission 2026-09-10, ``TODO.md`` L4): one level per call,
|
||
# sources at the top, folders + files below, the file-line format
|
||
# and the combined-identity handoff to read/grep intact.
|
||
assert ls["description"] == (
|
||
"List the knowledge base as a tree, one level at a time. "
|
||
"With no path: the synced sources — each with its document "
|
||
"count and a summary of its contents. With a source name (no "
|
||
"'/'): that source's top-level folders and files. With a "
|
||
"`source/folder` path: that folder's subfolders and files. "
|
||
"Folder lines carry a summary of what the folder contains. "
|
||
"File lines are `source: X | path: Y | title: Z` — use the "
|
||
"combined `source/path` with `read` and `grep`. Call one tool "
|
||
"at a time — wait for this result before your next call."
|
||
)
|
||
ls_params = ls["parameters"]
|
||
assert ls_params["type"] == "object"
|
||
assert ls_params["required"] == [] # path is optional
|
||
assert set(ls_params["properties"]) == {"path"}
|
||
assert ls_params["properties"]["path"]["type"] == "string"
|
||
# Phase 94 (task 03): the 'path' argument teaches the drill-down
|
||
# semantics — a source name lists that source's top level, a
|
||
# `source/folder` path drills one level deeper, omitted lists
|
||
# every source (pinned copy).
|
||
assert ls_params["properties"]["path"]["description"] == (
|
||
"Optional — a source name (e.g. 'homelab') to list its top "
|
||
"level, or a `source/folder` path to drill down (e.g. "
|
||
"'homelab/active'). Omit it to list every source."
|
||
)
|
||
read = by_name["read"]["function"]
|
||
# Phase 118 (task 04, A6): the read description is rewritten for
|
||
# the summary-seed mode — the <documents> section shows the
|
||
# top-ranked documents' SUMMARIES (their full texts are NOT in
|
||
# the prompt yet); read adds one of them (or any other document)
|
||
# to the context; only an already-read document is refused. The
|
||
# combined-identity handoff to the `ls` output / the <documents>
|
||
# blocks stays; the one-call-at-a-time discipline clause (task
|
||
# 05) survives byte-identical. Phase 131 (task 01): the old
|
||
# truncation dead-end paragraph ("Follow it and use `grep`
|
||
# (pattern) to locate the rest") is replaced by the working
|
||
# workflow — grep reports line numbers, then read the part with
|
||
# `offset` (1-based first line) + `limit` (number of lines).
|
||
assert read["description"] == (
|
||
"The <documents> section shows the SUMMARIES of the "
|
||
"top-ranked documents — their full texts are NOT in "
|
||
"your prompt yet. Use this tool to add one of them (or "
|
||
"any other document) to your context, by its combined "
|
||
"`source/path` string, exactly as shown in the `ls` "
|
||
"output or the <documents> blocks. Do not re-read a "
|
||
"document you have already read — its full text is "
|
||
"already in your prompt. Very large documents are "
|
||
"truncated: you receive the first part plus a TRUNCATED "
|
||
"notice naming how many more characters exist. To read "
|
||
"the rest: use `grep` (pattern) to find where your text "
|
||
"is — it reports line numbers — then call `read` again "
|
||
"on the same document with `offset` (1-based first line) "
|
||
"and `limit` (number of lines) to read exactly that "
|
||
"part. Do not re-read a document you have already read "
|
||
"in full — its full text is already in your prompt. "
|
||
"Call one tool at a time — wait for this result before "
|
||
"your next call."
|
||
)
|
||
# The byte-preserved contracts inside the rewrite, pinned as
|
||
# substrings: the combined source/path identity, the
|
||
# phase-131 grep → read(offset, limit) workflow paragraph, the
|
||
# one-call-at-a-time discipline sentence.
|
||
assert (
|
||
"by its combined `source/path` string, exactly as shown in "
|
||
"the `ls` output or the <documents> blocks"
|
||
) in read["description"]
|
||
assert (
|
||
"Very large documents are truncated: you receive the first "
|
||
"part plus a TRUNCATED notice naming how many more "
|
||
"characters exist. To read the rest: use `grep` (pattern) "
|
||
"to find where your text is — it reports line numbers — "
|
||
"then call `read` again on the same document with "
|
||
"`offset` (1-based first line) and `limit` (number of "
|
||
"lines) to read exactly that part."
|
||
) in read["description"]
|
||
assert (
|
||
"Call one tool at a time — wait for this result before "
|
||
"your next call."
|
||
) in read["description"]
|
||
read_params = read["parameters"]
|
||
assert read_params["type"] == "object"
|
||
# Phase 131 (task 01, LOCKED A2): offset/limit are OPTIONAL —
|
||
# ``required`` stays ``["path"]``.
|
||
assert read_params["required"] == ["path"]
|
||
assert set(read_params["properties"]) == {"path", "offset", "limit"}
|
||
assert read_params["properties"]["path"]["type"] == "string"
|
||
# The line-range parameters (phase 131, task 01, LOCKED A2):
|
||
# BOTH integer, 1-based (``offset`` = first line, ``limit`` =
|
||
# line count) — the pi-harness ``read`` shape the model was
|
||
# trained on. The descriptions carry the grep → read(offset,
|
||
# limit) teaching (pinned copy).
|
||
assert read_params["properties"]["offset"]["type"] == "integer"
|
||
assert read_params["properties"]["limit"]["type"] == "integer"
|
||
assert read_params["properties"]["offset"]["description"] == (
|
||
"The first line to read, 1-based. Omit it to read from the "
|
||
"beginning of the document. Use it to read a specific part "
|
||
"of a large document — grep reports the line numbers of "
|
||
"what you found."
|
||
)
|
||
assert read_params["properties"]["limit"]["description"] == (
|
||
"The maximum number of lines to read, starting at offset. "
|
||
"Omit it to read as many lines as fit under the character "
|
||
"cap. Keep it small — you are reading a part, not the "
|
||
"document."
|
||
)
|
||
# The combined source/path string is the canonical document identity
|
||
# (phase 70) — the description pins it with a worked example; the
|
||
# bare-path contract is stated up front (phase 72, task 02). Phase
|
||
# 118 (task 04, A6): the trailing clause is the do-not-RE-READ
|
||
# teaching — the seeds are summary blocks, not full text, so a
|
||
# first read of a suggested document succeeds and only an
|
||
# already-read document is refused.
|
||
assert read_params["properties"]["path"]["description"] == (
|
||
"The document to add to your context, as the combined "
|
||
"`source/path` string exactly as shown in the `ls` output (e.g. "
|
||
"'homelab/active/container_caddy/caddy.md'). A bare document "
|
||
"path (without the source name) will not resolve. Do not "
|
||
"re-read a document you have already read — its full "
|
||
"text is already in your prompt."
|
||
)
|
||
grep = by_name["grep"]["function"]
|
||
# Task 05 (live gate iterations 2-6, refined in the 2026-09-03
|
||
# re-run): the pattern-only-is-the-knowledge-base-search clause
|
||
# ("pass ONLY `pattern`") + the source-name-is-not-a-document
|
||
# clause (the model kept scoping grep with an ls-style source name
|
||
# — the 2026-09-03 incident loop shape, but on grep) plus the
|
||
# one-call-at-a-time discipline clause. The 2026-09-05 incident
|
||
# (the "Qwen 3.8" sample question — the harness prior is that grep
|
||
# takes a REGEX; this grep is a fixed substring, owner-locked A5):
|
||
# the plain-substring-never-a-regex clause states the contract up
|
||
# front, so the regex-shaped first grep that does fire gets the
|
||
# teaching no-match line instead of a trusted miss.
|
||
assert grep["description"] == (
|
||
"Search the indexed documents for an exact string "
|
||
"(case-insensitive) and return up to 20 matching lines "
|
||
"as `source/path:line: text` — a locator, not a "
|
||
"context-adder: read the winner with `read` — for a "
|
||
"large document, use the reported line number as "
|
||
"`offset`. The pattern is a plain substring, NEVER a "
|
||
"regex — if a pattern with regex syntax (like '.*' or "
|
||
"'\\.') comes back with no matches, retry with the plain "
|
||
"text you expect to see. For a normal search pass ONLY "
|
||
"`pattern` — it searches every document and that is how "
|
||
"you search the knowledge base; never pass a source name "
|
||
"as `path` (a source name is not a document). Call one "
|
||
"tool at a time — wait for this result before your "
|
||
"next call."
|
||
)
|
||
grep_params = grep["parameters"]
|
||
assert grep_params["type"] == "object"
|
||
assert grep_params["required"] == ["pattern"]
|
||
assert set(grep_params["properties"]) == {"pattern", "path"}
|
||
assert all(p["type"] == "string" for p in grep_params["properties"].values())
|
||
assert grep_params["properties"]["pattern"]["description"] == (
|
||
"The exact text to search for (a plain substring, "
|
||
"not a regex — no '.*', no '\\.', no character classes)"
|
||
)
|
||
# Phase 72 (task 02): the bare-path contract is stated up front;
|
||
# task 05 (live gate iterations 1-8): the one-known-document clause
|
||
# with a worked combined-identity example and the source-name ban
|
||
# (the model kept scoping grep with an ls-style source name — the
|
||
# incident loop shape, but on grep).
|
||
# Iteration 8 drops the standalone 'homelab' from this negative
|
||
# example — the gate's live telemetry showed the model emitting
|
||
# exactly that value, and naming it beside the parameter risks
|
||
# priming it (the negative-example effect). The 2026-09-03 re-run
|
||
# makes the rarity explicit ("Rarely needed") and re-states the
|
||
# pattern-only normal search.
|
||
assert grep_params["properties"]["path"]["description"] == (
|
||
"Rarely needed — only for re-searching one "
|
||
"document you already know: that document's "
|
||
"combined `source/path` identity (e.g. "
|
||
"'homelab/ansible/inventory.yaml'). Never a "
|
||
"source name. A bare document path (without "
|
||
"the source name) will not resolve. Omit it "
|
||
"for a normal search (pass only `pattern`)."
|
||
)
|
||
|
||
|
||
def test_agent_tools_order_is_ls_read_grep() -> None:
|
||
"""The listing → context → locator order the prompt teaches (the API
|
||
layer and the mock key off the names)."""
|
||
assert [t["function"]["name"] for t in AGENT_TOOLS] == ["ls", "read", "grep"]
|
||
|
||
|
||
def test_refusal_constants_are_harness_aligned() -> None:
|
||
"""The updated module-level refusal lines (the names moved to the
|
||
harness surface). The ALREADY_IN_CONTEXT line is a phase-72,
|
||
task 05 gate-iteration teaching (live telemetry: the model
|
||
repeated the terse phase-37 line) — same refusal behavior, the
|
||
copy names the correct action."""
|
||
assert agent.ALREADY_IN_CONTEXT == (
|
||
"Already in your context — the full text is already in your "
|
||
"prompt. Do not call read on it again; answer from that text."
|
||
)
|
||
assert agent.UNKNOWN_TOOL == "Unknown tool."
|
||
assert agent.MISSING_READ_ARGS == "read requires a string argument 'path'."
|
||
assert agent.MISSING_SEARCH_ARGS == "grep requires a string argument 'pattern'."
|
||
# Phase 94 (task 03): the phase-72 document-path teaching refusal is
|
||
# DELETED (a ``/`` now names a folder — the drill-down contract);
|
||
# the no-source refusal stays byte-identical (the task's "existing
|
||
# refusal, teaching parenthetical intact" pin).
|
||
assert not hasattr(agent, "LS_PATH_NOT_A_SOURCE")
|
||
assert agent.NO_SOURCE_NOT_A_DIRECTORY.startswith(
|
||
"No source named '{scope}' — check the ls output."
|
||
)
|
||
assert agent.NO_SOURCE_NOT_A_DIRECTORY == (
|
||
"No source named '{scope}' — check the ls output. (The 'path' "
|
||
"argument is a source name, not a directory — omit it to list "
|
||
"every document.)"
|
||
)
|
||
# Phase 94 (task 03): the NOT-A-FOLDER drill-down teaching template,
|
||
# pinned byte-for-byte (argument echoed, parent's subfolders
|
||
# listed), and the pinned file-line cap constant.
|
||
assert agent.NOT_A_FOLDER == "'{arg}' is not a folder — {parent} has: {subfolders}"
|
||
assert agent.LS_MAX_FILE_LINES == 50
|
||
# Phase 72 (task 02): the read/grep "did you mean …?" suggestion
|
||
# templates, pinned byte-for-byte, and the suggestion cap.
|
||
assert agent.NO_DOCUMENT_DID_YOU_MEAN == (
|
||
"No document at '{arg}' — did you mean '{source}/{path}'?"
|
||
)
|
||
assert agent.NO_DOCUMENT_DID_YOU_MEAN_MANY == (
|
||
"No document at '{arg}' — did you mean one of: {candidates}?"
|
||
)
|
||
assert agent.SUGGESTION_LIMIT == 3
|
||
|
||
|
||
# ---------- list_source_names (the scoped ls registry join) ----------
|
||
|
||
|
||
def test_list_source_names_resolves_registry_rows(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Names resolve exactly as the import pipeline indexes them (the
|
||
phase-69 ``resolve_source_name`` expressions — reuse, not
|
||
re-derivation), deduped (two rows resolving to the same name share
|
||
documents), in registry order."""
|
||
rows = [
|
||
GitSource(url="https://github.com/reese/homelab.git", kind="git"),
|
||
GitSource(
|
||
url="/srv/reese/deployments", kind="local", path="/srv/reese/deployments"
|
||
),
|
||
# The phase-69 sibling case: a second row, same resolved name.
|
||
GitSource(url="https://github.com/reese/homelab", kind="git"),
|
||
]
|
||
monkeypatch.setattr(agent, "effective_sources", lambda db: (rows, "db"))
|
||
assert agent.list_source_names(cast("Session", object())) == [
|
||
"homelab",
|
||
"deployments",
|
||
]
|
||
|
||
|
||
def test_list_source_names_empty_registry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(agent, "effective_sources", lambda db: ([], "env"))
|
||
assert agent.list_source_names(cast("Session", object())) == []
|
||
|
||
|
||
# ---------- happy path: ls → read (combined path) → answer ----------
|
||
|
||
|
||
def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
# Phase 94 (task 03): the no-arg ``ls`` is the drill-down TOP level
|
||
# (the registered sources, registry order) — monkeypatched the way
|
||
# the phase-70 full-catalog listing used to be.
|
||
monkeypatch.setattr(
|
||
agent, "ls_top", lambda db: [("Deployments", 1, None), ("Homelab", 1, None)]
|
||
)
|
||
target = _doc("Homelab", "aws-route53.md", "AWS Route53 Records", "R53-CONTENT")
|
||
calls: list[tuple[str, str]] = []
|
||
|
||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||
calls.append((source, path))
|
||
return target if (source, path) == ("Homelab", "aws-route53.md") else None
|
||
|
||
monkeypatch.setattr(agent, "find_document", _find)
|
||
seed = [_doc("Homelab", "kubernetes.md", "Kubernetes", "K8S-CONTENT")]
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||
[
|
||
ToolCallPiece(
|
||
id="call_2",
|
||
name="read",
|
||
arguments={"path": "Homelab/aws-route53.md"},
|
||
)
|
||
],
|
||
[StreamPiece("thinking", "hmm "), StreamPiece("content", "Done! ")],
|
||
)
|
||
|
||
pieces = asyncio.run(_run(llm, holder, _settings(), seed_docs=seed))
|
||
|
||
# Event order: tool pieces before the answer content/thinking.
|
||
assert [type(p) for p in pieces] == [
|
||
ToolCallPiece,
|
||
ToolCallPiece,
|
||
StreamPiece,
|
||
StreamPiece,
|
||
]
|
||
assert pieces[0] == ToolCallPiece(id="call_1", name="ls", arguments={})
|
||
assert pieces[1] == ToolCallPiece(
|
||
id="call_2", name="read", arguments={"path": "Homelab/aws-route53.md"}
|
||
)
|
||
assert pieces[3] == StreamPiece("content", "Done! ")
|
||
# The read document is recorded for done.sources / query_log (task 04).
|
||
assert holder.read_docs == [target]
|
||
assert holder.tool_calls == 2
|
||
|
||
# Phase 45: no per-tool budgets — the tools stay offered on every
|
||
# request (the round cap, not spent budgets, bounds the loop), so
|
||
# the answer request still carries them (2 rounds < default cap 10).
|
||
assert llm.requests[0][1] == AGENT_TOOLS
|
||
assert llm.requests[1][1] == AGENT_TOOLS
|
||
assert llm.requests[2][1] == AGENT_TOOLS
|
||
assert len(llm.requests) == 3
|
||
|
||
# read splits the combined form at the FIRST slash — one exact
|
||
# lookup, no self-correction candidates (phase 70).
|
||
assert calls == [("Homelab", "aws-route53.md")]
|
||
|
||
# The follow-up request carries the assistant tool-call + tool
|
||
# result (phase 106, D5: the ``date:`` second line rides every
|
||
# read — the first line is byte-identical).
|
||
msgs = llm.requests[1][0]
|
||
assert msgs[0] == {"role": "system", "content": "SYSTEM_PROMPT"}
|
||
assert msgs[1] == {"role": "user", "content": "QUESTION"}
|
||
assert msgs[2] == {
|
||
"role": "assistant",
|
||
"content": None,
|
||
"tool_calls": [
|
||
{
|
||
"id": "call_1",
|
||
"type": "function",
|
||
"function": {"name": "ls", "arguments": "{}"},
|
||
}
|
||
],
|
||
}
|
||
assert msgs[3] == {
|
||
"role": "tool",
|
||
"tool_call_id": "call_1",
|
||
"content": "2 sources:\n\nDeployments — 1 documents\nHomelab — 1 documents",
|
||
}
|
||
# The second follow-up request carries the read call + the FULL text.
|
||
msgs = llm.requests[2][0]
|
||
assert msgs[4]["role"] == "assistant"
|
||
assert msgs[4]["tool_calls"][0]["id"] == "call_2"
|
||
assert json.loads(msgs[4]["tool_calls"][0]["function"]["arguments"]) == {
|
||
"path": "Homelab/aws-route53.md"
|
||
}
|
||
assert msgs[5] == {
|
||
"role": "tool",
|
||
"tool_call_id": "call_2",
|
||
"content": (
|
||
"Document Homelab/aws-route53.md:\n"
|
||
"date: 2024-06-15\n"
|
||
"R53-CONTENT"
|
||
), # full text, no cap (date: the D5 second line)
|
||
}
|
||
|
||
|
||
def test_content_and_tool_call_in_one_stream_keeps_both(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Rare stream with content AND a tool call: the content stays (it was
|
||
already emitted) and the tool still runs."""
|
||
monkeypatch.setattr(agent, "ls_top", lambda db: [])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
StreamPiece("content", "Let me check "),
|
||
ToolCallPiece(id="call_1", name="ls", arguments={}),
|
||
],
|
||
[StreamPiece("content", "the answer")],
|
||
)
|
||
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||
assert [type(p) for p in pieces] == [StreamPiece, ToolCallPiece, StreamPiece]
|
||
assert holder.tool_calls == 1 # the tool ran despite the content
|
||
assert llm.requests[1][0][3]["content"] == "0 sources:"
|
||
|
||
|
||
# ---------- phase 74: client history between system and user ----------
|
||
|
||
|
||
def test_run_agent_default_history_keeps_two_message_request() -> None:
|
||
"""No *history* (the default ``()``) → the model sees exactly the
|
||
pre-phase-74 two-message request ``[system, user]`` — byte-identical
|
||
behavior (owner-locked A2)."""
|
||
llm = ScriptedLLM([StreamPiece("content", "the answer")])
|
||
asyncio.run(_run(llm, AgentHolder(), _settings()))
|
||
(messages, _tools) = llm.requests[0]
|
||
assert messages == [
|
||
{"role": "system", "content": "SYSTEM_PROMPT"},
|
||
{"role": "user", "content": "QUESTION"},
|
||
]
|
||
|
||
|
||
def test_run_agent_places_history_between_system_and_user() -> None:
|
||
"""A non-empty *history* (the client's prior turns, already mapped by
|
||
``history_to_messages``) is spliced between the system prompt and the
|
||
CURRENT user message — oldest-first, with the assistant turn's prior
|
||
thinking riding on ``reasoning_content`` (A4). The current question
|
||
stays LAST."""
|
||
history = [
|
||
{"role": "user", "content": "old question"},
|
||
{
|
||
"role": "assistant",
|
||
"content": "old answer",
|
||
"reasoning_content": "old thinking",
|
||
},
|
||
]
|
||
llm = ScriptedLLM([StreamPiece("content", "the answer")])
|
||
pieces = asyncio.run(
|
||
_run(llm, AgentHolder(), _settings(), history=history)
|
||
)
|
||
assert [p for p in pieces if isinstance(p, StreamPiece)] == [
|
||
StreamPiece("content", "the answer")
|
||
]
|
||
(messages, _tools) = llm.requests[0]
|
||
assert messages == [
|
||
{"role": "system", "content": "SYSTEM_PROMPT"},
|
||
{"role": "user", "content": "old question"},
|
||
{
|
||
"role": "assistant",
|
||
"content": "old answer",
|
||
"reasoning_content": "old thinking",
|
||
},
|
||
{"role": "user", "content": "QUESTION"},
|
||
]
|
||
|
||
|
||
def test_run_agent_history_survives_a_tool_round(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The tool rounds append assistant/tool messages to the SAME
|
||
``messages`` list — the prior history stays in place between the
|
||
system prompt and the current question on the SECOND request too."""
|
||
monkeypatch.setattr(agent, "ls_top", lambda db: [])
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||
[StreamPiece("content", "the answer")],
|
||
)
|
||
history = [{"role": "assistant", "content": "old answer"}]
|
||
asyncio.run(_run(llm, AgentHolder(), _settings(), history=history))
|
||
_first, second = llm.requests
|
||
assert second[0][:3] == [
|
||
{"role": "system", "content": "SYSTEM_PROMPT"},
|
||
{"role": "assistant", "content": "old answer"},
|
||
{"role": "user", "content": "QUESTION"},
|
||
]
|
||
|
||
|
||
# ---------- ls: the drill-down tree (phase 94, task 03) ----------
|
||
|
||
|
||
def test_ls_top_level_lists_sources_with_summaries(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""No argument: the TOP level — the registered sources, registry
|
||
order, each ``{source} — {n} documents`` + the indented summary line
|
||
only when stored — the pinned template, counted; the registry IS
|
||
consulted (unlike the phase-70 full catalog, the top level is the
|
||
registry itself)."""
|
||
monkeypatch.setattr(
|
||
agent,
|
||
"ls_top",
|
||
lambda db: [
|
||
("Deployments", 0, None),
|
||
("Homelab", 1, "The homelab notes."),
|
||
],
|
||
)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"2 sources:\n"
|
||
"\n"
|
||
"Deployments — 0 documents\n"
|
||
"Homelab — 1 documents\n"
|
||
" The homelab notes."
|
||
)
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_ls_empty_registry_says_zero_sources(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""No registered sources: the top level is the header line alone —
|
||
``0 sources:`` (the old ``0 documents:`` behavior preserved in
|
||
spirit), still a counted result."""
|
||
monkeypatch.setattr(agent, "ls_top", lambda db: [])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == "0 sources:"
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("arguments", "label"),
|
||
[
|
||
({"path": " "}, "blank path"),
|
||
({"path": 7}, "non-string path"),
|
||
],
|
||
)
|
||
def test_ls_blank_path_lists_top_level(
|
||
monkeypatch: pytest.MonkeyPatch, arguments: dict[str, Any], label: str
|
||
) -> None:
|
||
"""A blank (or non-string) ``path`` is treated as omitted — the top
|
||
level (the sources), counted (no refusal for an empty scope)."""
|
||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments=arguments)],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == "1 sources:\n\nS — 1 documents"
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_ls_source_scope_lists_root_folder(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""A registered source name (no ``/``): the source's ROOT folder —
|
||
subfolders (2-space-indented, path order, ``: {summary}`` only when
|
||
stored) + the root's own file lines in EXACTLY the
|
||
``source: X | path: Y | title: Z | date: YYYY-MM-DD`` format (the
|
||
phase-106 D5 appended date field) — the pinned template, counted."""
|
||
monkeypatch.setattr(
|
||
agent,
|
||
"ls_folder",
|
||
lambda db, source, folder: (
|
||
[("backups", 2, "Backup notes."), ("networking", 1, None)],
|
||
[("Homelab", "readme.md", "Readme", "2024-06-15")],
|
||
1,
|
||
),
|
||
)
|
||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["Deployments", "Homelab"])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments={"path": "Homelab"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Homelab — 1 documents, 2 folders:\n"
|
||
"\n"
|
||
" backups/ — 2 documents: Backup notes.\n"
|
||
" networking/ — 1 documents\n"
|
||
"\n"
|
||
"source: Homelab | path: readme.md | title: Readme | date: 2024-06-15"
|
||
)
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_ls_nested_folder_scope_lists_one_level_deeper(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A ``source/folder`` path: that folder's subfolders + own file
|
||
lines, identity = ``source/folder`` (the same template as the
|
||
root), counted; the fetchers are the source-scoped ones."""
|
||
|
||
def _rows(db: Any, source: str) -> list[tuple[str, str, str]]:
|
||
assert source == "Homelab" # db is a mock session (SEC-14-04)
|
||
return [
|
||
("networking/lan.md", "LAN", "2024-06-15"),
|
||
("networking/vpn.md", "VPN", "2024-06-15"),
|
||
]
|
||
|
||
monkeypatch.setattr(agent, "_source_document_rows", _rows)
|
||
monkeypatch.setattr(
|
||
agent, "_source_folder_summaries", lambda db, source: {"networking": "Network notes."}
|
||
)
|
||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab"])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1", name="ls", arguments={"path": "Homelab/networking"}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Homelab/networking — 2 documents, 0 folders:\n"
|
||
"\n"
|
||
"source: Homelab | path: networking/lan.md | title: LAN | date: 2024-06-15\n"
|
||
"source: Homelab | path: networking/vpn.md | title: VPN | date: 2024-06-15"
|
||
)
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_ls_scoped_known_source_with_zero_docs_counts(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A registered source with no indexed documents is KNOWN (the
|
||
registry is the source of truth, not the catalog): it lists its
|
||
header line alone (``… — 0 documents, 0 folders:`` — the old
|
||
``0 documents:`` behavior preserved in spirit) — a valid, counted
|
||
result, not a refusal."""
|
||
monkeypatch.setattr(
|
||
agent, "ls_folder", lambda db, source, folder: ([], [], 0)
|
||
)
|
||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab", "Other"])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments={"path": "Homelab"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == "Homelab — 0 documents, 0 folders:"
|
||
assert holder.tool_calls == 1 # an executed ls, not a refusal
|
||
assert llm.requests[1][1] == AGENT_TOOLS
|
||
|
||
|
||
def test_ls_scoped_unknown_source_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""A ``path`` without ``/`` matching no source name is a refusal —
|
||
the extended line with the teaching parenthetical (phase 72), not
|
||
counted, the round cap bounds its repetition."""
|
||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments={"path": "Ghost"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.tool_calls == 0 # a refusal counts in nothing
|
||
assert (
|
||
llm.requests[1][0][3]["content"]
|
||
== agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="Ghost")
|
||
)
|
||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||
|
||
|
||
def test_ls_path_like_scope_unknown_source_gets_no_source_refusal(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 94: a ``/`` now names a folder, so the phase-72
|
||
document-path teaching is DELETED — a ``source/…`` argument whose
|
||
FIRST segment names no registered source gets the no-source refusal
|
||
(the segment echoed), counted in nothing, tools stay offered."""
|
||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="ls",
|
||
arguments={"path": "app/rag/importer.py"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.tool_calls == 0 # a refusal counts in nothing
|
||
assert (
|
||
llm.requests[1][0][3]["content"]
|
||
== agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="app")
|
||
)
|
||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||
|
||
|
||
def test_ls_dot_scope_gets_not_a_directory_teaching_refusal(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 72: ``ls(path='.')`` (the incident's second round — no
|
||
``/``, no matching source) gets the extended no-source refusal with
|
||
the teaching parenthetical, ``'.'`` echoed — not counted, tools stay
|
||
offered (unchanged by phase 94)."""
|
||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments={"path": "."})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.tool_calls == 0 # a refusal counts in nothing
|
||
assert (
|
||
llm.requests[1][0][3]["content"]
|
||
== agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope=".")
|
||
)
|
||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||
|
||
|
||
def test_ls_unknown_top_level_folder_gets_not_a_folder_teaching(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 94: a ``source/…`` argument whose TOP-LEVEL folder segment
|
||
matches no indexed prefix gets the NOT-A-FOLDER teaching — the
|
||
argument echoed, the source named, its direct subfolders listed so
|
||
the model self-corrects in the next round; not counted, tools stay
|
||
offered."""
|
||
monkeypatch.setattr(
|
||
agent,
|
||
"_source_document_rows",
|
||
lambda db, source: [
|
||
("backups/cron.md", "Cron", "2024-06-15"),
|
||
("containers/caddy.md", "Caddy", "2024-06-15"),
|
||
("networking/lan.md", "LAN", "2024-06-15"),
|
||
],
|
||
)
|
||
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
|
||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab"])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1", name="ls", arguments={"path": "Homelab/netwoking"}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.tool_calls == 0 # a refusal counts in nothing
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"'Homelab/netwoking' is not a folder — Homelab has: "
|
||
"backups/ containers/ networking/"
|
||
)
|
||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||
|
||
|
||
def test_ls_unknown_nested_folder_gets_not_a_folder_with_nested_parent(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 94: a nested miss names the DEEPEST existing ancestor —
|
||
``source/folder`` — and lists ITS direct subfolders (bounded: the
|
||
parent's own listing, no new flood path)."""
|
||
monkeypatch.setattr(
|
||
agent,
|
||
"_source_document_rows",
|
||
lambda db, source: [
|
||
("networking/lan/a.md", "A", "2024-06-15"),
|
||
("networking/vpn/b.md", "B", "2024-06-15"),
|
||
("readme.md", "Readme", "2024-06-15"),
|
||
],
|
||
)
|
||
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
|
||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab"])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1", name="ls", arguments={"path": "Homelab/networking/lan/x"}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.tool_calls == 0
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"'Homelab/networking/lan/x' is not a folder — "
|
||
"Homelab/networking/lan has: none"
|
||
)
|
||
|
||
|
||
def test_ls_file_path_scope_gets_not_a_folder(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Phase 94: a document's OWN path is never a folder (nothing starts
|
||
with ``path + '/'``) — ``ls`` of a file path refuses with the
|
||
NOT-A-FOLDER teaching (the parent's subfolders listed)."""
|
||
monkeypatch.setattr(
|
||
agent,
|
||
"_source_document_rows",
|
||
lambda db, source: [
|
||
("notes.md", "Notes", "2024-06-15"),
|
||
("a/b.md", "B", "2024-06-15"),
|
||
],
|
||
)
|
||
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
|
||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(id="call_1", name="ls", arguments={"path": "S/notes.md"})
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.tool_calls == 0
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"'S/notes.md' is not a folder — S has: a/"
|
||
)
|
||
|
||
|
||
# ---------- ls_top / ls_folder: the drill-down accessors (pure + composed) ----------
|
||
|
||
|
||
def test_ls_top_registry_order_zero_docs_and_summaries(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""``ls_top``: registry order (not catalog order), a 0-document
|
||
source still lists, the summary is the stored ``(source, "")`` row
|
||
or ``None`` when absent; an empty registry → ``[]``."""
|
||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["Zeta", "Alpha"])
|
||
monkeypatch.setattr(
|
||
agent, "_source_document_counts", lambda db: [("Zeta", 3), ("Beta", 1)]
|
||
)
|
||
monkeypatch.setattr(
|
||
agent, "_source_root_summaries", lambda db: [("Zeta", "Zeta stuff.")]
|
||
)
|
||
assert agent.ls_top(cast("Session", object())) == [
|
||
("Zeta", 3, "Zeta stuff."),
|
||
("Alpha", 0, None), # 0 docs (no count row) + no stored summary
|
||
]
|
||
|
||
|
||
def test_ls_top_empty_registry_is_empty(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.setattr(agent, "list_source_names", lambda db: [])
|
||
|
||
def _boom(*_a: Any, **_k: Any) -> None:
|
||
raise AssertionError("no fetches for an empty registry")
|
||
|
||
monkeypatch.setattr(agent, "_source_document_counts", _boom)
|
||
monkeypatch.setattr(agent, "_source_root_summaries", _boom)
|
||
assert agent.ls_top(cast("Session", object())) == []
|
||
|
||
|
||
def test_ls_folder_composes_the_fetchers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""``ls_folder`` = the source's document rows + stored summaries
|
||
through the pure :func:`group_folder_listing` (the fetchers are the
|
||
monkeypatch surface)."""
|
||
seen: list[tuple[str, str, str]] = []
|
||
|
||
def _rows(db: Any, source: str) -> list[tuple[str, str, str]]:
|
||
seen.append(("rows", source, ""))
|
||
return [("a/b.md", "B", "2024-06-15"), ("a.md", "A", "2024-06-15")]
|
||
|
||
def _summaries(db: Any, source: str) -> dict[str, str]:
|
||
seen.append(("summaries", source, ""))
|
||
return {"a": "A stuff."}
|
||
|
||
monkeypatch.setattr(agent, "_source_document_rows", _rows)
|
||
monkeypatch.setattr(agent, "_source_folder_summaries", _summaries)
|
||
assert agent.ls_folder(cast("Session", object()), "S", "") == (
|
||
[("a", 1, "A stuff.")],
|
||
[("S", "a.md", "A", "2024-06-15")],
|
||
1,
|
||
)
|
||
assert seen == [("rows", "S", ""), ("summaries", "S", "")]
|
||
|
||
|
||
def test_group_folder_listing_subfolder_recursion_and_counts() -> None:
|
||
"""The recursive count per subfolder — every path equal to the
|
||
folder or starting with ``folder + '/'`` (a doc under ``a/b/``
|
||
counts for BOTH ``a`` and ``a/b``), path order, the stored summary
|
||
attached or ``None``."""
|
||
rows = [
|
||
("a/b/c.md", "C", "2024-06-15"),
|
||
("a/b/d.md", "D", "2024-06-15"),
|
||
("a/e.md", "E", "2024-06-15"),
|
||
("f.md", "F", "2024-06-15"),
|
||
]
|
||
sub, files, total = agent.group_folder_listing(
|
||
"S", "", rows, {"a": "A subtree.", "a/b": "B subtree."}
|
||
)
|
||
# ROOT level: the direct subfolders of "" are the TOP-LEVEL folders
|
||
# only (a/b is nested under a, not direct) — a's count is its whole
|
||
# recursive subtree (a/e.md + a/b/c.md + a/b/d.md), the stored
|
||
# summary attached.
|
||
assert sub == [("a", 3, "A subtree.")]
|
||
assert files == [("S", "f.md", "F", "2024-06-15")]
|
||
assert total == 1
|
||
# One level down: a/b is a's direct subfolder with its own count.
|
||
sub2, _files2, _total2 = agent.group_folder_listing("S", "a", rows, {"a/b": "B subtree."})
|
||
assert sub2 == [("a/b", 2, "B subtree.")]
|
||
|
||
|
||
def test_group_folder_listing_nested_level_counts_and_membership() -> None:
|
||
"""One level down: ``a``'s direct subfolder is ``a/b`` (count 2),
|
||
its own direct file is ``a/e.md`` (``a/b/c.md`` is NOT a direct
|
||
file of ``a``) — membership is the folder_of rule, order is path
|
||
order."""
|
||
rows = [
|
||
("a/b/c.md", "C", "2024-06-15"),
|
||
("a/b/d.md", "D", "2024-06-15"),
|
||
("a/e.md", "E", "2024-06-15"),
|
||
]
|
||
sub, files, total = agent.group_folder_listing("S", "a", rows, {})
|
||
assert sub == [("a/b", 2, None)]
|
||
assert files == [("S", "a/e.md", "E", "2024-06-15")]
|
||
assert total == 1
|
||
|
||
|
||
def test_group_folder_listing_file_path_is_not_a_folder() -> None:
|
||
"""A document whose path is a prefix of NO other path is a file,
|
||
never a folder: ``ls`` of it must not list a subfolder (and the
|
||
``path == folder`` count arm only fires for TRUE folders — a doc
|
||
sharing a real folder's name counts for that folder, the existence
|
||
rule intact)."""
|
||
rows = [
|
||
("a.md", "A", "2024-06-15"), # a file at the root, and a folder name? NO —
|
||
("b/x.md", "X", "2024-06-15"), # nothing starts with "a.md/"
|
||
]
|
||
sub, files, total = agent.group_folder_listing("S", "", rows, {})
|
||
assert sub == [("b", 1, None)] # "a.md" is NOT a subfolder
|
||
assert files == [("S", "a.md", "A", "2024-06-15")] # b/x.md is NOT a direct root file
|
||
assert total == 1
|
||
# The path == folder arm: a doc named "a" under a real folder "a/".
|
||
rows2 = [("a", "FileA", "2024-06-15"), ("a/c.md", "C", "2024-06-15")]
|
||
sub2, files2, total2 = agent.group_folder_listing("S", "", rows2, {})
|
||
assert sub2 == [("a", 2, None)] # the file "a" counts for folder "a"
|
||
assert files2 == [("S", "a", "FileA", "2024-06-15")] # …and is a direct ROOT file
|
||
assert total2 == 1
|
||
|
||
|
||
def test_group_folder_listing_caps_files_at_fifty_keeps_the_total() -> None:
|
||
"""The cap: 51 direct files → 50 file lines + the PRE-cap total (51)
|
||
for the renderer's note; 50 files → 50 lines, no note material.
|
||
A 500-file folder costs 50 lines, never 500."""
|
||
rows51 = [(f"big/f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(51)]
|
||
sub, files, total = agent.group_folder_listing("S", "big", rows51, {})
|
||
assert sub == []
|
||
assert total == 51
|
||
assert len(files) == 50
|
||
assert files[0] == ("S", "big/f000.md", "T0", "2024-06-15")
|
||
assert files[-1] == ("S", "big/f049.md", "T49", "2024-06-15")
|
||
rows50 = [(f"big/f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(50)]
|
||
_sub, files50, total50 = agent.group_folder_listing("S", "big", rows50, {})
|
||
assert total50 == 50 and len(files50) == 50
|
||
|
||
|
||
# ---------- phase 119 (D3): the suggested-folder context lines ----------
|
||
|
||
|
||
def _patch_catalog(
|
||
monkeypatch: pytest.MonkeyPatch, catalog: dict[str, list[tuple[str, str, str]]]
|
||
) -> None:
|
||
"""Monkeypatch the ``ls`` fetchers (house style) with one source
|
||
catalog of ``(path, title, date)`` rows per source name."""
|
||
monkeypatch.setattr(
|
||
agent, "_source_document_rows", lambda db, source: catalog.get(source, [])
|
||
)
|
||
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
|
||
|
||
|
||
def test_suggested_folder_lines_source_root_doc_lists_the_top_level(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A suggested doc at the source root (``""`` prefix) renders the
|
||
``<source>/: …`` line with the source's top level — subfolders with
|
||
recursive counts, the suggested doc itself excluded."""
|
||
_patch_catalog(
|
||
monkeypatch,
|
||
{
|
||
"Homelab": [
|
||
("a/b/one.md", "One", "2024-06-15"),
|
||
("a/b/two.md", "Two", "2024-06-15"),
|
||
("a/c.md", "C", "2024-06-15"),
|
||
("z.md", "Z", "2024-06-15"),
|
||
]
|
||
},
|
||
)
|
||
assert agent.suggested_folder_lines(
|
||
cast("Session", object()), [_doc("Homelab", "z.md")]
|
||
) == ["Homelab/: a/ (3 docs)"]
|
||
|
||
|
||
def test_suggested_folder_lines_nested_doc_lists_the_parent_folder(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A nested suggested doc renders the PARENT folder's line — the
|
||
direct children only: subfolder names (source-relative, recursive
|
||
counts) + the files by relative filename, the suggested doc itself
|
||
out of the entries."""
|
||
_patch_catalog(
|
||
monkeypatch,
|
||
{
|
||
"Homelab": [
|
||
("a/b/one.md", "One", "2024-06-15"),
|
||
("a/b/two.md", "Two", "2024-06-15"),
|
||
("a/c/d.md", "D", "2024-06-15"),
|
||
("a/e.md", "E", "2024-06-15"),
|
||
]
|
||
},
|
||
)
|
||
# The parent of a/b/one.md is a/b: one.md excluded, two.md stays.
|
||
assert agent.suggested_folder_lines(
|
||
cast("Session", object()), [_doc("Homelab", "a/b/one.md")]
|
||
) == ["Homelab/a/b/: two.md"]
|
||
# The parent of a/e.md is a: the subfolders (recursive counts — a/b
|
||
# carries two, a/c one) first, then the files; e.md excluded, so no
|
||
# file entries remain.
|
||
assert agent.suggested_folder_lines(
|
||
cast("Session", object()), [_doc("Homelab", "a/e.md")]
|
||
) == ["Homelab/a/: a/b/ (2 docs), a/c/ (1 doc)"]
|
||
|
||
|
||
def test_suggested_folder_lines_excludes_only_the_owning_doc(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The doc that OWNS the line (the first suggested doc in that
|
||
folder) is the only one excluded — a folder whose sole entry was
|
||
the suggested doc renders its header alone (the empty-level
|
||
precedent)."""
|
||
_patch_catalog(
|
||
monkeypatch,
|
||
{
|
||
"Homelab": [("a/only.md", "Only", "2024-06-15")]
|
||
},
|
||
)
|
||
assert agent.suggested_folder_lines(
|
||
cast("Session", object()), [_doc("Homelab", "a/only.md")]
|
||
) == ["Homelab/a/:"]
|
||
|
||
|
||
def test_suggested_folder_lines_dedupes_by_source_and_prefix(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Dedupe: two suggested docs in the same folder ⇒ ONE line (the
|
||
first doc's position, the first doc excluded, the second listed);
|
||
the key is ``(source, prefix)`` — the same prefix under a DIFFERENT
|
||
source is a distinct line; suggested-doc order is the line order."""
|
||
_patch_catalog(
|
||
monkeypatch,
|
||
{
|
||
"Homelab": [
|
||
("a/b/one.md", "One", "2024-06-15"),
|
||
("a/b/two.md", "Two", "2024-06-15"),
|
||
("a/c.md", "C", "2024-06-15"),
|
||
("a/d/e.md", "E", "2024-06-15"),
|
||
("z.md", "Z", "2024-06-15"),
|
||
],
|
||
"Other": [("a/b/x.md", "X", "2024-06-15")],
|
||
},
|
||
)
|
||
docs = [
|
||
_doc("Homelab", "z.md"),
|
||
_doc("Homelab", "a/b/one.md"),
|
||
_doc("Homelab", "a/b/two.md"), # deduped (a/b already seen)
|
||
_doc("Homelab", "a/c.md"),
|
||
_doc("Homelab", "a/d/e.md"),
|
||
_doc("Other", "a/b/x.md"), # same prefix, different source
|
||
]
|
||
assert agent.suggested_folder_lines(cast("Session", object()), docs) == [
|
||
"Homelab/: a/ (4 docs)",
|
||
"Homelab/a/b/: two.md",
|
||
"Homelab/a/: a/b/ (2 docs), a/d/ (1 doc)",
|
||
"Homelab/a/d/:",
|
||
"Other/a/b/:",
|
||
]
|
||
|
||
|
||
def test_suggested_folder_lines_max_lines_caps_the_list(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""At most *max_lines* lines — the walk stops, suggested-doc order
|
||
kept (the first distinct folders win)."""
|
||
_patch_catalog(
|
||
monkeypatch,
|
||
{
|
||
"Homelab": [
|
||
("f1/x.md", "1", "2024-06-15"),
|
||
("f2/x.md", "2", "2024-06-15"),
|
||
("f3/x.md", "3", "2024-06-15"),
|
||
]
|
||
},
|
||
)
|
||
docs = [_doc("Homelab", p) for p in ("f1/x.md", "f2/x.md", "f3/x.md")]
|
||
lines = agent.suggested_folder_lines(cast("Session", object()), docs, max_lines=2)
|
||
assert lines == ["Homelab/f1/:", "Homelab/f2/:"]
|
||
assert agent.suggested_folder_lines(
|
||
cast("Session", object()), docs, max_lines=1
|
||
) == ["Homelab/f1/:"]
|
||
|
||
|
||
def test_suggested_folder_lines_max_entries_cap_and_remainder(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""At most *max_entries* entries, then `` +N more`` with the EXACT
|
||
remainder (the true pre-cap folder total — the owning doc leaves
|
||
it); the ``(1 doc)`` singular form; no `` +N more`` when every
|
||
entry fits."""
|
||
rows = [(f"big/s{i}/x.md", f"S{i}", "2024-06-15") for i in range(4)] + [
|
||
(f"big/f{i:02d}.md", f"F{i}", "2024-06-15") for i in range(10)
|
||
]
|
||
_patch_catalog(monkeypatch, {"S": rows})
|
||
db = cast("Session", object())
|
||
# 4 subfolders (one doc each) + 10 files − the owning f00.md = 13
|
||
# entries; 8 shown (the 4 subfolders + f01..f04) + 5 more. The
|
||
# subfolder entries are source-relative (``big/s0/``) — exactly as
|
||
# the model's own ls output names them.
|
||
assert agent.suggested_folder_lines(db, [_doc("S", "big/f00.md")]) == [
|
||
"S/big/: big/s0/ (1 doc), big/s1/ (1 doc), big/s2/ (1 doc), big/s3/ (1 doc), "
|
||
"f01.md, f02.md, f03.md, f04.md +5 more"
|
||
]
|
||
# A wide enough cap shows every entry — no remainder marker.
|
||
assert agent.suggested_folder_lines(db, [_doc("S", "big/f00.md")], max_entries=20) == [
|
||
"S/big/: big/s0/ (1 doc), big/s1/ (1 doc), big/s2/ (1 doc), big/s3/ (1 doc), "
|
||
"f01.md, f02.md, f03.md, f04.md, f05.md, f06.md, f07.md, f08.md, f09.md"
|
||
]
|
||
# The remainder is exact at a tighter cap too (3 subfolders shown —
|
||
# files only follow ALL subfolders, the ls order — 10 left).
|
||
assert agent.suggested_folder_lines(db, [_doc("S", "big/f00.md")], max_entries=3) == [
|
||
"S/big/: big/s0/ (1 doc), big/s1/ (1 doc), big/s2/ (1 doc) +10 more"
|
||
]
|
||
|
||
|
||
def test_suggested_folder_lines_entry_order_matches_the_ls_listing(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The line's entry order IS the ``ls`` folder-level rendering order
|
||
(pinned against :func:`group_folder_listing` itself): the direct
|
||
subfolders first (path order), then the files (path order) — a
|
||
mixed folder where the file names and the subfolder names interleave
|
||
in path order still lists all subfolders before any file."""
|
||
rows = [
|
||
("a/a.md", "A", "2024-06-15"),
|
||
("a/b.md", "B", "2024-06-15"),
|
||
("a/c/d.md", "D", "2024-06-15"),
|
||
]
|
||
_patch_catalog(monkeypatch, {"Homelab": rows})
|
||
db = cast("Session", object())
|
||
suggested = _doc("Homelab", "a/b.md")
|
||
# The expected entry order, derived from group_folder_listing itself
|
||
# (subfolders → files, minus the owning doc) — the line must read
|
||
# the same as the model's own ls output.
|
||
sub, files, total = agent.group_folder_listing("Homelab", "a", rows, {})
|
||
assert total == 2
|
||
expected_entries = [
|
||
f"{g}/ ({c} {'doc' if c == 1 else 'docs'})" for g, c, _s in sub
|
||
] + [path.rsplit("/", 1)[-1] for _src, path, _t, _d in files if path != suggested.path]
|
||
# Subfolder source-relative (``a/c/``), files by relative filename.
|
||
assert expected_entries == ["a/c/ (1 doc)", "a.md"]
|
||
expected_line = f"Homelab/a/: {', '.join(expected_entries)}"
|
||
assert agent.suggested_folder_lines(db, [suggested]) == [expected_line]
|
||
|
||
|
||
def test_suggested_folder_lines_empty_suggested_yields_no_lines(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""No suggested docs → no lines (the caller then builds the
|
||
byte-identical phase-118 prompt) — and no fetcher call at all."""
|
||
calls: list[str] = []
|
||
|
||
def _rows(db: Any, source: str) -> list[tuple[str, str, str]]:
|
||
calls.append(source)
|
||
return []
|
||
|
||
monkeypatch.setattr(agent, "_source_document_rows", _rows)
|
||
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
|
||
assert agent.suggested_folder_lines(cast("Session", object()), []) == []
|
||
assert calls == []
|
||
|
||
|
||
# ---------- the pinned drill-down templates (byte-for-byte) ----------
|
||
|
||
|
||
def test_render_ls_top_template() -> None:
|
||
assert (
|
||
agent.render_ls_top(
|
||
[("Deployments", 3, None), ("Homelab", 5, "Home lab notes.")]
|
||
)
|
||
== "2 sources:\n\n"
|
||
"Deployments — 3 documents\n"
|
||
"Homelab — 5 documents\n"
|
||
" Home lab notes."
|
||
)
|
||
assert agent.render_ls_top([]) == "0 sources:"
|
||
|
||
|
||
def test_render_folder_listing_root_template() -> None:
|
||
assert (
|
||
agent.render_folder_listing(
|
||
"Homelab",
|
||
[("backups", 2, "Backup notes."), ("networking", 1, None)],
|
||
[("Homelab", "readme.md", "Readme", "2024-06-15")],
|
||
1,
|
||
)
|
||
== "Homelab — 1 documents, 2 folders:\n"
|
||
"\n"
|
||
" backups/ — 2 documents: Backup notes.\n"
|
||
" networking/ — 1 documents\n"
|
||
"\n"
|
||
"source: Homelab | path: readme.md | title: Readme | date: 2024-06-15"
|
||
)
|
||
|
||
|
||
def test_render_folder_listing_empty_level_is_header_alone() -> None:
|
||
"""A registered source with no documents: the header line alone —
|
||
the old ``0 documents:`` behavior preserved in spirit."""
|
||
assert agent.render_folder_listing("Homelab", [], [], 0) == (
|
||
"Homelab — 0 documents, 0 folders:"
|
||
)
|
||
|
||
|
||
def test_render_folder_listing_subfolders_only_no_blank_trailer() -> None:
|
||
"""Subfolders but no own files: header + blank + subfolder lines —
|
||
no trailing blank line, no file section."""
|
||
assert (
|
||
agent.render_folder_listing("S", [("a", 1, None)], [], 0)
|
||
== "S — 0 documents, 1 folders:\n\n a/ — 1 documents"
|
||
)
|
||
|
||
|
||
def test_render_folder_listing_cap_note_only_past_fifty() -> None:
|
||
"""The note appears ONLY when the folder's own files outnumber the
|
||
cap: 51 → 50 lines + the deterministic grep-pointer note (the
|
||
``…and 1 more…`` shape — unpluralized, the house pin); 50 → no
|
||
note."""
|
||
files51 = [("S", f"f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(51)]
|
||
capped = files51[:50]
|
||
rendered = agent.render_folder_listing("S/big", [], capped, 51)
|
||
lines = rendered.splitlines()
|
||
assert lines[0] == "S/big — 51 documents, 0 folders:"
|
||
assert len(lines) == 1 + 1 + 50 + 1 # header, blank, 50 lines, note
|
||
assert lines[-1] == (
|
||
"…and 1 more documents in this folder — use grep (pattern) to "
|
||
"find a specific one."
|
||
)
|
||
files50 = [("S", f"f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(50)]
|
||
rendered50 = agent.render_folder_listing("S/big", [], files50, 50)
|
||
assert rendered50.splitlines()[-1] == (
|
||
"source: S | path: f049.md | title: T49 | date: 2024-06-15"
|
||
)
|
||
assert "more documents" not in rendered50
|
||
|
||
|
||
# ---------- read: the canonical combined source/path form ----------
|
||
|
||
|
||
def test_read_combined_path_resolves_and_returns_full_content(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The combined ``source/path`` form (the model's trained shape) is
|
||
the canonical identity: split at the FIRST '/', one exact lookup,
|
||
the full content returned (A7-revised: never truncated) — even when
|
||
the path itself carries further slashes."""
|
||
doc = _doc("Homelab", "active/container_caddy/caddy.md", "Caddy", "CADDY-CONTENT")
|
||
calls: list[tuple[str, str]] = []
|
||
|
||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||
calls.append((source, path))
|
||
return (
|
||
doc
|
||
if (source, path) == ("Homelab", "active/container_caddy/caddy.md")
|
||
else None
|
||
)
|
||
|
||
monkeypatch.setattr(agent, "find_document", _find)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="read",
|
||
arguments={"path": "Homelab/active/container_caddy/caddy.md"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
# First-slash split — exactly one lookup, the canonical pair.
|
||
assert calls == [("Homelab", "active/container_caddy/caddy.md")]
|
||
assert holder.read_docs == [doc]
|
||
assert holder.tool_calls == 1
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document Homelab/active/container_caddy/caddy.md:\n"
|
||
"date: 2024-06-15\n"
|
||
"CADDY-CONTENT"
|
||
)
|
||
|
||
|
||
def test_read_bare_source_name_refused_without_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""A bare source name (no '/') can never be a document — the
|
||
no-document refusal (the argument echoed as passed), no DB lookup
|
||
(NOT even the phase-72 candidate lookup — ``all_documents`` must
|
||
not run either), nothing counted."""
|
||
|
||
def _boom(*_a: Any, **_k: Any) -> None:
|
||
raise AssertionError(
|
||
"no DB lookup (find_document or all_documents) for a bare "
|
||
"source name"
|
||
)
|
||
|
||
monkeypatch.setattr(agent, "find_document", _boom)
|
||
monkeypatch.setattr(agent, "all_documents", _boom)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "Homelab"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No document at 'Homelab' — check the ls output."
|
||
)
|
||
assert llm.requests[1][1] == AGENT_TOOLS
|
||
|
||
|
||
def test_read_unknown_path_refused_echoing_argument(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""An unknown combined identity that matches NO indexed document's
|
||
``path`` (zero candidates — the phase-72 lookup runs, finds nothing)
|
||
→ today's refusal echoing the argument as passed, byte-identical —
|
||
the old split-teaching refusal is gone (phase 70)."""
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/ghost.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No document at 'S/ghost.md' — check the ls output."
|
||
)
|
||
assert llm.requests[1][1] == AGENT_TOOLS # tools stay offered (cap bounds)
|
||
|
||
|
||
# ---------- read/grep: the "did you mean …?" suggestions (phase 72, task 02) ----------
|
||
|
||
|
||
def test_find_path_candidates_exact_suffix_catalog_order(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The pure catalog lookup (monkeypatched ``all_documents`` — one
|
||
bulk query per call): ``path`` == arg (exact) or a ``/arg`` suffix —
|
||
catalog order, case-sensitive, as ``(source, path, title)`` triples;
|
||
a plain substring is NOT a suffix; the result is uncapped (the
|
||
:data:`~app.rag.agent.SUGGESTION_LIMIT` cap lives in the refusal).
|
||
"""
|
||
docs = [
|
||
_doc("A", "x.md", "Ax", "A"),
|
||
_doc("A", "shared/x.md", "As", "AS"),
|
||
_doc("B", "shared/x.md", "Bs", "BS"),
|
||
_doc("C", "deep/shared/x.md", "Cs", "CS"),
|
||
_doc("D", "X.md", "Dx", "D"), # case-sensitive: not 'x.md'
|
||
_doc("E", "nosuffixx.md", "Ex", "E"), # substring, not a /suffix
|
||
]
|
||
calls: list[int] = []
|
||
|
||
def _all(db: Any) -> list[Document]:
|
||
calls.append(1)
|
||
return docs
|
||
|
||
monkeypatch.setattr(agent, "all_documents", _all)
|
||
db = cast("Session", object())
|
||
|
||
# Exact bare path ('shared/x.md') plus the deeper suffix
|
||
# ('deep/shared/x.md' ends with '/shared/x.md') — catalog order.
|
||
assert agent.find_path_candidates(db, "shared/x.md") == [
|
||
("A", "shared/x.md", "As"),
|
||
("B", "shared/x.md", "Bs"),
|
||
("C", "deep/shared/x.md", "Cs"),
|
||
]
|
||
# 'x.md' equals A's path exactly AND suffix-matches the rest — all
|
||
# four, catalog order (uncapped: the cap is the refusal's).
|
||
assert agent.find_path_candidates(db, "x.md") == [
|
||
("A", "x.md", "Ax"),
|
||
("A", "shared/x.md", "As"),
|
||
("B", "shared/x.md", "Bs"),
|
||
("C", "deep/shared/x.md", "Cs"),
|
||
]
|
||
# Case-sensitive file paths: 'X.md' matches ONLY D's identically-
|
||
# cased path (never the lowercase 'x.md' ones), and the plain
|
||
# substring inside 'nosuffixx.md' is not a suffix.
|
||
assert agent.find_path_candidates(db, "X.md") == [("D", "X.md", "Dx")]
|
||
# One bulk query per call (at most one).
|
||
assert len(calls) == 3
|
||
|
||
|
||
def test_read_bare_path_exact_match_gets_did_you_mean(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The incident shape: an unresolved ``read`` argument containing
|
||
``/`` that EXACTLY matches one indexed document's ``path`` (the bare
|
||
path missing the source prefix — the harness prior) gets the
|
||
``NO_DOCUMENT_DID_YOU_MEAN`` line naming the combined identity —
|
||
still a refusal: ``read_docs`` empty, nothing counted, tools stay
|
||
offered."""
|
||
doc = _doc("Homelab", "active/container_caddy/caddy.md", "Caddy", "CADDY-CONTENT")
|
||
|
||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||
return (
|
||
doc
|
||
if (source, path) == ("Homelab", "active/container_caddy/caddy.md")
|
||
else None
|
||
)
|
||
|
||
monkeypatch.setattr(agent, "find_document", _find)
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [doc])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="read",
|
||
arguments={"path": "active/container_caddy/caddy.md"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [] and holder.tool_calls == 0 # a refusal
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
agent.NO_DOCUMENT_DID_YOU_MEAN.format(
|
||
arg="active/container_caddy/caddy.md",
|
||
source="Homelab",
|
||
path="active/container_caddy/caddy.md",
|
||
)
|
||
)
|
||
# The rendered line, pinned byte-for-byte.
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No document at 'active/container_caddy/caddy.md' — "
|
||
"did you mean 'Homelab/active/container_caddy/caddy.md'?"
|
||
)
|
||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||
|
||
|
||
def test_read_bare_path_suffix_match_gets_did_you_mean(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The suffix form of the same teaching: a path-like argument that
|
||
matches a deeper indexed path (``active/container_caddy/caddy.md``
|
||
ends with ``/container_caddy/caddy.md``) names the same combined
|
||
identity."""
|
||
doc = _doc("Homelab", "active/container_caddy/caddy.md", "Caddy", "CADDY-CONTENT")
|
||
|
||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||
return (
|
||
doc
|
||
if (source, path) == ("Homelab", "active/container_caddy/caddy.md")
|
||
else None
|
||
)
|
||
|
||
monkeypatch.setattr(agent, "find_document", _find)
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [doc])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="read",
|
||
arguments={"path": "container_caddy/caddy.md"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [] and holder.tool_calls == 0 # a refusal
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No document at 'container_caddy/caddy.md' — "
|
||
"did you mean 'Homelab/active/container_caddy/caddy.md'?"
|
||
)
|
||
|
||
|
||
def test_read_bare_path_two_sources_gets_one_of_suggestion(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The same bare path under two sources: the ``one of`` line — up to
|
||
``SUGGESTION_LIMIT`` combined identities, each single-quoted, joined
|
||
with ``, `` in catalog order (A before B)."""
|
||
a = _doc("A", "shared/x.md", "Ax", "A")
|
||
b = _doc("B", "shared/x.md", "Bx", "B")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [a, b])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(id="call_1", name="read", arguments={"path": "shared/x.md"})
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [] and holder.tool_calls == 0 # a refusal
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
agent.NO_DOCUMENT_DID_YOU_MEAN_MANY.format(
|
||
arg="shared/x.md", candidates="'A/shared/x.md', 'B/shared/x.md'"
|
||
)
|
||
)
|
||
# The rendered line, pinned byte-for-byte.
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No document at 'shared/x.md' — did you mean one of: "
|
||
"'A/shared/x.md', 'B/shared/x.md'?"
|
||
)
|
||
|
||
|
||
def test_read_bare_path_four_sources_capped_at_three_suggestions(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Four sources sharing the same path: exactly ``SUGGESTION_LIMIT``
|
||
(3) identities are suggested — catalog order, the fourth dropped."""
|
||
docs = [_doc(s, "shared/x.md", f"{s}x", s) for s in ("A", "B", "C", "D")]
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: list(docs))
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(id="call_1", name="read", arguments={"path": "shared/x.md"})
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No document at 'shared/x.md' — did you mean one of: "
|
||
"'A/shared/x.md', 'B/shared/x.md', 'C/shared/x.md'?"
|
||
)
|
||
assert "'D/shared/x.md'" not in llm.requests[1][0][3]["content"]
|
||
assert holder.read_docs == [] and holder.tool_calls == 0 # a refusal
|
||
|
||
|
||
def test_read_bare_filename_without_slash_keeps_no_db_refusal(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The gate is the ``/`` in the argument: a bare FILENAME (no ``/``
|
||
— e.g. ``caddy.md``) is a bare name for the lookup — today's
|
||
refusal byte-identical, and NO ``find_document`` / ``all_documents``
|
||
call (the same no-DB-lookup invariant as a bare source name)."""
|
||
|
||
def _boom(*_a: Any, **_k: Any) -> None:
|
||
raise AssertionError("no DB lookup for a bare (no '/') argument")
|
||
|
||
monkeypatch.setattr(agent, "find_document", _boom)
|
||
monkeypatch.setattr(agent, "all_documents", _boom)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "caddy.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No document at 'caddy.md' — check the ls output."
|
||
)
|
||
assert llm.requests[1][1] == AGENT_TOOLS
|
||
|
||
|
||
def test_read_bare_path_of_suggested_doc_gets_suggestion_then_succeeds(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Dedupe precedence (phase 118): the in-context dedupe fires on
|
||
the SPLIT pair of a document already READ — the bare path of a
|
||
SUGGESTED (seeded) document (``read('app/rag/importer.py')`` with
|
||
``sample/app/rag/importer.py`` seeded) is NOT that pair, so it is
|
||
not a dedupe: it gets the suggestion line naming the combined
|
||
identity, and the model's next, correctly-formed call ADDS the
|
||
suggested document's full text (A6: the seeds are summary blocks,
|
||
not full text — a first read of a suggested document succeeds)."""
|
||
seed = [_doc("sample", "app/rag/importer.py", "Importer", "IMPORTER")]
|
||
|
||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||
return seed[0] if (source, path) == ("sample", "app/rag/importer.py") else None
|
||
|
||
monkeypatch.setattr(agent, "find_document", _find)
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: list(seed))
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="read",
|
||
arguments={"path": "app/rag/importer.py"},
|
||
)
|
||
],
|
||
[
|
||
# Round 2: the corrected call (the suggested combined
|
||
# identity) — the suggested document's full text is NOT in
|
||
# the prompt (only its summary is), so this read succeeds
|
||
# and adds the full content.
|
||
ToolCallPiece(
|
||
id="call_2",
|
||
name="read",
|
||
arguments={"path": "sample/app/rag/importer.py"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(), seed_docs=seed))
|
||
# Round 1 refused (the teaching), round 2 executed (A6).
|
||
assert holder.read_docs == [seed[0]]
|
||
assert holder.tool_calls == 1
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No document at 'app/rag/importer.py' — "
|
||
"did you mean 'sample/app/rag/importer.py'?"
|
||
)
|
||
assert llm.requests[2][0][5]["content"] == (
|
||
"Document sample/app/rag/importer.py:\n"
|
||
"date: 2024-06-15\n"
|
||
"IMPORTER"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("arguments", "label"),
|
||
[
|
||
({}, "no arguments"),
|
||
({"path": ""}, "empty path"),
|
||
({"path": " "}, "blank path"),
|
||
({"path": 7}, "non-string path"),
|
||
({"path": None}, "null path"),
|
||
],
|
||
)
|
||
def test_read_missing_arguments_refused(
|
||
monkeypatch: pytest.MonkeyPatch, arguments: dict[str, Any], label: str
|
||
) -> None:
|
||
def _boom(*_a: Any, **_k: Any) -> None:
|
||
raise AssertionError(f"find_document must not be called ({label})")
|
||
|
||
monkeypatch.setattr(agent, "find_document", _boom)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments=arguments)],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
assert llm.requests[1][0][3]["content"] == agent.MISSING_READ_ARGS
|
||
assert llm.requests[1][1] == AGENT_TOOLS
|
||
|
||
|
||
def test_reading_a_suggested_seed_doc_adds_its_full_text(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 118 (A6): a FIRST read of a suggested (seeded) document
|
||
SUCCEEDS — the seed is a summary block in the prompt, not the full
|
||
text, so it falls out of the dedupe set (``holder.read_docs``
|
||
only): the read goes through the existing path unchanged — the
|
||
full-content result (header + date line), appended to
|
||
``holder.read_docs``, counted in ``holder.tool_calls``."""
|
||
seed = [_doc("Homelab", "kubernetes.md", "Kubernetes", "K8S-CONTENT")]
|
||
monkeypatch.setattr(
|
||
agent,
|
||
"find_document",
|
||
lambda db, source, path: seed[0]
|
||
if (source, path) == ("Homelab", "kubernetes.md")
|
||
else None,
|
||
)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="read",
|
||
arguments={"path": "Homelab/kubernetes.md"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(), seed_docs=seed))
|
||
assert holder.read_docs == [seed[0]]
|
||
assert holder.tool_calls == 1
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document Homelab/kubernetes.md:\ndate: 2024-06-15\nK8S-CONTENT"
|
||
)
|
||
assert llm.requests[1][1] == AGENT_TOOLS
|
||
|
||
|
||
def test_re_reading_a_suggested_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Phase 118 (A6): once the suggested document's full text has been
|
||
read into context, a SECOND read of the same combined identity is
|
||
refused with the byte-identical ``ALREADY_IN_CONTEXT`` line — the
|
||
dedupe set is ``holder.read_docs`` only, and the counters stay
|
||
untouched. Phase 141 (task 02): the raw-arg fast path is RETIRED
|
||
(the merged span's fullness needs the document's true line count,
|
||
which only the resolve knows), so the re-read RESOLVES first —
|
||
two lookups, not one; the observable refusal is unchanged."""
|
||
seed = [_doc("Homelab", "kubernetes.md", "Kubernetes", "K8S-CONTENT")]
|
||
lookups: list[tuple[str, str]] = []
|
||
|
||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||
lookups.append((source, path))
|
||
return seed[0] if (source, path) == ("Homelab", "kubernetes.md") else None
|
||
|
||
monkeypatch.setattr(agent, "find_document", _find)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="read",
|
||
arguments={"path": "Homelab/kubernetes.md"},
|
||
)
|
||
],
|
||
[
|
||
ToolCallPiece(
|
||
id="call_2",
|
||
name="read",
|
||
arguments={"path": "Homelab/kubernetes.md"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(), seed_docs=seed))
|
||
assert holder.read_docs == [seed[0]] # appended exactly once
|
||
assert holder.tool_calls == 1 # the re-read counts nothing
|
||
# The re-read now goes through the resolve (the fast path is
|
||
# retired — the merged span (1, 1) == the whole 1-line doc makes
|
||
# the claim true) — two lookups.
|
||
assert lookups == [("Homelab", "kubernetes.md"), ("Homelab", "kubernetes.md")]
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document Homelab/kubernetes.md:\ndate: 2024-06-15\nK8S-CONTENT"
|
||
)
|
||
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
|
||
# Rejected → the tools are still offered on the next request (the
|
||
# round cap is the only bound).
|
||
assert llm.requests[2][1] == AGENT_TOOLS
|
||
|
||
|
||
def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""The second read of the same document (holder.read_docs) →
|
||
ALREADY_IN_CONTEXT — appended once, counted once."""
|
||
doc = _doc("S", "a.md", "A", "A-CONTENT")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/a.md"})],
|
||
[ToolCallPiece(id="call_2", name="read", arguments={"path": "S/a.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [doc] # appended exactly once
|
||
assert holder.tool_calls == 1 # the re-read counts nothing
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/a.md:\ndate: 2024-06-15\nA-CONTENT"
|
||
)
|
||
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
|
||
# Rejected → the tools are still offered on the next request…
|
||
assert llm.requests[2][1] == AGENT_TOOLS
|
||
|
||
|
||
# ---------- phase 95: the read cap (bounded reads, honest truncation) ----------
|
||
|
||
|
||
def test_read_exactly_at_cap_is_byte_identical_and_untruncated(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 95 boundary: a document whose content length EQUALS the cap
|
||
fits — read whole, byte-identical to the pre-phase-95 result (no
|
||
marker, no notice, no holder entry, no ``ToolResultPiece``)."""
|
||
cap = 20
|
||
content = "x" * cap
|
||
doc = _doc("S", "big.md", "Big", content)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/big.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
# The read result plus the phase-106 D5 date line (no marker, no
|
||
# notice).
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/big.md:\ndate: 2024-06-15\n" + content
|
||
)
|
||
assert TRUNCATION_MARKER not in llm.requests[1][0][3]["content"]
|
||
# No truncation recorded, none surfaced to the loop.
|
||
assert holder.read_truncations == []
|
||
assert not any(isinstance(p, ToolResultPiece) for p in out)
|
||
# Still a successful read.
|
||
assert holder.read_docs == [doc]
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_read_at_cap_plus_one_truncates_with_marker_and_notice(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 95 boundary: ONE char over the cap truncates — the first
|
||
``cap`` chars + the shared :data:`TRUNCATION_MARKER` + the pinned
|
||
grep-pointer notice (``{total}`` = the true length, ``{shown}`` = the
|
||
cap), and the truncation is recorded on the holder. A truncated read
|
||
is still a successful call (``tool_calls`` / ``read_docs`` as today)."""
|
||
cap = 20
|
||
content = "x" * (cap + 1)
|
||
doc = _doc("S", "big.md", "Big", content)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/big.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
expected = (
|
||
"Document S/big.md:\n"
|
||
"date: 2024-06-15\n"
|
||
+ content[:cap]
|
||
+ "\n"
|
||
+ TRUNCATION_MARKER
|
||
+ "\n"
|
||
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=cap + 1)
|
||
)
|
||
assert llm.requests[1][0][3]["content"] == expected
|
||
# (argument, chars_shown, chars_total) — the raw argument the tool
|
||
# frame carries, the cap kept, the true length.
|
||
assert holder.read_truncations == [("S/big.md", cap, cap + 1)]
|
||
assert holder.read_docs == [doc] # still added to the context
|
||
assert holder.tool_calls == 1 # still a counted, successful call
|
||
|
||
|
||
def test_read_truncation_notice_is_pinned() -> None:
|
||
"""Phase 95 (REWRITTEN phase 131, task 01): the notice copy is
|
||
pinned — it names the true length (``{total}``), the cap kept
|
||
(``{shown}``), states the rest is NOT shown (the document did
|
||
not end where it stopped), and teaches the working workflow —
|
||
the old dead-end is gone: ``grep`` reports line numbers, then
|
||
read that part with ``offset`` / ``limit`` (BOTH parameters
|
||
named in the notice itself)."""
|
||
assert READ_TRUNCATION_NOTICE.format(shown=100, total=250) == (
|
||
"TRUNCATED — this document is 250 characters; only the first "
|
||
"100 are in your context. The rest is NOT shown. Use grep "
|
||
"(pattern) to find what you need — it reports line numbers — "
|
||
"then read that part with `offset` (1-based first line) and "
|
||
"`limit` (number of lines)."
|
||
)
|
||
# Both line-range parameters are named in the notice — the model
|
||
# stuck at the truncation sees the exact follow-up call.
|
||
assert "`offset`" in READ_TRUNCATION_NOTICE
|
||
assert "`limit`" in READ_TRUNCATION_NOTICE
|
||
# The old dead-end sentence (no way to act on the line number)
|
||
# is gone.
|
||
assert "to locate what you need" not in READ_TRUNCATION_NOTICE
|
||
|
||
|
||
def test_run_agent_yields_tool_result_after_tool_frame_before_next_round(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 95: ``run_agent`` yields exactly ONE ``ToolResultPiece`` per
|
||
truncated read — AFTER the round's ``tool`` frame (the matching
|
||
``ToolCallPiece``) and BEFORE the next model round (the answer
|
||
pieces). It carries (argument, shown, total); ``argument`` is the
|
||
same value the matching ``tool`` frame carries (the raw
|
||
``source/path`` the model passed)."""
|
||
cap = 20
|
||
content = "y" * (cap + 5)
|
||
doc = _doc("S", "big.md", "Big", content)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/big.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
# The piece order: the read's ToolCallPiece, then the ToolResultPiece,
|
||
# then the next round's content (the answer).
|
||
assert isinstance(out[0], ToolCallPiece) and out[0].name == "read"
|
||
piece = out[1]
|
||
assert isinstance(piece, ToolResultPiece)
|
||
assert isinstance(out[2], StreamPiece)
|
||
assert piece.name == "read"
|
||
assert piece.argument == "S/big.md"
|
||
assert piece.truncated is True
|
||
assert piece.chars_shown == cap
|
||
assert piece.chars_total == cap + 5
|
||
# Exactly one ToolResultPiece for the one truncated read.
|
||
assert [p for p in out if isinstance(p, ToolResultPiece)] == [piece]
|
||
|
||
|
||
def test_run_agent_short_read_yields_no_tool_result_piece(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 95: a read at or under the cap yields NO ``ToolResultPiece``
|
||
— the non-truncated stream is byte-identical to the pre-phase-95
|
||
one (just the ``ToolCallPiece`` + the answer)."""
|
||
cap = 20
|
||
content = "z" * cap # exactly at the cap → not truncated
|
||
doc = _doc("S", "small.md", "Small", content)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/small.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
assert not any(isinstance(p, ToolResultPiece) for p in out)
|
||
assert holder.read_truncations == []
|
||
# Order: the ToolCallPiece then the answer content (no piece between).
|
||
assert isinstance(out[0], ToolCallPiece) and out[0].name == "read"
|
||
assert isinstance(out[1], StreamPiece)
|
||
|
||
|
||
def test_read_truncation_paths_with_suggested_seed(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 118 (A6) re-target of the phase-95 pins ("seed" →
|
||
"suggested"): a SUGGESTED (seeded) document that is over the cap is
|
||
read through the ordinary cap path — cut at the cap + the shared
|
||
marker + the pinned notice, the truncation recorded on the holder,
|
||
and the call still successful (the seed is a summary, not full
|
||
text) — and an unknown path is still the no-document refusal (no
|
||
content is read, so no truncation either)."""
|
||
big = "B" * 5000 # far over the tiny cap below
|
||
seed = _doc("S", "seed.md", "Seed", big)
|
||
monkeypatch.setattr(
|
||
agent,
|
||
"find_document",
|
||
lambda db, source, path: seed
|
||
if (source, path) == ("S", "seed.md")
|
||
else None,
|
||
)
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [seed])
|
||
# (a) Reading the (over-cap) SUGGESTED doc → the capped read: cut at
|
||
# the cap, marker + pinned notice, holder entry, still counted.
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/seed.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(read_max_chars=100), seed_docs=[seed]))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/seed.md:\n"
|
||
"date: 2024-06-15\n"
|
||
+ big[:100]
|
||
+ "\n"
|
||
+ TRUNCATION_MARKER
|
||
+ "\n"
|
||
+ READ_TRUNCATION_NOTICE.format(shown=100, total=5000)
|
||
)
|
||
# (argument, cap, total) — the raw argument, the cap kept, the
|
||
# true length.
|
||
assert holder.read_truncations == [("S/seed.md", 100, 5000)]
|
||
assert holder.tool_calls == 1 and holder.read_docs == [seed]
|
||
# (b) An unknown path → the no-document refusal (argument echoed),
|
||
# even though a big doc could have truncated — no content is read.
|
||
holder2 = AgentHolder()
|
||
llm2 = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/missing.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm2, holder2, _settings(read_max_chars=100), seed_docs=[seed]))
|
||
assert llm2.requests[1][0][3]["content"] == (
|
||
"No document at 'S/missing.md' — check the ls output."
|
||
)
|
||
assert holder2.read_truncations == []
|
||
assert holder2.tool_calls == 0 and holder2.read_docs == []
|
||
|
||
|
||
# ---------- phase 131 (task 02): the range-read execution (offset/limit) ----------
|
||
|
||
|
||
def _lines_doc(source: str, path: str, n_lines: int, prefix: str = "line ") -> Document:
|
||
"""A fixture document of EXACTLY *n_lines* lines (``prefix{i}``)."""
|
||
content = "\n".join(f"{prefix}{i}" for i in range(1, n_lines + 1))
|
||
return _doc(source, path, path, content)
|
||
|
||
|
||
def test_bad_range_args_is_pinned() -> None:
|
||
"""Phase 131 (task 02): the range-argument validation refusal is
|
||
pinned — the contract (whole line numbers ≥ 1) with the bad values
|
||
echoed (``!r`` — an absent value prints as ``None``)."""
|
||
assert agent.BAD_RANGE_ARGS.format(offset=2.5, limit=None) == (
|
||
"`offset` and `limit` are whole line numbers — pass integers ≥ 1 "
|
||
"(you passed offset=2.5, limit=None)."
|
||
)
|
||
assert agent.BAD_RANGE_ARGS.format(offset=None, limit="10") == (
|
||
"`offset` and `limit` are whole line numbers — pass integers ≥ 1 "
|
||
"(you passed offset=None, limit='10')."
|
||
)
|
||
assert agent.BAD_RANGE_ARGS.format(offset=True, limit=None) == (
|
||
"`offset` and `limit` are whole line numbers — pass integers ≥ 1 "
|
||
"(you passed offset=True, limit=None)."
|
||
)
|
||
|
||
|
||
def test_reread_partial_is_pinned() -> None:
|
||
"""Phase 141 (task 02): the plain re-read teaching for a
|
||
PARTIALLY-covered document is pinned — RETIRING the phase-131
|
||
``REREAD_TRUNCATED`` line (its char-based ``{shown}`` copy is
|
||
replaced by the line-based truth): the covered span(s) in line
|
||
form, the RESOLVED identity, the true line count, and the working
|
||
workflow (grep reports line numbers → read that part with
|
||
``offset`` + ``limit``)."""
|
||
assert agent.REREAD_PARTIAL.format(covered="1–14", source="S", path="big.md", total=20) == (
|
||
"You have lines 1–14 of S/big.md in your context (20 lines "
|
||
"total). To read the rest, find where your text is with grep "
|
||
"(it reports line numbers), then call read with `offset` "
|
||
"(1-based first line) and `limit` (number of lines)."
|
||
)
|
||
# The retired char-based line no longer exists in the module.
|
||
assert not hasattr(agent, "REREAD_TRUNCATED")
|
||
|
||
|
||
def test_offset_past_end_is_pinned() -> None:
|
||
"""Phase 131 (task 02): the offset-past-end teaching is pinned —
|
||
the RESOLVED identity, the true line count, the grep pointer."""
|
||
assert agent.OFFSET_PAST_END.format(
|
||
source="S", path="a.md", total_lines=10, offset=11
|
||
) == (
|
||
"S/a.md is 10 lines long — `offset` 11 is past the end. Use a "
|
||
"smaller offset (grep reports the exact line numbers)."
|
||
)
|
||
|
||
|
||
def test_read_range_args_plain_and_valid() -> None:
|
||
"""Phase 131 (task 02): the pure validator — both absent is a plain
|
||
read; valid ints pass through (either one-sided is ok: ``offset``
|
||
without ``limit`` reads to the end, ``limit`` without ``offset``
|
||
counts from the start — the caller normalizes)."""
|
||
assert agent._read_range_args(None, None) == (True, "", None, None)
|
||
assert agent._read_range_args(1, None) == (True, "", 1, None)
|
||
assert agent._read_range_args(None, 7) == (True, "", None, 7)
|
||
assert agent._read_range_args(3, 42) == (True, "", 3, 42)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("offset", "limit"),
|
||
[
|
||
(2.5, None), # a float
|
||
("3", None), # a string
|
||
(True, None), # a bool (an int subclass — the house guard excludes it)
|
||
(0, None), # zero
|
||
(-1, None), # a negative
|
||
(None, 2.5), # a float limit
|
||
(None, "10"), # a string limit
|
||
(None, False), # a bool limit
|
||
(None, 0), # a zero limit
|
||
(None, -3), # a negative limit
|
||
],
|
||
)
|
||
def test_read_range_args_bad_values_are_refused(
|
||
offset: Any, limit: Any
|
||
) -> None:
|
||
"""Phase 131 (task 02): a PRESENT bad value (not a whole int ≥ 1 —
|
||
the house ``isinstance(x, int) and not isinstance(x, bool)`` guard,
|
||
or ``< 1``) refuses with the values echoed; the range comes back
|
||
unusable (``None``/``None``)."""
|
||
ok, line, got_offset, got_limit = agent._read_range_args(offset, limit)
|
||
assert not ok
|
||
assert got_offset is None and got_limit is None
|
||
assert line == agent.BAD_RANGE_ARGS.format(offset=offset, limit=limit)
|
||
|
||
|
||
# --- phase 141 (task 02): the coverage helpers (pure, no DB) ---
|
||
|
||
|
||
def test_covered_spans_merges_adjacent_overlapping_and_contained() -> None:
|
||
"""Phase 141 (task 02): ``covered_spans`` selects the RESOLVED
|
||
pair's records and MERGES them — ADJACENT spans join (``(1, 10) +
|
||
(11, 20)`` → ``(1, 20)``: adjacency is continuous coverage),
|
||
overlapping spans collapse to their union, a span CONTAINED in
|
||
another is absorbed, and the result is sorted by first line. The
|
||
input order is irrelevant; records for other documents are
|
||
ignored (the keying is the resolved pair)."""
|
||
records = [
|
||
("S", "a.md", 11, 20),
|
||
("S", "a.md", 1, 10),
|
||
("T", "a.md", 5, 6), # a different source — ignored
|
||
("S", "b.md", 4, 8), # a different path — ignored
|
||
]
|
||
assert agent.covered_spans(records, "S", "a.md") == [(1, 20)]
|
||
assert agent.covered_spans(records, "S", "b.md") == [(4, 8)]
|
||
assert agent.covered_spans(records, "S", "c.md") == []
|
||
# Overlap + containment: (2, 9) + (5, 7) + (1, 3) → (1, 9).
|
||
assert agent.covered_spans(
|
||
[("S", "x.md", 2, 9), ("S", "x.md", 5, 7), ("S", "x.md", 1, 3)],
|
||
"S",
|
||
"x.md",
|
||
) == [(1, 9)]
|
||
# A single record round-trips unchanged.
|
||
assert agent.covered_spans([("S", "y.md", 3, 3)], "S", "y.md") == [(3, 3)]
|
||
|
||
|
||
def test_covered_spans_disjoint_spans_stay_separate() -> None:
|
||
"""Phase 141 (task 02): spans with a GAP between them stay
|
||
separate (only adjacency/overlap merge) — the gap is uncovered,
|
||
and the span order is by first line regardless of read order."""
|
||
records = [("S", "a.md", 5, 6), ("S", "a.md", 1, 2), ("S", "a.md", 1, 1)]
|
||
assert agent.covered_spans(records, "S", "a.md") == [(1, 2), (5, 6)]
|
||
|
||
|
||
def test_fully_covered_inclusive_per_line() -> None:
|
||
"""Phase 141 (task 02): ``fully_covered`` is per line and
|
||
INCLUSIVE — the requested span is covered when ONE merged span
|
||
starts at or before ``first`` and ends at or after ``last``; a
|
||
span crossing a gap is NOT covered even when both endpoints fall
|
||
in covered spans; an empty union covers nothing."""
|
||
assert agent.fully_covered([(1, 20)], 1, 20)
|
||
assert agent.fully_covered([(1, 20)], 5, 7)
|
||
assert agent.fully_covered([(5, 7)], 5, 7)
|
||
assert not agent.fully_covered([(1, 20)], 1, 21)
|
||
assert not agent.fully_covered([(1, 20)], 21, 22)
|
||
assert not agent.fully_covered([], 1, 1)
|
||
# The gap edge: 2-5 crosses the uncovered 3-4 between (1, 2) and
|
||
# (5, 6) → NOT covered; 1-5 and 5-5 are covered within one span.
|
||
assert not agent.fully_covered([(1, 2), (5, 6)], 2, 5)
|
||
assert not agent.fully_covered([(1, 2), (5, 6)], 1, 5)
|
||
assert agent.fully_covered([(1, 2), (5, 6)], 5, 5)
|
||
assert agent.fully_covered([(1, 2), (5, 6)], 1, 2)
|
||
|
||
|
||
def test_format_covered_spans_rendering() -> None:
|
||
"""Phase 141 (task 02): the ``{covered}`` rendering — one span →
|
||
the bare ``a–b`` (the en dash), a single line → the bare ``a``,
|
||
multiple merged spans → comma-joined pairs in span order."""
|
||
assert agent.format_covered_spans([(1, 14)]) == "1–14"
|
||
assert agent.format_covered_spans([(7, 7)]) == "7"
|
||
assert agent.format_covered_spans([(1, 2), (5, 6)]) == "1–2, 5–6"
|
||
assert agent.format_covered_spans([]) == ""
|
||
|
||
|
||
def test_bad_range_args_refused_before_any_lookup(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 131 (task 02): the validation refusal fires in the flow —
|
||
BEFORE the dedupe and the DB resolve (a float ``offset`` with an
|
||
unknown path still gets the range refusal, not the no-document
|
||
line): counts in nothing, consumes a round, the tools stay
|
||
offered."""
|
||
|
||
def _boom(*_a: Any, **_k: Any) -> None:
|
||
raise AssertionError("no DB lookup for a bad range argument")
|
||
|
||
monkeypatch.setattr(agent, "find_document", _boom)
|
||
monkeypatch.setattr(agent, "all_documents", _boom)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1", name="read", arguments={"path": "S/ghost.md", "offset": 2.5}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
assert llm.requests[1][0][3]["content"] == agent.BAD_RANGE_ARGS.format(
|
||
offset=2.5, limit=None
|
||
)
|
||
assert llm.requests[1][1] == AGENT_TOOLS
|
||
|
||
|
||
def test_bad_range_args_beats_the_dedupe(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 131 (task 02): a bad range argument on a document ALREADY
|
||
IN CONTEXT is the range refusal, not ``ALREADY_IN_CONTEXT`` — the
|
||
validation precedes the dedupe (a refusal either way: no count,
|
||
no re-append)."""
|
||
doc = _doc("S", "a.md", "A", "A-CONTENT")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/a.md"})],
|
||
[
|
||
ToolCallPiece(
|
||
id="c2", name="read", arguments={"path": "S/a.md", "offset": 0}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
assert llm.requests[2][0][5]["content"] == agent.BAD_RANGE_ARGS.format(
|
||
offset=0, limit=None
|
||
)
|
||
|
||
|
||
def test_offset_past_end_refused_naming_line_count(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 131 (task 02): ``offset`` = total+1 on a 10-line doc →
|
||
the pinned teaching (the true line count named, the grep pointer)
|
||
— a refusal: nothing counted, the doc NOT added to the context (a
|
||
corrected retry still proceeds)."""
|
||
doc = _lines_doc("S", "ten.md", 10)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1", name="read", arguments={"path": "S/ten.md", "offset": 11}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
assert llm.requests[1][0][3]["content"] == agent.OFFSET_PAST_END.format(
|
||
source="S", path="ten.md", total_lines=10, offset=11
|
||
)
|
||
|
||
|
||
def test_offset_past_end_on_one_line_doc(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 131 (task 02): a 1-line doc — ``offset=2`` is past the
|
||
end (the true count is 1); ``offset=1`` with an overshooting
|
||
``limit`` clamps to the single line."""
|
||
doc = _doc("S", "one.md", "One", "only")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
# (a) offset past the end of the 1-line doc.
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1", name="read", arguments={"path": "S/one.md", "offset": 2}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == agent.OFFSET_PAST_END.format(
|
||
source="S", path="one.md", total_lines=1, offset=2
|
||
)
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
# (b) the same doc read from line 1, limit overshoots — clamped.
|
||
holder2 = AgentHolder()
|
||
llm2 = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/one.md", "offset": 1, "limit": 5},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm2, holder2, _settings()))
|
||
assert llm2.requests[1][0][3]["content"] == (
|
||
"Document S/one.md:\ndate: 2024-06-15\nlines: 1-1 of 1\nonly"
|
||
)
|
||
assert holder2.read_docs == [doc] and holder2.tool_calls == 1
|
||
|
||
|
||
# --- the six-row dedupe matrix (one test per row) ---
|
||
|
||
|
||
def test_matrix_row1_plain_read_unread_doc_reads_full(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Matrix row 1: plain ``read(path)``, doc NOT in ``read_docs`` →
|
||
the full read (truncate at the cap as today — the pre-phase-131
|
||
path, no ``lines:`` header line)."""
|
||
doc = _doc("S", "m1.md", "M1", "one\ntwo\nthree")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/m1.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/m1.md:\ndate: 2024-06-15\none\ntwo\nthree"
|
||
)
|
||
|
||
|
||
def test_matrix_row2_plain_read_truncated_doc_gets_reread_teaching(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Matrix row 2 (the stuck scenario's first hop): a plain re-read
|
||
of a document whose first read TRUNCATED → the pinned
|
||
``REREAD_PARTIAL`` teaching naming the covered span — the
|
||
truncated first read covers lines 1–14 (the cut counted from the
|
||
actual cut string) of the 20-line doc (RE-PINED phase 141: the
|
||
char-based ``REREAD_TRUNCATED`` line is retired, the
|
||
``{total}`` comes from the REAL document) — a refusal: no
|
||
re-emit, no count. The raw-arg fast path is RETIRED (phase 141 —
|
||
the true line count only the resolve knows), so the re-read
|
||
resolves the document: TWO lookups, not one."""
|
||
cap = 100
|
||
doc = _lines_doc("S", "m2.md", 20) # 150 chars > the cap
|
||
lookups: list[tuple[str, str]] = []
|
||
|
||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||
lookups.append((source, path))
|
||
return doc if (source, path) == ("S", "m2.md") else None
|
||
|
||
monkeypatch.setattr(agent, "find_document", _find)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/m2.md"})],
|
||
[ToolCallPiece(id="c2", name="read", arguments={"path": "S/m2.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
# Round 1: the truncated plain read (the stuck trigger) — the
|
||
# pre-phase-131 truncation bytes, the holder tuple recorded.
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/m2.md:\ndate: 2024-06-15\n"
|
||
+ doc.content[:cap]
|
||
+ "\n"
|
||
+ TRUNCATION_MARKER
|
||
+ "\n"
|
||
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))
|
||
)
|
||
assert holder.read_truncations == [("S/m2.md", cap, len(doc.content))]
|
||
# Round 2: the plain re-read → the teaching — the covered span
|
||
# (1–14: the cut's truth) named, the true line count from the
|
||
# resolved document.
|
||
assert llm.requests[2][0][5]["content"] == agent.REREAD_PARTIAL.format(
|
||
covered="1–14", source="S", path="m2.md", total=20
|
||
)
|
||
# A refusal: the counters stay at the first read's, no re-append;
|
||
# the re-read RESOLVED first (the fast path is retired) — two
|
||
# lookups.
|
||
assert holder.read_docs == [doc]
|
||
assert holder.tool_calls == 1
|
||
assert lookups == [("S", "m2.md"), ("S", "m2.md")]
|
||
|
||
|
||
def test_matrix_row3_plain_read_full_doc_stays_already_in_context(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Matrix row 3: a plain re-read of a document read IN FULL (never
|
||
truncated) → ``ALREADY_IN_CONTEXT`` — the byte-identical line,
|
||
the row's observable contract unchanged by the phase-141
|
||
rework. The raw-arg fast path is RETIRED (the merged span == the
|
||
whole document, but only the resolve knows ``total_lines``), so
|
||
the re-read RESOLVES first — TWO lookups, not one (the
|
||
fast-path-retirement pin)."""
|
||
doc = _doc("S", "m3.md", "M3", "FULL-TEXT") # fits under any cap
|
||
lookups: list[tuple[str, str]] = []
|
||
|
||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||
lookups.append((source, path))
|
||
return doc if (source, path) == ("S", "m3.md") else None
|
||
|
||
monkeypatch.setattr(agent, "find_document", _find)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/m3.md"})],
|
||
[ToolCallPiece(id="c2", name="read", arguments={"path": "S/m3.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
# The re-read now goes through the resolve (the merged span (1, 1)
|
||
# == the whole 1-line doc makes the claim true) — two lookups.
|
||
assert lookups == [("S", "m3.md"), ("S", "m3.md")]
|
||
|
||
|
||
def test_matrix_row4_range_read_truncated_doc_matrix(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Matrix row 4 (the stuck scenario, re-pinned phase 141 — the
|
||
coverage-true form of the phase-131 truncated-then-range row): the
|
||
TRUNCATED plain read of this 20-line doc covers lines 1–14 (the
|
||
cut counted from the actual cut string) — so a range read of lines
|
||
INSIDE that span (5-7) is refused with ``ALREADY_IN_CONTEXT``
|
||
(the claim is now TRUE: those lines ARE in the prompt — the
|
||
pre-phase-141 pair-set matrix refused them too, but for the wrong
|
||
reason), while a range read of the part the truncated read did NOT
|
||
reach (15-16 — the stuck scenario's fix) PROCEEDS with the
|
||
``lines:`` header + exactly the named lines. The doc was appended
|
||
ONCE; the proceeding range read counts; the slice fits, so no new
|
||
truncation tuple."""
|
||
cap = 100
|
||
doc = _lines_doc("S", "m4.md", 20) # 150 chars > the cap
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/m4.md"})],
|
||
[
|
||
ToolCallPiece(
|
||
id="c2",
|
||
name="read",
|
||
arguments={"path": "S/m4.md", "offset": 5, "limit": 3},
|
||
)
|
||
],
|
||
[
|
||
ToolCallPiece(
|
||
id="c3",
|
||
name="read",
|
||
arguments={"path": "S/m4.md", "offset": 15, "limit": 2},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
# Round 1: the truncated plain read (covers lines 1–14).
|
||
assert llm.requests[1][0][3]["content"].endswith(
|
||
"\n"
|
||
+ TRUNCATION_MARKER
|
||
+ "\n"
|
||
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))
|
||
)
|
||
# Round 2: the range read of lines INSIDE the covered span (5-7 ⊆
|
||
# 1-14) is refused — the lines ARE in the prompt, the claim is
|
||
# true.
|
||
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
|
||
# Round 3: the range read of the part the truncated read did NOT
|
||
# reach (15-16) PROCEEDS — the stuck-scenario fix — lines 15-16
|
||
# of 20, exactly.
|
||
assert llm.requests[3][0][7]["content"] == (
|
||
"Document S/m4.md:\ndate: 2024-06-15\nlines: 15-16 of 20\n"
|
||
"line 15\nline 16"
|
||
)
|
||
assert holder.read_docs == [doc] # appended exactly once
|
||
assert holder.tool_calls == 2 # the refusal counted nothing
|
||
assert holder.read_truncations == [("S/m4.md", cap, len(doc.content))]
|
||
assert holder.read_coverage == [("S", "m4.md", 1, 14), ("S", "m4.md", 15, 16)]
|
||
|
||
|
||
def test_matrix_row5_range_read_full_doc_stays_already_in_context(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Matrix row 5: a range read of a document read IN FULL (never
|
||
truncated) → ``ALREADY_IN_CONTEXT`` — the range is a SUBSET of the
|
||
text already in the prompt (the matrix needs the resolved pair, so
|
||
the check runs after the resolve)."""
|
||
doc = _lines_doc("S", "m5.md", 5)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/m5.md"})],
|
||
[
|
||
ToolCallPiece(
|
||
id="c2",
|
||
name="read",
|
||
arguments={"path": "S/m5.md", "offset": 2, "limit": 2},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
assert holder.read_truncations == []
|
||
|
||
|
||
def test_matrix_row6_range_read_unread_doc_proceeds(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Matrix row 6: a range read of a NOT-yet-read document PROCEEDS —
|
||
the ``lines:`` header + the slice, the doc appended, the call
|
||
counted (a first read, plain or range, behaves like a first read)."""
|
||
doc = _lines_doc("S", "m6.md", 8)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/m6.md", "offset": 3, "limit": 2},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/m6.md:\ndate: 2024-06-15\nlines: 3-4 of 8\nline 3\nline 4"
|
||
)
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
|
||
|
||
def test_matrix_row_range_read_of_different_range_proceeds(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 02) — THE OWNER'S BUG, the matrix row that was
|
||
false pre-phase: after a range read of lines 5-7, a range read of
|
||
a DIFFERENT range (lines 10-11) of the SAME document PROCEEDS —
|
||
the pre-phase pair-set dedupe refused it with ``ALREADY_IN_CONTEXT``
|
||
(the false "full text is in your prompt" claim that dead-ended the
|
||
owner's stuck turn). A THIRD read of the already-covered lines 5-7
|
||
is refused with ``ALREADY_IN_CONTEXT`` — the claim is now TRUE
|
||
(those exact lines ARE in the prompt). The doc is appended ONCE;
|
||
the second range read counts; both spans are recorded in read
|
||
order."""
|
||
doc = _lines_doc("S", "m7.md", 12)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/m7.md", "offset": 5, "limit": 3},
|
||
)
|
||
],
|
||
[
|
||
ToolCallPiece(
|
||
id="c2",
|
||
name="read",
|
||
arguments={"path": "S/m7.md", "offset": 10, "limit": 2},
|
||
)
|
||
],
|
||
[
|
||
ToolCallPiece(
|
||
id="c3",
|
||
name="read",
|
||
arguments={"path": "S/m7.md", "offset": 5, "limit": 3},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/m7.md:\ndate: 2024-06-15\nlines: 5-7 of 12\n"
|
||
"line 5\nline 6\nline 7"
|
||
)
|
||
# Round 2: the DIFFERENT range PROCEEDS (the owner's stuck
|
||
# scenario, fixed — both ranges reach the model).
|
||
assert llm.requests[2][0][5]["content"] == (
|
||
"Document S/m7.md:\ndate: 2024-06-15\nlines: 10-11 of 12\n"
|
||
"line 10\nline 11"
|
||
)
|
||
# Round 3: the re-read of FULLY-covered lines 5-7 is refused —
|
||
# the claim in the byte-identical line is now true.
|
||
assert llm.requests[3][0][7]["content"] == agent.ALREADY_IN_CONTEXT
|
||
assert holder.read_docs == [doc] # appended exactly once
|
||
assert holder.tool_calls == 2 # the refusal counted nothing
|
||
assert holder.read_coverage == [("S", "m7.md", 5, 7), ("S", "m7.md", 10, 11)]
|
||
|
||
|
||
def test_matrix_row_disjoint_spans_gap_plain_teaching_and_merge(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 02) — the span-merge edges end to end: two
|
||
DISJOINT range reads (1-2 and 5-6 of an 8-line doc) leave lines
|
||
3-4 uncovered. A PLAIN re-read gets ``REREAD_PARTIAL`` naming BOTH
|
||
merged spans (``1–2, 5–6`` — the multi-span rendering, the
|
||
``{total}`` from the real doc). A range read SPANNING THE GAP
|
||
(2-5) is NOT fully covered → PROCEEDS (lines 3-4 are new), and
|
||
after it the merged union is (1, 6) — so a range read of 1-6 is
|
||
now FULLY covered → ``ALREADY_IN_CONTEXT`` (the merge made the
|
||
claim true)."""
|
||
doc = _lines_doc("S", "m8.md", 8)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/m8.md", "offset": 1, "limit": 2},
|
||
)
|
||
],
|
||
[
|
||
ToolCallPiece(
|
||
id="c2",
|
||
name="read",
|
||
arguments={"path": "S/m8.md", "offset": 5, "limit": 2},
|
||
)
|
||
],
|
||
[ToolCallPiece(id="c3", name="read", arguments={"path": "S/m8.md"})],
|
||
[
|
||
ToolCallPiece(
|
||
id="c4",
|
||
name="read",
|
||
arguments={"path": "S/m8.md", "offset": 2, "limit": 4},
|
||
)
|
||
],
|
||
[
|
||
ToolCallPiece(
|
||
id="c5",
|
||
name="read",
|
||
arguments={"path": "S/m8.md", "offset": 1, "limit": 6},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
# Rounds 1-2: the two disjoint range reads (both proceed — first
|
||
# reads of their spans).
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/m8.md:\ndate: 2024-06-15\nlines: 1-2 of 8\nline 1\nline 2"
|
||
)
|
||
assert llm.requests[2][0][5]["content"] == (
|
||
"Document S/m8.md:\ndate: 2024-06-15\nlines: 5-6 of 8\nline 5\nline 6"
|
||
)
|
||
# Round 3: the plain re-read of the PARTIALLY-covered doc — BOTH
|
||
# merged spans named (comma-joined, en dash), the true total.
|
||
assert llm.requests[3][0][7]["content"] == agent.REREAD_PARTIAL.format(
|
||
covered="1–2, 5–6", source="S", path="m8.md", total=8
|
||
)
|
||
# Round 4: the range SPANNING THE GAP (2-5 crosses the uncovered
|
||
# 3-4) is NOT fully covered → PROCEEDS.
|
||
assert llm.requests[4][0][9]["content"] == (
|
||
"Document S/m8.md:\ndate: 2024-06-15\nlines: 2-5 of 8\n"
|
||
"line 2\nline 3\nline 4\nline 5"
|
||
)
|
||
# Round 5: after the merge ((1, 2) + (2, 5) + (5, 6) → (1, 6)),
|
||
# 1-6 is FULLY covered → the (now true) refusal.
|
||
assert llm.requests[5][0][11]["content"] == agent.ALREADY_IN_CONTEXT
|
||
assert holder.read_docs == [doc]
|
||
assert holder.tool_calls == 3 # the two refusals counted nothing
|
||
assert holder.read_coverage == [
|
||
("S", "m8.md", 1, 2),
|
||
("S", "m8.md", 5, 6),
|
||
("S", "m8.md", 2, 5),
|
||
]
|
||
|
||
|
||
def test_matrix_row_adjacent_spans_merge_into_full_coverage(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 02) — adjacency is continuous coverage: range
|
||
reads of 1-3 and 4-6 of a 6-line doc MERGE into (1, 6) — so a
|
||
range read INSIDE the merged span (2-5) is refused with
|
||
``ALREADY_IN_CONTEXT``, and a PLAIN re-read (the merged span ==
|
||
the whole document) is refused with the same line (the full-doc
|
||
row via the merge — no single read covered the whole doc, the
|
||
MERGE did)."""
|
||
doc = _lines_doc("S", "m9.md", 6)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/m9.md", "offset": 1, "limit": 3},
|
||
)
|
||
],
|
||
[
|
||
ToolCallPiece(
|
||
id="c2",
|
||
name="read",
|
||
arguments={"path": "S/m9.md", "offset": 4, "limit": 3},
|
||
)
|
||
],
|
||
[
|
||
ToolCallPiece(
|
||
id="c3",
|
||
name="read",
|
||
arguments={"path": "S/m9.md", "offset": 2, "limit": 4},
|
||
)
|
||
],
|
||
[ToolCallPiece(id="c4", name="read", arguments={"path": "S/m9.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
# Rounds 1-2: the adjacent range reads (both proceed).
|
||
assert llm.requests[1][0][3]["content"].startswith(
|
||
"Document S/m9.md:\ndate: 2024-06-15\nlines: 1-3 of 6\n"
|
||
)
|
||
assert llm.requests[2][0][5]["content"].startswith(
|
||
"Document S/m9.md:\ndate: 2024-06-15\nlines: 4-6 of 6\n"
|
||
)
|
||
# Round 3: 2-5 sits inside the merged (1, 6) → refused.
|
||
assert llm.requests[3][0][7]["content"] == agent.ALREADY_IN_CONTEXT
|
||
# Round 4: the plain re-read — the merged span IS the whole doc
|
||
# (1, 6) → the full-doc row, the same line.
|
||
assert llm.requests[4][0][9]["content"] == agent.ALREADY_IN_CONTEXT
|
||
assert holder.read_docs == [doc]
|
||
assert holder.tool_calls == 2
|
||
assert holder.read_coverage == [("S", "m9.md", 1, 3), ("S", "m9.md", 4, 6)]
|
||
|
||
|
||
def test_scenario_adjacent_reads_cover_half_doc_teaching_and_rest_proceed(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 03) — the adjacent-span edge as a SCENARIO
|
||
(the task 02 row pins, end to end over a 200-line doc): two
|
||
ADJACENT range reads — 1-60 then 61-100 — together cover lines
|
||
1–100 of the 200-line document (the merge: adjacent spans join
|
||
into one). A PLAIN re-read gets ``REREAD_PARTIAL`` naming the
|
||
MERGED span (``1–100`` — one span, not two: the merge collapsed
|
||
the adjacency) and the true total (200); a read of the REST
|
||
(``offset=101`` — no ``limit``, to the end) PROCEEDS with
|
||
lines 101-200 (the owner's stuck-scenario fix on a document
|
||
whose first half was read in two adjacent hops: the covered
|
||
span stops at line 100, the rest is not in context)."""
|
||
doc = _lines_doc("S", "half.md", 200)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/half.md", "offset": 1, "limit": 60},
|
||
)
|
||
],
|
||
[
|
||
ToolCallPiece(
|
||
id="c2",
|
||
name="read",
|
||
arguments={"path": "S/half.md", "offset": 61, "limit": 40},
|
||
)
|
||
],
|
||
[ToolCallPiece(id="c3", name="read", arguments={"path": "S/half.md"})],
|
||
[
|
||
ToolCallPiece(
|
||
id="c4",
|
||
name="read",
|
||
arguments={"path": "S/half.md", "offset": 101},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
# Rounds 1-2: the adjacent range reads (both proceed — first
|
||
# reads of their spans).
|
||
assert llm.requests[1][0][3]["content"].startswith(
|
||
"Document S/half.md:\ndate: 2024-06-15\nlines: 1-60 of 200\n"
|
||
)
|
||
assert llm.requests[2][0][5]["content"].startswith(
|
||
"Document S/half.md:\ndate: 2024-06-15\nlines: 61-100 of 200\n"
|
||
)
|
||
# Round 3: the plain re-read — the MERGED span named as one
|
||
# (adjacent spans join: 1–100), the true total from the resolved
|
||
# doc.
|
||
assert llm.requests[3][0][7]["content"] == agent.REREAD_PARTIAL.format(
|
||
covered="1–100", source="S", path="half.md", total=200
|
||
)
|
||
# Round 4: the REST of the document (offset=101, no limit → to
|
||
# the end) PROCEEDS — the covered span stops at line 100.
|
||
assert llm.requests[4][0][9]["content"] == (
|
||
"Document S/half.md:\ndate: 2024-06-15\nlines: 101-200 of 200\n"
|
||
+ "\n".join(f"line {i}" for i in range(101, 201))
|
||
)
|
||
assert holder.read_docs == [doc]
|
||
assert holder.tool_calls == 3 # the teaching refusal counted nothing
|
||
assert holder.read_coverage == [
|
||
("S", "half.md", 1, 60),
|
||
("S", "half.md", 61, 100),
|
||
("S", "half.md", 101, 200),
|
||
]
|
||
|
||
|
||
# --- the slice math + the result shape ---
|
||
|
||
|
||
def test_range_read_offset_one_no_limit_reads_whole_doc(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 131 (task 02): ``offset=1`` without ``limit`` reads to the
|
||
end of the document — the true extent ``1-N of N``, the whole
|
||
content (the slice, not the plain-read shape)."""
|
||
doc = _doc("S", "r1.md", "R1", "a\nb\nc")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1", name="read", arguments={"path": "S/r1.md", "offset": 1}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/r1.md:\ndate: 2024-06-15\nlines: 1-3 of 3\na\nb\nc"
|
||
)
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
|
||
|
||
def test_range_read_offset_at_total_reads_last_line(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 131 (task 02): ``offset`` = the last line number →
|
||
exactly the last line (``N-N of N``)."""
|
||
doc = _doc("S", "r2.md", "R2", "a\nb\nc")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/r2.md", "offset": 3, "limit": 5},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/r2.md:\ndate: 2024-06-15\nlines: 3-3 of 3\nc"
|
||
)
|
||
|
||
|
||
def test_range_read_limit_overshoot_clamps_to_total(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 131 (task 02): an overshooting ``limit`` clamps ``end`` to
|
||
the last line (reads to the end, never past it)."""
|
||
doc = _doc("S", "r3.md", "R3", "a\nb\nc")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/r3.md", "offset": 2, "limit": 10},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/r3.md:\ndate: 2024-06-15\nlines: 2-3 of 3\nb\nc"
|
||
)
|
||
|
||
|
||
def test_range_read_limit_without_offset_counts_from_start(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 131 (task 02): ``limit`` WITHOUT ``offset`` is a line count
|
||
from the start of the document (offset = 1 — the helper's
|
||
documented one-sided semantics)."""
|
||
doc = _doc("S", "r4.md", "R4", "a\nb\nc")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1", name="read", arguments={"path": "S/r4.md", "limit": 2}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/r4.md:\ndate: 2024-06-15\nlines: 1-2 of 3\na\nb"
|
||
)
|
||
|
||
|
||
def test_range_read_image_doc_carries_marker_after_lines_line(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 131 (task 02): a range read of a standalone-IMAGE document
|
||
carries the :data:`agent.IMAGE_DOC_MARKER` line exactly where the
|
||
plain read puts it — after the header lines (here, after
|
||
``lines:``)."""
|
||
doc = _doc("S", "img.png", "Img", "IMG-DESC-OF-LINE-1\nline-2-text")
|
||
doc.is_image = True
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/img.png", "offset": 2, "limit": 1},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/img.png:\ndate: 2024-06-15\nlines: 2-2 of 2\n"
|
||
f"{agent.IMAGE_DOC_MARKER}\n"
|
||
"line-2-text"
|
||
)
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
|
||
|
||
def test_range_read_slice_over_cap_truncates_with_slice_length_total(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 131 (task 02): the cap applies to the SLICE — an over-cap
|
||
slice is cut at the cap + the shared marker + the pinned notice
|
||
(``{total}`` = the SLICE's length), and the holder tuple's
|
||
``chars_total`` is the slice's length, NOT the document's (the
|
||
loop still yields one ToolResultPiece for it)."""
|
||
cap = 20
|
||
lines = [f"L{i:02d}" + "x" * 30 for i in range(1, 11)] # 10 lines × 33 chars
|
||
content = "\n".join(lines)
|
||
doc = _doc("S", "cap.md", "Cap", content)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/cap.md", "offset": 5, "limit": 6},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
# The slice: lines 5-10 (6 × 33 chars + 5 newlines = 203 > cap).
|
||
slice_text = "\n".join(lines[4:10])
|
||
assert len(slice_text) == 203
|
||
assert len(content) == 339 # the document is much bigger
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/cap.md:\ndate: 2024-06-15\nlines: 5-10 of 10\n"
|
||
+ slice_text[:cap]
|
||
+ "\n"
|
||
+ TRUNCATION_MARKER
|
||
+ "\n"
|
||
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text))
|
||
)
|
||
# The tuple: the argument, the cap kept, the SLICE's length (203,
|
||
# not the document's 339).
|
||
assert holder.read_truncations == [("S/cap.md", cap, 203)]
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
# The loop yields the ToolResultPiece with the slice's total.
|
||
pieces = [p for p in out if isinstance(p, ToolResultPiece)]
|
||
assert len(pieces) == 1
|
||
assert pieces[0].argument == "S/cap.md"
|
||
assert pieces[0].chars_shown == cap
|
||
assert pieces[0].chars_total == 203
|
||
|
||
|
||
def test_range_read_truncated_then_plain_reread_teaching_then_range_proceeds(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 131 (task 02), re-pinned phase 141: a TRUNCATED range
|
||
read (offset=1, no limit — the whole 339-char doc under a 20-char
|
||
cap) records the slice tuple; a plain re-read then gets
|
||
``REREAD_PARTIAL`` naming the covered span — LINE 1 ONLY (the
|
||
cut of the truncated range read covers its one visible line; the
|
||
single-line rendering is the bare ``a``) with the true line
|
||
count (RE-PINED: the char-based ``REREAD_TRUNCATED`` line is
|
||
retired); a subsequent range read still PROCEEDS (the truncated-
|
||
then-range row — the requested 5-6 is not covered)."""
|
||
cap = 20
|
||
lines = [f"L{i:02d}" + "x" * 30 for i in range(1, 11)]
|
||
content = "\n".join(lines)
|
||
doc = _doc("S", "t.md", "T", content)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1", name="read", arguments={"path": "S/t.md", "offset": 1}
|
||
)
|
||
],
|
||
[ToolCallPiece(id="c2", name="read", arguments={"path": "S/t.md"})],
|
||
[
|
||
ToolCallPiece(
|
||
id="c3",
|
||
name="read",
|
||
arguments={"path": "S/t.md", "offset": 5, "limit": 2},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
# Round 1: the truncated range read (the whole doc, offset=1) —
|
||
# the slice is the whole doc here (339 chars), the tuple's total
|
||
# is the slice's length.
|
||
assert llm.requests[1][0][3]["content"].startswith(
|
||
"Document S/t.md:\ndate: 2024-06-15\nlines: 1-10 of 10\n"
|
||
)
|
||
assert llm.requests[1][0][3]["content"].endswith(
|
||
"\n"
|
||
+ TRUNCATION_MARKER
|
||
+ "\n"
|
||
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=339)
|
||
)
|
||
# Round 2: the plain re-read → the teaching — the covered span
|
||
# (line 1 only: the cut string's splitlines() is one line) named,
|
||
# the true line count from the resolved document.
|
||
assert llm.requests[2][0][5]["content"] == agent.REREAD_PARTIAL.format(
|
||
covered="1", source="S", path="t.md", total=10
|
||
)
|
||
# Round 3: the range read of the truncated doc PROCEEDS — lines 5-6
|
||
# (66 + 1 = 67 chars > the cap → truncated again; the tuple's total
|
||
# is the slice's length, the document's is irrelevant).
|
||
slice_text = "\n".join(lines[4:6])
|
||
assert llm.requests[3][0][7]["content"] == (
|
||
"Document S/t.md:\ndate: 2024-06-15\nlines: 5-6 of 10\n"
|
||
+ slice_text[:cap]
|
||
+ "\n"
|
||
+ TRUNCATION_MARKER
|
||
+ "\n"
|
||
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text))
|
||
)
|
||
assert holder.read_truncations == [("S/t.md", cap, 339), ("S/t.md", cap, 67)]
|
||
assert holder.read_docs == [doc] # appended exactly once, still
|
||
assert holder.tool_calls == 2 # the refusal counted nothing
|
||
|
||
|
||
def test_plain_read_after_range_read_gets_partial_teaching(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 131 (task 02), re-pinned phase 141 (task 02): a range
|
||
read covers ONLY its own span (lines 1-2 of a 3-line doc), so a
|
||
later PLAIN read of the document is the PARTIAL row —
|
||
``REREAD_PARTIAL`` naming the covered span (``1–2``) and the true
|
||
total (RE-PINED: the pre-phase-141 ``ALREADY_IN_CONTEXT`` line
|
||
claimed the FULL text was in the prompt — false, only 2 of 3
|
||
lines were; the false full-text claim is gone in BOTH refusal
|
||
directions). The doc is still appended to ``read_docs`` ONCE
|
||
(the read-once bookkeeping is unchanged)."""
|
||
doc = _doc("S", "p.md", "P", "a\nb\nc")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/p.md", "offset": 1, "limit": 2},
|
||
)
|
||
],
|
||
[ToolCallPiece(id="c2", name="read", arguments={"path": "S/p.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/p.md:\ndate: 2024-06-15\nlines: 1-2 of 3\na\nb"
|
||
)
|
||
assert llm.requests[2][0][5]["content"] == agent.REREAD_PARTIAL.format(
|
||
covered="1–2", source="S", path="p.md", total=3
|
||
)
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
|
||
|
||
# --- phase 141 (task 01): coverage tracking (which lines a read put
|
||
# --- in the model's context — the state task 02's dedupe matrix
|
||
# --- decides on) ---
|
||
|
||
|
||
def test_coverage_plain_read_under_cap_records_full_span(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): a plain read at or under the cap covers the
|
||
WHOLE document — one span ``(source, path, 1, total_lines)`` keyed
|
||
by the RESOLVED pair (the same pair ``read_docs`` keys on). The
|
||
existing signals stay byte-identical (unchanged meaning)."""
|
||
doc = _lines_doc("S", "cv1.md", 4)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/cv1.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/cv1.md:\ndate: 2024-06-15\nline 1\nline 2\nline 3\nline 4"
|
||
)
|
||
assert holder.read_coverage == [("S", "cv1.md", 1, 4)]
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
assert holder.read_truncations == []
|
||
|
||
|
||
def test_coverage_truncated_plain_read_records_cut_span(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): a truncated plain read covers from line 1
|
||
to the last line visible in the ACTUAL cut string — ``len(
|
||
content[:cap].splitlines())`` counted from the cut (the char cap
|
||
is never re-derived into lines arithmetically). The 150-char
|
||
20-line fixture under a 100-char cap cuts mid-line 14 (its
|
||
visible prefix ``li`` is the last line the model saw), so the
|
||
span is ``(1, 14)``."""
|
||
cap = 100
|
||
doc = _lines_doc("S", "cv2.md", 20) # 150 chars > the cap
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/cv2.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
assert len(doc.content[:cap].splitlines()) == 14 # the cut's truth
|
||
assert holder.read_coverage == [("S", "cv2.md", 1, 14)]
|
||
assert holder.read_truncations == [("S/cv2.md", cap, len(doc.content))]
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
|
||
|
||
def test_coverage_truncated_plain_read_cut_at_line_boundary(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): the boundary sub-case of the same
|
||
counted-from-truth rule — a cap that lands EXACTLY on a line
|
||
boundary (the cut ends with the line's newline): the last visible
|
||
line is complete and the span ends there. ``aa\nbb\ncc\ndd``
|
||
under a 6-char cap cuts ``aa\nbb\n`` → the span is ``(1, 2)``."""
|
||
cap = 6
|
||
doc = _doc("S", "cv3.md", "Cv3", "aa\nbb\ncc\ndd") # 11 chars > cap
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/cv3.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/cv3.md:\ndate: 2024-06-15\n"
|
||
+ doc.content[:cap]
|
||
+ "\n"
|
||
+ TRUNCATION_MARKER
|
||
+ "\n"
|
||
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))
|
||
)
|
||
assert holder.read_coverage == [("S", "cv3.md", 1, 2)]
|
||
assert holder.read_truncations == [("S/cv3.md", cap, 11)]
|
||
|
||
|
||
def test_coverage_range_read_under_cap_records_true_extent(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): a range read at or under the cap covers
|
||
exactly the TRUE extent the ``lines:`` header reports —
|
||
``(first, end)``. An overshooting ``limit`` clamps ``end`` to the
|
||
last line (the span never goes past it); ``offset`` without
|
||
``limit`` reads to the end; ``limit`` without ``offset`` counts
|
||
from line 1."""
|
||
doc = _doc("S", "cv4.md", "Cv4", "a\nb\nc\nd")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
# (a) offset=2 + an overshooting limit → the clamped extent 2-4.
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/cv4.md", "offset": 2, "limit": 10},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/cv4.md:\ndate: 2024-06-15\nlines: 2-4 of 4\nb\nc\nd"
|
||
)
|
||
assert holder.read_coverage == [("S", "cv4.md", 2, 4)]
|
||
# (b) offset without limit → reads to the end (2-3 of 3).
|
||
doc3 = _doc("S", "cv4b.md", "Cv4b", "a\nb\nc")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc3)
|
||
holder2 = AgentHolder()
|
||
llm2 = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1", name="read", arguments={"path": "S/cv4b.md", "offset": 2}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm2, holder2, _settings()))
|
||
assert holder2.read_coverage == [("S", "cv4b.md", 2, 3)]
|
||
# (c) limit without offset → a line count from the start (1-2 of 4
|
||
# on the 4-line doc).
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder3 = AgentHolder()
|
||
llm3 = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1", name="read", arguments={"path": "S/cv4.md", "limit": 2}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm3, holder3, _settings()))
|
||
assert llm3.requests[1][0][3]["content"] == (
|
||
"Document S/cv4.md:\ndate: 2024-06-15\nlines: 1-2 of 4\na\nb"
|
||
)
|
||
assert holder3.read_coverage == [("S", "cv4.md", 1, 2)]
|
||
|
||
|
||
def test_coverage_truncated_range_read_records_cut_extent(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): a truncated RANGE read covers from
|
||
``first`` to the last line visible in the cut SLICE — ``first - 1
|
||
+ len(slice[:cap].splitlines())``, clamped to ``end`` (the same
|
||
counted-from-truth rule as the plain read — ``slice_text[:cap]``
|
||
is the actual cut string the model saw). The 203-char lines-5-10
|
||
slice under a 20-char cap cuts mid-line-5 (``L05`` + 17 ``x``),
|
||
so the span is ``(5, 5)``."""
|
||
cap = 20
|
||
lines = [f"L{i:02d}" + "x" * 30 for i in range(1, 11)] # 10 × 33 chars
|
||
doc = _doc("S", "cv5.md", "Cv5", "\n".join(lines))
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/cv5.md", "offset": 5, "limit": 6},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
slice_text = "\n".join(lines[4:10]) # 203 chars > the cap
|
||
assert len(slice_text[:cap].splitlines()) == 1 # only line 5 is visible
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/cv5.md:\ndate: 2024-06-15\nlines: 5-10 of 10\n"
|
||
+ slice_text[:cap]
|
||
+ "\n"
|
||
+ TRUNCATION_MARKER
|
||
+ "\n"
|
||
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text))
|
||
)
|
||
assert holder.read_coverage == [("S", "cv5.md", 5, 5)]
|
||
assert holder.read_truncations == [("S/cv5.md", cap, len(slice_text))]
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
|
||
|
||
def test_coverage_truncated_range_read_cut_at_line_boundary(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): the boundary sub-case for the SLICE cut —
|
||
a cap that lands exactly on a line boundary (the cut ends with the
|
||
line's newline): the span ends at that complete line. The 8-char
|
||
lines-1-3 slice under a 5-char cap cuts ``aa\nbb\n`` → the span is
|
||
``(1, 2)`` (``end`` = 3, the clamp not reached)."""
|
||
cap = 5
|
||
doc = _doc("S", "cv6.md", "Cv6", "aa\nbb\ncc") # 8 chars > cap
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/cv6.md", "offset": 1, "limit": 3},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/cv6.md:\ndate: 2024-06-15\nlines: 1-3 of 3\n"
|
||
+ doc.content[:cap]
|
||
+ "\n"
|
||
+ TRUNCATION_MARKER
|
||
+ "\n"
|
||
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))
|
||
)
|
||
assert holder.read_coverage == [("S", "cv6.md", 1, 2)]
|
||
assert holder.read_truncations == [("S/cv6.md", cap, 8)]
|
||
|
||
|
||
def test_coverage_spans_accumulate_in_read_order(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): each SUCCESSFUL read appends its own span,
|
||
in read order — a truncated plain read (span 1) followed by the
|
||
range read that proceeds (phase-131 row 4) leaves BOTH spans,
|
||
keyed by the same resolved pair."""
|
||
cap = 100
|
||
doc = _lines_doc("S", "cv7.md", 20) # 150 chars > the cap
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/cv7.md"})],
|
||
[
|
||
ToolCallPiece(
|
||
id="c2",
|
||
name="read",
|
||
arguments={"path": "S/cv7.md", "offset": 15, "limit": 2},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
# Round 2 PROCEEDED (the truncated-then-range row) — lines 15-16
|
||
# (15 chars — under the cap, no second truncation).
|
||
assert llm.requests[2][0][5]["content"] == (
|
||
"Document S/cv7.md:\ndate: 2024-06-15\nlines: 15-16 of 20\n"
|
||
"line 15\nline 16"
|
||
)
|
||
assert holder.read_coverage == [("S", "cv7.md", 1, 14), ("S", "cv7.md", 15, 16)]
|
||
assert holder.tool_calls == 2
|
||
assert len(holder.read_truncations) == 1
|
||
|
||
|
||
def test_coverage_spans_keyed_by_the_resolved_pair(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): the span is keyed by the RESOLVED
|
||
``(source, path)`` pair — the document's own identity (the
|
||
``read_docs`` keying; by the ``_resolve_path`` first-slash
|
||
contract the raw argument's split and the resolved pair agree —
|
||
the same pair ``read_truncations`` keying recovers from the raw
|
||
argument). A nested path (slashes in the path part) keeps the
|
||
pair intact: the canonical ``source/path`` argument read records
|
||
the document's ``(source, path)``, not a mangled split."""
|
||
doc = _doc("S", "dir/nested/file.md", "File", "one\ntwo")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/dir/nested/file.md", "offset": 1, "limit": 1},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/dir/nested/file.md:\ndate: 2024-06-15\n"
|
||
"lines: 1-1 of 2\none"
|
||
)
|
||
assert holder.read_coverage == [("S", "dir/nested/file.md", 1, 1)]
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
|
||
|
||
# --- phase 141 (task 01): refusals record NOTHING ---
|
||
|
||
|
||
def test_coverage_bad_range_args_records_nothing(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): a bad range argument (the pre-dedupe
|
||
validation refusal) records NO span — a refused read counts in
|
||
nothing, including the coverage."""
|
||
doc = _lines_doc("S", "cvr1.md", 5)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1",
|
||
name="read",
|
||
arguments={"path": "S/cvr1.md", "offset": 2.5},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == agent.BAD_RANGE_ARGS.format(
|
||
offset=2.5, limit=None
|
||
)
|
||
assert holder.read_coverage == []
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
|
||
|
||
def test_coverage_unknown_doc_records_nothing(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): a read that resolves to NO document (the
|
||
no-document refusal) records NO span."""
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/ghost.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No document at 'S/ghost.md' — check the ls output."
|
||
)
|
||
assert holder.read_coverage == []
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
|
||
|
||
def test_coverage_offset_past_end_records_nothing(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): an ``offset`` past the end (the
|
||
post-resolve teaching refusal) records NO span — the document is
|
||
NOT added to the context, so a corrected retry still proceeds"""
|
||
doc = _lines_doc("S", "cvr3.md", 10)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="c1", name="read", arguments={"path": "S/cvr3.md", "offset": 11}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == agent.OFFSET_PAST_END.format(
|
||
source="S", path="cvr3.md", total_lines=10, offset=11
|
||
)
|
||
assert holder.read_coverage == []
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
|
||
|
||
def test_coverage_plain_reread_of_full_doc_records_nothing_new(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): the plain re-read of a read-in-full doc
|
||
(``ALREADY_IN_CONTEXT`` — the raw-arg fast path) appends NOTHING —
|
||
the list still holds the first read's span and nothing else."""
|
||
doc = _doc("S", "cvr4.md", "Cv4", "FULL-TEXT") # one line, under any cap
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/cvr4.md"})],
|
||
[ToolCallPiece(id="c2", name="read", arguments={"path": "S/cvr4.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"].startswith("Document S/cvr4.md:")
|
||
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
|
||
assert holder.read_coverage == [("S", "cvr4.md", 1, 1)] # still ONE span
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
|
||
|
||
def test_coverage_plain_reread_of_truncated_doc_records_nothing_new(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01; the refusal re-pinned task 02): the plain
|
||
re-read of a TRUNCATED doc (``REREAD_PARTIAL`` — the partial-
|
||
coverage row) appends NOTHING — the list still holds the first
|
||
read's cut span only."""
|
||
cap = 100
|
||
doc = _lines_doc("S", "cvr5.md", 20) # 150 chars > the cap
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/cvr5.md"})],
|
||
[ToolCallPiece(id="c2", name="read", arguments={"path": "S/cvr5.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||
assert llm.requests[2][0][5]["content"] == agent.REREAD_PARTIAL.format(
|
||
covered="1–14", source="S", path="cvr5.md", total=20
|
||
)
|
||
assert holder.read_coverage == [("S", "cvr5.md", 1, 14)] # still ONE span
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
|
||
|
||
def test_coverage_range_reread_of_full_doc_records_nothing_new(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 141 (task 01): the range read of a read-in-full doc
|
||
(``ALREADY_IN_CONTEXT`` — the post-resolve row) appends NOTHING —
|
||
the list still holds the first read's full span only."""
|
||
doc = _lines_doc("S", "cvr6.md", 5)
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="c1", name="read", arguments={"path": "S/cvr6.md"})],
|
||
[
|
||
ToolCallPiece(
|
||
id="c2",
|
||
name="read",
|
||
arguments={"path": "S/cvr6.md", "offset": 2, "limit": 2},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
|
||
assert holder.read_coverage == [("S", "cvr6.md", 1, 5)] # still ONE span
|
||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||
|
||
|
||
def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="delete_universe", arguments={"x": 1})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
assert llm.requests[1][0][3]["content"] == agent.UNKNOWN_TOOL
|
||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||
|
||
|
||
# ---------- grep (the phase-68 A5 contract under the new name) ----------
|
||
|
||
|
||
def test_grep_document_case_insensitive_line_numbers() -> None:
|
||
"""Case-insensitive fixed substring, 1-based line numbers, file order,
|
||
repeated matches within a line collapse to one match (grep semantics)."""
|
||
content = "The NEEDLE is here\nno hit\nneedle again\nNEEDLE NEEDLE\n"
|
||
assert agent.grep_document(content, "NEEDLE") == [
|
||
(1, "The NEEDLE is here"),
|
||
(3, "needle again"),
|
||
(4, "NEEDLE NEEDLE"),
|
||
]
|
||
|
||
|
||
def test_grep_document_rstrips_lines_and_empty_content() -> None:
|
||
assert agent.grep_document("hello \t\nworld ", "WORLD") == [(2, "world")]
|
||
assert agent.grep_document("", "x") == []
|
||
assert agent.grep_document("no newlines", "NO") == [(1, "no newlines")]
|
||
assert agent.grep_document("a\nb\n", "MISSING") == []
|
||
|
||
|
||
def test_grep_whole_kb_grep_style_output(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Whole-KB grep: catalog order, `source/path:line: text` lines,
|
||
case-insensitive; the call counts in ``tool_calls`` and never touches
|
||
``read_docs``; the tools stay offered on the answer request."""
|
||
d1 = _doc("Alpha", "a/one.md", "One", "first\nNEEDLE in one\nlast")
|
||
d2 = _doc("Beta", "b/two.md", "Two", "no hit\nneedle in two\n")
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [d1, d2])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "needle"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
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: a grep adds no context
|
||
assert llm.requests[1][1] == AGENT_TOOLS # tools stay offered
|
||
|
||
|
||
def test_grep_capped_at_20_matches_in_catalog_order(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The 20-match cap is GLOBAL across documents in catalog order, and
|
||
the scan stops once it is hit (a 35-match corpus yields exactly 20)."""
|
||
d1 = _doc("S", "a.md", "A", "\n".join(f"hit-{i}" for i in range(15)))
|
||
d2 = _doc("S", "b.md", "B", "\n".join(f"hit-{i}" for i in range(20)))
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [d1, d2])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "hit-"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
lines = llm.requests[1][0][3]["content"].split("\n")
|
||
assert len(lines) == agent.SEARCH_MAX_MATCHES
|
||
assert lines[0] == "S/a.md:1: hit-0"
|
||
assert lines[14] == "S/a.md:15: hit-14" # all of a.md
|
||
assert lines[15] == "S/b.md:1: hit-0" # then b.md, in order
|
||
assert lines[19] == "S/b.md:5: hit-4" # cut at the global cap
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_grep_truncates_match_lines_at_200_chars(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""A 300-char match line yields exactly 200 chars of it (no crash)."""
|
||
d1 = _doc("S", "a.md", "A", "top\n" + "x" * 300 + " NEEDLE tail")
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [d1])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "needle"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == f"S/a.md:2: {'x' * agent.SEARCH_LINE_LIMIT}"
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
# ---------- grep no-match teaching: the regex-shaped pattern
|
||
# (the 2026-09-05 "Qwen 3.8" incident — the harness prior is that
|
||
# grep takes a REGEX; this grep is a fixed substring, owner-locked
|
||
# A5, and the contract does not change) ----------
|
||
|
||
|
||
def test_plain_form_reduces_regex_to_literal_text() -> None:
|
||
"""The plain-form hint: the pattern reduced to literal text — the
|
||
incident's exact recovery (``qwen.*3\\.8`` → ``qwen3.8``) plus the
|
||
edge cases (raw ``.*`` runs dropped before unescape, so an escaped
|
||
dot survives; first alternative only; classes/quantifiers/parens/
|
||
anchors gone; whitespace preserved; pure metacharacters → ``""``).
|
||
"""
|
||
assert agent.plain_form(r"qwen.*3\.8") == "qwen3.8" # the incident
|
||
assert agent.plain_form(r"qwen 3\.8") == "qwen 3.8"
|
||
assert agent.plain_form(r"Qwen 3\.8") == "Qwen 3.8" # case kept
|
||
assert agent.plain_form(r"qwen3\.8") == "qwen3.8"
|
||
assert agent.plain_form(r"llama\.cpp") == "llama.cpp" # escaped dot kept
|
||
assert agent.plain_form(r"qwen[0-9]+") == "qwen" # class + quantifier
|
||
assert agent.plain_form("a|b") == "a" # first alternative only
|
||
assert agent.plain_form(r"\d+") == "" # no literal text — no hint
|
||
assert agent.plain_form(r".*") == "" # pure wildcard — no hint
|
||
assert agent.plain_form(r"(qwen)3\.8") == "qwen3.8" # group contents kept
|
||
assert agent.plain_form("a{2,3}b") == "ab"
|
||
assert agent.plain_form(r"^qwen$") == "qwen" # anchors dropped
|
||
assert agent.plain_form(r"a\.b") == "a.b" # escaped dot is a literal
|
||
assert agent.plain_form("plain") == "plain" # identity for plain text
|
||
|
||
|
||
def test_looks_like_regex_detection() -> None:
|
||
"""One metacharacter anywhere marks the pattern regex-shaped; a
|
||
plain substring (even with a space) does not."""
|
||
for p in (
|
||
r"qwen.*3\.8", r"qwen 3\.8", "qwen+", "a?b", "x|y", "(a)", "[a-z]", "a^b", "b$c", "a{2}"
|
||
):
|
||
assert agent.looks_like_regex(p) is True, p
|
||
for p in ("qwen 3.8", "qwen3.8", "plain substring", ""):
|
||
assert agent.looks_like_regex(p) is False, p
|
||
|
||
|
||
def test_grep_no_match_regex_pattern_gets_teaching_line(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The incident's shape: a regex-shaped pattern that (necessarily)
|
||
misses gets the TEACHING no-match line — the plain-substring
|
||
contract stated, the plain-form retry hint handed over. Still a
|
||
counted result; the context is untouched (locked A5)."""
|
||
d1 = _doc("Alpha", "a/one.md", "One", "qwen3.8-27b juggernaut\nno regex text")
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [d1])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1", name="grep", arguments={"pattern": r"qwen.*3\.8"}
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == agent.NO_MATCHES_REGEX.format(
|
||
pattern=r"qwen.*3\.8", plain="qwen3.8"
|
||
)
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No matches for 'qwen.*3\\.8'. grep matches a plain substring "
|
||
"(case-insensitive), not a regex — '.*', '\\.' and the like are "
|
||
"literal text here, so that pattern can never match. Retry with "
|
||
"the plain text you expect to see (e.g. 'qwen3.8')."
|
||
)
|
||
assert holder.tool_calls == 1 # a no-match with teaching is still a result
|
||
assert holder.read_docs == [] # locked A5: a grep adds no context
|
||
|
||
|
||
def test_grep_no_match_regex_scoped_gets_teaching_line(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The scoped teaching variant: the resolved identity is echoed, the
|
||
hint handed over."""
|
||
d1 = _doc("Alpha", "a/one.md", "One", "nothing regex-shaped here")
|
||
|
||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||
return d1 if (source, path) == ("Alpha", "a/one.md") else None
|
||
|
||
monkeypatch.setattr(agent, "find_document", _find)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="grep",
|
||
arguments={"pattern": r"qwen 3\.8", "path": "Alpha/a/one.md"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No matches for 'qwen 3\\.8' in Alpha/a/one.md. grep matches a "
|
||
"plain substring (case-insensitive), not a regex — retry with "
|
||
"the plain text you expect to see (e.g. 'qwen 3.8')."
|
||
)
|
||
assert holder.tool_calls == 1
|
||
assert holder.read_docs == []
|
||
|
||
|
||
def test_grep_no_match_plain_pattern_keeps_ordinary_line(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A no-match for a PLAIN pattern (no metacharacters — "qwen 3.8" with
|
||
the space included) keeps the ordinary line byte-identical: the
|
||
teaching never fires for a well-formed pattern (the retrieval side —
|
||
the name-hit lexical signal — is what covers that case)."""
|
||
d1 = _doc("Alpha", "a/one.md", "One", "qwen3.8-27b juggernaut")
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [d1])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "qwen 3.8"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No matches for 'qwen 3.8' in the knowledge base."
|
||
)
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_grep_matched_regex_pattern_returns_matches_not_teaching(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A pattern with metacharacters that MATCHES literally gets the
|
||
ordinary match output — the teaching can never suppress a real hit
|
||
(the detection keys on a NO-MATCH only)."""
|
||
d1 = _doc("S", "a.md", "A", "the C++ compiler is here")
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [d1])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "C++"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == "S/a.md:1: the C++ compiler is here"
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_grep_no_match_regex_reducing_to_empty_falls_back(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A regex-shaped pattern with no literal text left after the
|
||
reduction (``.*``) gets the ORDINARY line — no empty hint."""
|
||
d1 = _doc("Alpha", "a/one.md", "One", "any text at all")
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [d1])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="grep", arguments={"pattern": r".*"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No matches for '.*' in the knowledge base."
|
||
)
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_no_matches_regex_templates_pin() -> None:
|
||
"""The teaching templates are verbatim pins (the model-facing copy —
|
||
the mock E2E keys off the plain-substring clause)."""
|
||
assert agent.NO_MATCHES_REGEX == (
|
||
"No matches for '{pattern}'. grep matches a plain substring "
|
||
"(case-insensitive), not a regex — '.*', '\\.' and the like are "
|
||
"literal text here, so that pattern can never match. Retry with "
|
||
"the plain text you expect to see (e.g. '{plain}')."
|
||
)
|
||
assert agent.NO_MATCHES_REGEX_SCOPED == (
|
||
"No matches for '{pattern}' in {source}/{path}. grep matches a "
|
||
"plain substring (case-insensitive), not a regex — retry with "
|
||
"the plain text you expect to see (e.g. '{plain}')."
|
||
)
|
||
|
||
|
||
def test_grep_scoped_to_one_document(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Scoped grep: only the named document is loaded (find_document on
|
||
the first-slash split), ``all_documents`` never runs, and the match
|
||
line carries its path."""
|
||
d1 = _doc("S", "a.md", "A", "needle here")
|
||
|
||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||
if (source, path) == ("S", "a.md"):
|
||
return d1
|
||
raise AssertionError(
|
||
f"find_document({source}, {path}) — the scoped "
|
||
"grep must not load any other document"
|
||
)
|
||
|
||
def _boom(*_a: Any, **_k: Any) -> None:
|
||
raise AssertionError("all_documents must not run for a scoped grep")
|
||
|
||
monkeypatch.setattr(agent, "find_document", _find)
|
||
monkeypatch.setattr(agent, "all_documents", _boom)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="grep",
|
||
arguments={"pattern": "needle", "path": "S/a.md"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == "S/a.md:1: needle here"
|
||
assert holder.tool_calls == 1
|
||
assert holder.read_docs == [] # grepped doc did not enter the context
|
||
|
||
|
||
def test_grep_scoped_combined_path_with_nested_path(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A combined target whose path itself contains slashes: the split is
|
||
at the FIRST slash — the scoped grep runs on the right document."""
|
||
d1 = _doc("S", "deep/nested/a.md", "A", "needle here")
|
||
|
||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||
if (source, path) == ("S", "deep/nested/a.md"):
|
||
return d1
|
||
raise AssertionError(f"find_document({source}, {path}) — wrong first-slash split")
|
||
|
||
monkeypatch.setattr(agent, "find_document", _find)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="grep",
|
||
arguments={"pattern": "needle", "path": "S/deep/nested/a.md"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == "S/deep/nested/a.md:1: needle here"
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_grep_scoped_missing_document_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""A scoped ``grep`` miss that matches NO indexed document's ``path``
|
||
(zero candidates — the phase-72 lookup runs, finds nothing) keeps
|
||
today's line byte-identical: a refusal (not counted), tools stay
|
||
offered."""
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="grep",
|
||
arguments={"pattern": "x", "path": "S/ghost.md"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No document at 'S/ghost.md' — check the ls output."
|
||
)
|
||
assert holder.tool_calls == 0 and holder.read_docs == [] # a refusal
|
||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||
|
||
|
||
def test_grep_scoped_missing_path_like_doc_gets_did_you_mean(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The same teaching on the scoped ``grep`` miss: an unresolved
|
||
path-like scope that matches an indexed document's path gets the
|
||
``NO_DOCUMENT_DID_YOU_MEAN`` suggestion line (a refusal — not
|
||
counted, no context added, tools stay offered); the whole-KB grep is
|
||
untouched (no ``path`` argument → no scoped resolution at all).
|
||
"""
|
||
doc = _doc("Homelab", "active/container_caddy/caddy.md", "Caddy", "CADDY-CONTENT")
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [doc])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="grep",
|
||
arguments={
|
||
"pattern": "needle",
|
||
"path": "active/container_caddy/caddy.md",
|
||
},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
agent.NO_DOCUMENT_DID_YOU_MEAN.format(
|
||
arg="active/container_caddy/caddy.md",
|
||
source="Homelab",
|
||
path="active/container_caddy/caddy.md",
|
||
)
|
||
)
|
||
assert holder.tool_calls == 0 and holder.read_docs == [] # a refusal
|
||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||
|
||
|
||
def test_grep_scoped_bare_source_name_refused_without_db(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A bare source name as the grep target can never resolve to exactly
|
||
one document — the no-document refusal, no DB lookup, not counted."""
|
||
def _boom(*_a: Any, **_k: Any) -> None:
|
||
raise AssertionError("find_document must not run for a bare source name")
|
||
|
||
monkeypatch.setattr(agent, "find_document", _boom)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="grep",
|
||
arguments={"pattern": "x", "path": "Homelab"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No document at 'Homelab' — check the ls output."
|
||
)
|
||
assert holder.tool_calls == 0 and holder.read_docs == []
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("arguments", "label"),
|
||
[
|
||
({}, "no arguments"),
|
||
({"pattern": ""}, "empty pattern"),
|
||
({"pattern": " "}, "whitespace pattern"),
|
||
({"pattern": 42}, "non-string pattern"),
|
||
({"pattern": None}, "null pattern"),
|
||
],
|
||
)
|
||
def test_grep_missing_arguments_refused(
|
||
monkeypatch: pytest.MonkeyPatch, arguments: dict[str, Any], label: str
|
||
) -> None:
|
||
"""A missing/blank/non-string pattern → the missing-args refusal, with
|
||
no DB access at all."""
|
||
|
||
def _boom(*_a: Any, **_k: Any) -> None:
|
||
raise AssertionError(f"no DB access for a refused grep ({label})")
|
||
|
||
monkeypatch.setattr(agent, "all_documents", _boom)
|
||
monkeypatch.setattr(agent, "find_document", _boom)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="grep", arguments=arguments)],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == agent.MISSING_SEARCH_ARGS
|
||
assert holder.tool_calls == 0 and holder.read_docs == []
|
||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||
|
||
|
||
def test_grep_no_matches_whole_kb(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Zero hits across the KB → the no-match line (pattern quoted); the
|
||
grep still executed, so it counts — and never adds context."""
|
||
monkeypatch.setattr(
|
||
agent, "all_documents", lambda db: [_doc("S", "a.md", "A", "nothing here")]
|
||
)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "zebra"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"No matches for 'zebra' in the knowledge base."
|
||
)
|
||
assert holder.tool_calls == 1
|
||
assert holder.read_docs == []
|
||
|
||
|
||
def test_grep_no_matches_scoped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""The scoped no-match line is keyed on the resolved source/path."""
|
||
doc = _doc("S", "a.md", "A", "nothing here")
|
||
|
||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||
return doc if (source, path) == ("S", "a.md") else None
|
||
|
||
monkeypatch.setattr(agent, "find_document", _find)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(
|
||
id="call_1",
|
||
name="grep",
|
||
arguments={"pattern": "zebra", "path": "S/a.md"},
|
||
)
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == "No matches for 'zebra' in S/a.md."
|
||
assert holder.tool_calls == 1
|
||
assert holder.read_docs == []
|
||
|
||
|
||
def test_grep_no_match_truncates_long_pattern(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""A pattern longer than 100 chars is truncated in the no-match line
|
||
(kept short); the grep itself still runs on the full pattern."""
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "p" * 150})
|
||
],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
f"No matches for '{'p' * 100}' in the knowledge base."
|
||
)
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_grep_counts_but_never_adds_context(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""The locate-then-read workflow: a grep finds the document but does
|
||
NOT add it — the subsequent read does (and is not rejected as
|
||
already-in-context, because the grep touched nothing)."""
|
||
doc = _doc("S", "a.md", "A", "needle here")
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [doc])
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="grep", arguments={"pattern": "needle"})],
|
||
[ToolCallPiece(id="call_2", name="read", arguments={"path": "S/a.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.tool_calls == 2 # grep + read, both executed
|
||
assert holder.read_docs == [doc] # only the read added context (A5)
|
||
assert llm.requests[1][0][3]["content"] == "S/a.md:1: needle here"
|
||
assert llm.requests[2][0][5]["content"] == (
|
||
"Document S/a.md:\ndate: 2024-06-15\nneedle here"
|
||
)
|
||
|
||
|
||
# ---------- unlimited calls: re-lists and multi-reads (phase 45) ----------
|
||
|
||
|
||
def test_relist_executes_and_counts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Re-lists execute — a second ``ls`` in one turn returns the top
|
||
level again and counts in ``tool_calls`` (no budget to exhaust)."""
|
||
monkeypatch.setattr(
|
||
agent, "ls_top", lambda db: [("Deployments", 1, None), ("Homelab", 1, None)]
|
||
)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||
[ToolCallPiece(id="call_2", name="ls", arguments={})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.tool_calls == 2 # both re-lists executed and counted
|
||
listing = "2 sources:\n\nDeployments — 1 documents\nHomelab — 1 documents"
|
||
# The answer request carries the listing a second time as a tool result.
|
||
assert llm.requests[2][0][3]["content"] == listing # first listing
|
||
assert llm.requests[2][0][5]["content"] == listing # the re-list
|
||
assert llm.requests[2][1] == AGENT_TOOLS # still offered (no budgets)
|
||
|
||
|
||
def test_multi_read_executes_without_budgets(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Reads are no longer budgeted either — two different documents can
|
||
be read in one turn (re-reading the same one is still deduped via
|
||
ALREADY_IN_CONTEXT — see the rejection tests)."""
|
||
a = _doc("S", "a.md", "A", "A-CONTENT")
|
||
b = _doc("S", "b.md", "B", "B-CONTENT")
|
||
monkeypatch.setattr(
|
||
agent, "find_document", lambda db, source, path: {"a.md": a, "b.md": b}[path]
|
||
)
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/a.md"})],
|
||
[ToolCallPiece(id="call_2", name="read", arguments={"path": "S/b.md"})],
|
||
[StreamPiece("content", "ans")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert holder.read_docs == [a, b] # both reads appended, in order
|
||
assert holder.tool_calls == 2
|
||
assert llm.requests[1][0][3]["content"] == (
|
||
"Document S/a.md:\ndate: 2024-06-15\nA-CONTENT"
|
||
)
|
||
assert llm.requests[2][0][5]["content"] == (
|
||
"Document S/b.md:\ndate: 2024-06-15\nB-CONTENT"
|
||
)
|
||
assert llm.requests[2][1] == AGENT_TOOLS # the second read was still offered
|
||
|
||
|
||
# ---------- round cap (phase 45: replaces the per-tool budgets) ----------
|
||
|
||
|
||
def test_always_ls_bounded_by_round_cap(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""A model that keeps calling ``ls`` gets exactly
|
||
``agent_max_rounds`` tool rounds, then one forced ``tools=None``
|
||
request streams the answer — the cap is the only forced exit."""
|
||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||
listing = "1 sources:\n\nS — 1 documents"
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||
[ToolCallPiece(id="call_2", name="ls", arguments={})],
|
||
[ToolCallPiece(id="call_3", name="ls", arguments={})],
|
||
[StreamPiece("content", "forced answer")],
|
||
)
|
||
pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=3)))
|
||
assert [type(p) for p in pieces] == [
|
||
ToolCallPiece,
|
||
ToolCallPiece,
|
||
ToolCallPiece,
|
||
StreamPiece,
|
||
]
|
||
assert len(llm.requests) == 4 # 3 tool rounds + the forced answer
|
||
# The three tool rounds were offered the tools…
|
||
assert llm.requests[0][1] == AGENT_TOOLS
|
||
assert llm.requests[1][1] == AGENT_TOOLS
|
||
assert llm.requests[2][1] == AGENT_TOOLS
|
||
# …and the forced final request carries no tools, whatever is left.
|
||
assert llm.requests[3][1] is None
|
||
# Every re-list executed and counted.
|
||
assert holder.tool_calls == 3
|
||
# The final request carries all three executed listings as history.
|
||
final_msgs = llm.requests[3][0]
|
||
assert len(final_msgs) == 8 # 2 + 3 rounds × (assistant + tool)
|
||
assert final_msgs[3]["content"] == listing
|
||
assert final_msgs[5]["content"] == listing
|
||
assert final_msgs[7]["content"] == listing
|
||
|
||
|
||
def test_zero_max_rounds_is_one_request_without_tools() -> None:
|
||
"""``agent_max_rounds=0`` — the kill switch: exactly one request,
|
||
``tools=None``, no tool lines, no history growth (byte-identical to
|
||
the pre-phase-37 path)."""
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM([StreamPiece("thinking", "t "), StreamPiece("content", "direct answer")])
|
||
pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=0)))
|
||
assert [type(p) for p in pieces] == [StreamPiece, StreamPiece]
|
||
assert len(llm.requests) == 1
|
||
assert llm.requests[0][1] is None
|
||
assert llm.requests[0][0] == [
|
||
{"role": "system", "content": "SYSTEM_PROMPT"},
|
||
{"role": "user", "content": "QUESTION"},
|
||
]
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
|
||
|
||
def test_rejected_read_spam_runs_to_round_cap(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Every call rejected (unknown document — "No document at …"):
|
||
rejections no longer end the loop early via budgets — the round cap
|
||
bounds them and forces the final no-tools answer. Zero candidates
|
||
(empty catalog) → the pre-phase-72 line, byte-identical."""
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||
monkeypatch.setattr(agent, "all_documents", lambda db: [])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/ghost.md"})],
|
||
[ToolCallPiece(id="call_2", name="read", arguments={"path": "S/ghost.md"})],
|
||
[ToolCallPiece(id="call_3", name="read", arguments={"path": "S/ghost.md"})],
|
||
[StreamPiece("content", "forced answer")],
|
||
)
|
||
asyncio.run(_run(llm, holder, _settings(agent_max_rounds=3)))
|
||
assert len(llm.requests) == 4 # 3 rejected rounds + the forced answer
|
||
assert llm.requests[0][1] == AGENT_TOOLS
|
||
assert llm.requests[1][1] == AGENT_TOOLS
|
||
assert llm.requests[2][1] == AGENT_TOOLS
|
||
assert llm.requests[3][1] is None # the forced final request: no tools
|
||
assert holder.read_docs == [] and holder.tool_calls == 0 # nothing executed
|
||
refusal = "No document at 'S/ghost.md' — check the ls output."
|
||
assert llm.requests[1][0][3]["content"] == refusal
|
||
assert llm.requests[2][0][5]["content"] == refusal
|
||
assert llm.requests[3][0][7]["content"] == refusal
|
||
|
||
|
||
# ---------- retries inside the agent loop (phase 67, locked A2) ----------
|
||
|
||
|
||
class FailingLLM:
|
||
"""A scripted fake whose Nth ``chat_stream`` call yields pieces and
|
||
then raises (phase 67): ``attempts`` is a list of ``(pieces, error)``
|
||
— an error after zero pieces = "the endpoint died before the first
|
||
token"; after some pieces = a mid-stream drop. Records every
|
||
request's messages/tools and the indices of the attempts whose stream
|
||
teardown ran (``closed``)."""
|
||
|
||
def __init__(
|
||
self,
|
||
attempts: list[tuple[list[StreamPiece | ToolCallPiece], Exception | None]],
|
||
) -> None:
|
||
self.attempts = list(attempts)
|
||
self.requests: list[tuple[list[dict[str, Any]], list[dict[str, Any]] | None]] = []
|
||
#: Indices of attempts whose stream teardown has run.
|
||
self.closed: list[int] = []
|
||
|
||
def chat_stream(
|
||
self,
|
||
messages: list[dict[str, str]],
|
||
tools: list[dict[str, Any]] | None = None,
|
||
scaffolding: ScaffoldingFilter | None = None, # phase 71: fed like the real client
|
||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||
index = len(self.requests)
|
||
pieces, error = (
|
||
self.attempts[index]
|
||
if index < len(self.attempts)
|
||
else ([], LLMError("script exhausted"))
|
||
)
|
||
self.requests.append(
|
||
(deepcopy(messages), deepcopy(tools) if tools is not None else None)
|
||
)
|
||
return self._attempt(index, pieces, error, scaffolding)
|
||
|
||
async def _attempt(
|
||
self,
|
||
index: int,
|
||
pieces: list[StreamPiece | ToolCallPiece],
|
||
error: Exception | None,
|
||
scaffolding: ScaffoldingFilter | None,
|
||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||
try:
|
||
for piece in pieces:
|
||
if (
|
||
scaffolding is not None
|
||
and isinstance(piece, StreamPiece)
|
||
and piece.kind == "content"
|
||
):
|
||
cleaned = scaffolding.feed(piece.text)
|
||
if cleaned:
|
||
yield StreamPiece("content", cleaned)
|
||
else:
|
||
yield piece
|
||
if error is not None:
|
||
# The tail is NOT flushed on a failed attempt — the real
|
||
# client only flushes a cleanly completed stream.
|
||
raise error
|
||
if scaffolding is not None:
|
||
tail = scaffolding.flush()
|
||
if tail:
|
||
yield StreamPiece("content", tail)
|
||
finally:
|
||
self.closed.append(index)
|
||
|
||
|
||
def _record_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
||
"""Monkeypatch ``asyncio.sleep`` (what ``chat_stream_retried`` awaits
|
||
for the flat pre-retry delay) and record every awaited delay."""
|
||
sleeps: list[float] = []
|
||
|
||
async def fake_sleep(seconds: float) -> None:
|
||
sleeps.append(seconds)
|
||
|
||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||
return sleeps
|
||
|
||
|
||
def test_round_retried_before_first_piece(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
"""A tool round that dies before its first piece is restarted with the
|
||
same messages: the stream carries a RetryPiece BEFORE the tool call,
|
||
the tool executes, the final answer streams, and the per-call log line
|
||
is still emitted exactly once (retries are invisible to the loop)."""
|
||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||
holder = AgentHolder()
|
||
llm = FailingLLM(
|
||
[
|
||
([], LLMError("connection refused")),
|
||
([ToolCallPiece(id="call_1", name="ls", arguments={})], None),
|
||
([StreamPiece("content", "Done!")], None),
|
||
]
|
||
)
|
||
sleeps = _record_sleeps(monkeypatch)
|
||
with caplog.at_level(logging.INFO, logger="app.agent"):
|
||
pieces = asyncio.run(
|
||
_run(llm, holder, _settings(agent_max_rounds=2, llm_retry_delay=2.5))
|
||
)
|
||
assert pieces == [
|
||
RetryPiece(2, 4), # default llm_retries=3 → 4 attempts
|
||
ToolCallPiece(id="call_1", name="ls", arguments={}),
|
||
StreamPiece("content", "Done!"),
|
||
]
|
||
assert holder.tool_calls == 1
|
||
assert holder.read_docs == []
|
||
# The restart is byte-identical: same messages, same tools offered.
|
||
assert len(llm.requests) == 3
|
||
assert llm.requests[0] == llm.requests[1]
|
||
assert llm.requests[0][1] == AGENT_TOOLS
|
||
assert llm.requests[2][1] == AGENT_TOOLS # the answer round still offered
|
||
# The flat delay was awaited exactly once, before the retry.
|
||
assert sleeps == [2.5]
|
||
tool_logs = [r for r in caplog.records if r.getMessage().startswith("agent tool=")]
|
||
assert len(tool_logs) == 1 # the retry did not re-run the tool or log
|
||
assert tool_logs[0].getMessage() == "agent tool=ls args={} round=1/2"
|
||
|
||
|
||
def test_round_failure_after_first_piece_is_terminal(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Locked A2: a round that already streamed a piece fails the turn —
|
||
the LLMError propagates out of ``run_agent``, no RetryPiece, no
|
||
sleep, no second request, and the holder is untouched (the tool
|
||
never ran)."""
|
||
holder = AgentHolder()
|
||
llm = FailingLLM(
|
||
[([StreamPiece("content", "partial ")], LLMError("mid-stream drop"))]
|
||
)
|
||
sleeps = _record_sleeps(monkeypatch)
|
||
|
||
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
||
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
|
||
with pytest.raises(LLMError, match="mid-stream drop"):
|
||
async for piece in run_agent(
|
||
cast("LLMClient", llm),
|
||
lambda: _mock_session(),
|
||
system_prompt="SYSTEM_PROMPT",
|
||
user_message="QUESTION",
|
||
seed_docs=[],
|
||
settings=_settings(),
|
||
holder=holder,
|
||
):
|
||
out.append(piece)
|
||
return out
|
||
|
||
out = asyncio.run(drain())
|
||
assert out == [StreamPiece("content", "partial ")] # no RetryPiece
|
||
assert len(llm.requests) == 1 # no retry
|
||
assert sleeps == []
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
|
||
|
||
def test_forced_final_no_tools_call_is_retried(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""The forced final request (round cap reached) goes through the same
|
||
retry rule: a failure before its first piece yields a RetryPiece and
|
||
restarts with ``tools=None``; the answer from the retry streams."""
|
||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||
holder = AgentHolder()
|
||
llm = FailingLLM(
|
||
[
|
||
([ToolCallPiece(id="call_1", name="ls", arguments={})], None),
|
||
([ToolCallPiece(id="call_2", name="ls", arguments={})], None),
|
||
([], LLMError("down at the cap")),
|
||
([StreamPiece("content", "forced answer")], None),
|
||
]
|
||
)
|
||
pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=2)))
|
||
assert [type(p) for p in pieces] == [
|
||
ToolCallPiece,
|
||
ToolCallPiece,
|
||
RetryPiece,
|
||
StreamPiece,
|
||
]
|
||
assert pieces[2] == RetryPiece(2, 4)
|
||
assert pieces[3] == StreamPiece("content", "forced answer")
|
||
assert len(llm.requests) == 4 # 2 tool rounds + the final + its retry
|
||
# The forced final (and its retry) carry no tools, whatever is left.
|
||
assert llm.requests[2][1] is None
|
||
assert llm.requests[3][1] is None
|
||
# …and the restart is byte-identical.
|
||
assert llm.requests[2][0] == llm.requests[3][0]
|
||
assert holder.tool_calls == 2
|
||
|
||
|
||
def test_zero_retries_is_one_plain_attempt(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""The kill-switch path (``llm_retries=0``): a dead round raises
|
||
immediately — one request, no RetryPiece, no sleep (pre-phase-67
|
||
behavior)."""
|
||
holder = AgentHolder()
|
||
llm = FailingLLM([([], LLMError("connection refused"))])
|
||
sleeps = _record_sleeps(monkeypatch)
|
||
|
||
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
||
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
|
||
with pytest.raises(LLMError, match="connection refused"):
|
||
async for piece in run_agent(
|
||
cast("LLMClient", llm),
|
||
lambda: _mock_session(),
|
||
system_prompt="SYSTEM_PROMPT",
|
||
user_message="QUESTION",
|
||
seed_docs=[],
|
||
settings=_settings(llm_retries=0),
|
||
holder=holder,
|
||
):
|
||
out.append(piece)
|
||
return out
|
||
|
||
out = asyncio.run(drain())
|
||
assert out == [] # nothing streamed, no RetryPiece
|
||
assert len(llm.requests) == 1
|
||
assert sleeps == []
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
|
||
|
||
def test_abandon_mid_retry_sleep_leaks_nothing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Consumer abandon while a retried round is parked in the pre-retry
|
||
sleep (client disconnect): the driving task is cancelled cleanly, the
|
||
production teardown ``aclose()`` on ``run_agent`` does not raise, the
|
||
inner attempt's stream was torn down, and the retry never starts."""
|
||
entered = asyncio.Event()
|
||
|
||
async def parking_sleep(seconds: float) -> None:
|
||
entered.set()
|
||
await asyncio.Event().wait() # park until the abandon arrives
|
||
|
||
monkeypatch.setattr(asyncio, "sleep", parking_sleep)
|
||
holder = AgentHolder()
|
||
llm = FailingLLM(
|
||
[([], LLMError("endpoint down")), ([StreamPiece("content", "never")], None)]
|
||
)
|
||
|
||
async def run() -> None:
|
||
gen = run_agent(
|
||
cast("LLMClient", llm),
|
||
lambda: _mock_session(),
|
||
system_prompt="SYSTEM_PROMPT",
|
||
user_message="QUESTION",
|
||
seed_docs=[],
|
||
settings=_settings(),
|
||
holder=holder,
|
||
)
|
||
|
||
async def consumer() -> list[
|
||
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece
|
||
]:
|
||
return [p async for p in gen]
|
||
|
||
task = asyncio.ensure_future(consumer())
|
||
await entered.wait() # the round's retry is parked in the sleep
|
||
assert not task.done()
|
||
task.cancel() # client disconnect: the driving task is cancelled
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await task
|
||
# Production teardown (phase 48 pattern): must not raise. ``run_agent``
|
||
# is an async generator despite its AsyncIterator annotation.
|
||
await cast(
|
||
"AsyncGenerator[StreamPiece | ToolCallPiece | RetryPiece, None]", gen
|
||
).aclose()
|
||
|
||
asyncio.run(run())
|
||
assert len(llm.requests) == 1 # the retry never started
|
||
assert llm.closed == [0] # attempt 1's inner stream was torn down
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
|
||
|
||
def test_retries_are_invisible_to_the_round_cap(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
"""A failing-then-succeeding round consumes ONE round: with a cap of
|
||
2, the retried first round and the second tool round fill the cap —
|
||
the forced final follows the SECOND call, and the log lines read
|
||
round=1/2 and round=2/2."""
|
||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||
holder = AgentHolder()
|
||
llm = FailingLLM(
|
||
[
|
||
([], LLMError("down")),
|
||
([ToolCallPiece(id="call_1", name="ls", arguments={})], None),
|
||
([ToolCallPiece(id="call_2", name="ls", arguments={})], None),
|
||
([StreamPiece("content", "forced answer")], None),
|
||
]
|
||
)
|
||
with caplog.at_level(logging.INFO, logger="app.agent"):
|
||
pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=2)))
|
||
assert [type(p) for p in pieces] == [
|
||
RetryPiece,
|
||
ToolCallPiece,
|
||
ToolCallPiece,
|
||
StreamPiece,
|
||
]
|
||
assert len(llm.requests) == 4 # 2 (round 1 + its retry) + 1 + the forced final
|
||
assert llm.requests[3][1] is None # the forced final, after round 2
|
||
assert holder.tool_calls == 2
|
||
msgs = [r.getMessage() for r in caplog.records]
|
||
assert "agent tool=ls args={} round=1/2" in msgs
|
||
assert "agent tool=ls args={} round=2/2" in msgs
|
||
assert any("round cap reached (rounds=2)" in m for m in msgs)
|
||
|
||
|
||
# ---------- prompts: <tools> section (HIGH only) ----------
|
||
# NOTE (phase 70, task 02): these pins cover the phase-70 TOOLS_SECTION
|
||
# copy — the harness-aligned ls/read/grep names (the old phase-37/68
|
||
# names and the phase-37 per-tool budget line are gone; the round cap
|
||
# is the bound, not re-stated in the prompt, phase 45). The
|
||
# LOW/deflection path is untouched by this phase.
|
||
|
||
|
||
def test_high_prompt_carries_tools_section_after_documents() -> None:
|
||
prompt = build_high_prompt([_doc("S", "a.md", "A", "A-CONTENT")])
|
||
assert TOOLS_SECTION in prompt
|
||
# Phase 70: the harness-aligned ls/read/grep copy.
|
||
assert "`ls`" in prompt
|
||
assert "`grep`" in prompt
|
||
assert "`read`" in prompt
|
||
assert "source: X | path: Y | title: Z" in prompt
|
||
assert "locator, not a context-adder" in prompt
|
||
assert "combined `source/path`" in prompt
|
||
assert "Answer as soon as you have what you need" in prompt
|
||
# The old names and the per-tool budget restatement are gone.
|
||
for old in ("list_documents", "read_document", "search_documents"):
|
||
assert old not in prompt
|
||
assert "more than one extra document" not in prompt
|
||
# After the mode body: <tools> follows </documents>.
|
||
assert prompt.index("</documents>") < prompt.index("<tools>")
|
||
assert prompt.rstrip().endswith("</tools>")
|
||
|
||
|
||
def test_high_prompt_tools_section_with_notes_and_kb() -> None:
|
||
prompt = build_high_prompt(
|
||
[_doc("S", "a.md", "A", "A-CONTENT")], notes=["be concise"], kb_overview="- KB"
|
||
)
|
||
assert prompt.index("<knowledge_base>") < prompt.index("<tuning>")
|
||
assert prompt.index("<tuning>") < prompt.index("<documents>")
|
||
assert prompt.index("<documents>") < prompt.index("<tools>")
|
||
|
||
|
||
def test_low_prompt_is_byte_identical_and_tool_free() -> None:
|
||
# Phase 71: the LOW prompt carries the owner-permitted plain-text
|
||
# line after the DEFLECT_MODE sentence (the marker-keying contract
|
||
# is unchanged; the line must not leak into the HIGH prompt —
|
||
# pinned in tests/unit/test_prompts.py).
|
||
expected = (
|
||
_base("LOW")
|
||
+ "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
||
"your notes come to the question. They are titles only; do not pretend "
|
||
"they answer it. Use them to propose 2-3 alternative questions.\n"
|
||
"Reply in plain text only — you have no tools in this mode.\n"
|
||
+ "- T1\n- T2"
|
||
)
|
||
assert build_deflect_prompt(["T1", "T2"]) == expected
|
||
for prompt in (
|
||
build_deflect_prompt(["T1"]),
|
||
build_deflect_prompt(["T1"], notes=["be concise"]),
|
||
build_deflect_prompt(["T1"], kb_overview="- KB"),
|
||
build_deflect_prompt(["T1"], notes=["be concise"], kb_overview="- KB"),
|
||
):
|
||
assert "<tools>" not in prompt
|
||
assert TOOLS_SECTION not in prompt
|
||
|
||
|
||
# ---------- phase 71: the scaffolding recovery policy (deterministic only) ----------
|
||
|
||
#: The raw span from the 2026-09-03 incident (the E2E mock's trigger,
|
||
#: task 05) — a complete span the filter strips in full.
|
||
_INCIDENT_SPAN = (
|
||
"<|tool_call_start|>[read(path='/homelab/backup-notes.md')]<|tool_call_end|>"
|
||
)
|
||
|
||
|
||
def test_correction_instruction_is_the_harness_constant() -> None:
|
||
"""Verbatim constant: the E2E mock (task 05) keys on a stable
|
||
substring of it, so it must not drift."""
|
||
assert agent.CORRECTION_INSTRUCTION == (
|
||
"Your previous reply contained raw tool-call markup, which is not "
|
||
"interpreted here. Answer the user's question directly in plain "
|
||
"text — no tool syntax."
|
||
)
|
||
|
||
|
||
def test_malformed_reply_error_subclasses_llm_error() -> None:
|
||
assert issubclass(MalformedReplyError, LLMError)
|
||
assert not issubclass(LLMError, MalformedReplyError)
|
||
|
||
|
||
def test_scaffolding_only_round_gets_exactly_one_recovery(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
"""A round whose visible content is pure scaffolding → exactly TWO
|
||
model requests: the normal round, then the ONE recovery —
|
||
``tools=None`` with :data:`CORRECTION_INSTRUCTION` folded into the
|
||
original single system message (the user message stays last). The
|
||
clean recovery answer ends the turn, the holder is untouched by the
|
||
recovery, and the strip was captured in the warning log."""
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[StreamPiece("content", _INCIDENT_SPAN)],
|
||
[StreamPiece("content", "The clean recovery answer.")],
|
||
)
|
||
with caplog.at_level(logging.WARNING, logger="app.agent"):
|
||
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||
# Nothing of the round's scaffolding was yielded — only the recovery.
|
||
assert pieces == [StreamPiece("content", "The clean recovery answer.")]
|
||
assert len(llm.requests) == 2 # the round + the one recovery
|
||
# The round: the original system prompt, the tools offered.
|
||
assert llm.requests[0][1] == AGENT_TOOLS
|
||
assert llm.requests[0][0] == [
|
||
{"role": "system", "content": "SYSTEM_PROMPT"},
|
||
{"role": "user", "content": "QUESTION"},
|
||
]
|
||
# The recovery: no tools, a SINGLE system message carrying the folded
|
||
# correction, the user message last.
|
||
assert llm.requests[1][1] is None
|
||
assert llm.requests[1][0] == [
|
||
{
|
||
"role": "system",
|
||
"content": "SYSTEM_PROMPT\n" + agent.CORRECTION_INSTRUCTION,
|
||
},
|
||
{"role": "user", "content": "QUESTION"},
|
||
]
|
||
# The recovery is a fixed policy, not a conversation: the holder is
|
||
# untouched by it.
|
||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||
# The turn total feeds the API layer's ``scaffold_stripped=N`` field.
|
||
assert holder.scaffold_stripped == len(_INCIDENT_SPAN)
|
||
# One strip warning per stripped span, the span truncated to 200 chars.
|
||
strip_logs = [
|
||
r
|
||
for r in caplog.records
|
||
if r.levelno == logging.WARNING and r.getMessage().startswith("agent: stripped")
|
||
]
|
||
assert len(strip_logs) == 1
|
||
message = strip_logs[0].getMessage()
|
||
assert message.startswith(
|
||
f"agent: stripped {len(_INCIDENT_SPAN)} chars of tool-scaffolding in round 1:"
|
||
)
|
||
assert _INCIDENT_SPAN[:200] in message
|
||
|
||
|
||
def test_scaffolding_twice_settles_with_malformed_reply(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The recovery answer is scaffolding again (a second empty reply) —
|
||
terminal: :class:`MalformedReplyError` (an :class:`LLMError` subclass)
|
||
after exactly two requests — no third request, no recovery of a
|
||
recovery."""
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[StreamPiece("content", _INCIDENT_SPAN)],
|
||
[StreamPiece("content", _INCIDENT_SPAN)],
|
||
)
|
||
with pytest.raises(MalformedReplyError) as excinfo:
|
||
asyncio.run(_run(llm, holder, _settings()))
|
||
assert isinstance(excinfo.value, LLMError)
|
||
assert len(llm.requests) == 2 # exactly one recovery per turn
|
||
assert llm.requests[1][1] is None
|
||
assert agent.CORRECTION_INSTRUCTION in llm.requests[1][0][0]["content"]
|
||
assert holder.scaffold_stripped == 2 * len(_INCIDENT_SPAN)
|
||
|
||
|
||
def test_scaffolding_with_real_content_needs_no_recovery(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A round with real visible content PLUS scaffolding: the clean
|
||
content stands — one request only, the clean remainder yielded (no
|
||
raw tokens), no correction in any system prompt."""
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
StreamPiece("content", "Here it is: "),
|
||
StreamPiece("content", _INCIDENT_SPAN),
|
||
StreamPiece("content", " hope that helps."),
|
||
],
|
||
)
|
||
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||
assert [p for p in pieces if isinstance(p, StreamPiece)] == [
|
||
StreamPiece("content", "Here it is: "),
|
||
StreamPiece("content", " hope that helps."),
|
||
]
|
||
assert len(llm.requests) == 1 # no recovery
|
||
for messages, _tools in llm.requests:
|
||
assert all(agent.CORRECTION_INSTRUCTION not in m["content"] for m in messages)
|
||
assert holder.scaffold_stripped == len(_INCIDENT_SPAN)
|
||
|
||
|
||
def test_clean_turn_carries_no_correction_and_no_strip(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A clean turn: one request, no correction in any system prompt,
|
||
zero stripped (the log field stays 0 — uniform)."""
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[StreamPiece("thinking", "hmm "), StreamPiece("content", "a clean answer")]
|
||
)
|
||
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||
assert pieces == [
|
||
StreamPiece("thinking", "hmm "),
|
||
StreamPiece("content", "a clean answer"),
|
||
]
|
||
assert len(llm.requests) == 1
|
||
for messages, _tools in llm.requests:
|
||
assert all(agent.CORRECTION_INSTRUCTION not in m["content"] for m in messages)
|
||
assert holder.scaffold_stripped == 0
|
||
|
||
|
||
def test_empty_round_without_a_strip_keeps_today_behavior(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Round content 0 with NOTHING stripped (an empty/thinking-only
|
||
answer) → return as today — no recovery, no error."""
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM([StreamPiece("thinking", "nothing to say, honestly")])
|
||
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||
assert pieces == [StreamPiece("thinking", "nothing to say, honestly")]
|
||
assert len(llm.requests) == 1
|
||
assert holder.scaffold_stripped == 0
|
||
|
||
|
||
def test_scaffolding_round_with_tool_calls_needs_no_recovery(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A scaffolding-only round that ALSO carried tool calls: the tool
|
||
ran, and the policy keys on the no-calls exit only — no recovery (the
|
||
next round is a normal tools-offered round carrying the tool
|
||
history)."""
|
||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[
|
||
StreamPiece("content", _INCIDENT_SPAN),
|
||
ToolCallPiece(id="call_1", name="ls", arguments={}),
|
||
],
|
||
[StreamPiece("content", "the answer after the tool")],
|
||
)
|
||
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||
# The round's scaffolding was stripped (no raw delta), the tool frame
|
||
# and the next round's answer flowed on.
|
||
assert pieces == [
|
||
ToolCallPiece(id="call_1", name="ls", arguments={}),
|
||
StreamPiece("content", "the answer after the tool"),
|
||
]
|
||
assert len(llm.requests) == 2
|
||
assert llm.requests[1][1] == AGENT_TOOLS # a normal round, not a recovery
|
||
for messages, _tools in llm.requests:
|
||
assert all(
|
||
m["content"] is None or agent.CORRECTION_INSTRUCTION not in m["content"]
|
||
for m in messages
|
||
)
|
||
assert holder.tool_calls == 1
|
||
assert holder.scaffold_stripped == len(_INCIDENT_SPAN)
|
||
|
||
|
||
def test_recovery_after_tool_rounds_keeps_the_history(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A scaffolding-only answer round after a tool round: the recovery
|
||
keeps the SINGLE (folded) system message at the front and the tool
|
||
history intact behind it — no second system message, no duplicated
|
||
correction."""
|
||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||
[StreamPiece("content", _INCIDENT_SPAN)],
|
||
[StreamPiece("content", "recovered after a tool round")],
|
||
)
|
||
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||
assert pieces == [
|
||
ToolCallPiece(id="call_1", name="ls", arguments={}),
|
||
StreamPiece("content", "recovered after a tool round"),
|
||
]
|
||
assert len(llm.requests) == 3 # tool round + stripped round + recovery
|
||
assert llm.requests[2][1] is None
|
||
recovered = llm.requests[2][0]
|
||
assert recovered[0] == {
|
||
"role": "system",
|
||
"content": "SYSTEM_PROMPT\n" + agent.CORRECTION_INSTRUCTION,
|
||
}
|
||
assert recovered[1] == {"role": "user", "content": "QUESTION"}
|
||
assert len(recovered) == 4
|
||
assert recovered[2]["role"] == "assistant"
|
||
assert recovered[3] == {
|
||
"role": "tool",
|
||
"tool_call_id": "call_1",
|
||
"content": "1 sources:\n\nS — 1 documents",
|
||
}
|
||
assert sum(1 for m in recovered if m["role"] == "system") == 1
|
||
assert holder.scaffold_stripped == len(_INCIDENT_SPAN)
|
||
|
||
|
||
def test_forced_final_scaffolding_only_settles_malformed(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The round-cap forced final (``tools=None``) is filtered too: a
|
||
scaffolding-only forced answer never reaches the user raw — the turn
|
||
settles with :class:`MalformedReplyError` (the same terminal
|
||
semantics; this turn used no recovery, so nothing is doubled up)."""
|
||
monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)])
|
||
holder = AgentHolder()
|
||
llm = ScriptedLLM(
|
||
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||
[StreamPiece("content", _INCIDENT_SPAN)],
|
||
)
|
||
with pytest.raises(MalformedReplyError):
|
||
asyncio.run(_run(llm, holder, _settings(agent_max_rounds=1)))
|
||
assert len(llm.requests) == 2
|
||
assert llm.requests[1][1] is None # the forced final — no recovery after it
|
||
assert holder.scaffold_stripped == len(_INCIDENT_SPAN)
|