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
+109 -7
View File
@@ -40,6 +40,23 @@
* "New chat" (#new-chat-btn — bound by the shared header module,
* phase 34 task 02) clears the key + the list back to the empty state.
*
* Agent tool calls (phase 37, PLAN §4 extension): a grounded turn may
* call the two server-side document tools (list_documents /
* read_document, budgeted server-side). Each call streams a `tool` SSE
* frame, and the UI shows the "calling tool" state IN ADDITION to
* "thinking": the UI state itself stays "thinking" (button stays
* disabled — never stale, PLAN §7.4) while the LABELS change — the
* button says "Calling tool…", the #send-status + typing-indicator
* labels say what Brain is doing ("Brain of Reese is listing documents"
* / "Brain of Reese is reading source/path"), and a visible `.tool-call`
* line (own icon + accent color, distinct from the brand-ink Thinking
* block) is appended above the answer, one per call, in order.
* Append-only like thinking: frames are tolerated in any interleaving
* (a frame after the first delta just appends — the agent loop never
* emits one, but it must not crash). The turn record persists an
* optional `tools: [{name, argument}]` array next to `thinking` and
* restore re-renders the lines (phase 14 convention).
*
* Steering notes (phase 15) let the owner tune how Brain answers: a
* "Tune" button under every completed brain bubble (deflected included)
* opens an inline form → POST /api/steering → the note is stored in
@@ -362,7 +379,12 @@ function ensureThinkingBlock(wrap) {
block.innerHTML =
`<summary>Thinking</summary><div class="thinking-text"></div>`;
const body = wrap.querySelector(".msg-body");
body.insertBefore(block, body.querySelector(".bubble"));
// Phase 37: the scratchpad stays the TOP row of the wrap — if tool
// lines are already there (a `tool` frame preceded the first
// `thinking` frame), the block lands above them, not below.
const anchor =
body.querySelector(".tool-calls") ?? body.querySelector(".bubble");
body.insertBefore(block, anchor);
}
return block;
}
@@ -372,6 +394,44 @@ function closeThinkingBlock(wrap) {
if (block) block.open = false; // idempotent; no-op without a block
}
/* ---------- tool-call lines (phase 37, PLAN §4 extension) ----------
* One visible "calling tool" row per `tool` SSE frame, in the same wrap
* the Thinking block uses — above the answer, below the Thinking
* summary (ensureThinkingBlock keeps the scratchpad on top). The first
* frame creates the .tool-calls list; later frames — any tool, any
* interleaving with thinking frames, even after the first delta (the
* agent loop never emits one, but a late frame must not crash) — just
* append another line, in order. The SAME helper re-renders the
* persisted lines on restore (phase 14 convention): the path argument
* goes through textContent, so nothing HTML-shaped can come from
* storage. Lines are not interactive (no focus targets). */
function appendToolLine(wrap, name, argument) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
let container = body.querySelector(".tool-calls");
if (!container) {
container = document.createElement("div");
container.className = "tool-calls";
container.setAttribute("role", "list");
container.setAttribute("aria-label", "Tool calls");
// Before the bubble; below an existing Thinking block (both insert
// before the bubble, so document order is preserved).
body.insertBefore(container, body.querySelector(".bubble"));
}
const line = document.createElement("span");
line.className = "tool-call";
line.setAttribute("role", "listitem");
if (name === "read_document" && argument) {
line.textContent = "📄 Reading ";
const code = document.createElement("code");
code.textContent = argument; // the path is data, never markup
line.appendChild(code);
} else {
line.textContent = "🔎 Listing documents";
}
container.appendChild(line);
}
/* ---------- suggestions (shared chip component, phase 05) ----------
*
* One component, two homes: the onboarding row in the empty state and the
@@ -616,7 +676,7 @@ function appendMaybeTry(wrap, suggestions) {
*
* bor.chat.v1 → { v: 1, messages: [{ who: "user"|"brain", text,
* sources?, deflected?, suggestions?,
* thinking? }] }
* thinking?, tools? }] }
*
* Only RAW TEXT is stored — restore re-renders it through the escape-first
* markdown renderer, so no HTML is ever persisted. Save points: the user
@@ -704,6 +764,16 @@ function renderStoredMessage(m) {
block.open = false;
block.querySelector(".thinking-text").innerHTML = renderMarkdown(m.thinking);
}
if (Array.isArray(m.tools)) {
// Phase 37: restore the tool lines in saved order through the SAME
// append helper as the live frames (no HTML from storage, ever).
for (const t of m.tools) {
if (!t || typeof t.name !== "string") continue;
const arg =
typeof t.argument === "string" && t.argument ? t.argument : null;
appendToolLine(wrap, t.name, arg);
}
}
if (m.deflected) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, m.suggestions);
@@ -721,10 +791,10 @@ function restoreConversation() {
}
/* Brain message save point (on `done`): raw accumulated text + metadata.
Phase 17: meta.thinking is optional — `undefined` drops the key from
the JSON, so turns without thinking persist exactly as before. An empty
answer keeps the fallback/"…" text that was actually rendered — what
the user saw is what is stored. */
Phase 17: meta.thinking and phase 37: meta.tools are optional —
`undefined` drops the key from the JSON, so turns without them persist
exactly as before. An empty answer keeps the fallback/"…" text that
was actually rendered — what the user saw is what is stored. */
function rememberBrainTurn(rawText, meta) {
conversation.push({ who: "brain", text: rawText || "…", ...meta });
saveConversation();
@@ -828,6 +898,8 @@ async function handleSend(e) {
persistedOnLeave = false;
let sawThinking = false; // did any `thinking` frame arrive this turn?
let sawDone = false; // did the stream end with a `done` event?
let toolAcc = []; // phase 37: {name, argument} per `tool` frame —
// persisted with the turn (optional `tools` key)
try {
// thinking = pre-token: dots + busy button. The guard is armed so a
@@ -874,6 +946,34 @@ async function handleSend(e) {
textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom
scrollReveal(wrap); // page follows only while pinned (phase 18)
}
} else if (ev.type === "tool") {
// Phase 37 (PLAN §4 extension): an agent tool call. The UI
// state stays "thinking" — the button remains disabled (never
// stale, PLAN §7.4); what changes are the LABELS: the button
// carries the "calling tool" text, #send-status + the typing
// indicator (if still visible) say what Brain is doing, and a
// .tool-call line lands above the answer (append-only, in
// order). The elapsed-seconds hint (thinkingClock) keeps
// running through tool frames — no clock changes here.
const name = typeof ev.name === "string" ? ev.name : "";
const argument =
typeof ev.argument === "string" && ev.argument ? ev.argument : null;
toolAcc.push({ name, argument });
clearTurnTimeout(); // the stream is alive — a frame arrived
if (!wrap) wrap = addMessage("brain", "");
const toolStatus =
name === "read_document" && argument
? `Brain of Reese is reading ${argument}`
: "Brain of Reese is listing documents";
if (uiState === UI_STATE.thinking) {
sendLabel.textContent = "Calling tool…";
sendStatus.textContent = toolStatus;
document
.querySelector("#typing-indicator .bubble")
?.setAttribute("aria-label", toolStatus);
}
appendToolLine(wrap, name, argument);
scrollReveal(wrap); // page follows only while pinned (phase 18)
} else if (ev.type === "delta") {
acc += ev.text || "";
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
@@ -903,9 +1003,11 @@ async function handleSend(e) {
}
// Persistence save point 2: the answer lands only when the turn is
// complete (raw text + the done metadata; phase 17: + optional
// thinking — `undefined` drops the key from the JSON).
// thinking, phase 37: + optional tools — `undefined` drops the
// key from the JSON).
rememberBrainTurn(finalText || acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
deflected: !!ev.deflected,
sources: ev.sources,
suggestions: ev.suggestions,