All gates green. Verification complete. **Phase 123 — final verification pass (all 4 tasks already in `complete/`)** - Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer. - `uv run pytest` → **2796 passed**, exit 0 (unit + integration). - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%). - `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation. - `uv run ruff check . && uv run pyright` → clean (0 errors). **Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run). No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
1707 lines
83 KiB
Python
1707 lines
83 KiB
Python
"""Agent loop: the grounded-turn document tools (phase 37, task 03; the
|
|
harness-aligned ``ls``/``read``/``grep`` surface, phase 70; the
|
|
drill-down tree ``ls`` + sync-time folder summaries, phase 94).
|
|
|
|
Probe verdict (task 01 — ``uv run python -m scripts.llm_probe --tools``
|
|
run live against aipi): **``probe: turbo tool_calls=supported 2026-08-26``**
|
|
— ``turbo`` answers OpenAI ``tools`` requests with
|
|
``finish_reason="tool_calls"`` and streams the calls as indexed
|
|
``delta.tool_calls`` partials (id + name on the first partial, arguments
|
|
in fragments). This module therefore uses the **native tool-calling
|
|
path**: tool calls arrive as :class:`app.rag.llm.ToolCallPiece` values
|
|
from ``chat_stream(messages, tools=AGENT_TOOLS)``. The prompt-based
|
|
JSON-block fallback (documented in the task file) is *not* implemented —
|
|
it exists only for a "not supported"/"intermittent" verdict, and the
|
|
probe came back "supported".
|
|
|
|
Real-model gate (phase 72, task 05 — live vs the configured chat
|
|
model; re-run 2026-09-04 on the controlled fixture KB — see
|
|
``TOOL_CALLING_TESTING.md``): the bare-path teaching (did-you-mean
|
|
refusals) makes every trap self-correct in exactly one round — zero
|
|
cap hits, zero repeat loops. Locked derived battery (phase 72,
|
|
executed ≥ 90 % bar): ``gate: lite FAIL turns=10 answered=10 caps=0
|
|
tool-turns=10 calls 5/15 executed (33%) contract 12/15 (80%) 2026-09-04
|
|
(wall 47.7s)`` — the bar is blocked by :data:`ALREADY_IN_CONTEXT` dedupe
|
|
refusals on the corrected re-reads, a copy-invariant model behavior
|
|
(five copy variants, 2026-09-03 → 04) and an app-semantics decision
|
|
(TOOL_CALLING_TESTING.md §7). Controlled fixture battery (the 2026-09-04
|
|
methodology — contract accuracy ≥ 90 %): PASS on three consecutive runs,
|
|
``contract 11/11 (100%)`` / ``12/13 (92%)`` / ``11/11 (100%)``.
|
|
|
|
Loop contract (one grounded chat turn; the API layer wires this in,
|
|
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"): ``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.
|
|
Phase 94 revision (owner permission 2026-09-10, ``TODO.md`` L4 — the
|
|
tool-surface revision recorded in the phase 94 overview ``00_phase.md``;
|
|
PLAN.md is being redone by the owner): the ``ls`` RESULT format and
|
|
``path`` semantics changed — ``ls`` is now a filesystem-style
|
|
drill-down tree (no path: the synced sources with counts + stored
|
|
source-root summaries; a source name: its top-level folders + files;
|
|
a ``source/folder`` path: that folder's subfolders + files — the
|
|
model drills one level per call instead of flooding the whole
|
|
catalog into one result), while the tool NAME and the
|
|
``read``/``grep`` contract (combined ``source/path``) are untouched.
|
|
2. Each tool call the model emits is executed server-side against
|
|
Postgres only (no LLM, no network): ``ls`` lists ONE level of the
|
|
drill-down tree (phase 94, task 03) — no ``path``: every registered
|
|
source (registry order; a 0-document source still lists) as
|
|
``{source} — {n} documents`` plus the indented stored source-root
|
|
summary line when one is in ``folder_summaries``; a ``path`` that is
|
|
a registered source name (no ``/``): that source's root folder —
|
|
each direct subfolder `` {sub}/ — {m} documents[: {summary}]``
|
|
(the count is the subfolder's recursive subtree — every document
|
|
whose path equals the folder or starts with ``folder + "/"``, the
|
|
same set the sync-time folder summary describes — and the file
|
|
lines ``source: X | path: Y | title: Z | date: YYYY-MM-DD`` (the
|
|
canonical ``read``/``grep`` identity — the phase-63 labeled format —
|
|
plus the phase-106 D5 ``date`` field APPENDED after ``title``; only
|
|
FILE lines carry a date — source/folder lines are not documents)
|
|
in path order (``GET /api/docs`` order), capped at
|
|
:data:`LS_MAX_FILE_LINES` lines + one deterministic grep-pointer
|
|
note for the rest (a 500-file folder costs 50 lines, never 500);
|
|
a ``source/folder`` ``path``: that folder's subfolders + own file
|
|
lines (the same template, ``identity = source + "/" + folder``);
|
|
a registered source with no documents lists its header line alone
|
|
(``… — 0 documents, 0 folders:`` — the old ``0 documents:``
|
|
behavior preserved in spirit) — every successful listing (top/root/
|
|
folder) counts; a ``source/…`` argument whose first segment names no
|
|
registered source is the no-source refusal (below, the segment
|
|
echoed), and a folder segment matching no indexed prefix is the
|
|
:data:`NOT_A_FOLDER` teaching (below) —
|
|
``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 content — WHOLE at or
|
|
under ``settings.read_max_chars`` (``BOR_READ_MAX_CHARS``, default
|
|
128 000 chars ≈ 32k tokens), and CAPPED above it (phase 95, owner
|
|
permission 2026-09-10, ``TODO.md`` L5): the first ``read_max_chars``
|
|
chars plus the shared :data:`TRUNCATION_MARKER` and the pinned
|
|
:data:`READ_TRUNCATION_NOTICE` (the rest is NOT in the model's
|
|
context; ``grep`` — which searches the whole document — is the
|
|
follow-up), with the truncation recorded on the holder so the loop
|
|
yields a :class:`app.rag.llm.ToolResultPiece` (task 02 → the SSE
|
|
``tool_result`` frame + UI marker). **A6 re-revised contract
|
|
(phase 118, owner directive 2026-09-15):** the retrieval
|
|
``<documents>`` path seeds SUMMARIES only — a suggested document's
|
|
full text never enters the prompt on the retrieval path; full text
|
|
enters the context ONLY through this ``read`` TOOL path, which is
|
|
the only capped read (the phase-95 cap unchanged). 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 grep is a **locator**, not a
|
|
context-adder: it never appends to the answer context (only
|
|
``read`` does — ``holder.read_docs`` is untouched by a grep).
|
|
The 2026-09-05 incident teaching (the harness prior is that grep
|
|
takes a REGEX; this grep is a fixed substring and the contract does
|
|
not change): when a grep RAN but found nothing and the pattern is
|
|
regex-shaped (:func:`looks_like_regex`) with a non-empty
|
|
:func:`plain_form`, the no-match result is the TEACHING line —
|
|
:data:`NO_MATCHES_REGEX` / :data:`NO_MATCHES_REGEX_SCOPED` — which
|
|
states the plain-substring contract and hands over the plain-form
|
|
retry hint; a grep that matched, or a no-match for a plain pattern
|
|
(or one that reduces to nothing), is byte-identical to the ordinary
|
|
line. Still a (counted) result, never a refusal.
|
|
3. Rejected calls get a one-line refusal and count in nothing
|
|
(``holder.tool_calls`` tracks executed calls only): unknown tool name
|
|
→ ``"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``'s FIRST segment (split
|
|
at the first ``/``) names no registered source — a bare unknown
|
|
name (the 2026-09-03 incident's ``ls(path='.')``) or the source
|
|
segment of a ``source/…`` argument (phase 94: a ``/`` now names a
|
|
folder, so the phase-72 document-path teaching refusal is deleted)
|
|
→ :data:`NO_SOURCE_NOT_A_DIRECTORY`, the
|
|
no-source refusal with the teaching parenthetical appended, the
|
|
segment echoed; a ``source/…`` argument whose folder segment
|
|
matches no indexed prefix (the phase-94 existence rule — some
|
|
indexed path of the source starts with ``folder + "/"``) →
|
|
:data:`NOT_A_FOLDER`, the drill-down teaching with the argument
|
|
echoed and the deepest existing ancestor's direct subfolders
|
|
listed, so the model self-corrects in the next round; a document
|
|
already READ into full-text context (phase 118: the suggested
|
|
seeds are summary blocks in the prompt, not full text — only an
|
|
already-read document is refused) →
|
|
:data:`ALREADY_IN_CONTEXT` (phase 72, task 05 gate iteration:
|
|
the line names the correct action — answer from the text already
|
|
in the prompt, do not call read again — so a fired refusal ends
|
|
the loop instead of inviting a repeat); 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) — EXCEPT the phase-72 "did you mean …?" teaching
|
|
(task 02): when the argument is a path (contains ``/``) that matches
|
|
an indexed document's ``path`` (exact or as a ``/arg`` suffix,
|
|
case-sensitive, catalog order — :func:`find_path_candidates`, a pure
|
|
catalog lookup, one bulk query, called only from this refusal path),
|
|
the refusal names the combined identity instead — exactly one match
|
|
→ :data:`NO_DOCUMENT_DID_YOU_MEAN` (``did you mean
|
|
'source/path'?``), two or more → :data:`NO_DOCUMENT_DID_YOU_MEAN_MANY`
|
|
(up to :data:`SUGGESTION_LIMIT` identities), so the harness-prior
|
|
misuse (the bare document path missing the source prefix,
|
|
``read('app/rag/importer.py')``) self-corrects in one round; it is
|
|
still a refusal (counts in nothing, consumes a round — no silent
|
|
argument normalization), and a bare argument (no ``/``) or a
|
|
zero-candidate path keeps the line above byte-identical (the bare
|
|
form never hits the DB). 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 —
|
|
``max_rounds = settings.agent_max_rounds`` (``BOR_AGENT_MAX_ROUNDS``,
|
|
default 10) — the loop forces one final retried no-tools request
|
|
(``chat_stream_retried`` with ``tools=None``) and returns: the cap is
|
|
the **only** forced exit (besides "the stream carried no calls"), and
|
|
it bounds pathological rejected-call streams.
|
|
5. A rare stream that carries both content and a tool call keeps the
|
|
content (it was already emitted) **and** still runs the tool.
|
|
6. *holder* (an :class:`AgentHolder`) records the read documents and the
|
|
number of executed tool calls (re-lists included); the API layer
|
|
(task 04) reads it after the stream to extend ``done.sources`` /
|
|
``query_log.sources`` and the per-turn log line (``tool_calls=N``).
|
|
7. Retries (phase 67, owner-locked A2): every model request — each tool
|
|
round and the forced final ``tools=None`` call — goes through
|
|
``chat_stream_retried``: a round that dies before its first piece is
|
|
restarted with the SAME messages (up to ``settings.llm_retries``
|
|
restarts, a flat ``settings.llm_retry_delay`` between attempts, each
|
|
preceded by a :class:`app.rag.llm.RetryPiece` the API layer turns into
|
|
an SSE ``retry`` frame); a round that already streamed a piece fails
|
|
the turn as before (no partial answer is ever redone). Retries are
|
|
invisible to the round cap: a round that needed a retry still consumes
|
|
exactly one round. With ``settings.llm_retries=0`` every request is a
|
|
single plain attempt (the pre-phase-67 path).
|
|
|
|
Scaffolding guardrail (phase 71, deterministic only — owner permission
|
|
2026-09-03: "deterministic guardrails only right now, forget using a
|
|
model for that"): every model request (each round, the forced final,
|
|
and any recovery) runs its ``delta.content`` through a fresh caller-
|
|
owned :class:`app.rag.scaffolding.ScaffoldingFilter`, so raw
|
|
``<|tool_call_start|>…<|tool_call_end|>`` tokens can never reach the
|
|
user as answer text. A round that ends with NO visible content AND a
|
|
non-empty strip (the scaffolding was the whole "answer") gets exactly
|
|
ONE bounded recovery: one extra request with ``tools=None``, the same
|
|
messages with :data:`CORRECTION_INSTRUCTION` folded into the original
|
|
single system message, a fresh filter, and the same phase-67 retry
|
|
budget. A recovery that also comes back empty — or a round with no
|
|
strip and no content (today's empty/thinking-only answer) — settles as
|
|
before; a second empty reply raises :class:`MalformedReplyError` (the
|
|
API layer turns it into the dedicated error frame). A round with real
|
|
visible content plus scaffolding needs no recovery (the clean content
|
|
stands), and a scaffolding-only round that also carried tool calls
|
|
needs none either (the tool ran) — the policy keys on the no-calls
|
|
exit only. No model participates in detection or repair: the
|
|
registry + the fixed retry policy are the whole guardrail.
|
|
|
|
The DB accessors — the drill-down ``ls`` (:func:`ls_top`,
|
|
:func:`ls_folder`; the pure grouping :func:`group_folder_listing` and
|
|
the pure renderers :func:`render_ls_top` /
|
|
:func:`render_folder_listing` sit next to them),
|
|
: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
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, cast
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import Settings
|
|
from app.models import Document, FolderSummary
|
|
from app.rag.folder_summaries import folder_of
|
|
from app.rag.git_sources import effective_sources
|
|
from app.rag.llm import (
|
|
LLMClient,
|
|
LLMError,
|
|
RetryPiece,
|
|
StreamPiece,
|
|
ToolCallPiece,
|
|
ToolResultPiece,
|
|
chat_stream_retried,
|
|
)
|
|
from app.rag.retriever import TRUNCATION_MARKER
|
|
from app.rag.scaffolding import ScaffoldingFilter
|
|
from app.rag.source_removal import resolve_source_name
|
|
|
|
logger = logging.getLogger("app.agent")
|
|
|
|
#: 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": "ls",
|
|
"description": (
|
|
"List the knowledge base as a tree, one level at a "
|
|
"time. With no path: the synced sources — each with "
|
|
"its document count and a summary of its contents. "
|
|
"With a source name (no '/'): that source's top-level "
|
|
"folders and files. With a `source/folder` path: that "
|
|
"folder's subfolders and files. Folder lines carry a "
|
|
"summary of what the folder contains. File lines are "
|
|
"`source: X | path: Y | title: Z` — use the combined "
|
|
"`source/path` with `read` and `grep`. Call one tool "
|
|
"at a time — wait for this result before your next "
|
|
"call."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": (
|
|
"Optional — a source name (e.g. "
|
|
"'homelab') to list its top level, or a "
|
|
"`source/folder` path to drill down "
|
|
"(e.g. 'homelab/active'). Omit it to list "
|
|
"every source."
|
|
),
|
|
}
|
|
},
|
|
"required": [],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "read",
|
|
"description": (
|
|
"The <documents> section shows the SUMMARIES of the "
|
|
"top-ranked documents — their full texts are NOT in "
|
|
"your prompt yet. Use this tool to add one of them (or "
|
|
"any other document) to your context, by its combined "
|
|
"`source/path` string, exactly as shown in the `ls` "
|
|
"output or the <documents> blocks. Do not re-read a "
|
|
"document you have already read — its full text is "
|
|
"already in your prompt. Very large documents are "
|
|
"truncated: you receive the first part plus a TRUNCATED "
|
|
"notice naming how many more characters exist — the "
|
|
"notice is authoritative, the document did NOT end "
|
|
"where it stopped. Follow it and use `grep` (pattern) "
|
|
"to locate the rest — it searches the whole document. "
|
|
"Call one tool at a time — wait for this result before "
|
|
"your next call."
|
|
),
|
|
"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'). "
|
|
"A bare document path (without the source "
|
|
"name) will not resolve. Do not re-read a "
|
|
"document you have already read — its full "
|
|
"text is already in your prompt."
|
|
),
|
|
}
|
|
},
|
|
"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`. The "
|
|
"pattern is a plain substring, NEVER a regex — if a "
|
|
"pattern with regex syntax (like '.*' or '\\.') comes "
|
|
"back with no matches, retry with the plain text you "
|
|
"expect to see. For a normal search pass ONLY `pattern` "
|
|
"— it searches every document and that is how you "
|
|
"search the knowledge base; never pass a source name "
|
|
"as `path` (a source name is not a document). Call one "
|
|
"tool at a time — wait for this result before your "
|
|
"next call."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"pattern": {
|
|
"type": "string",
|
|
"description": (
|
|
"The exact text to search for (a plain "
|
|
"substring, not a regex — no '.*', no "
|
|
"'\\.', no character classes)"
|
|
),
|
|
},
|
|
"path": {
|
|
"type": "string",
|
|
"description": (
|
|
"Rarely needed — only for re-searching one "
|
|
"document you already know: that document's "
|
|
"combined `source/path` identity (e.g. "
|
|
"'homelab/ansible/inventory.yaml'). Never a "
|
|
"source name. A bare document path (without "
|
|
"the source name) will not resolve. Omit it "
|
|
"for a normal search (pass only `pattern`)."
|
|
),
|
|
},
|
|
},
|
|
"required": ["pattern"],
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
#: Tool refusal texts (phase 37): rejected calls count in nothing
|
|
#: (``holder.tool_calls`` tracks executed calls); the round cap bounds
|
|
#: their pathological repetition (phase 45). The in-context line is a
|
|
#: phase-72, task 05 gate-iteration teaching (live telemetry: the
|
|
#: ``lite`` model obeyed the user's "open it / read it" and re-read
|
|
#: seed-context documents, then repeated the call against the terse
|
|
#: phase-37 line — the refusal itself carried no correct action): same
|
|
#: behavior (a refusal: counts in nothing, consumes a round, changes
|
|
#: no context), the copy now names the action, so even a fired
|
|
#: refusal ends the loop instead of inviting a repeat.
|
|
ALREADY_IN_CONTEXT = (
|
|
"Already in your context — the full text is already in your "
|
|
"prompt. Do not call read on it again; answer from that text."
|
|
)
|
|
UNKNOWN_TOOL = "Unknown tool."
|
|
MISSING_READ_ARGS = "read requires a string argument 'path'."
|
|
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
|
|
|
|
#: The read-truncation notice (phase 95, task 01) — appended, after the
|
|
#: shared :data:`TRUNCATION_MARKER`, to a ``read`` result whose document
|
|
#: is LONGER than ``settings.read_max_chars`` (``BOR_READ_MAX_CHARS``,
|
|
#: default 128 000 chars ≈ 32k tokens). Two format fields: ``{total}``
|
|
#: (the document's true character count) and ``{shown}`` (the cap — how
|
|
#: many characters actually reached the model). The copy is pinned: it
|
|
#: tells the model the read was truncated, that the rest is NOT in its
|
|
#: context (the document did not end where it stopped), and names
|
|
#: ``grep`` — the phase-70 harness-aligned locator that searches the
|
|
#: WHOLE document — as the tool to find what it was looking for. A read
|
|
#: at or under the cap appends nothing (byte-identical to the
|
|
#: pre-phase-95 result).
|
|
READ_TRUNCATION_NOTICE = (
|
|
"TRUNCATED — this document is {total} characters; only the first "
|
|
"{shown} are in your context. The rest is NOT shown. Use grep "
|
|
"(pattern) to locate what you need — grep searches the whole "
|
|
"document."
|
|
)
|
|
|
|
#: The image-document marker (phase 122, task 05): the line prefixed to
|
|
#: the vision DESCRIPTION a ``read`` of a standalone-image document
|
|
#: returns — the model must reason about what it is reading (the text
|
|
#: below is a description GENERATED from the image, not the image's
|
|
#: own words). It sits on the result's THIRD line: the ``Document …``
|
|
#: header and the phase-106 date line stay byte-identical (the E2E
|
|
#: mock's ``_READ_RESULT_PREFIX`` header contract), and a NON-image
|
|
#: doc's result carries no marker at all (byte-identical to pre-122).
|
|
IMAGE_DOC_MARKER = (
|
|
"Image document — the text below is a description generated from the image:"
|
|
)
|
|
|
|
#: The no-source ``ls`` refusal with the teaching parenthetical
|
|
#: appended (phase 72): used when a stripped scope has no ``/`` and
|
|
#: matches no registered source (the incident's ``ls(path='.')``). The
|
|
#: prefix — the pre-phase-72 line — stays byte-identical; one ``{scope}``
|
|
#: field, the argument echoed.
|
|
NO_SOURCE_NOT_A_DIRECTORY = (
|
|
"No source named '{scope}' — check the ls output. (The 'path' "
|
|
"argument is a source name, not a directory — omit it to list "
|
|
"every document.)"
|
|
)
|
|
|
|
#: Teaching refusal for a ``read`` / scoped ``grep`` argument that
|
|
#: resolves to no combined identity but matches ONE indexed document's
|
|
#: ``path`` (phase 72, task 02): names the exact combined
|
|
#: ``source/path`` identity to use, so the harness-prior misuse — the
|
|
#: bare document path missing the source prefix
|
|
#: (``read('app/rag/importer.py')``) — self-corrects in one round.
|
|
#: One each of the fields ``{arg}`` (the argument echoed as passed),
|
|
#: ``{source}`` and ``{path}`` (the one candidate). Still a refusal:
|
|
#: it counts in nothing and consumes a round (no silent argument
|
|
#: normalization).
|
|
NO_DOCUMENT_DID_YOU_MEAN = (
|
|
"No document at '{arg}' — did you mean '{source}/{path}'?"
|
|
)
|
|
|
|
#: The ambiguous form of the same teaching (phase 72, task 02): the
|
|
#: argument matches SEVERAL indexed documents' ``path`` (the same path
|
|
#: under several sources). ``{candidates}`` holds up to
|
|
#: :data:`SUGGESTION_LIMIT` combined ``source/path`` identities, each
|
|
#: single-quoted, joined with ``", "`` in catalog order; ``{arg}`` is
|
|
#: the argument echoed as passed.
|
|
NO_DOCUMENT_DID_YOU_MEAN_MANY = (
|
|
"No document at '{arg}' — did you mean one of: {candidates}?"
|
|
)
|
|
|
|
#: Cap on the suggested combined identities per "did you mean …?"
|
|
#: refusal (phase 72, task 02): the same document ``path`` under
|
|
#: several sources suggests up to this many (catalog order, the rest
|
|
#: dropped).
|
|
SUGGESTION_LIMIT = 3
|
|
|
|
#: The drill-down ``ls`` file-line cap (phase 94, task 03): a folder's
|
|
#: own files list at most this many
|
|
#: ``source: X | path: Y | title: Z | date: YYYY-MM-DD``
|
|
#: lines (path order), then one deterministic grep-pointer note — a
|
|
#: 500-file folder costs the model 50 lines + the note, never 500.
|
|
#: Pinned module constant (no env var — the phase-94 TODO asks for a
|
|
#: shape change, not a knob; the constant lives next to
|
|
#: :data:`SEARCH_MAX_MATCHES`).
|
|
LS_MAX_FILE_LINES = 50
|
|
|
|
#: The NOT-A-FOLDER ``ls`` teaching refusal (phase 94, task 03):
|
|
#: a ``source/…`` argument whose folder segment matches no indexed
|
|
#: prefix (the ``00_phase.md`` existence rule — a folder exists iff
|
|
#: some indexed path starts with ``folder + "/"``; a document's own
|
|
#: path is never a folder). Phase-72 teaching style: one line, the
|
|
#: argument echoed (``{arg}``), the DEEPEST existing ancestor's name
|
|
#: (``{parent}`` — the source for a top-level miss, ``source/folder``
|
|
#: for a nested one) and its direct subfolders (``{subfolders}`` —
|
|
#: space-joined ``name/`` entries in path order, so the model
|
|
#: self-corrects in the next round; ``none`` when the ancestor has no
|
|
#: subfolders). Still a refusal: it counts in nothing and consumes a
|
|
#: round (no silent argument normalization).
|
|
NOT_A_FOLDER = "'{arg}' is not a folder — {parent} has: {subfolders}"
|
|
|
|
#: The harness-owned recovery line (phase 71, task 03) — folded into the
|
|
#: ORIGINAL single system message of the one bounded recovery request
|
|
#: (``system_prompt + "\n" + CORRECTION_INSTRUCTION``; provider-safe,
|
|
#: the user message stays last). Verbatim constant: the E2E mock
|
|
#: (task 05) keys on a stable substring of it, so it must not drift.
|
|
CORRECTION_INSTRUCTION: str = (
|
|
"Your previous reply contained raw tool-call markup, which is not "
|
|
"interpreted here. Answer the user's question directly in plain "
|
|
"text — no tool syntax."
|
|
)
|
|
|
|
|
|
class MalformedReplyError(LLMError):
|
|
"""The model kept replying in raw tool-scaffolding (phase 71).
|
|
|
|
Raised ONLY by the recovery policy — :func:`run_agent` (grounded
|
|
path) and ``app.api.chat`` (deflected path) — when the one bounded
|
|
``tools=None`` recovery still comes back with no visible content.
|
|
It is never raised from inside a stream, so
|
|
:func:`app.rag.llm.chat_stream_retried`'s retry-before-first-piece
|
|
rule never sees it. The API layer catches it BEFORE the generic
|
|
:class:`LLMError` handler and settles the turn with the dedicated
|
|
"malformed reply" error frame (no ``done``, no ``query_log`` row).
|
|
Deterministic only (owner permission 2026-09-03): no model
|
|
participates in detection or repair.
|
|
"""
|
|
|
|
#: 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.
|
|
SEARCH_MAX_MATCHES = 20
|
|
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 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}."
|
|
|
|
#: Teaching no-match lines (the 2026-09-05 incident — owner chat:
|
|
#: "Qwen 3.8" question failed repeatedly). The harness-trained prior is
|
|
#: that ``grep(pattern)`` takes a REGEX (pi.dev's grep, ripgrep, grep
|
|
#: itself); this app's grep is a case-insensitive FIXED SUBSTRING
|
|
#: (owner-locked A5 — the contract does not change). A regex-shaped
|
|
#: pattern (``qwen.*3\.8``, ``qwen 3\.8``) can therefore never match, and
|
|
#: the bare no-match line above made the turbo model trust the miss and
|
|
#: end the turn with a wrong "I searched the entire knowledge base"
|
|
#: refusal. These lines are the deterministic teaching: the same result
|
|
#: (a no-match is still a *result* — counted, no context added) with the
|
|
#: correct contract stated and a plain-text retry hint (the
|
|
#: :func:`plain_form` of the pattern, when non-empty). Deterministic only
|
|
#: (owner permission 2026-09-03: "deterministic guardrails only right
|
|
#: now"): no model participates in detection or repair.
|
|
NO_MATCHES_REGEX = (
|
|
"No matches for '{pattern}'. grep matches a plain substring "
|
|
"(case-insensitive), not a regex — '.*', '\\.' and the like are "
|
|
"literal text here, so that pattern can never match. Retry with the "
|
|
"plain text you expect to see (e.g. '{plain}')."
|
|
)
|
|
NO_MATCHES_REGEX_SCOPED = (
|
|
"No matches for '{pattern}' in {source}/{path}. grep matches a plain "
|
|
"substring (case-insensitive), not a regex — retry with the plain "
|
|
"text you expect to see (e.g. '{plain}')."
|
|
)
|
|
|
|
#: One of these metacharacters anywhere in the pattern marks it
|
|
#: regex-shaped (the detection for the teaching no-match lines above).
|
|
#: Plain substrings that happen to contain one ("C++", "a|b") are
|
|
#: affected only on a NO-MATCH — a pattern that matched literally still
|
|
#: gets its ordinary result, so the teaching can never suppress a real
|
|
#: hit.
|
|
_REGEX_META_RE = re.compile(r"[*+?(){}\[\]|\\^$]")
|
|
|
|
|
|
# Backslash + a non-alphanumeric char is an escaped literal (``\\.`` →
|
|
# ``.``); backslash + an alphanumeric is a class shorthand (``\\d``,
|
|
# ``\\w``, ``\\s``) with no plain-text equivalent (→ dropped).
|
|
_ESCAPE_RE = re.compile(r"\\(.)")
|
|
|
|
|
|
def looks_like_regex(pattern: str) -> bool:
|
|
"""True when *pattern* carries regex metacharacters (see
|
|
:data:`_REGEX_META_RE`). Pure detection — the grep itself stays a
|
|
fixed substring (owner-locked A5)."""
|
|
return _REGEX_META_RE.search(pattern) is not None
|
|
|
|
|
|
def plain_form(pattern: str) -> str:
|
|
"""A deterministic plain-text retry hint for a regex-shaped pattern.
|
|
|
|
The hint is the pattern reduced to literal text, in this order:
|
|
drop ``.*`` runs on the RAW pattern first (the wildcard — an escaped
|
|
``\\.`` + ``\\*`` pair carries no raw ``.*`` run, so a literal dot
|
|
survives), unescape (``\\.`` → ``.``; class shorthands like ``\\d``
|
|
dropped), keep only the FIRST ``|`` alternative, drop character
|
|
classes (``[0-9]``), drop quantifier runs (``*``, ``+``, ``?``,
|
|
``{2,3}``), drop group parens and anchors (contents kept). Whitespace
|
|
is preserved. ``qwen.*3\\.8`` → ``qwen3.8`` (the incident's exact
|
|
recovery), ``llama\\.cpp`` → ``llama.cpp``, ``qwen[0-9]+`` →
|
|
``qwen``. A pattern made of pure metacharacters reduces to ``""`` —
|
|
callers then fall back to the ordinary no-match line (no hint).
|
|
"""
|
|
p = re.sub(r"\.\*", "", pattern) # .* wildcard runs (raw form)
|
|
p = _ESCAPE_RE.sub(lambda m: m.group(1) if not m.group(1).isalnum() else "", p)
|
|
p = p.split("|", 1)[0] # first alternative only — a hint, not an answer
|
|
p = re.sub(r"\[[^\]]*\]", "", p) # character classes carry no literal text
|
|
p = re.sub(r"\{[^{}]*\}", "", p) # {n} / {n,} / {n,m} quantifiers
|
|
p = re.sub(r"[*+?]+", "", p) # stray * + ? quantifiers
|
|
p = p.replace("(", "").replace(")", "")
|
|
p = p.replace("^", "").replace("$", "")
|
|
return p.strip()
|
|
|
|
|
|
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
|
|
|
|
|
|
#: The drill-down ``ls`` fetchers (phase 94, task 03) — module-level so
|
|
#: unit tests can monkeypatch them without a database (the house style:
|
|
#: :func:`ls_top` / :func:`ls_folder` compose them; the pure grouping
|
|
#: :func:`group_folder_listing` and renderers
|
|
#: :func:`render_ls_top` / :func:`render_folder_listing` sit below).
|
|
|
|
|
|
def _source_document_counts(db: Session) -> list[tuple[str, int]]:
|
|
"""``(source, document count)`` per indexed source (one grouped
|
|
query — the top-level ``ls`` counts, phase 94 task 03)."""
|
|
return [
|
|
(source, count)
|
|
for source, count in db.execute(
|
|
select(Document.source, func.count()).group_by(Document.source)
|
|
)
|
|
]
|
|
|
|
|
|
def _source_root_summaries(db: Session) -> list[tuple[str, str]]:
|
|
"""The stored source-root summaries ``(source, summary)``
|
|
(``folder_path = ""`` — the top-level ``ls`` indented lines, phase
|
|
94 task 03; absent when never generated)."""
|
|
return [
|
|
(source, summary)
|
|
for source, summary in db.execute(
|
|
select(FolderSummary.source, FolderSummary.summary).where(
|
|
FolderSummary.folder_path == ""
|
|
)
|
|
)
|
|
]
|
|
|
|
|
|
def _source_document_rows(db: Session, source: str) -> list[tuple[str, str, str]]:
|
|
"""``(path, title, created_iso_date)`` of every document under
|
|
*source*, ordered by ``path`` — the one bounded fetch a folder
|
|
drill level lists (phase 94 task 03; one source's paths, not the
|
|
whole KB). The date is the row's ``created_at`` UTC date part
|
|
(``YYYY-MM-DD``, phase 106 D5 — the ``ls`` FILE line's appended
|
|
`` | date: …`` field; only file lines carry a date)."""
|
|
return [
|
|
(path, title, created_at.strftime("%Y-%m-%d"))
|
|
for path, title, created_at in db.execute(
|
|
select(Document.path, Document.title, Document.created_at)
|
|
.where(Document.source == source)
|
|
.order_by(Document.path)
|
|
)
|
|
]
|
|
|
|
|
|
def _source_folder_summaries(db: Session, source: str) -> dict[str, str]:
|
|
"""The stored folder summaries ``{folder_path: summary}`` of one
|
|
source (phase 94 task 03; includes the ``""`` source-root row when
|
|
stored)."""
|
|
return {
|
|
folder_path: summary
|
|
for folder_path, summary in db.execute(
|
|
select(FolderSummary.folder_path, FolderSummary.summary).where(
|
|
FolderSummary.source == source
|
|
)
|
|
)
|
|
}
|
|
|
|
|
|
def ls_top(db: Session) -> list[tuple[str, int, str | None]]:
|
|
"""The top level of the drill-down ``ls`` (phase 94, task 03).
|
|
|
|
Every registered source in :func:`list_source_names` order (the
|
|
registry is the source of truth — a registered source with 0
|
|
indexed documents still lists, the phase-70/72 invariant) as
|
|
``(name, recursive_document_count, source_root_summary)``: the
|
|
count is the source's whole subtree (all of its documents — the
|
|
same set its stored summary describes) and the summary is the
|
|
stored ``folder_summaries`` row for ``(source, "")`` (the source
|
|
root, phase 94 task 01) or ``None`` when absent. Module-level so
|
|
unit tests can monkeypatch the fetchers.
|
|
"""
|
|
names = list_source_names(db)
|
|
if not names:
|
|
return []
|
|
counts = dict(_source_document_counts(db))
|
|
summaries = dict(_source_root_summaries(db))
|
|
return [(name, counts.get(name, 0), summaries.get(name)) for name in names]
|
|
|
|
|
|
def group_folder_listing(
|
|
source: str,
|
|
folder: str,
|
|
rows: Sequence[tuple[str, str, str]],
|
|
summaries: Mapping[str, str],
|
|
) -> tuple[
|
|
list[tuple[str, int, str | None]], list[tuple[str, str, str, str]], int
|
|
]:
|
|
"""One level of the drill-down tree (phase 94, task 03) — pure.
|
|
|
|
Given *rows* — the source's ``(path, title, created_iso_date)``
|
|
triples in catalog (path) order — and *summaries* (the source's
|
|
stored ``folder_summaries`` rows: ``folder_path → summary``), the
|
|
folder level *folder* (source-relative; ``""`` = the source root):
|
|
|
|
* **(a) direct subfolders** — the folders whose parent is exactly
|
|
*folder*, in path order, each
|
|
``(sub_path_relative_to_source, recursive_count,
|
|
summary_or_None)``. A folder is a slash-boundary prefix of at
|
|
least one indexed path (the ``00_phase.md`` existence rule: a
|
|
folder ``F`` exists ⟺ some path starts with ``F + "/"`` — a
|
|
document's OWN path is never a folder); the count is the
|
|
folder's recursive subtree — every path equal to the folder or
|
|
starting with ``folder + "/"`` (the same set the sync-time
|
|
folder summary describes, phase 94 task 01 — one concept end to
|
|
end).
|
|
* **(b) direct file lines** — the documents whose folder (the
|
|
prefix before the last ``/`` —
|
|
:func:`app.rag.folder_summaries.folder_of`, the shared notion) IS
|
|
*folder*, in path order (catalog order — the same order
|
|
``GET /api/docs`` serves), as ``(source, path, title, date)``
|
|
4-tuples — the canonical ``read``/``grep`` identity plus the
|
|
phase-106 D5 ``date`` field (the row's ``created_at`` UTC date
|
|
part, APPENDED — never inserted before ``title``), capped at
|
|
:data:`LS_MAX_FILE_LINES` (the rest fold into the renderer's
|
|
note; a 500-file folder never costs 500 lines).
|
|
* **(c) the TOTAL direct-file count** — pre-cap, for the note.
|
|
|
|
Pure (no I/O) — unit tests drive the grouping without a database;
|
|
:func:`ls_folder` is the DB-composing wrapper.
|
|
"""
|
|
# The source's existing folders: every slash-boundary prefix of an
|
|
# indexed path (the existence rule's candidate set — a folder is
|
|
# present iff at least one path starts with ``folder + "/"``).
|
|
folders: set[str] = set()
|
|
for path, _title, _date in rows:
|
|
f = folder_of(path)
|
|
while f:
|
|
folders.add(f)
|
|
f = folder_of(f)
|
|
# The recursive count per folder — the ``path == folder`` arm (a
|
|
# document sharing a folder's name) plus the ``startswith
|
|
# folder + "/"`` arm (the folder's true descendants), one pass per
|
|
# document.
|
|
counts: dict[str, int] = {f: 0 for f in folders}
|
|
for path, _title, _date in rows:
|
|
if path in folders:
|
|
counts[path] += 1
|
|
f = folder_of(path)
|
|
while f:
|
|
counts[f] += 1
|
|
f = folder_of(f)
|
|
subfolders = [
|
|
(g, counts[g], summaries.get(g))
|
|
for g in sorted(g for g in folders if folder_of(g) == folder)
|
|
]
|
|
files = [
|
|
(source, path, title, date)
|
|
for path, title, date in rows
|
|
if folder_of(path) == folder
|
|
]
|
|
return subfolders, files[:LS_MAX_FILE_LINES], len(files)
|
|
|
|
|
|
def ls_folder(
|
|
db: Session, source: str, folder: str
|
|
) -> tuple[
|
|
list[tuple[str, int, str | None]], list[tuple[str, str, str, str]], int
|
|
]:
|
|
"""One folder level of the drill-down ``ls`` (phase 94, task 03).
|
|
|
|
The source's document rows (:func:`_source_document_rows`) and
|
|
stored folder summaries (:func:`_source_folder_summaries`) through
|
|
:func:`group_folder_listing` — the pure grouping the unit tests
|
|
drive directly. ``folder = ""`` is the source root. Module-level
|
|
so unit tests can monkeypatch the fetchers.
|
|
"""
|
|
return group_folder_listing(
|
|
source,
|
|
folder,
|
|
_source_document_rows(db, source),
|
|
_source_folder_summaries(db, source),
|
|
)
|
|
|
|
|
|
def _folder_exists_in(rows: Sequence[tuple[str, str, str]], folder: str) -> bool:
|
|
"""The phase-94 folder-existence rule (``00_phase.md``), pure.
|
|
|
|
Folder *folder* (source-relative) under a registered source
|
|
exists ⟺ ``folder == ""`` OR some indexed path starts with
|
|
``folder + "/"`` — a document's OWN path is never a folder
|
|
(nothing starts with ``path + "/"``), so ``ls`` of a file path
|
|
refuses with :data:`NOT_A_FOLDER` rather than listing.
|
|
"""
|
|
if not folder:
|
|
return True
|
|
prefix = folder + "/"
|
|
return any(path.startswith(prefix) for path, _title, _date in rows)
|
|
|
|
|
|
def _deepest_existing_ancestor(
|
|
rows: Sequence[tuple[str, str, str]], folder: str
|
|
) -> str:
|
|
"""The deepest EXISTING folder prefix of a missing *folder* (pure).
|
|
|
|
The :data:`NOT_A_FOLDER` refusal's teaching context: the argument's
|
|
segments are walked from the top; the walk stops at the first
|
|
segment that is no folder, so the returned prefix is the deepest
|
|
existing ancestor (``""`` = the source root when the first segment
|
|
is already the miss) and its direct subfolders are the bounded
|
|
self-correction list the refusal prints.
|
|
"""
|
|
parent = ""
|
|
for part in folder.split("/"):
|
|
candidate = f"{parent}/{part}" if parent else part
|
|
if not _folder_exists_in(rows, candidate):
|
|
break
|
|
parent = candidate
|
|
return parent
|
|
|
|
|
|
def render_ls_top(entries: Sequence[tuple[str, int, str | None]]) -> str:
|
|
"""The top-level ``ls`` result (phase 94, task 03) — the pinned
|
|
template.
|
|
|
|
``{N} sources:`` — the line alone when the registry is empty (the
|
|
old ``0 documents:`` behavior preserved in spirit) — then, when at
|
|
least one source is registered, a blank line and one block per
|
|
source in registry order: ``{source} — {n} documents`` plus the
|
|
indented `` {summary}`` line ONLY when the source-root summary is
|
|
stored (absent → the count line alone, no placeholder) — with NO
|
|
blank line between blocks.
|
|
"""
|
|
lines = [f"{len(entries)} sources:"]
|
|
if entries:
|
|
lines.append("")
|
|
for source, count, summary in entries:
|
|
lines.append(f"{source} — {count} documents")
|
|
if summary:
|
|
lines.append(f" {summary}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def render_folder_listing(
|
|
identity: str,
|
|
subfolders: Sequence[tuple[str, int, str | None]],
|
|
files: Sequence[tuple[str, str, str, str]],
|
|
total_files: int,
|
|
) -> str:
|
|
"""One folder level of the drill-down ``ls`` (phase 94, task 03) —
|
|
the pinned template.
|
|
|
|
The header ``{identity} — {n_files} documents, {n_folders}
|
|
folders:`` (``n_files`` = *total_files*, the PRE-cap direct-file
|
|
count; ``identity`` is the source name at the root level and
|
|
``source/folder`` below it), then — when the level carries anything
|
|
below the header — a blank line, the 2-space-indented subfolder
|
|
lines `` {sub}/ — {m} documents`` in path order (``: {summary}``
|
|
appended ONLY when the subfolder's summary is stored), a blank
|
|
line, the file lines in EXACTLY the
|
|
``source: X | path: Y | title: Z | date: YYYY-MM-DD`` format (the
|
|
canonical ``read``/``grep`` identity plus the phase-106 D5
|
|
appended ``date`` field — the only changed part), and the cap note
|
|
``…and {hidden} more documents in this folder — use grep
|
|
(pattern) to find a specific one.`` ONLY when the folder's own
|
|
files outnumber :data:`LS_MAX_FILE_LINES` (*files* arrives capped;
|
|
*total_files* carries the pre-cap count). A header with no
|
|
subfolders and no files — a registered source with no documents —
|
|
is the header line alone (``… — 0 documents, 0 folders:``).
|
|
"""
|
|
header = f"{identity} — {total_files} documents, {len(subfolders)} folders:"
|
|
if not subfolders and not files:
|
|
return header
|
|
body: list[str] = []
|
|
if subfolders:
|
|
body.append("")
|
|
for sub, count, summary in subfolders:
|
|
line = f" {sub}/ — {count} documents"
|
|
if summary:
|
|
line += f": {summary}"
|
|
body.append(line)
|
|
if files or total_files > len(files):
|
|
body.append("")
|
|
body.extend(
|
|
f"source: {source} | path: {path} | title: {title} | date: {date}"
|
|
for source, path, title, date in files
|
|
)
|
|
hidden = total_files - len(files)
|
|
if hidden > 0:
|
|
body.append(
|
|
f"…and {hidden} more documents in this folder — use grep "
|
|
"(pattern) to find a specific one."
|
|
)
|
|
return "\n".join([header, *body])
|
|
|
|
|
|
def suggested_folder_lines(
|
|
db: Session,
|
|
suggested: Sequence[Document],
|
|
max_lines: int = 5,
|
|
max_entries: int = 8,
|
|
) -> list[str]:
|
|
"""The HIGH prompt's suggested-folder context lines (phase 119, D3,
|
|
LOCKED A4) — pure composition over the existing ``ls`` machinery.
|
|
|
|
One line per DISTINCT parent folder of the *suggested* documents —
|
|
in suggested-doc order, deduped by ``(source, parent prefix)`` (the
|
|
first suggested doc wins the slot), at most *max_lines* lines:
|
|
|
|
* the parent prefix is the path up to (excluding) the last ``/``
|
|
(``""`` = the source root);
|
|
* the line is ``<source>/<prefix>/: e1, e2, …`` (the source root
|
|
renders as ``<source>/: …`` — the filesystem-style folder path,
|
|
trailing slash included, + the colon) with the folder's direct
|
|
children in the
|
|
EXISTING ``ls`` folder-level rendering order — the direct
|
|
subfolders first (``name/ (N docs)``, the recursive doc count,
|
|
singular ``(1 doc)``), then the files by relative filename — so
|
|
the line reads the same as the model's own ``ls`` output of that
|
|
folder (the :func:`group_folder_listing` grouping, over
|
|
:func:`_source_document_rows` + :func:`_source_folder_summaries`);
|
|
* the suggested document that OWNS the line is excluded from the
|
|
entries (its identity is already in its ``<document>`` block — the
|
|
line is the folder's OTHER contents, the pre-seed that makes the
|
|
model ``read`` the right file in round 1 instead of walking the
|
|
``ls`` drill-downs);
|
|
* at most *max_entries* entries, then `` +N more`` (N = the
|
|
remaining count, the true pre-cap folder total — the suggested
|
|
doc leaves the total even when its row sat past
|
|
:data:`LS_MAX_FILE_LINES`); a folder whose only entry was the
|
|
suggested doc renders its header alone (the ``… — 0 documents, 0
|
|
folders:`` empty-level precedent).
|
|
|
|
Empty *suggested* → ``[]`` (the caller then builds the
|
|
byte-identical phase-118 prompt). Module-level so unit tests can
|
|
monkeypatch the fetchers without a database (the house style:
|
|
:func:`ls_top` / :func:`ls_folder` compose the same fetchers).
|
|
"""
|
|
lines: list[str] = []
|
|
seen: set[tuple[str, str]] = set()
|
|
rows_cache: dict[str, tuple[list[tuple[str, str, str]], dict[str, str]]] = {}
|
|
for doc in suggested:
|
|
if len(lines) >= max_lines:
|
|
break
|
|
prefix = folder_of(doc.path)
|
|
key = (doc.source, prefix)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
if doc.source not in rows_cache:
|
|
rows_cache[doc.source] = (
|
|
_source_document_rows(db, doc.source),
|
|
_source_folder_summaries(db, doc.source),
|
|
)
|
|
rows, summaries = rows_cache[doc.source]
|
|
subfolders, files, total_files = group_folder_listing(
|
|
doc.source, prefix, rows, summaries
|
|
)
|
|
entries = [
|
|
f"{sub}/ ({count} {'doc' if count == 1 else 'docs'})"
|
|
for sub, count, _summary in subfolders
|
|
]
|
|
# The owning suggested doc is a direct file of this folder —
|
|
# drop it from the entries (its identity is already in its
|
|
# <document> block); its count leaves the total either way
|
|
# (even when its row sat past the LS_MAX_FILE_LINES file cap).
|
|
files = [entry for entry in files if entry[1] != doc.path]
|
|
# The file entries come AFTER the subfolders (the ls folder-level
|
|
# order) and ride by RELATIVE filename — the basename within the
|
|
# folder (the line's ``<source>/<prefix>/:`` header supplies the
|
|
# folder; combined ``source/prefix/name`` is the read identity).
|
|
entries.extend(path.rsplit("/", 1)[-1] for _src, path, _title, _date in files)
|
|
total = len(subfolders) + total_files - 1
|
|
shown = entries[:max_entries]
|
|
# The pinned identity shape: ``<source>/<prefix>/:`` (the source
|
|
# root: ``<source>/:``) — the filesystem-style folder path
|
|
# (trailing slash included) + the colon.
|
|
identity = f"{doc.source}/{prefix}/" if prefix else f"{doc.source}/"
|
|
line = f"{identity}:"
|
|
if shown:
|
|
suffix = ", ".join(shown)
|
|
if total > len(shown):
|
|
suffix += f" +{total - len(shown)} more"
|
|
line += f" {suffix}"
|
|
lines.append(line)
|
|
return lines
|
|
|
|
|
|
def find_document(db: Session, source: str, path: str) -> Document | None:
|
|
"""The indexed document at ``(source, path)``, or ``None``.
|
|
|
|
Module-level (not a method) so unit tests can monkeypatch it.
|
|
"""
|
|
return db.scalar(
|
|
select(Document).where(Document.source == source, Document.path == path)
|
|
)
|
|
|
|
|
|
def _resolve_path(db: Session, combined: str) -> tuple[Document | None, str, str]:
|
|
"""The combined ``source/path`` identity → document (phase 70).
|
|
|
|
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).
|
|
"""
|
|
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 ``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(
|
|
db.execute(
|
|
select(Document).order_by(Document.source, Document.path)
|
|
).scalars()
|
|
)
|
|
|
|
|
|
def find_path_candidates(db: Session, arg: str) -> list[tuple[str, str, str]]:
|
|
"""The indexed documents a bare document *arg* names by ``path``.
|
|
|
|
Phase 72, task 02: a ``read`` / scoped-``grep`` argument that
|
|
resolves to no combined identity but *is* a document path (contains
|
|
``/``) is matched against the indexed ``Document.path`` values so
|
|
the refusal can name the combined ``source/path`` identity to use
|
|
(the "did you mean …?" teaching). The documents whose ``path``
|
|
equals *arg* (the exact bare path) or ends with ``f"/{arg}"`` (the
|
|
file is nested deeper — the suffix match) — in catalog order (the
|
|
:func:`all_documents` order), case-sensitive (these are file
|
|
paths) — as ``(source, path, title)`` triples. One bulk query via
|
|
:func:`all_documents` (at most one); called ONLY from the refusal
|
|
path of :func:`_execute_tool` (never on the happy path) and only
|
|
when *arg* contains ``/`` (a bare name keeps today's no-DB-lookup
|
|
refusal). Module-level (not a method) so unit tests can
|
|
monkeypatch it.
|
|
"""
|
|
return [
|
|
(doc.source, doc.path, doc.title)
|
|
for doc in all_documents(db)
|
|
if doc.path == arg or doc.path.endswith(f"/{arg}")
|
|
]
|
|
|
|
|
|
def _no_document_refusal(db: Session, arg: str) -> str:
|
|
"""The no-document refusal for an unresolved ``read`` / scoped-
|
|
``grep`` argument (phase 72, task 02).
|
|
|
|
The pre-phase-72 line — the argument echoed as passed — whenever
|
|
there is nothing to suggest: a bare argument (no ``/`` — a bare
|
|
source name or any other bare name gets the no-DB-lookup refusal,
|
|
byte-identical to today) or a path-like argument that matches no
|
|
indexed document's ``path`` (zero candidates). A path-like argument
|
|
(contains ``/``) that matches exactly one indexed document's
|
|
``path`` gets :data:`NO_DOCUMENT_DID_YOU_MEAN` (the combined
|
|
identity named); two or more get :data:`NO_DOCUMENT_DID_YOU_MEAN_MANY`
|
|
(up to :data:`SUGGESTION_LIMIT`, catalog order). Deterministic
|
|
only: the suggestion is a pure catalog lookup, no model. A refusal
|
|
still counts in nothing and consumes a round.
|
|
"""
|
|
if "/" in arg:
|
|
candidates = find_path_candidates(db, arg)
|
|
if len(candidates) == 1:
|
|
source, path, _title = candidates[0]
|
|
return NO_DOCUMENT_DID_YOU_MEAN.format(arg=arg, source=source, path=path)
|
|
if len(candidates) > 1:
|
|
identities = ", ".join(
|
|
f"'{source}/{path}'"
|
|
for source, path, _title in candidates[:SUGGESTION_LIMIT]
|
|
)
|
|
return NO_DOCUMENT_DID_YOU_MEAN_MANY.format(arg=arg, candidates=identities)
|
|
return f"No document at '{arg}' — check the ls output."
|
|
|
|
|
|
def grep_document(content: str, pattern: str) -> list[tuple[int, str]]:
|
|
"""Every line of *content* that contains *pattern*, in file order.
|
|
|
|
Case-insensitive **fixed substring** (owner-locked A5: no regex — no
|
|
ReDoS surface, a simple contract for the model). Returns
|
|
``(1-based line number, line.rstrip())`` pairs; an empty *content*
|
|
never matches a non-empty pattern.
|
|
"""
|
|
needle = pattern.lower()
|
|
return [
|
|
(number, line.rstrip())
|
|
for number, line in enumerate(content.split("\n"), start=1)
|
|
if needle in line.lower()
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class AgentHolder:
|
|
"""Per-turn agent state the API layer reads after the stream (task 04).
|
|
|
|
``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
|
|
``tool_calls=N`` field (task 04).
|
|
``scaffold_stripped``: how many chars of tool-scaffolding the
|
|
turn's filters removed across the turn's requests (rounds + the
|
|
forced final + any recovery, phase 71) — drives the per-turn log
|
|
line's ``scaffold_stripped=N`` field on grounded turns (the
|
|
deflected path computes its own total in ``app.api.chat``).
|
|
``read_truncations`` (phase 95): one ``(argument, chars_shown,
|
|
chars_total)`` tuple per TRUNCATED ``read`` — the combined
|
|
``source/path`` the model passed, the cap kept
|
|
(``settings.read_max_chars``), and the document's true length — in
|
|
execution order. Recorded on truncation only; a read at or under the
|
|
cap appends nothing. A truncated read is still a **successful**
|
|
call: ``tool_calls`` increments as today and ``read_docs`` appends
|
|
as today — this list only carries the truncation signal the agent
|
|
loop turns into :class:`app.rag.llm.ToolResultPiece` values (task 02
|
|
surfaces them to the UI).
|
|
"""
|
|
|
|
read_docs: list[Document] = field(default_factory=list)
|
|
tool_calls: int = 0
|
|
scaffold_stripped: int = 0
|
|
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
|
|
|
|
|
|
def _execute_tool(
|
|
db: Session,
|
|
call: ToolCallPiece,
|
|
seed_docs: Sequence[Document],
|
|
holder: AgentHolder,
|
|
settings: Settings,
|
|
) -> str:
|
|
"""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 ``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. A ``read`` longer than ``settings.read_max_chars``
|
|
(phase 95) is cut at the cap and carries the shared
|
|
:data:`TRUNCATION_MARKER` + the pinned :data:`READ_TRUNCATION_NOTICE`
|
|
(the rest of the document is NOT in the model's context), and a
|
|
``(argument, cap, total)`` tuple is appended to
|
|
``holder.read_truncations`` (the signal the agent loop turns into a
|
|
:class:`app.rag.llm.ToolResultPiece`). Document targets are combined ``source/path``
|
|
strings, resolved by :func:`_resolve_path` (the canonical identity,
|
|
phase 70).
|
|
"""
|
|
if call.name == "ls":
|
|
# Phase 94 (task 03): the drill-down tree — one level per call
|
|
# (the owner-permitted tool-surface revision; the old
|
|
# whole-catalog listing is gone). A successful listing at ANY
|
|
# level (top/root/folder) counts; a refusal counts in nothing
|
|
# and consumes a round like every refusal.
|
|
raw_path = call.arguments.get("path")
|
|
scope = raw_path.strip() if isinstance(raw_path, str) else ""
|
|
if not scope:
|
|
# The top level: the synced sources, registry order, each
|
|
# with its recursive document count and its stored
|
|
# source-root summary (``None`` → no indented line).
|
|
holder.tool_calls += 1
|
|
return render_ls_top(ls_top(db))
|
|
source, _, rest = scope.partition("/")
|
|
if source not in list_source_names(db):
|
|
# The no-source refusal with the teaching parenthetical
|
|
# (phase 72, the prefix byte-identical to the pre-phase-72
|
|
# line): the FIRST segment is the source candidate — a bare
|
|
# unknown name (the incident's ``ls(path='.')``) or the
|
|
# source segment of a ``source/…`` argument (phase 94: a
|
|
# ``/`` now names a folder, so the phase-72 document-path
|
|
# teaching is deleted) — the segment echoed; counts in
|
|
# nothing, consumes a round like every refusal.
|
|
return NO_SOURCE_NOT_A_DIRECTORY.format(scope=source)
|
|
if not rest:
|
|
# The source's ROOT folder: subfolders + own file lines
|
|
# (capped + note) — the pinned template; a registered
|
|
# source with no documents lists its header line alone
|
|
# (``… — 0 documents, 0 folders:`` — the old
|
|
# ``0 documents:`` behavior preserved in spirit).
|
|
subfolders, files, total = ls_folder(db, source, "")
|
|
holder.tool_calls += 1
|
|
return render_folder_listing(source, subfolders, files, total)
|
|
rows = _source_document_rows(db, source)
|
|
summaries = _source_folder_summaries(db, source)
|
|
if not _folder_exists_in(rows, rest):
|
|
# The NOT-A-FOLDER teaching (phase 94, task 03): the
|
|
# argument echoed, the DEEPEST existing ancestor's name and
|
|
# its direct subfolders listed (bounded — the parent's own
|
|
# listing, so no new flood path), so the model
|
|
# self-corrects in the next round; counts in nothing,
|
|
# consumes a round like every refusal.
|
|
parent = _deepest_existing_ancestor(rows, rest)
|
|
parent_subs = group_folder_listing(source, parent, rows, summaries)[0]
|
|
return NOT_A_FOLDER.format(
|
|
arg=scope,
|
|
parent=source if not parent else f"{source}/{parent}",
|
|
subfolders=" ".join(f"{sub}/" for sub, _c, _s in parent_subs)
|
|
or "none",
|
|
)
|
|
# The folder level: the same template as the root, identity =
|
|
# source + "/" + folder.
|
|
subfolders, files, total = group_folder_listing(source, rest, rows, summaries)
|
|
holder.tool_calls += 1
|
|
return render_folder_listing(f"{source}/{rest}", subfolders, files, total)
|
|
if call.name == "read":
|
|
raw_path = call.arguments.get("path")
|
|
arg = raw_path.strip() if isinstance(raw_path, str) else ""
|
|
if not arg:
|
|
return MISSING_READ_ARGS
|
|
# Phase 118 (A6): the dedupe set is ``holder.read_docs`` ONLY —
|
|
# the ``seed_docs`` are SUMMARY blocks in the prompt, not full
|
|
# text, so a FIRST read of a suggested document adds its full
|
|
# text through the path below; only a document ALREADY READ is
|
|
# refused.
|
|
known = {(doc.source, doc.path) for doc in holder.read_docs}
|
|
# The dedupe check needs no DB: the split pair of a combined
|
|
# identity that is already in full-text 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:
|
|
# Echo the argument as passed — the model sees its own form
|
|
# (a bare argument can never be a document, no DB lookup);
|
|
# a path-like argument that matches an indexed document's
|
|
# path gets the "did you mean …?" teaching (phase 72).
|
|
return _no_document_refusal(db, arg)
|
|
holder.read_docs.append(doc)
|
|
holder.tool_calls += 1
|
|
# Phase 122 (task 05): an image document's content IS the vision
|
|
# description — the marker line (a third line between the
|
|
# byte-identical header/date lines and the text) tells the model
|
|
# what it is reading. A text doc's ``marker`` is "" — the result
|
|
# stays byte-identical to pre-122.
|
|
marker = f"{IMAGE_DOC_MARKER}\n" if doc.is_image else ""
|
|
cap = settings.read_max_chars
|
|
if len(doc.content) > cap:
|
|
# Phase 95 (owner permission 2026-09-10, ``TODO.md`` L5): the
|
|
# read cap — cut at the cap and tell the model the truth: the
|
|
# shared marker + the pinned notice name how much more exists
|
|
# and point at ``grep`` (which searches the WHOLE document).
|
|
# A truncated read is still a successful call (both counters
|
|
# above already bumped); the holder tuple is the truncation
|
|
# signal only, so the loop can yield a ToolResultPiece.
|
|
# ``raw_path`` (a str here — ``arg`` is non-empty only when it
|
|
# was) is the exact argument the matching SSE ``tool`` frame
|
|
# carries, so the UI can match the two (task 02); the cast
|
|
# keeps pyright honest about the holder tuple's first element.
|
|
holder.read_truncations.append(
|
|
(cast("str", raw_path), cap, len(doc.content))
|
|
)
|
|
# Phase 106 (D5): the date rides every document the model
|
|
# sees — the ``read`` result's SECOND line; the FIRST line
|
|
# stays ``Document {source}/{path}:`` BYTE-IDENTICAL (the
|
|
# E2E mock's ``_READ_RESULT_PREFIX`` header contract).
|
|
return (
|
|
f"Document {doc.source}/{doc.path}:\n"
|
|
f"date: {doc.created_at:%Y-%m-%d}\n"
|
|
f"{marker}"
|
|
f"{doc.content[:cap]}\n"
|
|
f"{TRUNCATION_MARKER}\n"
|
|
f"{READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))}"
|
|
)
|
|
# At or under the cap: the pre-phase-95 result plus the
|
|
# phase-106 D5 date line (first line byte-identical — the
|
|
# mock's header contract; no truncation marker, no notice, no
|
|
# holder entry, no ToolResultPiece) — and, phase 122, the
|
|
# image-document marker line for image docs only.
|
|
return (
|
|
f"Document {doc.source}/{doc.path}:\n"
|
|
f"date: {doc.created_at:%Y-%m-%d}\n"
|
|
f"{marker}"
|
|
f"{doc.content}"
|
|
)
|
|
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_path = call.arguments.get("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:
|
|
# The same phase-72 "did you mean …?" teaching as the
|
|
# read branch (a refusal — not counted, no context).
|
|
return _no_document_refusal(db, scope)
|
|
docs: list[Document] = [target]
|
|
scoped_to = (src, p) # the resolved (canonical) identity
|
|
else:
|
|
docs = all_documents(db)
|
|
matches: list[str] = []
|
|
for doc in docs:
|
|
for lineno, line in grep_document(doc.content, pattern):
|
|
matches.append(
|
|
f"{doc.source}/{doc.path}:{lineno}: {line[:SEARCH_LINE_LIMIT]}"
|
|
)
|
|
if len(matches) >= SEARCH_MAX_MATCHES:
|
|
break
|
|
if len(matches) >= SEARCH_MAX_MATCHES:
|
|
break # the global cap is hit — stop scanning
|
|
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
|
|
# The 2026-09-05 incident teaching: a regex-shaped pattern
|
|
# (the harness prior) can NEVER match a fixed-substring
|
|
# grep, so a no-match for one is not "the KB lacks this" —
|
|
# it is "the pattern was in the wrong form". Teach the
|
|
# contract and hand over the plain-form retry hint (when the
|
|
# reduction is non-empty); a plain pattern (or a pattern
|
|
# that reduces to nothing) keeps the ordinary line
|
|
# byte-identical.
|
|
plain = plain_form(pattern) if looks_like_regex(pattern) else ""
|
|
if plain:
|
|
if scoped_to is not None:
|
|
return NO_MATCHES_REGEX_SCOPED.format(
|
|
pattern=shown,
|
|
source=scoped_to[0],
|
|
path=scoped_to[1],
|
|
plain=plain[:100],
|
|
)
|
|
return NO_MATCHES_REGEX.format(pattern=shown, plain=plain[:100])
|
|
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=scoped_to[0], path=scoped_to[1]
|
|
)
|
|
return NO_MATCHES.format(pattern=shown)
|
|
return "\n".join(matches)
|
|
return UNKNOWN_TOOL
|
|
|
|
|
|
async def run_agent(
|
|
llm: LLMClient,
|
|
db_factory: Callable[[], Session],
|
|
*,
|
|
system_prompt: str,
|
|
user_message: str | list[dict[str, Any]],
|
|
seed_docs: Sequence[Document],
|
|
settings: Settings,
|
|
holder: AgentHolder,
|
|
history: Sequence[dict[str, Any]] = (),
|
|
) -> AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
|
"""Run the grounded-turn tool loop, yielding every stream piece.
|
|
|
|
Every piece (``thinking`` / ``content`` / tool calls /
|
|
:class:`RetryPiece`) is yielded as it arrives; the API layer (task 04)
|
|
turns tool-call pieces into SSE ``tool`` events and retry pieces into
|
|
SSE ``retry`` events. A truncated ``read`` (phase 95 — the document is
|
|
longer than ``settings.read_max_chars``) additionally yields one
|
|
:class:`ToolResultPiece` per truncated call, AFTER the round's
|
|
``tool`` frame and BEFORE the next model round (the API layer turns it
|
|
into an SSE ``tool_result`` frame, task 02). After the loop finishes,
|
|
*holder* carries the read documents, the executed tool-call count
|
|
(re-lists included), and the ``read_truncations`` signal list.
|
|
|
|
History (phase 74, TODO L4): *history* is the client's prior turns
|
|
already mapped to model messages by
|
|
:func:`app.rag.prompts.history_to_messages` (trimmed newest-first
|
|
against the settings budgets; assistant turns carry their prior
|
|
thinking as ``reasoning_content``). It is spliced between the system
|
|
prompt and the current user message —
|
|
``[system, *history, user]`` — and everything downstream (the tool
|
|
rounds, the phase-71 recovery rebuilding from ``messages[1:]``, the
|
|
retry restarts) already operates on that one ``messages`` list,
|
|
unchanged. ``()`` (the default) keeps the pre-phase-74 two-message
|
|
request byte-identical.
|
|
|
|
User message (phase 123, TODO L6): *user_message* is the current
|
|
turn's user content — the plain question string (every text-only
|
|
turn, byte-identical to pre-phase) OR the multimodal content list
|
|
``[{type: "text", …}, {type: "image_url", …}]`` for a question that
|
|
carried an image. FLOW (pinned): the API layer
|
|
(``app.api.chat``) builds the content via its ``build_user_content``
|
|
helper and passes it HERE — ``run_agent`` builds its OWN
|
|
``[system, *history, user]`` list from this value (it does NOT
|
|
receive the already-built ``messages``; the deflected branch is the
|
|
one that consumes chat.py's list directly), so a single value
|
|
covers both shapes and the tool rounds / recovery / retries operate
|
|
on it untouched. Prior turns' images are never replayed (LOCKED A7
|
|
— *history* is text-only by construction).
|
|
|
|
Retries (phase 67, owner-locked A2): every model request goes through
|
|
:func:`chat_stream_retried` — a failed round is retried **before** its
|
|
first piece (same messages, ``settings.llm_retries`` restarts, a flat
|
|
``settings.llm_retry_delay``); a round that already streamed pieces
|
|
fails the turn as before.
|
|
|
|
Scaffolding recovery (phase 71, deterministic only): every request —
|
|
each round, the forced final, and any recovery — runs its content
|
|
through a fresh :class:`app.rag.scaffolding.ScaffoldingFilter`. A
|
|
round that ends with NO visible content but a non-empty strip (the
|
|
scaffolding was the whole "answer") gets exactly ONE recovery:
|
|
``tools=None``, :data:`CORRECTION_INSTRUCTION` folded into the
|
|
original single system message (the rest of the history — user
|
|
message and tool results — unchanged), a fresh filter, the same
|
|
retry budget. A clean recovery ends the turn; a second empty reply
|
|
raises :class:`MalformedReplyError` (terminal — the API layer turns
|
|
it into the dedicated error frame). A round with visible content
|
|
plus scaffolding needs no recovery (the clean content stands), and
|
|
a scaffolding-only round that also carried tool calls needs none
|
|
(the tool ran) — the policy keys on the no-calls exit only. The
|
|
per-span strip warning log (each span truncated to 200 chars) is
|
|
the capture mechanism for new registry entries; *holder* accumulates
|
|
the turn's ``scaffold_stripped`` total for the API layer's log line.
|
|
|
|
``seed_docs`` are the suggested documents whose SUMMARY blocks the
|
|
caller put in the *system_prompt* (phase 118: the retrieval seeds
|
|
summaries, never full texts); reading one of them ADDS its full
|
|
text to the context through the ordinary ``read`` path — only a
|
|
document ALREADY READ is rejected with :data:`ALREADY_IN_CONTEXT`
|
|
(the phase-72 teaching line — answer from the text already in the
|
|
prompt); the rejection counts in nothing, but it still consumes a
|
|
round.
|
|
|
|
DB sessions (SEC-14-04): *db_factory* is a callable that returns a
|
|
new :class:`sqlalchemy.orm.Session` (e.g. ``lambda: SessionLocal()``).
|
|
Each tool call creates its own short-lived session via *db_factory*
|
|
and closes it after the tool result is produced — no session is held
|
|
across rounds, eliminating SSE-stream DB-connection pinning.
|
|
"""
|
|
messages: list[dict[str, Any]] = [
|
|
{"role": "system", "content": system_prompt},
|
|
*history, # phase 74: the client's prior turns (empty by default)
|
|
{"role": "user", "content": user_message},
|
|
]
|
|
# Phase 45: no per-tool budgets — the tools stay offered for the
|
|
# whole turn, bounded by the round cap. ``0`` is the no-tools kill
|
|
# switch: exactly one request with ``tools=None`` (the pre-phase-37
|
|
# path).
|
|
max_rounds = settings.agent_max_rounds
|
|
tools: list[dict[str, Any]] | None = AGENT_TOOLS if max_rounds > 0 else None
|
|
rounds = 0
|
|
while True:
|
|
calls: list[ToolCallPiece] = []
|
|
# Phase 71: one fresh filter per round (one per model request —
|
|
# the retry attempts of this logical request share it: a restart
|
|
# only happens while the filter was never fed). Content-only:
|
|
# thinking pieces pass through raw.
|
|
round_filter = ScaffoldingFilter()
|
|
round_content = 0 # visible (clean) content chars this round
|
|
# Phase 48: bind the round's stream so a consumer abandon
|
|
# (GeneratorExit into the yield below) tears down the in-flight
|
|
# model stream deterministically — not GC-dependent. Phase 67:
|
|
# the round goes through the retry primitive — a failure before
|
|
# the first piece restarts the request (locked A2) after a
|
|
# RetryPiece; closing the OUTER generator propagates GeneratorExit
|
|
# into ``chat_stream_retried``, whose own ``finally`` closes the
|
|
# in-flight inner ``chat_stream``, so teardown stays deterministic
|
|
# on consumer abandon. Awaiting ``aclose()`` in the ``finally`` is
|
|
# safe because it does not yield; on a fully consumed round it is
|
|
# a quiet no-op.
|
|
stream = chat_stream_retried(
|
|
llm,
|
|
messages,
|
|
tools=tools,
|
|
retries=settings.llm_retries,
|
|
delay=settings.llm_retry_delay,
|
|
scaffolding=round_filter,
|
|
)
|
|
try:
|
|
async for piece in stream:
|
|
if isinstance(piece, ToolCallPiece):
|
|
calls.append(piece)
|
|
elif isinstance(piece, StreamPiece) and piece.kind == "content":
|
|
round_content += len(piece.text)
|
|
yield piece
|
|
finally:
|
|
await stream.aclose()
|
|
# Phase 71: the per-strip-event capture log — one warning per
|
|
# stripped span, truncated to 200 chars (how a new scaffolding
|
|
# format gets captured and added to the registry) — and the turn
|
|
# total for the API layer's ``scaffold_stripped=N`` log field.
|
|
holder.scaffold_stripped += round_filter.stripped_chars
|
|
for span in round_filter.stripped_spans:
|
|
logger.warning(
|
|
"agent: stripped %d chars of tool-scaffolding in round %d: %r",
|
|
len(span),
|
|
rounds + 1,
|
|
span[:200],
|
|
)
|
|
if not calls:
|
|
if round_content > 0:
|
|
return # the answer was streamed (the clean content stands)
|
|
if round_filter.stripped_chars == 0:
|
|
# Empty/thinking-only answer — today's behavior, unchanged
|
|
# (the UI handles it); the guardrail keys on a strip.
|
|
return
|
|
# Phase 71: the scaffolding was the whole "answer" — the ONE
|
|
# bounded recovery (a fixed policy, not a conversation):
|
|
# ``tools=None``, the correction folded into the ORIGINAL
|
|
# single system message (provider-safe — the user message and
|
|
# any tool history stay in place), a fresh filter, the same
|
|
# phase-67 retry budget.
|
|
logger.warning(
|
|
"agent: round %d was pure tool-scaffolding (%d chars stripped) "
|
|
"— running the one bounded recovery",
|
|
rounds + 1,
|
|
round_filter.stripped_chars,
|
|
)
|
|
messages_recovered = [
|
|
{
|
|
"role": "system",
|
|
"content": system_prompt + "\n" + CORRECTION_INSTRUCTION,
|
|
},
|
|
*messages[1:],
|
|
]
|
|
recovery_filter = ScaffoldingFilter()
|
|
recovered = chat_stream_retried(
|
|
llm,
|
|
messages_recovered,
|
|
tools=None,
|
|
retries=settings.llm_retries,
|
|
delay=settings.llm_retry_delay,
|
|
scaffolding=recovery_filter,
|
|
)
|
|
recovery_content = 0
|
|
try:
|
|
async for piece in recovered:
|
|
if isinstance(piece, StreamPiece) and piece.kind == "content":
|
|
recovery_content += len(piece.text)
|
|
yield piece
|
|
finally:
|
|
await recovered.aclose()
|
|
holder.scaffold_stripped += recovery_filter.stripped_chars
|
|
for span in recovery_filter.stripped_spans:
|
|
logger.warning(
|
|
"agent: stripped %d chars of tool-scaffolding in the "
|
|
"recovery after round %d: %r",
|
|
len(span),
|
|
rounds + 1,
|
|
span[:200],
|
|
)
|
|
if recovery_content > 0:
|
|
return # the recovery answered — the turn ends
|
|
# The second empty reply is terminal (at most one recovery per
|
|
# turn). Raised OUTSIDE the stream, so chat_stream_retried's
|
|
# retry rule never sees it; the API layer catches it before
|
|
# the generic LLMError handler.
|
|
logger.warning(
|
|
"agent: the recovery reply was still empty "
|
|
"(scaffold_stripped=%d) — settling with a malformed-reply error",
|
|
holder.scaffold_stripped,
|
|
)
|
|
raise MalformedReplyError(
|
|
f"the model answered in raw tool-scaffolding twice in a row "
|
|
f"(round {rounds + 1} plus one recovery) — no clean answer "
|
|
"to stream"
|
|
)
|
|
call = calls[0] # a stream can carry several calls; run the first
|
|
# Phase 95: snapshot the truncation list BEFORE the execution so
|
|
# only the entries THIS call added are surfaced (one read per
|
|
# round, so at most one new entry — the loop still iterates the
|
|
# tail, so a future multi-call round stays correct).
|
|
trunc_before = len(holder.read_truncations)
|
|
with db_factory() as tool_db:
|
|
result = _execute_tool(tool_db, call, seed_docs, holder, settings)
|
|
rounds += 1 # every call the model emits consumes a round
|
|
logger.info(
|
|
"agent tool=%s args=%s round=%d/%d",
|
|
call.name,
|
|
json.dumps(call.arguments, ensure_ascii=False)[:200],
|
|
rounds,
|
|
max_rounds,
|
|
)
|
|
messages.append(
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": call.id,
|
|
"type": "function",
|
|
"function": {
|
|
"name": call.name,
|
|
"arguments": json.dumps(call.arguments),
|
|
},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
|
|
# Phase 95: surface this round's truncation(s) to the API layer.
|
|
# The piece lands AFTER the round's ``tool`` frame (the matching
|
|
# ``ToolCallPiece`` was already streamed, above) and BEFORE the
|
|
# next model round (the loop continues below) — task 02 turns it
|
|
# into an SSE ``tool_result`` frame + the UI marker.
|
|
for argument, shown, total in holder.read_truncations[trunc_before:]:
|
|
yield ToolResultPiece(
|
|
name=call.name,
|
|
argument=argument,
|
|
truncated=True,
|
|
chars_shown=shown,
|
|
chars_total=total,
|
|
)
|
|
if rounds >= max_rounds:
|
|
logger.warning(
|
|
"agent round cap reached (rounds=%d) — forcing a final "
|
|
"no-tools answer",
|
|
rounds,
|
|
)
|
|
# Phase 48: the forced final answer gets the same explicit
|
|
# teardown as the loop rounds (consumer abandon mid-final
|
|
# answer must still close the model's stream). Phase 67: the
|
|
# forced call retries under the same locked-A2 rule as the
|
|
# loop rounds. Phase 71: the forced final runs through a
|
|
# fresh filter too — raw scaffolding can never reach the
|
|
# user from ANY grounded request.
|
|
final_filter = ScaffoldingFilter()
|
|
final = chat_stream_retried(
|
|
llm,
|
|
messages,
|
|
tools=None,
|
|
retries=settings.llm_retries,
|
|
delay=settings.llm_retry_delay,
|
|
scaffolding=final_filter,
|
|
)
|
|
final_content = 0
|
|
try:
|
|
async for piece in final:
|
|
if isinstance(piece, StreamPiece) and piece.kind == "content":
|
|
final_content += len(piece.text)
|
|
yield piece
|
|
finally:
|
|
await final.aclose()
|
|
# Phase 71: the same capture log + turn total; a
|
|
# scaffolding-only forced final (this turn used no recovery,
|
|
# so nothing is doubled up) settles with the same terminal
|
|
# malformed-reply error rather than a silently empty answer.
|
|
holder.scaffold_stripped += final_filter.stripped_chars
|
|
for span in final_filter.stripped_spans:
|
|
logger.warning(
|
|
"agent: stripped %d chars of tool-scaffolding in the "
|
|
"forced final answer (round %d): %r",
|
|
len(span),
|
|
rounds + 1,
|
|
span[:200],
|
|
)
|
|
if final_content == 0 and final_filter.stripped_chars > 0:
|
|
logger.warning(
|
|
"agent: the forced final answer was pure tool-scaffolding "
|
|
"— settling with a malformed-reply error"
|
|
)
|
|
raise MalformedReplyError(
|
|
"the forced final answer was raw tool-scaffolding — no "
|
|
"clean answer to stream"
|
|
)
|
|
return
|