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).
821 lines
37 KiB
Python
821 lines
37 KiB
Python
"""Deterministic OpenAI-compatible mock for E2E tests (aipi stand-in).
|
|
|
|
Implements just enough of the aipi surface:
|
|
|
|
* ``GET /v1/models``
|
|
* ``POST /v1/embeddings`` — real bag-of-words vectors (768-dim, L2-normed).
|
|
Because similarity is *genuine token overlap*, the relevance threshold
|
|
behaves the same way it will in production: related questions score high,
|
|
unrelated ones score low and trigger honest deflection.
|
|
* ``POST /v1/chat/completions`` — streaming (SSE) or not. The content keys
|
|
off markers in the system prompt:
|
|
- user message containing ``write a long answer`` -> a ~900-word
|
|
deterministic numbered answer (long-answers story, phase 11)
|
|
- ``SUMMARY_MODE`` -> the deterministic summary digest: the first 24
|
|
tokens of the user message (the summarizer puts the capped document
|
|
content there) — byte-stable for a given fixture (document summaries,
|
|
phase 30)
|
|
- ``KB_OVERVIEW_MODE`` -> the deterministic outline: the first 8 tokens
|
|
of the user message (the generator puts the document list there) —
|
|
byte-stable for a given KB (KB overview, phase 31)
|
|
- ``DEFLECT_MODE`` -> honest "I haven't done anything like that" answer
|
|
- otherwise -> upbeat answer quoting the provided document context
|
|
- user message containing ``pretend to think slowly`` -> 3s warm-up delay
|
|
(used by the loading-feedback story).
|
|
- user message containing ``think out loud`` -> the answer is preceded by
|
|
~2 700 chars of deterministic ``reasoning_content`` chunks (the
|
|
thinking-display story, phase 17; lengthened in phase 21 so the
|
|
rendered scratchpad overflows the 320px ``.thinking-text`` window)
|
|
- user message containing ``think out loud then hesitate`` -> the
|
|
``think out loud`` stream, then a 4s pause before the first content
|
|
frame (the sources-midstream story, phase 20 — a deterministic
|
|
"leave during pure thinking" navigation window).
|
|
- system prompt containing ``<tuning>`` (phase 15, steering notes) ->
|
|
the composed answer ends with `` (tuning: <first note line>)`` —
|
|
makes prompt injection observable in the UI deterministically.
|
|
- system prompt containing ``<knowledge_base>`` (phase 31, KB overview)
|
|
-> the composed answer ends with `` (kb: <first bullet line>)`` —
|
|
the same echo convention for the overview's prompt injection.
|
|
- user message containing ``show the end of your notes`` (phase 24,
|
|
whole-document context) -> the answer quotes the **last 160 chars of
|
|
the ``<documents>`` block** — a tail echo, byte-stable across runs, so
|
|
a sentinel placed at the *end* of a document appears in the rendered
|
|
answer iff the whole document was in the prompt. (Phase 37: the HIGH
|
|
prompt now ends with a ``<tools>`` section after ``</documents>``, so
|
|
the echo targets the block itself; its tail still includes the
|
|
closing tag — same sentinel semantics.)
|
|
- user message containing ``use your tools`` (phase 37, agent document
|
|
tools) **and** the system prompt carries the ``<tools>`` section ->
|
|
the deterministic SINGLE-READ tool flow, discriminated statelessly
|
|
from the messages (the ``tools`` parameter gates the list/read
|
|
steps — a no-tools request with no tool results is not the flow):
|
|
* request 1 (``tools`` offered, no tool results yet): stream ONLY
|
|
``tool_calls`` deltas — ``list_documents`` (synthetic id
|
|
``call_0``, no arguments), ``finish_reason: "tool_calls"``, no
|
|
content;
|
|
* request 2 (a ``tool``-role catalog result in the messages):
|
|
parse the FIRST catalog line (``source/path — title`` → split on
|
|
``" — "`` → ``rsplit("/", 1)``) and stream a ``tool_calls`` delta
|
|
calling ``read_document`` on it (id ``call_1``);
|
|
* request 3 (a ``tool``-role read result in the messages): a
|
|
content answer, deterministic: ``Read <source/path>. <first 80
|
|
chars of the read document's content>`` — so a suite can assert
|
|
the read document reached the model and landed in the answer.
|
|
Reached regardless of the ``tools`` parameter (phase 45 keeps
|
|
the tools offered until the round cap).
|
|
The single-read flow stops at ONE read result; the MULTI-READ
|
|
variant below reads two.
|
|
- user message containing BOTH ``use your tools`` AND ``read two
|
|
documents`` (``MULTI_READ_TRIGGER``, phase 45 task 02) **and** the
|
|
system prompt carries the ``<tools>`` section -> the deterministic
|
|
MULTI-READ flow (list → read #1 → read #2 → answer), classified by
|
|
the COUNT of ``tool``-role read results (content starting with the
|
|
agent's ``"Document <source/path>:"`` prefix):
|
|
* 0 read results, no catalog yet: ``list_documents`` (id
|
|
``call_0``);
|
|
* 0 read results, catalog present: ``read_document`` on the FIRST
|
|
catalog line (id ``call_1``);
|
|
* 1 read result: ``read_document`` on the SECOND catalog line —
|
|
the first listing line whose ``source/path`` differs from the
|
|
one already read (id ``call_2``); a one-document catalog
|
|
degenerates to the single-read answer (nothing second to read);
|
|
* 2 read results: the forced answer, byte-stable: the single-read
|
|
shape quoting the FIRST read result, plus the line ``I read
|
|
<sp1> and <sp2>.`` naming both read paths in read order — so a
|
|
suite can assert the model used BOTH documents.
|
|
All other requests (including the marker without a ``<tools>``
|
|
section, or with the tool conversation not yet started and no tools
|
|
offered — e.g. ``agent_max_rounds=0``) behave exactly as today.
|
|
``E2E_REAL_LLM=1`` ignores the mock entirely (the real model does
|
|
what it does).
|
|
- user message containing ``show me a table`` (phase 44, markdown
|
|
tables, TODO.md L6) -> the fixed table answer (``TABLE_ANSWER``):
|
|
a 3-column service table, an ``<img onerror>`` XSS probe line, and
|
|
a deliberately wide 5-column table — byte-stable, so the story E2E
|
|
can assert the rendered ``<table class="md-table">`` shape, the
|
|
escaped XSS line, and the wrapper's horizontal scroll inside the
|
|
46rem column. Checked BEFORE the ``DEFLECT_MODE`` branch (a
|
|
deflection prompt never carries the marker, same reasoning as
|
|
``SUMMARY_MODE``), so a marker question always gets the table
|
|
answer; the E2E asks it against an on-topic fixture (HIGH gate) and
|
|
asserts non-deflection.
|
|
|
|
``max_tokens`` is honored deterministically (token ≈ whitespace word),
|
|
like a real endpoint: an answer longer than the cap is truncated. This
|
|
is what makes the phase-11 truncation regression observable.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import math
|
|
import os
|
|
import re
|
|
import signal
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
app = FastAPI()
|
|
|
|
DIM = 768
|
|
TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
|
|
|
|
def embed_text(text: str) -> list[float]:
|
|
vec = [0.0] * DIM
|
|
for tok in TOKEN_RE.findall(text.lower()):
|
|
idx = int(hashlib.md5(tok.encode()).hexdigest(), 16) % DIM
|
|
vec[idx] += 1.0
|
|
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
|
|
return [v / norm for v in vec]
|
|
|
|
|
|
def _messages(body: dict[str, Any]) -> list[dict[str, str]]:
|
|
return body.get("messages", [])
|
|
|
|
|
|
def _system(body: dict[str, Any]) -> str:
|
|
return " ".join(m.get("content", "") for m in _messages(body) if m.get("role") == "system")
|
|
|
|
|
|
def _user(body: dict[str, Any]) -> str:
|
|
parts = [m.get("content", "") for m in _messages(body) if m.get("role") == "user"]
|
|
return parts[-1] if parts else ""
|
|
|
|
|
|
def _context(body: dict[str, Any]) -> str:
|
|
"""The document context is the longest system/user message in practice."""
|
|
msgs = _messages(body)
|
|
return max((m.get("content", "") for m in msgs), key=len)
|
|
|
|
|
|
LONG_ANSWER_TRIGGER = "write a long answer"
|
|
#: ~920 words — comfortably past the old hard 700-token cap (where the
|
|
#: tail would be cut) yet short enough to stream in ~8s at the mock's
|
|
#: per-chunk pacing.
|
|
LONG_ANSWER_LINES = 40
|
|
LONG_ANSWER_END = "LONG-ANSWER-END"
|
|
|
|
#: Phase 17 (thinking-display story): a user message containing this
|
|
#: substring (case-insensitive) is answered with a deterministic
|
|
#: ``reasoning_content`` stream ahead of the content — same convention as
|
|
#: the other user-message triggers above. Existing E2E questions do not
|
|
#: contain the substring, so every other suite is unaffected.
|
|
THINKING_TRIGGER = "think out loud"
|
|
|
|
#: Phase 20 (sources-midstream bug): a user message containing this
|
|
#: substring (case-insensitive) gets the phase-17 thinking stream followed
|
|
#: by a multi-second pause before the FIRST content frame — the
|
|
#: navigation window for the "leave during pure thinking" scenario
|
|
#: (owner-confirmed A1.2: nothing brain-side may be persisted then).
|
|
#: Strictly longer than ``THINKING_TRIGGER``, so the phase-17 suite's
|
|
#: questions are unaffected.
|
|
SLOW_PRETOKEN_TRIGGER = "think out loud then hesitate"
|
|
PRE_CONTENT_PAUSE_S = 4.0
|
|
|
|
#: Phase 24 (whole-document-context story): a user message containing this
|
|
#: substring (case-insensitive) gets an answer quoting the TAIL of the
|
|
#: document context (see the module docstring). Verified 2026-08-24: no
|
|
#: existing E2E question or fixture file contains the phrase, so every
|
|
#: other suite is unaffected.
|
|
END_OF_NOTES_TRIGGER = "show the end of your notes"
|
|
|
|
#: The ``<documents>`` block of the system prompt (phase 37: the HIGH
|
|
#: prompt ends with the ``<tools>`` section after ``</documents>``, so the
|
|
#: phase-24 tail echo targets the block, not the raw message tail).
|
|
_DOCUMENTS_BLOCK_RE = re.compile(r"<documents>.*?</documents>", re.S)
|
|
|
|
#: Phase 37 (agent-document-tools story): a user message containing this
|
|
#: substring (case-insensitive) — combined with the ``<tools>`` section
|
|
#: in the system prompt — drives the deterministic tool flow documented
|
|
#: in the module docstring (list_documents → read_document on the first
|
|
#: catalog line → the quoted answer). Existing E2E questions do not
|
|
#: contain the phrase, so every other suite is unaffected.
|
|
TOOLS_TRIGGER = "use your tools"
|
|
|
|
#: Phase 45 (agent-unlimited-tools story, task 02): a user message
|
|
#: containing BOTH ``TOOLS_TRIGGER`` and this substring (case-insensitive
|
|
#: — the check lowercases the user message) drives the deterministic
|
|
#: MULTI-READ tool flow (list → read #1 → read #2 → the forced answer
|
|
#: naming both read paths) — see the module docstring. The existing
|
|
#: phase-37 E2E question carries ``TOOLS_TRIGGER`` but not this phrase,
|
|
#: so the 3-step flow is untouched.
|
|
MULTI_READ_TRIGGER = "read two documents"
|
|
|
|
#: Phase 44 (markdown-tables story, TODO.md L6): a user message
|
|
#: containing this substring (case-insensitive) gets the fixed table
|
|
#: answer (``TABLE_ANSWER`` below) — a 3-column table, an XSS probe
|
|
#: line, and a deliberately wide table (see the module docstring).
|
|
#: Existing E2E questions do not contain the phrase, so every other
|
|
#: suite is unaffected.
|
|
TABLE_TRIGGER = "show me a table"
|
|
|
|
#: The fixed table answer (phase 44) — byte-stable on purpose: the story
|
|
#: E2E asserts the rendered table shape, the escaped ``<img onerror>``
|
|
#: line (the XSS payload must survive the mock byte-for-byte), and the
|
|
#: wide table's ``scrollWidth > clientWidth`` inside the 46rem column.
|
|
TABLE_ANSWER = (
|
|
"Here's the shape, in a table:\n"
|
|
"\n"
|
|
"| Service | Port | Host |\n"
|
|
"|---|---|---|\n"
|
|
"| Caddy | 80 | homelab-gw |\n"
|
|
"| GitLab | 8929 | homelab-git |\n"
|
|
"| ntfy | 2087 | homelab-ntfy |\n"
|
|
"\n"
|
|
"<img src=x onerror=alert(1)>\n"
|
|
"\n"
|
|
"And the wide one:\n"
|
|
"\n"
|
|
"| A very long column header to force overflow | Second column with "
|
|
"some padding text | Third column | Fourth | Fifth |\n"
|
|
"|---|---|---|---|---|\n"
|
|
"| value-one | value-two | value-three | value-four | value-five |"
|
|
)
|
|
|
|
|
|
#: The agent's ``read_document`` tool-result prefix (app.rag.agent
|
|
#: ``_execute_tool``): ``"Document <source/path>:\n<content>"``.
|
|
_READ_RESULT_PREFIX = "Document "
|
|
|
|
|
|
def _read_results(body: dict[str, Any]) -> list[tuple[str, str]]:
|
|
"""The read results in the messages, in order: ``(source/path, content)``.
|
|
|
|
A read result is a ``tool``-role message whose content starts with
|
|
the agent's read-result prefix (``app.rag.agent`` ``_execute_tool``):
|
|
``"Document <source/path>:\n<content>"``. The header is stripped of
|
|
the prefix AND the trailing colon so the path stays clean.
|
|
"""
|
|
out: list[tuple[str, str]] = []
|
|
for m in _messages(body):
|
|
if m.get("role") != "tool":
|
|
continue
|
|
content = str(m.get("content") or "")
|
|
if content.startswith(_READ_RESULT_PREFIX):
|
|
header, _, doc_content = content.partition("\n")
|
|
sp = header[len(_READ_RESULT_PREFIX):].strip().removesuffix(":")
|
|
out.append((sp, doc_content))
|
|
return out
|
|
|
|
|
|
def _catalog_docs(body: dict[str, Any]) -> list[tuple[str, str]]:
|
|
"""Every ``source/path`` in the catalog tool result, in listing order.
|
|
|
|
Catalog lines are ``source/path — title`` (the agent's
|
|
``list_documents`` output): split on ``" — "``, keep the head, and
|
|
recover ``(source, path)`` with ``rsplit("/", 1)`` (``rpartition``)
|
|
— the same convention the single-read flow's read step uses. The
|
|
``"N documents:"`` header line carries no ``/`` and is skipped; read-
|
|
result messages are full documents, not listings, and are skipped
|
|
too.
|
|
"""
|
|
docs: list[tuple[str, str]] = []
|
|
for m in _messages(body):
|
|
if m.get("role") != "tool":
|
|
continue
|
|
content = str(m.get("content") or "")
|
|
if content.startswith(_READ_RESULT_PREFIX):
|
|
continue
|
|
for line in content.splitlines():
|
|
head = line.split(" — ", 1)[0].strip()
|
|
if "/" in head:
|
|
source, _, path = head.rpartition("/")
|
|
if source and path:
|
|
docs.append((source, path))
|
|
return docs
|
|
|
|
|
|
def _tool_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
|
"""Classify a marker request into one step of the tool flow.
|
|
|
|
Single-read (phase 37 — the user message carries ``TOOLS_TRIGGER``
|
|
only):
|
|
|
|
* ``("list", "", "")`` — ``tools`` are offered and no tool results
|
|
are in the messages yet: the model lists the catalog.
|
|
* ``("read", source, path, "call_1")`` — a ``tool``-role catalog
|
|
result is in the messages: the model reads its FIRST
|
|
``source/path — title`` line (split on ``" — "``, then
|
|
``rsplit("/", 1)``).
|
|
* ``("answer", "source/path", content)`` — a ``tool``-role read
|
|
result (``"Document <source/path>:\n<content>"``) is in the
|
|
messages: the model answers, quoting the read document. Reached
|
|
regardless of the ``tools`` parameter (phase 45 keeps the tools
|
|
offered until the round cap).
|
|
|
|
Multi-read (phase 45 task 02 — the user message carries BOTH
|
|
``TOOLS_TRIGGER`` and ``MULTI_READ_TRIGGER``), classified by the
|
|
count of ``tool``-role read results:
|
|
|
|
* 0 read results: ``("list", "", "")`` (no catalog yet) or
|
|
``("read", source, path, "call_1")`` on the FIRST catalog doc.
|
|
* 1 read result: ``("read", source, path, "call_2")`` on the SECOND
|
|
catalog doc — the first listing line whose ``source/path``
|
|
differs from the one already read. A one-document catalog
|
|
degenerates to the single-read ``("answer", ...)`` shape (nothing
|
|
second to read).
|
|
* 2 read results: ``("multi_answer", "", text)`` — the forced
|
|
answer, byte-stable: the single-read shape quoting the FIRST read
|
|
result, plus ``I read <sp1> and <sp2>.`` (both read paths, read
|
|
order). The second element is unused.
|
|
|
|
* ``None`` — not the marker flow: the request behaves exactly as
|
|
before (marker absent, no ``<tools>`` section, or a no-tools
|
|
request with no tool results — e.g. ``agent_max_rounds=0``).
|
|
"""
|
|
user = _user(body).lower()
|
|
if TOOLS_TRIGGER not in user:
|
|
return None
|
|
if "<tools>" not in _system(body):
|
|
return None
|
|
reads = _read_results(body)
|
|
if MULTI_READ_TRIGGER in user:
|
|
if not reads:
|
|
if not body.get("tools"):
|
|
return None
|
|
docs = _catalog_docs(body)
|
|
if not docs:
|
|
return ("list", "", "")
|
|
return ("read", docs[0][0], docs[0][1], "call_1")
|
|
if len(reads) == 1:
|
|
skip = reads[0][0]
|
|
second = next(
|
|
(d for d in _catalog_docs(body) if f"{d[0]}/{d[1]}" != skip), None
|
|
)
|
|
if second is None:
|
|
# One-document catalog: nothing second to read — the
|
|
# single-read answer shape (deterministic degenerate).
|
|
return ("answer", reads[0][0], reads[0][1])
|
|
return ("read", second[0], second[1], "call_2")
|
|
(sp1, c1), (sp2, _c2) = reads[0], reads[1]
|
|
answer = f"Read {sp1}. {c1[:80]} I read {sp1} and {sp2}."
|
|
return ("multi_answer", "", answer)
|
|
# Phase-37 single-read flow — byte-identical to the original.
|
|
if reads:
|
|
return ("answer", reads[0][0], reads[0][1])
|
|
if not body.get("tools"):
|
|
return None
|
|
docs = _catalog_docs(body)
|
|
if docs:
|
|
return ("read", docs[0][0], docs[0][1], "call_1")
|
|
return ("list", "", "")
|
|
|
|
|
|
def long_answer() -> str:
|
|
"""~900-word deterministic walkthrough (phase 11): numbered steps plus
|
|
a unique final line that must survive the stream untruncated."""
|
|
lines = [
|
|
f"{i}. Step {i}: configure node-{i} with the homelab defaults and "
|
|
f"verify that step {i} of the long walkthrough is complete before moving on."
|
|
for i in range(1, LONG_ANSWER_LINES + 1)
|
|
]
|
|
lines.append(LONG_ANSWER_END)
|
|
return "\n".join(lines)
|
|
|
|
|
|
#: First numbered note line of a ``<tuning>`` section (phase 15).
|
|
_TUNING_BLOCK_RE = re.compile(r"<tuning>\n(.*?)\n</tuning>", re.S)
|
|
_NOTE_LINE_RE = re.compile(r"^\d+\.\s*(.+)$")
|
|
|
|
|
|
def first_tuning_note(system: str) -> str | None:
|
|
"""The first steering note in the system prompt, or ``None``.
|
|
|
|
The prompt numbers notes 1..N oldest-first (see
|
|
``app.rag.prompts.build_steering_section``); the mock echoes the first
|
|
one into its answer so prompt injection is observable in the UI.
|
|
"""
|
|
block = _TUNING_BLOCK_RE.search(system)
|
|
if not block:
|
|
return None
|
|
for line in block.group(1).splitlines():
|
|
m = _NOTE_LINE_RE.match(line.strip())
|
|
if m:
|
|
return m.group(1).strip()
|
|
return None
|
|
|
|
|
|
#: First ``-`` bullet line of a ``<knowledge_base>`` section (phase 31).
|
|
_KB_BLOCK_RE = re.compile(r"<knowledge_base>\n(.*?)\n</knowledge_base>", re.S)
|
|
_KB_BULLET_RE = re.compile(r"^-(?:\s+(.*))?$")
|
|
|
|
|
|
def first_kb_bullet(system: str) -> str | None:
|
|
"""The first outline bullet in the system prompt, or ``None``.
|
|
|
|
The stored outline (phase 31) is ``-`` bullet lines (see
|
|
``app.rag.overview.OVERVIEW_INSTRUCTION``); the mock echoes the first
|
|
one into its answer as `` (kb: <bullet>)`` — the exact
|
|
:func:`first_tuning_note` convention, so prompt injection of the
|
|
``<knowledge_base>`` section is observable in the UI.
|
|
"""
|
|
block = _KB_BLOCK_RE.search(system)
|
|
if not block:
|
|
return None
|
|
for line in block.group(1).splitlines():
|
|
m = _KB_BULLET_RE.match(line.strip())
|
|
if m:
|
|
return (m.group(1) or "").strip()
|
|
return None
|
|
|
|
|
|
def compose_answer(body: dict[str, Any]) -> str:
|
|
system = _system(body)
|
|
user = _user(body)
|
|
if LONG_ANSWER_TRIGGER in user.lower():
|
|
answer = long_answer()
|
|
elif "SUMMARY_MODE" in system:
|
|
# Document summaries (phase 30): the ``lite`` stand-in returns a
|
|
# deterministic digest — the first 24 tokens of the user message
|
|
# (the summarizer puts the capped document content there). Byte-
|
|
# stable for a given fixture, so the summary chunk's retrieval
|
|
# rank is a pure function of the fixture text. Checked BEFORE the
|
|
# DEFLECT_MODE branch (task 06) so a deflection prompt that ever
|
|
# carries the marker cannot shadow the summary call.
|
|
answer = (
|
|
f"This document covers "
|
|
f"{' '.join(TOKEN_RE.findall(user.lower())[:24])}."
|
|
)
|
|
elif "KB_OVERVIEW_MODE" in system:
|
|
# KB overview (phase 31): the ``lite`` stand-in returns the
|
|
# deterministic outline — the first 8 tokens of the user message
|
|
# (the generator puts the document list there). Byte-stable for a
|
|
# given KB, so the stored row is a pure function of the fixture.
|
|
# Checked BEFORE the DEFLECT_MODE branch, like SUMMARY_MODE, so a
|
|
# prompt that ever carries both markers cannot shadow the
|
|
# overview call.
|
|
answer = "Knowledge base outline:\n- " + " ".join(
|
|
TOKEN_RE.findall(user.lower())[:8]
|
|
)
|
|
elif TABLE_TRIGGER in user.lower():
|
|
# Markdown tables (phase 44, TODO.md L6): the story E2E's
|
|
# deterministic table answer — a 3-column table, the
|
|
# <img onerror> XSS probe line (it must survive the mock
|
|
# byte-for-byte so the E2E can prove the renderer neutralizes
|
|
# it), and a wide 5-column table (guarantees scrollWidth >
|
|
# clientWidth inside the 46rem column). Byte-stable. Checked
|
|
# BEFORE the DEFLECT_MODE branch: a deflection prompt never
|
|
# carries the marker (it lives in the user message, same
|
|
# reasoning as SUMMARY_MODE), so a marker question always gets
|
|
# the table answer, whatever the gate says; the E2E asks it
|
|
# against an on-topic fixture, where the gate is HIGH, and
|
|
# asserts non-deflection as part of the table test.
|
|
answer = TABLE_ANSWER
|
|
elif "DEFLECT_MODE" in system:
|
|
answer = (
|
|
"Ah — I haven't done anything like that, so I don't want to make stuff up! "
|
|
"You're thinking bigger than my notes for a second. Try asking about "
|
|
"kubernetes, backups, or deploying a new service — I know those inside out. "
|
|
"You've got this!"
|
|
)
|
|
elif END_OF_NOTES_TRIGGER in user.lower():
|
|
# Whole-document-context story (phase 24): echo the tail of the
|
|
# document context. Byte-stable across runs — a sentinel on the
|
|
# document's last line appears in the answer iff the whole
|
|
# document was in the prompt. (The tail includes the closing
|
|
# </documents> — harmless for the E2E sentinel assertions.)
|
|
# Phase 37: the HIGH prompt now ends with the <tools> section
|
|
# after </documents>, so the echo targets the <documents> block
|
|
# itself — the sentinel semantics are unchanged.
|
|
block = _DOCUMENTS_BLOCK_RE.search(_system(body))
|
|
tail_source = block.group(0) if block else _context(body)
|
|
answer = (
|
|
f"…and the very end of my notes reads: “{tail_source[-160:]}” "
|
|
"(Deterministic mock answer for E2E.)"
|
|
)
|
|
else:
|
|
ctx = _context(body)
|
|
snippet = ctx[:220].replace("\n", " ").strip()
|
|
answer = (
|
|
f"Great question — you've absolutely got this! Here's what my notes say about "
|
|
f"“{user.strip()[:80]}”: {snippet}… That's the gist from the docs; happy to "
|
|
"dig into any of it. (Deterministic mock answer for E2E.)"
|
|
)
|
|
# Steering (phase 15): when the system prompt carries <tuning>, the
|
|
# answer ends with the first note — deterministically observable.
|
|
note = first_tuning_note(system)
|
|
if note:
|
|
answer = f"{answer} (tuning: {note})"
|
|
# KB overview (phase 31): when the system prompt carries
|
|
# <knowledge_base>, the answer ends with the first outline bullet —
|
|
# mirrors the steering echo exactly (appended after it, so the kb
|
|
# suffix is the last thing rendered).
|
|
bullet = first_kb_bullet(system)
|
|
if bullet:
|
|
answer = f"{answer} (kb: {bullet})"
|
|
return answer
|
|
|
|
|
|
def compose_thinking(body: dict[str, Any]) -> str:
|
|
"""Deterministic reasoning scratchpad (thinking-display story, phase 17;
|
|
lengthened in phase 21).
|
|
|
|
A fixed "Step 1… Step 4" template interleaved with a "Scratch" deep-dive
|
|
block, quoting the first ~60 chars of the user question: unique per
|
|
question, byte-stable across runs, ~2 700 chars total (≈ 230 frames at
|
|
the mock's 12-char/0.02s pacing). The length is deliberate (phase 21,
|
|
thinking-no-scroll story): rendered in the 320px ``.thinking-text``
|
|
window it overflows by ~2x, so the live-tail clip and the no-user-scroll
|
|
contract are observable in E2E. The ``Step 2: Check my notes`` line
|
|
fragment (phase 17) and the ``nothing is invented`` tail (phase 20's
|
|
THINKING_TAIL) are what the E2E assertions key off — both are preserved.
|
|
"""
|
|
q = _user(body).strip()[:60]
|
|
return (
|
|
f"Step 1: Read the question carefully — “{q}” — and figure out what kind of "
|
|
"answer it wants (a how-to, a lookup, or a design decision) before touching "
|
|
"the docs, so I don't over- or under-answer.\n"
|
|
"Step 2: Check my notes for the closest match. The homelab kubernetes file "
|
|
"is the obvious candidate, but I should also consider whether a deployments "
|
|
"note covers the same ground better.\n"
|
|
"Scratch 1: the kubernetes file is organized by component — control plane, "
|
|
"worker nodes, ingress, storage — so I can map each part of the question to "
|
|
"a section instead of summarizing the whole file at once, and keep the "
|
|
"answer anchored to the structure the notes actually use.\n"
|
|
"Scratch 2: I should check whether the deployments note duplicates any of "
|
|
"that ground; if it does, I will prefer the homelab file because the "
|
|
"question is phrased around the cluster itself, and I will say which file "
|
|
"each fact came from so the citation is honest.\n"
|
|
"Scratch 3: versions and ports are the facts most likely to be stale in my "
|
|
"memory — the etcd backup schedule, the ingress controller port, the "
|
|
"registry mirror address — so I will re-read those lines verbatim before "
|
|
"writing a single one of them into the answer.\n"
|
|
"Scratch 4: if the answer needs a sequence, for example how a node joins the "
|
|
"cluster or how the load balancer fronts the control plane, I will keep the "
|
|
"order exactly as the notes write it rather than re-deriving it from general "
|
|
"kubernetes knowledge that may not match this setup.\n"
|
|
"Scratch 5: anything I cannot find in the notes — a host I do not recognize, "
|
|
"a version I am not sure about, a schedule I cannot place — gets left out of "
|
|
"the answer instead of guessed, because the honesty rule beats a longer "
|
|
"answer every single time.\n"
|
|
"Scratch 6: one more pass over the question wording to make sure I am "
|
|
"answering the cluster setup, not some other homelab topic that shares the "
|
|
"same vocabulary, and I will stay on the specific the question asked about.\n"
|
|
"Scratch 7: I will also verify that the file describes the current setup — "
|
|
"if the notes mention a migration from an older cluster, I should answer "
|
|
"from the post-migration section and not mix in the old host names or the "
|
|
"old port numbers that no longer apply.\n"
|
|
"Scratch 8: final shape check before I commit — short paragraphs, a few "
|
|
"bullets at most, the document path cited where the fact came from, and no "
|
|
"invented facts anywhere in the draft.\n"
|
|
"Step 3: Re-read the relevant sections top to bottom so every specific — "
|
|
"hosts, versions, ports, schedules — is exact as written rather than "
|
|
"remembered, and note which document each fact comes from.\n"
|
|
"Step 4: Draft the answer around those specifics, keep it tight with short "
|
|
"paragraphs and bullets where it helps, cite the documents by path, and "
|
|
"double-check that nothing is invented."
|
|
)
|
|
|
|
|
|
@app.post("/__shutdown__")
|
|
def shutdown() -> dict[str, Any]:
|
|
"""Test hook (loading-feedback story): terminate this mock process to
|
|
simulate an LLM outage. The E2E fixture restores a fresh instance on
|
|
the same port afterwards, so the rest of the session keeps working."""
|
|
|
|
def _die() -> None:
|
|
time.sleep(0.1) # let the HTTP response flush before we exit
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
|
|
threading.Thread(target=_die, daemon=True).start()
|
|
return {"status": "shutting down"}
|
|
|
|
|
|
@app.get("/v1/models")
|
|
def models() -> dict[str, Any]:
|
|
return {
|
|
"object": "list",
|
|
"data": [
|
|
{"id": "turbo", "object": "model"},
|
|
{"id": "embed", "object": "model"},
|
|
{"id": "lite", "object": "model"},
|
|
],
|
|
}
|
|
|
|
|
|
@app.post("/v1/embeddings")
|
|
def embeddings(body: dict[str, Any]) -> dict[str, Any]:
|
|
raw = body.get("input")
|
|
if isinstance(raw, str):
|
|
raw = [raw]
|
|
inputs: list[Any] = list(raw) if isinstance(raw, list) else []
|
|
data = [
|
|
{"object": "embedding", "index": i, "embedding": embed_text(t)}
|
|
for i, t in enumerate(inputs)
|
|
]
|
|
return {
|
|
"object": "list",
|
|
"data": data,
|
|
"model": body.get("model", "embed"),
|
|
"usage": {"prompt_tokens": 8, "total_tokens": 8},
|
|
}
|
|
|
|
|
|
def _sse_stream(
|
|
answer: str, delay: float, thinking: str = "", pre_content_delay: float = 0.0
|
|
) -> Any:
|
|
"""SSE frames for one chat completion (phase 17: + reasoning).
|
|
|
|
When ``thinking`` is non-empty its 12-char slices go out FIRST as
|
|
``delta.reasoning_content`` frames — same 0.02s cadence and envelope
|
|
as the content frames, the aipi wire convention (reasoning before
|
|
content). Without ``thinking`` the output is byte-identical to the
|
|
content-only stream, so the other story suites are unaffected.
|
|
|
|
``pre_content_delay`` (phase 20) inserts a silence gap between the end
|
|
of the thinking stream and the first content frame — the client stays
|
|
in its pre-token "thinking" state the whole time (0.02s cadence and
|
|
frame shapes are unchanged, so 0.0 is byte-identical to before).
|
|
"""
|
|
model = "turbo"
|
|
chunk_id = f"chatcmpl-{uuid.uuid4()}"
|
|
if delay:
|
|
time.sleep(delay)
|
|
for piece in re.findall(r".{1,12}", thinking, re.S):
|
|
payload = {
|
|
"id": chunk_id,
|
|
"object": "chat.completion.chunk",
|
|
"created": int(time.time()),
|
|
"model": model,
|
|
"choices": [
|
|
{"index": 0, "delta": {"reasoning_content": piece}, "finish_reason": None}
|
|
],
|
|
}
|
|
yield f"data: {json_dumps(payload)}\n\n"
|
|
time.sleep(0.02)
|
|
if pre_content_delay:
|
|
time.sleep(pre_content_delay)
|
|
for piece in re.findall(r".{1,12}", answer, re.S):
|
|
payload = {
|
|
"id": chunk_id,
|
|
"object": "chat.completion.chunk",
|
|
"created": int(time.time()),
|
|
"model": model,
|
|
"choices": [{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
|
|
}
|
|
yield f"data: {json_dumps(payload)}\n\n"
|
|
time.sleep(0.02)
|
|
yield (
|
|
"data: "
|
|
+ json_dumps(
|
|
{
|
|
"id": chunk_id,
|
|
"object": "chat.completion.chunk",
|
|
"created": int(time.time()),
|
|
"model": model,
|
|
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
|
}
|
|
)
|
|
+ "\n\n"
|
|
)
|
|
yield "data: [DONE]\n\n"
|
|
|
|
|
|
def json_dumps(obj: dict[str, Any]) -> str:
|
|
import json
|
|
|
|
return json.dumps(obj)
|
|
|
|
|
|
def _apply_max_tokens(answer: str, max_tokens: Any) -> str:
|
|
"""Deterministic stand-in for the endpoint's output cap: one token ≈
|
|
one whitespace-separated word. Answers within the cap pass through
|
|
byte-identical, so existing (short) answers are unaffected."""
|
|
if not isinstance(max_tokens, int) or max_tokens <= 0:
|
|
return answer
|
|
words = answer.split()
|
|
if len(words) <= max_tokens:
|
|
return answer
|
|
return " ".join(words[:max_tokens])
|
|
|
|
|
|
def _tool_call_stream(name: str, arguments: dict[str, Any], call_id: str) -> Any:
|
|
"""SSE frames for one tool-call-only chat completion (phase 37).
|
|
|
|
The OpenAI wire convention the app accumulates (``app/rag/llm.py``):
|
|
the first partial of index 0 carries ``id`` + ``type`` +
|
|
``function.name`` plus the first ``function.arguments`` fragment;
|
|
the remaining fragments (deterministic 16-char split — so the
|
|
multi-fragment accumulation path is exercised) arrive on later
|
|
chunks; the final chunk carries ``finish_reason: "tool_calls"``.
|
|
No ``content`` / ``reasoning_content`` frames — the turn asked for a
|
|
tool instead of answering.
|
|
|
|
Pacing: 0.1 s per frame — deliberately SLOWER than the content
|
|
stream's 0.02 s, so the UI's transient "calling tool" state (held
|
|
from the first ``tool`` frame until the first answer ``delta``) is a
|
|
comfortable observation window for the story E2E (~1 s across the
|
|
two tool requests).
|
|
"""
|
|
model = "turbo"
|
|
chunk_id = f"chatcmpl-{uuid.uuid4()}"
|
|
raw_args = json_dumps(arguments) if arguments else "{}"
|
|
frags = [raw_args[i : i + 16] for i in range(0, len(raw_args), 16)] or ["{}"]
|
|
for i, frag in enumerate(frags):
|
|
tc: dict[str, Any] = {"index": 0, "function": {"arguments": frag}}
|
|
delta: dict[str, Any] = {"tool_calls": [tc]}
|
|
if i == 0:
|
|
tc = {
|
|
"index": 0,
|
|
"id": call_id,
|
|
"type": "function",
|
|
"function": {"name": name, "arguments": frag},
|
|
}
|
|
delta = {"role": "assistant", "tool_calls": [tc]}
|
|
payload = {
|
|
"id": chunk_id,
|
|
"object": "chat.completion.chunk",
|
|
"created": int(time.time()),
|
|
"model": model,
|
|
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
|
|
}
|
|
yield f"data: {json_dumps(payload)}\n\n"
|
|
time.sleep(0.1)
|
|
yield (
|
|
"data: "
|
|
+ json_dumps(
|
|
{
|
|
"id": chunk_id,
|
|
"object": "chat.completion.chunk",
|
|
"created": int(time.time()),
|
|
"model": model,
|
|
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
|
|
}
|
|
)
|
|
+ "\n\n"
|
|
)
|
|
yield "data: [DONE]\n\n"
|
|
|
|
|
|
@app.post("/v1/chat/completions")
|
|
def chat_completions(body: dict[str, Any]) -> Any:
|
|
user_lower = _user(body).lower()
|
|
# Phase 37 (agent document tools): the deterministic marker flow.
|
|
# The app's chat path is the only streaming consumer of this mock, so
|
|
# the flow handles streaming requests; a non-streaming marker request
|
|
# (never issued by the app) falls through to the regular answer.
|
|
if body.get("stream"):
|
|
flow = _tool_flow(body)
|
|
if flow is not None:
|
|
if flow[0] == "list":
|
|
stream = _tool_call_stream("list_documents", {}, "call_0")
|
|
elif flow[0] == "read":
|
|
# flow[3] is the synthetic call id — "call_1" for the
|
|
# single-read flow and the multi-read first read,
|
|
# "call_2" for the multi-read second read (phase 45,
|
|
# task 02).
|
|
stream = _tool_call_stream(
|
|
"read_document",
|
|
{"source": flow[1], "path": flow[2]},
|
|
flow[3],
|
|
)
|
|
elif flow[0] == "multi_answer":
|
|
# Phase 45 (task 02): the multi-read forced answer —
|
|
# computed in _tool_flow, byte-stable.
|
|
stream = _sse_stream(_apply_max_tokens(flow[2], body.get("max_tokens")), 0.0)
|
|
else: # "answer" — quote the read document (first 80 chars)
|
|
answer = _apply_max_tokens(
|
|
f"Read {flow[1]}. {flow[2][:80]}", body.get("max_tokens")
|
|
)
|
|
stream = _sse_stream(answer, 0.0)
|
|
return StreamingResponse(
|
|
stream,
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
)
|
|
|
|
answer = _apply_max_tokens(compose_answer(body), body.get("max_tokens"))
|
|
delay = 3.0 if "pretend to think slowly" in _user(body) else 0.0
|
|
thinking = compose_thinking(body) if THINKING_TRIGGER in user_lower else ""
|
|
pre_content = (
|
|
PRE_CONTENT_PAUSE_S if SLOW_PRETOKEN_TRIGGER in user_lower else 0.0
|
|
)
|
|
|
|
if not body.get("stream"):
|
|
message: dict[str, Any] = {"role": "assistant", "content": answer}
|
|
if thinking:
|
|
# Harmless future-proofing: the app only uses streaming, but a
|
|
# non-streaming client that reads the field gets the reasoning.
|
|
message["reasoning_content"] = thinking
|
|
return {
|
|
"id": f"chatcmpl-{uuid.uuid4()}",
|
|
"object": "chat.completion",
|
|
"created": int(time.time()),
|
|
"model": body.get("model", "turbo"),
|
|
"choices": [
|
|
{"index": 0, "message": message, "finish_reason": "stop"}
|
|
],
|
|
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
|
|
}
|
|
|
|
return StreamingResponse(
|
|
_sse_stream(answer, delay, thinking=thinking, pre_content_delay=pre_content),
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
)
|