feat(agent): align the document tools with the harness-trained shape — ls, read(path), grep(pattern, path?)
This commit is contained in:
+31
-33
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user