feat(agent): strip raw tool-scaffolding from streamed answers — deterministic filter with one bounded recovery
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
All criteria verified green; no defects found, no fixes needed.
|
||||||
|
|
||||||
|
## Phase 70 — Harness-Aligned Tools: verification report
|
||||||
|
|
||||||
|
**Verified (final pass — all 5 tasks already complete in `complete/`):**
|
||||||
|
- `AGENT_TOOLS` = exactly `ls`(path?) / `read`(path) / `grep`(pattern, path?); `rg "list_documents|read_document|search_documents" app/` → no matches
|
||||||
|
- `read` splits combined `source/path` at first slash, full content (A7); `grep` locked A5 (fixed substring, case-insens., 20×200, locator-only); `ls` prints `source: X | path: Y | title: Z`
|
||||||
|
- SSE `tool` frames: new names; `argument` = the single string passed (`grep`→pattern, else path) or null
|
||||||
|
- Kill switch (`MAX_ROUNDS=0` → one `tools=None` request) and LOW/deflected byte-identity pinned by unit tests and green
|
||||||
|
- `TOOLS_SECTION` rewritten for the new surface; frontend `read`/`grep`/`ls` render (old names still render for persisted chats); README §"Agent document tools (ls + read + grep)" updated
|
||||||
|
|
||||||
|
**Gates (exact commands):**
|
||||||
|
- `uv run pytest --cov=app` → exit 0, all pass; TOTAL coverage **99%** (>90%)
|
||||||
|
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors"
|
||||||
|
- `uv run pytest tests/e2e/test_harness_aligned_tools.py -v --no-cov` → **3 passed** (ls→read, grep→read, wire-argument rule)
|
||||||
|
- Isolated regressions: `test_agent_document_tools` **4 passed**, `test_agent_unlimited_tools` **4 passed**, `test_search_tool` **3 passed**, `test_chat_rag` **3 passed**
|
||||||
|
- Commit `801639e` exists with the exact Commit-block message; phase dir in `.agent/phases/complete/`
|
||||||
|
|
||||||
|
**Deviations:** none — no code changes required this pass.
|
||||||
|
**Next pending phase:** `71_scaffolding_guardrails` (in `todo/`).
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 36%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 57%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 67%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 77%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 93%]
|
||||||
|
........................................................................ [ 98%]
|
||||||
|
................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
-----------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 22 0 100%
|
||||||
|
app/api/chat.py 149 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 93 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 8 0 100%
|
||||||
|
app/api/sync.py 101 0 100%
|
||||||
|
app/config.py 126 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 20 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 52 0 100%
|
||||||
|
app/models.py 86 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 139 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 213 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 65 0 100%
|
||||||
|
app/rag/retriever.py 94 3 97%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 205 0 100%
|
||||||
|
-----------------------------------------------
|
||||||
|
TOTAL 2722 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
Done — the empty `.err` is a harness runtime artifact (tracked-report convention, same as prior phases); working tree is clean.
|
||||||
|
|
||||||
|
**Phase 70, task 05 complete**
|
||||||
|
|
||||||
|
- `tests/e2e/mock_llm.py`: deterministic flows now emit `ls` (no args, `call_0`), `read` with the **combined** `source/path` (mock joins the two catalog fields — only parse change), `grep` with `pattern`; docstrings + phase-70 notes updated
|
||||||
|
- Existing E2E suites updated to new frames: `test_agent_document_tools.py` (`ls`→`read`), `test_agent_unlimited_tools.py` (multi-read `ls`+2×`read`), `test_search_tool.py` (`grep` + pattern argument)
|
||||||
|
- NEW `tests/e2e/test_harness_aligned_tools.py` (3 tests): READ flow (ls line → 📄 Reading line → quoted answer → done.sources incl. read doc → no raw `<|…|>`/`tool_call` markup in DOM), SEARCH flow (🔎 Searching for sentinel → answer), wire argument rule across both flows (ls→null, read→combined path, grep→pattern; names ⊆ {ls, read, grep})
|
||||||
|
- README agent-tools section rewritten for `ls`/`read(path)`/`grep(pattern, path?)` (combined identity + A5 locator semantics); `.env.example` header comment refreshed
|
||||||
|
- Old-name cleanup so the rg criterion holds: `app/api/docs.py` route fn → `list_indexed_documents`, `app/rag/llm.py` docstrings, `test_llm_client.py`, `test_llm_stream_teardown.py`, `test_sse_events.py`, `test_mock_tool_flow.py`, `test_api.py` docstring. Remaining matches: only the explicitly-marked legacy frontend branches/pins + the marked legacy-chat persistence pin + the mandated "old names absent" regression pins from tasks 01–02
|
||||||
|
|
||||||
|
**Gates (all green):**
|
||||||
|
- `uv run pytest` → exit 0; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%)
|
||||||
|
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
|
||||||
|
- E2E in isolation: `test_harness_aligned_tools` 3 passed, `test_agent_document_tools` 4, `test_agent_unlimited_tools` 4, `test_search_tool` 3, `test_chat_rag` 3
|
||||||
|
|
||||||
|
**Decisions:** mock `_tool_flow` tuple kept `(source, path)`; the join happens at the wire-emission point (keeps `test_mock_tool_flow.py` pins valid). `.env.example` included in the commit for atomicity (not in the block's path list, but task 05 touched it). Phase-dir move left to the harness per executor rules.
|
||||||
|
|
||||||
|
**Commit:** `801639e feat(agent): align the document tools with the harness-trained shape — ls, read(path), grep(pattern, path?)` (55 files, `--no-gpg-sign`)
|
||||||
|
|
||||||
|
**Next pending:** `71_scaffolding_guardrails/01_filter.md`
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
........................................................................ [ 5%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 36%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 57%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 67%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 77%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 93%]
|
||||||
|
........................................................................ [ 98%]
|
||||||
|
................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
-----------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 22 0 100%
|
||||||
|
app/api/chat.py 149 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 93 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 8 0 100%
|
||||||
|
app/api/sync.py 101 0 100%
|
||||||
|
app/config.py 126 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 20 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 52 0 100%
|
||||||
|
app/models.py 86 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 139 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 213 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 65 0 100%
|
||||||
|
app/rag/retriever.py 94 3 97%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 205 0 100%
|
||||||
|
-----------------------------------------------
|
||||||
|
TOTAL 2722 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Task complete — all criteria green.
|
||||||
|
|
||||||
|
**Implemented**
|
||||||
|
- `app/rag/scaffolding.py`: pure module (`import re` only) with `SCAFFOLD_PATTERNS` (observed span + 2 standalone siblings, non-greedy span) and `ScaffoldingFilter` — streaming state machine: `feed()` strips leftmost-complete matches repeatedly, holds the live tail (open span or longest proper-prefix suffix of the 3 opening literals), `flush()` emits the tail as-is (pinned EOF choice), `stripped_chars` read-only property.
|
||||||
|
- `tests/unit/test_scaffolding_filter.py`: 61-test matrix — span at every start-token split offset (0..19) + mid-span/end-token splits, multi-span, standalone tokens (alone/embedded/char-by-char), look-alikes verbatim, partial markers at EOF, exact surrounding-text preservation.
|
||||||
|
|
||||||
|
**Results**
|
||||||
|
- `uv run pytest tests/unit/test_scaffolding_filter.py -v --no-cov` → 61 passed
|
||||||
|
- `rg "import (os|sys|logging|app)" app/rag/scaffolding.py` → no matches
|
||||||
|
- `uv run pytest --cov=app` → 1448 passed; `app/rag/scaffolding.py` 100%, TOTAL 99% (>90%)
|
||||||
|
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
|
||||||
|
|
||||||
|
**Decisions** — open-span defined as "start with no end token *after* it" (prevents scaffolding leak when a lone end token precedes a start); repeated leftmost-strip loop pins spec behavior where removing one match can expose another. No deviations.
|
||||||
|
|
||||||
|
**Next pending task:** `02_llm_integration.md` (wire filter into `chat_stream`/`chat_stream_retried`).
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 44%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 89%]
|
||||||
|
........................................................................ [ 94%]
|
||||||
|
........................................................................ [ 99%]
|
||||||
|
........ [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
-----------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 22 0 100%
|
||||||
|
app/api/chat.py 149 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 93 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 8 0 100%
|
||||||
|
app/api/sync.py 101 0 100%
|
||||||
|
app/config.py 126 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 20 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 52 0 100%
|
||||||
|
app/models.py 86 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 139 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 213 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 65 0 100%
|
||||||
|
app/rag/retriever.py 94 3 97%
|
||||||
|
app/rag/scaffolding.py 50 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 205 0 100%
|
||||||
|
-----------------------------------------------
|
||||||
|
TOTAL 2772 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
Task 02 complete. All gates green.
|
||||||
|
|
||||||
|
**Implemented**
|
||||||
|
- `app/rag/llm.py`: `chat_stream(..., scaffolding: ScaffoldingFilter | None = None)` — content deltas fed through the caller-owned filter (empty clean result yields nothing), thinking never filtered, `None` keeps the byte-identical raw path; `flush()` tail is yielded as a content piece **before** tool-call materialization; phase-48 teardown untouched. `chat_stream_retried(..., scaffolding=None)` forwards the same filter to every attempt (safe by construction — a restart only happens pre-piece, i.e. filter never fed).
|
||||||
|
- Tool materialization unified to a single post-loop emission point (the old in-loop `finish_reason="tool_calls"` early emission was removed) so the flushed tail always precedes `ToolCallPiece`s; identical wire behavior for all real/fixture stream shapes.
|
||||||
|
- Tests: 7 new `chat_stream` filter tests + 2 retry pass-through tests in `tests/unit/test_llm_client.py` (span mid-stream, split across chunks, pure-scaffolding → zero pieces, thinking raw, `None` raw pin, flush-before-tools order, abandon-with-filter teardown, filter forwarded to every attempt, unfed-filter reuse).
|
||||||
|
- Added the `scaffolding` kwarg to the five other `chat_stream` fakes (test_agent, test_chat_gate, test_agent_tools, test_chat_api) — required interface maintenance for the retry primitive to forward it.
|
||||||
|
|
||||||
|
**Results**
|
||||||
|
- `uv run pytest tests/unit/test_llm_client.py tests/unit/test_llm_stream_teardown.py -v --no-cov` → 60 passed
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → 1457 passed; `app/rag/llm.py` 100%, TOTAL 99% (>90%)
|
||||||
|
- `uv run ruff check . && uv run pyright` → clean
|
||||||
|
- E2E regressions in isolation: `test_harness_aligned_tools` (3), `test_agent_document_tools` (4), `test_chat_rag` (3) → all passed
|
||||||
|
|
||||||
|
**Decisions:** single post-loop tool-materialization point (simpler; pins the content-before-tools order at both former emission points); no commit — the phase commit belongs to task 05.
|
||||||
|
|
||||||
|
**Next pending task:** `.agent/phases/todo/71_scaffolding_guardrails/03_recovery_policy.md`
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 44%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 93%]
|
||||||
|
........................................................................ [ 98%]
|
||||||
|
................. [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
-----------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 22 0 100%
|
||||||
|
app/api/chat.py 149 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 93 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 8 0 100%
|
||||||
|
app/api/sync.py 101 0 100%
|
||||||
|
app/config.py 126 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 20 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 52 0 100%
|
||||||
|
app/models.py 86 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 139 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 216 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 65 0 100%
|
||||||
|
app/rag/retriever.py 94 3 97%
|
||||||
|
app/rag/scaffolding.py 50 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 205 0 100%
|
||||||
|
-----------------------------------------------
|
||||||
|
TOTAL 2775 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
All green. Task 03 is complete.
|
||||||
|
|
||||||
|
**Implemented**
|
||||||
|
- `app/rag/agent.py`: `CORRECTION_INSTRUCTION` (verbatim constant), `MalformedReplyError(LLMError)`, `AgentHolder.scaffold_stripped`; `run_agent` now runs every request (rounds, forced final, recovery) through a fresh `ScaffoldingFilter`, logs one capture warning per stripped span (200-char truncation), and on a scaffolding-only no-calls round runs exactly one `tools=None` recovery with the correction folded into the single system message; a second empty reply raises `MalformedReplyError`
|
||||||
|
- `app/api/chat.py`: deflected path uses a turn filter + extracted `_pump` piece loop, one bounded recovery when the filter wiped the reply, `MalformedReplyError` caught before the generic `LLMError` (dedicated error copy, no `done`/`query_log`), log line ends `…retries=N scaffold_stripped=N`
|
||||||
|
- `app/rag/scaffolding.py`: `stripped_spans` (allowed by task); tests: 10 new unit (grounded matrix) + 3 new integration (deflected matrix), fakes now feed the filter like the real client
|
||||||
|
|
||||||
|
**Results**
|
||||||
|
- `uv run pytest tests/unit/test_agent.py tests/integration/test_chat_api.py -v --no-cov` → 94 passed
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → green, TOTAL **99%** (>90%; all touched files 100%)
|
||||||
|
- `uv run ruff check . && uv run pyright` → clean
|
||||||
|
- E2E regressions in isolation: `test_harness_aligned_tools.py` (3 passed), `test_chat_rag.py` + `test_agent_document_tools.py` (7 passed)
|
||||||
|
|
||||||
|
**Decisions**
|
||||||
|
- Task's message formula conflicted with the locked "single system message" decision — implemented the locked one (replace original system msg with folded correction, history intact), pinned by unit tests
|
||||||
|
- Forced-final request is filtered too; scaffolding-only final settles `MalformedReplyError` (no recovery after it — at most one per turn)
|
||||||
|
|
||||||
|
**Next pending task:** `.agent/phases/todo/71_scaffolding_guardrails/04_deflect_prompt.md`
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 44%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 93%]
|
||||||
|
........................................................................ [ 97%]
|
||||||
|
.............................. [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
-----------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 22 0 100%
|
||||||
|
app/api/chat.py 176 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 93 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 8 0 100%
|
||||||
|
app/api/sync.py 101 0 100%
|
||||||
|
app/config.py 126 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 20 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 52 0 100%
|
||||||
|
app/models.py 86 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 181 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 216 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 65 0 100%
|
||||||
|
app/rag/retriever.py 94 3 97%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 205 0 100%
|
||||||
|
-----------------------------------------------
|
||||||
|
TOTAL 2849 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
All done. Final report:
|
||||||
|
|
||||||
|
**Task 04 complete — deflection plain-text line (prevention)**
|
||||||
|
|
||||||
|
- `app/rag/prompts.py`: appended "Reply in plain text only — you have no tools in this mode." as its own line to the `DEFLECT_MODE` body (LOW prompt's only change); module + function docstrings carry the phase-71 note (owner-permitted 2026-09-03; marker-keying contract unchanged); stale "LOW byte-identical" claims in the `<tools>` paragraph/comment corrected.
|
||||||
|
- `tests/unit/test_prompts.py`: diff/reconstruction pin (LOW = pre-phase text + exactly the one line, `count == 1`, title list untouched), line-present + marker-present pin, no-leak-into-HIGH pin (all 4 HIGH build variants); both empty-steering/overview byte-identity pins updated to the new LOW baseline.
|
||||||
|
- `tests/unit/test_agent.py`: LOW byte pin updated to include the line. `tests/unit/test_chat_gate.py`: line pinned present in the deflected `plan_turn` prompt and absent from the HIGH prompt.
|
||||||
|
|
||||||
|
**Results**
|
||||||
|
- `uv run pytest tests/unit/test_prompts.py tests/unit/test_chat_gate.py -v --no-cov` → 61 passed
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → 1473 passed, TOTAL 99% (`app/rag/prompts.py` 100%)
|
||||||
|
- `uv run ruff check . && uv run pyright` → clean (0 errors)
|
||||||
|
- `uv run pytest tests/e2e/test_honest_deflection.py -v --no-cov` → 3 passed (mock marker-keying intact)
|
||||||
|
|
||||||
|
**Decisions**: the line goes on its own line (the completion criterion's "pre-phase text + exactly one new line" diff pin), not appended to the existing sentence. No commit — commits belong to task 05 (phase 70 + 71/01–03 work is still uncommitted in-tree).
|
||||||
|
|
||||||
|
**Next pending task**: `.agent/phases/todo/71_scaffolding_guardrails/05_e2e_commit.md`
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
........................................................................ [ 97%]
|
||||||
|
................................. [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
-----------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 22 0 100%
|
||||||
|
app/api/chat.py 176 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 93 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 8 0 100%
|
||||||
|
app/api/sync.py 101 0 100%
|
||||||
|
app/config.py 126 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 20 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 52 0 100%
|
||||||
|
app/models.py 86 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 181 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 216 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 65 0 100%
|
||||||
|
app/rag/retriever.py 94 3 97%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 205 0 100%
|
||||||
|
-----------------------------------------------
|
||||||
|
TOTAL 2849 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
+139
-5
@@ -91,6 +91,30 @@ the same budget, and the deflected answer stream goes through
|
|||||||
The per-turn log line records ``retries=N`` after ``total_ms=N`` (0
|
The per-turn log line records ``retries=N`` after ``total_ms=N`` (0
|
||||||
when nothing was retried — the field is uniform across all turn
|
when nothing was retried — the field is uniform across all turn
|
||||||
shapes).
|
shapes).
|
||||||
|
|
||||||
|
Tool-scaffolding guardrail (phase 71, deterministic only — owner
|
||||||
|
permission 2026-09-03: "deterministic guardrails only right now, forget
|
||||||
|
using a model for that"): the raw chat-template tokens
|
||||||
|
``<|tool_call_start|>…<|tool_call_end|>`` the ``lite`` model sometimes
|
||||||
|
emits as plain answer text can never reach the user. The DEFLECTED
|
||||||
|
path's request runs ``delta.content`` through a caller-owned
|
||||||
|
``ScaffoldingFilter`` (content only — thinking stays raw); when the
|
||||||
|
filter wipes the whole reply (visible content 0, ``stripped_chars > 0``)
|
||||||
|
the turn gets exactly ONE bounded recovery: the same messages with
|
||||||
|
``agent.CORRECTION_INSTRUCTION`` folded into the single system prompt,
|
||||||
|
``tools=None``, a fresh filter, the same phase-67 retry budget, streamed
|
||||||
|
through the same piece loop (extracted as the inner ``_pump`` helper).
|
||||||
|
A recovery that also comes back empty — and any grounded round where the
|
||||||
|
recovery policy in ``run_agent`` fails — settles with the dedicated
|
||||||
|
structured ``error`` frame (``MalformedReplyError`` caught before the
|
||||||
|
generic ``LLMError`` handler): no ``done``, no ``query_log`` row,
|
||||||
|
byte-for-byte the existing terminal-error shape. The per-turn log line
|
||||||
|
records ``scaffold_stripped=N`` after ``retries=N`` — the sum across the
|
||||||
|
turn's requests (grounded: the agent's rounds + forced final + any
|
||||||
|
recovery, via the holder; deflected: this turn's filters), 0 on clean
|
||||||
|
turns (the field is uniform, the phase-67 ``retries=N`` pattern); the
|
||||||
|
recovery does not bump ``retries=N`` (it is not a phase-67
|
||||||
|
endpoint-retry).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -110,7 +134,12 @@ from app.api.steering import load_steering_notes
|
|||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.db import db_available, get_db
|
from app.db import db_available, get_db
|
||||||
from app.models import Document, QueryLog
|
from app.models import Document, QueryLog
|
||||||
from app.rag.agent import AgentHolder, run_agent
|
from app.rag.agent import (
|
||||||
|
CORRECTION_INSTRUCTION, # phase 71: the harness-owned recovery line
|
||||||
|
AgentHolder,
|
||||||
|
MalformedReplyError, # phase 71: the recovery policy's terminal signal
|
||||||
|
run_agent,
|
||||||
|
)
|
||||||
from app.rag.llm import (
|
from app.rag.llm import (
|
||||||
EmbeddingError,
|
EmbeddingError,
|
||||||
LLMClient,
|
LLMClient,
|
||||||
@@ -123,6 +152,7 @@ from app.rag.llm import (
|
|||||||
from app.rag.overview import load_kb_overview
|
from app.rag.overview import load_kb_overview
|
||||||
from app.rag.prompts import build_deflect_prompt, build_high_prompt
|
from app.rag.prompts import build_deflect_prompt, build_high_prompt
|
||||||
from app.rag.retriever import RetrievedChunk, retrieve, select_documents, weak_hit_titles
|
from app.rag.retriever import RetrievedChunk, retrieve, select_documents, weak_hit_titles
|
||||||
|
from app.rag.scaffolding import ScaffoldingFilter # phase 71: the streaming filter
|
||||||
from app.rag.suggestions import derive_suggestions
|
from app.rag.suggestions import derive_suggestions
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
ChatDoneEvent,
|
ChatDoneEvent,
|
||||||
@@ -373,19 +403,27 @@ async def chat(
|
|||||||
# ``tools=None`` request anyway (the kill switch).
|
# ``tools=None`` request anyway (the kill switch).
|
||||||
holder = AgentHolder()
|
holder = AgentHolder()
|
||||||
answer_stream: AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece]
|
answer_stream: AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece]
|
||||||
|
deflected_filter: ScaffoldingFilter | None = None
|
||||||
if plan.deflected:
|
if plan.deflected:
|
||||||
# Phase 67: the deflected stream goes through the retry
|
# Phase 67: the deflected stream goes through the retry
|
||||||
# primitive — a dead endpoint is restarted (SSE ``retry``
|
# primitive — a dead endpoint is restarted (SSE ``retry``
|
||||||
# frames) only before its first piece (locked A2); the
|
# frames) only before its first piece (locked A2); the
|
||||||
# grounded path stays a plain ``run_agent`` call (task 03
|
# grounded path stays a plain ``run_agent`` call (task 03
|
||||||
# makes IT retry internally) — its ``RetryPiece``s flow
|
# makes IT retry internally) — its ``RetryPiece``s flow
|
||||||
# through the shared piece loop below.
|
# through the shared piece loop below. Phase 71: the
|
||||||
|
# request's content also runs through a caller-owned
|
||||||
|
# filter (one per request) — a scaffolding-only reply
|
||||||
|
# streams zero ``delta`` frames instead of raw tokens, and
|
||||||
|
# the filter's ``stripped_chars`` drives the recovery
|
||||||
|
# decision after the piece loop.
|
||||||
|
deflected_filter = ScaffoldingFilter()
|
||||||
answer_stream = chat_stream_retried(
|
answer_stream = chat_stream_retried(
|
||||||
llm,
|
llm,
|
||||||
messages,
|
messages,
|
||||||
tools=None,
|
tools=None,
|
||||||
retries=settings.llm_retries,
|
retries=settings.llm_retries,
|
||||||
delay=settings.llm_retry_delay,
|
delay=settings.llm_retry_delay,
|
||||||
|
scaffolding=deflected_filter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
answer_stream = run_agent(
|
answer_stream = run_agent(
|
||||||
@@ -398,8 +436,19 @@ async def chat(
|
|||||||
holder=holder,
|
holder=holder,
|
||||||
)
|
)
|
||||||
thinking_chars = 0
|
thinking_chars = 0
|
||||||
try:
|
content_chars = 0 # phase 71: the turn's visible (clean) content
|
||||||
async for piece in answer_stream: # StreamPiece | ToolCallPiece | RetryPiece
|
scaffold_stripped = 0 # phase 71: sum across the turn's requests
|
||||||
|
|
||||||
|
async def _pump(
|
||||||
|
pieces: AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece],
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""One request's piece loop (phase 71 extraction): the
|
||||||
|
thinking/tool/retry/delta handling shared by the turn's
|
||||||
|
first pass and — deflected path only — the one bounded
|
||||||
|
recovery. Behavior-preserving for the first pass (pinned
|
||||||
|
by the existing integration suite)."""
|
||||||
|
nonlocal thinking_chars, content_chars, retries_used
|
||||||
|
async for piece in pieces: # StreamPiece | ToolCallPiece | RetryPiece
|
||||||
if isinstance(piece, ToolCallPiece):
|
if isinstance(piece, ToolCallPiece):
|
||||||
# Phase 37 (PLAN §4 extension; phase 70): one SSE
|
# Phase 37 (PLAN §4 extension; phase 70): one SSE
|
||||||
# ``tool`` frame per model-requested call.
|
# ``tool`` frame per model-requested call.
|
||||||
@@ -435,7 +484,91 @@ async def chat(
|
|||||||
if settings.stream_thinking:
|
if settings.stream_thinking:
|
||||||
yield sse_event(ChatThinkingEvent(text=piece.text).model_dump())
|
yield sse_event(ChatThinkingEvent(text=piece.text).model_dump())
|
||||||
else:
|
else:
|
||||||
|
content_chars += len(piece.text)
|
||||||
yield sse_event({"type": "delta", "text": piece.text})
|
yield sse_event({"type": "delta", "text": piece.text})
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for frame in _pump(answer_stream):
|
||||||
|
yield frame
|
||||||
|
if plan.deflected and deflected_filter is not None:
|
||||||
|
scaffold_stripped = deflected_filter.stripped_chars
|
||||||
|
# Phase 71: the deflected reply's visible content was
|
||||||
|
# wiped by the filter (the scaffolding was the whole
|
||||||
|
# "answer") — the ONE bounded recovery: the same
|
||||||
|
# messages with the correction folded into the single
|
||||||
|
# system prompt, ``tools=None``, a FRESH filter, the
|
||||||
|
# same phase-67 retry budget, streamed through the
|
||||||
|
# same piece loop. A round with real visible content
|
||||||
|
# needs no recovery (the clean content stands).
|
||||||
|
if content_chars == 0 and deflected_filter.stripped_chars > 0:
|
||||||
|
logger.warning(
|
||||||
|
"chat: deflected reply was pure tool-scaffolding "
|
||||||
|
"(%d chars stripped) — running the one bounded "
|
||||||
|
"recovery",
|
||||||
|
deflected_filter.stripped_chars,
|
||||||
|
)
|
||||||
|
recovery_filter = ScaffoldingFilter()
|
||||||
|
recovery_stream = chat_stream_retried(
|
||||||
|
llm,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
plan.system_prompt + "\n"
|
||||||
|
+ CORRECTION_INSTRUCTION
|
||||||
|
),
|
||||||
|
},
|
||||||
|
*messages[1:],
|
||||||
|
],
|
||||||
|
tools=None,
|
||||||
|
retries=settings.llm_retries,
|
||||||
|
delay=settings.llm_retry_delay,
|
||||||
|
scaffolding=recovery_filter,
|
||||||
|
)
|
||||||
|
async for frame in _pump(recovery_stream):
|
||||||
|
yield frame
|
||||||
|
scaffold_stripped += recovery_filter.stripped_chars
|
||||||
|
if content_chars == 0:
|
||||||
|
# The second empty reply is terminal (at most
|
||||||
|
# one recovery per turn) — the dedicated error
|
||||||
|
# frame below (no done, no query_log row).
|
||||||
|
logger.warning(
|
||||||
|
"chat: the recovery reply was still empty "
|
||||||
|
"(scaffold_stripped=%d) — settling with a "
|
||||||
|
"malformed-reply error",
|
||||||
|
scaffold_stripped,
|
||||||
|
)
|
||||||
|
raise MalformedReplyError(
|
||||||
|
"the deflected model answered in raw "
|
||||||
|
"tool-scaffolding twice in a row — no "
|
||||||
|
"clean answer to stream"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Grounded turns: the agent's rounds + forced final +
|
||||||
|
# any recovery already accumulated the turn total on
|
||||||
|
# the holder (the deflected fallback is 0 — the agent
|
||||||
|
# never runs, so this branch is grounded-only).
|
||||||
|
scaffold_stripped = holder.scaffold_stripped
|
||||||
|
except MalformedReplyError as e:
|
||||||
|
# Phase 71: the recovery policy's terminal signal —
|
||||||
|
# caught BEFORE the generic LLMError handler (it
|
||||||
|
# subclasses it), so the dedicated copy reaches the UI;
|
||||||
|
# the generic "dropped the connection" copy stays for
|
||||||
|
# transport failures.
|
||||||
|
logger.error(
|
||||||
|
"chat: malformed reply after the one bounded recovery "
|
||||||
|
"question=%r total_ms=%d — %s",
|
||||||
|
request.message,
|
||||||
|
int((time.monotonic() - started) * 1000),
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
settled = True # terminal: the error frame settles the turn
|
||||||
|
yield sse_event(
|
||||||
|
ChatErrorEvent(
|
||||||
|
detail="The model returned a malformed reply — please try again."
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
return
|
||||||
except LLMError as e:
|
except LLMError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"chat: LLM stream failed question=%r total_ms=%d — %s",
|
"chat: LLM stream failed question=%r total_ms=%d — %s",
|
||||||
@@ -504,7 +637,7 @@ async def chat(
|
|||||||
logger.info(
|
logger.info(
|
||||||
"question=%r embed_ms=%d top_score=%.3f fts_hits=%d summary_hits=%d tuning=%d "
|
"question=%r embed_ms=%d top_score=%.3f fts_hits=%d summary_hits=%d tuning=%d "
|
||||||
"kb_chars=%d threshold=%.2f deflected=%s sources=%r thinking_chars=%d "
|
"kb_chars=%d threshold=%.2f deflected=%s sources=%r thinking_chars=%d "
|
||||||
"tool_calls=%d total_ms=%d retries=%d",
|
"tool_calls=%d total_ms=%d retries=%d scaffold_stripped=%d",
|
||||||
request.message,
|
request.message,
|
||||||
embed_ms,
|
embed_ms,
|
||||||
plan.top_score,
|
plan.top_score,
|
||||||
@@ -519,6 +652,7 @@ async def chat(
|
|||||||
holder.tool_calls,
|
holder.tool_calls,
|
||||||
total_ms,
|
total_ms,
|
||||||
retries_used,
|
retries_used,
|
||||||
|
scaffold_stripped,
|
||||||
)
|
)
|
||||||
settled = True # terminal: the done frame settles the turn
|
settled = True # terminal: the done frame settles the turn
|
||||||
yield sse_event(
|
yield sse_event(
|
||||||
|
|||||||
+192
-2
@@ -95,6 +95,27 @@ task 04):
|
|||||||
exactly one round. With ``settings.llm_retries=0`` every request is a
|
exactly one round. With ``settings.llm_retries=0`` every request is a
|
||||||
single plain attempt (the pre-phase-67 path).
|
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 (:func:`list_catalog`, :func:`list_source_names`,
|
The DB accessors (:func:`list_catalog`, :func:`list_source_names`,
|
||||||
:func:`find_document`, :func:`all_documents`) and the
|
:func:`find_document`, :func:`all_documents`) and the
|
||||||
:func:`grep_document` line matcher are module-level functions so unit
|
:func:`grep_document` line matcher are module-level functions so unit
|
||||||
@@ -116,11 +137,13 @@ from app.models import Document
|
|||||||
from app.rag.git_sources import effective_sources
|
from app.rag.git_sources import effective_sources
|
||||||
from app.rag.llm import (
|
from app.rag.llm import (
|
||||||
LLMClient,
|
LLMClient,
|
||||||
|
LLMError,
|
||||||
RetryPiece,
|
RetryPiece,
|
||||||
StreamPiece,
|
StreamPiece,
|
||||||
ToolCallPiece,
|
ToolCallPiece,
|
||||||
chat_stream_retried,
|
chat_stream_retried,
|
||||||
)
|
)
|
||||||
|
from app.rag.scaffolding import ScaffoldingFilter
|
||||||
from app.rag.source_removal import resolve_source_name
|
from app.rag.source_removal import resolve_source_name
|
||||||
|
|
||||||
logger = logging.getLogger("app.agent")
|
logger = logging.getLogger("app.agent")
|
||||||
@@ -228,6 +251,33 @@ UNKNOWN_TOOL = "Unknown tool."
|
|||||||
MISSING_READ_ARGS = "read requires a string argument 'path'."
|
MISSING_READ_ARGS = "read requires a string argument 'path'."
|
||||||
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
|
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
|
||||||
|
|
||||||
|
#: 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
|
#: 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.
|
#: (across documents, in catalog order) and a per-match-line char limit.
|
||||||
SEARCH_MAX_MATCHES = 20
|
SEARCH_MAX_MATCHES = 20
|
||||||
@@ -352,10 +402,16 @@ class AgentHolder:
|
|||||||
rejected calls (unknown tool, unknown/missing arguments or document,
|
rejected calls (unknown tool, unknown/missing arguments or document,
|
||||||
already-in-context) do not count. Drives the per-turn log line's
|
already-in-context) do not count. Drives the per-turn log line's
|
||||||
``tool_calls=N`` field (task 04).
|
``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_docs: list[Document] = field(default_factory=list)
|
read_docs: list[Document] = field(default_factory=list)
|
||||||
tool_calls: int = 0
|
tool_calls: int = 0
|
||||||
|
scaffold_stripped: int = 0
|
||||||
|
|
||||||
|
|
||||||
def _execute_tool(
|
def _execute_tool(
|
||||||
@@ -476,6 +532,24 @@ async def run_agent(
|
|||||||
``settings.llm_retry_delay``); a round that already streamed pieces
|
``settings.llm_retry_delay``); a round that already streamed pieces
|
||||||
fails the turn as before.
|
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 documents the retrieval already put in context
|
``seed_docs`` are the documents the retrieval already put in context
|
||||||
(they shape the *system_prompt* the caller built); re-reading one of
|
(they shape the *system_prompt* the caller built); re-reading one of
|
||||||
them is rejected as "Already in your context." — the rejection counts
|
them is rejected as "Already in your context." — the rejection counts
|
||||||
@@ -494,6 +568,12 @@ async def run_agent(
|
|||||||
rounds = 0
|
rounds = 0
|
||||||
while True:
|
while True:
|
||||||
calls: list[ToolCallPiece] = []
|
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
|
# Phase 48: bind the round's stream so a consumer abandon
|
||||||
# (GeneratorExit into the yield below) tears down the in-flight
|
# (GeneratorExit into the yield below) tears down the in-flight
|
||||||
# model stream deterministically — not GC-dependent. Phase 67:
|
# model stream deterministically — not GC-dependent. Phase 67:
|
||||||
@@ -511,16 +591,97 @@ async def run_agent(
|
|||||||
tools=tools,
|
tools=tools,
|
||||||
retries=settings.llm_retries,
|
retries=settings.llm_retries,
|
||||||
delay=settings.llm_retry_delay,
|
delay=settings.llm_retry_delay,
|
||||||
|
scaffolding=round_filter,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async for piece in stream:
|
async for piece in stream:
|
||||||
if isinstance(piece, ToolCallPiece):
|
if isinstance(piece, ToolCallPiece):
|
||||||
calls.append(piece)
|
calls.append(piece)
|
||||||
|
elif isinstance(piece, StreamPiece) and piece.kind == "content":
|
||||||
|
round_content += len(piece.text)
|
||||||
yield piece
|
yield piece
|
||||||
finally:
|
finally:
|
||||||
await stream.aclose()
|
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 not calls:
|
||||||
return # the answer was streamed
|
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,
|
||||||
|
cast("list[dict[str, str]]", 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
|
call = calls[0] # a stream can carry several calls; run the first
|
||||||
result = _execute_tool(db, call, seed_docs, holder)
|
result = _execute_tool(db, call, seed_docs, holder)
|
||||||
rounds += 1 # every call the model emits consumes a round
|
rounds += 1 # every call the model emits consumes a round
|
||||||
@@ -558,17 +719,46 @@ async def run_agent(
|
|||||||
# teardown as the loop rounds (consumer abandon mid-final
|
# teardown as the loop rounds (consumer abandon mid-final
|
||||||
# answer must still close the model's stream). Phase 67: the
|
# answer must still close the model's stream). Phase 67: the
|
||||||
# forced call retries under the same locked-A2 rule as the
|
# forced call retries under the same locked-A2 rule as the
|
||||||
# loop rounds.
|
# 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(
|
final = chat_stream_retried(
|
||||||
llm,
|
llm,
|
||||||
cast("list[dict[str, str]]", messages),
|
cast("list[dict[str, str]]", messages),
|
||||||
tools=None,
|
tools=None,
|
||||||
retries=settings.llm_retries,
|
retries=settings.llm_retries,
|
||||||
delay=settings.llm_retry_delay,
|
delay=settings.llm_retry_delay,
|
||||||
|
scaffolding=final_filter,
|
||||||
)
|
)
|
||||||
|
final_content = 0
|
||||||
try:
|
try:
|
||||||
async for piece in final:
|
async for piece in final:
|
||||||
|
if isinstance(piece, StreamPiece) and piece.kind == "content":
|
||||||
|
final_content += len(piece.text)
|
||||||
yield piece
|
yield piece
|
||||||
finally:
|
finally:
|
||||||
await final.aclose()
|
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
|
return
|
||||||
|
|||||||
+48
-16
@@ -23,13 +23,18 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Literal, cast
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||||
|
|
||||||
from openai import AsyncOpenAI, AsyncStream
|
from openai import AsyncOpenAI, AsyncStream
|
||||||
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
|
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
|
||||||
|
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
# Phase 71: the filter type is only needed for typing (the module
|
||||||
|
# stays import-graph-clean; callers pass their own instances).
|
||||||
|
from app.rag.scaffolding import ScaffoldingFilter
|
||||||
|
|
||||||
logger = logging.getLogger("app.llm")
|
logger = logging.getLogger("app.llm")
|
||||||
|
|
||||||
|
|
||||||
@@ -338,6 +343,7 @@ class LLMClient:
|
|||||||
self,
|
self,
|
||||||
messages: list[dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
scaffolding: ScaffoldingFilter | None = None,
|
||||||
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
|
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
|
||||||
"""Stream assistant pieces from the chat model (PLAN A5/A15, phase 17).
|
"""Stream assistant pieces from the chat model (PLAN A5/A15, phase 17).
|
||||||
|
|
||||||
@@ -365,13 +371,26 @@ class LLMClient:
|
|||||||
``id`` and ``function.name`` on the first partial and
|
``id`` and ``function.name`` on the first partial and
|
||||||
``function.arguments`` in fragments — which are accumulated into
|
``function.arguments`` in fragments — which are accumulated into
|
||||||
one :class:`ToolCallPiece` per call, yielded in index order at
|
one :class:`ToolCallPiece` per call, yielded in index order at
|
||||||
stream end (or immediately once a chunk carries
|
stream end (after the stream's chunks are exhausted — aipi ends
|
||||||
``finish_reason="tool_calls"``). Malformed ``arguments`` JSON
|
the stream at ``finish_reason="tool_calls"``, so this is the
|
||||||
raises :class:`LLMError`. Wire convention verified live against
|
wire's emission point). Malformed ``arguments`` JSON raises
|
||||||
|
:class:`LLMError`. Wire convention verified live against
|
||||||
aipi's ``turbo`` on 2026-08-26 via
|
aipi's ``turbo`` on 2026-08-26 via
|
||||||
``uv run python -m scripts.llm_probe --tools`` (phase 37, task 01:
|
``uv run python -m scripts.llm_probe --tools`` (phase 37, task 01:
|
||||||
``probe: turbo tool_calls=supported 2026-08-26``).
|
``probe: turbo tool_calls=supported 2026-08-26``).
|
||||||
|
|
||||||
|
Scaffolding guardrail (phase 71): the caller may pass a
|
||||||
|
``ScaffoldingFilter`` — one per request, caller-owned (this
|
||||||
|
method never creates or resets one). When present, only
|
||||||
|
**content** is filtered: ``delta.content`` is fed through the
|
||||||
|
filter and only the clean text is yielded (an empty clean
|
||||||
|
result yields **no** piece — no empty ``delta`` frames); thinking
|
||||||
|
pieces are never filtered (the scratchpad stays raw, phase 17).
|
||||||
|
At stream end the filter's held tail is flushed to a content
|
||||||
|
piece **before** any tool-call materialization (content-
|
||||||
|
before-tools wire convention). ``None`` (the default) keeps
|
||||||
|
today's byte-identical raw path for callers that opt out.
|
||||||
|
|
||||||
Any failure (network, HTTP, malformed stream) surfaces as
|
Any failure (network, HTTP, malformed stream) surfaces as
|
||||||
:class:`LLMError` so the API layer can turn it into an SSE
|
:class:`LLMError` so the API layer can turn it into an SSE
|
||||||
``error`` event instead of a hung request.
|
``error`` event instead of a hung request.
|
||||||
@@ -405,7 +424,6 @@ class LLMClient:
|
|||||||
await self._client.chat.completions.create(**kwargs),
|
await self._client.chat.completions.create(**kwargs),
|
||||||
)
|
)
|
||||||
calls: dict[int, _ToolCallSlot] = {}
|
calls: dict[int, _ToolCallSlot] = {}
|
||||||
emitted = False
|
|
||||||
async for chunk in stream:
|
async for chunk in stream:
|
||||||
if not chunk.choices:
|
if not chunk.choices:
|
||||||
continue
|
continue
|
||||||
@@ -436,16 +454,22 @@ class LLMClient:
|
|||||||
yield StreamPiece("thinking", reasoning)
|
yield StreamPiece("thinking", reasoning)
|
||||||
content = delta.content
|
content = delta.content
|
||||||
if content:
|
if content:
|
||||||
yield StreamPiece("content", content)
|
if scaffolding is not None:
|
||||||
if (
|
# Phase 71: content only — an empty clean result
|
||||||
calls
|
# yields nothing (no empty delta frames).
|
||||||
and not emitted
|
cleaned = scaffolding.feed(content)
|
||||||
and getattr(choice, "finish_reason", None) == "tool_calls"
|
if cleaned:
|
||||||
):
|
yield StreamPiece("content", cleaned)
|
||||||
for piece in _materialize_tool_calls(calls):
|
else:
|
||||||
yield piece
|
yield StreamPiece("content", content)
|
||||||
emitted = True
|
# Phase 71: flush the filter's held tail at stream end, BEFORE
|
||||||
if calls and not emitted:
|
# any tool-call materialization — flushed-tail content precedes
|
||||||
|
# ToolCallPieces (content-before-tools wire convention).
|
||||||
|
if scaffolding is not None:
|
||||||
|
tail = scaffolding.flush()
|
||||||
|
if tail:
|
||||||
|
yield StreamPiece("content", tail)
|
||||||
|
if calls:
|
||||||
for piece in _materialize_tool_calls(calls):
|
for piece in _materialize_tool_calls(calls):
|
||||||
yield piece
|
yield piece
|
||||||
except LLMError:
|
except LLMError:
|
||||||
@@ -469,6 +493,7 @@ async def chat_stream_retried(
|
|||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
retries: int = 0,
|
retries: int = 0,
|
||||||
delay: float = 0.0,
|
delay: float = 0.0,
|
||||||
|
scaffolding: ScaffoldingFilter | None = None,
|
||||||
) -> AsyncGenerator[StreamPiece | ToolCallPiece | RetryPiece, None]:
|
) -> AsyncGenerator[StreamPiece | ToolCallPiece | RetryPiece, None]:
|
||||||
"""Stream a chat turn, retrying a dead endpoint (phase 67).
|
"""Stream a chat turn, retrying a dead endpoint (phase 67).
|
||||||
|
|
||||||
@@ -493,6 +518,13 @@ async def chat_stream_retried(
|
|||||||
The request is restarted byte-identical: ``chat_stream`` is stateless,
|
The request is restarted byte-identical: ``chat_stream`` is stateless,
|
||||||
so every attempt is opened with the SAME *messages*/*tools*.
|
so every attempt is opened with the SAME *messages*/*tools*.
|
||||||
|
|
||||||
|
Scaffolding guardrail (phase 71): *scaffolding* is passed through to
|
||||||
|
every attempt's ``chat_stream``. The SAME caller-owned filter object
|
||||||
|
across the retry attempts of one logical request is safe by
|
||||||
|
construction: a restarted attempt only happens while no piece was
|
||||||
|
emitted, i.e. the filter was never fed (its pending buffer is still
|
||||||
|
empty).
|
||||||
|
|
||||||
Teardown (phase 48, extended): every attempt's stream is explicitly
|
Teardown (phase 48, extended): every attempt's stream is explicitly
|
||||||
closed in a ``finally`` — normal exhaustion, a terminal
|
closed in a ``finally`` — normal exhaustion, a terminal
|
||||||
:class:`LLMError`, and a consumer abandon (``GeneratorExit`` mid-attempt
|
:class:`LLMError`, and a consumer abandon (``GeneratorExit`` mid-attempt
|
||||||
@@ -502,7 +534,7 @@ async def chat_stream_retried(
|
|||||||
max_attempts = retries + 1
|
max_attempts = retries + 1
|
||||||
for attempt in range(1, max_attempts + 1):
|
for attempt in range(1, max_attempts + 1):
|
||||||
emitted = False
|
emitted = False
|
||||||
stream = llm.chat_stream(messages, tools=tools)
|
stream = llm.chat_stream(messages, tools=tools, scaffolding=scaffolding)
|
||||||
try:
|
try:
|
||||||
async for piece in stream:
|
async for piece in stream:
|
||||||
emitted = True
|
emitted = True
|
||||||
|
|||||||
+25
-5
@@ -30,7 +30,19 @@ Agent tools (phase 37; phase 70: the copy teaches the harness-aligned
|
|||||||
may extend its context through the three server-side tools (round-
|
may extend its context through the three server-side tools (round-
|
||||||
capped, see :mod:`app.rag.agent`; the cap is the bound and this section
|
capped, see :mod:`app.rag.agent`; the cap is the bound and this section
|
||||||
does not re-state it, phase 45). The LOW/deflection prompt never
|
does not re-state it, phase 45). The LOW/deflection prompt never
|
||||||
carries it and stays byte-identical to the pre-phase text.
|
carries it (phase 71: the LOW prompt's only addition is the
|
||||||
|
plain-text line below — it still has no ``<tools>`` section).
|
||||||
|
|
||||||
|
Deflection plain-text line (phase 71, owner-permitted 2026-09-03):
|
||||||
|
the otherwise-locked ``LOW`` prompt gains exactly one instruction
|
||||||
|
line — "Reply in plain text only — you have no tools in this mode."
|
||||||
|
— appended to the ``DEFLECT_MODE`` body: a deflected turn offers no
|
||||||
|
tools, so any tool markup there is always wrong, and the line closes
|
||||||
|
the door at the prompt (the deterministic filter + one bounded
|
||||||
|
recovery in :mod:`app.rag.scaffolding` / :mod:`app.rag.agent` is the
|
||||||
|
backstop). The ``DEFLECT_MODE`` marker and everything else in the
|
||||||
|
prompt stay put — the E2E mock LLM keys on the marker's *presence*,
|
||||||
|
not the wording, so that contract is unchanged.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -83,9 +95,10 @@ _KB_INTRO = (
|
|||||||
#: not re-state it, phase 45). Appended after the mode body
|
#: not re-state it, phase 45). Appended after the mode body
|
||||||
#: (``<documents>``), so the instructions are the last thing the model
|
#: (``<documents>``), so the instructions are the last thing the model
|
||||||
#: reads. The LOW/deflection prompt never carries it — a deflection has
|
#: reads. The LOW/deflection prompt never carries it — a deflection has
|
||||||
#: no grounded context to extend — and stays byte-identical to the
|
#: no grounded context to extend (phase 71: the LOW prompt's only
|
||||||
#: pre-phase text. The E2E mock keys off the ``<tools>`` marker's
|
#: addition is the plain-text line in :func:`build_deflect_prompt`).
|
||||||
#: *presence*, not this wording.
|
#: The E2E mock keys off the ``<tools>`` marker's *presence*, not this
|
||||||
|
#: wording.
|
||||||
TOOLS_SECTION: str = (
|
TOOLS_SECTION: str = (
|
||||||
"<tools>\n"
|
"<tools>\n"
|
||||||
"You may extend your context with three tools. `ls` lists the "
|
"You may extend your context with three tools. `ls` lists the "
|
||||||
@@ -224,7 +237,10 @@ def build_deflect_prompt(
|
|||||||
|
|
||||||
Section order (phase 31): ``<relevance>`` → ``<knowledge_base>`` →
|
Section order (phase 31): ``<relevance>`` → ``<knowledge_base>`` →
|
||||||
``<tuning>`` → ``DEFLECT_MODE`` body; empty steering/overview omit
|
``<tuning>`` → ``DEFLECT_MODE`` body; empty steering/overview omit
|
||||||
their section, keeping the prompt byte-identical to the pre-phase text.
|
their section, keeping the prompt byte-identical to the pre-phase
|
||||||
|
text. The body ends with the phase-71 plain-text line (owner-
|
||||||
|
permitted 2026-09-03 — the LOW prompt's only change): a deflected
|
||||||
|
turn offers no tools, so any tool markup there is always wrong.
|
||||||
"""
|
"""
|
||||||
weak = "\n".join(f"- {t}" for t in titles) if titles else "(nothing close at all)"
|
weak = "\n".join(f"- {t}" for t in titles) if titles else "(nothing close at all)"
|
||||||
mid = "\n".join(
|
mid = "\n".join(
|
||||||
@@ -239,5 +255,9 @@ def build_deflect_prompt(
|
|||||||
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
||||||
"your notes come to the question. They are titles only; do not pretend "
|
"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"
|
"they answer it. Use them to propose 2-3 alternative questions.\n"
|
||||||
|
# Phase 71 (owner-permitted 2026-09-03): the one plain-text line
|
||||||
|
# — prevention at the prompt. The E2E mock keys on the
|
||||||
|
# DEFLECT_MODE marker's presence, so appending is safe.
|
||||||
|
"Reply in plain text only — you have no tools in this mode.\n"
|
||||||
+ weak
|
+ weak
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Deterministic tool-scaffolding guardrail — pattern registry + streaming
|
||||||
|
filter (phase 71, task 01).
|
||||||
|
|
||||||
|
The ``lite`` chat model occasionally emits its own chat-template
|
||||||
|
tool-scaffolding as plain answer text (incident 2026-09-03: a deflected
|
||||||
|
round streamed ``<|tool_call_start|>[read(path='…')]<|tool_call_end|>``
|
||||||
|
into the UI although no tools were offered). This module is the
|
||||||
|
detection half of the guardrail: a fixed registry of *observed*
|
||||||
|
scaffolding forms and a streaming state machine that strips them from
|
||||||
|
``delta.content`` as it flows. Pure Python — no I/O, no logging, no
|
||||||
|
model: the strip *warning* log and the one bounded recovery are emitted
|
||||||
|
by the integration layer (phase 71, tasks 02–03), so this module stays
|
||||||
|
unit-testable in isolation.
|
||||||
|
|
||||||
|
Content only: thinking pieces are the model's raw reasoning by design
|
||||||
|
(phase 17) and are never filtered — the guardrail protects the answer,
|
||||||
|
not the scratchpad.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
__all__ = ["SCAFFOLD_PATTERNS", "ScaffoldingFilter"]
|
||||||
|
|
||||||
|
#: The known scaffolding forms. The registry is the extension point: a
|
||||||
|
#: new entry needs an observed capture (the strip warning log, task 03)
|
||||||
|
#: + a unit fixture in ``tests/unit/test_scaffolding_filter.py`` — no
|
||||||
|
#: speculative entries. Every entry here traces to the 2026-09-03
|
||||||
|
#: incident (the span form is the deflected round's raw text; the
|
||||||
|
#: standalone siblings are from the same tokenizer family).
|
||||||
|
SCAFFOLD_PATTERNS: tuple[re.Pattern, ...] = (
|
||||||
|
# The observed span (incident 2026-09-03) — non-greedy, so multiple
|
||||||
|
# spans in one buffer each strip to their own end token.
|
||||||
|
re.compile(r"<\|tool_call_start\|>[\s\S]*?<\|tool_call_end\|>"),
|
||||||
|
# Standalone sibling token (same tokenizer family).
|
||||||
|
re.compile(r"<\|tool_calls\|>"),
|
||||||
|
# Standalone sibling token (same tokenizer family).
|
||||||
|
re.compile(r"<\|tool_call\|>"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_SPAN_START = "<|tool_call_start|>"
|
||||||
|
_SPAN_END = "<|tool_call_end|>"
|
||||||
|
#: The literal openings a live (possibly incomplete) match can begin with.
|
||||||
|
_OPENINGS: tuple[str, ...] = (_SPAN_START, "<|tool_calls|>", "<|tool_call|>")
|
||||||
|
#: Boundedness bound: in NORMAL state the held tail is at most one char
|
||||||
|
#: short of the longest opening (an open span is unbounded, but it is
|
||||||
|
#: being *stripped*, never emitted).
|
||||||
|
_MAX_OPENING = max(len(opening) for opening in _OPENINGS)
|
||||||
|
|
||||||
|
|
||||||
|
def _leftmost_match(buf: str) -> re.Match | None:
|
||||||
|
"""The leftmost complete match among :data:`SCAFFOLD_PATTERNS`, or
|
||||||
|
None when the buffer holds no complete scaffolding form.
|
||||||
|
|
||||||
|
(Two patterns cannot match at the same start position — their
|
||||||
|
literals diverge after ``<|tool_call`` — so the leftmost start is
|
||||||
|
unambiguous.)
|
||||||
|
"""
|
||||||
|
best: re.Match | None = None
|
||||||
|
for pattern in SCAFFOLD_PATTERNS:
|
||||||
|
match = pattern.search(buf)
|
||||||
|
if match is not None and (best is None or match.start() < best.start()):
|
||||||
|
best = match
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _hold_index(buf: str) -> int:
|
||||||
|
"""Index where the live hold-tail begins; everything from it stays
|
||||||
|
pending (called only after the strip loop found no complete match).
|
||||||
|
|
||||||
|
The tail is the leftmost of two candidates: an **open span** (a
|
||||||
|
start token with no end token after it — the span may continue in
|
||||||
|
future chunks, so everything from that start token onward is held)
|
||||||
|
or a **live prefix** (the longest suffix that is a proper prefix of
|
||||||
|
any opening literal — the token may complete in future chunks).
|
||||||
|
Everything before the leftmost candidate is safe to emit: no
|
||||||
|
complete match remains, and no match can grow from earlier text,
|
||||||
|
since a complete opening earlier in the buffer would already have
|
||||||
|
matched (span) or be a candidate in its own right (standalone).
|
||||||
|
"""
|
||||||
|
if not buf:
|
||||||
|
return 0
|
||||||
|
hold = len(buf)
|
||||||
|
start = buf.find(_SPAN_START)
|
||||||
|
if start != -1 and buf.find(_SPAN_END, start + 1) == -1:
|
||||||
|
hold = start
|
||||||
|
for k in range(min(len(buf), _MAX_OPENING - 1), 0, -1):
|
||||||
|
suffix = buf[-k:]
|
||||||
|
if any(k < len(opening) and opening.startswith(suffix) for opening in _OPENINGS):
|
||||||
|
hold = min(hold, len(buf) - k)
|
||||||
|
break
|
||||||
|
return hold
|
||||||
|
|
||||||
|
|
||||||
|
class ScaffoldingFilter:
|
||||||
|
"""Streaming state machine over a model **content** stream (phase 71).
|
||||||
|
|
||||||
|
One instance per model request (callers create fresh instances —
|
||||||
|
the phase-67 retry-after-dead-attempt case is safe: a dead attempt
|
||||||
|
never fed the filter). Feed the raw ``delta.content`` chunks; each
|
||||||
|
:meth:`feed` returns the clean text safe to emit *now*; :meth:`flush`
|
||||||
|
emits the tail at stream end.
|
||||||
|
|
||||||
|
``feed`` appends the chunk to the internal pending buffer, then
|
||||||
|
(a) repeatedly takes the leftmost complete match among
|
||||||
|
:data:`SCAFFOLD_PATTERNS` — drops it (counting it into
|
||||||
|
:attr:`stripped_chars`) and continues — until none remains; then
|
||||||
|
(b) checks the buffer tail for a **live prefix** via
|
||||||
|
:func:`_hold_index`: the longest suffix that is a proper prefix of
|
||||||
|
any opening literal, or an **open span** (a start token with no end
|
||||||
|
token yet — everything from that start token onward is held, since
|
||||||
|
the span may continue in future chunks). Everything before the held
|
||||||
|
tail is emitted; the held tail becomes the new pending state.
|
||||||
|
Bounded: the held tail in NORMAL state is ≤ the longest opening
|
||||||
|
token minus one char; in an open span it is unbounded but is being
|
||||||
|
*stripped*, never emitted.
|
||||||
|
|
||||||
|
``flush`` (end of stream) emits the pending tail **as-is**: a
|
||||||
|
partial marker at EOF is content, not scaffolding — a documented,
|
||||||
|
pinned choice (a stream that ends mid-``<|tool_call_st`` must not
|
||||||
|
be silently eaten, and a lone ``<|tool_call_end|>`` without a start
|
||||||
|
is prose the user sent or the model produced outside a span).
|
||||||
|
|
||||||
|
The stripped spans themselves are exposed read-only
|
||||||
|
(:attr:`stripped_spans`) so the integration layer can log one
|
||||||
|
warning per strip event (phase 71 task 03) — that log line is how
|
||||||
|
a *new* scaffolding format gets captured and added to the registry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._pending = ""
|
||||||
|
self._stripped_chars = 0
|
||||||
|
self._stripped_spans: list[str] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stripped_chars(self) -> int:
|
||||||
|
"""Total characters removed so far (read by the caller after the
|
||||||
|
round/turn — the strip warning log, phase 71 task 03)."""
|
||||||
|
return self._stripped_chars
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stripped_spans(self) -> list[str]:
|
||||||
|
"""The raw spans removed so far, in stream order (one entry per
|
||||||
|
strip event — the integration layer's per-span warning log
|
||||||
|
truncates each to 200 chars). Read-only: callers must not
|
||||||
|
mutate the filter's state."""
|
||||||
|
return list(self._stripped_spans)
|
||||||
|
|
||||||
|
def feed(self, chunk: str) -> str:
|
||||||
|
"""Append *chunk* to the pending buffer; return the clean text
|
||||||
|
safe to emit now (possibly ``""`` — e.g. while an open span or a
|
||||||
|
partial marker is still held). An empty chunk is a no-op."""
|
||||||
|
if not chunk:
|
||||||
|
return ""
|
||||||
|
buf = self._strip_complete(self._pending + chunk)
|
||||||
|
hold = _hold_index(buf)
|
||||||
|
self._pending = buf[hold:]
|
||||||
|
return buf[:hold]
|
||||||
|
|
||||||
|
def flush(self) -> str:
|
||||||
|
"""End of stream: emit the pending tail as-is and reset it."""
|
||||||
|
tail = self._pending
|
||||||
|
self._pending = ""
|
||||||
|
return tail
|
||||||
|
|
||||||
|
def _strip_complete(self, buf: str) -> str:
|
||||||
|
"""Drop leftmost-complete matches until none remains (step a)."""
|
||||||
|
while (match := _leftmost_match(buf)) is not None:
|
||||||
|
self._stripped_chars += match.end() - match.start()
|
||||||
|
self._stripped_spans.append(match.group(0))
|
||||||
|
buf = buf[: match.start()] + buf[match.end():]
|
||||||
|
return buf
|
||||||
@@ -127,6 +127,35 @@ Implements just enough of the aipi surface:
|
|||||||
specific phrase — same convention as ``think in paragraphs``); no
|
specific phrase — same convention as ``think in paragraphs``); no
|
||||||
existing E2E question or fixture file contains the trigger, so
|
existing E2E question or fixture file contains the trigger, so
|
||||||
every other suite is unaffected.
|
every other suite is unaffected.
|
||||||
|
- user message containing ``emit raw tool markup``
|
||||||
|
(``SCAFFOLD_TRIGGER``, phase 71, tool-scaffolding guardrails — the
|
||||||
|
2026-09-03 incident where a deflected round streamed the model's
|
||||||
|
raw ``<|tool_call_start|>…<|tool_call_end|>`` markup into the UI)
|
||||||
|
**or** ``always emit raw tool markup``
|
||||||
|
(``SCAFFOLD_ALWAYS_TRIGGER``, checked FIRST — it contains the
|
||||||
|
former phrase) -> the deterministic SCAFFOLDING flow, independent
|
||||||
|
of the ``<tools>`` marker (both grounded and deflected turns hit
|
||||||
|
it):
|
||||||
|
* ``SCAFFOLD_ALWAYS_TRIGGER``: EVERY request (the one bounded
|
||||||
|
recovery included) streams ONLY ``delta.content`` chunks
|
||||||
|
carrying the incident span ``SCAFFOLD_SPAN`` —
|
||||||
|
``<|tool_call_start|>[read(path='search_docs/reese-notes.md')]
|
||||||
|
<|tool_call_end|>`` — split across the mock's 12-char chunks
|
||||||
|
(the filter's boundary path), ``finish_reason: "stop"``, no
|
||||||
|
structured ``tool_calls``, no reasoning — the terminal
|
||||||
|
malformed-reply path.
|
||||||
|
* ``SCAFFOLD_TRIGGER``: request 1 (no ``CORRECTION_INSTRUCTION``
|
||||||
|
in the system prompt) streams the same scaffolding-only span;
|
||||||
|
request 2 (the system prompt carries the harness constant — a
|
||||||
|
stable substring of ``app.rag.agent.CORRECTION_INSTRUCTION``,
|
||||||
|
IMPORTED into this module so the mock can never drift from
|
||||||
|
it: the one bounded recovery, ``tools=None`` with the
|
||||||
|
correction folded into the single system prompt) streams the
|
||||||
|
clean ``SCAFFOLD_RECOVERY_ANSWER`` — the recovery path.
|
||||||
|
Checked BEFORE the ``SEARCH_TRIGGER`` / ``TOOLS_TRIGGER`` flows
|
||||||
|
(the trigger needs no ``<tools>`` section); no existing E2E
|
||||||
|
question or fixture file contains the phrase, so every other
|
||||||
|
suite is unaffected.
|
||||||
- user message containing ``show me a table`` (phase 44, markdown
|
- user message containing ``show me a table`` (phase 44, markdown
|
||||||
tables, TODO.md L6) -> the fixed table answer (``TABLE_ANSWER``):
|
tables, TODO.md L6) -> the fixed table answer (``TABLE_ANSWER``):
|
||||||
a 3-column service table, an ``<img onerror>`` XSS probe line, and
|
a 3-column service table, an ``<img onerror>`` XSS probe line, and
|
||||||
@@ -188,6 +217,8 @@ from typing import Any
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
|
from app.rag.agent import CORRECTION_INSTRUCTION # phase 71: the harness constant
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
DIM = 768
|
DIM = 768
|
||||||
@@ -364,6 +395,51 @@ ALWAYS_FAIL_TRIGGER = "always fail"
|
|||||||
#: bag-of-words vector — the endpoint's pre-stream embedding retry loop.
|
#: bag-of-words vector — the endpoint's pre-stream embedding retry loop.
|
||||||
EMBED_FAIL_TRIGGER = "embed fail once"
|
EMBED_FAIL_TRIGGER = "embed fail once"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 71 (tool-scaffolding guardrails, 2026-09-03 incident):
|
||||||
|
# deterministic raw-markup flows — see the module docstring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: A user message containing this substring (case-insensitive) drives
|
||||||
|
#: the scaffolding flow: request 1 streams ONLY the incident's raw tool
|
||||||
|
#: markup as ``delta.content``; the follow-up request carrying the
|
||||||
|
#: harness correction in the system prompt (the one bounded recovery)
|
||||||
|
#: streams the clean answer. Independent of the ``<tools>`` marker —
|
||||||
|
#: both grounded and deflected turns hit it. Existing E2E questions do
|
||||||
|
#: not contain the phrase, so every other suite is unaffected.
|
||||||
|
SCAFFOLD_TRIGGER = "emit raw tool markup"
|
||||||
|
|
||||||
|
#: A user message containing this substring (checked BEFORE
|
||||||
|
#: ``SCAFFOLD_TRIGGER`` — it contains that phrase) streams the
|
||||||
|
#: scaffolding-only span on EVERY request, recovery included — the
|
||||||
|
#: terminal malformed-reply path (the dedicated error frame, no done).
|
||||||
|
SCAFFOLD_ALWAYS_TRIGGER = "always emit raw tool markup"
|
||||||
|
|
||||||
|
#: The incident span (2026-09-03): the model's chat-template tool
|
||||||
|
#: syntax, emitted as plain ``delta.content`` although no tools were
|
||||||
|
#: offered. Streamed through the mock's 12-char chunking, so it always
|
||||||
|
#: spans ≥2 wire chunks (the filter's boundary path).
|
||||||
|
SCAFFOLD_SPAN = (
|
||||||
|
"<|tool_call_start|>[read(path='search_docs/reese-notes.md')]"
|
||||||
|
"<|tool_call_end|>"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The clean answer the one bounded recovery produces (byte-stable —
|
||||||
|
#: the dedicated E2E suite asserts the recovered bubble and the wire's
|
||||||
|
#: delta text against it).
|
||||||
|
SCAFFOLD_RECOVERY_ANSWER = "Here is the plain-text answer the recovery produced."
|
||||||
|
|
||||||
|
#: The stable substring of the harness-owned correction constant the
|
||||||
|
#: recovery request carries in its system prompt. Keyed on a substring
|
||||||
|
#: (not the whole constant) so a re-wrap of the constant cannot silently
|
||||||
|
#: re-route the mock; the module-level assert below fails loudly if the
|
||||||
|
#: substring ever leaves the constant (the mock must never drift from
|
||||||
|
#: ``app.rag.agent.CORRECTION_INSTRUCTION``).
|
||||||
|
_CORRECTION_MARKER = "no tool syntax"
|
||||||
|
assert _CORRECTION_MARKER in CORRECTION_INSTRUCTION, (
|
||||||
|
"mock drift: the correction marker left CORRECTION_INSTRUCTION"
|
||||||
|
)
|
||||||
|
|
||||||
#: One DEAD app-level chat attempt costs exactly this many HTTP POSTs
|
#: One DEAD app-level chat attempt costs exactly this many HTTP POSTs
|
||||||
#: while the endpoint stays down: the openai SDK's default policy
|
#: while the endpoint stays down: the openai SDK's default policy
|
||||||
#: (max_retries=2 — the app's ``LLMClient`` keeps it) re-POSTs a 500'd
|
#: (max_retries=2 — the app's ``LLMClient`` keeps it) re-POSTs a 500'd
|
||||||
@@ -535,6 +611,35 @@ def _search_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
|||||||
return ("search",)
|
return ("search",)
|
||||||
|
|
||||||
|
|
||||||
|
def _scaffold_flow(body: dict[str, Any]) -> str | None:
|
||||||
|
"""Classify a phase-71 scaffolding request (see the module docstring).
|
||||||
|
|
||||||
|
* ``"scaffold"`` — stream ONLY the incident span
|
||||||
|
(``SCAFFOLD_SPAN``) as ``delta.content`` chunks: ``finish_reason:
|
||||||
|
"stop"``, no structured ``tool_calls``, no reasoning. EVERY
|
||||||
|
request for ``SCAFFOLD_ALWAYS_TRIGGER`` (the recovery included),
|
||||||
|
and the FIRST request of ``SCAFFOLD_TRIGGER`` (no correction in
|
||||||
|
the system prompt yet).
|
||||||
|
* ``"recovery"`` — ``SCAFFOLD_TRIGGER`` whose system prompt carries
|
||||||
|
the harness correction (the one bounded recovery: ``tools=None``,
|
||||||
|
the constant folded into the single system prompt by
|
||||||
|
``app.api.chat`` / ``app.rag.agent``): stream the clean
|
||||||
|
``SCAFFOLD_RECOVERY_ANSWER``.
|
||||||
|
* ``None`` — not the scaffolding flow. The discrimination is
|
||||||
|
stateless, like the other marker flows: the trigger phrase in
|
||||||
|
the user message plus the correction's presence in the system
|
||||||
|
prompt.
|
||||||
|
"""
|
||||||
|
user = _user(body).lower()
|
||||||
|
if SCAFFOLD_ALWAYS_TRIGGER in user: # checked FIRST — it contains SCAFFOLD_TRIGGER
|
||||||
|
return "scaffold"
|
||||||
|
if SCAFFOLD_TRIGGER in user:
|
||||||
|
if _CORRECTION_MARKER in _system(body):
|
||||||
|
return "recovery"
|
||||||
|
return "scaffold"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _tool_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
def _tool_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||||
"""Classify a marker request into one step of the tool flow.
|
"""Classify a marker request into one step of the tool flow.
|
||||||
|
|
||||||
@@ -1061,6 +1166,29 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
|||||||
if _chat_dead(RETRY_TRIGGER, RETRY_DEAD_ATTEMPTS):
|
if _chat_dead(RETRY_TRIGGER, RETRY_DEAD_ATTEMPTS):
|
||||||
return _llm_500(RETRY_TRIGGER)
|
return _llm_500(RETRY_TRIGGER)
|
||||||
_fail_posts[RETRY_TRIGGER] = 0 # the answer streamed — restart
|
_fail_posts[RETRY_TRIGGER] = 0 # the answer streamed — restart
|
||||||
|
# Phase 71 (tool-scaffolding guardrails): the deterministic raw-
|
||||||
|
# markup flow — checked BEFORE the search/tool marker flows (the
|
||||||
|
# trigger is independent of the ``<tools>`` marker, so both
|
||||||
|
# grounded and deflected turns hit it; SCAFFOLD_ALWAYS_TRIGGER
|
||||||
|
# is checked first inside the classifier — the more specific
|
||||||
|
# phrase wins, same convention as THINK_PARAS_TRIGGER).
|
||||||
|
scaffold_flow = _scaffold_flow(body)
|
||||||
|
if scaffold_flow is not None:
|
||||||
|
# Request 1 (or EVERY request for the ALWAYS trigger): the
|
||||||
|
# incident span as plain delta.content, 12-char chunks
|
||||||
|
# (the span always spans ≥2 chunks — the filter's boundary
|
||||||
|
# path), finish_reason "stop", no tool_calls, no reasoning.
|
||||||
|
# Request 2 of the recovery trigger: the clean answer.
|
||||||
|
answer = (
|
||||||
|
SCAFFOLD_RECOVERY_ANSWER
|
||||||
|
if scaffold_flow == "recovery"
|
||||||
|
else SCAFFOLD_SPAN
|
||||||
|
)
|
||||||
|
return StreamingResponse(
|
||||||
|
_sse_stream(answer, 0.0),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
# Phase 68 (search tool): the deterministic search marker flow —
|
# Phase 68 (search tool): the deterministic search marker flow —
|
||||||
# checked BEFORE the phase-37 tool flow (the more specific
|
# checked BEFORE the phase-37 tool flow (the more specific
|
||||||
# trigger phrase wins, same convention as THINK_PARAS_TRIGGER).
|
# trigger phrase wins, same convention as THINK_PARAS_TRIGGER).
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"""Phase 71 E2E (Playwright, mock-only): tool-scaffolding guardrails —
|
||||||
|
the deterministic strip + one bounded recovery, through the real UI.
|
||||||
|
|
||||||
|
Owner request (chat, 2026-09-03): the same incident as phase 70 — a
|
||||||
|
deflected round streamed the model's raw
|
||||||
|
``<|tool_call_start|>[read(path='…')]<|tool_call_end|>`` chat-template
|
||||||
|
markup into the UI although no tools were offered. The guardrail is
|
||||||
|
DETERMINISTIC ONLY (no model in detection or repair): a streaming
|
||||||
|
filter strips known scaffolding from ``delta.content`` server-side
|
||||||
|
(``app/rag/scaffolding.py``), and a reply whose visible content ends
|
||||||
|
up empty gets exactly ONE bounded recovery (``tools=None``, the
|
||||||
|
harness correction folded into the system prompt); a second empty
|
||||||
|
reply settles with the dedicated error frame.
|
||||||
|
|
||||||
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_tool_scaffolding_guardrails.py -v --no-cov
|
||||||
|
|
||||||
|
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the
|
||||||
|
deterministic scaffolding flows in ``tests/e2e/mock_llm.py`` (phase
|
||||||
|
71), which are independent of the ``<tools>`` marker:
|
||||||
|
|
||||||
|
* ``emit raw tool markup`` (``SCAFFOLD_TRIGGER``): request 1 streams
|
||||||
|
ONLY the incident span as ``delta.content`` (split across the
|
||||||
|
mock's 12-char chunks — the filter's boundary path), no structured
|
||||||
|
``tool_calls``, no reasoning; the follow-up request carrying
|
||||||
|
``CORRECTION_INSTRUCTION`` in the system prompt (the one bounded
|
||||||
|
recovery) streams the clean ``SCAFFOLD_RECOVERY_ANSWER``.
|
||||||
|
* ``always emit raw tool markup`` (``SCAFFOLD_ALWAYS_TRIGGER``): the
|
||||||
|
scaffolding-only span on EVERY request (the recovery included) —
|
||||||
|
the terminal malformed-reply path.
|
||||||
|
|
||||||
|
Both triggers run on an EMPTY knowledge base: with zero chunks the
|
||||||
|
honesty gate is LOW (cosine 0.0 < the E2E 0.30 threshold, no FTS
|
||||||
|
hits), so every turn takes the DEFLECTED path — the exact path the
|
||||||
|
2026-09-03 incident hit — where the filter + recovery live in
|
||||||
|
``app/api/chat.py``.
|
||||||
|
|
||||||
|
Test → phase mapping (Playwright Mapping Rule):
|
||||||
|
1. ``test_recovery_strips_scaffolding_and_streams_clean_answer`` — the
|
||||||
|
recovery case: the turn settles (the composer re-enables, ``done``
|
||||||
|
on the wire), the final answer bubble carries the recovery's clean
|
||||||
|
text, ``document.body.innerText`` contains NEITHER
|
||||||
|
``tool_call_start`` nor ``tool_call_end`` (nor the raw
|
||||||
|
``[read(path=…`` fragment), and no error banner — and wire-level,
|
||||||
|
no ``delta`` frame ever carries a scaffolding fragment (the strip
|
||||||
|
happens server-side, not in the UI).
|
||||||
|
2. ``test_terminal_scaffolding_lands_on_dedicated_error_app_stays_usable``
|
||||||
|
— the terminal case: the existing error state renders with the
|
||||||
|
dedicated copy ("The model returned a malformed reply — please try
|
||||||
|
again."), no raw tokens in the DOM, no answer bubble, no ``done``
|
||||||
|
and NO ``query_log`` row (the existing terminal-error semantics) —
|
||||||
|
and the app stays usable: a follow-up plain question in the same
|
||||||
|
session gets a normal deflected answer and the banner clears.
|
||||||
|
3. ``test_plain_turn_never_recovers_and_streams_byte_clean`` — no
|
||||||
|
false positive: a plain deflected question streams its
|
||||||
|
first-request answer byte-clean (the concatenated delta text is
|
||||||
|
EXACTLY the mock's deterministic deflection answer — not the
|
||||||
|
recovery's), with no error state and no recovery request visible
|
||||||
|
(the turn settles on the first request).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import QueryLog
|
||||||
|
from tests.e2e.mock_llm import SCAFFOLD_RECOVERY_ANSWER, compose_answer
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Questions + the mock's deterministic expectations
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Carries ``SCAFFOLD_TRIGGER`` (and nothing else — the module-level
|
||||||
|
#: asserts below pin the trigger exclusions).
|
||||||
|
RECOVERY_Q = "Please emit raw tool markup in your reply to this."
|
||||||
|
|
||||||
|
#: Carries ``SCAFFOLD_ALWAYS_TRIGGER`` — the scaffolding-only span on
|
||||||
|
#: EVERY request, recovery included (the terminal path).
|
||||||
|
TERMINAL_Q = "Please always emit raw tool markup in every reply."
|
||||||
|
|
||||||
|
#: A plain question (the phase-67 deflection shape): no trigger at all.
|
||||||
|
PLAIN_Q = "How do I bake sourdough bread?"
|
||||||
|
|
||||||
|
for _q in (RECOVERY_Q, TERMINAL_Q, PLAIN_Q):
|
||||||
|
for _other in (
|
||||||
|
"read two documents",
|
||||||
|
"search your documents",
|
||||||
|
"write a long answer",
|
||||||
|
"think in paragraphs",
|
||||||
|
"think out loud",
|
||||||
|
"show the end of your notes",
|
||||||
|
"show me a table",
|
||||||
|
"fail then answer",
|
||||||
|
"always fail",
|
||||||
|
"embed fail once",
|
||||||
|
"pretend to think slowly",
|
||||||
|
"use your tools",
|
||||||
|
):
|
||||||
|
assert _other not in _q.lower(), f"{_other!r} unexpectedly in {_q!r}"
|
||||||
|
assert "emit raw tool markup" in RECOVERY_Q.lower()
|
||||||
|
assert "always emit raw tool markup" not in RECOVERY_Q.lower()
|
||||||
|
assert "always emit raw tool markup" in TERMINAL_Q.lower()
|
||||||
|
assert "emit raw tool markup" not in PLAIN_Q.lower()
|
||||||
|
|
||||||
|
#: The terminal malformed-reply copy (``app.api.chat`` — the phase-71
|
||||||
|
#: dedicated error frame; the mock's ALWAYS trigger is what makes the
|
||||||
|
#: recovery come back empty).
|
||||||
|
MALFORMED_ERROR_COPY = "The model returned a malformed reply — please try again."
|
||||||
|
|
||||||
|
#: The mock's deterministic deflection phrase (the phase-67 pin).
|
||||||
|
DEFLECT_PHRASE = r"haven't done anything like that"
|
||||||
|
|
||||||
|
#: The raw scaffolding fragments that must NEVER reach the user — the
|
||||||
|
#: span's tokens and the incident's argument fragment (``SCAFFOLD_SPAN``
|
||||||
|
#: in the mock).
|
||||||
|
RAW_TOKENS = ("tool_call_start", "tool_call_end", "[read(path=", "<|")
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_deflect_answer(question: str) -> str:
|
||||||
|
"""The mock's deterministic DEFLECT_MODE answer for *question*.
|
||||||
|
|
||||||
|
Derived from the mock itself (``compose_answer`` on a synthetic
|
||||||
|
body: the mode marker in the system prompt, the question as the
|
||||||
|
user message, no tuning/KB sections — the suite truncates
|
||||||
|
``steering_notes`` / ``kb_overview``, so the real prompt carries
|
||||||
|
none), so the byte-clean pin can never drift from the mock.
|
||||||
|
"""
|
||||||
|
return compose_answer(
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "DEFLECT_MODE marker"},
|
||||||
|
{"role": "user", "content": question},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# DB reset (EMPTY knowledge base → every turn is deterministically
|
||||||
|
# deflected: the LOW gate, the incident's path) + query_log reads
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_db_empty() -> None:
|
||||||
|
"""Truncate the KB (plus the prompt-shaping tables): an EMPTY
|
||||||
|
knowledge base, so the honesty gate is LOW for every question
|
||||||
|
(no chunks → cosine 0.0 < 0.30, fts_hits 0) — the deflected path
|
||||||
|
where the phase-71 filter + recovery live, and the prompts stay
|
||||||
|
byte-stable regardless of leftovers from other suites."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(
|
||||||
|
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _query_log_rows() -> list[QueryLog]:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
return list(db.scalars(select(QueryLog)).all())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Page hooks (the SSE capture — the house pattern from
|
||||||
|
# test_agent_document_tools.py / test_llm_retry.py)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Captures the raw SSE ``data:`` payloads of the /api/chat stream
|
||||||
|
#: (a response clone read in the background) — wire-level assertions
|
||||||
|
#: independent of the UI rendering.
|
||||||
|
SSE_HOOK = """
|
||||||
|
() => {
|
||||||
|
if (window.__sseInstalled) return;
|
||||||
|
window.__sseInstalled = true;
|
||||||
|
window.__sseFrames = [];
|
||||||
|
const origFetch = window.fetch;
|
||||||
|
window.fetch = async function (...args) {
|
||||||
|
const res = await origFetch.apply(this, args);
|
||||||
|
try {
|
||||||
|
const url = typeof args[0] === 'string' ? args[0] : args[0].url;
|
||||||
|
if (url.includes('/api/chat')) {
|
||||||
|
res.clone().text().then((bodyText) => {
|
||||||
|
for (const block of bodyText.split('\\n\\n')) {
|
||||||
|
const line = block.trim();
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
window.__sseFrames.push(line.slice(6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { /* non-clonable responses: ignored */ }
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _install_sse_hook(page: Page) -> None:
|
||||||
|
page.evaluate(SSE_HOOK)
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_frames(page: Page, terminal: str = "done") -> list[dict]:
|
||||||
|
"""One turn's SSE frames: wait for that turn's *terminal* frame
|
||||||
|
(``done`` — or ``error`` for the terminal case), then return EVERY
|
||||||
|
frame captured since the last drain. The hook's background read
|
||||||
|
appends the whole stream at once after it closes, so
|
||||||
|
clearing-and-reading is race-free per turn: the terminal frame is
|
||||||
|
observed only in the batch that carries the turn's full frame
|
||||||
|
sequence."""
|
||||||
|
deadline = time.monotonic() + 30.0
|
||||||
|
while True:
|
||||||
|
raw = page.evaluate(
|
||||||
|
"() => { const f = window.__sseFrames || []; "
|
||||||
|
"window.__sseFrames = []; return f; }"
|
||||||
|
)
|
||||||
|
parsed = [json.loads(line) for line in raw if line]
|
||||||
|
if any(f.get("type") == terminal for f in parsed):
|
||||||
|
return parsed
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
raise AssertionError(
|
||||||
|
f"SSE hook captured no `{terminal}` frame (frames so far: "
|
||||||
|
f"{len(parsed)}) — hook install failed?"
|
||||||
|
)
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_text(frames: list[dict]) -> str:
|
||||||
|
"""The turn's answer text exactly as the wire carried it."""
|
||||||
|
return "".join(f.get("text", "") for f in frames if f.get("type") == "delta")
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_raw_tokens_on_wire(frames: list[dict]) -> None:
|
||||||
|
for frame in frames:
|
||||||
|
if frame.get("type") != "delta":
|
||||||
|
continue
|
||||||
|
text = frame.get("text", "")
|
||||||
|
for raw in RAW_TOKENS:
|
||||||
|
assert raw not in text, f"raw scaffolding {raw!r} on the wire: {text!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def _submit(page: Page, question: str) -> None:
|
||||||
|
page.fill("#message-input", question)
|
||||||
|
page.click("#send-btn")
|
||||||
|
# The user bubble lands synchronously with the submit handler.
|
||||||
|
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_settled(page: Page) -> None:
|
||||||
|
"""The turn is complete: answer text in the bubble, button recovered.
|
||||||
|
|
||||||
|
Phase 48: the label assertion carries the settle wait with an
|
||||||
|
explicit timeout — the in-flight button is the enabled Stop control
|
||||||
|
(never disabled), so ``to_be_enabled`` no longer blocks until the
|
||||||
|
turn settles."""
|
||||||
|
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000)
|
||||||
|
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_raw_tokens(page: Page) -> None:
|
||||||
|
"""The user-visible contract: no raw scaffolding fragment anywhere
|
||||||
|
in the rendered page (``document.body.innerText``)."""
|
||||||
|
body = page.locator("body").inner_text()
|
||||||
|
for raw in RAW_TOKENS:
|
||||||
|
assert raw not in body, f"raw scaffolding {raw!r} leaked into the DOM"
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_error_banner(page: Page) -> None:
|
||||||
|
"""The turn settled through the normal done path — never the red
|
||||||
|
role=alert error banner (the KB-offline banner is a separate,
|
||||||
|
health-driven state the db_ready fixture keeps away)."""
|
||||||
|
banner = page.locator("#kb-banner")
|
||||||
|
expect(banner).to_be_hidden()
|
||||||
|
expect(banner).not_to_have_attribute("role", "alert")
|
||||||
|
expect(banner).not_to_have_class(re.compile(r"is-error"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 1. The recovery case: the span is stripped server-side, the ONE
|
||||||
|
# bounded recovery answers, and no raw token ever reaches the DOM
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_strips_scaffolding_and_streams_clean_answer(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
_reset_db_empty()
|
||||||
|
page.goto(app_url)
|
||||||
|
_install_sse_hook(page)
|
||||||
|
|
||||||
|
_submit(page, RECOVERY_Q)
|
||||||
|
_wait_settled(page)
|
||||||
|
|
||||||
|
# The final answer bubble carries the RECOVERY's clean text — the
|
||||||
|
# mock's deterministic recovery answer, proof the one bounded
|
||||||
|
# recovery ran (request 2, with the correction in the system
|
||||||
|
# prompt) and its answer is what the user saw.
|
||||||
|
bubble = page.locator(".msg.brain .bubble").last
|
||||||
|
expect(bubble).to_contain_text(SCAFFOLD_RECOVERY_ANSWER)
|
||||||
|
# The deflected message state is intact — the turn took the LOW
|
||||||
|
# path, the incident's path.
|
||||||
|
expect(page.locator(".msg.brain").last).to_have_class(re.compile(r"is-deflected"))
|
||||||
|
_assert_no_raw_tokens(page)
|
||||||
|
_assert_no_error_banner(page)
|
||||||
|
|
||||||
|
# Wire level: the strip happens SERVER-side — no `delta` frame ever
|
||||||
|
# carries a scaffolding fragment, and the concatenated delta text is
|
||||||
|
# EXACTLY the recovery answer (request 1's span produced zero delta
|
||||||
|
# frames). No tool frames, no retry frames, no error frame; the
|
||||||
|
# turn settled with `done` (deflected).
|
||||||
|
frames = _drain_frames(page, terminal="done")
|
||||||
|
assert [f for f in frames if f.get("type") == "delta"], (
|
||||||
|
"the recovery answer must have streamed delta frames"
|
||||||
|
)
|
||||||
|
_assert_no_raw_tokens_on_wire(frames)
|
||||||
|
assert _delta_text(frames) == SCAFFOLD_RECOVERY_ANSWER
|
||||||
|
assert not [f for f in frames if f.get("type") == "tool"]
|
||||||
|
assert not [f for f in frames if f.get("type") == "retry"]
|
||||||
|
assert not [f for f in frames if f.get("type") == "error"]
|
||||||
|
done = next(f for f in frames if f.get("type") == "done")
|
||||||
|
assert done["deflected"] is True
|
||||||
|
|
||||||
|
# The recovered turn settles durably: exactly one query_log row
|
||||||
|
# (deflected — the incident's path).
|
||||||
|
rows = _query_log_rows()
|
||||||
|
assert len(rows) == 1, rows
|
||||||
|
assert rows[0].question == RECOVERY_Q
|
||||||
|
assert rows[0].deflected is True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 2. The terminal case: scaffolding twice → the dedicated error frame,
|
||||||
|
# no done, no query_log row — and the app stays usable
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_scaffolding_lands_on_dedicated_error_app_stays_usable(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
_reset_db_empty()
|
||||||
|
page.goto(app_url)
|
||||||
|
_install_sse_hook(page)
|
||||||
|
|
||||||
|
_submit(page, TERMINAL_Q)
|
||||||
|
|
||||||
|
# BOTH requests (original + the one bounded recovery) came back
|
||||||
|
# scaffolding-only: no clean content ever streamed, so the turn
|
||||||
|
# settles with the EXISTING terminal error state — the banner
|
||||||
|
# (role=alert) with the DEDICATED malformed-reply copy — and the
|
||||||
|
# send button re-enabled (the banner path settles the state
|
||||||
|
# machine, cf. the phase-67 exhaustion test).
|
||||||
|
expect(page.locator("#kb-banner")).to_have_attribute(
|
||||||
|
"role", "alert", timeout=60_000
|
||||||
|
)
|
||||||
|
expect(page.locator("#kb-banner")).to_contain_text(MALFORMED_ERROR_COPY)
|
||||||
|
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||||
|
|
||||||
|
# No answer bubble was ever rendered (every streamed frame was
|
||||||
|
# stripped server-side) and no raw token is anywhere in the DOM.
|
||||||
|
expect(page.locator("#messages > .msg.brain")).to_have_count(0)
|
||||||
|
_assert_no_raw_tokens(page)
|
||||||
|
|
||||||
|
# Wire: the terminal error frame is LAST — no done, and NO delta
|
||||||
|
# frame at all (both requests' content was pure scaffolding).
|
||||||
|
frames = _drain_frames(page, terminal="error")
|
||||||
|
assert frames[-1]["type"] == "error"
|
||||||
|
assert MALFORMED_ERROR_COPY in frames[-1]["detail"]
|
||||||
|
assert not [f for f in frames if f.get("type") == "done"]
|
||||||
|
assert not [f for f in frames if f.get("type") == "delta"]
|
||||||
|
|
||||||
|
# Terminal semantics (byte-for-byte the existing LLMError shape):
|
||||||
|
# the turn writes no query_log row.
|
||||||
|
assert _query_log_rows() == []
|
||||||
|
|
||||||
|
# The app stays usable: a follow-up plain question (no trigger) in
|
||||||
|
# the SAME session gets a normal streamed deflected answer, the
|
||||||
|
# banner clears, and the wire is clean.
|
||||||
|
_submit(page, PLAIN_Q)
|
||||||
|
_wait_settled(page)
|
||||||
|
bubble = page.locator(".msg.brain .bubble").last
|
||||||
|
expect(bubble).to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE))
|
||||||
|
_assert_no_error_banner(page)
|
||||||
|
_assert_no_raw_tokens(page)
|
||||||
|
follow_frames = _drain_frames(page, terminal="done")
|
||||||
|
assert next(f for f in follow_frames if f["type"] == "done")["deflected"] is True
|
||||||
|
assert not [f for f in follow_frames if f.get("type") == "error"]
|
||||||
|
_assert_no_raw_tokens_on_wire(follow_frames)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 3. No false positive: a plain turn streams its first-request answer
|
||||||
|
# byte-clean — no strip, no recovery request, no error state
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_turn_never_recovers_and_streams_byte_clean(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
_reset_db_empty()
|
||||||
|
page.goto(app_url)
|
||||||
|
_install_sse_hook(page)
|
||||||
|
|
||||||
|
_submit(page, PLAIN_Q)
|
||||||
|
_wait_settled(page)
|
||||||
|
|
||||||
|
# Wire level: the plain deflected answer streams byte-clean from
|
||||||
|
# the FIRST request — the concatenated delta text is EXACTLY the
|
||||||
|
# mock's deterministic deflection answer (derived from the mock
|
||||||
|
# itself above) and NOT the recovery answer (a recovery request
|
||||||
|
# would have streamed that text instead).
|
||||||
|
frames = _drain_frames(page, terminal="done")
|
||||||
|
delta_text = _delta_text(frames)
|
||||||
|
assert delta_text == _expected_deflect_answer(PLAIN_Q)
|
||||||
|
assert SCAFFOLD_RECOVERY_ANSWER not in delta_text
|
||||||
|
_assert_no_raw_tokens_on_wire(frames)
|
||||||
|
# No recovery request is visible: no error frame, no retry frame
|
||||||
|
# (the recovery is not a phase-67 retry), and the turn settled on
|
||||||
|
# the first request with `done`.
|
||||||
|
assert not [f for f in frames if f.get("type") == "error"]
|
||||||
|
assert not [f for f in frames if f.get("type") == "retry"]
|
||||||
|
done = next(f for f in frames if f.get("type") == "done")
|
||||||
|
assert done["deflected"] is True
|
||||||
|
|
||||||
|
# UI level: the deflected answer rendered, no error banner, no raw
|
||||||
|
# token in the DOM.
|
||||||
|
bubble = page.locator(".msg.brain .bubble").last
|
||||||
|
expect(bubble).to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE))
|
||||||
|
_assert_no_error_banner(page)
|
||||||
|
_assert_no_raw_tokens(page)
|
||||||
@@ -23,7 +23,7 @@ import asyncio
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncIterator, Iterator
|
from collections.abc import AsyncIterator, Iterator
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import delete, text
|
from sqlalchemy import delete, text
|
||||||
@@ -35,6 +35,9 @@ from app.rag import agent
|
|||||||
from app.rag.agent import AGENT_TOOLS, AgentHolder, run_agent
|
from app.rag.agent import AGENT_TOOLS, AgentHolder, run_agent
|
||||||
from app.rag.llm import LLMClient, RetryPiece, StreamPiece, ToolCallPiece
|
from app.rag.llm import LLMClient, RetryPiece, StreamPiece, ToolCallPiece
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.rag.scaffolding import ScaffoldingFilter
|
||||||
|
|
||||||
|
|
||||||
def _doc(db: Session, source: str, path: str, title: str, content: str) -> Document:
|
def _doc(db: Session, source: str, path: str, title: str, content: str) -> Document:
|
||||||
doc = Document(
|
doc = Document(
|
||||||
@@ -168,6 +171,7 @@ class ScriptedToolLLM:
|
|||||||
self,
|
self,
|
||||||
messages: list[dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
scaffolding: ScaffoldingFilter | None = None, # phase 71 pass-through
|
||||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||||
self.requests.append((deepcopy(messages), deepcopy(tools)))
|
self.requests.append((deepcopy(messages), deepcopy(tools)))
|
||||||
if len(self.requests) == 1:
|
if len(self.requests) == 1:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import math
|
|||||||
import re
|
import re
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -33,6 +33,9 @@ from app.rag.agent import AGENT_TOOLS
|
|||||||
from app.rag.importer import import_sources
|
from app.rag.importer import import_sources
|
||||||
from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece
|
from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.rag.scaffolding import ScaffoldingFilter
|
||||||
|
|
||||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
|
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
|
||||||
QUESTION = "How is my Kubernetes cluster set up?"
|
QUESTION = "How is my Kubernetes cluster set up?"
|
||||||
OFF_TOPIC = "How do I bake sourdough bread?"
|
OFF_TOPIC = "How do I bake sourdough bread?"
|
||||||
@@ -63,6 +66,7 @@ class FakeRagLLM:
|
|||||||
tool_script: list[list[StreamPiece | ToolCallPiece]] | None = None,
|
tool_script: list[list[StreamPiece | ToolCallPiece]] | None = None,
|
||||||
embed_fail_count: int = 0,
|
embed_fail_count: int = 0,
|
||||||
stream_fail_count: int = 0,
|
stream_fail_count: int = 0,
|
||||||
|
answer_sequence: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
||||||
self.embed_batches = 0
|
self.embed_batches = 0
|
||||||
@@ -71,6 +75,11 @@ class FakeRagLLM:
|
|||||||
self.embed_error = embed_error
|
self.embed_error = embed_error
|
||||||
self.stream_error = stream_error
|
self.stream_error = stream_error
|
||||||
self.fail_mid_stream = fail_mid_stream
|
self.fail_mid_stream = fail_mid_stream
|
||||||
|
#: Phase 71: per-request canned answers (the recovery matrix):
|
||||||
|
#: request *i* (0-based, in ``seen_messages`` order) yields
|
||||||
|
#: ``answer_sequence[i]``; once exhausted it falls back to
|
||||||
|
#: ``answer``. ``None`` keeps the single-``answer`` behavior.
|
||||||
|
self.answer_sequence = answer_sequence
|
||||||
#: Phase 67: the first N ``embed_one`` calls raise an
|
#: Phase 67: the first N ``embed_one`` calls raise an
|
||||||
#: ``EmbeddingError`` (then succeed) — a dead-then-recovered
|
#: ``EmbeddingError`` (then succeed) — a dead-then-recovered
|
||||||
#: embeddings endpoint for the retry loop.
|
#: embeddings endpoint for the retry loop.
|
||||||
@@ -117,17 +126,35 @@ class FakeRagLLM:
|
|||||||
self.question_embeds.append(text)
|
self.question_embeds.append(text)
|
||||||
return _token_vec(text)
|
return _token_vec(text)
|
||||||
|
|
||||||
|
def _answer_for_request(self) -> str:
|
||||||
|
"""The canned answer for the request that was just recorded
|
||||||
|
(phase 71 ``answer_sequence``; ``None`` → the single answer)."""
|
||||||
|
if self.answer_sequence is None:
|
||||||
|
return self.answer
|
||||||
|
index = len(self.seen_messages) - 1
|
||||||
|
if index < len(self.answer_sequence):
|
||||||
|
return self.answer_sequence[index]
|
||||||
|
return self.answer
|
||||||
|
|
||||||
async def chat_stream(
|
async def chat_stream(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
scaffolding: ScaffoldingFilter | None = None,
|
||||||
):
|
):
|
||||||
"""Typed stream (phase 17): ``thinking`` slices (same 12-char
|
"""Typed stream (phase 17): ``thinking`` slices (same 12-char
|
||||||
cadence as content) **before** the content pieces. With the
|
cadence as content) **before** the content pieces. With the
|
||||||
default ``thinking=""`` this yields content-only pieces — today's
|
default ``thinking=""`` this yields content-only pieces — today's
|
||||||
behavior, new yield type. Phase 37: *tools* is the agent loop's
|
behavior, new yield type. Phase 37: *tools* is the agent loop's
|
||||||
``tools=…`` passthrough (recorded in ``seen_tools``); a request
|
``tools=…`` passthrough (recorded in ``seen_tools``); a request
|
||||||
with tools consumes the next ``tool_script`` entry, if any."""
|
with tools consumes the next ``tool_script`` entry, if any.
|
||||||
|
Phase 71: *scaffolding* mirrors ``LLMClient.chat_stream`` — the
|
||||||
|
canned content pieces are fed through the caller's filter (an
|
||||||
|
empty clean result yields nothing) and the held tail is flushed
|
||||||
|
on normal completion, so a scaffolding-only canned answer streams
|
||||||
|
zero content pieces and leaves ``stripped_chars`` behind for the
|
||||||
|
recovery policy to key on. ``None`` (e.g. pre-phase callers) keeps
|
||||||
|
the byte-identical raw path."""
|
||||||
self.seen_messages.append(messages)
|
self.seen_messages.append(messages)
|
||||||
self.seen_tools.append(tools)
|
self.seen_tools.append(tools)
|
||||||
if self.stream_error is not None:
|
if self.stream_error is not None:
|
||||||
@@ -135,17 +162,46 @@ class FakeRagLLM:
|
|||||||
if self.stream_fail_count > 0:
|
if self.stream_fail_count > 0:
|
||||||
self.stream_fail_count -= 1
|
self.stream_fail_count -= 1
|
||||||
raise LLMError("simulated pre-piece endpoint failure")
|
raise LLMError("simulated pre-piece endpoint failure")
|
||||||
|
mid_stream_drop = False
|
||||||
|
raw: list[StreamPiece | ToolCallPiece]
|
||||||
if tools is not None and self.tool_script:
|
if tools is not None and self.tool_script:
|
||||||
for piece in self.tool_script.pop(0):
|
raw = self.tool_script.pop(0)
|
||||||
|
elif self.fail_mid_stream:
|
||||||
|
raw = [StreamPiece("content", "partial ")]
|
||||||
|
mid_stream_drop = True
|
||||||
|
else:
|
||||||
|
answer = self._answer_for_request()
|
||||||
|
raw = cast(
|
||||||
|
"list[StreamPiece | ToolCallPiece]",
|
||||||
|
[
|
||||||
|
StreamPiece("thinking", self.thinking[i : i + 12])
|
||||||
|
for i in range(0, len(self.thinking), 12)
|
||||||
|
]
|
||||||
|
+ [
|
||||||
|
StreamPiece("content", answer[i : i + 12])
|
||||||
|
for i in range(0, len(answer), 12)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if scaffolding is None:
|
||||||
|
for piece in raw:
|
||||||
yield piece
|
yield piece
|
||||||
|
if mid_stream_drop:
|
||||||
|
raise LLMError("mid-stream dropout")
|
||||||
return
|
return
|
||||||
if self.fail_mid_stream:
|
for piece in raw:
|
||||||
yield StreamPiece("content", "partial ")
|
if isinstance(piece, StreamPiece) and piece.kind == "content":
|
||||||
|
cleaned = scaffolding.feed(piece.text)
|
||||||
|
if cleaned:
|
||||||
|
yield StreamPiece("content", cleaned)
|
||||||
|
else:
|
||||||
|
yield piece
|
||||||
|
if mid_stream_drop:
|
||||||
|
# The tail is NOT flushed on a failed stream — the real
|
||||||
|
# client only flushes a cleanly completed one.
|
||||||
raise LLMError("mid-stream dropout")
|
raise LLMError("mid-stream dropout")
|
||||||
for i in range(0, len(self.thinking), 12):
|
tail = scaffolding.flush()
|
||||||
yield StreamPiece("thinking", self.thinking[i : i + 12])
|
if tail:
|
||||||
for i in range(0, len(self.answer), 12):
|
yield StreamPiece("content", tail)
|
||||||
yield StreamPiece("content", self.answer[i : i + 12])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@@ -621,6 +677,7 @@ def test_grounded_turn_streams_tool_frames_and_cites_read_doc(
|
|||||||
assert lines and "tool_calls=2" in lines[-1]
|
assert lines and "tool_calls=2" in lines[-1]
|
||||||
assert "'docs/homelab/kubernetes.md'" in lines[-1]
|
assert "'docs/homelab/kubernetes.md'" in lines[-1]
|
||||||
assert "'docs/homelab/backups.md'" in lines[-1]
|
assert "'docs/homelab/backups.md'" in lines[-1]
|
||||||
|
assert "scaffold_stripped=0" in lines[-1] # phase 71: uniform clean-turn field
|
||||||
|
|
||||||
|
|
||||||
def test_grounded_turn_streams_grep_tool_frames(
|
def test_grounded_turn_streams_grep_tool_frames(
|
||||||
@@ -853,6 +910,7 @@ def test_zero_max_rounds_reproduce_pre_phase_single_request(
|
|||||||
assert "backups.md" not in row.sources
|
assert "backups.md" not in row.sources
|
||||||
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||||
assert lines and "tool_calls=0" in lines[-1]
|
assert lines and "tool_calls=0" in lines[-1]
|
||||||
|
assert "scaffold_stripped=0" in lines[-1] # phase 71: uniform clean-turn field
|
||||||
|
|
||||||
|
|
||||||
def test_tool_execution_db_failure_yields_error_event(
|
def test_tool_execution_db_failure_yields_error_event(
|
||||||
@@ -929,6 +987,7 @@ def test_embed_failure_retries_then_turn_completes(
|
|||||||
assert frames[-1]["type"] == "done"
|
assert frames[-1]["type"] == "done"
|
||||||
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||||
assert lines and "retries=1" in lines[-1]
|
assert lines and "retries=1" in lines[-1]
|
||||||
|
assert "scaffold_stripped=0" in lines[-1] # phase 71: uniform clean-turn field
|
||||||
|
|
||||||
|
|
||||||
def test_embed_failure_exhausts_retries_then_terminal_error(
|
def test_embed_failure_exhausts_retries_then_terminal_error(
|
||||||
@@ -988,6 +1047,7 @@ def test_deflected_stream_retries_before_the_first_piece(
|
|||||||
assert flaky.seen_tools == [None, None] # …byte-identical (no tools key)
|
assert flaky.seen_tools == [None, None] # …byte-identical (no tools key)
|
||||||
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||||
assert lines and "retries=1" in lines[-1]
|
assert lines and "retries=1" in lines[-1]
|
||||||
|
assert "scaffold_stripped=0" in lines[-1] # phase 71: uniform clean-turn field
|
||||||
|
|
||||||
|
|
||||||
def test_deflected_stream_failure_after_first_frame_is_terminal(
|
def test_deflected_stream_failure_after_first_frame_is_terminal(
|
||||||
@@ -1034,3 +1094,135 @@ def test_zero_retries_keep_the_pre_phase_wire_shape(
|
|||||||
assert frames[0]["type"] == "error"
|
assert frames[0]["type"] == "error"
|
||||||
assert "embedding" in frames[0]["detail"]
|
assert "embedding" in frames[0]["detail"]
|
||||||
assert not any(f["type"] == "retry" for f in frames)
|
assert not any(f["type"] == "retry" for f in frames)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- phase 71: the deterministic scaffolding guardrail (deflected path) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _scaffold_span() -> str:
|
||||||
|
"""The raw span from the 2026-09-03 incident (the E2E mock's trigger,
|
||||||
|
task 05) — a complete span the filter strips in full."""
|
||||||
|
return "<|tool_call_start|>[read(path='/homelab/backup-notes.md')]<|tool_call_end|>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deflected_scaffolding_only_reply_recovers_once(
|
||||||
|
client,
|
||||||
|
db,
|
||||||
|
seeded_kb: FakeRagLLM,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""(a) A deflected reply that is pure scaffolding streams ZERO delta
|
||||||
|
frames (no raw tokens on the wire); the one bounded recovery —
|
||||||
|
``tools=None``, the correction folded into the single system prompt,
|
||||||
|
a fresh filter, the same retry budget — streams the clean answer, the
|
||||||
|
turn settles with ``done`` + a query_log row, and the log line counts
|
||||||
|
the stripped chars (the recovery does not bump ``retries=N``)."""
|
||||||
|
span = _scaffold_span()
|
||||||
|
clean = "I don't have that on hand — try one of the chips below?"
|
||||||
|
flaky = FakeRagLLM(answer_sequence=[span, clean])
|
||||||
|
live = get_settings()
|
||||||
|
monkeypatch.setattr(chat_api, "get_settings", lambda: _retry_settings(live))
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: flaky
|
||||||
|
try:
|
||||||
|
caplog.set_level(logging.INFO, logger="app.chat")
|
||||||
|
_, _, frames = _stream_chat(client, OFF_TOPIC)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
# The raw tokens never reach the wire; the deltas reassemble to the
|
||||||
|
# clean recovery answer.
|
||||||
|
assert span not in json.dumps(frames)
|
||||||
|
deltas = [f for f in frames if f["type"] == "delta"]
|
||||||
|
assert "".join(d["text"] for d in deltas) == clean
|
||||||
|
assert not any(f["type"] == "error" for f in frames)
|
||||||
|
done = frames[-1]
|
||||||
|
assert done["type"] == "done" and done["deflected"] is True
|
||||||
|
|
||||||
|
# Exactly two requests: the stripped round + the one recovery, both
|
||||||
|
# without a tools key…
|
||||||
|
assert len(flaky.seen_messages) == 2
|
||||||
|
assert flaky.seen_tools == [None, None]
|
||||||
|
# …and the recovery's system prompt is the ORIGINAL deflected prompt
|
||||||
|
# with the correction folded in (a single system message — the user
|
||||||
|
# message stays last).
|
||||||
|
recovered = flaky.seen_messages[1]
|
||||||
|
assert len(recovered) == 2
|
||||||
|
assert recovered[1] == {"role": "user", "content": OFF_TOPIC}
|
||||||
|
first_system = flaky.seen_messages[0][0]["content"]
|
||||||
|
assert "DEFLECT_MODE" in first_system
|
||||||
|
assert recovered[0] == {
|
||||||
|
"role": "system",
|
||||||
|
"content": first_system + "\n" + agent.CORRECTION_INSTRUCTION,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The turn settled normally: one query_log row…
|
||||||
|
(row,) = db.scalars(select(QueryLog)).all()
|
||||||
|
assert row.deflected is True
|
||||||
|
# …and the log line carries the summed stripped count (the clean
|
||||||
|
# recovery stripped nothing) with retries untouched.
|
||||||
|
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||||
|
assert lines and f"scaffold_stripped={len(span)}" in lines[-1]
|
||||||
|
assert "retries=0" in lines[-1] # the recovery is not an endpoint-retry
|
||||||
|
|
||||||
|
|
||||||
|
def test_deflected_scaffolding_twice_settles_malformed(
|
||||||
|
client, db, seeded_kb: FakeRagLLM, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""(b) The recovery answer is scaffolding again — a second empty
|
||||||
|
reply is terminal: the DEDICATED error frame (the exact copy), no
|
||||||
|
``done``, no query_log row — byte-for-byte today's ``LLMError``
|
||||||
|
terminal shape — and no third request (at most one recovery per
|
||||||
|
turn)."""
|
||||||
|
span = _scaffold_span()
|
||||||
|
dead = FakeRagLLM(answer_sequence=[span, span])
|
||||||
|
live = get_settings()
|
||||||
|
monkeypatch.setattr(chat_api, "get_settings", lambda: _retry_settings(live))
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: dead
|
||||||
|
try:
|
||||||
|
_, _, frames = _stream_chat(client, OFF_TOPIC)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert [f["type"] for f in frames] == ["error"]
|
||||||
|
assert frames[0]["detail"] == (
|
||||||
|
"The model returned a malformed reply — please try again."
|
||||||
|
)
|
||||||
|
assert set(frames[0].keys()) == {"type", "detail"} # the contract shape
|
||||||
|
assert span not in json.dumps(frames)
|
||||||
|
assert not any(f["type"] == "done" for f in frames)
|
||||||
|
assert db.scalars(select(QueryLog)).all() == []
|
||||||
|
assert len(dead.seen_messages) == 2 # round + one recovery — no more
|
||||||
|
assert dead.seen_tools == [None, None]
|
||||||
|
|
||||||
|
|
||||||
|
def test_deflected_mixed_scaffolding_and_content_needs_no_recovery(
|
||||||
|
client,
|
||||||
|
db,
|
||||||
|
seeded_kb: FakeRagLLM,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""(c) Real visible content plus scaffolding: the clean remainder
|
||||||
|
streams (no raw tokens on the wire), NO recovery runs, and the log
|
||||||
|
line counts the stripped span (``scaffold_stripped>0``)."""
|
||||||
|
span = _scaffold_span()
|
||||||
|
mixed = f"I don't have that. {span} Try the chips below?"
|
||||||
|
flaky = FakeRagLLM(answer=mixed)
|
||||||
|
live = get_settings()
|
||||||
|
monkeypatch.setattr(chat_api, "get_settings", lambda: _retry_settings(live))
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: flaky
|
||||||
|
try:
|
||||||
|
caplog.set_level(logging.INFO, logger="app.chat")
|
||||||
|
_, _, frames = _stream_chat(client, OFF_TOPIC)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert span not in json.dumps(frames)
|
||||||
|
deltas = [f for f in frames if f["type"] == "delta"]
|
||||||
|
assert "".join(d["text"] for d in deltas) == "I don't have that. Try the chips below?"
|
||||||
|
assert not any(f["type"] == "error" for f in frames)
|
||||||
|
assert frames[-1]["type"] == "done"
|
||||||
|
assert len(flaky.seen_messages) == 1 # the clean content stands — no recovery
|
||||||
|
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
||||||
|
assert lines and f"scaffold_stripped={len(span)}" in lines[-1]
|
||||||
|
|||||||
+302
-6
@@ -32,7 +32,7 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncGenerator, AsyncIterator
|
from collections.abc import AsyncGenerator, AsyncIterator
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -43,11 +43,15 @@ from app.rag import agent
|
|||||||
from app.rag.agent import (
|
from app.rag.agent import (
|
||||||
AGENT_TOOLS,
|
AGENT_TOOLS,
|
||||||
AgentHolder,
|
AgentHolder,
|
||||||
|
MalformedReplyError,
|
||||||
run_agent,
|
run_agent,
|
||||||
)
|
)
|
||||||
from app.rag.llm import LLMClient, LLMError, RetryPiece, StreamPiece, ToolCallPiece
|
from app.rag.llm import LLMClient, LLMError, RetryPiece, StreamPiece, ToolCallPiece
|
||||||
from app.rag.prompts import TOOLS_SECTION, _base, build_deflect_prompt, build_high_prompt
|
from app.rag.prompts import TOOLS_SECTION, _base, build_deflect_prompt, build_high_prompt
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.rag.scaffolding import ScaffoldingFilter
|
||||||
|
|
||||||
|
|
||||||
def _settings(**kwargs: Any) -> Settings:
|
def _settings(**kwargs: Any) -> Settings:
|
||||||
kwargs.setdefault("_env_file", None)
|
kwargs.setdefault("_env_file", None)
|
||||||
@@ -68,7 +72,13 @@ def _doc(source: str, path: str, title: str = "Title", content: str = "CONTENT")
|
|||||||
|
|
||||||
class ScriptedLLM:
|
class ScriptedLLM:
|
||||||
"""Canned stream sequences; records every ``chat_stream`` request so
|
"""Canned stream sequences; records every ``chat_stream`` request so
|
||||||
the tests can assert on the messages and the ``tools`` passthrough."""
|
the tests can assert on the messages and the ``tools`` passthrough.
|
||||||
|
Phase 71: when the caller passes a ``ScaffoldingFilter``, the canned
|
||||||
|
content pieces are fed through it exactly like
|
||||||
|
``LLMClient.chat_stream`` (an empty clean result yields nothing; the
|
||||||
|
held tail is flushed on normal completion) — so a scaffolding-only
|
||||||
|
canned round streams no content pieces and leaves ``stripped_chars``
|
||||||
|
behind for the recovery policy to key on."""
|
||||||
|
|
||||||
def __init__(self, *streams: list[StreamPiece | ToolCallPiece]) -> None:
|
def __init__(self, *streams: list[StreamPiece | ToolCallPiece]) -> None:
|
||||||
self.streams: list[list[StreamPiece | ToolCallPiece]] = list(streams)
|
self.streams: list[list[StreamPiece | ToolCallPiece]] = list(streams)
|
||||||
@@ -78,12 +88,26 @@ class ScriptedLLM:
|
|||||||
self,
|
self,
|
||||||
messages: list[dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
scaffolding: ScaffoldingFilter | None = None,
|
||||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||||
self.requests.append((deepcopy(messages), tools))
|
self.requests.append((deepcopy(messages), tools))
|
||||||
if not self.streams:
|
if not self.streams:
|
||||||
raise AssertionError("ScriptedLLM ran out of canned streams")
|
raise AssertionError("ScriptedLLM ran out of canned streams")
|
||||||
for piece in self.streams.pop(0):
|
pieces = self.streams.pop(0)
|
||||||
yield piece
|
if scaffolding is None:
|
||||||
|
for piece in pieces:
|
||||||
|
yield piece
|
||||||
|
return
|
||||||
|
for piece in pieces:
|
||||||
|
if isinstance(piece, StreamPiece) and piece.kind == "content":
|
||||||
|
cleaned = scaffolding.feed(piece.text)
|
||||||
|
if cleaned:
|
||||||
|
yield StreamPiece("content", cleaned)
|
||||||
|
else:
|
||||||
|
yield piece
|
||||||
|
tail = scaffolding.flush()
|
||||||
|
if tail:
|
||||||
|
yield StreamPiece("content", tail)
|
||||||
|
|
||||||
|
|
||||||
async def _run(
|
async def _run(
|
||||||
@@ -1102,6 +1126,7 @@ class FailingLLM:
|
|||||||
self,
|
self,
|
||||||
messages: list[dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
scaffolding: ScaffoldingFilter | None = None, # phase 71: fed like the real client
|
||||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||||
index = len(self.requests)
|
index = len(self.requests)
|
||||||
pieces, error = (
|
pieces, error = (
|
||||||
@@ -1112,19 +1137,35 @@ class FailingLLM:
|
|||||||
self.requests.append(
|
self.requests.append(
|
||||||
(deepcopy(messages), deepcopy(tools) if tools is not None else None)
|
(deepcopy(messages), deepcopy(tools) if tools is not None else None)
|
||||||
)
|
)
|
||||||
return self._attempt(index, pieces, error)
|
return self._attempt(index, pieces, error, scaffolding)
|
||||||
|
|
||||||
async def _attempt(
|
async def _attempt(
|
||||||
self,
|
self,
|
||||||
index: int,
|
index: int,
|
||||||
pieces: list[StreamPiece | ToolCallPiece],
|
pieces: list[StreamPiece | ToolCallPiece],
|
||||||
error: Exception | None,
|
error: Exception | None,
|
||||||
|
scaffolding: ScaffoldingFilter | None,
|
||||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||||
try:
|
try:
|
||||||
for piece in pieces:
|
for piece in pieces:
|
||||||
yield piece
|
if (
|
||||||
|
scaffolding is not None
|
||||||
|
and isinstance(piece, StreamPiece)
|
||||||
|
and piece.kind == "content"
|
||||||
|
):
|
||||||
|
cleaned = scaffolding.feed(piece.text)
|
||||||
|
if cleaned:
|
||||||
|
yield StreamPiece("content", cleaned)
|
||||||
|
else:
|
||||||
|
yield piece
|
||||||
if error is not None:
|
if error is not None:
|
||||||
|
# The tail is NOT flushed on a failed attempt — the real
|
||||||
|
# client only flushes a cleanly completed stream.
|
||||||
raise error
|
raise error
|
||||||
|
if scaffolding is not None:
|
||||||
|
tail = scaffolding.flush()
|
||||||
|
if tail:
|
||||||
|
yield StreamPiece("content", tail)
|
||||||
finally:
|
finally:
|
||||||
self.closed.append(index)
|
self.closed.append(index)
|
||||||
|
|
||||||
@@ -1402,11 +1443,16 @@ def test_high_prompt_tools_section_with_notes_and_kb() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_low_prompt_is_byte_identical_and_tool_free() -> None:
|
def test_low_prompt_is_byte_identical_and_tool_free() -> None:
|
||||||
|
# Phase 71: the LOW prompt carries the owner-permitted plain-text
|
||||||
|
# line after the DEFLECT_MODE sentence (the marker-keying contract
|
||||||
|
# is unchanged; the line must not leak into the HIGH prompt —
|
||||||
|
# pinned in tests/unit/test_prompts.py).
|
||||||
expected = (
|
expected = (
|
||||||
_base("LOW")
|
_base("LOW")
|
||||||
+ "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
+ "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
||||||
"your notes come to the question. They are titles only; do not pretend "
|
"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"
|
"they answer it. Use them to propose 2-3 alternative questions.\n"
|
||||||
|
"Reply in plain text only — you have no tools in this mode.\n"
|
||||||
+ "- T1\n- T2"
|
+ "- T1\n- T2"
|
||||||
)
|
)
|
||||||
assert build_deflect_prompt(["T1", "T2"]) == expected
|
assert build_deflect_prompt(["T1", "T2"]) == expected
|
||||||
@@ -1418,3 +1464,253 @@ def test_low_prompt_is_byte_identical_and_tool_free() -> None:
|
|||||||
):
|
):
|
||||||
assert "<tools>" not in prompt
|
assert "<tools>" not in prompt
|
||||||
assert TOOLS_SECTION not in prompt
|
assert TOOLS_SECTION not in prompt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- phase 71: the scaffolding recovery policy (deterministic only) ----------
|
||||||
|
|
||||||
|
#: The raw span from the 2026-09-03 incident (the E2E mock's trigger,
|
||||||
|
#: task 05) — a complete span the filter strips in full.
|
||||||
|
_INCIDENT_SPAN = (
|
||||||
|
"<|tool_call_start|>[read(path='/homelab/backup-notes.md')]<|tool_call_end|>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_correction_instruction_is_the_harness_constant() -> None:
|
||||||
|
"""Verbatim constant: the E2E mock (task 05) keys on a stable
|
||||||
|
substring of it, so it must not drift."""
|
||||||
|
assert agent.CORRECTION_INSTRUCTION == (
|
||||||
|
"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."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_reply_error_subclasses_llm_error() -> None:
|
||||||
|
assert issubclass(MalformedReplyError, LLMError)
|
||||||
|
assert not issubclass(LLMError, MalformedReplyError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffolding_only_round_gets_exactly_one_recovery(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""A round whose visible content is pure scaffolding → exactly TWO
|
||||||
|
model requests: the normal round, then the ONE recovery —
|
||||||
|
``tools=None`` with :data:`CORRECTION_INSTRUCTION` folded into the
|
||||||
|
original single system message (the user message stays last). The
|
||||||
|
clean recovery answer ends the turn, the holder is untouched by the
|
||||||
|
recovery, and the strip was captured in the warning log."""
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[StreamPiece("content", _INCIDENT_SPAN)],
|
||||||
|
[StreamPiece("content", "The clean recovery answer.")],
|
||||||
|
)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.agent"):
|
||||||
|
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||||||
|
# Nothing of the round's scaffolding was yielded — only the recovery.
|
||||||
|
assert pieces == [StreamPiece("content", "The clean recovery answer.")]
|
||||||
|
assert len(llm.requests) == 2 # the round + the one recovery
|
||||||
|
# The round: the original system prompt, the tools offered.
|
||||||
|
assert llm.requests[0][1] == AGENT_TOOLS
|
||||||
|
assert llm.requests[0][0] == [
|
||||||
|
{"role": "system", "content": "SYSTEM_PROMPT"},
|
||||||
|
{"role": "user", "content": "QUESTION"},
|
||||||
|
]
|
||||||
|
# The recovery: no tools, a SINGLE system message carrying the folded
|
||||||
|
# correction, the user message last.
|
||||||
|
assert llm.requests[1][1] is None
|
||||||
|
assert llm.requests[1][0] == [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "SYSTEM_PROMPT\n" + agent.CORRECTION_INSTRUCTION,
|
||||||
|
},
|
||||||
|
{"role": "user", "content": "QUESTION"},
|
||||||
|
]
|
||||||
|
# The recovery is a fixed policy, not a conversation: the holder is
|
||||||
|
# untouched by it.
|
||||||
|
assert holder.read_docs == [] and holder.tool_calls == 0
|
||||||
|
# The turn total feeds the API layer's ``scaffold_stripped=N`` field.
|
||||||
|
assert holder.scaffold_stripped == len(_INCIDENT_SPAN)
|
||||||
|
# One strip warning per stripped span, the span truncated to 200 chars.
|
||||||
|
strip_logs = [
|
||||||
|
r
|
||||||
|
for r in caplog.records
|
||||||
|
if r.levelno == logging.WARNING and r.getMessage().startswith("agent: stripped")
|
||||||
|
]
|
||||||
|
assert len(strip_logs) == 1
|
||||||
|
message = strip_logs[0].getMessage()
|
||||||
|
assert message.startswith(
|
||||||
|
f"agent: stripped {len(_INCIDENT_SPAN)} chars of tool-scaffolding in round 1:"
|
||||||
|
)
|
||||||
|
assert _INCIDENT_SPAN[:200] in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffolding_twice_settles_with_malformed_reply(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The recovery answer is scaffolding again (a second empty reply) —
|
||||||
|
terminal: :class:`MalformedReplyError` (an :class:`LLMError` subclass)
|
||||||
|
after exactly two requests — no third request, no recovery of a
|
||||||
|
recovery."""
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[StreamPiece("content", _INCIDENT_SPAN)],
|
||||||
|
[StreamPiece("content", _INCIDENT_SPAN)],
|
||||||
|
)
|
||||||
|
with pytest.raises(MalformedReplyError) as excinfo:
|
||||||
|
asyncio.run(_run(llm, holder, _settings()))
|
||||||
|
assert isinstance(excinfo.value, LLMError)
|
||||||
|
assert len(llm.requests) == 2 # exactly one recovery per turn
|
||||||
|
assert llm.requests[1][1] is None
|
||||||
|
assert agent.CORRECTION_INSTRUCTION in llm.requests[1][0][0]["content"]
|
||||||
|
assert holder.scaffold_stripped == 2 * len(_INCIDENT_SPAN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffolding_with_real_content_needs_no_recovery(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A round with real visible content PLUS scaffolding: the clean
|
||||||
|
content stands — one request only, the clean remainder yielded (no
|
||||||
|
raw tokens), no correction in any system prompt."""
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[
|
||||||
|
StreamPiece("content", "Here it is: "),
|
||||||
|
StreamPiece("content", _INCIDENT_SPAN),
|
||||||
|
StreamPiece("content", " hope that helps."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||||||
|
assert [p for p in pieces if isinstance(p, StreamPiece)] == [
|
||||||
|
StreamPiece("content", "Here it is: "),
|
||||||
|
StreamPiece("content", " hope that helps."),
|
||||||
|
]
|
||||||
|
assert len(llm.requests) == 1 # no recovery
|
||||||
|
for messages, _tools in llm.requests:
|
||||||
|
assert all(agent.CORRECTION_INSTRUCTION not in m["content"] for m in messages)
|
||||||
|
assert holder.scaffold_stripped == len(_INCIDENT_SPAN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_turn_carries_no_correction_and_no_strip(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A clean turn: one request, no correction in any system prompt,
|
||||||
|
zero stripped (the log field stays 0 — uniform)."""
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[StreamPiece("thinking", "hmm "), StreamPiece("content", "a clean answer")]
|
||||||
|
)
|
||||||
|
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||||||
|
assert pieces == [
|
||||||
|
StreamPiece("thinking", "hmm "),
|
||||||
|
StreamPiece("content", "a clean answer"),
|
||||||
|
]
|
||||||
|
assert len(llm.requests) == 1
|
||||||
|
for messages, _tools in llm.requests:
|
||||||
|
assert all(agent.CORRECTION_INSTRUCTION not in m["content"] for m in messages)
|
||||||
|
assert holder.scaffold_stripped == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_round_without_a_strip_keeps_today_behavior(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Round content 0 with NOTHING stripped (an empty/thinking-only
|
||||||
|
answer) → return as today — no recovery, no error."""
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM([StreamPiece("thinking", "nothing to say, honestly")])
|
||||||
|
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||||||
|
assert pieces == [StreamPiece("thinking", "nothing to say, honestly")]
|
||||||
|
assert len(llm.requests) == 1
|
||||||
|
assert holder.scaffold_stripped == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffolding_round_with_tool_calls_needs_no_recovery(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A scaffolding-only round that ALSO carried tool calls: the tool
|
||||||
|
ran, and the policy keys on the no-calls exit only — no recovery (the
|
||||||
|
next round is a normal tools-offered round carrying the tool
|
||||||
|
history)."""
|
||||||
|
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[
|
||||||
|
StreamPiece("content", _INCIDENT_SPAN),
|
||||||
|
ToolCallPiece(id="call_1", name="ls", arguments={}),
|
||||||
|
],
|
||||||
|
[StreamPiece("content", "the answer after the tool")],
|
||||||
|
)
|
||||||
|
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||||||
|
# The round's scaffolding was stripped (no raw delta), the tool frame
|
||||||
|
# and the next round's answer flowed on.
|
||||||
|
assert pieces == [
|
||||||
|
ToolCallPiece(id="call_1", name="ls", arguments={}),
|
||||||
|
StreamPiece("content", "the answer after the tool"),
|
||||||
|
]
|
||||||
|
assert len(llm.requests) == 2
|
||||||
|
assert llm.requests[1][1] == AGENT_TOOLS # a normal round, not a recovery
|
||||||
|
for messages, _tools in llm.requests:
|
||||||
|
assert all(
|
||||||
|
m["content"] is None or agent.CORRECTION_INSTRUCTION not in m["content"]
|
||||||
|
for m in messages
|
||||||
|
)
|
||||||
|
assert holder.tool_calls == 1
|
||||||
|
assert holder.scaffold_stripped == len(_INCIDENT_SPAN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_after_tool_rounds_keeps_the_history(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A scaffolding-only answer round after a tool round: the recovery
|
||||||
|
keeps the SINGLE (folded) system message at the front and the tool
|
||||||
|
history intact behind it — no second system message, no duplicated
|
||||||
|
correction."""
|
||||||
|
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||||||
|
[StreamPiece("content", _INCIDENT_SPAN)],
|
||||||
|
[StreamPiece("content", "recovered after a tool round")],
|
||||||
|
)
|
||||||
|
pieces = asyncio.run(_run(llm, holder, _settings()))
|
||||||
|
assert pieces == [
|
||||||
|
ToolCallPiece(id="call_1", name="ls", arguments={}),
|
||||||
|
StreamPiece("content", "recovered after a tool round"),
|
||||||
|
]
|
||||||
|
assert len(llm.requests) == 3 # tool round + stripped round + recovery
|
||||||
|
assert llm.requests[2][1] is None
|
||||||
|
recovered = llm.requests[2][0]
|
||||||
|
assert recovered[0] == {
|
||||||
|
"role": "system",
|
||||||
|
"content": "SYSTEM_PROMPT\n" + agent.CORRECTION_INSTRUCTION,
|
||||||
|
}
|
||||||
|
assert recovered[1] == {"role": "user", "content": "QUESTION"}
|
||||||
|
assert len(recovered) == 4
|
||||||
|
assert recovered[2]["role"] == "assistant"
|
||||||
|
assert recovered[3] == {
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "call_1",
|
||||||
|
"content": "1 documents:\nsource: S | path: a.md | title: A",
|
||||||
|
}
|
||||||
|
assert sum(1 for m in recovered if m["role"] == "system") == 1
|
||||||
|
assert holder.scaffold_stripped == len(_INCIDENT_SPAN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_forced_final_scaffolding_only_settles_malformed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The round-cap forced final (``tools=None``) is filtered too: a
|
||||||
|
scaffolding-only forced answer never reaches the user raw — the turn
|
||||||
|
settles with :class:`MalformedReplyError` (the same terminal
|
||||||
|
semantics; this turn used no recovery, so nothing is doubled up)."""
|
||||||
|
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||||
|
holder = AgentHolder()
|
||||||
|
llm = ScriptedLLM(
|
||||||
|
[ToolCallPiece(id="call_1", name="ls", arguments={})],
|
||||||
|
[StreamPiece("content", _INCIDENT_SPAN)],
|
||||||
|
)
|
||||||
|
with pytest.raises(MalformedReplyError):
|
||||||
|
asyncio.run(_run(llm, holder, _settings(agent_max_rounds=1)))
|
||||||
|
assert len(llm.requests) == 2
|
||||||
|
assert llm.requests[1][1] is None # the forced final — no recovery after it
|
||||||
|
assert holder.scaffold_stripped == len(_INCIDENT_SPAN)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -25,6 +25,9 @@ from app.rag.llm import StreamPiece
|
|||||||
from app.rag.retriever import RetrievedChunk, weak_hit_titles
|
from app.rag.retriever import RetrievedChunk, weak_hit_titles
|
||||||
from app.rag.suggestions import MAX_SUGGESTIONS, derive_suggestions
|
from app.rag.suggestions import MAX_SUGGESTIONS, derive_suggestions
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.rag.scaffolding import ScaffoldingFilter
|
||||||
|
|
||||||
ANSWER = "I haven't done anything like that — try one of these instead!"
|
ANSWER = "I haven't done anything like that — try one of these instead!"
|
||||||
|
|
||||||
#: A small KB outline standing in for the lite-generated one (phase 31).
|
#: A small KB outline standing in for the lite-generated one (phase 31).
|
||||||
@@ -332,6 +335,11 @@ def test_low_prompt_has_titles_only_no_content() -> None:
|
|||||||
assert "<relevance>LOW</relevance>" in prompt
|
assert "<relevance>LOW</relevance>" in prompt
|
||||||
assert "DEFLECT_MODE" in prompt
|
assert "DEFLECT_MODE" in prompt
|
||||||
assert "HONESTY GATE" in prompt # the LOW rule is what the model follows
|
assert "HONESTY GATE" in prompt # the LOW rule is what the model follows
|
||||||
|
# Phase 71 (owner-permitted 2026-09-03): the deflection plain-text
|
||||||
|
# line — the LOW turn offers no tools, so any tool markup there is
|
||||||
|
# always wrong (prevention at the prompt; the filter + recovery is
|
||||||
|
# the backstop).
|
||||||
|
assert "Reply in plain text only — you have no tools in this mode." in prompt
|
||||||
assert "- Kubernetes Homelab Cluster" in prompt
|
assert "- Kubernetes Homelab Cluster" in prompt
|
||||||
assert "- Backup Strategy" in prompt
|
assert "- Backup Strategy" in prompt
|
||||||
assert "ALPHA_DOC_CONTENT" not in prompt
|
assert "ALPHA_DOC_CONTENT" not in prompt
|
||||||
@@ -347,6 +355,8 @@ def test_high_path_unaffected() -> None:
|
|||||||
assert plan.suggestions == []
|
assert plan.suggestions == []
|
||||||
assert "<relevance>HIGH</relevance>" in plan.system_prompt
|
assert "<relevance>HIGH</relevance>" in plan.system_prompt
|
||||||
assert "DEFLECT_MODE" not in plan.system_prompt
|
assert "DEFLECT_MODE" not in plan.system_prompt
|
||||||
|
# Phase 71: the deflection plain-text line never leaks into HIGH.
|
||||||
|
assert "Reply in plain text only" not in plan.system_prompt
|
||||||
assert "ALPHA_DOC_CONTENT" in plan.system_prompt
|
assert "ALPHA_DOC_CONTENT" in plan.system_prompt
|
||||||
assert "BETA_DOC_CONTENT" in plan.system_prompt
|
assert "BETA_DOC_CONTENT" in plan.system_prompt
|
||||||
assert [d.title for d in plan.docs] == ["Kubernetes Homelab Cluster", "Backup Strategy"]
|
assert [d.title for d in plan.docs] == ["Kubernetes Homelab Cluster", "Backup Strategy"]
|
||||||
@@ -438,6 +448,7 @@ class _CannedLLM:
|
|||||||
self,
|
self,
|
||||||
messages: list[dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
scaffolding: ScaffoldingFilter | None = None, # phase 71 pass-through
|
||||||
):
|
):
|
||||||
self.seen.append(messages)
|
self.seen.append(messages)
|
||||||
self.seen_tools.append(tools)
|
self.seen_tools.append(tools)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.rag.llm import (
|
|||||||
ToolCallPiece,
|
ToolCallPiece,
|
||||||
chat_stream_retried,
|
chat_stream_retried,
|
||||||
)
|
)
|
||||||
|
from app.rag.scaffolding import ScaffoldingFilter
|
||||||
|
|
||||||
|
|
||||||
def _settings(**kwargs: Any) -> Settings:
|
def _settings(**kwargs: Any) -> Settings:
|
||||||
@@ -337,13 +338,19 @@ class _FakeCompletions:
|
|||||||
self.completion = completion
|
self.completion = completion
|
||||||
self.kwargs: dict | None = None
|
self.kwargs: dict | None = None
|
||||||
self.chat_kwargs: dict | None = None
|
self.chat_kwargs: dict | None = None
|
||||||
|
#: Every SDK-shaped stream handed out — teardown tests assert the
|
||||||
|
#: phase-48 ``close()`` on them (phase 71 task 02: with/without
|
||||||
|
#: a filter, the teardown path is the same object).
|
||||||
|
self.streams: list[_FakeChatStream] = []
|
||||||
|
|
||||||
async def create(self, **kwargs) -> _FakeChatStream | _FakeCompletion:
|
async def create(self, **kwargs) -> _FakeChatStream | _FakeCompletion:
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
if self.fail is not None:
|
if self.fail is not None:
|
||||||
raise self.fail
|
raise self.fail
|
||||||
if kwargs.get("stream"):
|
if kwargs.get("stream"):
|
||||||
return _FakeChatStream(self.chunks)
|
stream = _FakeChatStream(self.chunks)
|
||||||
|
self.streams.append(stream)
|
||||||
|
return stream
|
||||||
self.chat_kwargs = kwargs
|
self.chat_kwargs = kwargs
|
||||||
assert self.completion is not None
|
assert self.completion is not None
|
||||||
return self.completion
|
return self.completion
|
||||||
@@ -721,6 +728,146 @@ def test_chat_stream_non_object_tool_arguments_raise_llm_error() -> None:
|
|||||||
asyncio.run(drain())
|
asyncio.run(drain())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- scaffolding filter integration (phase 71, task 02) ----------
|
||||||
|
|
||||||
|
#: The incident's raw span (2026-09-03) — the filter's reason to exist.
|
||||||
|
_INCIDENT_SPAN = "<|tool_call_start|>[read(path='/homelab/backup-notes.md')]<|tool_call_end|>"
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_filtered(
|
||||||
|
llm: LLMClient,
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
scaffolding: ScaffoldingFilter | None,
|
||||||
|
) -> list[StreamPiece]:
|
||||||
|
"""Collect pieces from a tools-less filtered stream (phase 71): without
|
||||||
|
tools, no ToolCallPiece can appear (the phase-37 contract)."""
|
||||||
|
async def run() -> list[StreamPiece]:
|
||||||
|
pieces = [p async for p in llm.chat_stream(messages, scaffolding=scaffolding)]
|
||||||
|
assert all(isinstance(p, StreamPiece) for p in pieces)
|
||||||
|
return cast("list[StreamPiece]", pieces)
|
||||||
|
|
||||||
|
return asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_stream_with_filter_strips_span_mid_stream() -> None:
|
||||||
|
"""A span mid-stream never reaches the pieces: the clean remainder
|
||||||
|
flows, ``stripped_chars`` is exact, and no piece carries scaffolding."""
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
llm, _ = _make_stream_client(
|
||||||
|
[_chunk("Hello "), _chunk(_INCIDENT_SPAN + " world"), _chunk("!")]
|
||||||
|
)
|
||||||
|
pieces = _collect_filtered(llm, _RETRY_MSGS, f)
|
||||||
|
assert [(p.kind, p.text) for p in pieces] == [
|
||||||
|
("content", "Hello "),
|
||||||
|
("content", " world"),
|
||||||
|
("content", "!"),
|
||||||
|
]
|
||||||
|
assert f.stripped_chars == len(_INCIDENT_SPAN)
|
||||||
|
assert all("<|" not in p.text for p in pieces if p.kind == "content")
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_stream_with_filter_span_split_across_chunks() -> None:
|
||||||
|
"""A span split across two chunks never emits a partial marker —
|
||||||
|
nothing leaks until the span completes, then the clean text on both
|
||||||
|
sides flows and the whole span counts as stripped."""
|
||||||
|
span = _INCIDENT_SPAN
|
||||||
|
cut = len("<|tool_call_start|>") # split right after the start token
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
llm, _ = _make_stream_client([_chunk("A " + span[:cut]), _chunk(span[cut:] + " B")])
|
||||||
|
pieces = _collect_filtered(llm, _RETRY_MSGS, f)
|
||||||
|
assert [(p.kind, p.text) for p in pieces] == [
|
||||||
|
("content", "A "),
|
||||||
|
("content", " B"),
|
||||||
|
]
|
||||||
|
assert f.stripped_chars == len(span)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_stream_with_filter_yields_nothing_for_pure_scaffolding() -> None:
|
||||||
|
"""A content stream of pure scaffolding yields ZERO content pieces —
|
||||||
|
an empty clean result yields nothing (no empty ``delta`` frames)."""
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
llm, _ = _make_stream_client(
|
||||||
|
[_chunk(_INCIDENT_SPAN), _chunk("<|tool_calls|>")]
|
||||||
|
)
|
||||||
|
pieces = _collect_filtered(llm, _RETRY_MSGS, f)
|
||||||
|
assert pieces == []
|
||||||
|
assert f.stripped_chars == len(_INCIDENT_SPAN) + len("<|tool_calls|>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_stream_with_filter_leaves_thinking_raw() -> None:
|
||||||
|
"""Locked (phase 71): the scratchpad stays raw — a span inside
|
||||||
|
``reasoning_content`` is yielded verbatim and never counts as
|
||||||
|
stripped."""
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
llm, _ = _make_stream_client(
|
||||||
|
[_chunk("", reasoning=_INCIDENT_SPAN), _chunk("ok")]
|
||||||
|
)
|
||||||
|
pieces = _collect_filtered(llm, _RETRY_MSGS, f)
|
||||||
|
assert [(p.kind, p.text) for p in pieces] == [
|
||||||
|
("thinking", _INCIDENT_SPAN),
|
||||||
|
("content", "ok"),
|
||||||
|
]
|
||||||
|
assert f.stripped_chars == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_stream_without_filter_is_the_raw_path() -> None:
|
||||||
|
"""``scaffolding=None`` (the default, and the explicit opt-out): a span
|
||||||
|
in content is yielded verbatim — byte-identical to the pre-phase-71
|
||||||
|
raw path for callers that do not pass a filter."""
|
||||||
|
llm, _ = _make_stream_client([_chunk(_INCIDENT_SPAN)])
|
||||||
|
explicit_none = _collect_filtered(llm, _RETRY_MSGS, None)
|
||||||
|
default = asyncio.run(_collect(llm, _RETRY_MSGS))
|
||||||
|
assert explicit_none == default == [StreamPiece("content", _INCIDENT_SPAN)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_stream_flushed_tail_precedes_tool_call_pieces() -> None:
|
||||||
|
"""Content-before-tools wire convention (phase 71): a stream that ends
|
||||||
|
with a held filter tail (a partial marker at EOF — flushed as-is) +
|
||||||
|
tool_calls deltas yields the flushed tail content piece BEFORE the
|
||||||
|
materialized ``ToolCallPiece``."""
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
llm, _ = _make_stream_client(
|
||||||
|
[
|
||||||
|
_chunk("done <|tool_call_st"), # held: a live prefix of the start token
|
||||||
|
_chunk(
|
||||||
|
None,
|
||||||
|
tool_calls=[_tool_call(0, id="call_t", name="ls")],
|
||||||
|
finish_reason="tool_calls",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run() -> list[StreamPiece | ToolCallPiece]:
|
||||||
|
return [
|
||||||
|
p
|
||||||
|
async for p in llm.chat_stream(_RETRY_MSGS, tools=_AGENT_TOOLS, scaffolding=f)
|
||||||
|
]
|
||||||
|
|
||||||
|
pieces = asyncio.run(run())
|
||||||
|
assert pieces == [
|
||||||
|
StreamPiece("content", "done "),
|
||||||
|
StreamPiece("content", "<|tool_call_st"),
|
||||||
|
ToolCallPiece(id="call_t", name="ls", arguments={}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_stream_abandon_with_filter_closes_stream() -> None:
|
||||||
|
"""Phase-48 teardown is independent of the phase-71 filter: a consumer
|
||||||
|
abandon mid-filter still closes the endpoint stream exactly once."""
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
llm, completions = _make_stream_client([_chunk(f"piece {i} ") for i in range(1, 6)])
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
gen = llm.chat_stream(_RETRY_MSGS, scaffolding=f)
|
||||||
|
first = await gen.__anext__()
|
||||||
|
assert isinstance(first, StreamPiece)
|
||||||
|
assert first.text == "piece 1 "
|
||||||
|
await gen.aclose() # the consumer stops after the first piece
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
assert completions.streams[0].close_calls == 1
|
||||||
|
|
||||||
|
|
||||||
# ---------- one-shot chat: LLMClient.chat (phase 30, task 01) ----------
|
# ---------- one-shot chat: LLMClient.chat (phase 30, task 01) ----------
|
||||||
|
|
||||||
|
|
||||||
@@ -826,11 +973,15 @@ class _ScriptedClient(LLMClient):
|
|||||||
] = []
|
] = []
|
||||||
#: Indices of attempts whose stream teardown has run.
|
#: Indices of attempts whose stream teardown has run.
|
||||||
self.closed: list[int] = []
|
self.closed: list[int] = []
|
||||||
|
#: The caller-owned filter each attempt's ``chat_stream`` received
|
||||||
|
#: (phase 71 task 02 — the retry primitive forwards it).
|
||||||
|
self.scaffoldings: list[ScaffoldingFilter | None] = []
|
||||||
|
|
||||||
def chat_stream(
|
def chat_stream(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
scaffolding: ScaffoldingFilter | None = None,
|
||||||
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
|
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
|
||||||
index = len(self.request_args)
|
index = len(self.request_args)
|
||||||
pieces, error = (
|
pieces, error = (
|
||||||
@@ -841,15 +992,30 @@ class _ScriptedClient(LLMClient):
|
|||||||
self.request_args.append(
|
self.request_args.append(
|
||||||
(list(messages), list(tools) if tools is not None else None)
|
(list(messages), list(tools) if tools is not None else None)
|
||||||
)
|
)
|
||||||
return self._attempt(index, pieces, error)
|
self.scaffoldings.append(scaffolding)
|
||||||
|
return self._attempt(index, pieces, error, scaffolding)
|
||||||
|
|
||||||
async def _attempt(
|
async def _attempt(
|
||||||
self, index: int, pieces: list[StreamPiece | ToolCallPiece],
|
self,
|
||||||
|
index: int,
|
||||||
|
pieces: list[StreamPiece | ToolCallPiece],
|
||||||
error: Exception | None,
|
error: Exception | None,
|
||||||
|
scaffolding: ScaffoldingFilter | None,
|
||||||
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
|
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
|
||||||
try:
|
try:
|
||||||
for piece in pieces:
|
for piece in pieces:
|
||||||
yield piece
|
if (
|
||||||
|
scaffolding is not None
|
||||||
|
and isinstance(piece, StreamPiece)
|
||||||
|
and piece.kind == "content"
|
||||||
|
):
|
||||||
|
# Emulate the real contract (phase 71): content feeds
|
||||||
|
# the filter, an empty clean result yields nothing.
|
||||||
|
cleaned = scaffolding.feed(piece.text)
|
||||||
|
if cleaned:
|
||||||
|
yield StreamPiece("content", cleaned)
|
||||||
|
else:
|
||||||
|
yield piece
|
||||||
if error is not None:
|
if error is not None:
|
||||||
raise error
|
raise error
|
||||||
finally:
|
finally:
|
||||||
@@ -875,12 +1041,18 @@ def _collect_retried(
|
|||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
retries: int,
|
retries: int,
|
||||||
delay: float,
|
delay: float,
|
||||||
|
scaffolding: ScaffoldingFilter | None = None,
|
||||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||||
async def run() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
async def run() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||||
return [
|
return [
|
||||||
p
|
p
|
||||||
async for p in chat_stream_retried(
|
async for p in chat_stream_retried(
|
||||||
client, messages, tools=tools, retries=retries, delay=delay
|
client,
|
||||||
|
messages,
|
||||||
|
tools=tools,
|
||||||
|
retries=retries,
|
||||||
|
delay=delay,
|
||||||
|
scaffolding=scaffolding,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1016,6 +1188,40 @@ def test_retried_zero_delay_still_notifies(monkeypatch: pytest.MonkeyPatch) -> N
|
|||||||
assert sleeps == [0]
|
assert sleeps == [0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_retried_forwards_the_filter_to_every_attempt(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Phase 71: the caller-owned filter reaches EVERY attempt's
|
||||||
|
``chat_stream`` — the same object on the dead attempt and the
|
||||||
|
surviving one."""
|
||||||
|
client = _ScriptedClient(
|
||||||
|
[([], LLMError("down")), ([StreamPiece("content", "ok")], None)]
|
||||||
|
)
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
_record_sleeps(monkeypatch)
|
||||||
|
pieces = _collect_retried(client, _RETRY_MSGS, retries=1, delay=0, scaffolding=f)
|
||||||
|
assert pieces == [RetryPiece(2, 2), StreamPiece("content", "ok")]
|
||||||
|
assert client.scaffoldings == [f, f]
|
||||||
|
|
||||||
|
|
||||||
|
def test_retried_reuses_the_unfed_filter_after_a_dead_attempt(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Phase 71: reusing the SAME filter across attempts is safe by
|
||||||
|
construction — the dead attempt emitted no piece, so the filter was
|
||||||
|
never fed; the surviving attempt filters through it as if fresh."""
|
||||||
|
span = _INCIDENT_SPAN
|
||||||
|
client = _ScriptedClient(
|
||||||
|
[([], LLMError("down")), ([StreamPiece("content", span + " clean")], None)]
|
||||||
|
)
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
_record_sleeps(monkeypatch)
|
||||||
|
pieces = _collect_retried(client, _RETRY_MSGS, retries=1, delay=0, scaffolding=f)
|
||||||
|
assert pieces == [RetryPiece(2, 2), StreamPiece("content", " clean")]
|
||||||
|
assert f.stripped_chars == len(span)
|
||||||
|
assert client.scaffoldings == [f, f]
|
||||||
|
|
||||||
|
|
||||||
def test_retried_abandon_mid_attempt_closes_the_attempt_stream() -> None:
|
def test_retried_abandon_mid_attempt_closes_the_attempt_stream() -> None:
|
||||||
"""Consumer abandon at a mid-attempt piece (the stop-generation path,
|
"""Consumer abandon at a mid-attempt piece (the stop-generation path,
|
||||||
phase 48): no exception leaks and the attempt's stream is torn down
|
phase 48): no exception leaks and the attempt's stream is torn down
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ lite-generated KB outline): its own builder contract (empty → ``""``,
|
|||||||
char budget + ``[…truncated…]`` marker, pathological budgets), its
|
char budget + ``[…truncated…]`` marker, pathological budgets), its
|
||||||
placement between ``<relevance>`` and ``<tuning>`` in both modes, and
|
placement between ``<relevance>`` and ``<tuning>`` in both modes, and
|
||||||
the byte-identical-when-absent convention (phase 15 precedent).
|
the byte-identical-when-absent convention (phase 15 precedent).
|
||||||
|
|
||||||
|
And the phase-71 deflection plain-text line (owner-permitted
|
||||||
|
2026-09-03): the LOW prompt = pre-phase text + exactly the one new
|
||||||
|
line; the ``DEFLECT_MODE`` marker-keying contract is unchanged and
|
||||||
|
the line never leaks into the HIGH prompt.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -129,11 +134,15 @@ def test_zero_note_prompt_is_byte_identical_to_pre_steering() -> None:
|
|||||||
assert build_high_prompt([doc]) == (
|
assert build_high_prompt([doc]) == (
|
||||||
_base("HIGH") + "\n<documents>\n" + block + "\n</documents>" + "\n" + TOOLS_SECTION
|
_base("HIGH") + "\n<documents>\n" + block + "\n</documents>" + "\n" + TOOLS_SECTION
|
||||||
)
|
)
|
||||||
|
# Phase 71: the LOW prompt carries the owner-permitted plain-text
|
||||||
|
# line after the DEFLECT_MODE sentence (the marker-keying contract
|
||||||
|
# is unchanged — the E2E mock keys on the marker's presence).
|
||||||
assert build_deflect_prompt(["T1", "T2"]) == (
|
assert build_deflect_prompt(["T1", "T2"]) == (
|
||||||
_base("LOW")
|
_base("LOW")
|
||||||
+ "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
+ "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
||||||
"your notes come to the question. They are titles only; do not pretend "
|
"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"
|
"they answer it. Use them to propose 2-3 alternative questions.\n"
|
||||||
|
"Reply in plain text only — you have no tools in this mode.\n"
|
||||||
+ "- T1\n- T2"
|
+ "- T1\n- T2"
|
||||||
)
|
)
|
||||||
assert "<tuning>" not in build_high_prompt([doc])
|
assert "<tuning>" not in build_high_prompt([doc])
|
||||||
@@ -299,11 +308,14 @@ def test_no_overview_prompt_is_byte_identical_to_pre_phase() -> None:
|
|||||||
docs_block = "\n<documents>\n" + block + "\n</documents>" + "\n" + TOOLS_SECTION
|
docs_block = "\n<documents>\n" + block + "\n</documents>" + "\n" + TOOLS_SECTION
|
||||||
high_plain = _base("HIGH") + docs_block
|
high_plain = _base("HIGH") + docs_block
|
||||||
high_steered = _base("HIGH") + "\n" + build_steering_section(["be concise"]) + docs_block
|
high_steered = _base("HIGH") + "\n" + build_steering_section(["be concise"]) + docs_block
|
||||||
|
# Phase 71: the owner-permitted plain-text line is part of the
|
||||||
|
# DEFLECT_MODE body in every LOW build (with or without steering).
|
||||||
low_plain = (
|
low_plain = (
|
||||||
_base("LOW")
|
_base("LOW")
|
||||||
+ "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
+ "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
||||||
"your notes come to the question. They are titles only; do not pretend "
|
"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"
|
"they answer it. Use them to propose 2-3 alternative questions.\n"
|
||||||
|
"Reply in plain text only — you have no tools in this mode.\n"
|
||||||
+ "- T1\n- T2"
|
+ "- T1\n- T2"
|
||||||
)
|
)
|
||||||
low_steered = (
|
low_steered = (
|
||||||
@@ -313,6 +325,7 @@ def test_no_overview_prompt_is_byte_identical_to_pre_phase() -> None:
|
|||||||
+ "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
+ "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
||||||
"your notes come to the question. They are titles only; do not pretend "
|
"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"
|
"they answer it. Use them to propose 2-3 alternative questions.\n"
|
||||||
|
"Reply in plain text only — you have no tools in this mode.\n"
|
||||||
+ "- T1\n- T2"
|
+ "- T1\n- T2"
|
||||||
)
|
)
|
||||||
for kb in (None, "", " \n\t "):
|
for kb in (None, "", " \n\t "):
|
||||||
@@ -399,3 +412,67 @@ def test_prompt_kb_section_over_settings_budget_capped_with_marker(
|
|||||||
close = section.index("</knowledge_base>")
|
close = section.index("</knowledge_base>")
|
||||||
section = section[: close + len("</knowledge_base>")]
|
section = section[: close + len("</knowledge_base>")]
|
||||||
assert len(section) <= 200
|
assert len(section) <= 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- phase 71: the deflection plain-text line (prevention) ----------
|
||||||
|
|
||||||
|
#: The owner-permitted (2026-09-03) line appended to the ``DEFLECT_MODE``
|
||||||
|
#: body — the LOW prompt's only phase-71 change. The E2E mock keys on
|
||||||
|
#: the ``DEFLECT_MODE`` marker's *presence*, not the wording, so the
|
||||||
|
#: marker-keying contract is unchanged by the appended line.
|
||||||
|
PLAIN_TEXT_ONLY_LINE = "Reply in plain text only — you have no tools in this mode."
|
||||||
|
|
||||||
|
|
||||||
|
def _pre_phase71_low_body() -> str:
|
||||||
|
"""The ``DEFLECT_MODE`` body exactly as it was before phase 71."""
|
||||||
|
return (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_prompt_is_pre_phase_plus_exactly_the_plain_text_line() -> None:
|
||||||
|
"""Diff pin: the LOW prompt = pre-phase text + exactly the one new
|
||||||
|
line, appended to the ``DEFLECT_MODE`` body; the weak-hit title list
|
||||||
|
follows exactly as before (and the line occurs exactly once)."""
|
||||||
|
prompt = build_deflect_prompt(["T1", "T2"])
|
||||||
|
assert prompt == (
|
||||||
|
_base("LOW")
|
||||||
|
+ "\n"
|
||||||
|
+ _pre_phase71_low_body()
|
||||||
|
+ PLAIN_TEXT_ONLY_LINE
|
||||||
|
+ "\n"
|
||||||
|
+ "- T1\n- T2"
|
||||||
|
)
|
||||||
|
assert prompt.count(PLAIN_TEXT_ONLY_LINE) == 1
|
||||||
|
assert prompt.endswith("- T1\n- T2") # the title list is untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_prompt_carries_the_line_and_keeps_the_mock_marker() -> None:
|
||||||
|
"""The new line is present in the LOW prompt (inside the
|
||||||
|
``DEFLECT_MODE`` body, after the marker) and the ``DEFLECT_MODE``
|
||||||
|
marker the E2E mock keys on stays put."""
|
||||||
|
for titles, tail in ((["T1"], "- T1"), ([], "(nothing close at all)")):
|
||||||
|
prompt = build_deflect_prompt(titles)
|
||||||
|
assert "DEFLECT_MODE" in prompt
|
||||||
|
assert PLAIN_TEXT_ONLY_LINE in prompt
|
||||||
|
assert prompt.index("DEFLECT_MODE") < prompt.index(PLAIN_TEXT_ONLY_LINE)
|
||||||
|
# The title list (or the no-titles fallback) follows the line
|
||||||
|
# exactly as before.
|
||||||
|
assert prompt.endswith(tail)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_text_line_never_leaks_into_high_prompt() -> None:
|
||||||
|
"""The line is the LOW prompt's: every HIGH build (with/without
|
||||||
|
steering/overview) is unchanged and carries none of it."""
|
||||||
|
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||||
|
for notes, kb in (
|
||||||
|
(None, None),
|
||||||
|
(["be concise"], None),
|
||||||
|
(None, OVERVIEW),
|
||||||
|
(["be concise"], OVERVIEW),
|
||||||
|
):
|
||||||
|
high = build_high_prompt([doc], notes=notes, kb_overview=kb)
|
||||||
|
assert PLAIN_TEXT_ONLY_LINE not in high
|
||||||
|
assert "you have no tools" not in high
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""Unit tests: the deterministic tool-scaffolding filter (phase 71, task 01).
|
||||||
|
|
||||||
|
The matrix from the phase overview: a span in one chunk (exact
|
||||||
|
``stripped_chars``), the observed incident span split across chunks at
|
||||||
|
**every** boundary offset of the start token (0..len) plus a mid-span
|
||||||
|
and an end-token split, multiple spans in one chunk, the standalone
|
||||||
|
sibling tokens (alone, embedded, char-by-char), look-alikes that must
|
||||||
|
NOT be stripped, partial markers at EOF (``flush`` emits as-is), and
|
||||||
|
exact preservation of surrounding text.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.rag.scaffolding import SCAFFOLD_PATTERNS, ScaffoldingFilter
|
||||||
|
|
||||||
|
# The observed incident text (2026-09-03 — the deflected round that
|
||||||
|
# streamed raw model tool-scaffolding into the UI).
|
||||||
|
SPAN_START = "<|tool_call_start|>"
|
||||||
|
SPAN_END = "<|tool_call_end|>"
|
||||||
|
SPAN_CONTENT = "[read(path='/homelab/backup-notes.md')]"
|
||||||
|
SPAN = f"{SPAN_START}{SPAN_CONTENT}{SPAN_END}"
|
||||||
|
TOOL_CALLS = "<|tool_calls|>"
|
||||||
|
TOOL_CALL = "<|tool_call|>"
|
||||||
|
|
||||||
|
|
||||||
|
def _run(text: str, chunks: list[str] | None = None) -> tuple[str, ScaffoldingFilter]:
|
||||||
|
"""Feed *chunks* (or *text* as one chunk) + ``flush``; return the
|
||||||
|
(total output, filter) pair the assertions below work on."""
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
parts = [f.feed(chunk) for chunk in (chunks if chunks is not None else [text])]
|
||||||
|
parts.append(f.flush())
|
||||||
|
return "".join(parts), f
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the registry
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_is_the_three_observed_forms() -> None:
|
||||||
|
# Every entry traces to the 2026-09-03 incident — no speculative
|
||||||
|
# entries (the registry is the extension point; new forms need an
|
||||||
|
# observed capture + a fixture here).
|
||||||
|
assert len(SCAFFOLD_PATTERNS) == 3
|
||||||
|
assert SCAFFOLD_PATTERNS[0].pattern == r"<\|tool_call_start\|>[\s\S]*?<\|tool_call_end\|>"
|
||||||
|
assert SCAFFOLD_PATTERNS[1].pattern == r"<\|tool_calls\|>"
|
||||||
|
assert SCAFFOLD_PATTERNS[2].pattern == r"<\|tool_call\|>"
|
||||||
|
# The span form is non-greedy: two spans each strip to their own end.
|
||||||
|
assert SCAFFOLD_PATTERNS[0].sub("", SPAN + "x" + SPAN) == "x"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------- span in one chunk, split spans
|
||||||
|
|
||||||
|
|
||||||
|
def test_span_in_one_chunk_is_stripped_with_exact_count() -> None:
|
||||||
|
out, f = _run(SPAN)
|
||||||
|
assert out == ""
|
||||||
|
assert f.stripped_chars == len(SPAN)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_offsets() -> list[int]:
|
||||||
|
"""Every split offset of the start token (0..len) + a mid-span split
|
||||||
|
and an end-token split (the task-01 matrix)."""
|
||||||
|
offsets = list(range(len(SPAN_START) + 1)) # 0 .. 19, every start-token offset
|
||||||
|
offsets += [
|
||||||
|
len(SPAN_START) + 1, # one char into the span body
|
||||||
|
len(SPAN_START) + len(SPAN_CONTENT) // 2, # mid-span
|
||||||
|
len(SPAN) - len(SPAN_END), # exactly at the end token
|
||||||
|
len(SPAN) - 5, # inside the end token
|
||||||
|
len(SPAN) - 1, # the very last char
|
||||||
|
]
|
||||||
|
return offsets
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("offset", _split_offsets())
|
||||||
|
def test_span_split_at_boundary_offsets_emits_nothing_until_complete(offset: int) -> None:
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
first = f.feed(SPAN[:offset])
|
||||||
|
second = f.feed(SPAN[offset:])
|
||||||
|
third = f.flush()
|
||||||
|
assert first == "" # nothing ever emits until the span completes
|
||||||
|
assert second == ""
|
||||||
|
assert third == ""
|
||||||
|
assert f.stripped_chars == len(SPAN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_span_completed_by_a_later_chunk_then_content_follows() -> None:
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
assert f.feed(f"lead {SPAN_START}part") == "lead " # prose before the span flows
|
||||||
|
assert f.feed(f"{SPAN_END} tail") == " tail"
|
||||||
|
assert f.flush() == ""
|
||||||
|
assert f.stripped_chars == len(SPAN_START) + 4 + len(SPAN_END) # body: "part"
|
||||||
|
|
||||||
|
|
||||||
|
def test_span_with_nested_start_token_strips_to_first_end() -> None:
|
||||||
|
# Non-greedy: the span runs from the FIRST start to the FIRST end.
|
||||||
|
text = f"{SPAN_START}a{SPAN_START}b{SPAN_END}"
|
||||||
|
out, f = _run(text)
|
||||||
|
assert out == ""
|
||||||
|
assert f.stripped_chars == len(text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spans_across_chunks_with_a_standalone_token_after() -> None:
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
assert f.feed(SPAN[:25]) == "" # inside the span body
|
||||||
|
assert f.feed(SPAN[25:] + " mid " + TOOL_CALLS) == " mid "
|
||||||
|
assert f.flush() == ""
|
||||||
|
assert f.stripped_chars == len(SPAN) + len(TOOL_CALLS)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- multiple spans / tokens
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_spans_in_one_chunk_strip_each_to_their_own_end() -> None:
|
||||||
|
out, f = _run(f"{SPAN} middle {SPAN}")
|
||||||
|
assert out == " middle "
|
||||||
|
assert f.stripped_chars == 2 * len(SPAN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_spans_with_different_content() -> None:
|
||||||
|
other = f"{SPAN_START}[grep(pattern='vault')]{SPAN_END}"
|
||||||
|
out, f = _run(f"before{SPAN}mid{other}after")
|
||||||
|
assert out == "beforemidafter"
|
||||||
|
assert f.stripped_chars == len(SPAN) + len(other)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_strip_until_no_complete_match_remains() -> None:
|
||||||
|
# A standalone token embedded in a would-be token: step (a) repeats
|
||||||
|
# leftmost-complete removals until the buffer is clean.
|
||||||
|
out, f = _run(f"x<|tool_call{TOOL_CALLS}|>y")
|
||||||
|
assert out == "xy"
|
||||||
|
assert f.stripped_chars == len(TOOL_CALLS) + len(TOOL_CALL)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ standalone tokens
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("token", "expected"),
|
||||||
|
[(TOOL_CALLS, 14), (TOOL_CALL, 13)],
|
||||||
|
)
|
||||||
|
def test_standalone_token_alone_is_stripped(token: str, expected: int) -> None:
|
||||||
|
out, f = _run(token)
|
||||||
|
assert out == ""
|
||||||
|
assert f.stripped_chars == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("token", [TOOL_CALLS, TOOL_CALL])
|
||||||
|
def test_standalone_token_embedded_in_a_line_is_stripped(token: str) -> None:
|
||||||
|
out, f = _run(f"line {token} tail\n")
|
||||||
|
assert out == "line tail\n"
|
||||||
|
assert f.stripped_chars == len(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_standalone_tokens_adjacent_to_text() -> None:
|
||||||
|
out, f = _run(f"a{TOOL_CALL}b{TOOL_CALLS}c")
|
||||||
|
assert out == "abc"
|
||||||
|
assert f.stripped_chars == len(TOOL_CALL) + len(TOOL_CALLS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("token", [TOOL_CALLS, TOOL_CALL])
|
||||||
|
def test_standalone_token_char_by_char_never_emits(token: str) -> None:
|
||||||
|
out, f = _run("", list(token))
|
||||||
|
assert out == ""
|
||||||
|
assert f.stripped_chars == len(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_standalone_token_completing_in_a_later_chunk() -> None:
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
assert f.feed("<|tool_ca") == ""
|
||||||
|
assert f.feed("lls|>") == ""
|
||||||
|
assert f.flush() == ""
|
||||||
|
assert f.stripped_chars == len(TOOL_CALLS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_token_resolving_to_prose_is_emitted() -> None:
|
||||||
|
# ``<|tool_cat|>`` is no known form — the held partial prefix must be
|
||||||
|
# released as content once the input can no longer complete a token.
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
assert f.feed("x" * 50 + "<|tool_ca") == "x" * 50 # live prefix held
|
||||||
|
assert f.feed("t|> done") == "<|tool_cat|> done"
|
||||||
|
assert f.flush() == ""
|
||||||
|
assert f.stripped_chars == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- look-alikes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text",
|
||||||
|
[
|
||||||
|
"tool_call_start", # the word as prose, no delimiters
|
||||||
|
"the tool_call and tool_calls words", # prose words
|
||||||
|
"<|tool_call_start|", # missing delimiter close — prose at EOF
|
||||||
|
"<|some_other_token|>", # unknown token
|
||||||
|
"<|tool_call_end|>", # a lone end token without a start
|
||||||
|
"a < b", # a lone angle bracket
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_lookalikes_are_not_stripped_and_emit_verbatim(text: str) -> None:
|
||||||
|
out, f = _run(text)
|
||||||
|
assert out == text
|
||||||
|
assert f.stripped_chars == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_lone_end_token_before_an_open_start_stays_content_until_closed() -> None:
|
||||||
|
# The lone end token is content (emitted as-is); the trailing start
|
||||||
|
# opens a span and is HELD — if the span later closes, only the
|
||||||
|
# span is stripped, the lone end token stays.
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
assert f.feed(f"{SPAN_END}{SPAN_START}") == SPAN_END
|
||||||
|
assert f.feed(f"body{SPAN_END}") == ""
|
||||||
|
assert f.flush() == ""
|
||||||
|
assert f.stripped_chars == len(SPAN_START) + 4 + len(SPAN_END)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lone_end_token_at_eof_flushes_as_is() -> None:
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
out = f.feed(SPAN_END)
|
||||||
|
assert out + f.flush() == SPAN_END # verbatim total (the tail may be split)
|
||||||
|
assert f.stripped_chars == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- EOF / empty / clean
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_start_token_at_eof_flushes_as_is() -> None:
|
||||||
|
# A partial marker at EOF is content, not scaffolding (pinned choice).
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
assert f.feed("<|tool_call_st") == ""
|
||||||
|
assert f.flush() == "<|tool_call_st"
|
||||||
|
assert f.stripped_chars == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"partial",
|
||||||
|
[
|
||||||
|
"<",
|
||||||
|
"<|",
|
||||||
|
"<|tool_ca",
|
||||||
|
"<|tool_call|", # one char short of the standalone token
|
||||||
|
"<|tool_calls|", # one char short of the standalone token
|
||||||
|
"<|tool_call_start|", # one char short of the span opening
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_partial_tokens_at_eof_flush_as_is(partial: str) -> None:
|
||||||
|
out, f = _run(partial)
|
||||||
|
assert out == partial
|
||||||
|
assert f.stripped_chars == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_chunk_is_a_noop() -> None:
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
assert f.feed("") == ""
|
||||||
|
assert f.feed("") == ""
|
||||||
|
assert f.flush() == ""
|
||||||
|
assert f.stripped_chars == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_chunk_amid_pending_is_a_noop() -> None:
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
assert f.feed("<|tool_ca") == ""
|
||||||
|
assert f.feed("") == ""
|
||||||
|
assert f.feed("lls|>") == "" # completes the standalone token
|
||||||
|
assert f.flush() == ""
|
||||||
|
assert f.stripped_chars == len(TOOL_CALLS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flush_on_a_clean_stream_emits_nothing_new() -> None:
|
||||||
|
f = ScaffoldingFilter()
|
||||||
|
assert f.feed("hello ") == "hello "
|
||||||
|
assert f.feed("world") == "world"
|
||||||
|
assert f.flush() == ""
|
||||||
|
assert f.stripped_chars == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_surrounding_text_preserved_exactly() -> None:
|
||||||
|
# No reflow beyond the removal — both spaces around the span stay.
|
||||||
|
out, f = _run(f"hello {SPAN} world")
|
||||||
|
assert out == "hello world"
|
||||||
|
assert f.stripped_chars == len(SPAN)
|
||||||
Reference in New Issue
Block a user