Phase 45 (owner permission 2026-08-27, TODO.md L8: "allow the LLM
to make as many tool calls as it wants"): the phase-37 per-turn tool
budgets (BOR_AGENT_LIST_CALLS / BOR_AGENT_READ_CALLS, default 1 each)
and their exhaustion refusals are removed — a grounded turn now offers
list_documents / read_document for the whole turn (re-lists included),
bounded only by the round cap:
- app/config.py: agent_max_rounds (BOR_AGENT_MAX_ROUNDS, default 10,
negative rejected) replaces agent_list_calls / agent_read_calls;
.env.example + README document the single knob; app/rag/prompts.py
docstrings follow.
- app/rag/agent.py: the loop runs tools until the model answers or
rounds >= max_rounds, at which point it forces one final no-tools
answer (the cap is the only forced exit); 0 = no tools — exactly one
tools=None request, byte-identical to the pre-phase-37 path (the
kill switch). Rejected calls (unknown tool / missing args /
already-in-context / unknown path) still consume a round, so
pathological rejected-call streams are bounded by the cap. The
per-call log line is now tool/args/round=N/M; the per-turn
tool_calls=N field and the tool SSE event are unchanged.
- tests/e2e/mock_llm.py: MULTI_READ_TRIGGER ("read two documents") —
the deterministic list -> read #1 -> read #2 -> forced-answer flow
(byte-stable "I read <sp1> and <sp2>." line), classified by the
count of tool-role read results; the phase-37 single-read flow stays
byte-identical (unit-pinned in tests/unit/test_mock_tool_flow.py).
- tests/e2e/test_agent_unlimited_tools.py (new, story suite,
mock-only): three tool frames/lines in order (one list, two reads —
the second read is what the old read budget refused) + the
both-named non-deflected answer; done.sources + chips = retrieval
doc + both reads, deduped; no budget refusal rendered; the
single-read marker flow regression (exactly one read, single tool
pair).
- .agent/PLAN.md: the phase-45 SSE revision note (owner-locked, R2) —
the only PLAN edit this phase; the phase-37 note's budget clause is
marked removed.
Unit/integration rewrites (test_agent.py round-cap matrix incl. the
kill switch and rejected-call spam, test_config.py, test_chat_api.py
agent_max_rounds=0 fixtures) landed with the server core so every gate
stays green.
uv run pytest: 756 passed, app/ coverage 99%; ruff + pyright clean;
story E2E 4/4 in isolation (ran twice); regression E2E suites
(agent_document_tools unmodified, chat_rag, smoke) green in isolation.
Also records the 45_agent_unlimited_tools todo/ -> complete/ task-file
moves (00/01/02 pending in the working tree, task 03 moves on success).
214 lines
8.2 KiB
Python
214 lines
8.2 KiB
Python
"""Unit tests: settings defaults & env overrides."""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from pydantic import ValidationError
|
||
from pydantic_settings import SettingsError
|
||
|
||
from app.config import Settings
|
||
|
||
|
||
def _settings(**kwargs: Any) -> Settings:
|
||
"""Build Settings without reading a .env file (deterministic tests)."""
|
||
kwargs.setdefault("_env_file", None)
|
||
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
||
|
||
|
||
def test_defaults_match_locked_decisions(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
# The test process sets BOR_RELEVANCE_THRESHOLD=0.30 for the mock-
|
||
# calibrated in-process suites (see tests/conftest.py) — the *default*
|
||
# under test is the production one.
|
||
monkeypatch.delenv("BOR_RELEVANCE_THRESHOLD", raising=False)
|
||
s = _settings()
|
||
assert s.llm_chat_model == "turbo"
|
||
assert s.llm_embed_model == "embed"
|
||
# A5 extended (phase 30): one-shot completions default to the ``lite``
|
||
# model on the same endpoint.
|
||
assert s.llm_summary_model == "lite"
|
||
assert s.embedding_dim == 768
|
||
assert s.llm_base_url.endswith("/v1")
|
||
# A8 (revised): the honesty gate input is the best cosine, default 0.62.
|
||
assert s.relevance_threshold == 0.62
|
||
# A7 (revised): hybrid retrieval — cosine top-N ∪ FTS top-N, RRF-fused.
|
||
assert s.hybrid_vector_candidates >= 1
|
||
assert s.hybrid_lexical_candidates >= 1
|
||
assert s.rrf_k >= 1
|
||
assert s.top_n_docs >= 1
|
||
# Owner instruction 2026-08-22: answers may run up to 32 768 tokens.
|
||
assert s.max_output_tokens == 32_768
|
||
# Phase 17: the model's thinking streams by default (kill-switch off).
|
||
assert s.stream_thinking is True
|
||
assert len(s.suggestions) >= 3
|
||
# A9 (revised): the import scope covers the seven A9 formats.
|
||
assert s.import_extension_set == {
|
||
".md", ".markdown", ".txt", ".yaml", ".yml", ".json", ".py"
|
||
}
|
||
|
||
|
||
def test_env_override(monkeypatch) -> None:
|
||
monkeypatch.setenv("BOR_RELEVANCE_THRESHOLD", "0.42")
|
||
monkeypatch.setenv("BOR_LLM_CHAT_MODEL", "juggernaut")
|
||
s = _settings()
|
||
assert s.relevance_threshold == 0.42
|
||
assert s.llm_chat_model == "juggernaut"
|
||
|
||
|
||
def test_llm_summary_model_env_override(monkeypatch) -> None:
|
||
"""Phase 30: ``BOR_LLM_SUMMARY_MODEL`` overrides the ``lite`` default"""
|
||
monkeypatch.setenv("BOR_LLM_SUMMARY_MODEL", "mini")
|
||
s = _settings()
|
||
assert s.llm_summary_model == "mini"
|
||
|
||
|
||
def test_summary_max_chars_default_and_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Phase 30: document content sent to the ``lite`` model is capped at
|
||
``BOR_SUMMARY_MAX_CHARS`` (default 12 000 chars per call)."""
|
||
monkeypatch.delenv("BOR_SUMMARY_MAX_CHARS", raising=False)
|
||
assert _settings().summary_max_chars == 12_000
|
||
monkeypatch.setenv("BOR_SUMMARY_MAX_CHARS", "5000")
|
||
assert _settings().summary_max_chars == 5000
|
||
|
||
|
||
def test_max_output_tokens_env_override(monkeypatch) -> None:
|
||
monkeypatch.setenv("BOR_MAX_OUTPUT_TOKENS", "1234")
|
||
s = _settings()
|
||
assert s.max_output_tokens == 1234
|
||
|
||
|
||
def test_agent_max_rounds_default_and_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Phase 45: the per-tool budgets are gone — ``BOR_AGENT_MAX_ROUNDS``
|
||
(default 10) is the single agent-loop knob; ``0`` is the no-tools
|
||
kill switch."""
|
||
monkeypatch.delenv("BOR_AGENT_MAX_ROUNDS", raising=False)
|
||
assert _settings().agent_max_rounds == 10
|
||
monkeypatch.setenv("BOR_AGENT_MAX_ROUNDS", "5")
|
||
assert _settings().agent_max_rounds == 5
|
||
monkeypatch.setenv("BOR_AGENT_MAX_ROUNDS", "0")
|
||
assert _settings().agent_max_rounds == 0
|
||
|
||
|
||
def test_agent_max_rounds_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""``0`` is the kill switch — a negative value is a typo, so the
|
||
validator fails loudly at startup."""
|
||
monkeypatch.setenv("BOR_AGENT_MAX_ROUNDS", "-1")
|
||
with pytest.raises(ValidationError, match="agent_max_rounds"):
|
||
_settings()
|
||
|
||
|
||
def test_stream_thinking_default_true_and_env_parse(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Phase 17 kill-switch (``BOR_STREAM_THINKING``): on by default,
|
||
``0``/``false`` turn the ``thinking`` SSE frames off."""
|
||
assert _settings().stream_thinking is True
|
||
assert _settings(stream_thinking=False).stream_thinking is False
|
||
monkeypatch.setenv("BOR_STREAM_THINKING", "0")
|
||
assert _settings().stream_thinking is False
|
||
monkeypatch.setenv("BOR_STREAM_THINKING", "false")
|
||
assert _settings().stream_thinking is False
|
||
monkeypatch.setenv("BOR_STREAM_THINKING", "1")
|
||
assert _settings().stream_thinking is True
|
||
|
||
|
||
def test_import_extensions_env_override_is_a_csv_list(monkeypatch) -> None:
|
||
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,yml")
|
||
s = _settings()
|
||
assert s.import_extension_set == {".md", ".yml"}
|
||
|
||
|
||
def test_import_extensions_rejects_unknown_format(monkeypatch) -> None:
|
||
"""A typo in the CSV fails at startup (loudly), not by silently
|
||
walking zero files."""
|
||
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,docx")
|
||
with pytest.raises(ValidationError, match="docx"):
|
||
_settings()
|
||
|
||
|
||
def test_import_extensions_rejects_empty(monkeypatch) -> None:
|
||
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", " ")
|
||
with pytest.raises(ValidationError):
|
||
_settings()
|
||
|
||
|
||
def test_git_sources_default_empty_and_sources_dir_default() -> None:
|
||
"""Phase 28: no git sources by default (backwards-compatible with the
|
||
``--source`` / ``DEFAULT_SOURCES`` fallback); the clone location stays
|
||
a raw string (``~`` is expanded by the import script, not the setting)."""
|
||
s = _settings()
|
||
assert s.git_sources == ""
|
||
assert s.git_source_list == []
|
||
assert s.sources_dir == "~/bor-sources"
|
||
|
||
|
||
def test_git_sources_env_override_parses_comma_separated_list(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""``BOR_GIT_SOURCES`` is a raw CSV: entries are trimmed and empty
|
||
entries dropped; URLs are stored untouched (no scheme parsing here)."""
|
||
monkeypatch.setenv(
|
||
"BOR_GIT_SOURCES",
|
||
"https://github.com/user/homelab.git, "
|
||
" git@github.com:user/deployments.git ,, https://git.reeseapps.com/x/y.git ",
|
||
)
|
||
s = _settings()
|
||
# The raw CSV string is preserved untouched (no parsing in the setting).
|
||
assert s.git_sources == (
|
||
"https://github.com/user/homelab.git, "
|
||
" git@github.com:user/deployments.git ,, https://git.reeseapps.com/x/y.git "
|
||
)
|
||
assert s.git_source_list == [
|
||
"https://github.com/user/homelab.git",
|
||
"git@github.com:user/deployments.git",
|
||
"https://git.reeseapps.com/x/y.git",
|
||
]
|
||
|
||
|
||
def test_git_sources_whitespace_only_yields_empty_list(monkeypatch) -> None:
|
||
"""A configured-but-blank value behaves the same as unset: no git
|
||
sources, so the script falls back to its legacy local defaults."""
|
||
monkeypatch.setenv("BOR_GIT_SOURCES", " , , ")
|
||
s = _settings()
|
||
assert s.git_source_list == []
|
||
|
||
|
||
def test_sources_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setenv("BOR_SOURCES_DIR", "/data/bor/sources")
|
||
s = _settings()
|
||
assert s.sources_dir == "/data/bor/sources"
|
||
|
||
|
||
def test_suggestions_default_is_three_plus_real_questions() -> None:
|
||
s = _settings()
|
||
assert len(s.suggestions) >= 3
|
||
assert all(isinstance(q, str) and q.strip() for q in s.suggestions)
|
||
# Distinct chips only — duplicates in the onboarding row are noise.
|
||
assert len({q.strip().lower() for q in s.suggestions}) == len(s.suggestions)
|
||
|
||
|
||
def test_suggestions_env_override_is_json_list(monkeypatch) -> None:
|
||
override = [
|
||
"How do I back up with Borg?",
|
||
"How is my K3S cluster set up?",
|
||
"How do I deploy a service?",
|
||
]
|
||
monkeypatch.setenv("BOR_SUGGESTIONS", json.dumps(override))
|
||
s = _settings()
|
||
assert s.suggestions == override
|
||
|
||
|
||
def test_suggestions_malformed_json_fails_loudly(monkeypatch) -> None:
|
||
monkeypatch.setenv("BOR_SUGGESTIONS", "[not valid json")
|
||
with pytest.raises(SettingsError):
|
||
_settings()
|
||
|
||
|
||
def test_effective_api_key_fallback(monkeypatch) -> None:
|
||
monkeypatch.delenv("AIPI_KEY", raising=False)
|
||
s = _settings()
|
||
assert s.effective_api_key == "not-needed"
|
||
|
||
monkeypatch.setenv("AIPI_KEY", "sk-from-env")
|
||
s2 = _settings()
|
||
assert s2.effective_api_key == "sk-from-env"
|