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
+519
View File
@@ -0,0 +1,519 @@
"""Unit: the grounded-turn agent loop (phase 37, ``app.rag.agent``).
A scripted fake LLM (canned stream sequences) + monkeypatched
``list_catalog`` / ``find_document`` — no database, no network. Covers
the loop mechanics: the list → read → answer happy path (event order,
holder state, the ``tools=None`` request after the budgets are spent,
the assistant/tool message history), the 0/0 single-call path, budget
exhaustion, dedupe, unknown tool / missing args / unknown path, the
round cap, and the ``<tools>`` prompt section (HIGH only).
"""
from __future__ import annotations
import asyncio
import json
import uuid
from collections.abc import AsyncIterator
from copy import deepcopy
from typing import Any, cast
import pytest
from sqlalchemy.orm import Session
from app.config import Settings
from app.models import Document
from app.rag import agent
from app.rag.agent import (
AGENT_TOOLS,
AgentHolder,
run_agent,
)
from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece
from app.rag.prompts import TOOLS_SECTION, _base, build_deflect_prompt, build_high_prompt
def _settings(**kwargs: Any) -> Settings:
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
def _doc(source: str, path: str, title: str = "Title", content: str = "CONTENT") -> Document:
return Document(
id=uuid.uuid4(),
source=source,
path=path,
full_path=f"/tmp/{path}",
title=title,
content=content,
content_hash="0" * 64,
)
class ScriptedLLM:
"""Canned stream sequences; records every ``chat_stream`` request so
the tests can assert on the messages and the ``tools`` passthrough."""
def __init__(self, *streams: list[StreamPiece | ToolCallPiece]) -> None:
self.streams: list[list[StreamPiece | ToolCallPiece]] = list(streams)
self.requests: list[tuple[list[dict[str, Any]], list[dict[str, Any]] | None]] = []
async def chat_stream(
self,
messages: list[dict[str, str]],
tools: list[dict[str, Any]] | None = None,
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
self.requests.append((deepcopy(messages), tools))
if not self.streams:
raise AssertionError("ScriptedLLM ran out of canned streams")
for piece in self.streams.pop(0):
yield piece
async def _run(
llm: ScriptedLLM,
holder: AgentHolder,
settings: Settings,
seed_docs: list[Document] | None = None,
) -> list[StreamPiece | ToolCallPiece]:
out: list[StreamPiece | ToolCallPiece] = []
async for piece in run_agent(
cast("LLMClient", llm),
cast("Session", None),
system_prompt="SYSTEM_PROMPT",
user_message="QUESTION",
seed_docs=seed_docs or [],
settings=settings,
holder=holder,
):
out.append(piece)
return out
# ---------- AGENT_TOOLS shape ----------
def test_agent_tools_names_and_parameters() -> None:
by_name = {t["function"]["name"]: t for t in AGENT_TOOLS}
assert set(by_name) == {"list_documents", "read_document"}
assert all(t["type"] == "function" for t in AGENT_TOOLS)
list_params = by_name["list_documents"]["function"]["parameters"]
assert list_params["type"] == "object"
assert list_params["properties"] == {} # no parameters
read_params = by_name["read_document"]["function"]["parameters"]
assert read_params["required"] == ["source", "path"]
assert set(read_params["properties"]) == {"source", "path"}
# ---------- happy path: list → read → answer ----------
def test_list_then_read_then_answer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
catalog = [
("Deployments", "backups.md", "Backup Strategy"),
("Homelab", "aws-route53.md", "AWS Route53 Records"),
]
monkeypatch.setattr(agent, "list_catalog", lambda db: catalog)
target = _doc("Homelab", "aws-route53.md", "AWS Route53 Records", "R53-CONTENT")
monkeypatch.setattr(agent, "find_document", lambda db, source, path: target)
seed = [_doc("Homelab", "kubernetes.md", "Kubernetes", "K8S-CONTENT")]
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="list_documents", arguments={})],
[
ToolCallPiece(
id="call_2",
name="read_document",
arguments={"source": "Homelab", "path": "aws-route53.md"},
)
],
[StreamPiece("thinking", "hmm "), StreamPiece("content", "Done! ")],
)
pieces = asyncio.run(_run(llm, holder, _settings(), seed_docs=seed))
# Event order: tool pieces before the answer content/thinking.
assert [type(p) for p in pieces] == [
ToolCallPiece,
ToolCallPiece,
StreamPiece,
StreamPiece,
]
assert pieces[0] == ToolCallPiece(id="call_1", name="list_documents", arguments={})
assert isinstance(pieces[1], ToolCallPiece)
assert pieces[1].name == "read_document"
assert pieces[3] == StreamPiece("content", "Done! ")
# The read document is recorded for done.sources / query_log (task 04).
assert holder.read_docs == [target]
assert holder.tool_calls == 2
# Default budgets (1/1): tools offered while any budget remains…
assert llm.requests[0][1] == AGENT_TOOLS
assert llm.requests[1][1] == AGENT_TOOLS
# …and dropped (tools=None) once both are spent.
assert llm.requests[2][1] is None
assert len(llm.requests) == 3
# The follow-up request carries the assistant tool-call + tool result.
msgs = llm.requests[1][0]
assert msgs[0] == {"role": "system", "content": "SYSTEM_PROMPT"}
assert msgs[1] == {"role": "user", "content": "QUESTION"}
assert msgs[2] == {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "list_documents", "arguments": "{}"},
}
],
}
assert msgs[3] == {
"role": "tool",
"tool_call_id": "call_1",
"content": (
"2 documents:\n"
"Deployments/backups.md — Backup Strategy\n"
"Homelab/aws-route53.md — AWS Route53 Records"
),
}
# The second follow-up request carries the read call + the FULL text.
msgs = llm.requests[2][0]
assert msgs[4]["role"] == "assistant"
assert msgs[4]["tool_calls"][0]["id"] == "call_2"
assert json.loads(msgs[4]["tool_calls"][0]["function"]["arguments"]) == {
"source": "Homelab",
"path": "aws-route53.md",
}
assert msgs[5] == {
"role": "tool",
"tool_call_id": "call_2",
"content": "Document Homelab/aws-route53.md:\nR53-CONTENT", # full text, no cap
}
def test_empty_catalog_listing_says_zero_documents(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="list_documents", arguments={})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[1][0][3]["content"] == "0 documents:\n"
assert holder.tool_calls == 1
def test_content_and_tool_call_in_one_stream_keeps_both(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Rare stream with content AND a tool call: the content stays (it was
already emitted) and the tool still runs."""
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
holder = AgentHolder()
llm = ScriptedLLM(
[
StreamPiece("content", "Let me check "),
ToolCallPiece(id="call_1", name="list_documents", arguments={}),
],
[StreamPiece("content", "the answer")],
)
pieces = asyncio.run(_run(llm, holder, _settings()))
assert [type(p) for p in pieces] == [StreamPiece, ToolCallPiece, StreamPiece]
assert holder.tool_calls == 1 # the tool ran despite the content
assert llm.requests[1][0][3]["content"] == "0 documents:\n"
# ---------- budgets ----------
def test_zero_budgets_is_one_request_without_tools() -> None:
"""BOR_AGENT_LIST_CALLS=0 BOR_AGENT_READ_CALLS=0 → byte-identical
single-call path: exactly one request, tools=None, no history growth."""
holder = AgentHolder()
llm = ScriptedLLM([StreamPiece("thinking", "t "), StreamPiece("content", "direct answer")])
pieces = asyncio.run(
_run(llm, holder, _settings(agent_list_calls=0, agent_read_calls=0))
)
assert [type(p) for p in pieces] == [StreamPiece, StreamPiece]
assert len(llm.requests) == 1
assert llm.requests[0][1] is None
assert llm.requests[0][0] == [
{"role": "system", "content": "SYSTEM_PROMPT"},
{"role": "user", "content": "QUESTION"},
]
assert holder.read_docs == [] and holder.tool_calls == 0
def test_read_budget_exhausted_refuses_and_appends_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
a = _doc("S", "a.md", "A", "A-CONTENT")
monkeypatch.setattr(
agent, "find_document", lambda db, source, path: a if path == "a.md" else None
)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="call_1", name="read_document", arguments={"source": "S", "path": "a.md"}
)
],
[
ToolCallPiece(
id="call_2", name="read_document", arguments={"source": "S", "path": "b.md"}
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings(agent_list_calls=1, agent_read_calls=1)))
assert holder.read_docs == [a] # the refused read appended nothing
assert holder.tool_calls == 1 # …and consumed no budget
refusal = llm.requests[2][0][5]
assert refusal == {
"role": "tool",
"tool_call_id": "call_2",
"content": agent.READ_EXHAUSTED,
}
# The list budget is still open, so tools stay offered after the refusal.
assert llm.requests[2][1] == AGENT_TOOLS
def test_list_budget_exhausted_refuses_with_its_own_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="list_documents", arguments={})],
[ToolCallPiece(id="call_2", name="list_documents", arguments={})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings(agent_list_calls=1, agent_read_calls=1)))
assert holder.tool_calls == 1
assert llm.requests[2][0][5]["content"] == agent.LIST_EXHAUSTED
# The read budget is still open, so tools stay offered after the refusal.
assert llm.requests[2][1] == AGENT_TOOLS
# ---------- rejections (no budget consumed) ----------
def test_reading_a_seed_doc_is_already_in_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seed = [_doc("Homelab", "kubernetes.md", "Kubernetes", "K8S-CONTENT")]
def _boom(*_a: Any, **_k: Any) -> None:
raise AssertionError("find_document must not be called for a seeded doc")
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
monkeypatch.setattr(agent, "find_document", _boom)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="call_1",
name="read_document",
arguments={"source": "Homelab", "path": "kubernetes.md"},
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings(), seed_docs=seed))
assert holder.read_docs == [] and holder.tool_calls == 0
assert llm.requests[1][0][3]["content"] == agent.ALREADY_IN_CONTEXT
# No budget consumed → tools are still offered on the next request.
assert llm.requests[1][1] == AGENT_TOOLS
def test_reading_an_already_read_doc_is_deduped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
doc = _doc("S", "a.md", "A", "A-CONTENT")
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="call_1", name="read_document", arguments={"source": "S", "path": "a.md"}
)
],
[
ToolCallPiece(
id="call_2", name="read_document", arguments={"source": "S", "path": "a.md"}
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings(agent_list_calls=1, agent_read_calls=1)))
assert holder.read_docs == [doc] # appended exactly once
assert holder.tool_calls == 1
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
# The read budget is intact after the deduped refusal…
assert llm.requests[2][1] == AGENT_TOOLS
def test_unknown_path_refused_without_budget(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
holder = AgentHolder()
llm = ScriptedLLM(
[
ToolCallPiece(
id="call_1",
name="read_document",
arguments={"source": "S", "path": "ghost.md"},
)
],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert holder.read_docs == [] and holder.tool_calls == 0
assert (
llm.requests[1][0][3]["content"]
== "No document at S/ghost.md — check the list_documents output."
)
assert llm.requests[1][1] == AGENT_TOOLS # budget intact
def test_unknown_tool_name_refused(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="delete_universe", arguments={"x": 1})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert holder.read_docs == [] and holder.tool_calls == 0
assert llm.requests[1][0][3]["content"] == agent.UNKNOWN_TOOL
assert llm.requests[1][1] == AGENT_TOOLS # nothing was consumed
@pytest.mark.parametrize(
("arguments", "label"),
[
({}, "no arguments"),
({"source": "S"}, "path missing"),
({"path": "p.md"}, "source missing"),
({"source": "", "path": "p.md"}, "empty source"),
({"source": "S", "path": " "}, "blank path"),
({"source": 7, "path": "p.md"}, "non-string source"),
],
)
def test_read_document_missing_arguments_refused(
monkeypatch: pytest.MonkeyPatch, arguments: dict[str, Any], label: str
) -> None:
def _boom(*_a: Any, **_k: Any) -> None:
raise AssertionError(f"find_document must not be called ({label})")
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
monkeypatch.setattr(agent, "find_document", _boom)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="read_document", arguments=arguments)],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert holder.read_docs == [] and holder.tool_calls == 0
assert llm.requests[1][0][3]["content"] == agent.MISSING_READ_ARGS
assert llm.requests[1][1] == AGENT_TOOLS
# ---------- round cap (pathological stream) ----------
def test_round_cap_forces_a_final_no_tools_answer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A model that keeps calling a budget-exhausted tool must be forced
to answer at ``max_rounds = 2 + list + read`` (= 4 for 1/1)."""
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="list_documents", arguments={})],
[ToolCallPiece(id="call_2", name="list_documents", arguments={})],
[ToolCallPiece(id="call_3", name="list_documents", arguments={})],
[ToolCallPiece(id="call_4", name="list_documents", arguments={})],
[StreamPiece("content", "forced answer")],
)
pieces = asyncio.run(_run(llm, holder, _settings(agent_list_calls=1, agent_read_calls=1)))
assert [type(p) for p in pieces] == [
ToolCallPiece,
ToolCallPiece,
ToolCallPiece,
ToolCallPiece,
StreamPiece,
]
assert len(llm.requests) == 5
# The forced final request carries no tools, whatever is left.
assert llm.requests[4][1] is None
# Only the first call consumed budget; the three rejections did not.
assert holder.tool_calls == 1
# The 4th rejection sits at messages[2 + 4*2 - 1] of the final request.
assert llm.requests[4][0][9]["content"] == agent.LIST_EXHAUSTED
# ---------- settings ----------
def test_agent_budget_settings_default_to_one_each() -> None:
s = _settings()
assert s.agent_list_calls == 1
assert s.agent_read_calls == 1
def test_agent_budget_settings_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BOR_AGENT_LIST_CALLS", "0")
monkeypatch.setenv("BOR_AGENT_READ_CALLS", "2")
s = _settings()
assert s.agent_list_calls == 0
assert s.agent_read_calls == 2
# ---------- prompts: <tools> section (HIGH only) ----------
def test_high_prompt_carries_tools_section_after_documents() -> None:
prompt = build_high_prompt([_doc("S", "a.md", "A", "A-CONTENT")])
assert TOOLS_SECTION in prompt
assert "call `list_documents`" in prompt
assert "then `read_document` to pull in exactly one more document" in prompt
assert "do not read more than one extra document" in prompt
# After the mode body: <tools> follows </documents>.
assert prompt.index("</documents>") < prompt.index("<tools>")
assert prompt.rstrip().endswith("</tools>")
def test_high_prompt_tools_section_with_notes_and_kb() -> None:
prompt = build_high_prompt(
[_doc("S", "a.md", "A", "A-CONTENT")], notes=["be concise"], kb_overview="- KB"
)
assert prompt.index("<knowledge_base>") < prompt.index("<tuning>")
assert prompt.index("<tuning>") < prompt.index("<documents>")
assert prompt.index("<documents>") < prompt.index("<tools>")
def test_low_prompt_is_byte_identical_and_tool_free() -> None:
expected = (
_base("LOW")
+ "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest "
"your notes come to the question. They are titles only; do not pretend "
"they answer it. Use them to propose 2-3 alternative questions.\n"
+ "- T1\n- T2"
)
assert build_deflect_prompt(["T1", "T2"]) == expected
for prompt in (
build_deflect_prompt(["T1"]),
build_deflect_prompt(["T1"], notes=["be concise"]),
build_deflect_prompt(["T1"], kb_overview="- KB"),
build_deflect_prompt(["T1"], notes=["be concise"], kb_overview="- KB"),
):
assert "<tools>" not in prompt
assert TOOLS_SECTION not in prompt
+63 -2
View File
@@ -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],
+4 -1
View File
@@ -114,7 +114,10 @@ def test_save_points_user_on_send_and_brain_on_done() -> None:
# in the same meta object).
done_idx = js.find('ev.type === "done"')
assert done_idx != -1
done_block = js[done_idx : done_idx + 1300]
# Window: the whole done branch (up to the error branch) — the meta
# object legitimately grows with phases (phase 17: thinking, phase
# 37: tools), so a fixed char offset would false-fail.
done_block = js[done_idx : js.find('ev.type === "error"')]
assert "rememberBrainTurn(finalText || acc" in done_block
assert "thinking: thinkingAcc || undefined" in done_block
assert "deflected: !!ev.deflected" in done_block
+201
View File
@@ -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
+266 -7
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
from typing import Any
from typing import Any, cast
import pytest
@@ -22,6 +22,7 @@ from app.rag.llm import (
LLMClient,
LLMError,
StreamPiece,
ToolCallPiece,
)
@@ -242,21 +243,47 @@ def test_single_oversized_text_fails_actionably() -> None:
# ---------- chat streaming (phase 03) ----------
def _tool_call(
index: int,
id: str | None = None,
name: str | None = None,
arguments: str | None = None,
):
"""One fake ``delta.tool_calls[]`` partial (openai SDK shape, phase 37).
``function`` is None when neither *name* nor *arguments* is given —
mirroring the real wire, where id-only fragments carry no function.
"""
fn = None
if name is not None or arguments is not None:
fn = SimpleNamespace(name=name, arguments=arguments)
return SimpleNamespace(index=index, id=id, function=fn)
def _chunk(
content: str | None = "text", empty: bool = False, reasoning: str | None = None
content: str | None = "text",
empty: bool = False,
reasoning: str | None = None,
tool_calls: list | None = None,
finish_reason: str | None = None,
):
"""One fake ChatCompletionChunk (``choices[].delta`` shape).
``reasoning_content`` is present on the delta only when *reasoning*
is not None — mirroring the real wire, where the field exists only
when the model sends it.
``reasoning_content``, ``tool_calls`` and ``finish_reason`` are
present only when provided — mirroring the real wire, where the
fields exist only when the model sends them.
"""
if empty:
return SimpleNamespace(choices=[])
delta: SimpleNamespace = SimpleNamespace(content=content)
if reasoning is not None:
delta.reasoning_content = reasoning
return SimpleNamespace(choices=[SimpleNamespace(delta=delta)])
if tool_calls is not None:
delta.tool_calls = tool_calls
choice = SimpleNamespace(delta=delta)
if finish_reason is not None:
choice.finish_reason = finish_reason
return SimpleNamespace(choices=[choice])
class _FakeChatStream:
@@ -326,7 +353,11 @@ def _make_stream_client(
async def _collect(llm: LLMClient, messages: list[dict[str, str]]) -> list[StreamPiece]:
return [p async for p in llm.chat_stream(messages)]
"""Collect pieces from a tools-less stream (phase 37 task 02, test (a):
without tools, no ToolCallPiece can appear)."""
pieces = [p async for p in llm.chat_stream(messages)]
assert all(isinstance(p, StreamPiece) for p in pieces)
return cast("list[StreamPiece]", pieces)
def test_chat_stream_yields_deltas_in_order() -> None:
@@ -355,6 +386,9 @@ def test_chat_stream_uses_locked_generation_params() -> None:
# BOR_MAX_OUTPUT_TOKENS (default 32 768) so they are not cut off.
assert completions.kwargs["max_tokens"] == 32_768
assert completions.kwargs["messages"] == messages
# Phase 37: no tools passed ⇒ no `tools` key at all (byte-identical
# request to pre-phase-37).
assert "tools" not in completions.kwargs
def test_chat_stream_max_tokens_comes_from_settings() -> None:
@@ -455,6 +489,231 @@ def test_chat_stream_llm_error_passes_through_unwrapped() -> None:
asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
# ---------- tool-call streaming (phase 37, task 02) ----------
#: The agent's tool list (phase 37) — the exact wire shape AGENT_TOOLS will
#: pass through (the names are whatever the caller's tools list names).
_AGENT_TOOLS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "list_documents",
"description": "List the indexed documents.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "read_document",
"description": "Add one indexed document's full text to the context.",
"parameters": {
"type": "object",
"properties": {
"source": {"type": "string"},
"path": {"type": "string"},
},
"required": ["source", "path"],
},
},
},
]
def _collect_with_tools(
llm: LLMClient, messages: list[dict[str, str]], tools: list[dict[str, Any]]
) -> list[StreamPiece | ToolCallPiece]:
async def run() -> list[StreamPiece | ToolCallPiece]:
return [p async for p in llm.chat_stream(messages, tools=tools)]
return asyncio.run(run())
def test_chat_stream_passes_tools_when_given() -> None:
"""(e) A non-None tools list is forwarded verbatim to create()."""
llm, completions = _make_stream_client([_chunk("ok")], llm_chat_model="turbo")
_collect_with_tools(llm, [{"role": "user", "content": "q"}], _AGENT_TOOLS)
assert completions.kwargs is not None
assert completions.kwargs["tools"] == _AGENT_TOOLS
def test_chat_stream_accumulates_tool_call_across_chunk_partials() -> None:
"""(b) name on the first partial, arguments in fragments — merged into
one ToolCallPiece with the concatenated JSON, at finish_reason."""
llm, _ = _make_stream_client(
[
_chunk(
None,
tool_calls=[
_tool_call(
0,
id="call_abc",
name="read_document",
arguments='{"source": "Homelab", "pa',
)
],
),
_chunk(None, tool_calls=[_tool_call(0, arguments='th": "kubernetes.md"}')]),
_chunk(None, finish_reason="tool_calls"),
]
)
pieces = _collect_with_tools(
llm, [{"role": "user", "content": "q"}], _AGENT_TOOLS
)
assert pieces == [
ToolCallPiece(
id="call_abc",
name="read_document",
arguments={"source": "Homelab", "path": "kubernetes.md"},
)
]
def test_chat_stream_two_tool_calls_yielded_in_index_order() -> None:
"""(c) Indices 0 and 1, interleaved partials (index 1 seen first) —
both calls, in index order, each merged from its own fragments."""
llm, _ = _make_stream_client(
[
_chunk(
None,
tool_calls=[
_tool_call(1, id="call_b", name="read_document", arguments='{"sou')
],
),
_chunk(
None,
tool_calls=[
_tool_call(0, id="call_a", name="list_documents"),
_tool_call(1, arguments='rce": "Homelab", "path": "a.md"}')
],
),
_chunk(None, finish_reason="tool_calls"),
]
)
pieces = _collect_with_tools(
llm, [{"role": "user", "content": "q"}], _AGENT_TOOLS
)
assert pieces == [
ToolCallPiece(id="call_a", name="list_documents", arguments={}),
ToolCallPiece(
id="call_b",
name="read_document",
arguments={"source": "Homelab", "path": "a.md"},
),
]
def test_chat_stream_tool_calls_yielded_at_stream_end_without_finish_reason() -> None:
"""The spec's other emission point: stream ends without a
finish_reason="tool_calls" chunk — pieces still materialize."""
llm, _ = _make_stream_client(
[
_chunk(
None,
tool_calls=[_tool_call(0, id="call_z", name="list_documents")],
)
]
)
pieces = _collect_with_tools(
llm, [{"role": "user", "content": "q"}], _AGENT_TOOLS
)
assert pieces == [ToolCallPiece(id="call_z", name="list_documents", arguments={})]
def test_chat_stream_synthesizes_call_id_when_absent() -> None:
"""Wire never carried the call id ⇒ synthesized "call_<index>"."""
llm, _ = _make_stream_client(
[
_chunk(None, tool_calls=[_tool_call(2, name="read_document", arguments="{}")]),
_chunk(None, finish_reason="tool_calls"),
]
)
pieces = _collect_with_tools(
llm, [{"role": "user", "content": "q"}], _AGENT_TOOLS
)
assert pieces == [
ToolCallPiece(
id="call_2",
name="read_document",
arguments={},
)
]
def test_chat_stream_null_arguments_become_empty_dict() -> None:
"""JSON "null" (and, by the same branch, absent arguments) ⇒ {}."""
llm, _ = _make_stream_client(
[
_chunk(
None,
tool_calls=[
_tool_call(0, id="call_n", name="list_documents", arguments="null")
],
),
_chunk(None, finish_reason="tool_calls"),
]
)
pieces = _collect_with_tools(
llm, [{"role": "user", "content": "q"}], _AGENT_TOOLS
)
assert pieces == [ToolCallPiece(id="call_n", name="list_documents", arguments={})]
def test_chat_stream_malformed_tool_arguments_raise_llm_error() -> None:
"""(d) A silently dropped tool call would corrupt the loop — malformed
arguments JSON must fail loudly."""
llm, _ = _make_stream_client(
[
_chunk(
None,
tool_calls=[
_tool_call(
0,
id="call_x",
name="read_document",
arguments='{"source": "Homelab",',
)
],
),
_chunk(None, finish_reason="tool_calls"),
]
)
async def drain() -> None:
async for _ in llm.chat_stream(
[{"role": "user", "content": "q"}], tools=_AGENT_TOOLS
):
pass
with pytest.raises(LLMError, match="malformed tool-call arguments"):
asyncio.run(drain())
def test_chat_stream_non_object_tool_arguments_raise_llm_error() -> None:
"""The OpenAI contract says arguments is a JSON *object* — a bare array
is malformed too."""
llm, _ = _make_stream_client(
[
_chunk(
None,
tool_calls=[
_tool_call(0, id="call_y", name="read_document", arguments='[1, 2]')
],
),
_chunk(None, finish_reason="tool_calls"),
]
)
async def drain() -> None:
async for _ in llm.chat_stream(
[{"role": "user", "content": "q"}], tools=_AGENT_TOOLS
):
pass
with pytest.raises(LLMError, match="non-object tool-call arguments"):
asyncio.run(drain())
# ---------- one-shot chat: LLMClient.chat (phase 30, task 01) ----------
+240
View File
@@ -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"
+11 -4
View File
@@ -16,6 +16,7 @@ from app.config import Settings
from app.models import Document
from app.rag.prompts import (
PERSONA,
TOOLS_SECTION,
_base,
build_deflect_prompt,
build_high_prompt,
@@ -116,14 +117,18 @@ def test_low_prompt_with_no_titles() -> None:
def test_zero_note_prompt_is_byte_identical_to_pre_steering() -> None:
"""Phase 15 contract: with no steering notes the prompt is exactly what
it was before the <tuning> section existed."""
it was before the <tuning> section existed. (Phase 37: the HIGH prompt
additionally carries the ``<tools>`` section after the mode body — the
fixtures account for it; the LOW prompt is untouched.)"""
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
block = (
'<document source="Homelab" path="kubernetes.md" title="Kubernetes Homelab Cluster">\n'
"Talos Linux on three nodes.\n"
"</document>"
)
assert build_high_prompt([doc]) == _base("HIGH") + "\n<documents>\n" + block + "\n</documents>"
assert build_high_prompt([doc]) == (
_base("HIGH") + "\n<documents>\n" + block + "\n</documents>" + "\n" + TOOLS_SECTION
)
assert build_deflect_prompt(["T1", "T2"]) == (
_base("LOW")
+ "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest "
@@ -220,14 +225,16 @@ def test_kb_section_tiny_budget_never_exceeds_cap() -> None:
def test_no_overview_prompt_is_byte_identical_to_pre_phase() -> None:
"""Phase 31 contract: with no KB overview (None, empty, or blank)
every prompt is exactly what it was before the ``<knowledge_base>``
section existed — with or without steering notes."""
section existed — with or without steering notes. (Phase 37: the HIGH
prompt additionally carries the ``<tools>`` section after the mode
body — the fixtures account for it; the LOW prompt is untouched.)"""
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
block = (
'<document source="Homelab" path="kubernetes.md" title="Kubernetes Homelab Cluster">\n'
"Talos Linux on three nodes.\n"
"</document>"
)
docs_block = "\n<documents>\n" + block + "\n</documents>"
docs_block = "\n<documents>\n" + block + "\n</documents>" + "\n" + TOOLS_SECTION
high_plain = _base("HIGH") + docs_block
high_steered = _base("HIGH") + "\n" + build_steering_section(["be concise"]) + docs_block
low_plain = (
+25 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import json
from app.api.chat import sse_event
from app.schemas import ChatErrorEvent, ChatThinkingEvent
from app.schemas import ChatErrorEvent, ChatThinkingEvent, ChatToolEvent
def _payload(frame: str) -> dict:
@@ -76,3 +76,27 @@ def test_thinking_event_shape_is_type_and_text_only() -> None:
dumped = ChatThinkingEvent(text="hmm").model_dump()
assert set(dumped.keys()) == {"type", "text"}
assert dumped["type"] == "thinking" # default — call sites never spell it out
def test_tool_frame_serializes_exactly() -> None:
"""Phase 37 (PLAN §4 extension): the ``tool`` frame is exactly
``{type: "tool", name: str, argument: str | null}`` — one per
model-requested document tool call, streamed ahead of the ``delta``
frames of the answer."""
frame = sse_event(ChatToolEvent(name="read_document", argument="S/p.md").model_dump())
assert frame == 'data: {"type": "tool", "name": "read_document", "argument": "S/p.md"}\n\n'
assert _payload(frame) == {"type": "tool", "name": "read_document", "argument": "S/p.md"}
def test_tool_frame_argument_is_null_for_parameterless_tools() -> None:
"""``list_documents`` takes no parameters, so its frame's ``argument``
serializes as JSON null (the client renders the name alone)."""
dumped = ChatToolEvent(name="list_documents").model_dump()
assert dumped == {"type": "tool", "name": "list_documents", "argument": None}
assert _payload(sse_event(dumped))["argument"] is None
def test_tool_event_shape_is_type_name_argument_only() -> None:
dumped = ChatToolEvent(name="read_document", argument="S/p.md").model_dump()
assert set(dumped.keys()) == {"type", "name", "argument"}
assert dumped["type"] == "tool" # default — call sites never spell it out