feat(rag): agent document tools — list/read tools with env-tuned budgets, SSE tool events + "calling tool" UI

Grounded chat turns now run the agent loop (app/rag/agent.py) instead
of a bare chat_stream: while the per-turn budgets last
(BOR_AGENT_LIST_CALLS / BOR_AGENT_READ_CALLS, default 1 each) the model
gets list_documents (the indexed catalog, /api/docs order) and
read_document (full text, never truncated — A7-revised contract); once
both budgets are spent the tools key is dropped from the request and
the model must answer. Rejected calls (unknown tool, unknown/missing
path, document already in context, spent budget) consume no budget.
Budgets 0/0 make exactly one tools=None request — byte-identical to
the pre-phase path (budgets-as-kill-switch). Deflected turns keep the
direct chat_stream (A8 unchanged; the LOW prompt never carries the
<tools> section).

SSE contract gains {"type":"tool","name":...,"argument":
"source/path"|null} frames ahead of the answer deltas (PLAN §4
extension, owner permission 2026-08-26); done.sources, query_log.sources
and the per-turn log line (gains tool_calls=N) report the retrieval
docs + read docs, deduped. The UI shows a "calling tool"
button/label state and one visible .tool-call line per call above the
answer; the lines persist with the chat record and re-render on
reload. chat_stream passes tools through and accumulates streaming
tool_calls deltas into ToolCallPiece (tools=None stays byte-identical).

E2E: deterministic mock tool flow ("use your tools" + <tools> marker:
list -> read first catalog line -> quoted answer) plus the story suite
(marker flow, reload re-render, plain/deflected no-tool regressions).
Docs: .env.example + README (the two tools, the budgets, the SSE tool
frame, the "calling tool" UI state).

probe: turbo tool_calls=supported 2026-08-26 (uv run python -m
scripts.llm_probe --tools — non-streaming + streaming
finish_reason=tool_calls, indexed delta.tool_calls partials)
This commit is contained in:
2026-08-26 22:39:14 -04:00
parent 9efffcb428
commit 15c1272828
30 changed files with 3594 additions and 67 deletions
+37
View File
@@ -129,6 +129,41 @@ exactly as before.
To hide it, set `BOR_STREAM_THINKING=0` — the `thinking` events stop
(the per-turn log line still counts `thinking_chars`).
## Agent document tools (list + read)
Retrieval only puts the top documents in context. When an answer depends
on a file a note *references* ("the exact JSON shape is in
example-record-file.json"), the model can extend its own context with two
server-side tools — on **grounded** (high-relevance) turns only:
* **`list_documents`** — lists every indexed document, one
`source/path — title` line each (the same order as the Sources page);
* **`read_document(source, path)`** — appends the **full** text of
exactly one more indexed document to the context (never truncated).
Each call the model requests is executed against Postgres only (no extra
LLM round trip) and streamed as an SSE `tool` frame ahead of the answer —
`{"type": "tool", "name": …, "argument": "source/path" | null}`. In the
chat, each call shows a **"calling tool" state** in addition to
"thinking": the send button keeps its busy state ("Calling tool…") and a
visible tool line (`🔎 Listing documents` / `📄 Reading source/path`) lands
above the answer, one per call, in order. The tool lines persist with the
message, so a reloaded conversation re-renders them. The read document is
reflected in the answer's **source chips** and in the `query_log` row.
The opportunities are budgeted per turn:
| Env | Default | Meaning |
|---|---|---|
| `BOR_AGENT_LIST_CALLS` | `1` | `list_documents` calls per turn (0 disables the tool) |
| `BOR_AGENT_READ_CALLS` | `1` | `read_document` calls per turn (0 disables the tool) |
Once both budgets are spent the tools are dropped from the LLM request
and the model must answer. `BOR_AGENT_LIST_CALLS=0 BOR_AGENT_READ_CALLS=0`
reproduces the pre-agent chat behavior exactly (no `tools` in the
request, no `tool` frames). Deflected turns run no tools at all — the
low-relevance path is unchanged.
## Admin & sign-in
Brain of Reese has exactly **one account: the admin (you)**. Signing in
@@ -559,6 +594,8 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
| `BOR_HYBRID_VECTOR_CANDIDATES` | `100` | cosine list width for the RRF fusion |
| `BOR_HYBRID_LEXICAL_CANDIDATES` | `30` | FTS list width for the RRF fusion |
| `BOR_RRF_K` | `60` | RRF damping constant (`1/(k + rank)`) |
| `BOR_AGENT_LIST_CALLS` | `1` | per-turn `list_documents` tool opportunities on grounded turns (0 disables the tool) |
| `BOR_AGENT_READ_CALLS` | `1` | per-turn `read_document` tool opportunities on grounded turns (0 disables the tool) |
| `BOR_IMPORT_EXTENSIONS` | `md,markdown,txt,yaml,yml,json,py` | csv of importable formats (may only narrow the A9 set) |
| `BOR_GIT_SOURCES` | — (empty) | csv of git repo URLs — **fallback while the admin Git sources page's list (Postgres `git_sources`) is empty**; the page is the primary management surface (see *Git-based sources*) |
| `BOR_SOURCES_DIR` | `~/bor-sources` | where the git source repos are cloned/pulled (one subdirectory per repo) |