Phase 45 (owner permission 2026-08-27, TODO.md L8: "allow the LLM
to make as many tool calls as it wants"): the phase-37 per-turn tool
budgets (BOR_AGENT_LIST_CALLS / BOR_AGENT_READ_CALLS, default 1 each)
and their exhaustion refusals are removed — a grounded turn now offers
list_documents / read_document for the whole turn (re-lists included),
bounded only by the round cap:
- app/config.py: agent_max_rounds (BOR_AGENT_MAX_ROUNDS, default 10,
negative rejected) replaces agent_list_calls / agent_read_calls;
.env.example + README document the single knob; app/rag/prompts.py
docstrings follow.
- app/rag/agent.py: the loop runs tools until the model answers or
rounds >= max_rounds, at which point it forces one final no-tools
answer (the cap is the only forced exit); 0 = no tools — exactly one
tools=None request, byte-identical to the pre-phase-37 path (the
kill switch). Rejected calls (unknown tool / missing args /
already-in-context / unknown path) still consume a round, so
pathological rejected-call streams are bounded by the cap. The
per-call log line is now tool/args/round=N/M; the per-turn
tool_calls=N field and the tool SSE event are unchanged.
- tests/e2e/mock_llm.py: MULTI_READ_TRIGGER ("read two documents") —
the deterministic list -> read #1 -> read #2 -> forced-answer flow
(byte-stable "I read <sp1> and <sp2>." line), classified by the
count of tool-role read results; the phase-37 single-read flow stays
byte-identical (unit-pinned in tests/unit/test_mock_tool_flow.py).
- tests/e2e/test_agent_unlimited_tools.py (new, story suite,
mock-only): three tool frames/lines in order (one list, two reads —
the second read is what the old read budget refused) + the
both-named non-deflected answer; done.sources + chips = retrieval
doc + both reads, deduped; no budget refusal rendered; the
single-read marker flow regression (exactly one read, single tool
pair).
- .agent/PLAN.md: the phase-45 SSE revision note (owner-locked, R2) —
the only PLAN edit this phase; the phase-37 note's budget clause is
marked removed.
Unit/integration rewrites (test_agent.py round-cap matrix incl. the
kill switch and rejected-call spam, test_config.py, test_chat_api.py
agent_max_rounds=0 fixtures) landed with the server core so every gate
stays green.
uv run pytest: 756 passed, app/ coverage 99%; ruff + pyright clean;
story E2E 4/4 in isolation (ran twice); regression E2E suites
(agent_document_tools unmodified, chat_rag, smoke) green in isolation.
Also records the 45_agent_unlimited_tools todo/ -> complete/ task-file
moves (00/01/02 pending in the working tree, task 03 moves on success).
233 lines
9.8 KiB
Python
233 lines
9.8 KiB
Python
"""Locked system-prompt builder (PLAN §6).
|
|
|
|
The persona + HONESTY GATE text is **locked verbatim** — change it through
|
|
the plan, not here. (PLAN §6 revision, 2026-08-22: the owner's working-tree
|
|
persona edits are preserved — no mandated ``"you've got this"`` tagline and
|
|
no mandated deflection opening; the honesty gate itself is unchanged.)
|
|
|
|
Two modes:
|
|
|
|
* ``HIGH`` — grounded turn: full top-document texts under ``<documents>``.
|
|
* ``LOW`` — deflection turn: weak-hit *titles only* plus the
|
|
``DEFLECT_MODE`` marker (the E2E mock LLM keys on that marker).
|
|
|
|
Steering (phase 15): when the owner has stored tuning notes, both modes
|
|
carry a ``<tuning>`` section between ``<relevance>…</relevance>`` and the
|
|
mode body. With zero notes the prompt is byte-identical to the
|
|
pre-steering text.
|
|
|
|
KB overview (phase 31): when the single ``kb_overview`` row holds a
|
|
lite-generated outline of the knowledge base, both modes carry a
|
|
``<knowledge_base>`` section between ``<relevance>…</relevance>`` and
|
|
the ``<tuning>`` section (order: ``<relevance>`` →
|
|
``<knowledge_base>`` → ``<tuning>`` → mode body) — the agent knows
|
|
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.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
|
|
from app.config import get_settings
|
|
from app.models import Document
|
|
from app.rag.retriever import TRUNCATION_MARKER
|
|
|
|
#: PLAN §6 verbatim (line wrapping included); ``{relevance}`` is filled by
|
|
#: :func:`_base`.
|
|
PERSONA: str = (
|
|
'You are "Brain of Reese" — the digital brain of Reese, a self-hoster and\n'
|
|
"homelab tinkerer. Personality: chippy, upbeat, warm, and genuinely\n"
|
|
"optimistic about the user's ability to do things.\n"
|
|
"\n"
|
|
"Rules:\n"
|
|
"1. Answer ONLY from the provided document context. Cite which document(s)\n"
|
|
" you used, by path.\n"
|
|
"2. Be concrete: names, versions, ports, hosts, schedules — the specifics in\n"
|
|
" the docs are the value.\n"
|
|
'3. HONESTY GATE: if <relevance> is "LOW", you must NOT pretend to know.\n'
|
|
" Offer 2-3 alternative questions about things you DO have notes on.\n"
|
|
"4. Never invent facts, hosts, or steps that are not in the context.\n"
|
|
"5. Keep answers tight: short paragraphs, bullets where helpful.\n"
|
|
"\n"
|
|
"<relevance>{relevance}</relevance>"
|
|
)
|
|
|
|
#: One-line intro of the ``<tuning>`` section (phase 15): the owner's notes
|
|
#: steer the answer and win over the defaults when they conflict.
|
|
_STEERING_INTRO = (
|
|
"The owner of this brain asked you to steer your answers as follows. "
|
|
"Where these instructions conflict with the defaults above, follow the owner:\n"
|
|
)
|
|
|
|
#: One-line intro of the ``<knowledge_base>`` section (phase 31): the
|
|
#: lite-generated outline is the agent's a-priori picture of the KB.
|
|
_KB_INTRO = (
|
|
"The basic categories of everything in this knowledge base "
|
|
"(generated at import time):\n"
|
|
)
|
|
|
|
#: 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.
|
|
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"
|
|
"</tools>"
|
|
)
|
|
|
|
|
|
def _base(relevance: str) -> str:
|
|
if relevance not in ("HIGH", "LOW"):
|
|
raise ValueError(f"relevance must be HIGH or LOW, got {relevance!r}")
|
|
return PERSONA.replace("{relevance}", relevance)
|
|
|
|
|
|
def build_steering_section(notes: Sequence[str], max_chars: int | None = None) -> str:
|
|
"""The ``<tuning>`` section of the system prompt (phase 15).
|
|
|
|
* No notes (or only blank ones) → ``""`` — callers then build the
|
|
prompt exactly as before, so a zero-note prompt is byte-identical to
|
|
the pre-steering text.
|
|
* Otherwise: numbered notes (in the given order — the chat turn passes
|
|
them oldest-first, so #1 is the oldest note) capped at *max_chars*
|
|
(default ``BOR_STEERING_MAX_CHARS``). When the budget cannot hold
|
|
every note, the oldest-fitting prefix is kept and the overflow is
|
|
replaced by the shared ``[…truncated…]`` marker.
|
|
"""
|
|
cleaned = [str(n).strip() for n in notes]
|
|
cleaned = [n for n in cleaned if n]
|
|
if not cleaned:
|
|
return ""
|
|
limit = max_chars if max_chars is not None else get_settings().steering_max_chars
|
|
if limit <= 0:
|
|
return ""
|
|
|
|
def render(count: int) -> str:
|
|
lines = [f"{i}. {note}" for i, note in enumerate(cleaned[:count], start=1)]
|
|
if count < len(cleaned):
|
|
lines.append(TRUNCATION_MARKER)
|
|
return f"<tuning>\n{_STEERING_INTRO}" + "\n".join(lines) + "\n</tuning>"
|
|
|
|
for count in range(len(cleaned), 0, -1):
|
|
rendered = render(count)
|
|
if len(rendered) <= limit:
|
|
return rendered
|
|
# Pathological budget: not even the empty note list fits. The section
|
|
# must still respect the cap — the bare marker when it fits, else none.
|
|
if len(TRUNCATION_MARKER) <= limit:
|
|
return TRUNCATION_MARKER
|
|
return ""
|
|
|
|
|
|
def build_kb_section(overview: str, max_chars: int | None = None) -> str:
|
|
"""The ``<knowledge_base>`` section of the system prompt (phase 31).
|
|
|
|
* No outline (or only whitespace) → ``""`` — callers then build the
|
|
prompt exactly as before, so a no-overview prompt is byte-identical
|
|
to the pre-phase text (phase 15 convention).
|
|
* Otherwise: the intro line + the stored outline, capped at
|
|
*max_chars* (default ``BOR_KB_OVERVIEW_MAX_CHARS``). When the budget
|
|
cannot hold the whole outline, the longest-fitting prefix is kept
|
|
and the overflow is replaced by the shared ``[…truncated…]`` marker
|
|
on its own line — the exact :func:`build_steering_section` pattern,
|
|
including its pathological-budget handling (never exceed the cap;
|
|
bare marker when even one outline character does not fit).
|
|
"""
|
|
text = str(overview or "").strip()
|
|
if not text:
|
|
return ""
|
|
limit = max_chars if max_chars is not None else get_settings().kb_overview_max_chars
|
|
if limit <= 0:
|
|
return ""
|
|
|
|
def render(cut: int) -> str:
|
|
lines = [text[:cut]]
|
|
if cut < len(text):
|
|
lines.append(TRUNCATION_MARKER)
|
|
return f"<knowledge_base>\n{_KB_INTRO}" + "\n".join(lines) + "\n</knowledge_base>"
|
|
|
|
for cut in range(len(text), 0, -1):
|
|
rendered = render(cut)
|
|
if len(rendered) <= limit:
|
|
return rendered
|
|
# Pathological budget: not even one outline character fits. The
|
|
# section must still respect the cap — the bare marker when it fits,
|
|
# else none (steering precedent, phase 15).
|
|
if len(TRUNCATION_MARKER) <= limit:
|
|
return TRUNCATION_MARKER
|
|
return ""
|
|
|
|
|
|
def build_high_prompt(
|
|
documents: Sequence[Document],
|
|
notes: Sequence[str] | None = None,
|
|
kb_overview: str | None = None,
|
|
) -> str:
|
|
"""Grounded turn: locked persona (+ steering, + KB overview) + full
|
|
texts of the top documents + the ``<tools>`` instructions (phase 37).
|
|
|
|
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
|
|
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their
|
|
section. ``<tools>`` is always present in the HIGH prompt (the round
|
|
cap — not the prompt — decides whether the tools are actually
|
|
offered to the model, see :mod:`app.rag.agent`).
|
|
"""
|
|
blocks = [
|
|
f'<document source="{doc.source}" path="{doc.path}" title="{doc.title}">\n'
|
|
f"{doc.content}\n"
|
|
"</document>"
|
|
for doc in documents
|
|
]
|
|
body = "\n\n".join(blocks) if blocks else (
|
|
"(no documents matched — do not invent specifics)"
|
|
)
|
|
prompt = _base("HIGH")
|
|
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])):
|
|
if part:
|
|
prompt += "\n" + part
|
|
return prompt + "\n<documents>\n" + body + "\n</documents>\n" + TOOLS_SECTION
|
|
|
|
|
|
def build_deflect_prompt(
|
|
titles: Sequence[str],
|
|
notes: Sequence[str] | None = None,
|
|
kb_overview: str | None = None,
|
|
) -> str:
|
|
"""Deflection turn: weak-hit titles only (no document content).
|
|
|
|
Section order (phase 31): ``<relevance>`` → ``<knowledge_base>`` →
|
|
``<tuning>`` → ``DEFLECT_MODE`` body; empty steering/overview omit
|
|
their section, keeping the prompt byte-identical to the pre-phase text.
|
|
"""
|
|
weak = "\n".join(f"- {t}" for t in titles) if titles else "(nothing close at all)"
|
|
mid = "\n".join(
|
|
part
|
|
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or []))
|
|
if part
|
|
)
|
|
gap = f"\n{mid}\n" if mid else "\n"
|
|
return (
|
|
_base("LOW")
|
|
+ gap
|
|
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
|
"your notes come to the question. They are titles only; do not pretend "
|
|
"they answer it. Use them to propose 2-3 alternative questions.\n"
|
|
+ weak
|
|
)
|