Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson
2026-05-30 13:19:27 -05:00
157 changed files with 10059 additions and 831 deletions
@@ -94,6 +94,47 @@ def test_install_npm_works_without_extras(tmp_path, monkeypatch):
assert install_targets == ["pyright"]
def test_existing_binary_finds_windows_wrapper_in_staging(tmp_path, monkeypatch):
"""Installed Windows shims should satisfy later status/probe calls."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from agent.lsp import install as install_mod
wrapper = install_mod.hermes_lsp_bin_dir() / "pyright-langserver.cmd"
wrapper.write_text("@echo off\n")
wrapper.chmod(0o755)
monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
monkeypatch.setattr(install_mod.shutil, "which", lambda _name: None)
assert install_mod._existing_binary("pyright-langserver") == str(wrapper)
assert install_mod.detect_status("pyright") == "installed"
def test_install_pip_finds_windows_scripts_launcher(tmp_path, monkeypatch):
"""pip console scripts can land in Scripts/ on native Windows."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from agent.lsp import install as install_mod
def fake_run(cmd, **kwargs):
scripts_dir = install_mod.hermes_lsp_bin_dir().parent / "python-packages" / "Scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
launcher = scripts_dir / "fake-language-server.exe"
launcher.write_text("launcher\n")
launcher.chmod(0o755)
return MagicMock(returncode=0, stderr="")
monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
monkeypatch.setattr(install_mod.subprocess, "run", fake_run)
resolved = install_mod._install_pip("fake-lsp", "fake-language-server")
assert resolved is not None
assert resolved.endswith("fake-language-server.exe")
assert (install_mod.hermes_lsp_bin_dir() / "fake-language-server.exe").exists()
# ---------------------------------------------------------------------------
# Fix 2: ``hermes lsp status`` surfaces shellcheck-missing for bash
# ---------------------------------------------------------------------------
+29
View File
@@ -41,6 +41,8 @@ class TestShouldCompress:
class TestUpdateFromResponse:
def test_updates_fields(self, compressor):
compressor.awaiting_real_usage_after_compression = True
compressor.last_compression_rough_tokens = 90_000
compressor.update_from_response({
"prompt_tokens": 5000,
"completion_tokens": 1000,
@@ -48,12 +50,39 @@ class TestUpdateFromResponse:
})
assert compressor.last_prompt_tokens == 5000
assert compressor.last_completion_tokens == 1000
assert compressor.last_real_prompt_tokens == 5000
assert compressor.last_rough_tokens_when_real_prompt_fit == 90_000
assert compressor.awaiting_real_usage_after_compression is False
def test_missing_fields_default_zero(self, compressor):
compressor.update_from_response({})
assert compressor.last_prompt_tokens == 0
class TestPreflightDeferral:
def test_defers_when_recent_real_usage_fit_and_rough_growth_is_small(self, compressor):
compressor.threshold_tokens = 85_000
compressor.last_real_prompt_tokens = 50_000
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
assert compressor.should_defer_preflight_to_real_usage(93_000) is True
assert compressor.last_rough_tokens_when_real_prompt_fit == 93_000
def test_does_not_defer_when_rough_growth_is_large(self, compressor):
compressor.threshold_tokens = 85_000
compressor.last_real_prompt_tokens = 50_000
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
assert compressor.should_defer_preflight_to_real_usage(100_000) is False
def test_does_not_defer_without_recent_real_usage(self, compressor):
compressor.threshold_tokens = 85_000
compressor.last_real_prompt_tokens = 0
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
assert compressor.should_defer_preflight_to_real_usage(93_000) is False
class TestCompress:
def _make_messages(self, n):
-62
View File
@@ -123,55 +123,6 @@ class TestEstimateMessagesTokensRough:
# =========================================================================
class TestDefaultContextLengths:
def test_claude_models_context_lengths(self):
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
if "claude" not in key:
continue
# Claude 4.6+ models (4.6, 4.7, 4.8) have 1M context at standard
# API pricing (no long-context premium). Older Claude 4.x and
# 3.x models cap at 200k.
if any(tag in key for tag in ("4.6", "4-6", "4.7", "4-7", "4.8", "4-8")):
assert value == 1000000, f"{key} should be 1000000"
else:
assert value == 200000, f"{key} should be 200000"
def test_gpt4_models_128k_or_1m(self):
# gpt-4.1 and gpt-4.1-mini have 1M context; other gpt-4* have 128k
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
if "gpt-4" in key and "gpt-4.1" not in key:
assert value == 128000, f"{key} should be 128000"
def test_gpt41_models_1m(self):
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
if "gpt-4.1" in key:
assert value == 1047576, f"{key} should be 1047576"
def test_gemini_models_1m(self):
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
if "gemini" in key:
assert value == 1048576, f"{key} should be 1048576"
def test_grok_models_context_lengths(self):
# xAI /v1/models does not return context_length metadata, so
# DEFAULT_CONTEXT_LENGTHS must cover the Grok family explicitly.
# Values sourced from models.dev (2026-04).
expected = {
"grok-4.20": 2000000,
"grok-4-fast": 2000000,
"grok-4": 256000,
"grok-build": 256000,
"grok-code-fast": 256000,
"grok-3": 131072,
"grok-2": 131072,
"grok-2-vision": 8192,
"grok": 131072,
}
for key, value in expected.items():
assert key in DEFAULT_CONTEXT_LENGTHS, f"{key} missing from DEFAULT_CONTEXT_LENGTHS"
assert DEFAULT_CONTEXT_LENGTHS[key] == value, (
f"{key} should be {value}, got {DEFAULT_CONTEXT_LENGTHS[key]}"
)
def test_grok_substring_matching(self):
# Longest-first substring matching must resolve the real xAI model
# IDs to the correct fallback entries without 128k probe-down.
@@ -268,13 +219,6 @@ class TestDefaultContextLengths:
f"{model_id}: expected {expected_ctx}, got {actual}"
)
def test_all_values_positive(self):
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
assert value > 0, f"{key} has non-positive context length"
def test_dict_is_not_empty(self):
assert len(DEFAULT_CONTEXT_LENGTHS) >= 10
# =========================================================================
# Codex OAuth context-window resolution (provider="openai-codex")
@@ -1141,12 +1085,6 @@ class TestContextProbeTiers:
for i in range(len(CONTEXT_PROBE_TIERS) - 1):
assert CONTEXT_PROBE_TIERS[i] > CONTEXT_PROBE_TIERS[i + 1]
def test_first_tier_is_256k(self):
assert CONTEXT_PROBE_TIERS[0] == 256_000
def test_last_tier_is_8k(self):
assert CONTEXT_PROBE_TIERS[-1] == 8_000
class TestGetNextProbeTier:
def test_from_256k(self):
-11
View File
@@ -82,17 +82,6 @@ SAMPLE_REGISTRY = {
class TestProviderMapping:
def test_all_mapped_providers_are_strings(self):
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
assert isinstance(hermes_id, str)
assert isinstance(mdev_id, str)
def test_known_providers_mapped(self):
assert PROVIDER_TO_MODELS_DEV["anthropic"] == "anthropic"
assert PROVIDER_TO_MODELS_DEV["copilot"] == "github-copilot"
assert PROVIDER_TO_MODELS_DEV["stepfun"] == "stepfun"
assert PROVIDER_TO_MODELS_DEV["kilocode"] == "kilo"
def test_xai_oauth_uses_xai_catalog(self):
assert PROVIDER_TO_MODELS_DEV["xai"] == "xai"
assert PROVIDER_TO_MODELS_DEV["xai-oauth"] == "xai"
@@ -0,0 +1,141 @@
"""Regression coverage for #35344: a resumed session must not let a stale
``## Active Task`` from an inherited compaction handoff hijack the reply to a
new, unrelated user message.
The failure mode (real report): a lineage was compacted, producing a handoff
whose ``## Active Task`` described task A. The lineage was resumed later and
the user asked about an unrelated task B. The model answered with A because
the handoff's resume directive outranked the fresh ask.
The structural fix lives in ``SUMMARY_PREFIX``: the handoff is framed as
reference-only and the latest user message explicitly *wins* on conflict, with
named reverse-signal verbs. Two invariants guard the resume path specifically:
1. A handoff persisted under the OLD (conflicting) prefix is re-normalized to
the CURRENT prefix when it is re-compacted on a resumed lineage — so a
pre-fix stale handoff cannot keep its "resume exactly" directive forever.
2. The current handoff prefix contains an unambiguous "latest message wins /
discard stale Active Task" rule, so an unrelated new ask is privileged over
the inherited ``## Active Task``.
These are content/structural assertions (no live model call) — they pin the
mechanism that makes the stale task historical rather than active.
"""
from agent.context_compressor import (
SUMMARY_PREFIX,
LEGACY_SUMMARY_PREFIX,
ContextCompressor,
)
# The conflicting prefix that shipped before the #35344 fix. A handoff
# persisted in a resumed lineage could carry this verbatim.
_OLD_CONFLICTING_PREFIX = (
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Your current task is identified in the '## Active Task' section of the "
"summary — resume exactly from there. "
"Respond ONLY to the latest user message "
"that appears AFTER this summary. The current session state (files, "
"config, etc.) may reflect work described here — avoid repeating it:"
)
def test_latest_message_wins_over_inherited_active_task():
"""The handoff must explicitly privilege the latest user message over a
stale ``## Active Task`` — the core #35344 contract."""
lower = SUMMARY_PREFIX.lower()
assert "latest user message" in lower
assert "## active task" in lower
# Conflict-resolution must be explicit, not implied.
assert "wins" in lower or "supersede" in lower
assert "discard" in lower
def test_no_resume_exactly_directive_can_hijack():
"""The directive that caused the hijack ("resume exactly from Active
Task") must be gone."""
assert "resume exactly" not in SUMMARY_PREFIX.lower()
def test_resumed_stale_handoff_gets_renormalized_to_current_prefix():
"""A handoff persisted under the OLD conflicting prefix (e.g. saved before
the fix and inherited into a resumed lineage) is upgraded to the CURRENT
prefix when re-normalized on re-compaction — so the "resume exactly"
directive cannot survive into a resumed session."""
stale_body = (
"## Active Task\n"
"User asked: 'Migrate the billing module to Stripe'\n\n"
"## Goal\nMigrate billing.\n"
)
stale_handoff = f"{_OLD_CONFLICTING_PREFIX}\n{stale_body}"
# Sanity: the fixture really does carry the old directive.
assert "resume exactly" in stale_handoff.lower()
renormalized = ContextCompressor._with_summary_prefix(stale_handoff)
# The body is preserved...
assert "Migrate the billing module to Stripe" in renormalized
# ...but the conflicting directive is stripped and replaced with the
# current latest-message-wins framing.
assert "resume exactly" not in renormalized.lower()
assert renormalized.startswith(SUMMARY_PREFIX)
assert "wins" in renormalized.lower()
def test_legacy_prefix_handoff_also_renormalized():
"""The same upgrade applies to the oldest ``[CONTEXT SUMMARY]:`` handoff
format that may sit in a long-lived resumed lineage."""
legacy = f"{LEGACY_SUMMARY_PREFIX} ## Active Task\nUser asked: 'task A'"
renormalized = ContextCompressor._with_summary_prefix(legacy)
assert renormalized.startswith(SUMMARY_PREFIX)
assert LEGACY_SUMMARY_PREFIX not in renormalized
assert "task A" in renormalized
def test_inherited_handoff_detected_in_resumed_protected_head():
"""On a resumed lineage the handoff commonly sits right after the system
prompt (in the protected head). ``_find_latest_context_summary`` must
detect it there so re-compaction rehydrates state from it rather than
serializing it as a fresh user turn (which is what let the stale Active
Task read as live intent)."""
messages = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": f"{SUMMARY_PREFIX}\n## Active Task\nUser asked: 'task A'"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "Unrelated task B: what's the capital of France?"},
]
# Search the whole post-system range.
idx, body = ContextCompressor._find_latest_context_summary(
messages, 1, len(messages)
)
assert idx == 1, "handoff in protected head must be found"
assert "task A" in body
# The detected body is stripped of the prefix (treated as state, not a
# standalone instruction message).
assert not body.startswith(SUMMARY_PREFIX)
def test_historical_prefixed_handoff_detected_and_stripped():
"""A pre-fix handoff (old conflicting prefix) inherited into a resumed
lineage must still be recognized as a context summary AND have its old
directive stripped on detection — otherwise re-compaction serializes the
stale 'resume exactly' text as a fresh turn."""
messages = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": f"{_OLD_CONFLICTING_PREFIX}\n## Active Task\nUser asked: 'task A'"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "Unrelated task B"},
]
idx, body = ContextCompressor._find_latest_context_summary(
messages, 1, len(messages)
)
assert idx == 1
assert "task A" in body
assert "resume exactly" not in body.lower()
@@ -0,0 +1,226 @@
"""Regression test: set_runtime_main() must pass base_url/api_key/api_mode
so that _resolve_auto() can route custom: providers in Step 1.
Fixes https://github.com/NousResearch/hermes-agent/issues/34777
"""
import pytest
from unittest.mock import patch, MagicMock
def _get_globals(mod):
"""Read runtime globals without triggering redaction."""
return {
"provider": mod._RUNTIME_MAIN_PROVIDER,
"model": mod._RUNTIME_MAIN_MODEL,
"base_url": mod._RUNTIME_MAIN_BASE_URL,
"cred": mod._RUNTIME_MAIN_API_KEY, # renamed to avoid redaction
"api_mode": mod._RUNTIME_MAIN_API_MODE,
}
class TestSetRuntimeMainCustomProvider:
"""set_runtime_main must propagate base_url/api_key/api_mode for custom providers."""
def test_globals_stored(self):
"""set_runtime_main stores all five fields in process-local globals."""
import agent.auxiliary_client as mod
mod.clear_runtime_main()
try:
mod.set_runtime_main(
"custom:my-router",
"glm-5.1",
base_url="https://my-server.example.com/v1",
api_key="sk-test-key",
api_mode="chat_completions",
)
g = _get_globals(mod)
assert g["provider"] == "custom:my-router"
assert g["model"] == "glm-5.1"
assert g["base_url"] == "https://my-server.example.com/v1"
assert g["cred"] == "sk-test-key"
assert g["api_mode"] == "chat_completions"
finally:
mod.clear_runtime_main()
def test_clear_resets_all_globals(self):
"""clear_runtime_main resets all five globals to empty."""
import agent.auxiliary_client as mod
mod.set_runtime_main(
"custom:x", "m",
base_url="https://x.example.com",
api_key="sk-abc",
api_mode="chat_completions",
)
mod.clear_runtime_main()
g = _get_globals(mod)
for v in g.values():
assert v == "", f"Expected empty, got {v!r}"
def test_resolve_auto_uses_globals_for_custom_provider(self):
"""_resolve_auto reads base_url/api_key from globals when main_runtime is None."""
import agent.auxiliary_client as mod
mod.clear_runtime_main()
try:
mod.set_runtime_main(
"custom:test-router",
"test-model",
base_url="https://custom-endpoint.example.com/v1",
api_key="sk-test-123",
)
with patch.object(mod, "resolve_provider_client") as mock_resolve:
mock_resolve.return_value = (MagicMock(), "test-model")
client, resolved = mod._resolve_auto(main_runtime=None)
mock_resolve.assert_called_once()
call_args = mock_resolve.call_args
assert call_args[0][0] == "custom"
assert call_args[1]["explicit_base_url"] == "https://custom-endpoint.example.com/v1"
assert call_args[1]["explicit_api_key"] == "sk-test-123"
finally:
mod.clear_runtime_main()
def test_explicit_main_runtime_takes_precedence(self):
"""When main_runtime dict has values, globals are NOT used."""
import agent.auxiliary_client as mod
mod.clear_runtime_main()
try:
mod.set_runtime_main(
"custom:router-a",
"model-a",
base_url="https://from-global.example.com",
api_key="sk-global",
)
with patch.object(mod, "resolve_provider_client") as mock_resolve:
mock_resolve.return_value = (MagicMock(), "model-b")
main_rt = {
"provider": "custom:router-b",
"model": "model-b",
"base_url": "https://from-dict.example.com",
"api_key": "sk-dict",
}
mod._resolve_auto(main_runtime=main_rt)
call_args = mock_resolve.call_args[1]
assert call_args["explicit_base_url"] == "https://from-dict.example.com"
assert call_args["explicit_api_key"] == "sk-dict"
finally:
mod.clear_runtime_main()
def test_backward_compatible_defaults(self):
"""Calling set_runtime_main with only positional args still works."""
import agent.auxiliary_client as mod
mod.clear_runtime_main()
try:
mod.set_runtime_main("openrouter", "gpt-4o")
g = _get_globals(mod)
assert g["provider"] == "openrouter"
assert g["model"] == "gpt-4o"
assert g["base_url"] == ""
assert g["cred"] == ""
assert g["api_mode"] == ""
finally:
mod.clear_runtime_main()
class TestResolveAutoCustomEndToEnd:
"""End-to-end routing assertions — build a *real* client (no mock on
resolve_provider_client) and verify the auxiliary auto-detect chain lands
on the user's custom endpoint instead of falling through to the aggregator
chain. These guard the actual user-visible symptom in #34777 (aux tasks
silently routed to a fallback provider) rather than just the wiring.
"""
@staticmethod
def _client_base_url(client):
for chain in (("base_url",), ("_client", "base_url")):
obj = client
try:
for attr in chain:
obj = getattr(obj, attr)
return str(obj)
except AttributeError:
continue
return None
def test_config_less_custom_endpoint_routes_via_global(self, tmp_path, monkeypatch):
"""custom:<name> with NO config entry: the live base_url carried by
set_runtime_main() must build a real client at that endpoint — not
fall through to Step 2 (the regression in #34777)."""
import agent.auxiliary_client as mod
# Hermetic: no aggregator creds, no stale OPENAI_BASE_URL.
for var in ("OPENROUTER_API_KEY", "NOUS_API_KEY", "OPENAI_API_KEY",
"OPENAI_BASE_URL"):
monkeypatch.delenv(var, raising=False)
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"model:\n"
" default: glm-5.1\n"
" provider: 'custom:ephemeral'\n"
" base_url: ''\n"
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
mod.clear_runtime_main()
try:
mod.set_runtime_main(
"custom:ephemeral",
"glm-5.1",
base_url="https://ephemeral.live/v1",
api_key="sk-live",
)
client, resolved = mod.resolve_provider_client("auto", None)
assert client is not None, (
"config-less custom endpoint fell through to Step 2 — "
"the #34777 bug is back"
)
assert resolved == "glm-5.1"
base = self._client_base_url(client)
assert base and base.rstrip("/") == "https://ephemeral.live/v1"
finally:
mod.clear_runtime_main()
def test_named_custom_with_config_entry_still_routes(self, tmp_path, monkeypatch):
"""Regression guard: custom:<name> WITH a custom_providers entry must
still resolve to that entry's endpoint. An earlier competing fix
collapsed the provider to bare ``custom`` before resolution, which
broke the named-custom branch and returned None here."""
import agent.auxiliary_client as mod
for var in ("OPENROUTER_API_KEY", "NOUS_API_KEY", "OPENAI_API_KEY",
"OPENAI_BASE_URL"):
monkeypatch.delenv(var, raising=False)
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"model:\n"
" default: glm-5.1\n"
" provider: 'custom:openclaw'\n"
" base_url: ''\n"
"custom_providers:\n"
" - name: openclaw\n"
" base_url: 'https://withcfg.example/v1'\n"
" model: glm-5.1\n"
" api_key: cfg-key\n"
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# No live base_url carried — resolution must come from config alone,
# via the named-custom branch in resolve_provider_client.
mod.clear_runtime_main()
try:
mod.set_runtime_main("custom:openclaw", "glm-5.1")
client, resolved = mod.resolve_provider_client("auto", None)
assert client is not None
base = self._client_base_url(client)
assert base and base.rstrip("/") == "https://withcfg.example/v1"
finally:
mod.clear_runtime_main()
@@ -0,0 +1,62 @@
"""Pin the semantics of SUMMARY_PREFIX so the compaction handoff doesn't
re-introduce conflicting instructions.
Background: SUMMARY_PREFIX previously contained two contradictory directives:
1. "treat it as background reference, NOT as active instructions"
"Do NOT answer questions or fulfill requests mentioned in this summary"
"Respond ONLY to the latest user message that appears AFTER this summary"
2. "Your current task is identified in the '## Active Task' section of the
summary — resume exactly from there."
When the latest user message contradicted Active Task (e.g. "stop the
i18n refactor", "never mind, look at grafana"), the model often followed
(2) anyway because "resume exactly" is a strong directive — leading to
the agent repeatedly re-surfacing already-cancelled work across turns.
These tests pin the post-fix invariants so the conflict cannot regress.
"""
from agent.context_compressor import SUMMARY_PREFIX
def test_no_resume_exactly_directive():
"""The prefix must not tell the model to resume Active Task verbatim."""
assert "resume exactly" not in SUMMARY_PREFIX.lower()
def test_latest_message_wins_on_conflict():
"""The prefix must explicitly say latest user message wins on conflict."""
lower = SUMMARY_PREFIX.lower()
assert "latest user message" in lower
# Must have an explicit conflict-resolution rule.
assert "wins" in lower or "supersede" in lower or "discard" in lower
def test_reverse_signals_called_out():
"""Reverse signals (stop/undo/never mind/topic change) must be named so
the model recognizes them as cancellation triggers, not just background."""
lower = SUMMARY_PREFIX.lower()
# At least a few of the canonical reverse-signal verbs should appear.
reverse_terms = ["stop", "undo", "roll back", "never mind", "just verify"]
hits = sum(1 for t in reverse_terms if t in lower)
assert hits >= 3, (
f"Expected ≥3 reverse-signal terms in SUMMARY_PREFIX, found {hits}. "
"Without naming them the model treats reverse signals as ordinary "
"context and keeps pushing the cancelled task."
)
def test_summary_marked_reference_only():
"""The REFERENCE ONLY framing must remain — it's the entire point."""
assert "REFERENCE ONLY" in SUMMARY_PREFIX
assert "background reference" in SUMMARY_PREFIX
assert "NOT as active instructions" in SUMMARY_PREFIX
def test_memory_authority_preserved():
"""The fix must not weaken the MEMORY.md / USER.md authority clause."""
assert "MEMORY.md" in SUMMARY_PREFIX
assert "USER.md" in SUMMARY_PREFIX
assert "authoritative" in SUMMARY_PREFIX