feat(agent): align the document tools with the harness-trained shape — ls, read(path), grep(pattern, path?)

This commit is contained in:
2026-09-03 11:17:47 -04:00
parent 16f1cfbcaf
commit 801639efcc
55 changed files with 4031 additions and 1466 deletions
+205 -193
View File
@@ -1,4 +1,5 @@
"""Agent loop: the grounded-turn document tools (phase 37, task 03).
"""Agent loop: the grounded-turn document tools (phase 37, task 03; the
harness-aligned ``ls``/``read``/``grep`` surface, phase 70).
Probe verdict (task 01 — ``uv run python -m scripts.llm_probe --tools``
run live against aipi): **``probe: turbo tool_calls=supported 2026-08-26``**
@@ -18,44 +19,56 @@ task 04):
1. The model is offered the three OpenAI functions in :data:`AGENT_TOOLS`
for the whole turn — phase 45 removed the phase-37 per-tool budgets
(owner permission 2026-08-27, ``TODO.md`` L8: "allow the LLM to make
as many tool calls as it wants"): ``list_documents``,
``read_document`` and ``search_documents`` can each be called as many
times as the model needs, re-lists and re-searches included. With
``settings.agent_max_rounds`` (``BOR_AGENT_MAX_ROUNDS``, default 10)
at 0 the loop makes exactly one request with ``tools=None`` —
byte-identical to the pre-phase-37 chat path (the kill switch).
as many tool calls as it wants"): ``ls``, ``read`` and ``grep`` can
each be called as many times as the model needs, re-lists and
re-greps included. With ``settings.agent_max_rounds``
(``BOR_AGENT_MAX_ROUNDS``, default 10) at 0 the loop makes exactly
one request with ``tools=None`` — byte-identical to the
pre-phase-37 chat path (the kill switch). Phase 70 (owner permission
2026-09-03: "match existing harnesses as much as possible") renamed
and reshaped the tools to the harness-trained surface —
``ls(path?)`` / ``read(path)`` / ``grep(pattern, path?)``, the
pi.dev tool shapes the model was trained on: the combined
``source/path`` string is the canonical document identity in every
tool argument, refusal, and result header, and the old two-argument
split (with its self-correction and "teach the split" refusals) is
gone — the model's combined form is now simply correct. The
phase-68 A5 match/output contract rides along under the new name.
2. Each tool call the model emits is executed server-side against
Postgres only (no LLM, no network): ``list_documents`` returns the
indexed catalog — one ``source: X | path: Y | title: Z`` line per
document (phase 63: labeled fields — unambiguous for LLM parsing),
Postgres only (no LLM, no network): ``ls`` returns the indexed
catalog — one ``source: X | path: Y | title: Z`` line per document
(phase 63: labeled fields — unambiguous for LLM parsing),
``GET /api/docs`` order (uncapped in v1; the UI never shows it, only
the model does) — ``read_document`` returns the document's **full**
content (A7-revised contract: never truncated) — and
``search_documents`` greps the indexed documents (or one named
document) for a case-insensitive fixed substring and returns up to 20
the model does) — optionally scoped to one source name (a ``path``
argument matching no source name is a refusal; a registered source
with no indexed documents lists as ``0 documents:`` and counts) —
``read`` takes the combined ``source/path`` string, splits it at the
FIRST ``'/'`` (source names are directory basenames — they can never
contain ``'/'``), and returns the document's **full** content
(A7-revised contract: never truncated) — and ``grep`` greps the
indexed documents (or the one document a combined ``source/path``
names) for a case-insensitive fixed substring and returns up to 20
``source/path:line: text`` match lines (owner-locked A5, phase 68),
each line truncated to 200 chars. A search is a **locator**, not a
each line truncated to 200 chars. A grep is a **locator**, not a
context-adder: it never appends to the answer context (only
``read_document`` does — ``holder.read_docs`` is untouched by a
search).
``read`` does — ``holder.read_docs`` is untouched by a grep).
3. Rejected calls get a one-line refusal and count in nothing
(``holder.tool_calls`` tracks executed calls only): unknown tool name
→ ``"Unknown tool."``; missing ``source``/``path`` arguments; a search
without a usable ``pattern`` (missing, blank or non-string) or with a
half-specified ``source``/``path`` target; a document already in
context (seed or previously read) → ``"Already in your
context."``; an unknown ``source/path`` (read or scoped search) →
``"No document at …"``. A ``source`` argument containing a ``'/'``
(the model passed the combined ``source/path`` form) is first
self-corrected by splitting at the first slash (see
:func:`_resolve_document` — source names are directory basenames and
can never contain ``'/'``); if the split still matches nothing, the
refusal teaches the split instead of repeating the combined form.
A search that ran but found nothing is NOT a
rejection — its ``"No matches for …"`` line is a (counted) result.
A rejected call still consumes a *round* in the loop, so a
pathological stream that keeps emitting rejected calls is bounded by
the cap (point 4).
→ ``"Unknown tool."``; a ``read`` without a usable ``path`` (missing,
blank or non-string) → ``"read requires a string argument
'path'."``; a ``grep`` without a usable ``pattern`` (missing, blank
or non-string) → ``"grep requires a string argument
'pattern'."``; a scoped ``ls`` whose ``path`` matches no source name
→ ``"No source named '…' — check the ls output."``; a document
already in context (seed or previously read) → ``"Already in your
context."``; an unknown document (a ``read`` or scoped ``grep`` whose
combined ``source/path`` matches nothing — a bare source name, which
can never be a document, included) → ``"No document at '…' — check
the ls output."`` with the argument echoed as passed (the model sees
its own form). A grep that ran but found nothing is NOT a rejection
— its ``"No matches for …"`` line is a (counted) result. A rejected
call still consumes a *round* in the loop, so a pathological stream
that keeps emitting rejected calls is bounded by the cap (point 4).
4. Every call the model emits is appended back to the message history as
the assistant tool-call message + the tool result (refusals included),
consumes one round, and the model is called again. At the round cap —
@@ -82,10 +95,10 @@ task 04):
exactly one round. With ``settings.llm_retries=0`` every request is a
single plain attempt (the pre-phase-67 path).
The DB accessors (:func:`list_catalog`, :func:`find_document`,
:func:`all_documents`) and the :func:`grep_document` line matcher are
module-level functions so unit tests can monkeypatch them without a
database.
The DB accessors (:func:`list_catalog`, :func:`list_source_names`,
:func:`find_document`, :func:`all_documents`) and the
:func:`grep_document` line matcher are module-level functions so unit
tests can monkeypatch them without a database.
"""
from __future__ import annotations
@@ -100,6 +113,7 @@ from sqlalchemy.orm import Session
from app.config import Settings
from app.models import Document
from app.rag.git_sources import effective_sources
from app.rag.llm import (
LLMClient,
RetryPiece,
@@ -107,91 +121,78 @@ from app.rag.llm import (
ToolCallPiece,
chat_stream_retried,
)
from app.rag.source_removal import resolve_source_name
logger = logging.getLogger("app.agent")
#: Parameter descriptions shared by ``read_document`` and
#: ``search_documents``. The model repeatedly conflated the two fields —
#: passing the combined ``source/path`` string (as printed in search
#: result lines, read-result headers and refusals) as ``source`` — so
#: the descriptions define the split explicitly: ``source`` is the part
#: BEFORE the first ``'/'``, ``path`` the part after it, with a worked
#: example in the ``read_document`` description itself.
_SOURCE_PARAM: dict[str, Any] = {
"type": "string",
"description": (
"Top-level source name only (e.g. 'homelab') — the part BEFORE "
"the first '/' of a combined 'source/path' string, exactly as "
"shown after 'source: ' in the list_documents output. Must not "
"contain '/' itself — do not pass the full source/path here."
),
}
_PATH_PARAM: dict[str, Any] = {
"type": "string",
"description": (
"File path relative to the source directory (e.g. "
"'active/container_caddy/caddy.md') — the part AFTER the first "
"'/' of a combined 'source/path' string, exactly as shown after "
"'path: ' in the list_documents output. Must not start with the "
"source name."
),
}
#: The three agent tools (phase 37; ``search_documents`` added in phase
#: 68): OpenAI function definitions passed as ``tools=AGENT_TOOLS`` to
#: ``chat_stream`` for the whole grounded turn — phase 45 removed the
#: per-tool budgets; the round cap (``BOR_AGENT_MAX_ROUNDS``) is the only
#: bound.
#: The three agent tools (phase 70: the harness-aligned surface —
#: ``ls`` / ``read`` / ``grep``, the pi.dev tool shapes the model was
#: trained on, replacing the phase-37 list/read and phase-68 search
#: names): OpenAI function
#: definitions passed as ``tools=AGENT_TOOLS`` to ``chat_stream`` for
#: the whole grounded turn — phase 45 removed the per-tool budgets; the
#: round cap (``BOR_AGENT_MAX_ROUNDS``) is the only bound. The combined
#: ``source/path`` string is the canonical document identity in every
#: argument (phase 70, owner permission 2026-09-03).
AGENT_TOOLS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "list_documents",
"name": "ls",
"description": (
"List every document indexed in the knowledge base, one "
"`source: X | path: Y | title: Z` line each"
),
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "read_document",
"description": (
"Add the full content of one more indexed document "
"to your context. A document is identified by the "
"(source, path) pair exactly as shown in the "
"list_documents output: 'source' is the top-level "
"source name only (e.g. 'homelab'), 'path' is the file "
"path inside that source (e.g. "
"'active/container_caddy/caddy.md'). If you only have a "
"combined 'source/path' string (as in search_documents "
"results), split it at the FIRST '/': the part before "
"is the source, the part after is the path. Example: "
"read_document(source='homelab', "
"path='active/container_caddy/caddy.md')."
"List the indexed documents as `source: X | path: Y | "
"title: Z` lines."
),
"parameters": {
"type": "object",
"properties": {"source": _SOURCE_PARAM, "path": _PATH_PARAM},
"required": ["source", "path"],
"properties": {
"path": {
"type": "string",
"description": (
"Source name to list one source's documents "
"(e.g. 'homelab'); omit to list every "
"document."
),
}
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "search_documents",
"name": "read",
"description": (
"Search every indexed document for an exact string "
"(case-insensitive) and return up to 20 matching lines as "
"'source/path:line: text' — use this to locate content, "
"then read_document the winner (each result line's "
"'source/path' splits at the first '/': the part before "
"is the source, the part after is the path). Optionally "
"pass 'source' and 'path' (as shown in list_documents) "
"to search one document only."
"Add the full content of one indexed document to your "
"context."
),
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"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')."
),
}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "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`."
),
"parameters": {
"type": "object",
@@ -203,8 +204,15 @@ AGENT_TOOLS: list[dict[str, Any]] = [
"substring, not a regex)"
),
},
"source": _SOURCE_PARAM,
"path": _PATH_PARAM,
"path": {
"type": "string",
"description": (
"Limit the search to one document, as a "
"combined `source/path` string from the "
"`ls` output (omit to search every "
"document)."
),
},
},
"required": ["pattern"],
},
@@ -217,8 +225,8 @@ AGENT_TOOLS: list[dict[str, Any]] = [
#: their pathological repetition (phase 45).
ALREADY_IN_CONTEXT = "Already in your context."
UNKNOWN_TOOL = "Unknown tool."
MISSING_READ_ARGS = "read_document requires string arguments 'source' and 'path'."
MISSING_SEARCH_ARGS = "search_documents requires a string argument 'pattern'."
MISSING_READ_ARGS = "read requires a string argument 'path'."
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
#: Search caps (owner-locked A5, phase 68): a global per-call match cap
#: (across documents, in catalog order) and a per-match-line char limit.
@@ -227,7 +235,7 @@ SEARCH_LINE_LIMIT = 200
#: No-match result lines (templates — the pattern is truncated to 100
#: chars before formatting, to keep a long pattern from bloating the
#: tool result). A no-match line is a *result* of an executed search,
#: tool result). A no-match line is a *result* of an executed grep,
#: not a refusal (see the module docstring, point 3).
NO_MATCHES = "No matches for '{pattern}' in the knowledge base."
NO_MATCHES_SCOPED = "No matches for '{pattern}' in {source}/{path}."
@@ -247,6 +255,31 @@ def list_catalog(db: Session) -> list[tuple[str, str, str]]:
return [(source, path, title) for source, path, title in rows]
def list_source_names(db: Session) -> list[str]:
"""Every registered source name, deduped, in registry order.
The source registry (the ``git_sources`` rows — the
``BOR_GIT_SOURCES`` env fallback while the table is empty) is the
source of truth for *source* names independent of document count:
a registered source with no indexed documents still lists (as
``0 documents:`` — the scoped ``ls`` must not refuse it as unknown).
Names resolve exactly as the import pipeline indexes them
(:func:`app.rag.source_removal.resolve_source_name` — reuse, or a
scoped ``ls`` would judge the wrong names unknown, the phase-69
"RAG consistent with the registry" invariant); two rows resolving
to the same name (the phase-69 sibling case) share documents, so
the name is listed once. Module-level (not a method) so unit tests
can monkeypatch it.
"""
rows, _origin = effective_sources(db)
names: list[str] = []
for row in rows:
name = resolve_source_name(row)
if name not in names:
names.append(name)
return names
def find_document(db: Session, source: str, path: str) -> Document | None:
"""The indexed document at ``(source, path)``, or ``None``.
@@ -257,42 +290,33 @@ def find_document(db: Session, source: str, path: str) -> Document | None:
)
def _resolve_document(
db: Session, source: str, path: str
) -> tuple[Document | None, str, str]:
"""``(source, path)`` → document, with combined-form self-correction.
def _resolve_path(db: Session, combined: str) -> tuple[Document | None, str, str]:
"""The combined ``source/path`` identity → document (phase 70).
The exact pair is tried first. If it misses and *source* contains a
``'/'``, the model passed the combined ``source/path`` form — search
result lines, read-result headers and the generic refusal all print
that form, so the model treats it as the document's identity. Source
names are directory basenames (``app.rag.importer``: ``source =
root.name``) and can never contain a ``'/'``, so the pair is retried
at the FIRST slash: the part before is the source name, the part
after is the path. A second candidate covers a split at a LATER
slash (``source`` carried source + leading directories, ``path`` the
remainder).
Returns ``(doc, src, p)`` where ``(src, p)`` is the first-slash
split when one was attempted (so a refusal can teach it), else the
original pair.
The canonical document identity in every tool argument, refusal and
result header is the combined string exactly as printed in the
``ls`` output, the ``Document …`` result headers, and the grep
result lines. Source names are directory basenames (``app.rag.importer``:
``source = root.name``) and can never contain a ``'/'``, so the
split at the FIRST slash is exact: the part before is the source
name, the part after is the path. Returns ``(doc, source, path)``
with the split pair (so callers can echo the canonical form, e.g.
the scoped no-match line); no ``'/'`` in the argument →
``(None, combined, "")`` — a bare source name is never a document
(no DB lookup; the refusal echoes the argument as passed).
"""
doc = find_document(db, source, path)
if doc is not None or "/" not in source:
return doc, source, path
split_source, _, split_path = source.partition("/")
doc = find_document(db, split_source, split_path)
if doc is None and path and path != split_path:
doc = find_document(db, split_source, f"{split_path}/{path}")
return doc, split_source, split_path
if "/" not in combined:
return None, combined, ""
source, _, path = combined.partition("/")
return find_document(db, source, path), source, path
def all_documents(db: Session) -> list[Document]:
"""Every indexed document (full rows), ordered by ``(source, path)``
— catalog order.
The whole-KB ``search_documents`` path loads all contents in this one
bulk query (catalog order is the locked match order, owner-locked A5).
The whole-KB ``grep`` path loads all contents in this one bulk query
(catalog order is the locked match order, owner-locked A5).
Module-level (not a method) so unit tests can monkeypatch it.
"""
return list(
@@ -322,8 +346,8 @@ def grep_document(content: str, pattern: str) -> list[tuple[int, str]]:
class AgentHolder:
"""Per-turn agent state the API layer reads after the stream (task 04).
``read_docs``: the documents ``read_document`` added to the context,
in read order (deduped — re-reading a document appends nothing).
``read_docs``: the documents ``read`` added to the context, in read
order (deduped — re-reading a document appends nothing).
``tool_calls``: how many tool calls executed (re-lists included);
rejected calls (unknown tool, unknown/missing arguments or document,
already-in-context) do not count. Drives the per-turn log line's
@@ -343,78 +367,64 @@ def _execute_tool(
"""Execute one tool call server-side (DB only).
Returns the tool result text. A successful call bumps
``holder.tool_calls`` (a successful read also appends the
:class:`Document` to ``holder.read_docs``; a search never does — it
is a locator, locked A5); rejected calls return their refusal line
and count in nothing. A search that ran but found nothing is still a
successful (counted) call — its no-match line is a result, not a
refusal. A combined-form ``source`` (containing a ``'/'``) is
self-corrected through :func:`_resolve_document` before any refusal.
``holder.tool_calls`` (a successful ``read`` also appends the
:class:`Document` to ``holder.read_docs``; a ``grep`` never does —
it is a locator, locked A5); rejected calls return their refusal
line and count in nothing. A grep that ran but found nothing is
still a successful (counted) call — its no-match line is a result,
not a refusal. Document targets are combined ``source/path``
strings, resolved by :func:`_resolve_path` (the canonical identity,
phase 70).
"""
if call.name == "list_documents":
if call.name == "ls":
raw_path = call.arguments.get("path")
scope = raw_path.strip() if isinstance(raw_path, str) else ""
rows = list_catalog(db)
if scope:
if scope not in list_source_names(db):
return f"No source named '{scope}' — check the ls output."
rows = [row for row in rows if row[0] == scope]
listing = f"{len(rows)} documents:\n" + "\n".join(
f"source: {source} | path: {path} | title: {title}"
for source, path, title in rows
)
holder.tool_calls += 1
return listing
if call.name == "read_document":
raw_source = call.arguments.get("source")
if call.name == "read":
raw_path = call.arguments.get("path")
source = raw_source.strip() if isinstance(raw_source, str) else ""
path = raw_path.strip() if isinstance(raw_path, str) else ""
if not source or not path:
arg = raw_path.strip() if isinstance(raw_path, str) else ""
if not arg:
return MISSING_READ_ARGS
known = {(doc.source, doc.path) for doc in (*seed_docs, *holder.read_docs)}
if (source, path) in known:
return ALREADY_IN_CONTEXT
doc, split_source, split_path = _resolve_document(db, source, path)
# The dedupe check needs no DB: the split pair of a combined
# identity that is in context is in `known` as-is (the resolve
# below would find the same document).
if "/" in arg:
src, _, p = arg.partition("/")
if (src, p) in known:
return ALREADY_IN_CONTEXT
doc, _source, _path = _resolve_path(db, arg)
if doc is None:
if "/" in source:
# Educational refusal: the combined form is the model's
# mistake — teach the split instead of repeating it.
return (
f"source must not contain '/': for '{source}' call "
f"read_document(source='{split_source}', "
f"path='{split_path}')."
)
return (
f"No document at {source}/{path} — check the list_documents output."
)
if (doc.source, doc.path) in known:
# A self-corrected combined form for a document already in
# context (the raw pair above cannot have matched it).
return ALREADY_IN_CONTEXT
# Echo the argument as passed — the model sees its own form
# (a bare source name can never be a document, no DB lookup).
return f"No document at '{arg}' — check the ls output."
holder.read_docs.append(doc)
holder.tool_calls += 1
return f"Document {doc.source}/{doc.path}:\n{doc.content}"
if call.name == "search_documents":
if call.name == "grep":
raw_pattern = call.arguments.get("pattern")
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
if not pattern:
return MISSING_SEARCH_ARGS
raw_source = call.arguments.get("source")
raw_path = call.arguments.get("path")
source = raw_source.strip() if isinstance(raw_source, str) else ""
path = raw_path.strip() if isinstance(raw_path, str) else ""
if (source == "") != (path == ""):
# A half-specified target is a model error — fail loud with
# the missing-args refusal instead of silently widening to a
# whole-KB search (house style).
return MISSING_SEARCH_ARGS
if source:
target, split_source, split_path = _resolve_document(db, source, path)
scope = raw_path.strip() if isinstance(raw_path, str) else ""
scoped_to: tuple[str, str] | None = None
if scope:
target, src, p = _resolve_path(db, scope)
if target is None:
if "/" in source:
return (
f"source must not contain '/': for '{source}' use "
f"source='{split_source}', path='{split_path}'."
)
return (
f"No document at {source}/{path} — check the list_documents output."
)
return f"No document at '{scope}' — check the ls output."
docs: list[Document] = [target]
scoped_to = (src, p) # the resolved (canonical) identity
else:
docs = all_documents(db)
matches: list[str] = []
@@ -427,13 +437,15 @@ def _execute_tool(
break
if len(matches) >= SEARCH_MAX_MATCHES:
break # the global cap is hit — stop scanning
holder.tool_calls += 1 # the search executed (no-match counts too)
# Locked A5: a search never adds context — read_docs untouched.
holder.tool_calls += 1 # the grep executed (no-match counts too)
# Locked A5: a grep never adds context — read_docs untouched.
if not matches:
shown = pattern[:100] # keep a long pattern short in the line
if source:
if scoped_to is not None:
# The scoped no-match line is keyed on the resolved
# source/path (== the argument, stripped).
return NO_MATCHES_SCOPED.format(
pattern=shown, source=source, path=path
pattern=shown, source=scoped_to[0], path=scoped_to[1]
)
return NO_MATCHES.format(pattern=shown)
return "\n".join(matches)
+3 -3
View File
@@ -74,12 +74,12 @@ class ToolCallPiece:
``id`` is the model's tool_call id (synthesized as ``call_<index>``
when the wire never carried one), ``name`` is the function name
(whatever the caller's ``tools`` list names — for the agent loop,
``list_documents`` / ``read_document``), and ``arguments`` is the
``ls`` / ``read`` / ``grep``, phase 70), and ``arguments`` is the
parsed JSON object (``{}`` when the model sent none).
"""
id: str # the model's tool_call id; synthesized "call_<index>" when absent
name: str # "list_documents" | "read_document" (whatever AGENT_TOOLS names)
name: str # "ls" | "read" | "grep" (whatever AGENT_TOOLS names, phase 70)
arguments: dict[str, Any]
@@ -124,7 +124,7 @@ def _materialize_tool_calls(
Malformed ``arguments`` JSON raises :class:`LLMError` — a silently
dropped tool call would corrupt the agent loop (fail-loud house
style). Empty/``null`` arguments become ``{}`` (a no-parameter call
such as ``list_documents``).
such as an unscoped ``ls``).
"""
pieces: list[ToolCallPiece] = []
for index in sorted(slots):
+30 -19
View File
@@ -24,11 +24,13 @@ the ``<tuning>`` section (order: ``<relevance>`` →
roughly what the KB contains before retrieval. With an empty row the
prompt is byte-identical to the pre-phase text.
Agent tools (phase 37): the **HIGH** prompt only carries a ``<tools>``
section after the ``<documents>`` body — the grounded turn may call the
server-side ``list_documents`` / ``read_document`` tools (round-capped,
see :mod:`app.rag.agent`). The LOW/deflection prompt never carries it
and stays byte-identical to the pre-phase text.
Agent tools (phase 37; phase 70: the copy teaches the harness-aligned
``ls`` / ``read`` / ``grep`` shapes): the **HIGH** prompt only carries a
``<tools>`` section after the ``<documents>`` body — the grounded turn
may extend its context through the three server-side tools (round-
capped, see :mod:`app.rag.agent`; the cap is the bound and this section
does not re-state it, phase 45). The LOW/deflection prompt never
carries it and stays byte-identical to the pre-phase text.
"""
from __future__ import annotations
@@ -73,21 +75,29 @@ _KB_INTRO = (
)
#: The ``<tools>`` instructions section — **HIGH prompt only** (phase 37,
#: task 03): a grounded turn may extend its context through the two
#: server-side tools (round cap: ``BOR_AGENT_MAX_ROUNDS``, see
#: :mod:`app.rag.agent`). Appended after
#: the mode body (``<documents>``), so the instructions are the last
#: thing the model reads. The LOW/deflection prompt never carries it —
#: a deflection has no grounded context to extend — and stays
#: byte-identical to the pre-phase text. The E2E mock keys off the
#: ``<tools>`` marker's *presence*, not this wording.
#: task 03; phase 70: the copy is rewritten for the harness-aligned
#: ``ls`` / ``read`` / ``grep`` shapes, names/args exactly as the
#: ``AGENT_TOOLS`` schemas in :mod:`app.rag.agent`): a grounded turn may
#: extend its context through the three server-side tools (round cap:
#: ``BOR_AGENT_MAX_ROUNDS`` — the cap is the bound and this section does
#: not re-state it, phase 45). Appended after the mode body
#: (``<documents>``), so the instructions are the last thing the model
#: reads. The LOW/deflection prompt never carries it — a deflection has
#: no grounded context to extend — and stays byte-identical to the
#: pre-phase text. The E2E mock keys off the ``<tools>`` marker's
#: *presence*, not this wording.
TOOLS_SECTION: str = (
"<tools>\n"
"If the documents in your context reference other files, or you need "
"content that is not included above, call `list_documents` to see what "
"is indexed, then `read_document` to pull in exactly one more document. "
"Answer as soon as you have what you need — do not read more than one "
"extra document.\n"
"You may extend your context with three tools. `ls` lists the "
"indexed documents as `source: X | path: Y | title: Z` lines "
"(pass a source name as `path` to list one source's documents; "
"omit it to list every document). `grep` locates an exact string "
"(case-insensitive) in the indexed documents and returns up to 20 "
"matching `source/path:line: text` lines — a locator, not a "
"context-adder: read the winner with `read`. `read` pulls in one "
"document by its combined `source/path` string, exactly as shown in "
"the `ls` output, adding its full content to your context. Answer "
"as soon as you have what you need.\n"
"</tools>"
)
@@ -180,7 +190,8 @@ def build_high_prompt(
kb_overview: str | None = None,
) -> str:
"""Grounded turn: locked persona (+ steering, + KB overview) + full
texts of the top documents + the ``<tools>`` instructions (phase 37).
texts of the top documents + the ``<tools>`` instructions (phase 37;
the phase-70 copy teaches the ``ls`` / ``read`` / ``grep`` shapes).
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their