Files
hermes-agent/tests/test_ollama_num_ctx.py
T
Teknium cb71d5f1b1 fix(agent_init): clamp compressor window to Ollama num_ctx resolved after construction
model.ollama_num_ctx is resolved AFTER the context compressor is
constructed, so a config that sets only ollama_num_ctx (without
model.context_length) ran every request at the smaller served num_ctx
while the compressor still targeted the probed GGUF window (e.g. 256K
Gemma metadata). The compaction trigger then sat several times above the
window the server actually serves and never fired — reproducing the
original #57275 'blows past the limit' symptom on current main.

Live repro (real imports, temp HERMES_HOME, config = {model:
{ollama_num_ctx: 65536}}, probed window 262144):
  before: _ollama_num_ctx=65536, compressor.context_length=262144,
          threshold_tokens=196608 (300% of the served window)
  after:  compressor.context_length=65536, threshold below the window

The clamp is one-directional (a num_ctx larger than the resolved window
never inflates the compressor) and reuses update_model() so every
threshold-derived budget recalibrates. Overlaps #60103 (silent-clamp
dead zone) — this is the init-order half.

Reported by @Artemonim in #57275 (residual claim 3).
2026-08-31 12:22:17 -07:00

190 lines
8.2 KiB
Python

"""Tests for Ollama num_ctx context length detection and injection.
Covers:
agent/model_metadata.py — query_ollama_num_ctx()
run_agent.py — _ollama_num_ctx detection + extra_body injection
"""
from unittest.mock import patch, MagicMock
from agent.model_metadata import query_ollama_num_ctx, query_ollama_supports_vision
# ═══════════════════════════════════════════════════════════════════════
# Level 1: query_ollama_num_ctx — Ollama API interaction
# ═══════════════════════════════════════════════════════════════════════
def _mock_httpx_client(show_response_data, status_code=200):
"""Create a mock httpx.Client context manager that returns given /api/show data."""
mock_resp = MagicMock(status_code=status_code)
mock_resp.json.return_value = show_response_data
mock_client = MagicMock()
mock_client.post.return_value = mock_resp
mock_ctx = MagicMock()
mock_ctx.__enter__ = MagicMock(return_value=mock_client)
mock_ctx.__exit__ = MagicMock(return_value=False)
return mock_ctx, mock_client
class TestQueryOllamaNumCtx:
"""Test the Ollama /api/show context length query."""
def test_returns_context_from_model_info(self):
"""Should extract context_length from GGUF model_info metadata."""
show_data = {
"model_info": {"llama.context_length": 131072},
"parameters": "",
}
mock_ctx, _ = _mock_httpx_client(show_data)
with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
# httpx is imported inside the function — patch the module import
import httpx
with patch.object(httpx, "Client", return_value=mock_ctx):
result = query_ollama_num_ctx("llama3.1:8b", "http://localhost:11434/v1")
assert result == 131072
def test_prefers_explicit_num_ctx_from_modelfile(self):
"""If the Modelfile sets num_ctx explicitly, that should take priority."""
show_data = {
"model_info": {"llama.context_length": 131072},
"parameters": "num_ctx 32768\ntemperature 0.7",
}
mock_ctx, _ = _mock_httpx_client(show_data)
with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
import httpx
with patch.object(httpx, "Client", return_value=mock_ctx):
result = query_ollama_num_ctx("custom-model", "http://localhost:11434")
assert result == 32768
def test_strips_provider_prefix(self):
"""Should strip 'local:' prefix from model name before querying."""
show_data = {
"model_info": {"qwen2.context_length": 32768},
"parameters": "",
}
mock_ctx, mock_client = _mock_httpx_client(show_data)
with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
import httpx
with patch.object(httpx, "Client", return_value=mock_ctx):
result = query_ollama_num_ctx("local:qwen2.5:7b", "http://localhost:11434/v1")
# Verify the post was called with stripped name (no "local:" prefix)
call_args = mock_client.post.call_args
assert call_args[1]["json"]["name"] == "qwen2.5:7b" or call_args[0][1] is not None
assert result == 32768
def test_handles_qwen2_architecture_key(self):
"""Different model architectures use different key prefixes in model_info."""
show_data = {
"model_info": {"qwen2.context_length": 65536},
"parameters": "",
}
mock_ctx, _ = _mock_httpx_client(show_data)
with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
import httpx
with patch.object(httpx, "Client", return_value=mock_ctx):
result = query_ollama_num_ctx("qwen2.5:32b", "http://localhost:11434")
assert result == 65536
class TestQueryOllamaSupportsVision:
"""Test Ollama /api/show vision capability detection."""
def test_returns_true_when_capabilities_include_vision(self):
show_data = {"capabilities": ["completion", "vision"]}
mock_ctx, _ = _mock_httpx_client(show_data)
with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
import httpx
with patch.object(httpx, "Client", return_value=mock_ctx):
result = query_ollama_supports_vision("gemma4:e2b", "http://localhost:11434/v1")
assert result is True
def test_falls_back_to_model_info_vision_block_count(self):
show_data = {"model_info": {"gemma3.vision.block_count": 27}}
mock_ctx, _ = _mock_httpx_client(show_data)
with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
import httpx
with patch.object(httpx, "Client", return_value=mock_ctx):
result = query_ollama_supports_vision("llava", "http://localhost:11434")
assert result is True
def test_returns_none_for_non_ollama_server(self):
with patch("agent.model_metadata.detect_local_server_type", return_value="vllm"):
result = query_ollama_supports_vision("llava", "http://localhost:8000/v1")
assert result is None
# ═══════════════════════════════════════════════════════════════════════
# Level 3: init-order — compressor window must clamp to effective num_ctx
# (#57275 residual claim 3 / #60103 init-order half)
# ═══════════════════════════════════════════════════════════════════════
class TestCompressorClampsToNumCtx:
"""A config setting ONLY model.ollama_num_ctx (no model.context_length)
must not leave the compressor targeting the probed model window while
requests run at the smaller served num_ctx."""
def _build_agent(self, cfg, probed_ctx):
import agent.context_compressor as cc_mod
with (
patch("run_agent.get_tool_definitions", return_value=[]),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("run_agent.OpenAI"),
patch("hermes_cli.config.load_config", return_value=cfg),
patch("hermes_cli.config.load_config_readonly", return_value=cfg),
patch(
"agent.model_metadata.get_model_context_length",
return_value=probed_ctx,
),
patch.object(
cc_mod, "get_model_context_length", return_value=probed_ctx,
),
):
from run_agent import AIAgent
return AIAgent(
model="gemma3:27b",
api_key="ollama",
base_url="http://localhost:11434/v1",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
def test_num_ctx_only_config_clamps_compressor_window(self):
agent = self._build_agent(
{"agent": {}, "model": {"ollama_num_ctx": 65536}}, probed_ctx=262144
)
assert agent._ollama_num_ctx == 65536
# The compressor must target the served window, not the probed 256K —
# otherwise its trigger sits far above what the server accepts and
# compaction never fires (#57275 claim 3).
assert agent.context_compressor.context_length == 65536
assert agent.context_compressor.threshold_tokens < 65536
def test_larger_num_ctx_does_not_inflate_compressor_window(self):
agent = self._build_agent(
{"agent": {}, "model": {"ollama_num_ctx": 131072}}, probed_ctx=65536
)
# num_ctx above the resolved window must not RAISE the compressor
# window: the clamp is one-directional.
assert agent.context_compressor.context_length == 65536