Standardize on the .agents/ directory (shared with project skills): phases/, user_stories/, reports/, screenshots/, validate.sh, and phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves history; runtime artifacts move alongside). Updates every reference in AGENTS.md, README.md, .gitignore, app docstrings, and test story headers. Historical KB content in data/ and the runtime pipeline.log transcript are left untouched.
3.5 KiB
3.5 KiB
Task 02 — Filter Integration in chat_stream / chat_stream_retried
Phase: 71_scaffolding_guardrails · Story: n/a (owner request from chat, 2026-09-03)
Objective
Route delta.content through the caller-supplied ScaffoldingFilter in
app/rag/llm.py — content filtered, thinking raw, tail flushed before tool-call
materialization — with None keeping today's byte-identical raw path.
Work
app/rag/llm.py—chat_stream(..., scaffolding: "ScaffoldingFilter | None = None)"(type imported underTYPE_CHECKINGor as a string annotation to keep the module's import graph clean — the filter type is only needed for typing):- Content path:
content = delta.content→ when a filter is present,cleaned = scaffolding.feed(content); yieldStreamPiece("content", cleaned)only whencleanedis non-empty (an empty clean result yields nothing — no emptydeltaframes). Without a filter the existingif content: yieldstands untouched (byte-identical). - Thinking path: unchanged —
reasoning_contentpieces are never filtered (locked: the scratchpad stays raw). - End of stream: after the
async forexhausts and before the tool-call materialization block (if calls and not emitted: …and the finish-reason emission),tail = scaffolding.flush()when a filter is present; yield a content piece for the tail when non-empty. Order pinned: flushed-tail content precedesToolCallPieces (content-before-tools wire convention). - Teardown (phase 48) untouched: the
finally: await stream.close()behavior is independent of the filter. - Docstring: a short "Scaffolding guardrail (phase 71)" paragraph — the filter
is caller-owned (one per request), content-only,
None= raw path.
- Content path:
app/rag/llm.py—chat_stream_retried(..., scaffolding=None)— pass-through tollm.chat_stream. The same filter object is used across retry attempts of one logical request: safe by construction (a restarted attempt only happens when no piece was emitted, i.e. the filter was never fed — note this in the docstring).tests/unit/test_llm_client.py— scripted-chunk tests (the existing fake-client pattern):- with a filter: a chunk carrying a span mid-stream → the
deltapieces carry only the clean text; a span split across two chunks → no partial emit;filter.stripped_charscorrect after consumption. - thinking pieces pass through raw even when they contain a span (pinned).
scaffolding=None→ the yielded pieces are byte-identical to the pre-phase raw path (a span in content is yielded verbatim — the raw contract for callers that opt out).- flush ordering: a trailing partial-then-complete tail yields its content
piece before the materialized
ToolCallPiece(a chunk stream that ends with…clean tail+ tool_calls deltas). - teardown interaction: the phase-48
aclose()/abandon tests (tests/unit/test_llm_stream_teardown.py) stay green with and without a filter (run, don't rewrite).
- with a filter: a chunk carrying a span mid-stream → the
Testing & Quality
- Unit:
tests/unit/test_llm_client.py(+ the untouched teardown suite green). - Coverage: >90% on this task's modified code (
app/rag/llm.py).
Completion Criteria
uv run pytest tests/unit/test_llm_client.py tests/unit/test_llm_stream_teardown.py -v --no-covgreen.scaffolding=Nonecallers (including the kill-switch no-tools requests) are byte-identical (pinned).uv run ruff check . && uv run pyrightclean.