feat(agent): search_documents tool — the model can grep the indexed documents for an exact string
Build and Push Containers / build-and-push-db (push) Canceled after 0s
Build and Push Containers / build-and-push-app (push) Canceled after 1m11s

This commit is contained in:
2026-09-02 12:04:34 -04:00
parent 88293ed02f
commit 8cf3a827ee
26 changed files with 1780 additions and 85 deletions
+39 -30
View File
@@ -49,25 +49,30 @@ outline (0 when absent) and the per-turn log line records
Agent document tools (phase 37, PLAN §4 extension, owner permission
2026-08-26; phase 45 removed the per-tool budgets — owner permission
2026-08-27): a **grounded** turn (``not plan.deflected``) no longer
streams a bare ``chat_stream`` — it runs the agent loop
(``app.rag.agent.run_agent``), which offers the model the two
server-side tools ``list_documents`` / ``read_document`` for the whole
turn (as many calls as the model wants, re-lists included) until it
answers or the round cap (``BOR_AGENT_MAX_ROUNDS``, default 10) forces
one final no-tools answer. Each model-requested call streams as an SSE
``tool`` event — ``{"type": "tool", "name": …, "argument":
"source/path" | null}`` — ahead of the answer's ``delta`` frames.
``done.sources``, ``query_log.sources`` and the per-turn log line all
report the same combined source list (retrieval docs + the agent's
read docs, deduped by ``(source, path)``, order preserved), and the log
line records ``tool_calls=N`` after ``thinking_chars=N`` (PLAN §9 line
extension — ``N`` counts executed tool calls; rejected calls do not
count). **Deflected turns keep the direct ``chat_stream`` —
byte-identical to the pre-phase path (A8):** the LOW prompt never
carries tools, and with ``agent_max_rounds`` at **0** ``run_agent``
makes exactly one ``tools=None`` request, reproducing the pre-phase
behavior (the kill switch).
2026-08-27; phase 68 added the ``search_documents`` grep): a
**grounded** turn (``not plan.deflected``) no longer streams a bare
``chat_stream`` — it runs the agent loop (``app.rag.agent.run_agent``),
which offers the model the three server-side tools
``list_documents`` / ``read_document`` / ``search_documents`` for the
whole turn (as many calls as the model wants, re-lists and re-searches
included) until it answers or the round cap (``BOR_AGENT_MAX_ROUNDS``,
default 10) forces one final no-tools answer. Each model-requested call
streams as an SSE ``tool`` event — ``{"type": "tool", "name": …,
"argument": "source/path" | pattern | null}`` — ahead of the answer's
``delta`` frames: ``argument`` is the read document's path for
``read_document``, the raw search pattern for ``search_documents``
(a non-string pattern — a model error the backend refuses — yields
null), and null for ``list_documents``. ``done.sources``,
``query_log.sources`` and the per-turn log line all report the same
combined source list (retrieval docs + the agent's read docs, deduped
by ``(source, path)``, order preserved — a search adds no source; it is
a locator, locked A5), and the log line records ``tool_calls=N`` after
``thinking_chars=N`` (PLAN §9 line extension — ``N`` counts executed
tool calls; rejected calls do not count). **Deflected turns keep the
direct ``chat_stream`` — byte-identical to the pre-phase path (A8):**
the LOW prompt never carries tools, and with ``agent_max_rounds`` at
**0** ``run_agent`` makes exactly one ``tools=None`` request,
reproducing the pre-phase behavior (the kill switch).
LLM retries (phase 67, ``BOR_LLM_RETRIES`` / ``BOR_LLM_RETRY_DELAY``,
owner-locked 2026-09-01): when the aipi endpoint dies before a request
@@ -396,18 +401,22 @@ async def chat(
async for piece in answer_stream: # StreamPiece | ToolCallPiece | RetryPiece
if isinstance(piece, ToolCallPiece):
# Phase 37 (PLAN §4 extension): one SSE ``tool``
# frame per model-requested call; ``argument`` is
# the read_document "source/path" (null
# otherwise).
# frame per model-requested call. ``argument`` is
# the read_document "source/path"; phase 68
# extends it with the search_documents pattern
# (a non-string pattern — a model error the
# backend refuses — is null); null otherwise.
if piece.name == "read_document":
argument = (
f"{piece.arguments.get('source')}/{piece.arguments.get('path')}"
)
elif piece.name == "search_documents":
pattern = piece.arguments.get("pattern")
argument = pattern if isinstance(pattern, str) else None
else:
argument = None
yield sse_event(
ChatToolEvent(
name=piece.name,
argument=(
f"{piece.arguments.get('source')}/{piece.arguments.get('path')}"
if piece.name == "read_document"
else None
),
).model_dump()
ChatToolEvent(name=piece.name, argument=argument).model_dump()
)
continue
if isinstance(piece, RetryPiece):