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:
@@ -0,0 +1,201 @@
|
||||
"""Unit: the phase-37 "calling tool" frontend contract (task 05).
|
||||
|
||||
No new Python app logic exists for this task — the behavior lives in
|
||||
``frontend/assets/app.js`` + ``styles.css`` and is E2E-gated by the story
|
||||
suite (task 06). Like the other frontend-adjacent unit files, this module
|
||||
pins the JS/CSS markers the story depends on, so a silent regression in
|
||||
the tool branch, the persistence shape, or the tool-line styling is
|
||||
caught without a browser.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return APP_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_tool_branch_is_a_first_class_turn_branch() -> None:
|
||||
"""The turn handler must branch on `tool` frames BETWEEN the
|
||||
thinking and delta branches: the stream stays alive (guard clears),
|
||||
the brain wrap is created on demand, and the label state is
|
||||
applied only while the UI state is still "thinking" (a late frame
|
||||
after the first delta just appends the line — never a crash)."""
|
||||
js = _js()
|
||||
thinking_idx = js.find('ev.type === "thinking"')
|
||||
tool_idx = js.find('ev.type === "tool"')
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
assert -1 < thinking_idx < tool_idx < delta_idx, (
|
||||
"the turn handler must branch on tool frames"
|
||||
)
|
||||
branch = js[tool_idx:delta_idx]
|
||||
assert "toolAcc.push" in branch, "every tool frame is recorded for persistence"
|
||||
assert "clearTurnTimeout()" in branch, "a tool frame proves the stream is alive"
|
||||
assert 'addMessage("brain", "")' in branch, "first frame creates the brain wrap"
|
||||
assert "uiState === UI_STATE.thinking" in branch, (
|
||||
"label updates only while the state is still thinking"
|
||||
)
|
||||
assert "appendToolLine(wrap, name, argument)" in branch
|
||||
# No setUiState in the branch: the state stays "thinking" (never stale).
|
||||
assert "setUiState" not in branch, (
|
||||
"the tool branch must keep uiState=thinking (button stays disabled)"
|
||||
)
|
||||
|
||||
|
||||
def test_calling_tool_label_strings() -> None:
|
||||
"""The 'calling tool' label strings the story keys off: the button
|
||||
text and the status/typing-indicator labels (plain literals —
|
||||
phase 39 centralizes brand strings; no helper here)."""
|
||||
js = _js()
|
||||
tool_idx = js.find('ev.type === "tool"')
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
branch = js[tool_idx:delta_idx]
|
||||
assert '"Calling tool…"' in branch, "the button carries the calling-tool text"
|
||||
assert '"Brain of Reese is listing documents"' in branch
|
||||
assert "Brain of Reese is reading ${argument}" in branch
|
||||
assert "sendStatus.textContent = toolStatus" in branch, (
|
||||
"the #send-status live region announces what Brain is doing"
|
||||
)
|
||||
assert 'setAttribute("aria-label", toolStatus)' in branch, (
|
||||
"the typing indicator label follows the tool state"
|
||||
)
|
||||
# The elapsed-seconds hint keeps running through tool frames: the
|
||||
# branch must not stop/restart the clock.
|
||||
assert "stopThinkingClock" not in branch
|
||||
assert "startThinkingClock" not in branch
|
||||
|
||||
|
||||
def test_tool_lines_render_into_the_bubble_wrap() -> None:
|
||||
"""appendToolLine: first frame creates the .tool-calls list (role=list
|
||||
+ accessible name) BEFORE the bubble — below an existing Thinking
|
||||
block — and each line is a .tool-call listitem with the exact marks:
|
||||
🔎 Listing documents / 📄 Reading <code>path</code>. The path goes
|
||||
through textContent (storage can never inject HTML)."""
|
||||
js = _js()
|
||||
fn = js.find("function appendToolLine")
|
||||
assert fn != -1, "appendToolLine must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert 'querySelector(".tool-calls")' in body, "idempotent per wrap"
|
||||
assert 'className = "tool-calls"' in body
|
||||
assert 'setAttribute("role", "list")' in body
|
||||
assert 'setAttribute("aria-label", "Tool calls")' in body
|
||||
assert 'insertBefore(container, body.querySelector(".bubble"))' in body, (
|
||||
"the list sits ABOVE the answer"
|
||||
)
|
||||
assert 'className = "tool-call"' in body
|
||||
assert 'setAttribute("role", "listitem")' in body
|
||||
assert 'line.textContent = "📄 Reading "' in body
|
||||
assert 'line.textContent = "🔎 Listing documents"' in body
|
||||
assert "code.textContent = argument" in body, (
|
||||
"the path is data — textContent, never innerHTML"
|
||||
)
|
||||
assert "name === \"read_document\" && argument" in body
|
||||
|
||||
|
||||
def test_tool_branch_is_append_only_and_interleaving_safe() -> None:
|
||||
"""Append-only, the same rule as thinking: multiple calls append
|
||||
multiple lines in order, and a tool frame after the first delta
|
||||
(should not happen in v1) still appends — the branch has no early
|
||||
return gated on acc/delta state."""
|
||||
js = _js()
|
||||
tool_idx = js.find('ev.type === "tool"')
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
branch = js[tool_idx:delta_idx]
|
||||
assert "if (aborted) return" not in branch, (
|
||||
"the aborted guard lives at the dispatch top, not per branch"
|
||||
)
|
||||
assert "acc" not in branch.split("appendToolLine")[0].replace("toolAcc", ""), (
|
||||
"the tool branch must not depend on accumulated answer text"
|
||||
)
|
||||
|
||||
|
||||
def test_tool_frames_persist_next_to_thinking() -> None:
|
||||
"""The `done` save point gains an optional `tools` key next to
|
||||
`thinking` (empty turns persist exactly as before — `undefined`
|
||||
drops the key from the JSON), and the accumulator is turn-scoped
|
||||
in handleSend."""
|
||||
js = _js()
|
||||
assert "let toolAcc = []" in js
|
||||
done_idx = js.find('ev.type === "done"')
|
||||
error_idx = js.find('ev.type === "error"')
|
||||
assert -1 < done_idx < error_idx
|
||||
done_block = js[done_idx:error_idx]
|
||||
assert "thinking: thinkingAcc || undefined" in done_block
|
||||
assert "tools: toolAcc.length ? toolAcc : undefined" in done_block, (
|
||||
"tools persisted next to thinking, optional like it"
|
||||
)
|
||||
|
||||
|
||||
def test_tool_lines_re_render_on_restore() -> None:
|
||||
"""Phase 14 convention: a stored brain record with `tools` re-renders
|
||||
the lines on load through the SAME append helper (after the thinking
|
||||
re-render, before the deflected/sources additions)."""
|
||||
js = _js()
|
||||
fn = js.find("function renderStoredMessage")
|
||||
assert fn != -1
|
||||
end = js.find("function restoreConversation")
|
||||
body = js[fn:end]
|
||||
assert "Array.isArray(m.tools)" in body
|
||||
assert "appendToolLine(wrap, t.name, arg)" in body
|
||||
thinking_restore = body.find("if (m.thinking)")
|
||||
tools_restore = body.find("Array.isArray(m.tools)")
|
||||
assert -1 < thinking_restore < tools_restore, (
|
||||
"tools restore sits after the thinking restore (same order as live)"
|
||||
)
|
||||
assert "typeof t.name !== \"string\"" in body, "malformed entries skipped"
|
||||
|
||||
|
||||
def test_thinking_block_stays_on_top_of_tool_lines() -> None:
|
||||
"""If a tool frame precedes the first thinking frame, the Thinking
|
||||
block is still created ABOVE the tool lines (scratchpad on top),
|
||||
not below them."""
|
||||
js = _js()
|
||||
fn = js.find("function ensureThinkingBlock")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert 'querySelector(".tool-calls")' in body, (
|
||||
"the thinking anchor must account for existing tool lines"
|
||||
)
|
||||
assert 'querySelector(".bubble")' in body
|
||||
assert "insertBefore" in body
|
||||
assert "block.open = true" in body
|
||||
|
||||
|
||||
def test_tool_call_style_is_accent_and_contrast_safe() -> None:
|
||||
"""styles.css: .tool-call is an inline row with the accent palette
|
||||
(distinct from the brand-ink Thinking block) and mono `code` styling
|
||||
for the path; the wrapper stacks lines without shifting the column."""
|
||||
css = _css()
|
||||
assert ".tool-calls" in css
|
||||
assert ".tool-call" in css
|
||||
m = re.search(r"\.tool-call \{([^}]*)\}", css)
|
||||
assert m, "the .tool-call rule must exist"
|
||||
row = m.group(1)
|
||||
assert "display: flex" in row, "inline row: icon + text"
|
||||
assert "var(--accent-ink)" in row, (
|
||||
"accent color distinguishes it from the thinking block (≈10.4:1 on surface)"
|
||||
)
|
||||
assert "var(--accent-line)" in row, "accent left border"
|
||||
code = re.search(r"\.tool-call code \{([^}]*)\}", css)
|
||||
assert code, "the path `code` must be styled"
|
||||
assert "var(--mono)" in code.group(1)
|
||||
assert "var(--ink)" in code.group(1) # ≈11.5:1 on --brand-soft
|
||||
assert "gap" in css.split(".tool-calls {")[1].split("}")[0], (
|
||||
"lines stack with a gap — append-only, no reflow"
|
||||
)
|
||||
|
||||
|
||||
def test_no_cdn_added() -> None:
|
||||
"""AGENTS.md rule 6: the tool state adds no external script/link."""
|
||||
index = (FRONTEND / "index.html").read_text(encoding="utf-8")
|
||||
assert 'src="http' not in index and 'href="http' not in index
|
||||
Reference in New Issue
Block a user