phase: 146_search_tool_scope
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:
@@ -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
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user