1717 lines
70 KiB
Python
1717 lines
70 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
|
||
``list_catalog`` / ``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 ``ls`` scoping (no-arg full catalog in the phase-63
|
||
labeled-field format, a one-source scope, a known source with 0
|
||
documents → ``0 documents:`` counted, an unknown-source refusal that
|
||
counts nothing), ``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-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
|
||
from copy import deepcopy
|
||
from typing import TYPE_CHECKING, Any, cast
|
||
|
||
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,
|
||
AgentHolder,
|
||
MalformedReplyError,
|
||
run_agent,
|
||
)
|
||
from app.rag.llm import LLMClient, LLMError, RetryPiece, StreamPiece, ToolCallPiece
|
||
from app.rag.prompts import TOOLS_SECTION, _base, build_deflect_prompt, build_high_prompt
|
||
|
||
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]
|
||
|
||
|
||
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,
|
||
)
|
||
|
||
|
||
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)
|
||
|
||
|
||
async def _run(
|
||
llm: ScriptedLLM | FailingLLM,
|
||
holder: AgentHolder,
|
||
settings: Settings,
|
||
seed_docs: list[Document] | None = None,
|
||
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
|
||
async for piece in run_agent(
|
||
cast("LLMClient", llm),
|
||
cast("Session", None),
|
||
system_prompt="SYSTEM_PROMPT",
|
||
user_message="QUESTION",
|
||
seed_docs=seed_docs or [],
|
||
settings=settings,
|
||
holder=holder,
|
||
):
|
||
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"]
|
||
assert ls["description"] == (
|
||
"List the indexed documents as `source: X | path: Y | title: Z` lines."
|
||
)
|
||
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"
|
||
assert ls_params["properties"]["path"]["description"] == (
|
||
"Source name to list one source's documents (e.g. 'homelab'); "
|
||
"omit to list every document."
|
||
)
|
||
read = by_name["read"]["function"]
|
||
assert read["description"] == (
|
||
"Add the full content of one indexed document to your context."
|
||
)
|
||
read_params = read["parameters"]
|
||
assert read_params["type"] == "object"
|
||
assert read_params["required"] == ["path"]
|
||
assert set(read_params["properties"]) == {"path"}
|
||
assert read_params["properties"]["path"]["type"] == "string"
|
||
# The combined source/path string is the canonical document identity
|
||
# (phase 70) — the description pins it with a worked example.
|
||
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')."
|
||
)
|
||
grep = by_name["grep"]["function"]
|
||
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`."
|
||
)
|
||
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)"
|
||
)
|
||
assert grep_params["properties"]["path"]["description"] == (
|
||
"Limit the search to one document, as a combined `source/path` "
|
||
"string from the `ls` output (omit to search every document)."
|
||
)
|
||
|
||
|
||
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; ALREADY_IN_CONTEXT / UNKNOWN_TOOL unchanged)."""
|
||
assert agent.ALREADY_IN_CONTEXT == "Already in your context."
|
||
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'."
|
||
|
||
|
||
# ---------- 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:
|
||
catalog = [
|
||
("Deployments", "backups.md", "Backup Strategy"),
|
||
("Homelab", "aws-route53.md", "AWS Route53 Records"),
|
||
]
|
||
monkeypatch.setattr(agent, "list_catalog", lambda db: catalog)
|
||
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.
|
||
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 documents:\n"
|
||
"source: Deployments | path: backups.md | title: Backup Strategy\n"
|
||
"source: Homelab | path: aws-route53.md | title: AWS Route53 Records"
|
||
),
|
||
}
|
||
# 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:\nR53-CONTENT", # full text, no cap
|
||
}
|
||
|
||
|
||
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, "list_catalog", 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 documents:\n"
|
||
|
||
|
||
# ---------- ls: full catalog + scoping ----------
|
||
|
||
|
||
def test_ls_full_catalog_format(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""No argument: the full catalog in the phase-63 labeled-field format
|
||
(``source: X | path: Y | title: Z``) — counted; no registry lookup."""
|
||
catalog = [
|
||
("Deployments", "backups.md", "Backup Strategy"),
|
||
("Homelab", "aws-route53.md", "AWS Route53 Records"),
|
||
]
|
||
monkeypatch.setattr(agent, "list_catalog", lambda db: catalog)
|
||
|
||
def _boom_sources(*_a: Any, **_k: Any) -> None:
|
||
raise AssertionError("no registry lookup for an unscoped ls")
|
||
|
||
monkeypatch.setattr(agent, "list_source_names", _boom_sources)
|
||
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 documents:\n"
|
||
"source: Deployments | path: backups.md | title: Backup Strategy\n"
|
||
"source: Homelab | path: aws-route53.md | title: AWS Route53 Records"
|
||
)
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_ls_empty_catalog_says_zero_documents(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(agent, "list_catalog", 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 documents:\n"
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("arguments", "label"),
|
||
[
|
||
({"path": " "}, "blank path"),
|
||
({"path": 7}, "non-string path"),
|
||
],
|
||
)
|
||
def test_ls_blank_path_lists_full_catalog(
|
||
monkeypatch: pytest.MonkeyPatch, arguments: dict[str, Any], label: str
|
||
) -> None:
|
||
"""A blank (or non-string) ``path`` is treated as omitted — the full
|
||
catalog, counted (no refusal for an empty scope)."""
|
||
catalog = [("S", "a.md", "A")]
|
||
monkeypatch.setattr(agent, "list_catalog", lambda db: catalog)
|
||
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 documents:\nsource: S | path: a.md | title: A"
|
||
)
|
||
assert holder.tool_calls == 1
|
||
|
||
|
||
def test_ls_scoped_to_known_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""A known source name: the same listing filtered to that source —
|
||
counted."""
|
||
catalog = [
|
||
("Deployments", "backups.md", "Backup Strategy"),
|
||
("Homelab", "a.md", "A"),
|
||
("Homelab", "b.md", "B"),
|
||
]
|
||
monkeypatch.setattr(agent, "list_catalog", lambda db: catalog)
|
||
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"] == (
|
||
"2 documents:\n"
|
||
"source: Homelab | path: a.md | title: A\n"
|
||
"source: Homelab | path: b.md | title: B"
|
||
)
|
||
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 as
|
||
``0 documents:`` — a valid, counted result, not a refusal."""
|
||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("Other", "a.md", "A")])
|
||
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"] == "0 documents:\n"
|
||
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`` matching no source name is a refusal — not counted, the
|
||
round cap bounds its repetition."""
|
||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||
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"] == "No source named 'Ghost' — check the ls output."
|
||
)
|
||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||
|
||
|
||
# ---------- 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:\nCADDY-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,
|
||
nothing counted."""
|
||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("Homelab", "a.md", "A")])
|
||
|
||
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="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 → the refusal echoing the argument as
|
||
passed (the model sees its own form) — the old split-teaching refusal
|
||
is gone (phase 70)."""
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||
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)
|
||
|
||
|
||
@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_seed_doc_is_already_in_context(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""The combined identity of a seeded document: its split pair is in
|
||
the known set → ALREADY_IN_CONTEXT with no DB lookup (the dedupe
|
||
check precedes the resolve)."""
|
||
seed = [_doc("Homelab", "kubernetes.md", "Kubernetes", "K8S-CONTENT")]
|
||
|
||
def _boom(*_a: Any, **_k: Any) -> None:
|
||
raise AssertionError("find_document must not be called for a seeded doc")
|
||
|
||
monkeypatch.setattr(agent, "find_document", _boom)
|
||
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 == [] and holder.tool_calls == 0
|
||
assert llm.requests[1][0][3]["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[1][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:\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
|
||
|
||
|
||
def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||
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
|
||
|
||
|
||
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:
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||
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_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:\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 catalog
|
||
again and counts in ``tool_calls`` (no budget to exhaust)."""
|
||
catalog = [
|
||
("Deployments", "backups.md", "Backup Strategy"),
|
||
("Homelab", "aws-route53.md", "AWS Route53 Records"),
|
||
]
|
||
monkeypatch.setattr(agent, "list_catalog", lambda db: catalog)
|
||
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 documents:\n"
|
||
"source: Deployments | path: backups.md | title: Backup Strategy\n"
|
||
"source: Homelab | path: aws-route53.md | title: AWS Route53 Records"
|
||
)
|
||
# The answer request carries the catalog 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:\nA-CONTENT"
|
||
assert llm.requests[2][0][5]["content"] == "Document S/b.md:\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, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||
listing = "1 documents:\nsource: S | path: a.md | title: A"
|
||
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."""
|
||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||
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, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||
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)."""
|
||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||
holder = AgentHolder()
|
||
llm = FailingLLM(
|
||
[([StreamPiece("content", "partial ")], LLMError("mid-stream drop"))]
|
||
)
|
||
sleeps = _record_sleeps(monkeypatch)
|
||
|
||
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
|
||
with pytest.raises(LLMError, match="mid-stream drop"):
|
||
async for piece in run_agent(
|
||
cast("LLMClient", llm),
|
||
cast("Session", None),
|
||
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, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||
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)."""
|
||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||
holder = AgentHolder()
|
||
llm = FailingLLM([([], LLMError("connection refused"))])
|
||
sleeps = _record_sleeps(monkeypatch)
|
||
|
||
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
|
||
with pytest.raises(LLMError, match="connection refused"):
|
||
async for piece in run_agent(
|
||
cast("LLMClient", llm),
|
||
cast("Session", None),
|
||
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)
|
||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||
holder = AgentHolder()
|
||
llm = FailingLLM(
|
||
[([], LLMError("endpoint down")), ([StreamPiece("content", "never")], None)]
|
||
)
|
||
|
||
async def run() -> None:
|
||
gen = run_agent(
|
||
cast("LLMClient", llm),
|
||
cast("Session", None),
|
||
system_prompt="SYSTEM_PROMPT",
|
||
user_message="QUESTION",
|
||
seed_docs=[],
|
||
settings=_settings(),
|
||
holder=holder,
|
||
)
|
||
|
||
async def consumer() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||
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, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||
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, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||
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, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||
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 documents:\nsource: S | path: a.md | title: A",
|
||
}
|
||
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, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||
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)
|