feat(backend): BACKEND_SLOT_PINNING — per-phase engine slot ownership

Each pipeline phase owns one llama-server slot (steward 0, orchestrator
1, synthesizer 2), carried as id_slot in extra_body through the same
mechanism tool_choice already uses, 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; a no-op on the Claude backend and ignored by
Ollama, so the flag is safe on any backend and the cutover itself stays
a pure env swap.

The merge helper preserves existing extra_body keys — mutation-checked
(dropping the merge fails exactly the test written for it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 22:25:53 +02:00
co-authored by Claude Fable 5
parent 9ff698b55d
commit 23217bdb26
6 changed files with 80 additions and 10 deletions
+11 -6
View File
@@ -162,15 +162,20 @@ class StewardAgent:
OpenAI-compatible server (Ollama, llama-server) can sit behind
OLLAMA_HOST without this method knowing which.
"""
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
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.ollama_host}/v1/chat/completions",
json={
"model": self.ollama_model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3, # Lower = more consistent
"top_p": 0.9,
},
json=payload,
)
response.raise_for_status()
+7 -4
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
from src.anthropic.model_selector import get_tool_choice_settings, with_slot_pinning
result = await scoped_agent.run(
enriched_message,
message_history=pydantic_history if pydantic_history else None,
deps=tool_tracker,
model_settings=get_tool_choice_settings(),
model_settings=with_slot_pinning(get_tool_choice_settings(), slot=1),
)
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
from src.anthropic.model_selector import get_tool_choice_settings, with_slot_pinning
result = await scoped_agent.run(
enriched_message,
message_history=pydantic_history if pydantic_history else None,
deps=tool_tracker,
model_settings=get_tool_choice_settings(),
model_settings=with_slot_pinning(get_tool_choice_settings(), slot=1),
)
# Extract tool calls and results from the agent's messages
@@ -888,9 +888,12 @@ class TatlockAgent(AgentInterface):
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
# Run synthesis
from src.anthropic.model_selector import with_slot_pinning
result = await synthesis_agent.run(
synthesis_prompt,
message_history=pydantic_history if pydantic_history else None,
model_settings=with_slot_pinning(None, slot=2),
)
logger.info(
+22
View File
@@ -259,6 +259,28 @@ def get_tool_choice_settings() -> ModelSettings:
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.
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.
"""
if not config.BACKEND_SLOT_PINNING or resolve_backend() == "claude":
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
merged["extra_body"] = extra_body
return ModelSettings(**merged)
def get_sampling_settings(temperature: float) -> ModelSettings:
"""
Get model_settings with a sampling temperature where the backend allows it.
+4
View File
@@ -147,6 +147,10 @@ class Config(BaseSettings):
default=None,
description="OpenAI-compatible embeddings host; falls back to OLLAMA_HOST so gen and embed can live on different servers",
)
BACKEND_SLOT_PINNING: bool = Field(
default=False,
description="Pin each pipeline phase to a llama-server slot (steward=0, orchestrator=1, synthesizer=2) so a phase's stable prompt prefix stays in that slot's KV cache; no-op on Claude, ignored by Ollama",
)
# Redis Memory Database
REDIS_MEMORY_DB: int = Field(default=1, description="Redis database number for memory cache")