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:
+268
-5
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user