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
+3
View File
@@ -272,11 +272,14 @@ async def run_librarian(
)
try:
from src.anthropic.model_selector import with_phase_binding
# One shared library-desk connection for all tool calls in this run
async with library_client_session():
result = await agent.run(
prompt,
message_history=message_history,
model_settings=with_phase_binding(None, "librarian"),
)
logger.info(
+16 -3
View File
@@ -162,15 +162,17 @@ class StewardAgent:
OpenAI-compatible server (Ollama, llama-server) can sit behind
OLLAMA_HOST without this method knowing which.
"""
from src.anthropic.model_selector import phase_extra_body
payload: dict = {
"model": self.ollama_model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3, # Lower = more consistent
"top_p": 0.9,
}
if config.BACKEND_SLOT_PINNING:
# The steward owns engine slot 0 (see BACKEND_SLOT_PINNING)
payload["id_slot"] = 0
# Session through the wrapper, id_slot 0 against a bare
# llama-server, nothing elsewhere (repo T-4).
payload.update(phase_extra_body("steward"))
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
@@ -180,6 +182,17 @@ class StewardAgent:
response.raise_for_status()
result = response.json()
# The wrapper reports on session-carrying responses (its
# D-4/D-5); this raw call bypasses the provider hook, so
# the body extras are read here. Acting on compaction_due
# is a later ticket — the signal must not pass silently.
balancing = result.get("balancing")
if balancing:
logger.info("backend_balancing", session="tatlock-steward", moves=balancing)
if result.get("compaction_due"):
logger.warning("backend_compaction_due", session="tatlock-steward")
return result["choices"][0]["message"]["content"].strip()
async def analyze(self, query: str, conversation_history: list[dict] | None = None) -> str:
+6 -6
View File
@@ -536,13 +536,13 @@ class TatlockAgent(AgentInterface):
# Run with scoped tools and tracker
# Force tool_choice to make LLM actually call tools
from src.anthropic.model_selector import get_tool_choice_settings, with_slot_pinning
from src.anthropic.model_selector import get_tool_choice_settings, with_phase_binding
result = await scoped_agent.run(
enriched_message,
message_history=pydantic_history if pydantic_history else None,
deps=tool_tracker,
model_settings=with_slot_pinning(get_tool_choice_settings(), slot=1),
model_settings=with_phase_binding(get_tool_choice_settings(), "orchestrate"),
)
logger.info(
@@ -725,13 +725,13 @@ class TatlockAgent(AgentInterface):
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
# Run with scoped tools and tracker
from src.anthropic.model_selector import get_tool_choice_settings, with_slot_pinning
from src.anthropic.model_selector import get_tool_choice_settings, with_phase_binding
result = await scoped_agent.run(
enriched_message,
message_history=pydantic_history if pydantic_history else None,
deps=tool_tracker,
model_settings=with_slot_pinning(get_tool_choice_settings(), slot=1),
model_settings=with_phase_binding(get_tool_choice_settings(), "orchestrate"),
)
# Extract tool calls and results from the agent's messages
@@ -888,12 +888,12 @@ class TatlockAgent(AgentInterface):
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
# Run synthesis
from src.anthropic.model_selector import with_slot_pinning
from src.anthropic.model_selector import with_phase_binding
result = await synthesis_agent.run(
synthesis_prompt,
message_history=pydantic_history if pydantic_history else None,
model_settings=with_slot_pinning(None, slot=2),
model_settings=with_phase_binding(None, "synthesize"),
)
logger.info(
+74 -18
View File
@@ -28,9 +28,11 @@ logger = get_logger(__name__)
# Cached health check results (set once at startup)
_claude_available: bool | None = None
_ollama_available: bool | None = None
# Which server answers behind OLLAMA_HOST: "llama-server" or "ollama".
# They disagree on tool_choice semantics (see get_tool_choice_settings),
# so the health check probes /props — served by llama-server only — and
# Which server answers behind OLLAMA_HOST: "boilerroom", "llama-server"
# or "ollama". They disagree on tool_choice semantics and on which
# extension fields do anything (see get_tool_choice_settings and
# phase_extra_body), so the health check probes for the wrapper's name
# on /health, then for /props — served by llama-server only — and
# caches the answer here.
_local_flavor: str | None = None
@@ -58,12 +60,26 @@ async def check_ollama_health() -> bool:
response.raise_for_status()
names = [m.get("id", "") for m in response.json().get("data", [])]
# /props is llama-server's own surface; Ollama 404s it.
# The boilerroom wrapper names itself on /health; a bare
# llama-server answers /health without a service field and
# serves /props (its own surface); Ollama answers neither.
is_wrapper = False
try:
props = await client.get(f"{host}/props")
_local_flavor = "llama-server" if props.status_code == 200 else "ollama"
except httpx.HTTPError:
_local_flavor = "ollama"
health = await client.get(f"{host}/health")
is_wrapper = (
health.status_code == 200 and health.json().get("service") == "boilerroom"
)
except (httpx.HTTPError, ValueError):
is_wrapper = False
if is_wrapper:
_local_flavor = "boilerroom"
else:
try:
props = await client.get(f"{host}/props")
_local_flavor = "llama-server" if props.status_code == 200 else "ollama"
except httpx.HTTPError:
_local_flavor = "ollama"
if model in names or f"{model}:latest" in names:
_ollama_available = True
@@ -271,36 +287,75 @@ def get_tool_choice_settings() -> ModelSettings:
observed at cutover). Its --jinja template renders tool definitions
the way gemma4 was trained, and the model calls tools reliably
unforced (probed 3/3).
boilerroom: same as llama-server — the wrapper forwards to it, and
its /props passthrough is what lets this detection keep working.
"""
from pydantic_ai.settings import ModelSettings
if resolve_backend() == "claude":
# PydanticAI's Anthropic model handles tool_choice internally
return ModelSettings()
if _local_flavor == "llama-server":
if _local_flavor in ("llama-server", "boilerroom"):
return ModelSettings()
# Ollama needs explicit tool_choice via extra_body
return ModelSettings(extra_body={"tool_choice": "required"})
def with_slot_pinning(settings: ModelSettings | None, slot: int) -> ModelSettings | None:
"""
Merge llama-server slot pinning into model settings when enabled.
# Phase bindings for the local backend (this repo's T-4, decided
# 2026-09-12): through the boilerroom wrapper each phase is a named
# session with an eviction rank — lower parks sooner, so the most-used
# context keeps its KV warm. The pipeline phases outrank the librarian,
# which outranks webber (20, filed in its own repo); Open WebUI stays
# session-less and can never evict anyone (wrapper D-4).
# phase -> (direct llama-server slot, wrapper session name, eviction rank)
_PHASE_BINDINGS: dict[str, tuple[int | None, str, int]] = {
"steward": (0, "tatlock-steward", 40),
"orchestrate": (1, "tatlock-orchestrate", 40),
"synthesize": (2, "tatlock-synthesize", 40),
"librarian": (None, "librarian", 30),
}
Each pipeline phase owns one engine slot (steward=0, orchestrator=1,
synthesizer=2), so the phase's stable prompt prefix stays in that
slot's KV cache and a turn re-prefills only its new tokens. Off by
default (BACKEND_SLOT_PINNING); a no-op on the Claude backend, and
Ollama ignores the field, so enabling it is safe on any backend.
def phase_extra_body(phase: str) -> dict[str, str | int]:
"""
The extension fields a pipeline phase sends to the local backend.
Through the boilerroom wrapper: a named session with its eviction
rank — the wrapper owns the name-to-slot map and reports parks and
restores on the response. Against a bare llama-server: the raw
id_slot pin this replaces (the librarian floats there, as it always
did). Ollama and Claude get nothing, and an unprobed flavor sends
nothing rather than guessing. Gated by BACKEND_SLOT_PINNING like
the pinning it grew out of.
"""
if not config.BACKEND_SLOT_PINNING or resolve_backend() == "claude":
return {}
slot, session, rank = _PHASE_BINDINGS[phase]
if _local_flavor == "boilerroom":
return {"session": session, "eviction_order": rank}
if _local_flavor == "llama-server" and slot is not None:
return {"id_slot": slot}
return {}
def with_phase_binding(settings: ModelSettings | None, phase: str) -> ModelSettings | None:
"""
Merge a phase's local-backend binding into model settings.
Session fields through the wrapper, id_slot against a bare
llama-server, settings untouched everywhere else — one call site
stays correct on any backend (see phase_extra_body).
"""
extra = phase_extra_body(phase)
if not extra:
return settings
from pydantic_ai.settings import ModelSettings
merged = dict(settings or {})
extra_body = dict(merged.get("extra_body") or {})
extra_body["id_slot"] = slot
extra_body.update(extra)
merged["extra_body"] = extra_body
return ModelSettings(**merged)
@@ -337,5 +392,6 @@ def get_model_info() -> dict:
"claude_configured": bool(config.ANTHROPIC_API_KEY),
"ollama_available": is_ollama_available(),
"ollama_model": config.OLLAMA_DEFAULT_MODEL,
"local_flavor": _local_flavor,
"prefer_cloud": config.PREFER_CLOUD_BACKEND,
}
+31
View File
@@ -8,6 +8,7 @@ This provider sanitizes messages to use empty strings instead of null.
from typing import Any
import httpx
from openai import AsyncOpenAI
from pydantic_ai.providers.ollama import OllamaProvider
@@ -17,6 +18,27 @@ from src.core.logging_config import get_logger
logger = get_logger(__name__)
async def _log_wrapper_signals(response: httpx.Response) -> None:
"""
Surface the boilerroom wrapper's per-response signals (repo T-4).
balancing names the sessions parked or restored to serve this
request (wrapper D-4 — never silent); compaction-due says a session
crossed its authored context budget (wrapper D-5 — a signal, not an
interrupt). Reading them is this ticket; producing the compacted
transcript is a later one. The headers are absent on any other
backend, so the hook costs nothing there.
"""
balancing = response.headers.get("x-boilerroom-balancing")
if balancing and balancing != "[]":
logger.info("backend_balancing", moves=balancing)
if response.headers.get("x-boilerroom-compaction-due") == "true":
logger.warning(
"backend_compaction_due",
hint="GET /sessions on the wrapper names the session over budget",
)
class TatlockOllamaProvider(OllamaProvider):
"""
Custom OllamaProvider with message sanitization for Tatlock agents.
@@ -56,6 +78,15 @@ class _SanitizedAsyncOpenAI(AsyncOpenAI):
# configured Ollama timeout instead of the SDK default (~600s),
# so one stuck request cannot eat the whole delegation budget.
kwargs.setdefault("timeout", float(config.OLLAMA_TIMEOUT))
# The response hook reads the wrapper's balancing/compaction
# headers on every call this client makes, streams included.
kwargs.setdefault(
"http_client",
httpx.AsyncClient(
event_hooks={"response": [_log_wrapper_signals]},
timeout=float(config.OLLAMA_TIMEOUT),
),
)
super().__init__(api_key="ollama", **kwargs)
@property