Files
EvoScientist/tests/test_message_budget.py
m4 aae8d0a379 feat: workspace file references, read-file-images middleware, image model enabled flag
In-progress work committed to unblock the config import/export plan:
- prompts: FILE_REFERENCES section for workspace-relative file citation
- backends: resolve quoted virtual absolute paths onto the sandbox workspace
- middleware: read_file_images middleware; message_budget extensions
- image_gen/model_registry: image model 'enabled' flag refactor
- memory/launch, gateway/background_runs, tools/image follow-ons
- scripts: dev_backend.sh, release.sh
- tests for the above
2026-08-12 19:43:35 +08:00

608 lines
23 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)
assert budget.reserved_tokens == 4096
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)
assert tools_only.reserved_tokens == 4096 + 8192
assert full.reserved_tokens == 4096 + 8192 + 4096
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()
# =============================================================================
# Compression token stats on the summarization event
# =============================================================================
class TestCompressionTokenStats:
"""A freshly created ``_summarization_event`` records the estimated
prompt size before and after compaction plus the active budget, so the
WebUI can show what compression actually did (character estimates — the
provider-measured sizes keep flowing through the usage pipeline)."""
def _event_response(self):
event = {
"cutoff_index": 1,
"summary_message": HumanMessage(content="summary"),
"file_path": None,
}
return SimpleNamespace(
command=SimpleNamespace(update={"_summarization_event": event})
)
@staticmethod
def _fresh_event_response():
return TestCompressionTokenStats._event_response(None)
def test_augment_attaches_estimates_and_budget(self, store, runtime):
from EvoScientist.middleware.message_budget import (
_LAST_TOTAL_TOKENS,
_augment_summarization_event,
)
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")]))
token = _LAST_TOTAL_TOKENS.set(5_000)
try:
response = self._event_response()
_augment_summarization_event(response, budget, [1_250])
finally:
_LAST_TOTAL_TOKENS.reset(token)
event = response.command.update["_summarization_event"]
assert event["estimated_tokens_before"] == 5_000
assert event["estimated_tokens_after"] == 1_250
assert event["budget"] == {
"hard_tokens": budget.hard_tokens,
"soft_tokens": budget.soft_tokens,
"keep_tokens": budget.keep_tokens,
}
def test_augment_ignores_responses_without_a_new_event(self, store, runtime):
from EvoScientist.middleware.message_budget import (
_augment_summarization_event,
)
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")]))
plain = SimpleNamespace()
_augment_summarization_event(plain, budget, [10])
assert not hasattr(plain, "command")
no_event = SimpleNamespace(command=SimpleNamespace(update={"messages": []}))
_augment_summarization_event(no_event, budget, [10])
assert "_summarization_event" not in no_event.command.update
def test_should_summarize_stashes_total_tokens(self, store, runtime):
from EvoScientist.middleware.message_budget import (
_ACTIVE_BUDGET,
_LAST_TOTAL_TOKENS,
)
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
snapshot = make_snapshot(store)
messages = [HumanMessage(content="a" * 400)]
with _patched_config(_configurable_for(snapshot)):
token = _ACTIVE_BUDGET.set(
middleware._budget_for_request(_request(messages))
)
try:
middleware._should_summarize(messages, 123_456)
assert _LAST_TOTAL_TOKENS.get() == 123_456
finally:
_ACTIVE_BUDGET.reset(token)
def test_wrap_model_call_counts_final_prompt_and_augments_event(
self, store, runtime
):
from deepagents.middleware.summarization import SummarizationMiddleware
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
snapshot = make_snapshot(store)
compressed = [
HumanMessage(content="summary"),
HumanMessage(content="kept tail"),
]
response_holder = {}
def fake_base(_self, _request, handler):
# The base class's last handler call carries the exact prompt sent
# to the model after compaction.
modified = SimpleNamespace(messages=compressed, tools=[])
response_holder["handler_result"] = handler(modified)
return TestCompressionTokenStats._fresh_event_response()
with (
_patched_config(_configurable_for(snapshot)),
patch.object(SummarizationMiddleware, "wrap_model_call", fake_base),
):
response = middleware.wrap_model_call(
SimpleNamespace(messages=[HumanMessage("x" * 400)], tools=[]),
MagicMock(return_value="model-response"),
)
assert response_holder["handler_result"] == "model-response"
event = response.command.update["_summarization_event"]
assert event["estimated_tokens_after"] == count_message_text_tokens(
compressed
)
assert event["budget"]["hard_tokens"] > 0
async def test_awrap_model_call_counts_final_prompt_and_augments_event(
self, store, runtime
):
from deepagents.middleware.summarization import SummarizationMiddleware
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
snapshot = make_snapshot(store)
compressed = [HumanMessage(content="summary")]
async def fake_base(_self, _request, handler):
modified = SimpleNamespace(messages=compressed, tools=[])
await handler(modified)
return TestCompressionTokenStats._fresh_event_response()
async def handler(_request):
return "model-response"
with (
_patched_config(_configurable_for(snapshot)),
patch.object(SummarizationMiddleware, "awrap_model_call", fake_base),
):
response = await middleware.awrap_model_call(
SimpleNamespace(messages=[HumanMessage("x" * 400)], tools=[]),
handler,
)
event = response.command.update["_summarization_event"]
assert event["estimated_tokens_after"] == count_message_text_tokens(
compressed
)
class TestContextUsageEmission:
"""Every model call streams its prompt size on the ``custom`` stream
mode so the UI can render a live context-occupancy indicator."""
def _budget(self, middleware, snapshot):
with _patched_config(_configurable_for(snapshot)):
return middleware._budget_for_request(_request([HumanMessage("hi")]))
def test_emit_writes_payload_with_writer(self, store, runtime):
from EvoScientist.middleware.message_budget import _emit_context_usage
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
budget = self._budget(middleware, make_snapshot(store))
written = []
with patch(
"langgraph.config.get_stream_writer",
lambda: written.append,
):
_emit_context_usage(budget, 123)
assert written == [
{
"type": "evoscientist_context_usage",
"used_tokens": 123,
"reserved_tokens": budget.reserved_tokens,
"input_limit": budget.input_limit,
"hard_tokens": budget.hard_tokens,
"soft_tokens": budget.soft_tokens,
}
]
def test_emit_skips_outside_runnable_context(self, store, runtime):
from EvoScientist.middleware.message_budget import _emit_context_usage
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
budget = self._budget(middleware, make_snapshot(store))
with patch(
"langgraph.config.get_stream_writer",
side_effect=RuntimeError("no runnable context"),
):
_emit_context_usage(budget, 123) # must not raise
def test_wrap_model_call_emits_final_prompt_size(self, store, runtime):
from deepagents.middleware.summarization import SummarizationMiddleware
middleware = create_message_budget_middleware(
MagicMock(), MagicMock(), has_tools=False, runtime=runtime
)
snapshot = make_snapshot(store)
sent = [HumanMessage(content="final prompt")]
written = []
def fake_base(_self, _request, handler):
handler(SimpleNamespace(messages=sent, tools=[]))
return TestCompressionTokenStats._fresh_event_response()
with (
_patched_config(_configurable_for(snapshot)),
patch.object(SummarizationMiddleware, "wrap_model_call", fake_base),
patch("langgraph.config.get_stream_writer", lambda: written.append),
):
middleware.wrap_model_call(
SimpleNamespace(messages=[HumanMessage("x" * 400)], tools=[]),
MagicMock(return_value="model-response"),
)
assert [event["used_tokens"] for event in written] == [
count_message_text_tokens(sent)
]
assert all(event["type"] == "evoscientist_context_usage" for event in written)