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:
@@ -20,6 +20,7 @@ from app.api import chat as chat_api
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Document, KbOverview, QueryLog
|
||||
from app.rag.agent import AGENT_TOOLS
|
||||
from app.rag.llm import StreamPiece
|
||||
from app.rag.retriever import RetrievedChunk, weak_hit_titles
|
||||
from app.rag.suggestions import MAX_SUGGESTIONS, derive_suggestions
|
||||
@@ -416,19 +417,30 @@ def test_suggestions_empty_input_yields_fallback_only() -> None:
|
||||
|
||||
|
||||
class _CannedLLM:
|
||||
"""Records the messages it is given; streams a canned answer."""
|
||||
"""Records the messages it is given; streams a canned answer.
|
||||
|
||||
Never emits tool calls, so a grounded turn through the phase-37 agent
|
||||
loop ends after the single (tools-offered) request; *seen_tools*
|
||||
records each request's ``tools`` value for the phase-37 wiring pins.
|
||||
"""
|
||||
|
||||
def __init__(self, answer: str = ANSWER) -> None:
|
||||
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
||||
self.embed_batches = 0
|
||||
self.answer = answer
|
||||
self.seen: list[list[dict[str, str]]] = []
|
||||
self.seen_tools: list[list[dict[str, Any]] | None] = []
|
||||
|
||||
async def embed_one(self, _text: str) -> list[float]:
|
||||
return [0.0] * 768
|
||||
|
||||
async def chat_stream(self, messages: list[dict[str, str]]):
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
):
|
||||
self.seen.append(messages)
|
||||
self.seen_tools.append(tools)
|
||||
for i in range(0, len(self.answer), 12):
|
||||
yield StreamPiece("content", self.answer[i : i + 12])
|
||||
|
||||
@@ -545,6 +557,55 @@ def test_endpoint_just_below_threshold_deflects(
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
def test_endpoint_grounded_turn_runs_agent_loop_with_tools(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 37: a grounded endpoint turn runs the agent loop — the
|
||||
single no-tool-call request carries ``AGENT_TOOLS`` (default 1/1
|
||||
budgets), no ``tool`` frames stream, and the ``done`` event is the
|
||||
plain retrieval shape (the tool-free answer is byte-identical)."""
|
||||
_session, llm = gate_env
|
||||
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
|
||||
|
||||
frames = _ask(client, "How is my Kubernetes cluster set up?")
|
||||
|
||||
assert frames[-1]["type"] == "done"
|
||||
assert frames[-1]["deflected"] is False
|
||||
assert not any(f["type"] == "tool" for f in frames)
|
||||
assert len(llm.seen) == 1
|
||||
assert llm.seen_tools == [AGENT_TOOLS] # one request, tools offered
|
||||
# The system prompt is the HIGH prompt with the <tools> instructions.
|
||||
(system, _user) = llm.seen[0][0], llm.seen[0][1]
|
||||
assert "<relevance>HIGH</relevance>" in system["content"]
|
||||
assert "<tools>" in system["content"]
|
||||
|
||||
|
||||
def test_endpoint_deflected_turn_never_offers_tools(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 37: a deflected endpoint turn keeps the direct
|
||||
``chat_stream`` — the single request carries no ``tools`` key
|
||||
(``seen_tools == [None]``), A8 byte-identical."""
|
||||
_session, llm = gate_env
|
||||
doc = _doc("Deploying a New Service", "DOC_CONTENT_NEVER_SENT")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.2999)]))
|
||||
|
||||
frames = _ask(client, "How do I bake sourdough bread?")
|
||||
|
||||
assert frames[-1]["type"] == "done"
|
||||
assert frames[-1]["deflected"] is True
|
||||
assert not any(f["type"] == "tool" for f in frames)
|
||||
assert len(llm.seen) == 1
|
||||
assert llm.seen_tools == [None]
|
||||
(system, _user) = llm.seen[0][0], llm.seen[0][1]
|
||||
assert "<tools>" not in system["content"] # the LOW prompt never carries it
|
||||
|
||||
|
||||
def test_endpoint_score_at_threshold_answers(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
|
||||
Reference in New Issue
Block a user