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:
+109
-7
@@ -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,
|
||||
|
||||
@@ -569,6 +569,45 @@ details.thinking .thinking-text {
|
||||
details.thinking .thinking-text p,
|
||||
details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
|
||||
/* Agent tool-call lines (phase 37): one visible "calling tool" row per
|
||||
`tool` SSE frame — in the same wrap as the Thinking block, above the
|
||||
answer, below the Thinking summary. Deliberately distinct from the
|
||||
scratchpad: accent palette (--accent-ink) vs the brand-ink summary,
|
||||
own icon, own accent left border. Contrast: --accent-ink on the row's
|
||||
--surface ≈10.4:1 (11.6:1 on the page bg), and --ink on --brand-soft
|
||||
in the path `code` ≈11.5:1 — all comfortably AA in the (single dark)
|
||||
theme. Inline rows only: appending lines never shifts the 46rem chat
|
||||
column (no new container), and the rows are not interactive — no
|
||||
focus targets. */
|
||||
.tool-calls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.tool-call {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.3rem 0.75rem;
|
||||
color: var(--accent-ink);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.tool-call code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.95em;
|
||||
background: var(--brand-soft);
|
||||
color: var(--ink);
|
||||
padding: 0.05em 0.35em;
|
||||
border-radius: 5px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.msg-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-soft);
|
||||
|
||||
Reference in New Issue
Block a user