phase: 146_search_tool_scope
Build and Push Containers / build-and-push-app (push) Successful in 2m24s
Build and Push Containers / build-and-push-db (push) Successful in 13s

Phase 146 final verification pass complete — all green, tree untouched by me.

**Verified (final pass — both tasks already in `complete/`):**
- Scoped grep streams `{"type":"tool","name":"grep","argument":<pattern>,"path":<source/path>}`; bare grep/read/ls frames dump byte-identical (unit matrix in `test_sse_events.py` + builder pins in `test_chat_gate.py` L1364–1448)
- Tool line `🔎 Searching for <pattern> in <source/path>` (two `<code>` spans, `.grep-scope` sibling, no new emoji); bare line byte-identical (unit Node test + E2E)
- Status label "… is searching for X in Y" scoped-only; `path` persisted additively on the saved tool record and re-rendered on `/?chat=<id>` restore; pre-phase records restore byte-identically (E2E tests 2 & 3)

**Results (exact commands):**
- `uv run pytest --cov=app --cov-report=term-missing` → **3419 passed**, `app/` coverage **99%** (>90% ✓)
- `uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov` → **3 passed** in isolation ✓
- `uv run ruff check . && uv run pyright` → clean (0 errors) ✓

**Completion criteria:** all 6 ✓ (wire shape, UI line, status label, save/restore, gates, no completed-phase behavior change — full suite green includes all prior-phase pins).

**Documentation:** README.md (incl. its phase-146 section) and AGENTS.md reviewed in full against the codebase — verified consistent, no changes needed (`.env.example` ⊆ README config tables; no stale vars; phase-100 72rem rule in AGENTS.md §5 matches shipped CSS; no-CDN/smoke-DB/gpgsign claims all confirmed).

**Notable:** none — no defects found, no live-infrastructure touched, nothing committed per harness protocol.

**Next pending phase:** `147_sync_changed_only`.
This commit is contained in:
2026-10-01 12:48:04 -04:00
parent 32a3ec4f5e
commit ab3f5f685b
27 changed files with 2151 additions and 29 deletions
+24
View File
@@ -941,12 +941,36 @@ async def chat(
else None
)
limit = limit if _whole_line_number(limit) else None
# Phase 146 (task 01): a SCOPED grep's frame
# carries the scope — the re-searched
# document's combined ``source/path`` (the
# grep's optional one-doc argument, PLAN §6)
# — as the additive ``path`` field, so the
# UI can show WHERE the search runs (the
# "looks like the LLM is repeating itself"
# confusion, TODO L3). ONLY ``grep`` reads it
# (a non-grep ``path`` argument is that
# tool's ``argument``, never a scope); the
# same string guard as ``argument`` — a
# non-string value is a model error the agent
# already refuses; the guard just yields
# None → the key is omitted on
# serialization, so every non-scoped frame
# keeps its exact pre-phase wire bytes (the
# ChatToolEvent wrap serializer).
scope = (
piece.arguments.get("path")
if piece.name == "grep"
else None
)
scope = scope if isinstance(scope, str) else None
yield sse_event(
ChatToolEvent(
name=piece.name,
argument=argument,
offset=offset,
limit=limit,
path=scope,
).model_dump()
)
continue
+58 -2
View File
@@ -188,8 +188,11 @@ class ChatToolEvent(BaseModel):
grounded turn may call the server-side document tools (``ls`` /
``read`` / ``grep``, see :mod:`app.rag.agent`); each model-requested
call streams as ``{type: "tool", name: str, argument: str | null}``
ahead of the answer's ``delta`` frames. ``argument`` is the single
string argument the model passed — ``read``'s ``path`` (the combined
ahead of the answer's ``delta`` frames — a scoped ``grep``'s frame
alone additionally carries the optional ``path`` (phase 146 — the
re-searched document's combined ``source/path``; the ONLY case the
field is present, see below). ``argument`` is the single string
argument the model passed — ``read``'s ``path`` (the combined
``source/path``), ``grep``'s ``pattern``, ``ls``'s ``path`` — or
null (a non-string value, a model error the backend refuses, and an
omitted argument both yield null). The client renders each frame as
@@ -211,6 +214,21 @@ class ChatToolEvent(BaseModel):
result, and the frame carries no range). The client ignores the
extra fields (PLAN §4); they exist so the wire — and the phase-131
story E2E — can assert the range the model requested.
Phase 146 (task 01): ``path`` — the SCOPE of a scoped ``grep``: the
re-searched document's combined ``source/path`` identity (the
grep's optional one-doc scope, PLAN §6), so the UI can show WHERE
the search runs (the "looks like the LLM is repeating itself"
confusion, TODO L3). Additive in the ``offset`` / ``limit`` style:
absent (``None``) on every frame that is NOT a scoped grep — bare
``grep``, ``ls``, ``read`` (with and without range), and every
pre-phase frame — where the key is DROPPED on serialization
(ABSENT, never ``null``), so those frames stay byte-identical to
pre-phase (the story E2E suites pin them as exact dicts). The frame
builder (:mod:`app.api.chat`) sets it ONLY for ``piece.name ==
"grep"`` from the grep's ``path`` argument, string-guarded like
``argument`` (a non-string value is a model error the agent already
refuses; the guard just yields the omission).
"""
type: Literal["tool"] = "tool"
@@ -218,6 +236,10 @@ class ChatToolEvent(BaseModel):
argument: str | None = None # the single string argument passed, or null
offset: int | None = None # read only: the 1-based first line requested
limit: int | None = None # read only: the line count requested
# Phase 146 (task 01): grep only — the scoped re-search's document
# (the model's ``path`` argument, combined ``source/path``); None on
# every non-scoped frame → the key is ABSENT, never null.
path: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
@@ -229,6 +251,11 @@ class ChatToolEvent(BaseModel):
data.pop("offset", None)
if self.limit is None:
data.pop("limit", None)
# Phase 146 (task 01): the scoped grep's scope rides ``path`` —
# ABSENT (never null) on every non-scoped frame, so those stay
# byte-identical to pre-phase (the same omission pattern).
if self.path is None:
data.pop("path", None)
return data
@@ -1010,6 +1037,20 @@ class ToolCall(BaseModel):
422s here is one the UI never legitimately produces). A saved chat
written before the phase (no fields) validates UNCHANGED and renders
without the suffix (the phase-50 backward-compat rule).
Phase 146 (task 02): ``path`` — the SCOPE of a scoped ``grep``:
the re-searched document's combined ``source/path`` identity (the
same additive ``path`` the :class:`ChatToolEvent` frame carries,
PLAN §6) rides the SAME record, so a saved scoped-grep turn
re-renders the scope on restore (the chat page's restore path reads
it off the stored row). Additive-omission in the ``ChatToolEvent``
/ ``SourceRef.image_url`` style: ABSENT (never ``null``) on every
non-scoped record — the UI stamps the key only when the frame
carried a scope — so pre-phase and bare-grep records validate
UNCHANGED and round-trip byte-identically (the phase-50
backward-compat rule; no migration). ``max_length=2000`` mirrors
``argument`` (the combined ``source/path`` identity is ≤ 120 + 1 +
1000; 2 000 is the same ×2 headroom).
"""
name: str = Field(max_length=100)
@@ -1019,6 +1060,21 @@ class ToolCall(BaseModel):
chars_total: int | None = Field(default=None, ge=0)
offset: int | None = Field(default=None, ge=1)
limit: int | None = Field(default=None, ge=1)
# Phase 146 (task 02): grep only — the scoped re-search's document
# (the frame's additive ``path``); None on every non-scoped record
# → the key is ABSENT, never null (the serializer below).
path: str | None = Field(default=None, max_length=2000)
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
# Phase 146 (task 02): the scope is ABSENT (never null) on
# every non-scoped record — the pre-phase / bare-grep saved
# shape stays byte-identical (the ChatToolEvent omission
# pattern; the FULL_BRAIN round-trip pin keys on it).
data = handler(self)
if self.path is None:
data.pop("path", None)
return data
#: One suggestion chip (phase 83, A1): a short deterministic string —