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
+268 -5
View File
@@ -2,22 +2,281 @@
Lists available models and verifies the embedding dimension of the
configured ``embed`` model against ``BOR_EMBEDDING_DIM`` (default 768).
Run this before the first import if the LLM backend ever changes:
With ``--tools`` it additionally probes the configured chat model's
OpenAI-style tool-calling support (phase 37, task 01): a trivial
no-parameter ``get_time`` function is offered in a non-streaming and a
streaming ``chat/completions`` request, and a supported / not-supported
verdict is printed for each — the agent loop (``app/rag/agent.py``) is
built against that verdict. Run this before the first import if the LLM
backend ever changes:
uv run python -m scripts.llm_probe
uv run python -m scripts.llm_probe --tools
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections.abc import Iterable
from datetime import date
import httpx
from dotenv import load_dotenv
load_dotenv()
#: Trivial probe function (phase 37, task 01): no parameters, so a
#: compliant tool call carries arguments of exactly ``{}``.
_PROBE_TOOL: dict = {
"type": "function",
"function": {
"name": "get_time",
"description": "Get the current time.",
"parameters": {"type": "object", "properties": {}},
},
}
#: A question for which calling ``get_time`` is the natural move.
_PROBE_MESSAGES: list[dict] = [{"role": "user", "content": "What time is it right now?"}]
def main() -> int:
def parse_tool_response_nonstreaming(payload: dict | None) -> dict:
"""Extract tool-call facts from one non-streaming chat.completions body.
Returns ``{"finish_reason": str | None, "calls": [(name, arguments)]}``
— empty/None when the reply carries no tool calls (a plain content
answer) or is malformed (including a non-dict body).
"""
choices = (payload or {}).get("choices") or []
if not isinstance(choices, list) or not choices:
return {"finish_reason": None, "calls": []}
first = choices[0]
message = first.get("message") or {}
calls: list[tuple[str, str]] = []
for tc in message.get("tool_calls") or []:
fn = tc.get("function") or {}
calls.append((str(fn.get("name") or ""), str(fn.get("arguments") or "")))
return {"finish_reason": first.get("finish_reason"), "calls": calls}
def parse_tool_response_streaming(lines: Iterable[str]) -> dict:
"""Accumulate ``delta.tool_calls`` fragments across SSE ``data:`` lines.
Wire convention (OpenAI): the first fragment of a call carries
``index`` + ``id`` + ``function.name`` and (possibly partial)
``function.arguments``; later fragments carry ``index`` + further
``arguments`` pieces; the final chunk carries ``finish_reason``.
Parsing stops at ``data: [DONE]``; malformed ``data:`` lines are
skipped (aipi sometimes interleaves keep-alive noise). Returns::
{
"finish_reason": str | None,
"calls": [(name, arguments)], # accumulated per index
"delta_chunks": int, # chunks carrying tool_calls
"indexed": bool, # every such chunk had int "index"
"had_id": bool, # some chunk carried the call "id"
"arguments_in_deltas": bool, # some fragment carried arguments
}
"""
finish_reason: str | None = None
by_index: dict[int, dict[str, str]] = {}
delta_chunks = 0
indexed = True
had_id = False
arguments_in_deltas = False
for raw in lines:
line = raw.strip()
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
payload = json.loads(data)
except json.JSONDecodeError:
continue
choices = payload.get("choices") or []
if not isinstance(choices, list):
continue
for choice in choices:
fr = choice.get("finish_reason")
if fr:
finish_reason = fr
delta = choice.get("delta") or {}
for tc in delta.get("tool_calls") or []:
idx = tc.get("index")
if isinstance(idx, int):
key = idx
else:
indexed = False
key = 0 if not by_index else max(by_index) + 1
slot = by_index.setdefault(key, {"name": "", "arguments": ""})
delta_chunks += 1
if tc.get("id"):
had_id = True
fn = tc.get("function") or {}
if fn.get("name"):
slot["name"] += str(fn["name"])
if fn.get("arguments"):
arguments_in_deltas = True
slot["arguments"] += str(fn["arguments"])
calls = [(slot["name"], slot["arguments"]) for _, slot in sorted(by_index.items())]
return {
"finish_reason": finish_reason,
"calls": calls,
"delta_chunks": delta_chunks,
"indexed": indexed,
"had_id": had_id,
"arguments_in_deltas": arguments_in_deltas,
}
def _called(result: dict, expected: str) -> bool:
"""True when the request finished with tool_calls invoking *expected*."""
return (
result["finish_reason"] == "tool_calls"
and any(name == expected for name, _ in result["calls"])
)
def classify_tool_calling(nonstream: dict, stream: dict, expected: str = "get_time") -> str:
"""Phase-37 verdict: ``"supported"`` or ``"not-supported"``.
Supported requires *both* requests to finish with
``finish_reason="tool_calls"`` calling *expected*, and the streaming
request to deliver the calls as indexed ``delta.tool_calls`` chunks
with a call ``id`` (the OpenAI wire convention). Anything less —
including an intermittent split outcome — is ``"not-supported"``
(fail-loud house style); the phase then uses the documented
prompt-based structured-call fallback.
"""
if (
_called(nonstream, expected)
and _called(stream, expected)
and stream["delta_chunks"] > 0
and stream["indexed"]
and stream["had_id"]
):
return "supported"
return "not-supported"
def _no_stream_result() -> dict:
return {
"finish_reason": None,
"calls": [],
"delta_chunks": 0,
"indexed": False,
"had_id": False,
"arguments_in_deltas": False,
}
def probe_tools(client: httpx.Client, chat_model: str) -> int:
"""Run the ``--tools`` probe (phase 37, task 01) and print the verdicts.
Returns 0 when both requests were made and classified (either verdict
is a valid, recorded outcome) and 1 when the endpoint is unreachable
(the probe is inconclusive, not a "not supported" signal).
"""
base_payload: dict = {
"model": chat_model,
"messages": _PROBE_MESSAGES,
"tools": [_PROBE_TOOL],
}
# (a) non-streaming request
ns_raw: dict | None = None
try:
resp = client.post("/chat/completions", json={**base_payload, "stream": False})
resp.raise_for_status()
ns_raw = resp.json()
nonstream = parse_tool_response_nonstreaming(ns_raw)
except httpx.HTTPStatusError as e:
print(f"[probe] tools(non-stream) HTTP {e.response.status_code}: {e.response.text[:200]}")
nonstream = {"finish_reason": None, "calls": []}
except httpx.TransportError as e:
print(f"[probe] tools(non-stream) transport error: {e}")
return 1
ns_ok = _called(nonstream, "get_time")
print(f"[probe] tools(non-stream) verdict : {'supported' if ns_ok else 'not supported'}")
print(
f"[probe] tools(non-stream) finish_reason={nonstream['finish_reason']!r} "
f"calls={nonstream['calls']!r}"
)
if not ns_ok and ns_raw:
content = ((ns_raw.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
if content:
print(f"[probe] tools(non-stream) answered in content instead: {content[:160]!r}")
# (b) streaming request
lines: list[str] = []
try:
with client.stream(
"POST", "/chat/completions", json={**base_payload, "stream": True}
) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
lines.append(line)
if line.startswith("data:") and line[5:].strip() == "[DONE]":
break
stream = parse_tool_response_streaming(lines)
except httpx.HTTPStatusError as e:
print(f"[probe] tools(stream) HTTP {e.response.status_code}: {e.response.text[:200]}")
stream = _no_stream_result()
except httpx.TransportError as e:
print(f"[probe] tools(stream) transport error: {e}")
return 1
st_ok = (
_called(stream, "get_time")
and stream["delta_chunks"] > 0
and stream["indexed"]
and stream["had_id"]
)
print(f"[probe] tools(stream) verdict : {'supported' if st_ok else 'not supported'}")
print(
f"[probe] tools(stream) finish_reason={stream['finish_reason']!r} "
f"calls={stream['calls']!r}"
)
print(
f"[probe] tools(stream) delta.tool_calls: chunks={stream['delta_chunks']} "
f"indexed={stream['indexed']} had_id={stream['had_id']} "
f"arguments_in_deltas={stream['arguments_in_deltas']}"
)
verdict = classify_tool_calling(nonstream, stream)
today = date.today().isoformat()
detail = (
"non-streaming + streaming tool_calls follow the OpenAI wire convention"
if verdict == "supported"
else "phase falls back to the documented prompt-based structured-call path"
)
print(f"[probe] TOOLS VERDICT ({today}) : {verdict} — {detail}")
print(f"[probe] summary : probe: {chat_model} tool_calls={verdict} {today}")
return 0
def main(argv: list[str] | None = None) -> int:
# CLI-only: pick up .env without side effects on import (the parse/
# classify functions above are imported from unit tests, where the
# ambient environment must stay pristine).
load_dotenv()
parser = argparse.ArgumentParser(
description=(
"Probe the aipi endpoint: models, embedding dimension, and "
"(with --tools) the chat model's tool-calling support."
)
)
parser.add_argument(
"--tools",
action="store_true",
help=(
"also probe the chat model's OpenAI-style tools/tool_calls support "
"(non-streaming + streaming) and print a supported / not-supported verdict"
),
)
args = parser.parse_args(argv)
base_url = os.environ.get("BOR_LLM_BASE_URL", "https://aipi.reeseapps.com/v1").rstrip("/")
api_key = (
os.environ.get("BOR_LLM_API_KEY")
@@ -29,7 +288,8 @@ def main() -> int:
expected_dim = int(os.environ.get("BOR_EMBEDDING_DIM", "768"))
headers = {"Authorization": f"Bearer {api_key}"}
with httpx.Client(base_url=base_url, headers=headers, timeout=30.0) as client:
tools_rc = 0
with httpx.Client(base_url=base_url, headers=headers, timeout=60.0) as client:
r = client.get("/models")
r.raise_for_status()
models = [m["id"] for m in r.json()["data"]]
@@ -49,6 +309,9 @@ def main() -> int:
dims = sorted({len(d["embedding"]) for d in r.json()["data"]})
print(f"[probe] dims({embed_model}): {dims}")
if args.tools:
tools_rc = probe_tools(client, chat_model)
if dims != [expected_dim]:
print(
f"[probe] MISMATCH: expected {expected_dim}, got {dims}. "
@@ -56,7 +319,7 @@ def main() -> int:
)
return 1
print("[probe] OK — models present, embedding dimension matches configuration.")
return 0
return tools_rc
if __name__ == "__main__":