mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
fix(model-turn): temperature truly inherits — None never reaches the wire
The second xhigh review caught the fix-round design error one layer
down: omitting the temperature kwarg did not yield the server default —
every adapter's create_completion signature defaulted it to 0.5 and
apply_temperature wrote it to the wire, so the deleted lane pins had
silently become a hidden universal 0.5 pin.
The house rule is now implemented end to end:
- Protocol + adapters take temperature: float | None = None, and None
is OMITTED from the wire (apply_temperature None-gate; Anthropic's
builder keeps its API-required thinking=1.0 forcing but never writes
an unresolved value; Responses/xAI builders widened).
- resolve_lane climbs the documented ladder: ModelConfig.temperature →
ConfigStore global model.temperature (new config_store param,
threaded from ChatSession into both judges and perception) → None.
- perception.describe/describe_cached take alias/registry/config_store
so operator settings on the perception alias actually reach the wire
(previously structurally unreachable — no remediation path for a
degraded memoized description).
- The agent seam stops relaying the SESSION model's temperature: the
task/agent alias's own ladder governs, per the inherit-from-the-model
contract.
Generation-coherence and audit fixes from the same review:
- ChatSession._resolve_capabilities fetches its config UNCAUGHT again —
a registry failure on the session's own alias raises loudly instead
of silently caching degraded static-table caps for the session
lifetime (the never-crash fetch is a judge-constructor property).
- Judge constructors pass cfg=model_cfg (zero independent get_config
fetches; pinned by test); the per-evaluation lane's constructor-
frozen capabilities are documented as deliberate (window-coupled,
refreshed on judge swap).
- OutputGuardJudge splits _lane_alias from _judge_model_alias so the
audit label keeps its pre-#827 fallback semantics ("" → raw model id)
while lane resolution inherits the session alias.
- model_turn fetches the alias config ONCE per call and threads it into
both live flags (cfg sentinel standardized across the resolvers:
... = fetch for me, None = fetched-and-missed — also removes
resolve_lane's latent double-fetch on a miss).
- cap_tool_calls shared by the eval and optimizer loops; hand-built
ModelLane sites converted to resolve_lane; hand-rolled test result
namespaces consolidated onto mock_completion_result; stale synth-test
module docstring re-pointed.
This commit is contained in:
@@ -949,6 +949,29 @@ class TestModelAliasResolution:
|
||||
# alias's configured value, inherited through the lane.
|
||||
assert alias_provider.create_completion.call_args.kwargs["temperature"] == 0.3
|
||||
|
||||
def test_constructor_resolves_from_one_config_fetch(self):
|
||||
"""The constructor consumes the ModelConfig that registry.resolve()
|
||||
already returned (the ``cfg=`` pass-through) — ZERO independent
|
||||
get_config fetches, so a registry hot-reload between two lookups
|
||||
cannot bind the resolved client/window to a different capability
|
||||
generation."""
|
||||
alias_provider = _make_mock_provider(response_content=_good_verdict_json())
|
||||
registry = self._make_alias_registry(
|
||||
"judge-mini",
|
||||
alias_provider,
|
||||
MagicMock(base_url="https://a/v1", api_key="k"),
|
||||
"local-9b",
|
||||
)
|
||||
IntentJudge(
|
||||
config=JudgeConfig(enabled=True, model="judge-mini"),
|
||||
session_provider=_make_mock_provider(),
|
||||
session_client=MagicMock(base_url="https://s/v1", api_key="s"),
|
||||
session_model="session-model",
|
||||
session_capabilities=MagicMock(context_window=100_000),
|
||||
model_registry=registry,
|
||||
)
|
||||
assert registry.get_config.call_count == 0
|
||||
|
||||
def test_fallback_threads_session_capabilities_to_wire(self):
|
||||
"""No judge alias → the judge inherits the session model AND the
|
||||
session's resolved capabilities, threaded to ``create_completion``."""
|
||||
|
||||
@@ -368,12 +368,50 @@ def test_temperature_caller_value_wins_over_lane() -> None:
|
||||
assert provider.calls[0]["temperature"] == 0.9
|
||||
|
||||
|
||||
def test_temperature_omitted_when_unresolved() -> None:
|
||||
# No caller value, no lane value → the kwarg is omitted entirely and
|
||||
# the provider default applies (house rule: code never pins one).
|
||||
def test_temperature_unresolved_passes_none_and_wire_omits_it() -> None:
|
||||
# No caller value, no lane value → model_turn passes temperature=None,
|
||||
# and the PROVIDER layer omits the field from the wire so the server
|
||||
# default applies (house rule: code never pins one). Both halves are
|
||||
# pinned: a Python-signature default of 0.5 anywhere on this path is a
|
||||
# hidden universal pin — the exact bug the second xhigh review caught.
|
||||
provider = _FakeProvider([CompletionResult(content="")])
|
||||
model_turn(_lane(provider), [Turn.user("x")])
|
||||
assert "temperature" not in provider.calls[0]
|
||||
assert provider.calls[0]["temperature"] is None
|
||||
|
||||
from turnstone.core.providers._openai_common import apply_temperature
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
apply_temperature(kwargs, ModelCapabilities(), None, "medium")
|
||||
assert "temperature" not in kwargs # None never reaches the wire
|
||||
apply_temperature(kwargs, ModelCapabilities(), 1.0, "medium")
|
||||
assert kwargs["temperature"] == 1.0 # a real value still does
|
||||
|
||||
|
||||
def test_resolve_lane_global_config_store_rung() -> None:
|
||||
# ModelConfig.temperature=None means "use the global default from
|
||||
# ConfigStore" (the documented ladder) — resolve_lane climbs it.
|
||||
provider = _FakeProvider([])
|
||||
registry = _fake_registry(temperature=None)
|
||||
store = SimpleNamespace(get=lambda key: 1.0 if key == "model.temperature" else None)
|
||||
lane = resolve_lane(provider, object(), "m", alias="ali", registry=registry, config_store=store)
|
||||
assert lane.temperature == 1.0
|
||||
# The per-model value wins over the global rung.
|
||||
registry2 = _fake_registry(temperature=0.3)
|
||||
lane2 = resolve_lane(
|
||||
provider, object(), "m", alias="ali", registry=registry2, config_store=store
|
||||
)
|
||||
assert lane2.temperature == 0.3
|
||||
|
||||
|
||||
def test_model_turn_fetches_config_once_per_call() -> None:
|
||||
# ONE get_config per plant call feeds both live flags (replay + vLLM
|
||||
# attach) — a hot-reload between them cannot mix config generations
|
||||
# within a single request.
|
||||
registry = _fake_registry(replay=True)
|
||||
provider = _FakeProvider([CompletionResult(content="")])
|
||||
lane = _lane(provider, alias="ali", registry=registry)
|
||||
model_turn(lane, [Turn.user("x")])
|
||||
assert registry.get_config.call_count == 1
|
||||
|
||||
|
||||
def test_resolve_lane_inherits_config_temperature() -> None:
|
||||
|
||||
@@ -7715,6 +7715,21 @@ def test_web_fetch_extraction_caps_max_tokens_to_window_reserve():
|
||||
assert kw["max_tokens"] == 2048 # context_window // 4, not the 16384 session value
|
||||
|
||||
|
||||
def test_resolve_capabilities_raises_loudly_on_registry_failure():
|
||||
"""The session lane must NOT silently cache degraded static-table caps:
|
||||
a get_config failure on the session's own alias PROPAGATES (pre-#827
|
||||
semantics) — the never-crash defensive fetch is a judge-constructor
|
||||
property, and applying it here would let one transient registry hiccup
|
||||
pin wrong capabilities (window, thinking mode, token param) onto the
|
||||
session cache for its whole lifetime."""
|
||||
session = _make_session()
|
||||
session._registry = MagicMock()
|
||||
session._registry.get_config.side_effect = ValueError("Unknown model alias")
|
||||
session._model_alias = "primary"
|
||||
with pytest.raises(ValueError):
|
||||
session._get_capabilities()
|
||||
|
||||
|
||||
def test_record_aux_usage_skips_when_usage_missing():
|
||||
"""A provider that reports no usage object must not emit a phantom
|
||||
zero-token row."""
|
||||
|
||||
@@ -27,6 +27,7 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from tests._session_helpers import make_session as _make_session
|
||||
from tests._session_helpers import mock_completion_result
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
from turnstone.core.providers._openai_responses import OpenAIResponsesProvider
|
||||
@@ -403,14 +404,7 @@ class TestCallSitesInvokeMaybeAttach:
|
||||
|
||||
def capture_completion(**kwargs: Any) -> Any:
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
content="",
|
||||
tool_calls=None,
|
||||
finish_reason="stop",
|
||||
usage=None,
|
||||
provider_blocks=[],
|
||||
reasoning="",
|
||||
)
|
||||
return mock_completion_result("")
|
||||
|
||||
provider = OpenAIChatCompletionsProvider()
|
||||
provider.create_completion = capture_completion # type: ignore[method-assign]
|
||||
|
||||
@@ -27,6 +27,7 @@ from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests._session_helpers import make_session as _make_session
|
||||
from tests._session_helpers import mock_completion_result
|
||||
from turnstone.core.trajectory import Turn
|
||||
|
||||
|
||||
@@ -627,14 +628,7 @@ class TestUtilityCompletionPassesFlag:
|
||||
|
||||
def capture_completion(**kwargs: Any) -> Any:
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
content="title",
|
||||
tool_calls=None,
|
||||
finish_reason="stop",
|
||||
usage=None,
|
||||
provider_blocks=[],
|
||||
reasoning="",
|
||||
)
|
||||
return mock_completion_result("title")
|
||||
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.create_completion = capture_completion
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""Tests for ChatSession synthetic ``reasoning_text`` block stamping (Phase 3 path 3).
|
||||
"""Tests for the synthetic ``reasoning_text`` block stamping (Phase 3 path 3).
|
||||
|
||||
Path 3 covers OpenAI Chat Completions endpoints — vLLM with
|
||||
``--reasoning-parser``, llama.cpp with ``reasoning_format``, Gemini's
|
||||
``/v1beta/openai/`` endpoint, and any other server that surfaces
|
||||
``delta.reasoning_content`` Pydantic extras. These have no native
|
||||
provider_blocks shape on the wire, so ``ChatSession._stream_response``
|
||||
captures the streamed reasoning text into ``reasoning_parts`` and
|
||||
``_maybe_synth_reasoning_block`` stamps it onto ``_provider_content``
|
||||
as a synthetic ``{type: "reasoning_text"}`` block at the end of the
|
||||
turn.
|
||||
provider_blocks shape on the wire, so the captured reasoning text is
|
||||
stamped onto ``_provider_content`` as a synthetic
|
||||
``{type: "reasoning_text"}`` block at the end of the turn by
|
||||
``model_turn.synth_reasoning_block`` — the one synthesizer every lane
|
||||
runs (the main loop reaches it through ``ChatSession._stream_response``
|
||||
→ ``_finalize_provider_blocks``; agents and judges through
|
||||
``model_turn``).
|
||||
|
||||
These tests pin:
|
||||
1. The synthesizer fires only when no native blocks were emitted AND
|
||||
|
||||
+19
-5
@@ -977,12 +977,15 @@ class IntentJudge:
|
||||
rule_registry: Any | None = None,
|
||||
model_registry: Any | None = None,
|
||||
session_model_alias: str = "",
|
||||
config_store: Any | None = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._rule_registry = rule_registry
|
||||
# Carried into the per-evaluation ModelLane so extra_params and the
|
||||
# live operator flags resolve from the registry like every other lane.
|
||||
# Carried into the per-evaluation ModelLane so extra_params, the
|
||||
# live operator flags, and the temperature ladder (per-model value →
|
||||
# global ``model.temperature``) resolve like every other lane.
|
||||
self._model_registry = model_registry
|
||||
self._config_store = config_store
|
||||
# The caller (ChatSession) resolves the session model's real caps from
|
||||
# _get_capabilities (config/registry-aware) and passes them in; they are
|
||||
# this judge's wire capabilities and window when it inherits the session
|
||||
@@ -1022,8 +1025,12 @@ class IntentJudge:
|
||||
# sized into the judge's window budget right below (the
|
||||
# static caps table reports 200000 for local models, which
|
||||
# would silently over-budget them).
|
||||
# ``cfg=model_cfg`` reuses the config resolve() already
|
||||
# fetched — one lookup, one generation; a hot-reload
|
||||
# between two fetches cannot mix client/window with
|
||||
# foreign capability overrides.
|
||||
self._capabilities = resolve_capabilities(
|
||||
self._provider, self._model, config.model, model_registry
|
||||
self._provider, self._model, config.model, model_registry, cfg=model_cfg
|
||||
)
|
||||
# Use the registry's per-model context window, NOT
|
||||
# ``provider.get_capabilities().context_window``: the static
|
||||
@@ -1329,8 +1336,14 @@ class IntentJudge:
|
||||
tools = list(_JUDGE_TOOL_SCHEMAS)
|
||||
|
||||
# The judge's resolved lane for this evaluation: fresh client per run
|
||||
# (thread isolation), constructor-resolved capabilities, extra_params
|
||||
# and live operator flags from the registry like every other lane.
|
||||
# (thread isolation), extra_params / live flags / temperature ladder
|
||||
# from the registry like every other lane. Capabilities are
|
||||
# DELIBERATELY the constructor-frozen set (not re-resolved here):
|
||||
# the judge's window budget was sized against them at construction,
|
||||
# and the session swaps the whole judge on model/credential change —
|
||||
# an in-place capabilities edit to the same alias applies on the
|
||||
# next judge swap, keeping caps and window from ever disagreeing
|
||||
# within one judge lifetime.
|
||||
lane = resolve_lane(
|
||||
self._provider,
|
||||
client,
|
||||
@@ -1338,6 +1351,7 @@ class IntentJudge:
|
||||
alias=self._alias,
|
||||
registry=self._model_registry,
|
||||
capabilities=self._capabilities,
|
||||
config_store=self._config_store,
|
||||
)
|
||||
|
||||
# Multi-turn judge loop
|
||||
|
||||
@@ -98,7 +98,7 @@ def resolve_capabilities(
|
||||
alias: str,
|
||||
registry: ModelRegistry | None,
|
||||
*,
|
||||
cfg: Any | None = None,
|
||||
cfg: Any | EllipsisType = ...,
|
||||
) -> ModelCapabilities:
|
||||
"""Provider static capabilities, merged with registry alias overrides.
|
||||
|
||||
@@ -109,14 +109,16 @@ def resolve_capabilities(
|
||||
judges' old mirror — capability resolution must never crash a judge
|
||||
turn). *cfg* accepts a pre-fetched ModelConfig so a caller resolving
|
||||
several lane facets reads ONE config generation (see
|
||||
:func:`resolve_lane`); omitted, the config is fetched defensively.
|
||||
:func:`resolve_lane`); ``None`` means "fetched and missed" (no second
|
||||
lookup), and the ``...`` sentinel default means "fetch defensively for
|
||||
me".
|
||||
NOTE the window landmine documented on #826:
|
||||
``ModelConfig.context_window`` is a separate top-level column and is
|
||||
deliberately NOT merged here; callers that need the operator window
|
||||
must read it off the config themselves.
|
||||
"""
|
||||
caps = provider.get_capabilities(model)
|
||||
if cfg is None:
|
||||
if cfg is ...:
|
||||
cfg = _get_config_or_none(registry, alias)
|
||||
if cfg is not None:
|
||||
overrides_raw = getattr(cfg, "capabilities", None)
|
||||
@@ -133,7 +135,7 @@ def provider_extra_params(
|
||||
registry: ModelRegistry | None,
|
||||
alias: str,
|
||||
*,
|
||||
cfg: Any | None = None,
|
||||
cfg: Any | EllipsisType = ...,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Operator ``server_compat["extra_body"]`` pins for the OpenAI-shaped
|
||||
lanes (and the anthropic-compatible lane, whose SDK also takes
|
||||
@@ -147,7 +149,7 @@ def provider_extra_params(
|
||||
|
||||
if provider.provider_name not in ("openai", "openai-compatible", "anthropic-compatible"):
|
||||
return None
|
||||
if cfg is None:
|
||||
if cfg is ...:
|
||||
cfg = _get_config_or_none(registry, alias)
|
||||
server_compat = getattr(cfg, "server_compat", None) if cfg is not None else None
|
||||
extra = merge_server_compat(None, server_compat if isinstance(server_compat, dict) else {})
|
||||
@@ -184,6 +186,7 @@ def resolve_replay_reasoning_to_model(
|
||||
alias: str,
|
||||
*,
|
||||
caps: ModelCapabilities | None = None,
|
||||
cfg: Any | EllipsisType = ...,
|
||||
) -> bool:
|
||||
"""Operator ``ModelConfig.replay_reasoning_to_model`` for an alias.
|
||||
|
||||
@@ -197,13 +200,15 @@ def resolve_replay_reasoning_to_model(
|
||||
``caps.supports_reasoning_replay`` (mirrors the gate in
|
||||
``OpenAIResponsesProvider._build_kwargs``); omitted, the operator flag
|
||||
passes through unchanged for callers that haven't threaded caps.
|
||||
*cfg* follows the shared sentinel contract (``...`` = fetch for me,
|
||||
``None`` = fetched-and-missed) so ``model_turn`` reads ONE config
|
||||
generation per call across both per-call flags.
|
||||
"""
|
||||
if not registry or not alias:
|
||||
return False
|
||||
try:
|
||||
operator_on = bool(registry.get_config(alias).replay_reasoning_to_model)
|
||||
except Exception:
|
||||
if cfg is ...:
|
||||
cfg = _get_config_or_none(registry, alias)
|
||||
if cfg is None:
|
||||
return False
|
||||
operator_on = bool(getattr(cfg, "replay_reasoning_to_model", False))
|
||||
if caps is None:
|
||||
return operator_on
|
||||
return operator_on and bool(caps.supports_reasoning_replay)
|
||||
@@ -214,6 +219,8 @@ def maybe_attach_vllm_chat_reasoning(
|
||||
provider: LLMProvider,
|
||||
registry: ModelRegistry | None,
|
||||
alias: str,
|
||||
*,
|
||||
cfg: Any | EllipsisType = ...,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Phase 5 of reasoning persistence: attach vLLM's non-standard
|
||||
``reasoning`` field to outgoing assistant messages so a vLLM-served
|
||||
@@ -235,7 +242,8 @@ def maybe_attach_vllm_chat_reasoning(
|
||||
|
||||
if not isinstance(provider, OpenAIChatCompletionsProvider):
|
||||
return messages
|
||||
cfg = _get_config_or_none(registry, alias)
|
||||
if cfg is ...:
|
||||
cfg = _get_config_or_none(registry, alias)
|
||||
if cfg is None:
|
||||
return messages
|
||||
# Both gate fields read off the single ``cfg`` fetch (no second
|
||||
@@ -289,6 +297,7 @@ def resolve_lane(
|
||||
registry: ModelRegistry | None = None,
|
||||
capabilities: ModelCapabilities | None = None,
|
||||
extra_params: dict[str, Any] | None | EllipsisType = ...,
|
||||
config_store: Any | None = None,
|
||||
) -> ModelLane:
|
||||
"""Build a :class:`ModelLane`, resolving what the caller didn't supply.
|
||||
|
||||
@@ -298,6 +307,15 @@ def resolve_lane(
|
||||
pass. ``...`` (the sentinel default) means "resolve for me" —
|
||||
``None`` is a valid resolved value for *extra_params*.
|
||||
|
||||
The lane temperature climbs the documented ladder
|
||||
(``ModelConfig.temperature`` docstring: "None = use global default
|
||||
from ConfigStore"): the alias's per-model value, else the operator's
|
||||
global ``model.temperature`` when *config_store* is supplied, else
|
||||
``None`` — which the providers translate to omitting the field so the
|
||||
SERVER default applies. This mirrors the session_factory / ``/model``
|
||||
switch resolution so a judge or single-shot lane samples exactly like
|
||||
the main loop on the same model.
|
||||
|
||||
All resolved facets read ONE defensively-fetched ModelConfig, so a
|
||||
registry hot-reload mid-resolution cannot mix config generations, and
|
||||
an alias that raced away degrades every facet to its miss behavior
|
||||
@@ -310,6 +328,15 @@ def resolve_lane(
|
||||
if extra_params is ...
|
||||
else extra_params
|
||||
)
|
||||
temperature = getattr(cfg, "temperature", None) if cfg is not None else None
|
||||
if temperature is None and config_store is not None:
|
||||
try:
|
||||
temperature = config_store.get("model.temperature")
|
||||
except Exception:
|
||||
# Best-effort global rung — a broken store degrades to the
|
||||
# server default, never crashes lane resolution.
|
||||
log.debug("config_store model.temperature lookup failed", exc_info=True)
|
||||
temperature = None
|
||||
return ModelLane(
|
||||
provider=provider,
|
||||
client=client,
|
||||
@@ -318,7 +345,7 @@ def resolve_lane(
|
||||
capabilities=caps,
|
||||
extra_params=extra,
|
||||
registry=registry,
|
||||
temperature=getattr(cfg, "temperature", None) if cfg is not None else None,
|
||||
temperature=temperature,
|
||||
)
|
||||
|
||||
|
||||
@@ -496,6 +523,21 @@ class ModelTurnResult:
|
||||
return self.turn.text
|
||||
|
||||
|
||||
def cap_tool_calls(result: ModelTurnResult, max_calls: int) -> tuple[list[dict[str, Any]], Turn]:
|
||||
"""Degenerate-repetition guard shared by the bounded tool loops (eval,
|
||||
optimizer analyst): cap the mirror at *max_calls* and return the turn to
|
||||
append. A capped turn is rebuilt WITHOUT its native lane — a full lane
|
||||
beside a truncated mirror would replay orphan native tool blocks the
|
||||
mirror no longer carries (the same native↔mirror rule
|
||||
:func:`finalize_provider_blocks` enforces).
|
||||
"""
|
||||
capped = result.tool_calls[:max_calls]
|
||||
turn = result.turn
|
||||
if len(result.tool_calls) > len(capped):
|
||||
turn = Turn.assistant(result.content, tool_calls=turn.tool_calls[: len(capped)])
|
||||
return capped, turn
|
||||
|
||||
|
||||
def model_turn(
|
||||
lane: ModelLane,
|
||||
turns: Sequence[Turn],
|
||||
@@ -553,29 +595,34 @@ def model_turn(
|
||||
"model_turn: mint requires wire_id_map — minted ids are "
|
||||
"unrestorable on the wire without the recovery map"
|
||||
)
|
||||
# ONE config fetch per plant call feeds both live per-call flags — a
|
||||
# registry hot-reload cannot hand the replay gate and the attach gate
|
||||
# different config generations within a single request.
|
||||
cfg = _get_config_or_none(lane.registry, lane.alias)
|
||||
wire = restore_provider_tool_ids(
|
||||
sanitize_tool_call_arguments(dicts_from_turns(list(turns))),
|
||||
wire_id_map if wire_id_map is not None else {},
|
||||
)
|
||||
wire = maybe_attach_vllm_chat_reasoning(wire, lane.provider, lane.registry, lane.alias)
|
||||
wire = maybe_attach_vllm_chat_reasoning(wire, lane.provider, lane.registry, lane.alias, cfg=cfg)
|
||||
call_kwargs: dict[str, Any] = {
|
||||
"client": lane.client,
|
||||
"model": lane.model,
|
||||
"messages": wire,
|
||||
"tools": tools,
|
||||
"max_tokens": max_tokens,
|
||||
# None temperature flows through to the providers, which OMIT the
|
||||
# field from the wire so the server default applies (house rule: a
|
||||
# Python-level constant anywhere on this path is a hidden pin).
|
||||
"temperature": temperature if temperature is not None else lane.temperature,
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"extra_params": lane.extra_params,
|
||||
"capabilities": lane.capabilities,
|
||||
"replay_reasoning_to_model": resolve_replay_reasoning_to_model(
|
||||
lane.registry, lane.alias, caps=lane.capabilities
|
||||
lane.registry, lane.alias, caps=lane.capabilities, cfg=cfg
|
||||
),
|
||||
}
|
||||
if resolve_attachments is not None:
|
||||
call_kwargs["resolve_attachments"] = resolve_attachments
|
||||
effective_temperature = temperature if temperature is not None else lane.temperature
|
||||
if effective_temperature is not None:
|
||||
call_kwargs["temperature"] = effective_temperature
|
||||
result = lane.provider.create_completion(**call_kwargs)
|
||||
|
||||
raw_calls: list[dict[str, Any]] = list(result.tool_calls or [])
|
||||
|
||||
@@ -270,11 +270,14 @@ class OutputGuardJudge:
|
||||
model_registry: Any | None = None,
|
||||
session_capabilities: ModelCapabilities | None = None,
|
||||
session_model_alias: str = "",
|
||||
config_store: Any | None = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
# Carried into the per-evaluation ModelLane so extra_params and the
|
||||
# live operator flags resolve from the registry like every other lane.
|
||||
# Carried into the per-evaluation ModelLane so extra_params, the
|
||||
# live operator flags, and the temperature ladder resolve from the
|
||||
# registry like every other lane.
|
||||
self._model_registry = model_registry
|
||||
self._config_store = config_store
|
||||
# Caller's resolved session-model caps (config/registry-aware): the wire
|
||||
# capabilities + window when this judge inherits the session model, and
|
||||
# the alias path's window fallback. The window comes ONLY from these
|
||||
@@ -308,10 +311,17 @@ class OutputGuardJudge:
|
||||
)
|
||||
self._model = model_name
|
||||
self._judge_model_alias = config.output_guard_model
|
||||
self._lane_alias = config.output_guard_model
|
||||
# Shared lane resolver (model_turn); ModelConfig.context_window
|
||||
# stays separate and is sized into the guard window below.
|
||||
# ``cfg=model_cfg`` reuses the config resolve() already
|
||||
# fetched — one lookup, one generation.
|
||||
self._capabilities = resolve_capabilities(
|
||||
self._provider, self._model, config.output_guard_model, model_registry
|
||||
self._provider,
|
||||
self._model,
|
||||
config.output_guard_model,
|
||||
model_registry,
|
||||
cfg=model_cfg,
|
||||
)
|
||||
self._judge_context_window = _positive_window(
|
||||
getattr(model_cfg, "context_window", None),
|
||||
@@ -338,10 +348,18 @@ class OutputGuardJudge:
|
||||
session_client, session_provider.provider_name
|
||||
)
|
||||
self._model = session_model
|
||||
# Inherit the session's registry alias so the lane resolves
|
||||
# extra_params / replay flag exactly like every other lane on
|
||||
# the same model (see IntentJudge's fallback for the rationale).
|
||||
self._judge_model_alias = session_model_alias
|
||||
# AUDIT label keeps its pre-#827 fallback semantics: "" here so
|
||||
# recorded verdicts show ``judge_model = self._model`` (the raw
|
||||
# model id), not the session alias — threading the alias into
|
||||
# this field would silently change recorded judge_model values
|
||||
# across the upgrade on default-config installs.
|
||||
self._judge_model_alias = ""
|
||||
# LANE alias inherits the session's registry alias so the lane
|
||||
# resolves extra_params / replay flag / temperature exactly like
|
||||
# every other lane on the same model (see IntentJudge's fallback
|
||||
# for the rationale). Lane resolution and audit labeling are
|
||||
# different roles — hence two fields.
|
||||
self._lane_alias = session_model_alias
|
||||
# Wire caps: the caller's resolved session caps, or the provider's
|
||||
# static table as a last resort for degraded / legacy callers.
|
||||
self._capabilities = (
|
||||
@@ -508,15 +526,18 @@ class OutputGuardJudge:
|
||||
# worker is non-daemon, and concurrent.futures joins it from an atexit
|
||||
# hook regardless of shutdown(wait=False) — so a wedged upstream call
|
||||
# would otherwise hang shutdown.)
|
||||
# Single-shot lane: constructor-resolved capabilities, extra_params
|
||||
# and live operator flags from the registry like every other lane.
|
||||
# Single-shot lane: constructor-frozen capabilities (window-coupled,
|
||||
# refreshed on judge swap — see IntentJudge's lane note), extra_params
|
||||
# / live flags / temperature ladder from the registry like every
|
||||
# other lane. ``_lane_alias``, not the audit label.
|
||||
lane = resolve_lane(
|
||||
self._provider,
|
||||
client,
|
||||
self._model,
|
||||
alias=self._judge_model_alias,
|
||||
alias=self._lane_alias,
|
||||
registry=self._model_registry,
|
||||
capabilities=self._capabilities,
|
||||
config_store=self._config_store,
|
||||
)
|
||||
# Temperature deliberately not pinned (house rule) — the lane
|
||||
# inherits the guard model's configured temperature.
|
||||
|
||||
@@ -75,6 +75,9 @@ def describe(
|
||||
model: str,
|
||||
parts: list[dict[str, Any]],
|
||||
prompt: str = _DESCRIBE_PROMPT,
|
||||
alias: str = "",
|
||||
registry: Any | None = None,
|
||||
config_store: Any | None = None,
|
||||
) -> str:
|
||||
"""Perceive ``parts`` via the perception model, returning the text.
|
||||
|
||||
@@ -82,10 +85,16 @@ def describe(
|
||||
image/PDF-page perception, ``input_audio`` for audio. The trajectory
|
||||
carries them by reference; ``model_turn`` hands the resolver to the
|
||||
provider translator, which materializes the placeholder into these exact
|
||||
parts (one ref may expand to many, e.g. a rasterized PDF). Temperature
|
||||
is not pinned (house rule) — the perception model's own configuration
|
||||
governs sampling. Raises :class:`PerceptionBackendError` if the backend
|
||||
call fails. Never caches — see :func:`describe_cached`.
|
||||
parts (one ref may expand to many, e.g. a rasterized PDF).
|
||||
|
||||
*alias* / *registry* / *config_store* make the perception lane a real
|
||||
lane: the alias's capability overrides, ``server_compat.extra_body``
|
||||
pins, and the temperature ladder (per-model → global
|
||||
``model.temperature`` → server default; house rule: no code pins) all
|
||||
reach the wire, so an operator can actually remediate a degraded,
|
||||
memoized description from the Models tab. Raises
|
||||
:class:`PerceptionBackendError` if the backend call fails. Never
|
||||
caches — see :func:`describe_cached`.
|
||||
"""
|
||||
if not parts:
|
||||
return ""
|
||||
@@ -99,7 +108,9 @@ def describe(
|
||||
),
|
||||
)
|
||||
]
|
||||
lane = resolve_lane(provider, client, model)
|
||||
lane = resolve_lane(
|
||||
provider, client, model, alias=alias, registry=registry, config_store=config_store
|
||||
)
|
||||
try:
|
||||
result = model_turn(
|
||||
lane,
|
||||
@@ -136,6 +147,8 @@ def describe_cached(
|
||||
content_hash: str,
|
||||
parts: list[dict[str, Any]],
|
||||
prompt: str = _DESCRIBE_PROMPT,
|
||||
registry: Any | None = None,
|
||||
config_store: Any | None = None,
|
||||
) -> str:
|
||||
"""Memoized, non-raising :func:`describe` for the wire fallback.
|
||||
|
||||
@@ -154,6 +167,9 @@ def describe_cached(
|
||||
model=model,
|
||||
parts=parts,
|
||||
prompt=prompt,
|
||||
alias=alias,
|
||||
registry=registry,
|
||||
config_store=config_store,
|
||||
)
|
||||
except PerceptionBackendError as exc:
|
||||
log.warning("perception fallback failed (alias=%s): %s", alias, exc)
|
||||
|
||||
@@ -407,7 +407,7 @@ class AnthropicProvider:
|
||||
reasoning_effort: str,
|
||||
extra_params: dict[str, Any] | None,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
temperature: float | None,
|
||||
converted_msgs: list[dict[str, Any]],
|
||||
system_prompt: str,
|
||||
model: str,
|
||||
@@ -453,7 +453,10 @@ class AnthropicProvider:
|
||||
# 90% input cost reduction on cache hits; 1.25x write on first turn.
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
if caps.supports_temperature:
|
||||
# None temperature is never written — the request omits the field so
|
||||
# the server default applies (house rule: no code pins). The
|
||||
# thinking branches above still force 1.0 where the API requires it.
|
||||
if caps.supports_temperature and temperature is not None:
|
||||
kwargs["temperature"] = temperature
|
||||
if system_prompt:
|
||||
kwargs["system"] = system_prompt
|
||||
@@ -1075,7 +1078,7 @@ class AnthropicProvider:
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.5,
|
||||
temperature: float | None = None,
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
|
||||
@@ -312,7 +312,7 @@ class OpenAIChatCompletionsProvider:
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.5,
|
||||
temperature: float | None = None,
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
|
||||
@@ -361,16 +361,22 @@ def lookup_openai_capabilities(model: str) -> ModelCapabilities:
|
||||
def apply_temperature(
|
||||
kwargs: dict[str, Any],
|
||||
caps: ModelCapabilities,
|
||||
temperature: float,
|
||||
temperature: float | None,
|
||||
reasoning_effort: str,
|
||||
) -> None:
|
||||
"""Conditionally add temperature to *kwargs*.
|
||||
|
||||
- ``None`` temperature is never written: the request omits the field
|
||||
so the SERVER default applies. House rule: code never pins a
|
||||
temperature — a Python-level constant here would silently re-pin
|
||||
every lane that deliberately left it unresolved.
|
||||
- Models with ``supports_temperature=False`` (GPT-5 base, O-series)
|
||||
never receive temperature.
|
||||
- Models that list ``"none"`` in their effort values (GPT-5.1/5.2)
|
||||
only receive temperature when reasoning is inactive.
|
||||
"""
|
||||
if temperature is None:
|
||||
return
|
||||
if not caps.supports_temperature:
|
||||
return
|
||||
if "none" in caps.reasoning_effort_values and reasoning_effort not in ("none", ""):
|
||||
@@ -381,7 +387,7 @@ def apply_temperature(
|
||||
def apply_temperature_and_effort(
|
||||
kwargs: dict[str, Any],
|
||||
caps: ModelCapabilities,
|
||||
temperature: float,
|
||||
temperature: float | None,
|
||||
reasoning_effort: str,
|
||||
) -> None:
|
||||
"""Conditionally add temperature and reasoning_effort to *kwargs*.
|
||||
|
||||
@@ -354,7 +354,7 @@ class OpenAIResponsesProvider:
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
temperature: float | None,
|
||||
reasoning_effort: str,
|
||||
deferred_names: frozenset[str] | None,
|
||||
capabilities: ModelCapabilities | None = None,
|
||||
@@ -676,7 +676,7 @@ class OpenAIResponsesProvider:
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.5,
|
||||
temperature: float | None = None,
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
|
||||
@@ -469,7 +469,7 @@ class LLMProvider(Protocol):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.5,
|
||||
temperature: float | None = None,
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
@@ -480,6 +480,12 @@ class LLMProvider(Protocol):
|
||||
) -> CompletionResult:
|
||||
"""Create a non-streaming request, returning a normalized result.
|
||||
|
||||
``temperature=None`` (the default) means the field is OMITTED from
|
||||
the wire request and the server's own default applies — it must
|
||||
never be replaced by a Python-level constant (house rule: code
|
||||
never pins a temperature; ``model_turn`` resolves the operator's
|
||||
ladder and passes ``None`` when nothing is configured).
|
||||
|
||||
``replay_reasoning_to_model`` mirrors the per-model
|
||||
``model_definitions`` operator flag. Anthropic uses it to
|
||||
gate the verbatim ``_provider_content`` replay (Phase 2);
|
||||
|
||||
@@ -164,7 +164,7 @@ class XAIProvider(OpenAIResponsesProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
temperature: float | None,
|
||||
reasoning_effort: str,
|
||||
deferred_names: frozenset[str] | None,
|
||||
capabilities: ModelCapabilities | None = None,
|
||||
|
||||
+37
-14
@@ -124,7 +124,6 @@ from turnstone.core.metacognition import (
|
||||
should_nudge,
|
||||
)
|
||||
from turnstone.core.model_turn import (
|
||||
ModelLane,
|
||||
ModelTurnResult,
|
||||
ensure_tool_call_ids,
|
||||
finalize_provider_blocks,
|
||||
@@ -132,6 +131,7 @@ from turnstone.core.model_turn import (
|
||||
model_turn,
|
||||
provider_extra_params,
|
||||
resolve_capabilities,
|
||||
resolve_lane,
|
||||
resolve_replay_reasoning_to_model,
|
||||
)
|
||||
from turnstone.core.nudge_queue import (
|
||||
@@ -2043,9 +2043,14 @@ class ChatSession:
|
||||
"""Get model capabilities, applying config.toml overrides if present.
|
||||
|
||||
Delegates to :func:`turnstone.core.model_turn.resolve_capabilities` —
|
||||
the one resolution path every lane shares (#827).
|
||||
the one resolution path every lane shares (#827) — but fetches the
|
||||
config ITSELF, uncaught: a registry failure on the session's own
|
||||
alias must raise loudly (pre-#827 semantics), never silently cache
|
||||
degraded static-table caps for the session lifetime. The defensive
|
||||
never-crash fetch is a judge-constructor property, not a session one.
|
||||
"""
|
||||
return resolve_capabilities(provider, model, alias or "", self._registry)
|
||||
cfg = self._registry.get_config(alias) if (self._registry and alias) else None
|
||||
return resolve_capabilities(provider, model, alias or "", self._registry, cfg=cfg)
|
||||
|
||||
def _get_capabilities(self, provider: Any = None, model: str = "") -> ModelCapabilities:
|
||||
"""Get capabilities for a model. Cached for the primary session model."""
|
||||
@@ -4234,6 +4239,14 @@ class ChatSession:
|
||||
alias=alias,
|
||||
content_hash=content_hash,
|
||||
parts=parts,
|
||||
# Thread the registry + config store so the perception lane
|
||||
# resolves the alias's extra_params / capability overrides /
|
||||
# temperature ladder like every other lane — without these,
|
||||
# operator settings on the perception alias never reach the
|
||||
# wire and there is no remediation path for a degraded,
|
||||
# memoized description.
|
||||
registry=self._registry,
|
||||
config_store=self._config_store,
|
||||
)
|
||||
if not text:
|
||||
return None
|
||||
@@ -4762,14 +4775,15 @@ class ChatSession:
|
||||
"""
|
||||
caps = self._get_capabilities()
|
||||
clamped = min(max_tokens, caps.max_output_tokens) if caps.max_output_tokens else max_tokens
|
||||
lane = ModelLane(
|
||||
provider=self._provider,
|
||||
client=self.client,
|
||||
model=self.model,
|
||||
lane = resolve_lane(
|
||||
self._provider,
|
||||
self.client,
|
||||
self.model,
|
||||
alias=self._model_alias or "",
|
||||
registry=self._registry,
|
||||
capabilities=caps,
|
||||
extra_params=self._provider_extra_params(),
|
||||
registry=self._registry,
|
||||
config_store=self._config_store,
|
||||
)
|
||||
result = model_turn(
|
||||
lane,
|
||||
@@ -7754,6 +7768,8 @@ class ChatSession:
|
||||
# model — thread its alias too, so the lane resolves
|
||||
# extra_params / live flags like every other session lane.
|
||||
session_model_alias=self._model_alias or "",
|
||||
# For the temperature ladder's global rung (model.temperature).
|
||||
config_store=self._config_store,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("judge.init_failed", exc_info=True)
|
||||
@@ -7789,6 +7805,8 @@ class ChatSession:
|
||||
# source IntentJudge gets via _ensure_judge.
|
||||
session_capabilities=self._get_capabilities(),
|
||||
session_model_alias=self._model_alias or "",
|
||||
# For the temperature ladder's global rung (model.temperature).
|
||||
config_store=self._config_store,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("output_guard_judge.init_failed", exc_info=True)
|
||||
@@ -15064,18 +15082,24 @@ class ChatSession:
|
||||
# operator flags (replay-reasoning, Phase 5 vLLM attach) re-resolve
|
||||
# inside ``model_turn`` through the carried registry, so mid-session
|
||||
# admin toggles keep applying exactly as they did pre-extraction.
|
||||
# Temperature follows the AGENT model's own ladder (its ModelConfig,
|
||||
# else the global ``model.temperature``) — relaying the session
|
||||
# model's value here would make a task alias's configured
|
||||
# temperature unreachable (house rule: the model's configuration is
|
||||
# the source of truth).
|
||||
# Agent trajectories stay excluded from the persistence/replay
|
||||
# contract — history is in-memory, rebuilt per ``_run_agent``
|
||||
# invocation; the native lane carried here serves the WITHIN-RUN
|
||||
# reasoning continuity of the agent's own tool loop.
|
||||
lane = ModelLane(
|
||||
provider=agent_provider,
|
||||
client=agent_client,
|
||||
model=agent_model,
|
||||
lane = resolve_lane(
|
||||
agent_provider,
|
||||
agent_client,
|
||||
agent_model,
|
||||
alias=agent_alias or "",
|
||||
registry=self._registry,
|
||||
capabilities=agent_caps,
|
||||
extra_params=agent_extra,
|
||||
registry=self._registry,
|
||||
config_store=self._config_store,
|
||||
)
|
||||
|
||||
def _api_call(
|
||||
@@ -15098,7 +15122,6 @@ class ChatSession:
|
||||
turns,
|
||||
tools=_tools,
|
||||
max_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
reasoning_effort=reasoning_effort or self.reasoning_effort,
|
||||
mint=mint,
|
||||
wire_id_map=wire_id_map,
|
||||
|
||||
+10
-18
@@ -30,7 +30,7 @@ from typing import Any
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from turnstone.core.model_turn import ModelLane, model_turn
|
||||
from turnstone.core.model_turn import cap_tool_calls, model_turn, resolve_lane
|
||||
from turnstone.core.providers import LLMProvider, create_client, create_provider
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.storage import get_storage, init_storage, reset_storage
|
||||
@@ -306,14 +306,14 @@ class HeadlessSession(ChatSession):
|
||||
# The eval lane — resolved once per run, like the sub-agent seam.
|
||||
# ``temperature`` relays the harness's operator-resolved knob per
|
||||
# call below (house rule: relay, never pin).
|
||||
lane = ModelLane(
|
||||
provider=self._provider,
|
||||
client=self.client,
|
||||
model=self.model,
|
||||
lane = resolve_lane(
|
||||
self._provider,
|
||||
self.client,
|
||||
self.model,
|
||||
alias=self._model_alias or "",
|
||||
registry=self._registry,
|
||||
capabilities=self._get_capabilities(),
|
||||
extra_params=self._provider_extra_params(),
|
||||
registry=self._registry,
|
||||
)
|
||||
|
||||
for turn in range(max_turns):
|
||||
@@ -345,18 +345,10 @@ class HeadlessSession(ChatSession):
|
||||
self._total_usage["prompt"] += result.usage.prompt_tokens
|
||||
self._total_usage["completion"] += result.usage.completion_tokens
|
||||
|
||||
# Cap parallel tool calls to prevent degenerate repetition. The
|
||||
# cap applies to the mirror AND the appended turn; a capped turn
|
||||
# drops its native lane rather than replaying orphan native
|
||||
# tool_use blocks the mirror no longer carries.
|
||||
capped_calls = (result.tool_calls or [])[:10]
|
||||
assistant_turn = result.turn
|
||||
if len(result.tool_calls) > len(capped_calls):
|
||||
assistant_turn = Turn(
|
||||
role=Role.ASSISTANT,
|
||||
content=assistant_turn.content,
|
||||
tool_calls=assistant_turn.tool_calls[: len(capped_calls)],
|
||||
)
|
||||
# Cap parallel tool calls to prevent degenerate repetition
|
||||
# (shared guard — see model_turn.cap_tool_calls for the
|
||||
# native-lane-drop rationale on capped turns).
|
||||
capped_calls, assistant_turn = cap_tool_calls(result, 10)
|
||||
|
||||
self.messages.append(assistant_turn)
|
||||
msg_len = len(result.content or "")
|
||||
|
||||
+4
-10
@@ -27,7 +27,7 @@ from typing import Any
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from turnstone.core.model_turn import model_turn, resolve_lane
|
||||
from turnstone.core.model_turn import cap_tool_calls, model_turn, resolve_lane
|
||||
from turnstone.core.providers import LLMProvider, create_provider
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.trajectory import Role, Turn
|
||||
@@ -776,15 +776,9 @@ def _run_analyst(
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
|
||||
# Same degenerate-repetition cap as before; a capped turn drops its
|
||||
# native lane rather than replay orphan native tool blocks the
|
||||
# mirror no longer carries.
|
||||
capped = mtr.tool_calls[:5]
|
||||
assistant_turn = mtr.turn
|
||||
if len(mtr.tool_calls) > len(capped):
|
||||
assistant_turn = Turn.assistant(
|
||||
mtr.content, tool_calls=mtr.turn.tool_calls[: len(capped)]
|
||||
)
|
||||
# Same degenerate-repetition cap as before (shared guard — see
|
||||
# model_turn.cap_tool_calls for the native-lane-drop rationale).
|
||||
capped, assistant_turn = cap_tool_calls(mtr, 5)
|
||||
turns.append(assistant_turn)
|
||||
|
||||
if not capped:
|
||||
|
||||
Reference in New Issue
Block a user