feat(backend): T-4 — named sessions through the boilerroom wrapper

The flavor probe gains a third answer: the wrapper names itself on
/health, so behind OLLAMA_HOST tatlock now distinguishes boilerroom,
a bare llama-server, and Ollama. Through the wrapper each pipeline
phase is a named session with the decided eviction ranking —
tatlock-steward/-orchestrate/-synthesize at 40, librarian at 30,
lower parks sooner (webber will sit at 20; Open WebUI stays
session-less and can never evict anyone). Against a bare llama-server
the raw id_slot pins survive unchanged, Ollama gets neither, and an
unprobed flavor sends nothing rather than guessing — the backend
stays swappable by env alone. tool_choice through the wrapper follows
the llama-server rule, since that is who answers.

The wrapper's balancing and compaction-due signals are read
everywhere: an httpx response hook on the provider covers every
PydanticAI call, streams included, and the steward's raw call reads
the body extras. Acting on compaction_due is a future ticket — the
signal just must not pass silently.

Verified end to end against the live wrapper: the dev server probed
flavor=boilerroom, a full pipeline turn answered in 5.7 s, and
GET /sessions showed all three phase sessions resident at rank 40
with engine-reported occupancies. The demonstration also filled the
production slot map — session-less prod delegations would have 503d
— cleared by a wrapper restart and filed as boilerroom T-11 (sessions
need an exit). A latent test flaw surfaced too: the ollama
tool_choice test relied on the dev backend probing as ollama; it now
pins the flavor it claims to test.

27 selector tests (9 new), 677 total green; three mutations shown to
fail their tests (librarian rank, the wrapper branch, the no-nudge
set); the new wrapper contract class runs 10/10 against the live
boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 16:13:29 +02:00
co-authored by Claude Fable 5
parent a5681e9511
commit 5b141669ce
11 changed files with 297 additions and 42 deletions
+80 -14
View File
@@ -67,7 +67,10 @@ class TestGetModel:
class TestToolChoiceSettings:
def test_ollama_forces_tool_choice(self, local_first):
def test_ollama_forces_tool_choice(self, local_first, monkeypatch):
# Pin the flavor: the session-scoped app init probes the real
# dev backend, and this test's answer must not depend on it.
monkeypatch.setattr(model_selector, "_local_flavor", "ollama")
settings = model_selector.get_tool_choice_settings()
assert settings.get("extra_body") == {"tool_choice": "required"}
@@ -93,33 +96,89 @@ class TestGetModelInfo:
assert info["model"] == config.ANTHROPIC_MODEL
class TestWithSlotPinning:
class TestPhaseBinding:
def test_disabled_returns_settings_unchanged(self, local_first, monkeypatch):
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", False)
base = model_selector.get_tool_choice_settings()
assert model_selector.with_slot_pinning(base, slot=1) is base
assert model_selector.with_slot_pinning(None, slot=2) is None
assert model_selector.with_phase_binding(base, "orchestrate") is base
assert model_selector.with_phase_binding(None, "synthesize") is None
def test_enabled_merges_slot_with_existing_extra_body(self, local_first, monkeypatch):
def test_wrapper_flavor_sends_session_and_rank(self, local_first, monkeypatch):
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
settings = model_selector.with_slot_pinning(
model_selector.get_tool_choice_settings(), slot=1
monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom")
settings = model_selector.with_phase_binding(
model_selector.get_tool_choice_settings(), "orchestrate"
)
extra_body = settings["extra_body"]
assert extra_body["id_slot"] == 1
# tool_choice from the base settings survives the merge
assert extra_body["tool_choice"] == "required"
assert extra_body["session"] == "tatlock-orchestrate"
assert extra_body["eviction_order"] == 40
assert "id_slot" not in extra_body
def test_enabled_pins_bare_settings(self, local_first, monkeypatch):
def test_wrapper_ranking_matches_the_decision(self, local_first, monkeypatch):
# Decided 2026-09-12: pipeline over librarian over webber (20,
# filed in its own repo); lower eviction_order parks sooner.
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
settings = model_selector.with_slot_pinning(None, slot=2)
monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom")
ranks = {
phase: model_selector.phase_extra_body(phase)["eviction_order"]
for phase in ("steward", "orchestrate", "synthesize", "librarian")
}
assert ranks["steward"] == ranks["orchestrate"] == ranks["synthesize"] == 40
assert ranks["librarian"] == 30
assert ranks["librarian"] < ranks["steward"]
assert ranks["librarian"] > 20 # webber's rank stays below
def test_wrapper_session_names_are_stable(self, local_first, monkeypatch):
# The wrapper's map keys on these; renaming one orphans its slot.
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom")
names = {
phase: model_selector.phase_extra_body(phase)["session"]
for phase in ("steward", "orchestrate", "synthesize", "librarian")
}
assert names == {
"steward": "tatlock-steward",
"orchestrate": "tatlock-orchestrate",
"synthesize": "tatlock-synthesize",
"librarian": "librarian",
}
def test_direct_llama_server_keeps_id_slot(self, local_first, monkeypatch):
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
monkeypatch.setattr(model_selector, "_local_flavor", "llama-server")
settings = model_selector.with_phase_binding(None, "synthesize")
assert settings["extra_body"] == {"id_slot": 2}
def test_claude_backend_never_pinned(self, local_first, monkeypatch):
def test_librarian_floats_on_direct_llama_server(self, local_first, monkeypatch):
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
monkeypatch.setattr(model_selector, "_local_flavor", "llama-server")
assert model_selector.phase_extra_body("librarian") == {}
def test_ollama_flavor_gets_no_fields(self, local_first, monkeypatch):
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
monkeypatch.setattr(model_selector, "_local_flavor", "ollama")
assert model_selector.phase_extra_body("steward") == {}
def test_unprobed_flavor_sends_nothing_rather_than_guessing(self, local_first, monkeypatch):
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
monkeypatch.setattr(model_selector, "_local_flavor", None)
assert model_selector.phase_extra_body("orchestrate") == {}
def test_merge_preserves_existing_extra_body(self, local_first, monkeypatch):
from pydantic_ai.settings import ModelSettings
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom")
base = ModelSettings(extra_body={"marker": 1})
merged = model_selector.with_phase_binding(base, "steward")
assert merged["extra_body"]["marker"] == 1
assert merged["extra_body"]["session"] == "tatlock-steward"
def test_claude_backend_never_bound(self, local_first, monkeypatch):
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
monkeypatch.setattr(config, "PREFER_CLOUD_BACKEND", True)
base = model_selector.get_tool_choice_settings()
assert model_selector.with_slot_pinning(base, slot=1) is base
assert model_selector.with_phase_binding(base, "orchestrate") is base
class TestLocalFlavorToolChoice:
@@ -128,6 +187,13 @@ class TestLocalFlavorToolChoice:
settings = model_selector.get_tool_choice_settings()
assert not settings.get("extra_body")
def test_boilerroom_flavor_sends_no_tool_choice(self, local_first, monkeypatch):
# The wrapper forwards to llama-server, which enforces
# tool_choice — the nudge through it is the tool-loop incident.
monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom")
settings = model_selector.get_tool_choice_settings()
assert not settings.get("extra_body")
def test_ollama_flavor_keeps_advisory_required(self, local_first, monkeypatch):
monkeypatch.setattr(model_selector, "_local_flavor", "ollama")
settings = model_selector.get_tool_choice_settings()
+59
View File
@@ -254,3 +254,62 @@ class TestRedisContract:
await client.delete("contract-test-key")
finally:
await client.aclose()
@pytest.mark.contract
class TestBoilerroomWrapperContract:
"""Boundary: the boilerroom wrapper surface session adoption rides on
(repo T-4). Skips when OLLAMA_HOST is not the wrapper — a direct
engine or Ollama is a valid deployment, not a violation.
"""
async def _skip_unless_wrapper(self) -> None:
response = await _get_or_skip(f"{OLLAMA}/health", "local-backend")
try:
service = response.json().get("service")
except ValueError:
service = None
if service != "boilerroom":
pytest.skip("OLLAMA_HOST is not the boilerroom wrapper")
async def test_wrapper_names_itself_on_health(self):
# Mirrors check_ollama_health()'s wrapper probe: the flavor
# detection keys on this exact field.
await self._skip_unless_wrapper()
async def test_props_passes_through_for_flavor_detection(self):
# tatlock treats a non-200 /props as Ollama and applies the
# tool_choice nudge — enforced by llama-server into a tool
# loop. The wrapper passing /props through is what keeps that
# detection safe (boilerroom T-10).
await self._skip_unless_wrapper()
response = await _get_or_skip(f"{OLLAMA}/props", "local-backend")
assert response.status_code == 200
async def test_session_fields_answer_with_signals(self):
# Mirrors phase_extra_body() through the wrapper: a session-
# carrying request must come back with the balancing header and
# the compaction signal in the body (wrapper D-3/D-4/D-5).
await self._skip_unless_wrapper()
response = await _post_or_skip(
f"{OLLAMA}/v1/chat/completions",
"local-backend",
{
"model": config.OLLAMA_DEFAULT_MODEL,
# No max_tokens: gemma4 spends its first tokens thinking,
# and a tight cap returns empty content (not a wrapper
# fault — the sibling passthrough test runs uncapped too).
"messages": [{"role": "user", "content": "Reply with the single word: pong"}],
"session": "contract-probe",
"eviction_order": 1,
},
timeout=config.OLLAMA_TIMEOUT,
)
assert response.status_code == 200
assert "x-boilerroom-balancing" in response.headers
assert response.headers.get("x-boilerroom-compaction-due") in ("true", "false")
body = response.json()
assert isinstance(body.get("compaction_due"), bool)
assert isinstance(body.get("balancing"), list)
# The extension fields must not leak into the engine's answer
assert body["choices"][0]["message"]["content"].strip()