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
+31 -33
View File
@@ -49,27 +49,28 @@ outline (0 when absent) and the per-turn log line records
Agent document tools (phase 37, PLAN §4 extension, owner permission
2026-08-26; phase 45 removed the per-tool budgets — owner permission
2026-08-27; phase 68 added the ``search_documents`` grep): a
2026-08-27; phase 70 aligned the surface to the harness-trained
``ls`` / ``read`` / ``grep`` — owner permission 2026-09-03): a
**grounded** turn (``not plan.deflected``) no longer streams a bare
``chat_stream`` — it runs the agent loop (``app.rag.agent.run_agent``),
which offers the model the three server-side tools
``list_documents`` / ``read_document`` / ``search_documents`` for the
whole turn (as many calls as the model wants, re-lists and re-searches
included) until it answers or the round cap (``BOR_AGENT_MAX_ROUNDS``,
default 10) forces one final no-tools answer. Each model-requested call
streams as an SSE ``tool`` event — ``{"type": "tool", "name": …,
"argument": "source/path" | pattern | null}`` — ahead of the answer's
``delta`` frames: ``argument`` is the read document's path for
``read_document``, the raw search pattern for ``search_documents``
(a non-string pattern — a model error the backend refuses — yields
null), and null for ``list_documents``. ``done.sources``,
``query_log.sources`` and the per-turn log line all report the same
combined source list (retrieval docs + the agent's read docs, deduped
by ``(source, path)``, order preserved — a search adds no source; it is
a locator, locked A5), and the log line records ``tool_calls=N`` after
``thinking_chars=N`` (PLAN §9 line extension — ``N`` counts executed
tool calls; rejected calls do not count). **Deflected turns keep the
direct ``chat_stream`` — byte-identical to the pre-phase path (A8):**
``ls`` / ``read`` / ``grep`` for the whole turn (as many calls as the
model wants, re-lists and re-greps included) until it answers or the
round cap (``BOR_AGENT_MAX_ROUNDS``, default 10) forces one final
no-tools answer. Each model-requested call streams as an SSE ``tool``
event — ``{"type": "tool", "name": …, "argument": … | null}`` — ahead
of the answer's ``delta`` frames: ``argument`` is the single string the
model passed — ``read``'s ``path`` (the combined ``source/path``),
``grep``'s ``pattern``, ``ls``'s ``path`` — or null (a non-string
value — a model error the backend refuses — and an omitted argument
both yield null). ``done.sources``, ``query_log.sources`` and the
per-turn log line all report the same combined source list (retrieval
docs + the agent's read docs, deduped by ``(source, path)``, order
preserved — a grep adds no source; it is a locator, locked A5), and the
log line records ``tool_calls=N`` after ``thinking_chars=N`` (PLAN §9
line extension — ``N`` counts executed tool calls; rejected calls do not
count). **Deflected turns keep the direct ``chat_stream`` —
byte-identical to the pre-phase path (A8):**
the LOW prompt never carries tools, and with ``agent_max_rounds`` at
**0** ``run_agent`` makes exactly one ``tools=None`` request,
reproducing the pre-phase behavior (the kill switch).
@@ -400,21 +401,18 @@ async def chat(
try:
async for piece in answer_stream: # StreamPiece | ToolCallPiece | RetryPiece
if isinstance(piece, ToolCallPiece):
# Phase 37 (PLAN §4 extension): one SSE ``tool``
# frame per model-requested call. ``argument`` is
# the read_document "source/path"; phase 68
# extends it with the search_documents pattern
# (a non-string pattern — a model error the
# backend refuses — is null); null otherwise.
if piece.name == "read_document":
argument = (
f"{piece.arguments.get('source')}/{piece.arguments.get('path')}"
)
elif piece.name == "search_documents":
pattern = piece.arguments.get("pattern")
argument = pattern if isinstance(pattern, str) else None
else:
argument = None
# Phase 37 (PLAN §4 extension; phase 70): one SSE
# ``tool`` frame per model-requested call.
# ``argument`` is the single string the model
# passed — ``read``'s ``path`` (the combined
# ``source/path``), ``grep``'s ``pattern``,
# ``ls``'s ``path`` — or null (a non-string value
# is a model error the backend refuses, as is an
# omitted argument).
argument = piece.arguments.get(
"pattern" if piece.name == "grep" else "path"
)
argument = argument if isinstance(argument, str) else None
yield sse_event(
ChatToolEvent(name=piece.name, argument=argument).model_dump()
)
+1 -1
View File
@@ -36,7 +36,7 @@ def doc_format(path: str) -> str:
@router.get("/docs", response_model=DocList)
def list_documents(
def list_indexed_documents(
db: Session = Depends(get_db), # noqa: B008
_admin: None = Depends(require_admin), # noqa: B008
) -> DocList:
+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
+23 -18
View File
@@ -71,24 +71,26 @@ class ChatThinkingEvent(BaseModel):
class ChatToolEvent(BaseModel):
"""SSE frame for one agent tool call (phase 37, PLAN §4 extension).
A15 extension (owner permission 2026-08-26; ``search_documents``
added in phase 68): a grounded turn may call the server-side
document tools (``list_documents`` / ``read_document`` /
``search_documents``, see :mod:`app.rag.agent`); each model-requested
A15 extension (owner permission 2026-08-26; the grep added in phase
68; phase 70 aligned the surface to the harness-trained
``ls`` / ``read`` / ``grep`` — owner permission 2026-09-03): a
grounded turn may call the server-side document tools (``ls`` /
``read`` / ``grep``, see :mod:`app.rag.agent`); each model-requested
call streams as ``{type: "tool", name: str, argument: str | null}``
ahead of the answer's ``delta`` frames. ``argument`` is the read
document's ``"source/path"`` for ``read_document``, the search
pattern for ``search_documents``, and null otherwise (a non-string
pattern — a model error the backend refuses — is null). The client
renders each frame as a "calling tool" line/state (phase 37 task 05);
the ``delta`` / ``done`` shapes are unchanged — the read document is
reflected in ``done.sources`` instead (a search adds no source: it is
a locator, locked A5).
ahead of the answer's ``delta`` frames. ``argument`` is the single
string argument the model passed — ``read``'s ``path`` (the combined
``source/path``), ``grep``'s ``pattern``, ``ls``'s ``path`` — or
null (a non-string value, a model error the backend refuses, and an
omitted argument both yield null). The client renders each frame as
a "calling tool" line/state (phase 37 task 05); the ``delta`` /
``done`` shapes are unchanged — the read document is reflected in
``done.sources`` instead (a grep adds no source: it is a locator,
locked A5).
"""
type: Literal["tool"] = "tool"
name: str # "list_documents" | "read_document" | "search_documents"
argument: str | None = None # "source/path" for read_document, pattern for search_documents
name: str # "ls" | "read" | "grep" (whatever AGENT_TOOLS names)
argument: str | None = None # the single string argument passed, or null
class ChatDoneEvent(BaseModel):
@@ -375,10 +377,13 @@ class ToolCall(BaseModel):
"""One agent tool-call record (the phase-37 ``tools`` record shape).
Mirrors the ``{name, argument}`` pair the SSE ``tool`` frames carry
(PLAN §4 extension): ``argument`` is the read document's
``"source/path"`` for ``read_document`` and null otherwise. Stored
inside :class:`ChatMessage.tools` so a saved chat restores the
"calling tool" lines pixel-identical (phase 50).
(PLAN §4 extension; phase 70): ``argument`` is the single string
argument the model passed (``read``'s combined ``source/path``,
``grep``'s pattern, ``ls``'s scope) or null. Stored inside
:class:`ChatMessage.tools` so a saved chat restores the "calling
tool" lines pixel-identical (phase 50). Saved chats persisting the
pre-phase-70 tool names still validate — ``name`` is opaque
(no migration, locked).
"""
name: str