Files
EvoScientist/tests/test_message_budget.py
T
m4 bb9bed82e1 feat(runtime)!: remove auxiliary model role, resolve all roles from run snapshot
ModelRole collapses to "primary": every role (main, tool selector, memory
agents, subagents, summarizer) resolves to the snapshot's frozen primary
model, per design 6.1/8.3 — users typically configure a single usable LLM,
so compile-time auxiliary bindings were bypassing run snapshots and
mis-attributing usage. Legacy auxiliary keys in stored snapshots, registry
JSON, and thread metadata are tolerated on read and dropped.

BREAKING CHANGE: ThreadModelSelection no longer carries an auxiliary ref;
snapshot selection_hash is computed over {primary, reasoning_effort} only;
ConfigurableModelMiddleware(role="auxiliary") is rejected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-23 10:18:44 +08:00

360 lines
14 KiB
Python

"""Tests for message-only context budgeting (design doc 6.5, 8.1, 8.3).
Every budget comes from the frozen ``ResolvedModelConfig.budget`` of the
run snapshot — the middleware's own ``snapshot_role`` selects which frozen
configuration sizes the budget (section 6.1 role mapping). Runs without an
explicit ``runtime_snapshot_id`` lazily create a local snapshot bound to
their thread (section 8.1); a bootstrap registry fails closed with
``MODEL_REGISTRY_NOT_READY``. There is no interim 32K/profile fallback.
"""
from __future__ import annotations
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI
from EvoScientist.middleware.message_budget import (
_snapshot_message_budget,
count_message_text_tokens,
create_message_budget_middleware,
)
from EvoScientist.model_registry.errors import (
MODEL_CONFIG_OUTSIDE_SNAPSHOT,
MODEL_REGISTRY_NOT_READY,
SNAPSHOT_NOT_FOUND,
ModelRegistryError,
)
from EvoScientist.model_registry.runtime import SnapshotRuntime
from EvoScientist.model_registry.store import ModelRuntimeStore
from tests.registry_fixtures import make_active_store, make_snapshot
@contextmanager
def _patched_config(configurable: dict | None):
"""Patch ``langgraph.config.get_config`` to expose ``configurable``."""
import langgraph.config as _lg_cfg
if configurable is None:
with patch.object(
_lg_cfg,
"get_config",
side_effect=RuntimeError("Called get_config outside of a runnable context"),
):
yield
else:
with patch.object(
_lg_cfg,
"get_config",
return_value={"configurable": configurable},
):
yield
@pytest.fixture
def store(tmp_path):
return make_active_store(tmp_path / "model-runtime")
@pytest.fixture
def runtime(store):
return SnapshotRuntime(store)
def _request(messages, tools=()):
return SimpleNamespace(messages=messages, tools=list(tools))
def _configurable_for(snapshot, **overrides):
configurable = {
"runtime_snapshot_id": snapshot.snapshot_id,
"thread_id": snapshot.thread_id,
}
configurable.update(overrides)
return configurable
def test_text_counter_excludes_attachment_payloads_and_counts_tool_results():
messages = [
HumanMessage(
content=[
{"type": "text", "text": "abcd"},
{"type": "image", "base64": "x" * 100_000},
{"type": "file", "data": "y" * 100_000},
]
),
ToolMessage(content="wxyz", tool_call_id="tool-1"),
]
assert count_message_text_tokens(messages) == 2
# =============================================================================
# Snapshot-driven budget recomputation (section 6.5)
# =============================================================================
class TestSnapshotMessageBudget:
"""The frozen fixture registry resolves to:
resolved_input_limit = 1048576 - 32768 = 1015808
reserves: system 4096, tools 8192, attachments 4096
"""
def test_base_mode_deducts_only_system_reserve(self, store):
snapshot = make_snapshot(store)
budget = _snapshot_message_budget(
snapshot, "primary", has_tools=False, has_attachments=False
)
message_budget = 1015808 - 4096
assert budget.input_limit == 1015808
assert budget.hard_tokens == int(message_budget * 0.90)
assert budget.soft_tokens == int(budget.hard_tokens * 0.70)
assert budget.keep_tokens == int(budget.hard_tokens * 0.35)
def test_tools_and_attachments_deduct_their_reserves(self, store):
snapshot = make_snapshot(store)
tools_only = _snapshot_message_budget(
snapshot, "primary", has_tools=True, has_attachments=False
)
full = _snapshot_message_budget(
snapshot, "primary", has_tools=True, has_attachments=True
)
assert tools_only.hard_tokens == int((1015808 - 4096 - 8192) * 0.90)
assert full.hard_tokens == int((1015808 - 4096 - 8192 - 4096) * 0.90)
class TestSnapshotModeMiddleware:
def test_budget_uses_frozen_reserves_not_model_profile(self, store, runtime):
"""A misleading compile-time profile must not affect snapshot mode."""
model = MagicMock()
model.profile = {"max_input_tokens": 32_768}
middleware = create_message_budget_middleware(
model, MagicMock(), runtime=runtime
)
snapshot = make_snapshot(store)
with _patched_config(_configurable_for(snapshot)):
budget = middleware._budget_for_request(_request([HumanMessage("hi")]))
assert budget.input_limit == 1015808
assert budget.has_tools is True # conservative construction-time flag
assert budget.has_attachments is False
assert budget.hard_tokens == int((1015808 - 4096 - 8192) * 0.90)
def test_recomputed_per_call_with_current_attachments(self, store, runtime):
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), runtime=runtime
)
snapshot = make_snapshot(store)
plain = _request([HumanMessage(content="plain")])
attached = _request(
[HumanMessage(content=[{"type": "image", "url": "https://example.test/a"}])]
)
with _patched_config(_configurable_for(snapshot)):
first = middleware._budget_for_request(plain)
second = middleware._budget_for_request(attached)
third = middleware._budget_for_request(plain)
assert first.has_attachments is False
assert second.has_attachments is True
assert second.hard_tokens == int((1015808 - 4096 - 8192 - 4096) * 0.90)
# No caching across calls: the third call recomputes from scratch.
assert third == first
def test_has_tools_is_conservative_not_request_derived(self, store, runtime):
"""Tool mode comes from construction, not the request's bound tools."""
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=True, runtime=runtime
)
snapshot = make_snapshot(store)
with _patched_config(_configurable_for(snapshot)):
budget = middleware._budget_for_request(_request([HumanMessage("hi")]))
# The request binds no tools at all, yet the tools reserve applies.
assert budget.has_tools is True
assert budget.hard_tokens == int((1015808 - 4096 - 8192) * 0.90)
def test_has_tools_false_when_agent_has_no_toolset(self, store, runtime):
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
snapshot = make_snapshot(store)
with _patched_config(_configurable_for(snapshot)):
budget = middleware._budget_for_request(
_request([HumanMessage("hi")], tools=[{"name": "read_file"}])
)
assert budget.has_tools is False
assert budget.hard_tokens == int((1015808 - 4096) * 0.90)
def test_unknown_snapshot_role_rejected(self, runtime):
with pytest.raises(ValueError, match="Unknown model role"):
create_message_budget_middleware(
MagicMock(), MagicMock(), snapshot_role="bogus", runtime=runtime
)
def test_snapshot_binding_is_verified(self, store, runtime):
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), runtime=runtime
)
snapshot = make_snapshot(store)
with (
_patched_config(_configurable_for(snapshot, thread_id="other")),
pytest.raises(ModelRegistryError) as excinfo,
):
middleware._budget_for_request(_request([HumanMessage("hi")]))
assert excinfo.value.code == SNAPSHOT_NOT_FOUND
def test_summarizer_model_uses_snapshot_primary(self, store, runtime):
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), runtime=runtime
)
snapshot = make_snapshot(store)
with _patched_config(_configurable_for(snapshot)):
model = middleware.model
# Every role maps to the snapshot's frozen primary (section 6.1).
assert isinstance(model, ChatOpenAI)
assert model.model_name == "glm-5.2"
def test_summarizer_model_cached_per_snapshot(self, store, runtime):
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), runtime=runtime
)
snapshot = make_snapshot(store)
with _patched_config(_configurable_for(snapshot)):
assert middleware.model is middleware.model
# =============================================================================
# Local snapshot entry (section 8.1): no explicit snapshot ID
# =============================================================================
class TestLocalSnapshotEntry:
def test_missing_snapshot_lazily_creates_one_bound_to_the_thread(
self, store, runtime
):
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
with _patched_config({"thread_id": "cron-thread-1"}):
budget = middleware._budget_for_request(_request([HumanMessage("hi")]))
assert budget.input_limit == 1015808
# The lazy snapshot is persisted, bound to the run's own thread.
row = store.find_active_run_snapshot(
deployment_id="local",
thread_id="cron-thread-1",
run_request_id="auto:cron-thread-1",
)
assert row is not None
def test_lazy_creation_is_shared_across_middleware_calls(self, store, runtime):
"""The same thread converges on one snapshot (idempotent create)."""
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), runtime=runtime
)
with _patched_config({"thread_id": "cron-thread-2"}):
first = middleware._snapshot()
middleware._budget_for_request(_request([HumanMessage("hi")]))
second = middleware._snapshot()
assert first.snapshot_id == second.snapshot_id
def test_missing_snapshot_and_thread_id_fails_closed(self, runtime):
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), runtime=runtime
)
with (
_patched_config({}),
pytest.raises(ModelRegistryError) as excinfo,
):
middleware._budget_for_request(_request([HumanMessage("hi")]))
assert excinfo.value.code == MODEL_CONFIG_OUTSIDE_SNAPSHOT
def test_bootstrap_registry_fails_with_not_ready(self, tmp_path):
"""No 32K fallback: bootstrap registry → MODEL_REGISTRY_NOT_READY."""
bootstrap_runtime = SnapshotRuntime(
ModelRuntimeStore(config_dir=tmp_path / "model-runtime")
)
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), runtime=bootstrap_runtime
)
with (
_patched_config({"thread_id": "cli-thread"}),
pytest.raises(ModelRegistryError) as excinfo,
):
middleware._budget_for_request(_request([HumanMessage("hi")]))
assert excinfo.value.code == MODEL_REGISTRY_NOT_READY
# =============================================================================
# Cutoff behavior (budget-driven summarization trigger)
# =============================================================================
def test_budget_middleware_uses_message_threshold_and_safe_tool_cutoff(tmp_path):
# A small context window keeps the trigger thresholds reachable in a test.
store = make_active_store(
tmp_path / "model-runtime-small",
context_window_tokens=32768,
max_output_tokens=8192,
min_effective_input_tokens=1024,
fixed_system_reserve_tokens=1024,
fixed_tools_reserve_tokens=2048,
fixed_attachments_reserve_tokens=1024,
)
runtime = SnapshotRuntime(store)
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
snapshot = make_snapshot(store)
messages = [
HumanMessage(content="a" * 30_000),
AIMessage(
content="", tool_calls=[{"name": "read_file", "args": {}, "id": "tool-1"}]
),
ToolMessage(content="b" * 30_000, tool_call_id="tool-1"),
HumanMessage(content="c" * 30_000),
]
total = count_message_text_tokens(messages)
from EvoScientist.middleware.message_budget import _ACTIVE_BUDGET
with _patched_config(_configurable_for(snapshot)):
# Simulate the per-call budget activation that wrap_model_call sets.
token = _ACTIVE_BUDGET.set(middleware._budget_for_request(_request(messages)))
try:
assert middleware._should_summarize(messages, total) is True
cutoff = middleware._determine_cutoff_index(messages)
finally:
_ACTIVE_BUDGET.reset(token)
assert cutoff in {1, 3}
# A cutoff never leaves the tool response without its matching AI tool call.
if cutoff == 1:
assert isinstance(messages[cutoff], AIMessage)
def test_active_budget_outside_model_call_fails_loudly(runtime):
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), runtime=runtime
)
with pytest.raises(RuntimeError, match="outside a model call"):
middleware._active_budget()