feat(agent): search_documents tool — the model can grep the indexed documents for an exact string
This commit is contained in:
@@ -100,6 +100,24 @@ Implements just enough of the aipi surface:
|
||||
offered — e.g. ``agent_max_rounds=0``) behave exactly as today.
|
||||
``E2E_REAL_LLM=1`` ignores the mock entirely (the real model does
|
||||
what it does).
|
||||
- user message containing ``search your documents``
|
||||
(``SEARCH_TRIGGER``, phase 68, search tool) **and** the system
|
||||
prompt carries the ``<tools>`` section -> the deterministic SEARCH
|
||||
tool flow, discriminated statelessly from the messages (streaming
|
||||
only):
|
||||
* request 1 (``tools`` offered, no search result yet): stream
|
||||
ONLY ``tool_calls`` deltas — ``search_documents`` with
|
||||
``{"pattern": SEARCH_PATTERN}`` (id ``call_0``);
|
||||
* request 2 (a ``tool``-role search result in the messages —
|
||||
recognizable by its ``source/path:line: text`` match lines or
|
||||
the sentinel in its content): the content answer, deterministic:
|
||||
``Found <first matched line's content up to 80 chars>`` — so a
|
||||
suite can assert the search result reached the model and landed
|
||||
in the answer.
|
||||
Checked BEFORE the plain ``use your tools`` flow (it is the more
|
||||
specific phrase — same convention as ``think in paragraphs``); no
|
||||
existing E2E question or fixture file contains the trigger, so
|
||||
every other suite is unaffected.
|
||||
- user message containing ``show me a table`` (phase 44, markdown
|
||||
tables, TODO.md L6) -> the fixed table answer (``TABLE_ANSWER``):
|
||||
a 3-column service table, an ``<img onerror>`` XSS probe line, and
|
||||
@@ -264,6 +282,24 @@ TOOLS_TRIGGER = "use your tools"
|
||||
#: so the 3-step flow is untouched.
|
||||
MULTI_READ_TRIGGER = "read two documents"
|
||||
|
||||
#: Phase 68 (search tool, TODO.md L4): a user message containing this
|
||||
#: substring (case-insensitive) — combined with the ``<tools>`` section
|
||||
#: in the system prompt — drives the deterministic SEARCH tool flow
|
||||
#: (search_documents for ``SEARCH_PATTERN`` → the "Found …" answer),
|
||||
#: documented in the module docstring. Checked BEFORE ``TOOLS_TRIGGER``
|
||||
#: (the more specific phrase wins — the same convention as
|
||||
#: ``THINK_PARAS_TRIGGER``); verified 2026-09-01: no existing E2E
|
||||
#: question or fixture file contains the phrase, so every other suite
|
||||
#: is unaffected.
|
||||
SEARCH_TRIGGER = "search your documents"
|
||||
|
||||
#: The sentinel the search flow greps for: the e2e fixture document
|
||||
#: (``tests/fixtures/search_docs/reese-notes.md``) carries exactly one
|
||||
#: line containing it, so the search result — and the "Found …" answer
|
||||
#: that quotes its first matched line — is byte-stable (the sentinel
|
||||
#: convention of ``END_OF_NOTES_TRIGGER``).
|
||||
SEARCH_PATTERN = "reese-sentinel-42"
|
||||
|
||||
#: Phase 44 (markdown-tables story, TODO.md L6): a user message
|
||||
#: containing this substring (case-insensitive) gets the fixed table
|
||||
#: answer (``TABLE_ANSWER`` below) — a 3-column table, an XSS probe
|
||||
@@ -424,6 +460,69 @@ def _catalog_docs(body: dict[str, Any]) -> list[tuple[str, str]]:
|
||||
return docs
|
||||
|
||||
|
||||
#: One line of the agent's ``search_documents`` output (app.rag.agent
|
||||
#: ``_execute_tool``, phase 68): ``source/path:LINE: text``. The
|
||||
#: non-greedy prefix keeps nested paths (``/`` in the path) intact.
|
||||
_SEARCH_LINE_RE = re.compile(r"^(?P<sp>.+?):(?P<line>\d+): (?P<text>.*)$")
|
||||
|
||||
|
||||
def _search_result_line(body: dict[str, Any]) -> str | None:
|
||||
"""The first matched line's text of a search result in the messages.
|
||||
|
||||
A search result is a ``tool``-role message — never a read result
|
||||
(those start with the agent's ``"Document "`` prefix) — that either
|
||||
carries ``source/path:LINE: text`` match lines (the agent's
|
||||
``search_documents`` output, phase 68) or the sentinel pattern
|
||||
itself (its no-match line quotes the pattern). Returns the first
|
||||
match line's ``text`` part (already 200-char-capped server-side),
|
||||
or the message's first line in the sentinel-only shape, or ``None``
|
||||
when no search result is in the messages yet.
|
||||
"""
|
||||
sentinel = SEARCH_PATTERN.lower()
|
||||
for m in _messages(body):
|
||||
if m.get("role") != "tool":
|
||||
continue
|
||||
content = str(m.get("content") or "")
|
||||
if content.startswith(_READ_RESULT_PREFIX):
|
||||
continue
|
||||
for line in content.splitlines():
|
||||
match = _SEARCH_LINE_RE.match(line)
|
||||
if match:
|
||||
return match.group("text")
|
||||
if sentinel in content.lower():
|
||||
lines = content.splitlines()
|
||||
return lines[0] if lines else ""
|
||||
return None
|
||||
|
||||
|
||||
def _search_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
"""Classify a SEARCH_TRIGGER request into a step of the search flow.
|
||||
|
||||
* ``("search",)`` — ``tools`` are offered and no search result is
|
||||
in the messages yet: the model greps the whole KB for
|
||||
``SEARCH_PATTERN`` (id ``call_0``).
|
||||
* ``("found", first_line)`` — a ``tool``-role search result is in
|
||||
the messages: the model answers, quoting the first matched line
|
||||
(``Found <first matched line's content up to 80 chars>``). Reached
|
||||
regardless of the ``tools`` parameter (phase 45 keeps the tools
|
||||
offered until the round cap).
|
||||
* ``None`` — not the search flow: the trigger is absent, the
|
||||
``<tools>`` section is missing (deflected turns never carry it),
|
||||
or ``tools`` are not offered and no search result is in the
|
||||
messages yet (e.g. ``agent_max_rounds=0``).
|
||||
"""
|
||||
if SEARCH_TRIGGER not in _user(body).lower():
|
||||
return None
|
||||
if "<tools>" not in _system(body):
|
||||
return None
|
||||
first_line = _search_result_line(body)
|
||||
if first_line is not None:
|
||||
return ("found", first_line)
|
||||
if not body.get("tools"):
|
||||
return None
|
||||
return ("search",)
|
||||
|
||||
|
||||
def _tool_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
"""Classify a marker request into one step of the tool flow.
|
||||
|
||||
@@ -948,6 +1047,25 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
||||
if _chat_dead(RETRY_TRIGGER, RETRY_DEAD_ATTEMPTS):
|
||||
return _llm_500(RETRY_TRIGGER)
|
||||
_fail_posts[RETRY_TRIGGER] = 0 # the answer streamed — restart
|
||||
# Phase 68 (search tool): the deterministic search marker flow —
|
||||
# checked BEFORE the phase-37 tool flow (the more specific
|
||||
# trigger phrase wins, same convention as THINK_PARAS_TRIGGER).
|
||||
search_flow = _search_flow(body)
|
||||
if search_flow is not None:
|
||||
if search_flow[0] == "search":
|
||||
stream = _tool_call_stream(
|
||||
"search_documents", {"pattern": SEARCH_PATTERN}, "call_0"
|
||||
)
|
||||
else: # "found" — quote the first matched line (80 chars)
|
||||
answer = _apply_max_tokens(
|
||||
f"Found {search_flow[1][:80]}", body.get("max_tokens")
|
||||
)
|
||||
stream = _sse_stream(answer, 0.0)
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
flow = _tool_flow(body)
|
||||
if flow is not None:
|
||||
if flow[0] == "list":
|
||||
|
||||
Reference in New Issue
Block a user