Files
hermes-agent/tests/agent/test_bedrock_context_cache.py

247 lines
13 KiB
Python

"""Bedrock cache provenance and compressor budgets across disk-backed restarts.
AWS documents Grok 4.6's Bedrock context window as 500K, independently of
xAI's direct API catalog: https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-xai-grok-4-6.html
Only provider I/O is stubbed; cache/config readers and compressor are real.
"""
import json
import os
import subprocess
import sys
import time
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
import yaml
from agent import bedrock_adapter as ba
from agent import model_metadata as mm
from agent.context_compressor import ContextCompressor
@pytest.fixture(autouse=True)
def isolated_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
mm._BEDROCK_PROBE_FAILURE_CACHE.clear()
monkeypatch.setattr(ba, "resolve_bedrock_region", lambda: "us-east-1")
yield tmp_path
mm._BEDROCK_PROBE_FAILURE_CACHE.clear()
@pytest.mark.parametrize("model", ["xai.grok-4.6", "global.xai.grok-4.6", "us.xai.grok-4.6"])
@pytest.mark.parametrize("base_url", ["", "https://bedrock-runtime.us-east-1.amazonaws.com"])
@pytest.mark.parametrize("legacy", [None, 128_000, 700_000])
@pytest.mark.parametrize("probed", [None, 128_000, 800_000])
def test_bedrock_resolution_migrates_ambiguous_cache_and_preserves_probe(
isolated_home, monkeypatch, model, base_url, legacy, probed,
):
"""Neither an old small nor large scalar proves where it came from.
A successful probe is authoritative, including below-table limits. Its
provenance must survive unrelated writes and an actual process restart.
"""
cache_url = base_url or "bedrock://"
key = mm._context_cache_key(model, cache_url)
cache_file = isolated_home / "context_length_cache.yaml"
if legacy is not None:
cache_file.write_text(yaml.safe_dump({"context_lengths": {key: legacy}}))
probe = Mock(return_value=probed)
monkeypatch.setattr(ba, "probe_bedrock_context_length", probe)
expected = probed if probed is not None else 500_000
compressor = ContextCompressor(model, provider="bedrock", base_url=base_url, quiet_mode=True)
assert compressor.context_length == expected
# Preserve the existing raise-only 75% floor for windows below 512K.
expected_threshold = int(expected * (0.75 if expected < 512_000 else 0.5))
assert compressor.threshold_tokens == expected_threshold
assert mm.get_model_context_length(model, provider="bedrock", base_url=base_url) == expected
probe.assert_called_once_with(model, "us-east-1")
assert mm.get_cached_context_length(model, cache_url, bedrock_confirmed=True) == probed
# Ordinary cache updates must not erase another entry's provenance.
mm.save_context_length("other-model", "https://other.example/v1", 64_000)
mm._invalidate_cached_context_length("other-model", "https://other.example/v1")
if probed is None:
assert yaml.safe_load(cache_file.read_text())["context_lengths"].get(key) == legacy
else:
script = '''
import json, sys
from agent import bedrock_adapter as ba
from agent.context_compressor import ContextCompressor
ba.probe_bedrock_context_length = lambda *a, **k: (_ for _ in ()).throw(AssertionError("reprobed persisted success"))
c = ContextCompressor(sys.argv[1], provider="bedrock", base_url=sys.argv[2], quiet_mode=True)
print(json.dumps([c.context_length, c.threshold_tokens]))
'''
result = subprocess.run([sys.executable, "-c", script, model, base_url],
env=dict(os.environ), text=True, capture_output=True, check=True)
assert json.loads(result.stdout) == [expected, expected_threshold]
@pytest.mark.parametrize("base_url", ["", "https://bedrock-runtime.us-east-1.amazonaws.com"])
@pytest.mark.parametrize("retry", ["ttl", "invalidate", "restart", "profile", "endpoint"])
def test_failure_memo_retry_scope_and_expiry(isolated_home, monkeypatch, base_url, retry):
model = "global.xai.grok-4.6"
cache_url = base_url or "bedrock://"
probe = Mock(side_effect=[None, 96_000])
monkeypatch.setattr(ba, "probe_bedrock_context_length", probe)
assert mm.get_model_context_length(model, provider="bedrock", base_url=base_url) == 500_000
assert mm.get_model_context_length(model, provider="bedrock", base_url=base_url) == 500_000
assert probe.call_count == 1
assert not (isolated_home / "context_length_cache.yaml").exists()
if retry == "ttl":
expired = time.monotonic() - mm._BEDROCK_PROBE_FAILURE_TTL_SECONDS - 1
for key in mm._BEDROCK_PROBE_FAILURE_CACHE:
mm._BEDROCK_PROBE_FAILURE_CACHE[key] = expired
elif retry == "invalidate":
mm._invalidate_cached_context_length(model, cache_url)
elif retry == "restart":
mm._BEDROCK_PROBE_FAILURE_CACHE.clear()
elif retry == "profile":
new_home = isolated_home / "other-profile"
new_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(new_home))
else:
base_url = "https://bedrock-runtime.us-east-1.amazonaws.com/other"
assert mm.get_model_context_length(model, provider="bedrock", base_url=base_url) == 96_000
assert probe.call_count == 2
if retry == "ttl":
assert not mm._BEDROCK_PROBE_FAILURE_CACHE
@pytest.mark.parametrize("base_url", ["", "https://bedrock-runtime.us-east-1.amazonaws.com"])
@pytest.mark.parametrize("override", ["argument", "config"])
def test_explicit_context_override_and_compressor_caps_win(isolated_home, monkeypatch, base_url, override):
model = "us.xai.grok-4.6"
probe = Mock(side_effect=AssertionError("explicit override must not probe"))
monkeypatch.setattr(ba, "probe_bedrock_context_length", probe)
explicit_context = 80_000 if override == "argument" else None
if override == "config":
(isolated_home / "config.yaml").write_text(yaml.safe_dump({
"model_overrides": {"bedrock": {model: {"context_window": 80_000}}},
}))
compressor = ContextCompressor(model, provider="bedrock", base_url=base_url,
threshold_tokens_cap=30_000, max_tokens=10_000,
quiet_mode=True, config_context_length=explicit_context)
assert compressor.context_length == 80_000
assert compressor.threshold_tokens == 30_000
assert not (isolated_home / "context_length_cache.yaml").exists()
probe.assert_not_called()
def test_unknown_model_fallback_and_host_inference(monkeypatch):
probe = Mock(return_value=None)
monkeypatch.setattr(ba, "probe_bedrock_context_length", probe)
base_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
assert mm.get_model_context_length("unknown.future-model", base_url=base_url) == ba.BEDROCK_DEFAULT_CONTEXT_LENGTH
assert mm.get_model_context_length("xai.grok-4.6", base_url=base_url) == 500_000
assert mm.get_cached_context_length("unknown.future-model", base_url) is None
@pytest.mark.parametrize("base_url", ["", "https://bedrock-runtime.us-east-1.amazonaws.com"])
@pytest.mark.parametrize("writer", ["overflow", "usage"])
def test_provider_confirmed_writers_survive_restart(monkeypatch, base_url, writer):
from agent.turn_overflow import _adopt_provider_context_limit
from agent.turn_usage import record_response_usage
model = "global.xai.grok-4.6"
compressor = ContextCompressor(model, base_url=base_url, provider="bedrock", quiet_mode=True)
compressor.context_length = 500_000
agent = SimpleNamespace(model=model, provider="bedrock", api_mode="bedrock", base_url=base_url,
context_compressor=compressor, _buffer_vprint=lambda *a: None,
_safe_print=lambda *a: None, log_prefix="", client=None,
_session_db=None, verbose_logging=False, quiet_mode=True,
session_api_calls=0, session_estimated_cost_usd=0)
if writer == "overflow":
assert _adopt_provider_context_limit(SimpleNamespace(agent=agent),
"maximum context length is 96000 tokens", 500_000) == 96_000
else:
compressor.context_length = 96_000
compressor._context_probed = compressor._context_probe_persistable = True
for name in ("prompt", "completion", "total", "input", "output", "cache_read", "cache_write", "reasoning"):
setattr(agent, f"session_{name}_tokens", 0)
response = SimpleNamespace(usage={"input_tokens": 100, "output_tokens": 5})
record_response_usage(agent, response, messages=[{"role": "user", "content": "hi"}],
api_call_count=1, api_duration=0.1, compression_attempts=0, max_compression_attempts=3)
assert mm.get_cached_context_length(model, base_url or "bedrock://") == 96_000
script = '''
import sys
from agent import model_metadata as mm, bedrock_adapter as ba
ba.probe_bedrock_context_length = lambda *a, **k: (_ for _ in ()).throw(AssertionError("lost provider limit"))
assert mm.get_model_context_length(sys.argv[1], base_url=sys.argv[2], provider="bedrock") == 96000
'''
subprocess.run([sys.executable, "-c", script, model, base_url], check=True, capture_output=True, text=True)
@pytest.mark.parametrize("base_url", ["", "https://bedrock-runtime.us-east-1.amazonaws.com"])
def test_readonly_legacy_cache_does_not_reset_probe_cooldown(isolated_home, monkeypatch, base_url):
model = "global.xai.grok-4.6"
key = mm._context_cache_key(model, base_url or "bedrock://")
cache_file = isolated_home / "context_length_cache.yaml"
cache_file.write_text(yaml.safe_dump({"context_lengths": {key: 128_000}}))
probe = Mock(return_value=None)
monkeypatch.setattr(ba, "probe_bedrock_context_length", probe)
monkeypatch.setattr(mm, "_write_context_cache", Mock(side_effect=OSError("read-only")))
for _ in range(3):
assert mm.get_model_context_length(model, provider="bedrock", base_url=base_url) == 500_000
assert probe.call_count == 1
@pytest.mark.parametrize("rewrite", [None, 96_000, 120_000])
def test_provenance_is_backward_readable_and_generic_writes_clear_it(isolated_home, monkeypatch, rewrite):
model, base_url = "xai.grok-4.6", "https://bedrock-runtime.us-east-1.amazonaws.com"
probe = Mock(side_effect=[96_000, 110_000])
monkeypatch.setattr(ba, "probe_bedrock_context_length", probe)
assert mm.get_model_context_length(model, base_url=base_url) == 96_000
raw = yaml.safe_load((isolated_home / "context_length_cache.yaml").read_text())
# Old readers take this value directly into arithmetic. Metadata is additive.
assert raw["context_lengths"][mm._context_cache_key(model, base_url)] + 1 == 96_001
if rewrite is not None:
mm.save_context_length(model, base_url, rewrite)
assert mm.get_model_context_length(model, base_url=base_url) == 110_000
else:
assert mm.get_model_context_length(model, base_url=base_url) == 96_000
@pytest.mark.parametrize("lengths", [True, [128_000], "bad"])
def test_malformed_lengths_do_not_block_provider_persistence(isolated_home, monkeypatch, lengths):
(isolated_home / "context_length_cache.yaml").write_text(yaml.safe_dump({"context_lengths": lengths}))
monkeypatch.setattr(ba, "probe_bedrock_context_length", lambda *a: 96_000)
assert mm.get_model_context_length("xai.grok-4.6", provider="bedrock") == 96_000
assert mm.get_cached_context_length("xai.grok-4.6", "bedrock://") == 96_000
@pytest.mark.parametrize("marker", [True, "96000", 128_000, [96_000], {"source": "probe"}])
def test_malformed_or_mismatched_provenance_requires_revalidation(isolated_home, monkeypatch, marker):
model, base_url = "xai.grok-4.6", "bedrock://"
key = mm._context_cache_key(model, base_url)
(isolated_home / "context_length_cache.yaml").write_text(yaml.safe_dump({
"context_lengths": {key: 96_000}, "bedrock_confirmed_v1": {key: marker},
}))
probe = Mock(return_value=100_000)
monkeypatch.setattr(ba, "probe_bedrock_context_length", probe)
assert mm.get_model_context_length(model, provider="bedrock") == 100_000
probe.assert_called_once()
def test_context_local_profile_memos_do_not_cross_and_expired_rows_are_pruned(isolated_home, monkeypatch):
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
probe = Mock(side_effect=[None, None, 96_000])
monkeypatch.setattr(ba, "probe_bedrock_context_length", probe)
model = "global.xai.grok-4.6"
assert mm.get_model_context_length(model, provider="bedrock") == 500_000
# Leave a different model's expired row: lookup must prune it too.
assert mm.get_model_context_length("unknown.future", provider="bedrock") == ba.BEDROCK_DEFAULT_CONTEXT_LENGTH
for key in mm._BEDROCK_PROBE_FAILURE_CACHE:
if "unknown.future" in key:
mm._BEDROCK_PROBE_FAILURE_CACHE[key] = time.monotonic() - mm._BEDROCK_PROBE_FAILURE_TTL_SECONDS - 1
token = set_hermes_home_override(isolated_home / "routed-profile")
try:
assert mm.get_model_context_length(model, provider="bedrock") == 96_000
finally:
reset_hermes_home_override(token)
assert probe.call_count == 3
assert all("unknown.future" not in key for key in mm._BEDROCK_PROBE_FAILURE_CACHE)
assert mm.get_model_context_length(model, provider="bedrock") == 500_000
assert probe.call_count == 3