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,240 @@
|
||||
"""Unit tests: scripts/llm_probe.py --tools response parsing (phase 37, task 01).
|
||||
|
||||
The live probe talks to aipi; the parsing and verdict-classification logic
|
||||
is factored into pure functions and is what this module pins. The fixtures
|
||||
mirror the exact wire shapes observed live against ``turbo`` on 2026-08-26
|
||||
(reasoning_content first, then indexed delta.tool_calls fragments).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from scripts.llm_probe import (
|
||||
classify_tool_calling,
|
||||
parse_tool_response_nonstreaming,
|
||||
parse_tool_response_streaming,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_nonstreaming_tool_calls() -> None:
|
||||
payload = {
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning_content": "Let me call it.\n",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"type": "function",
|
||||
"function": {"name": "get_time", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
out = parse_tool_response_nonstreaming(payload)
|
||||
assert out == {"finish_reason": "tool_calls", "calls": [("get_time", "{}")]}
|
||||
|
||||
|
||||
def test_parse_nonstreaming_plain_content_answer() -> None:
|
||||
payload = {
|
||||
"choices": [
|
||||
{"finish_reason": "stop", "message": {"role": "assistant", "content": "It is noon."}}
|
||||
]
|
||||
}
|
||||
assert parse_tool_response_nonstreaming(payload) == {
|
||||
"finish_reason": "stop",
|
||||
"calls": [],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_nonstreaming_empty_or_malformed() -> None:
|
||||
assert parse_tool_response_nonstreaming({"choices": []}) == {
|
||||
"finish_reason": None,
|
||||
"calls": [],
|
||||
}
|
||||
assert parse_tool_response_nonstreaming({}) == {"finish_reason": None, "calls": []}
|
||||
assert parse_tool_response_nonstreaming(None) == {"finish_reason": None, "calls": []}
|
||||
|
||||
|
||||
def _sse(payload: dict) -> str:
|
||||
return "data: " + json.dumps(payload)
|
||||
|
||||
|
||||
def test_parse_streaming_accumulates_fragments() -> None:
|
||||
"""The live wire shape: id+name+partial args in chunk 1, args in chunk 2."""
|
||||
lines = [
|
||||
_sse({"choices": [{"delta": {"reasoning_content": "Let me think."}, "index": 0}]}),
|
||||
_sse(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 0,
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_time", "arguments": "{"},
|
||||
}
|
||||
]
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
_sse(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "}"}}]},
|
||||
"index": 0,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
_sse({"choices": [{"delta": {}, "finish_reason": "tool_calls", "index": 0}]}),
|
||||
"data: [DONE]",
|
||||
]
|
||||
out = parse_tool_response_streaming(lines)
|
||||
assert out["finish_reason"] == "tool_calls"
|
||||
assert out["calls"] == [("get_time", "{}")]
|
||||
assert out["delta_chunks"] == 2
|
||||
assert out["indexed"] is True
|
||||
assert out["had_id"] is True
|
||||
assert out["arguments_in_deltas"] is True
|
||||
|
||||
|
||||
def test_parse_streaming_no_tool_calls() -> None:
|
||||
lines = [
|
||||
_sse({"choices": [{"delta": {"content": "It is "}, "index": 0}]}),
|
||||
_sse({"choices": [{"delta": {"content": "noon."}, "index": 0}]}),
|
||||
_sse({"choices": [{"delta": {}, "finish_reason": "stop", "index": 0}]}),
|
||||
"data: [DONE]",
|
||||
]
|
||||
out = parse_tool_response_streaming(lines)
|
||||
assert out["finish_reason"] == "stop"
|
||||
assert out["calls"] == []
|
||||
assert out["delta_chunks"] == 0
|
||||
assert out["had_id"] is False
|
||||
assert out["arguments_in_deltas"] is False
|
||||
|
||||
|
||||
def test_parse_streaming_stops_at_done_and_skips_malformed() -> None:
|
||||
lines = [
|
||||
"data: not-json",
|
||||
"",
|
||||
_sse(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 0,
|
||||
"id": "x",
|
||||
"type": "function",
|
||||
"function": {"name": "get_time", "arguments": "{}"},
|
||||
}
|
||||
]
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
"data: [DONE]",
|
||||
# After [DONE] nothing must be parsed:
|
||||
_sse({"choices": [{"delta": {"content": "should not appear"}, "index": 0}]}),
|
||||
]
|
||||
out = parse_tool_response_streaming(lines)
|
||||
assert out["calls"] == [("get_time", "{}")]
|
||||
assert out["delta_chunks"] == 1
|
||||
assert out["finish_reason"] is None
|
||||
|
||||
|
||||
def test_parse_streaming_multiple_calls_by_index() -> None:
|
||||
lines = [
|
||||
_sse(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 1,
|
||||
"id": "b",
|
||||
"function": {"name": "second", "arguments": "{\"a\": "},
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"id": "a",
|
||||
"function": {"name": "first", "arguments": "{}"},
|
||||
},
|
||||
]
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
_sse(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"tool_calls": [{"index": 1, "function": {"arguments": "1}"}}]
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
]
|
||||
out = parse_tool_response_streaming(lines)
|
||||
assert out["calls"] == [("first", "{}"), ("second", '{"a": 1}')]
|
||||
assert out["delta_chunks"] == 3
|
||||
|
||||
|
||||
def _ns(ok: bool = True) -> dict:
|
||||
return {
|
||||
"finish_reason": "tool_calls" if ok else "stop",
|
||||
"calls": [("get_time", "{}")] if ok else [],
|
||||
}
|
||||
|
||||
|
||||
def _st(**overrides: object) -> dict:
|
||||
result: dict = {
|
||||
"finish_reason": "tool_calls",
|
||||
"calls": [("get_time", "{}")],
|
||||
"delta_chunks": 2,
|
||||
"indexed": True,
|
||||
"had_id": True,
|
||||
"arguments_in_deltas": True,
|
||||
}
|
||||
result.update(overrides)
|
||||
return result
|
||||
|
||||
|
||||
def test_classify_supported() -> None:
|
||||
assert classify_tool_calling(_ns(), _st()) == "supported"
|
||||
|
||||
|
||||
def test_classify_not_supported_variants() -> None:
|
||||
# Non-streaming did not call the tool.
|
||||
assert classify_tool_calling(_ns(False), _st()) == "not-supported"
|
||||
# Streaming did not call the tool.
|
||||
assert classify_tool_calling(_ns(), _st(finish_reason="stop", calls=[])) == "not-supported"
|
||||
# Streamed, but not as delta.tool_calls chunks.
|
||||
assert classify_tool_calling(_ns(), _st(delta_chunks=0)) == "not-supported"
|
||||
# Delta chunks without the OpenAI "index" field.
|
||||
assert classify_tool_calling(_ns(), _st(indexed=False)) == "not-supported"
|
||||
# Delta chunks without a call "id".
|
||||
assert classify_tool_calling(_ns(), _st(had_id=False)) == "not-supported"
|
||||
# Intermittent: non-stream ok, stream answered in content.
|
||||
assert classify_tool_calling(_ns(), _st(finish_reason="stop")) == "not-supported"
|
||||
Reference in New Issue
Block a user