Phase 72 (72_teaching_refusals) — completed under the 2026-09-04 controlled methodology (owner directive: stop clearing/re-importing the homelab KB per iteration; measure tool-calling accuracy on a controlled fixture KB, target >90%). Real-model gate verdicts (live, configured chat model 'lite', fixture KB): - Controlled fixture battery (the new methodology's pass condition — contract accuracy >= 90%): PASS, 4 consecutive runs: gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 8/11 executed (73%) contract 11/11 (100%) 2026-09-04 (wall 43.4s) gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 8/13 executed (62%) contract 12/13 (92%) 2026-09-04 (wall 50.6s) gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 7/11 executed (64%) contract 11/11 (100%) 2026-09-04 (wall 46.8s) gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 9/15 executed (60%) contract 14/15 (93%) 2026-09-04 (wall 54.8s) - Locked derived battery (phase-72 task 05, executed >= 90% bar, run unchanged on the same fixture KB): gate: lite FAIL turns=10 answered=10 caps=0 tool-turns=10 calls 5/15 executed (33%) contract 12/15 (80%) 2026-09-04 (wall 47.7s) The teaching works — every bare-path trap self-corrects in exactly one round, zero cap hits, zero repeat loops, 10/10 answered. The locked executed bar is blocked by ALREADY_IN_CONTEXT dedupe refusals on the corrected re-reads (the trap question seeds its target, so the correct combined-form read is refused for redundancy) — a copy-invariant model behavior (five copy variants, 0/15 re-reads flipped, 2026-09-03 -> 04) and an app-semantics decision for the owner (TOOL_CALLING_TESTING.md sections 5 and 7), not a copy lever. Copy changes this phase owns (unit pins updated to follow): - app/rag/agent.py: ls teaching refusals (path-like scope -> document-path line; unknown source -> no-source line with the source-name parenthetical), read/grep 'did you mean source/path?' teaching (find_path_candidates: exact or suffix path match, catalog order, cap 3), ALREADY_IN_CONTEXT naming the correct action (answer from the text already in the prompt), read tool description front-loaded with the do-not-read rule (the 2026-09-04 controlled telemetry: the re-read is the only remaining refusal class; contract accuracy 92-100% across runs) - app/rag/prompts.py: TOOLS_SECTION states the document-identity contract up front (ls path = source name; read/grep = combined source/path including the source name; do-not-read for <documents> documents placed next to the read teaching; one-call-per-reply and never-repeat rules) - tests: refusal pins (unit + integration), new dedicated E2E suite tests/e2e/test_tool_path_teaching.py (mock misuse flow, green in isolation), regression suites green in isolation (harness_aligned_tools, agent_document_tools, agent_unlimited_tools, search_tool, chat_rag). Gates: uv run pytest green (1501); coverage TOTAL 99% (>90%); ruff + pyright clean. Carries the still-uncommitted phase-71 todo/ -> complete/ move and both phases' .agent/reports/ (AGENTS.md 8).
11 KiB
Phase 72 — Teaching Refusals: End the Post-Harness Tool-Loop Rambling
Story: .agent/user_stories/agent-document-tools.md (this phase repairs the model-facing
contract the phase-70 tools reshaped)
Context:
app/rag/agent.py—AGENT_TOOLS(the phase-70ls/read/grepOpenAI function definitions),_execute_tool(the refusal strings:"No source named '…' — check the ls output.","No document at '…' — check the ls output."),all_documents(catalog-order bulk loader — reused by the suggestion lookup).app/rag/prompts.py—TOOLS_SECTION(HIGH prompt only; the E2E mock keys off the<tools>marker's presence, not its wording).tests/unit/test_agent.py(refusal-string pins; theScriptedLLM+ monkeypatched- accessor pattern),tests/integration/test_agent_tools.py(the same pins against real Postgres,kb/srcfixtures).tests/e2e/mock_llm.py— the deterministic mock tool flows (TOOLS_TRIGGERsingle-read,MULTI_READ_TRIGGER,SEARCH_TRIGGER;_CATALOG_LINE_REcatalog-line parse) and the dedicated-suite-per-phase E2E house pattern.scripts/llm_probe.py— the house live-endpoint probe pattern (python -m scripts.…, argparse, dotenv, printed verdict line);app/api/chat.py— the grounded path the real-model gate mirrors (retrieve→select_documents→build_high_prompt→run_agent)- Incident (owner chat, 2026-09-03, post phase 70/71): the question "list the files
in this directory" produced a Thinking-display trace of the model calling
ls(path='app/rag/importer.py')→"No source named 'app/rag/importer.py' — check the ls output.", thenls(path='.')→ the same-style refusal, then re-reasoning the same paragraphs over and over across rounds (each round'sreasoning_contentappends to the open Thinking block) before finally answering from the seed documents alone. Root cause: the harness-trained prior (ls'spath= a directory to list) collides with this app's contract (path= a source-name filter), and the terse refusal does not correct the misunderstanding, so the model burns rounds. The identical trap awaitsread/grep: a bare document path missing the source prefix (read('app/rag/importer.py')) →"No document at '…'"with no hint of the combined form.
Objective
Make the affected tool refusals teaching so the harness-prior misuse self-corrects in
at most one extra round: a scoped ls whose path looks like a document path (contains
/) or names an unknown source gets a fixed-template refusal that states the correct
contract; a read / scoped-grep argument that resolves to no combined identity but
matches an indexed document's path (exact or suffix) gets a
"did you mean 'source/path'?" refusal naming the exact combined identity to use. The
AGENT_TOOLS path descriptions and the TOOLS_SECTION prompt copy say the same
contract up front. Deterministic only — no model participates in detection or repair; the
phase-70 harness shape (ls / read(path) / grep(pattern, path?)) is unchanged
verbatim. The phase does not pass on mocks alone: a live acceptance gate runs the
fixed question battery through run_agent against the real configured chat model
(lite per .env) and must PASS before the commit (owner directive, 2026-09-03 —
"test with the real lite model until tool calls work consistently; don't pass until a
sufficient number of tool calls succeed").
Dependencies
70_harness_aligned_tools(complete) — the tool surface this phase teaches (shape untouched).71_scaffolding_guardrails(complete) — the deterministic-guardrail house style this phase follows.
Tasks
01_ls_teaching_refusal.md—ls: path-like and unknown-source scopes get teaching refusals; thelspathdescription says "source name, not a file or directory path".02_read_grep_path_suggestion.md—read/grep: an unresolved argument that matches an indexed documentpathgets the "did you mean 'source/path'?" suggestion; descriptions updated.03_prompt_copy.md—TOOLS_SECTIONcopy: thelspathis a source name, not a directory;read/grepneed the combined identity including the source name.04_mock_e2e.md— mockls-misuse flow, dedicated E2E suite (green in isolation).05_real_model_gate.md— the live real-lite acceptance gate (scripts/agent_realmodel_check.py): iterate the copy levers until the gate PASSES, then full gates and the commit.
Testing & Quality
- Unit:
tests/unit/test_agent.py— the newlsteaching refusals (scope containing/→ the document-path line; scope without/unknown → the extended no-source line; both count in nothing, tools stay offered; valid-scope and no-arg listings byte-identical to today);find_path_candidates(exactpathmatch, suffix match, multiple candidates in catalog order capped at 3, zero candidates, no-/argument → no DB lookup);read/grepwiring (in-context dedupe precedence, valid combined form unchanged, scopedgrepsuggestion, A5 grep contract regression). - Integration:
tests/integration/test_agent_tools.py— changed pins updated; new end-to-end suggestion cases throughrun_agentagainst real Postgres (bare path under one source; the same path under two sources). - E2E (mandatory, house rule): NEW dedicated suite
tests/e2e/test_tool_path_teaching.py, run in isolation — the mock flow (misusels(path='.')→ teaching refusal → corrected no-argls()→ listing answer) through the real UI with the two-round shape pinned on the SSE wire; regression suites green in isolation:test_harness_aligned_tools.py,test_agent_document_tools.py,test_agent_unlimited_tools.py,test_search_tool.py,test_chat_rag.py. - Real-model acceptance gate (owner-locked, the phase's pass condition):
uv run python -m scripts.agent_realmodel_checkagainst the live endpoint with the configured chat model (lite) — the fixed 10-question battery (3lsturns including the incident's "list the files in this directory" and a source-name trap, 4readturns including two bare-path traps, 1grepturn, 2 mixed) driven through the real grounded path. PASS = every turn answers (noLLMError/MalformedReplyError), zero turns hit the round cap, ≥6 of 10 turns emit ≥1 tool call, and ≥90% of all emitted tool calls execute (rejections don't count). Until it passes, task 05 iterates the copy levers this phase owns (refusal templates,AGENT_TOOLSdescriptions,TOOLS_SECTION) — the question set and thresholds are fixed by the task file and may not be weakened. - Coverage: >90% on
app/(uv run pytest --cov=app --cov-report=term-missing).
Completion Criteria
- A scoped
lswhose strippedpathcontains/gets the document-path teaching refusal; an unknown source name without/gets the extended "source name, not a directory" refusal; neither counts in anything; a valid scope and the no-arg listing are byte-identical to today. read/ scopedgrepwith an unresolved argument that matches an indexed documentpath(exact or suffix) gets the "did you mean …?" refusal (one candidate → one combined identity; two or more → up to 3, catalog order); a non-matching argument gets today's refusal byte-identical; the in-context dedupe refusal still wins.- The
AGENT_TOOLSpathdescriptions forls/read/grepstate the contract explicitly; the tool names and argument shapes are unchanged (rg '"name":' app/rag/agent.py→ exactlyls,read,grep). TOOLS_SECTIONclarifies the source-namelspathand the source-name-required combined identity; the HIGH prompt still ends with the<tools>section; the LOW/deflection prompt is byte-identical to today.uv run pytestgreen;uv run pytest --cov=app --cov-report=term-missingTOTAL >90%;uv run ruff check . && uv run pyrightclean.uv run pytest tests/e2e/test_tool_path_teaching.py -v --no-covgreen in isolation; the regression suites above green in isolation.uv run python -m scripts.agent_realmodel_checkexits 0 against the live endpoint (all four pass conditions met with the configured model) — the verdict line recorded in theapp/rag/agent.pymodule docstring and in the commit body.- One
--no-gpg-signcommit (message in the Commit block); the phase directory moved to.agent/phases/complete/.
Locked decisions
- The phase-70 tool surface is unchanged (owner lock, 2026-09-03):
ls(path?)/read(path)/grep(pattern, path?)— no renames, no argument additions or removals; this phase changes refusal copy, tool descriptions, and prompt copy only. - Deterministic only (owner 2026-09-03, phase-71 house style): no model in detection
or repair; suggestions are a pure catalog lookup (exact or suffix
pathmatch, case-sensitive, catalog order, capped at 3); every refusal is a fixed template constant. - Teach, don't silently fix: a misused call is still a refusal (counts in nothing,
consumes a round); the model sees its own argument echoed plus the correct form. No
silent argument normalization —
ls(path='.')does NOT become a full listing. - The zero-candidate refusal is byte-identical to today
(
"No document at '…' — check the ls output.") — no behavior change where the model is not confused; thelsno-source refusal keeps its prefix (the teaching parenthetical is appended). - No UI change: the Thinking display (phases 17/21/43) works as designed — the fix
ends the loop, it does not hide the scratchpad. No SSE contract change (refusals
are tool results in the message history; the
toolframes already carry the call'sname/argument). No model swap (owner keepslite), no env change, no schema change. - Real-model gate is a pass condition, not a smoke test (owner directive,
2026-09-03): the phase is NOT complete — and gets NO commit — until
scripts/agent_realmodel_check.pyPASSES against the reallitemodel. The 10 questions, the ≥6-of-10 tool-usage floor, the ≥90% executed-call bar, and the zero-cap rule are fixed by task 05's file; the executor may iterate ONLY the copy levers this phase owns (refusal templates,AGENT_TOOLSdescriptions,TOOLS_SECTION— with their unit pins updated to follow the constants). Lowering a threshold, swapping in easier questions, or skipping the gate to "make it pass" is forbidden; a gate still failing after iteration stops the phase with the per-turn numbers reported for the owner (fail-loud house style). The verdict line (house precedent: the phase-37 probe verdict inapp/rag/agent.py) is recorded in that module's docstring and in the commit body.
Commit
git add -A .agent/ app/ tests/ scripts/ && git commit --no-gpg-sign -m "fix(agent): teach the document-identity contract on ls/read/grep refusals — end the post-harness tool-loop rambling" -m "<real-model gate verdict line, e.g. real-model gate (lite): 10/10 answered, caps=0, tool-turns=8, calls 21/23 executed (91%) — 2026-09-03>"
The commit also carries the still-uncommitted phase-71 todo/ → complete/ move and
.agent/reports/71_scaffolding_guardrails/ (.agent/ is tracked and committed with the
phase — AGENTS.md §8; only .agent/phase-sessions/ and .agent/pipeline.log are
gitignored).