hermes-agent/tests/agent/test_auxiliary_compression_timeout_floor.py
Teknium eb6aa03609
feat(analytics): record auxiliary model usage per task in session accounting (#65537)
* feat(analytics): record auxiliary model usage per task in session accounting

Auxiliary LLM calls (vision, compression, title_generation, web_extract,
session_search, ...) discarded their token usage, leaving dashboard
analytics blind to aux model spend (issue #23270).

- hermes_state.py: session_model_usage gains a task PK dimension
  (''=main loop) via v22 table-rebuild migration (SQLite can't alter a
  PK); record_auxiliary_usage() writes per-(model,provider,task) deltas
  WITHOUT touching sessions counters (gateway overwrites those with
  absolute main-loop totals — folding aux in would double-count or be
  clobbered). Aux rows never inherit the session's main-loop route.
- agent/aux_accounting.py: ContextVar ambient accounting context
  (mirrors the portal_tags conversation context); record_aux_usage()
  normalizes usage via usage_pricing.normalize_usage, estimates cost,
  and is strictly best-effort. moa_reference/moa_aggregator excluded —
  conversation_loop already folds MoA usage+cost into the main delta.
- agent/auxiliary_client.py: _validate_llm_response is the recording
  chokepoint — every successful non-streaming aux response passes
  through it exactly once, sync and async, including fallback paths
  (model read from the response itself stays accurate across
  fallbacks).
- run_agent.py: run_conversation publishes/resets the accounting
  context; agent/title_generator.py republishes on its bare thread.
- hermes_cli/web_server.py: /api/analytics/usage folds aux rows into
  by_model (aux-only models finally appear) and adds a by_task
  summary; /api/analytics/models surfaces aux rows on the Models page.

Design per review of PR #62850 by @eeksock (thread-local + separate
auxiliary_usage table): rebuilt on ContextVar (async-safe — thread-local
cross-attributes concurrent coroutines on one event loop) and the
existing session_model_usage table instead of a parallel accounting
path, extended beyond vision to every aux task, and wired the analytics
endpoints so the dashboard actually shows it. Credit to @eeksock for
the approach and @tboatman for the detailed root-cause analysis.

* test(moa): match _validate_llm_response mock to new accounting-hint signature

* test(aux): accept accounting-hint kwargs in remaining _validate_llm_response mocks
2026-07-16 04:23:12 -07:00

189 lines
7.5 KiB
Python

"""Regression tests for the compression-scoped auxiliary timeout floor (#54915).
Context compression summarises large conversation histories. When the
resolved auxiliary provider is a reasoning model (e.g. Codex / GPT-5.5) the
summary can legitimately exceed the default ``auxiliary.compression.timeout``
of 120 s, causing the stream to time out and the compressor to fall back to a
deterministic context marker — silently losing the LLM summary.
The fix layers a *bounded* timeout floor on top of the config-derived
compression timeout, while honouring the four constraints from the issue:
* Only the ``compression`` task gets the floor (other auxiliary tasks keep
their own timeouts).
* An explicit per-call ``timeout=`` override is **not** floored.
* The floor is a minimum — a config value already above it is unchanged.
* Both the sync (``call_llm``) and async (``async_call_llm``) paths are
covered.
These tests exercise the real ``call_llm`` / ``async_call_llm`` production
paths with a mocked LLM client and assert the timeout that actually reaches
``client.chat.completions.create``.
"""
from unittest.mock import patch, MagicMock, AsyncMock
import pytest
from agent.auxiliary_client import call_llm, async_call_llm
# The committed bounded floor for config-derived compression timeouts.
# Behaviour contract (see AGENTS.md "Behavior contracts over snapshots"):
# compression's effective timeout must be at least this when it is
# config-derived.
COMPRESSION_TIMEOUT_FLOOR = 300.0
# The default ``auxiliary.compression.timeout`` shipped in the config schema
# (hermes_cli/config.py). Simulated here as the config-derived value.
COMPRESSION_CONFIG_TIMEOUT = 120.0
def _ok_response():
return {"ok": True}
def _client_sync():
client = MagicMock()
client.base_url = "https://api.openai.com/v1"
client.chat.completions.create.return_value = _ok_response()
return client
def _client_async():
client = MagicMock()
client.base_url = "https://api.openai.com/v1"
client.chat.completions.create = AsyncMock(return_value=_ok_response())
return client
def _patches(client, *, task_timeout):
"""Common mocks: provider resolution, cached client, response validation,
and the config-derived task timeout."""
return (
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("openai-codex", "gpt-5.5", None, None, None)),
patch("agent.auxiliary_client._get_cached_client",
return_value=(client, "gpt-5.5")),
patch("agent.auxiliary_client._validate_llm_response",
side_effect=lambda resp, _task, **_kw: resp),
patch("agent.auxiliary_client._get_task_timeout",
return_value=task_timeout),
)
class TestCompressionTimeoutFloorSync:
"""Sync ``call_llm`` applies the floor to config-derived compression timeouts."""
def test_config_derived_compression_timeout_is_raised_to_floor(self):
"""Layer 1: compression with a 120 s config timeout must reach the
client with at least the 300 s floor."""
client = _client_sync()
p1, p2, p3, p4 = _patches(client, task_timeout=COMPRESSION_CONFIG_TIMEOUT)
with p1, p2, p3, p4:
call_llm(
task="compression",
messages=[{"role": "user", "content": "summarise this"}],
)
timeout = client.chat.completions.create.call_args.kwargs["timeout"]
assert timeout >= COMPRESSION_TIMEOUT_FLOOR, (
f"compression timeout {timeout} should be >= floor "
f"{COMPRESSION_TIMEOUT_FLOOR}"
)
assert timeout > COMPRESSION_CONFIG_TIMEOUT, (
"the too-low config timeout must not pass through unchanged"
)
def test_explicit_per_call_timeout_is_not_floored(self):
"""Layer 3: an explicit per-call ``timeout=`` override is honoured
even when it is below the floor."""
client = _client_sync()
explicit = 60.0
p1, p2, p3, p4 = _patches(client, task_timeout=COMPRESSION_CONFIG_TIMEOUT)
with p1, p2, p3, p4:
call_llm(
task="compression",
messages=[{"role": "user", "content": "x"}],
timeout=explicit,
)
timeout = client.chat.completions.create.call_args.kwargs["timeout"]
assert timeout == explicit, (
f"explicit per-call timeout {explicit} must not be floored, got {timeout}"
)
def test_non_compression_task_is_not_floored(self):
"""Layer 4: only ``compression`` gets the floor; another auxiliary
task with the same low config timeout must pass it through."""
client = _client_sync()
low = 30.0
p1, p2, p3, p4 = _patches(client, task_timeout=low)
with p1, p2, p3, p4:
call_llm(
task="title_generation",
messages=[{"role": "user", "content": "x"}],
)
timeout = client.chat.completions.create.call_args.kwargs["timeout"]
assert timeout == low, (
f"non-compression task timeout must stay {low}, got {timeout}"
)
def test_higher_config_timeout_is_not_lowered(self):
"""Layer 5: the floor is a minimum — a config value already above it
is kept unchanged (``max`` semantics)."""
client = _client_sync()
high = 600.0
p1, p2, p3, p4 = _patches(client, task_timeout=high)
with p1, p2, p3, p4:
call_llm(
task="compression",
messages=[{"role": "user", "content": "x"}],
)
timeout = client.chat.completions.create.call_args.kwargs["timeout"]
assert timeout == high, (
f"config timeout {high} above the floor must be unchanged, got {timeout}"
)
class TestCompressionTimeoutFloorAsync:
"""Async ``async_call_llm`` mirrors the sync floor (Layer 2)."""
@pytest.mark.asyncio
async def test_async_config_derived_compression_timeout_is_raised_to_floor(self):
client = _client_async()
p1, p2, p3, p4 = _patches(client, task_timeout=COMPRESSION_CONFIG_TIMEOUT)
with p1, p2, p3, p4:
await async_call_llm(
task="compression",
messages=[{"role": "user", "content": "summarise this"}],
)
timeout = client.chat.completions.create.call_args.kwargs["timeout"]
assert timeout >= COMPRESSION_TIMEOUT_FLOOR, (
f"async compression timeout {timeout} should be >= floor "
f"{COMPRESSION_TIMEOUT_FLOOR}"
)
@pytest.mark.asyncio
async def test_async_explicit_per_call_timeout_is_not_floored(self):
client = _client_async()
explicit = 45.0
p1, p2, p3, p4 = _patches(client, task_timeout=COMPRESSION_CONFIG_TIMEOUT)
with p1, p2, p3, p4:
await async_call_llm(
task="compression",
messages=[{"role": "user", "content": "x"}],
timeout=explicit,
)
timeout = client.chat.completions.create.call_args.kwargs["timeout"]
assert timeout == explicit
@pytest.mark.asyncio
async def test_async_non_compression_task_is_not_floored(self):
client = _client_async()
low = 30.0
p1, p2, p3, p4 = _patches(client, task_timeout=low)
with p1, p2, p3, p4:
await async_call_llm(
task="session_search",
messages=[{"role": "user", "content": "x"}],
)
timeout = client.chat.completions.create.call_args.kwargs["timeout"]
assert timeout == low