Merge remote-tracking branch 'origin/main' into bb/gui

# Conflicts:
#	apps/dashboard/src/i18n/af.ts
#	apps/dashboard/src/i18n/de.ts
#	apps/dashboard/src/i18n/es.ts
#	apps/dashboard/src/i18n/fr.ts
#	apps/dashboard/src/i18n/ga.ts
#	apps/dashboard/src/i18n/hu.ts
#	apps/dashboard/src/i18n/it.ts
#	apps/dashboard/src/i18n/ja.ts
#	apps/dashboard/src/i18n/ko.ts
#	apps/dashboard/src/i18n/pt.ts
#	apps/dashboard/src/i18n/ru.ts
#	apps/dashboard/src/i18n/tr.ts
#	apps/dashboard/src/i18n/uk.ts
#	apps/dashboard/src/i18n/zh-hant.ts
#	gateway/config.py
#	hermes_cli/main.py
#	plugins/strike-freedom-cockpit/README.md
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson
2026-05-11 16:40:09 -04:00
310 changed files with 43818 additions and 4220 deletions
+383
View File
@@ -2123,6 +2123,227 @@ class TestCodexAuxiliaryAdapterTimeout:
assert time.monotonic() - started < 0.14
# ---------------------------------------------------------------------------
# Issue #23432 — auxiliary timeout poisons cached client; later aux calls fail
# ---------------------------------------------------------------------------
class TestAuxiliaryClientPoisonedCacheEviction:
"""Connection/timeout errors must evict the cached aux client.
Otherwise the next auxiliary call (compression retry, memory flush,
background review) reuses the closed httpx transport and fails with
``Connection error`` even though the main provider route is healthy.
See https://github.com/NousResearch/hermes-agent/issues/23432.
"""
def test_evict_cached_client_instance_drops_direct_match(self):
from agent.auxiliary_client import (
_client_cache, _client_cache_lock, _evict_cached_client_instance,
)
target = MagicMock(name="target_client")
other = MagicMock(name="other_client")
with _client_cache_lock:
_client_cache.clear()
_client_cache[("openrouter", False, None, None, None)] = (target, "x", None)
_client_cache[("anthropic", False, None, None, None)] = (other, "y", None)
try:
assert _evict_cached_client_instance(target) is True
assert ("openrouter", False, None, None, None) not in _client_cache
assert ("anthropic", False, None, None, None) in _client_cache
finally:
with _client_cache_lock:
_client_cache.clear()
def test_evict_cached_client_instance_walks_codex_wrapper(self):
"""Closing the underlying OpenAI client must evict the Codex shim."""
from agent.auxiliary_client import (
_client_cache, _client_cache_lock, _evict_cached_client_instance,
CodexAuxiliaryClient,
)
real = SimpleNamespace(api_key="k", base_url="https://chatgpt.com/backend-api/codex",
responses=SimpleNamespace(stream=lambda **k: None),
close=lambda: None)
wrapper = CodexAuxiliaryClient(real, "gpt-5.5")
with _client_cache_lock:
_client_cache.clear()
_client_cache[("openai-codex", False, None, None, None)] = (wrapper, "gpt-5.5", None)
try:
# Eviction by the inner OpenAI client must remove the wrapper entry.
assert _evict_cached_client_instance(real) is True
assert ("openai-codex", False, None, None, None) not in _client_cache
finally:
with _client_cache_lock:
_client_cache.clear()
def test_evict_cached_client_instance_handles_none_and_misses(self):
from agent.auxiliary_client import _evict_cached_client_instance
assert _evict_cached_client_instance(None) is False
assert _evict_cached_client_instance(MagicMock()) is False
def test_evict_cached_client_instance_walks_async_wrapper(self):
"""async_mode is part of the cache key so sync and async share the same
underlying OpenAI client across two distinct cache entries. A single
timeout that closes the leaf must evict BOTH — otherwise the async
entry survives, keeps reusing the dead transport, and every async
aux call (compression, vision, session_search) fails fast with
'Connection error' until gateway restart even while the sync route
recovers.
Regression for the async-side gap left by #23482, which fixed the
sync wrapper's _real_client walk but missed the async wrappers.
"""
from agent.auxiliary_client import (
_client_cache, _client_cache_lock, _evict_cached_client_instance,
CodexAuxiliaryClient, AsyncCodexAuxiliaryClient,
)
real = SimpleNamespace(api_key="k", base_url="https://chatgpt.com/backend-api/codex",
responses=SimpleNamespace(stream=lambda **k: None),
close=lambda: None)
sync_wrapper = CodexAuxiliaryClient(real, "gpt-5.5")
async_wrapper = AsyncCodexAuxiliaryClient(sync_wrapper)
with _client_cache_lock:
_client_cache.clear()
_client_cache[("openai-codex", False, None, None, None)] = (sync_wrapper, "gpt-5.5", None)
_client_cache[("openai-codex", True, None, None, None)] = (async_wrapper, "gpt-5.5", None)
try:
assert _evict_cached_client_instance(real) is True
assert ("openai-codex", False, None, None, None) not in _client_cache
assert ("openai-codex", True, None, None, None) not in _client_cache, (
"async cache entry survived eviction — wrapper is missing _real_client"
)
finally:
with _client_cache_lock:
_client_cache.clear()
def test_codex_timeout_evicts_cached_wrapper(self):
"""The timeout closer evicts the cache entry that wraps the closed client."""
from agent.auxiliary_client import (
_client_cache, _client_cache_lock,
_CodexCompletionsAdapter, CodexAuxiliaryClient,
)
class SlowAliveStream:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
for _ in range(20):
time.sleep(0.01)
yield SimpleNamespace(type="response.in_progress")
def get_final_response(self): # pragma: no cover — timeout fires first
return SimpleNamespace(output=[], usage=None)
closed = {"flag": False}
class FakeClient:
def __init__(self):
self.responses = SimpleNamespace(stream=lambda **k: SlowAliveStream())
self.api_key = "k"
self.base_url = "https://chatgpt.com/backend-api/codex"
def close(self):
closed["flag"] = True
fake_real = FakeClient()
wrapper = CodexAuxiliaryClient(fake_real, "gpt-5.5")
cache_key = ("openai-codex", False, None, None, None)
with _client_cache_lock:
_client_cache.clear()
_client_cache[cache_key] = (wrapper, "gpt-5.5", None)
try:
adapter = _CodexCompletionsAdapter(fake_real, "gpt-5.5")
with pytest.raises(TimeoutError):
adapter.create(
messages=[{"role": "user", "content": "x"}],
timeout=0.05,
)
assert closed["flag"] is True, "timeout closer must close inner client"
assert cache_key not in _client_cache, (
"timeout closer must evict cache entry that wraps the closed client"
)
finally:
with _client_cache_lock:
_client_cache.clear()
def test_call_llm_evicts_on_connection_error_with_explicit_provider(self):
"""Connection error on an explicit provider must drop the cached client.
This is the exact reporter scenario: ``auxiliary.compression.provider:
main`` (resolves to ``openai-codex``) → no fallback chain runs (not
auto), but the cached client was poisoned by a prior timeout and must
be evicted so the next call rebuilds.
"""
from agent.auxiliary_client import _client_cache, _client_cache_lock
poisoned = MagicMock(name="poisoned_client")
poisoned.base_url = "https://chatgpt.com/backend-api/codex"
poisoned.chat.completions.create.side_effect = ConnectionError("transport closed")
cache_key = ("openai-codex", False, None, None, None)
with _client_cache_lock:
_client_cache.clear()
_client_cache[cache_key] = (poisoned, "gpt-5.5", None)
try:
with 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=(poisoned, "gpt-5.5"),
):
with pytest.raises(ConnectionError):
call_llm(
task="compression",
messages=[{"role": "user", "content": "x"}],
)
assert cache_key not in _client_cache, (
"connection error must evict cached client so the next call rebuilds"
)
finally:
with _client_cache_lock:
_client_cache.clear()
@pytest.mark.asyncio
async def test_async_call_llm_evicts_on_connection_error_with_explicit_provider(self):
from agent.auxiliary_client import _client_cache, _client_cache_lock
poisoned = MagicMock(name="poisoned_async_client")
poisoned.base_url = "https://chatgpt.com/backend-api/codex"
poisoned.chat.completions.create = AsyncMock(side_effect=ConnectionError("transport closed"))
cache_key = ("openai-codex", True, None, None, None)
with _client_cache_lock:
_client_cache.clear()
_client_cache[cache_key] = (poisoned, "gpt-5.5", None)
try:
with 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=(poisoned, "gpt-5.5"),
):
with pytest.raises(ConnectionError):
await async_call_llm(
task="compression",
messages=[{"role": "user", "content": "x"}],
)
assert cache_key not in _client_cache
finally:
with _client_cache_lock:
_client_cache.clear()
# ---------------------------------------------------------------------------
# _build_call_kwargs — tool dedup at API boundary
# ---------------------------------------------------------------------------
@@ -2311,3 +2532,165 @@ class TestAnthropicExplicitApiKey:
assert mock_build.call_args.args[0] == "explicit-fallback-key", (
"resolve_provider_client must forward explicit_api_key to _try_anthropic()"
)
# ── Auxiliary unhealthy-provider TTL cache (issue #23570) ────────────────
class TestAuxUnhealthyCache:
"""Recently-402'd providers are skipped on subsequent aux calls.
Without this, every compression / title-gen / session-search call on a
long session retries a depleted OpenRouter (~1 RTT to 402) before
falling back to the next provider. The TTL cache hides the unhealthy
provider for ``_AUX_UNHEALTHY_TTL_SECONDS`` so the chain skips it.
"""
def setup_method(self):
from agent.auxiliary_client import _reset_aux_unhealthy_cache
_reset_aux_unhealthy_cache()
def teardown_method(self):
from agent.auxiliary_client import _reset_aux_unhealthy_cache
_reset_aux_unhealthy_cache()
def test_mark_then_skip(self):
from agent.auxiliary_client import (
_mark_provider_unhealthy,
_is_provider_unhealthy,
)
assert _is_provider_unhealthy("openrouter") is False
_mark_provider_unhealthy("openrouter")
assert _is_provider_unhealthy("openrouter") is True
def test_ttl_expiry_evicts(self):
from agent.auxiliary_client import (
_mark_provider_unhealthy,
_is_provider_unhealthy,
_aux_unhealthy_until,
)
_mark_provider_unhealthy("openrouter", ttl=0.01)
assert _is_provider_unhealthy("openrouter") is True
import time
time.sleep(0.02)
# Lazy eviction: first lookup after expiry returns False AND removes the entry.
assert _is_provider_unhealthy("openrouter") is False
assert "openrouter" not in _aux_unhealthy_until
def test_alias_normalization(self):
"""'codex' should normalize to 'openai-codex' so the cache lookup
matches the chain label."""
from agent.auxiliary_client import (
_mark_provider_unhealthy,
_is_provider_unhealthy,
)
_mark_provider_unhealthy("codex")
assert _is_provider_unhealthy("openai-codex") is True
def test_resolve_auto_skips_unhealthy_step2(self):
"""_resolve_auto Step-2 chain skips unhealthy providers."""
from agent.auxiliary_client import (
_resolve_auto,
_mark_provider_unhealthy,
)
nous_client = MagicMock()
# Mark OpenRouter unhealthy → chain should skip it and pick nous.
_mark_provider_unhealthy("openrouter")
with patch("agent.auxiliary_client._read_main_provider", return_value=""), \
patch("agent.auxiliary_client._read_main_model", return_value=""), \
patch("agent.auxiliary_client._try_openrouter") as or_try, \
patch("agent.auxiliary_client._try_nous", return_value=(nous_client, "nous-model")), \
patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
client, model = _resolve_auto()
assert client is nous_client
assert model == "nous-model"
# The skipped provider's _try_* should NOT have been called at all.
or_try.assert_not_called()
def test_resolve_auto_skips_unhealthy_main_in_step1(self):
"""Step-1 also consults the unhealthy cache so a depleted main
provider doesn't burn a 402 RTT every aux call. Falls through to
Step-2 chain (which also respects the cache)."""
from agent.auxiliary_client import (
_resolve_auto,
_mark_provider_unhealthy,
)
nous_client = MagicMock()
_mark_provider_unhealthy("openrouter")
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4.6"), \
patch("agent.auxiliary_client.resolve_provider_client") as step1, \
patch("agent.auxiliary_client._try_openrouter") as or_try, \
patch("agent.auxiliary_client._try_nous", return_value=(nous_client, "n-model")), \
patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
client, model = _resolve_auto()
# Step-1 was bypassed — resolve_provider_client never invoked
step1.assert_not_called()
# Step-2 also skipped openrouter and landed on nous
or_try.assert_not_called()
assert client is nous_client
def test_payment_fallback_skips_unhealthy(self):
"""_try_payment_fallback also consults the unhealthy cache so a 402
on OpenRouter doesn't cause a second OR call within the same chain
iteration if it gets re-entered."""
from agent.auxiliary_client import (
_try_payment_fallback,
_mark_provider_unhealthy,
)
nous_client = MagicMock()
# Mark BOTH the failed provider (openrouter) and a sibling (custom)
# unhealthy. The chain should still find nous.
_mark_provider_unhealthy("local/custom")
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
patch("agent.auxiliary_client._try_openrouter") as or_try, \
patch("agent.auxiliary_client._try_nous", return_value=(nous_client, "n-model")), \
patch("agent.auxiliary_client._try_custom_endpoint") as custom_try, \
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
client, model, label = _try_payment_fallback("openrouter", task="compression")
assert client is nous_client
assert label == "nous"
# OR is skipped via skip_chain_labels (failed provider), custom via unhealthy cache.
or_try.assert_not_called()
custom_try.assert_not_called()
def test_call_llm_marks_provider_unhealthy_on_402(self, monkeypatch):
"""A 402 from call_llm causes the provider to be marked unhealthy
so the next call skips it instead of re-trying the same depleted
endpoint."""
from agent.auxiliary_client import (
call_llm,
_is_provider_unhealthy,
)
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
# base_url tells _recoverable_pool_provider() that this is OpenRouter
# (resolved_provider="auto" doesn't carry that information by itself).
primary_client.base_url = "https://openrouter.ai/api/v1/"
err = Exception("Payment Required: insufficient credits")
err.status_code = 402
primary_client.chat.completions.create.side_effect = err
nous_client = MagicMock()
nous_resp = MagicMock()
nous_resp.choices = [MagicMock(message=MagicMock(content="ok"))]
nous_client.chat.completions.create.return_value = nous_resp
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "google/gemini-3-flash-preview")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", "google/gemini-3-flash-preview", None, None, None)), \
patch("agent.auxiliary_client._try_payment_fallback",
return_value=(nous_client, "n-model", "nous")), \
patch("agent.auxiliary_client._build_call_kwargs",
return_value={"model": "n-model", "messages": [{"role": "user", "content": "hi"}]}):
assert _is_provider_unhealthy("openrouter") is False
call_llm(
task="compression",
messages=[{"role": "user", "content": "hi"}],
)
# After the 402, OpenRouter is in the unhealthy cache.
assert _is_provider_unhealthy("openrouter") is True
+103
View File
@@ -1020,3 +1020,106 @@ def test_rename_summary_mixed_consolidation_and_pruning(curator_env):
assert merge_idx < drop_idx, "consolidated should render before pruned"
assert "merge-me → umbrella" in lines[merge_idx]
assert "drop-me — pruned (stale)" in lines[drop_idx]
# ---------------------------------------------------------------------------
# Pin hint — surfaces `hermes curator pin <umbrella>` in the rename block so
# users learn the command exists at the moment they care (a consolidation
# just landed against their library). The hint is gated on having at least
# one umbrella destination — pruned-only runs skip it.
# ---------------------------------------------------------------------------
def test_rename_summary_pin_hint_appears_when_consolidation_produced_umbrella(curator_env):
"""When at least one skill was absorbed into an umbrella, hint at pinning it."""
result = curator_env._build_rename_summary(
before_names={"pdf-extraction", "docx-extraction", "document-tools"},
after_report=[{"name": "document-tools", "state": "active"}],
tool_calls=[
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "delete",
"name": "pdf-extraction",
"absorbed_into": "document-tools",
}),
},
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "delete",
"name": "docx-extraction",
"absorbed_into": "document-tools",
}),
},
],
model_final="",
)
assert "hermes curator pin document-tools" in result
assert "keep an umbrella stable" in result
def test_rename_summary_pin_hint_skipped_for_pruned_only_runs(curator_env):
"""Pruned-only runs have nothing surviving to pin — hint should not appear."""
result = curator_env._build_rename_summary(
before_names={"old-flaky-thing", "another-stale", "keeper"},
after_report=[{"name": "keeper", "state": "active"}],
tool_calls=[
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "delete",
"name": "old-flaky-thing",
"absorbed_into": "",
}),
},
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "delete",
"name": "another-stale",
"absorbed_into": "",
}),
},
],
model_final="",
)
# Block still renders (skills were archived) but no pin hint.
assert "archived 2 skill(s):" in result
assert "hermes curator pin" not in result
assert "keep an umbrella stable" not in result
def test_rename_summary_pin_hint_picks_one_umbrella_when_multiple_absorbed(curator_env):
"""Multiple umbrellas → hint shows one example (alphabetically first), not a list."""
result = curator_env._build_rename_summary(
before_names={"a-skill", "b-skill", "umbrella-zeta", "umbrella-alpha"},
after_report=[
{"name": "umbrella-zeta", "state": "active"},
{"name": "umbrella-alpha", "state": "active"},
],
tool_calls=[
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "delete",
"name": "a-skill",
"absorbed_into": "umbrella-zeta",
}),
},
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "delete",
"name": "b-skill",
"absorbed_into": "umbrella-alpha",
}),
},
],
model_final="",
)
# Sorted picks alphabetically first.
assert "hermes curator pin umbrella-alpha" in result
# Exactly one hint line, not one per umbrella.
pin_lines = [ln for ln in result.splitlines() if "hermes curator pin" in ln]
assert len(pin_lines) == 1
+6 -1
View File
@@ -156,7 +156,12 @@ def test_t_missing_key_in_non_english_falls_back_to_english(tmp_path, monkeypatc
(fake_locales / "zh.yaml").write_text("# intentionally empty\n", encoding="utf-8")
monkeypatch.setattr(i18n, "_locales_dir", lambda: fake_locales)
i18n.reset_language_cache()
assert i18n.t("foo", lang="zh") == "English Foo"
try:
assert i18n.t("foo", lang="zh") == "English Foo"
finally:
# Clear the cache on teardown so subsequent tests don't see the
# fake "foo: English Foo" catalog instead of the real locales/*.yaml.
i18n.reset_language_cache()
def test_t_unknown_language_uses_english():
+210
View File
@@ -0,0 +1,210 @@
"""Tests for `agent.markdown_tables.realign_markdown_tables`.
These cover the alignment guarantee on CJK / wide-character tables and
the conservative no-op behaviour on non-table input.
"""
from __future__ import annotations
from textwrap import dedent
from wcwidth import wcswidth
from agent.markdown_tables import (
is_table_divider,
looks_like_table_row,
realign_markdown_tables,
split_table_row,
)
def _column_offsets(line: str) -> list[int]:
"""Return the display-cell index of every ``|`` in ``line``."""
cells: list[int] = []
width = 0
for ch in line:
if ch == "|":
cells.append(width)
# wcswidth on a single char; clamp negatives.
w = wcswidth(ch)
width += w if w > 0 else 1
return cells
# ---------------------------------------------------------------------------
# split_table_row / is_table_divider / looks_like_table_row
# ---------------------------------------------------------------------------
def test_split_strips_outer_pipes_and_trims():
assert split_table_row("| a | b | c |") == ["a", "b", "c"]
assert split_table_row("|配置|状态|") == ["配置", "状态"]
assert split_table_row("a | b | c") == ["a", "b", "c"]
def test_is_table_divider_handles_alignment_colons():
assert is_table_divider("|---|---|")
assert is_table_divider("| :--- | ---: | :---: |")
assert not is_table_divider("| - | - |") # 1 dash is not a divider
assert not is_table_divider("| a | b |")
assert not is_table_divider("---") # single column, no pipes
def test_looks_like_table_row():
assert looks_like_table_row("| a | b |")
assert looks_like_table_row("a | b | c") # no leading pipe, ≥2 pipes
assert not looks_like_table_row("not a table")
assert not looks_like_table_row("a | b") # one pipe, no leading pipe
assert not looks_like_table_row("")
# ---------------------------------------------------------------------------
# realign_markdown_tables
# ---------------------------------------------------------------------------
def test_no_op_on_text_without_tables():
text = "Hello world\nThis has no | pipes table.\n"
assert realign_markdown_tables(text) == text
def test_no_op_when_pipes_but_no_divider():
text = "echo a | grep b\necho c | wc -l\n"
assert realign_markdown_tables(text) == text
def test_cjk_table_pipes_align_across_rows():
# Model-emitted (under-padded for CJK) input.
src = dedent(
"""\
| 配置 | Config | 论文 (%) | 复现 (%) | 差值 | 状态 |
|------|--------|---------|---------|------|------|
| Vicuna (report) | dense | 79.30 | 未完成 | - | × |
| ChatGLM | chat | 37.60 | 37.82 | +0.22 | ✓ |
| 通义千问 | qwen | (无) | 报错 | - | × |
"""
)
out = realign_markdown_tables(src).rstrip("\n").split("\n")
# All rows in the rebuilt block must have pipes at identical display
# columns — that's the alignment guarantee.
offsets = [_column_offsets(row) for row in out]
assert all(o == offsets[0] for o in offsets), (
"rebuilt table rows do not share pipe column offsets:\n"
+ "\n".join(out)
)
# And we expect 7 pipes per row (6 columns + outer borders).
assert len(offsets[0]) == 7
def test_emoji_with_cjk_table_aligns():
src = dedent(
"""\
| 模型 | 状态 | 备注 |
|------|------|------|
| 千问 | ✅ | 通过 |
| Claude | ✅ | 推理强 |
| 文心一言 | ❌ | 报错 |
"""
)
out = realign_markdown_tables(src).rstrip("\n").split("\n")
offsets = [_column_offsets(row) for row in out]
# The emoji-with-variation-selector case (⚠️) intentionally tolerates
# 1-cell drift; bare emoji like ✅ / ❌ have stable wcwidth and must
# align. Use bare emoji here so the assertion is hard.
assert all(o == offsets[0] for o in offsets), (
"emoji+CJK rows do not share pipe column offsets:\n" + "\n".join(out)
)
def test_already_aligned_ascii_table_remains_aligned():
src = dedent(
"""\
| a | b |
|-----|-----|
| 1 | 2 |
| foo | bar |
"""
)
out = realign_markdown_tables(src).rstrip("\n").split("\n")
offsets = [_column_offsets(row) for row in out]
assert all(o == offsets[0] for o in offsets)
def test_passes_non_table_lines_through_around_a_table():
src = dedent(
"""\
Here is a comparison:
| 模型 | 状态 |
|------|------|
| 千问 | 通过 |
And some prose after.
"""
)
out = realign_markdown_tables(src)
assert out.startswith("Here is a comparison:\n")
assert out.endswith("And some prose after.\n")
# And the table lines are aligned.
block = [ln for ln in out.split("\n") if "|" in ln]
offsets = [_column_offsets(row) for row in block]
assert all(o == offsets[0] for o in offsets)
def test_handles_ragged_rows_by_padding_short_rows():
src = dedent(
"""\
| a | b | c |
|---|---|---|
| 1 | 2 |
| x | y | z |
"""
)
out = realign_markdown_tables(src).rstrip("\n").split("\n")
offsets = [_column_offsets(row) for row in out]
# Short rows must be padded out so they have the same pipe count
# and column positions as the header.
assert all(len(o) == len(offsets[0]) for o in offsets)
assert all(o == offsets[0] for o in offsets)
def test_multiple_tables_in_one_text():
src = dedent(
"""\
First:
| 配置 | 值 |
|------|----|
| 通义 | 1 |
Second:
| model | n |
|-------|---|
| gpt | 2 |
"""
)
out = realign_markdown_tables(src)
# Each table block individually aligns.
blocks: list[list[str]] = []
current: list[str] = []
for line in out.split("\n"):
if "|" in line:
current.append(line)
elif current:
blocks.append(current)
current = []
if current:
blocks.append(current)
assert len(blocks) == 2
for block in blocks:
offsets = [_column_offsets(row) for row in block]
assert all(o == offsets[0] for o in offsets), (
f"block did not align:\n" + "\n".join(block)
)
+991
View File
@@ -0,0 +1,991 @@
"""Unit tests for the plugin LLM facade (``agent.plugin_llm``).
These tests exercise the trust gate, JSON parsing, schema validation,
image input encoding, and the auxiliary-client invocation contract.
The auxiliary client itself is stubbed via ``make_plugin_llm_for_test``
so we don't hit real providers.
"""
from __future__ import annotations
import asyncio
import base64
import json
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from agent.plugin_llm import (
PluginLlm,
PluginLlmCompleteResult,
PluginLlmImageInput,
PluginLlmStructuredResult,
PluginLlmTextInput,
PluginLlmTrustError,
_build_structured_messages,
_check_overrides,
_coerce_allowlist,
_parse_structured_text,
_strip_code_fences,
_TrustPolicy,
make_plugin_llm_for_test,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fake_response(text: str, *, prompt: int = 4, completion: int = 6) -> SimpleNamespace:
"""Build an OpenAI-shaped response with the given text + token usage."""
return SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(content=text, role="assistant"),
finish_reason="stop",
)
],
usage=SimpleNamespace(
prompt_tokens=prompt,
completion_tokens=completion,
total_tokens=prompt + completion,
),
)
def _trusted_policy(plugin_id: str = "trusted-plugin", **overrides: Any) -> _TrustPolicy:
defaults = dict(
allow_provider_override=True,
allowed_providers=None,
allow_any_provider=True,
allow_model_override=True,
allowed_models=None,
allow_any_model=True,
allow_agent_id_override=True,
allow_profile_override=True,
)
defaults.update(overrides)
return _TrustPolicy(plugin_id=plugin_id, **defaults)
# ---------------------------------------------------------------------------
# Trust gate
# ---------------------------------------------------------------------------
class TestTrustGate:
def test_default_policy_blocks_provider_override(self):
policy = _TrustPolicy(plugin_id="locked")
with pytest.raises(PluginLlmTrustError, match="cannot override the provider"):
_check_overrides(
policy,
requested_provider="anthropic",
requested_model=None,
requested_agent_id=None,
requested_profile=None,
)
def test_default_policy_blocks_model_override(self):
policy = _TrustPolicy(plugin_id="locked")
with pytest.raises(PluginLlmTrustError, match="cannot override the model"):
_check_overrides(
policy,
requested_provider=None,
requested_model="claude-3-5-sonnet",
requested_agent_id=None,
requested_profile=None,
)
def test_default_policy_blocks_agent_override(self):
policy = _TrustPolicy(plugin_id="locked")
with pytest.raises(PluginLlmTrustError, match="non-default agent id"):
_check_overrides(
policy,
requested_provider=None,
requested_model=None,
requested_agent_id="ada",
requested_profile=None,
)
def test_default_policy_blocks_profile_override(self):
policy = _TrustPolicy(plugin_id="locked")
with pytest.raises(PluginLlmTrustError, match="cannot override the auth profile"):
_check_overrides(
policy,
requested_provider=None,
requested_model=None,
requested_agent_id=None,
requested_profile="work",
)
def test_overrides_independent(self):
"""Each override is gated independently — turning on
``allow_model_override`` does NOT also grant provider override."""
policy = _TrustPolicy(
plugin_id="model-only",
allow_model_override=True,
allow_any_model=True,
)
# model alone passes
_, m, _, _ = _check_overrides(
policy,
requested_provider=None,
requested_model="gpt-4o",
requested_agent_id=None,
requested_profile=None,
)
assert m == "gpt-4o"
# provider alone is still denied
with pytest.raises(PluginLlmTrustError, match="cannot override the provider"):
_check_overrides(
policy,
requested_provider="anthropic",
requested_model=None,
requested_agent_id=None,
requested_profile=None,
)
def test_provider_allowlist_rejects_non_listed(self):
policy = _TrustPolicy(
plugin_id="restricted",
allow_provider_override=True,
allowed_providers=frozenset({"openrouter", "anthropic"}),
allow_any_provider=False,
)
with pytest.raises(PluginLlmTrustError, match="not in plugins.entries"):
_check_overrides(
policy,
requested_provider="openai",
requested_model=None,
requested_agent_id=None,
requested_profile=None,
)
def test_provider_allowlist_accepts_listed_case_insensitively(self):
policy = _TrustPolicy(
plugin_id="restricted",
allow_provider_override=True,
allowed_providers=frozenset({"openrouter"}),
allow_any_provider=False,
)
p, _, _, _ = _check_overrides(
policy,
requested_provider="OpenRouter",
requested_model=None,
requested_agent_id=None,
requested_profile=None,
)
assert p == "OpenRouter"
def test_model_allowlist_rejects_non_listed(self):
policy = _TrustPolicy(
plugin_id="restricted",
allow_model_override=True,
allowed_models=frozenset({"openai/gpt-4o-mini"}),
allow_any_model=False,
)
with pytest.raises(PluginLlmTrustError, match="not in plugins.entries"):
_check_overrides(
policy,
requested_provider=None,
requested_model="anthropic/claude-3-opus",
requested_agent_id=None,
requested_profile=None,
)
def test_model_allowlist_accepts_listed_case_insensitively(self):
policy = _TrustPolicy(
plugin_id="restricted",
allow_model_override=True,
allowed_models=frozenset({"openai/gpt-4o-mini"}),
allow_any_model=False,
)
_, m, _, _ = _check_overrides(
policy,
requested_provider=None,
requested_model="OpenAI/GPT-4o-mini",
requested_agent_id=None,
requested_profile=None,
)
assert m == "OpenAI/GPT-4o-mini"
def test_no_overrides_passes_through(self):
policy = _TrustPolicy(plugin_id="locked")
result = _check_overrides(
policy,
requested_provider=None,
requested_model=None,
requested_agent_id=None,
requested_profile=None,
)
assert result == (None, None, None, None)
def test_all_overrides_when_fully_trusted(self):
policy = _trusted_policy()
result = _check_overrides(
policy,
requested_provider="openrouter",
requested_model="anthropic/claude-3-5-sonnet",
requested_agent_id="ada",
requested_profile="work",
)
assert result == ("openrouter", "anthropic/claude-3-5-sonnet", "ada", "work")
class TestAllowlistCoercion:
def test_missing_yields_none(self):
ranges, allow_any = _coerce_allowlist(None)
assert ranges is None
assert allow_any is False
def test_list_of_strings(self):
ranges, allow_any = _coerce_allowlist(["A", "B"])
assert ranges == frozenset({"a", "b"})
assert allow_any is False
def test_star_alone_means_any(self):
ranges, allow_any = _coerce_allowlist(["*"])
assert ranges == frozenset()
assert allow_any is True
def test_star_plus_specific_keeps_specifics(self):
ranges, allow_any = _coerce_allowlist(["*", "openrouter"])
assert ranges == frozenset({"openrouter"})
assert allow_any is True
def test_non_list_yields_none(self):
ranges, allow_any = _coerce_allowlist("openrouter")
assert ranges is None
assert allow_any is False
# ---------------------------------------------------------------------------
# Structured message building
# ---------------------------------------------------------------------------
class TestStructuredMessageBuilding:
def test_text_only_input(self):
messages = _build_structured_messages(
instructions="Extract the action items",
inputs=[PluginLlmTextInput(text="meeting notes go here")],
json_mode=False,
json_schema=None,
schema_name=None,
system_prompt=None,
)
assert len(messages) == 1
assert messages[0]["role"] == "user"
parts = messages[0]["content"]
assert parts[0]["type"] == "text"
assert "Extract the action items" in parts[0]["text"]
assert parts[1] == {"type": "text", "text": "meeting notes go here"}
def test_json_mode_adds_system_directive(self):
messages = _build_structured_messages(
instructions="Summarise",
inputs=[PluginLlmTextInput(text="content")],
json_mode=True,
json_schema=None,
schema_name=None,
system_prompt=None,
)
assert messages[0]["role"] == "system"
assert "JSON object" in messages[0]["content"]
def test_schema_name_appended_to_header(self):
messages = _build_structured_messages(
instructions="Extract fields",
inputs=[PluginLlmTextInput(text="data")],
json_mode=False,
json_schema=None,
schema_name="action.items",
system_prompt=None,
)
header = messages[0]["content"][0]["text"]
assert "Schema name: action.items" in header
def test_image_bytes_encoded_as_data_url(self):
png_bytes = b"\x89PNG\r\n\x1a\nfake"
messages = _build_structured_messages(
instructions="Read the image",
inputs=[
PluginLlmImageInput(data=png_bytes, mime_type="image/png"),
PluginLlmTextInput(text="prefer printed text"),
],
json_mode=False,
json_schema=None,
schema_name=None,
system_prompt=None,
)
parts = messages[0]["content"]
assert parts[1]["type"] == "image_url"
url = parts[1]["image_url"]["url"]
assert url.startswith("data:image/png;base64,")
decoded = base64.b64decode(url.split(",", 1)[1])
assert decoded == png_bytes
assert parts[2] == {"type": "text", "text": "prefer printed text"}
def test_image_url_passed_through(self):
messages = _build_structured_messages(
instructions="Caption this",
inputs=[PluginLlmImageInput(url="https://example.com/cat.jpg")],
json_mode=False,
json_schema=None,
schema_name=None,
system_prompt=None,
)
img_part = messages[0]["content"][1]
assert img_part["type"] == "image_url"
assert img_part["image_url"]["url"] == "https://example.com/cat.jpg"
def test_dict_inputs_normalized(self):
messages = _build_structured_messages(
instructions="Test",
inputs=[
{"type": "text", "text": "hello"},
{"type": "image", "url": "https://x.example/y.png"},
],
json_mode=False,
json_schema=None,
schema_name=None,
system_prompt=None,
)
parts = messages[0]["content"]
assert parts[1]["text"] == "hello"
assert parts[2]["image_url"]["url"] == "https://x.example/y.png"
def test_invalid_input_block_rejected(self):
with pytest.raises(ValueError, match="Unknown input block"):
_build_structured_messages(
instructions="Test",
inputs=[{"type": "audio", "data": b""}],
json_mode=False,
json_schema=None,
schema_name=None,
system_prompt=None,
)
# ---------------------------------------------------------------------------
# JSON parsing
# ---------------------------------------------------------------------------
class TestJsonParsing:
def test_strip_code_fences_with_json_label(self):
assert _strip_code_fences('```json\n{"a":1}\n```') == '{"a":1}'
def test_strip_code_fences_without_label(self):
assert _strip_code_fences("```\nfoo\n```") == "foo"
def test_strip_code_fences_no_fence(self):
assert _strip_code_fences('{"a":1}') == '{"a":1}'
def test_parse_returns_text_when_not_json_mode(self):
parsed, ct = _parse_structured_text(
text='{"a": 1}', json_mode=False, json_schema=None
)
assert parsed is None
assert ct == "text"
def test_parse_valid_json_with_json_mode(self):
parsed, ct = _parse_structured_text(
text='{"language": "French", "is_question": true}',
json_mode=True,
json_schema=None,
)
assert parsed == {"language": "French", "is_question": True}
assert ct == "json"
def test_parse_strips_code_fences_before_loading(self):
parsed, ct = _parse_structured_text(
text='Here you go:\n```json\n{"ok": true}\n```',
json_mode=True,
json_schema=None,
)
assert parsed == {"ok": True}
assert ct == "json"
def test_parse_returns_text_on_invalid_json(self):
parsed, ct = _parse_structured_text(
text="not even close to json",
json_mode=True,
json_schema=None,
)
assert parsed is None
assert ct == "text"
def test_schema_validation_rejects_mismatch(self):
pytest.importorskip("jsonschema")
schema = {
"type": "object",
"properties": {"language": {"type": "string"}},
"required": ["language"],
}
with pytest.raises(ValueError, match="did not match schema"):
_parse_structured_text(
text='{"is_question": true}',
json_mode=False,
json_schema=schema,
)
def test_schema_validation_accepts_match(self):
pytest.importorskip("jsonschema")
schema = {
"type": "object",
"properties": {"language": {"type": "string"}},
"required": ["language"],
}
parsed, ct = _parse_structured_text(
text='{"language": "French"}',
json_mode=False,
json_schema=schema,
)
assert parsed == {"language": "French"}
assert ct == "json"
# ---------------------------------------------------------------------------
# End-to-end facade
# ---------------------------------------------------------------------------
class TestPluginLlmFacade:
def test_complete_uses_active_model_by_default(self):
captured: dict = {}
def fake_caller(**kwargs):
captured.update(kwargs)
return "auto", "default", _fake_response("Hello world.")
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_TrustPolicy(plugin_id="my-plugin"),
sync_caller=fake_caller,
)
result = llm.complete([{"role": "user", "content": "hi"}])
assert isinstance(result, PluginLlmCompleteResult)
assert result.text == "Hello world."
assert captured["provider_override"] is None
assert captured["model_override"] is None
assert captured["profile_override"] is None
assert result.usage.input_tokens == 4
assert result.usage.total_tokens == 10
def test_complete_rejects_provider_override_without_trust(self):
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_TrustPolicy(plugin_id="my-plugin"),
sync_caller=lambda **_: ("x", "y", _fake_response("")),
)
with pytest.raises(PluginLlmTrustError, match="cannot override the provider"):
llm.complete(
[{"role": "user", "content": "hi"}],
provider="openrouter",
)
def test_complete_rejects_model_override_without_trust(self):
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_TrustPolicy(plugin_id="my-plugin"),
sync_caller=lambda **_: ("x", "y", _fake_response("")),
)
with pytest.raises(PluginLlmTrustError, match="cannot override the model"):
llm.complete(
[{"role": "user", "content": "hi"}],
model="anthropic/claude-3-opus",
)
def test_complete_passes_through_trusted_overrides(self):
captured: dict = {}
def fake_caller(**kwargs):
captured.update(kwargs)
return "anthropic", "claude-3-opus", _fake_response("ok")
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_trusted_policy("my-plugin"),
sync_caller=fake_caller,
)
result = llm.complete(
[{"role": "user", "content": "hi"}],
provider="anthropic",
model="claude-3-opus",
profile="work",
agent_id="ada",
temperature=0.0,
max_tokens=128,
timeout=10.0,
purpose="extract",
)
# The recorded provider/model in the result come from the override,
# since the stub caller echoed those values.
assert result.provider == "anthropic"
assert result.model == "claude-3-opus"
assert captured["provider_override"] == "anthropic"
assert captured["model_override"] == "claude-3-opus"
assert captured["profile_override"] == "work"
assert captured["temperature"] == 0.0
assert captured["max_tokens"] == 128
assert captured["timeout"] == 10.0
def test_complete_structured_returns_parsed_json(self):
def fake_caller(**_kwargs):
return "openai", "gpt-4o", _fake_response(
'{"language": "French", "is_question": true, "confidence": 0.99}'
)
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_TrustPolicy(plugin_id="my-plugin"),
sync_caller=fake_caller,
)
result = llm.complete_structured(
instructions="Detect language",
input=[PluginLlmTextInput(text="Comment ça va?")],
json_mode=True,
)
assert isinstance(result, PluginLlmStructuredResult)
assert result.parsed == {
"language": "French",
"is_question": True,
"confidence": 0.99,
}
assert result.content_type == "json"
def test_complete_structured_returns_text_on_unparseable_response(self):
def fake_caller(**_kwargs):
return "openai", "gpt-4o", _fake_response("Sorry, I can't help with that.")
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_TrustPolicy(plugin_id="my-plugin"),
sync_caller=fake_caller,
)
result = llm.complete_structured(
instructions="Detect language",
input=[PluginLlmTextInput(text="x")],
json_mode=True,
)
assert result.parsed is None
assert result.content_type == "text"
assert result.text.startswith("Sorry")
def test_complete_structured_validates_against_schema(self):
pytest.importorskip("jsonschema")
def fake_caller(**_kwargs):
return "openai", "gpt-4o", _fake_response('{"unrelated": "field"}')
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_TrustPolicy(plugin_id="my-plugin"),
sync_caller=fake_caller,
)
schema = {
"type": "object",
"properties": {"language": {"type": "string"}},
"required": ["language"],
}
with pytest.raises(ValueError, match="did not match schema"):
llm.complete_structured(
instructions="Detect language",
input=[PluginLlmTextInput(text="x")],
json_schema=schema,
)
def test_complete_structured_requires_instructions(self):
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_TrustPolicy(plugin_id="my-plugin"),
sync_caller=MagicMock(),
)
with pytest.raises(ValueError, match="non-empty instructions"):
llm.complete_structured(
instructions=" ",
input=[PluginLlmTextInput(text="x")],
)
def test_complete_structured_requires_at_least_one_input(self):
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_TrustPolicy(plugin_id="my-plugin"),
sync_caller=MagicMock(),
)
with pytest.raises(ValueError, match="at least one input"):
llm.complete_structured(
instructions="Extract",
input=[],
)
def test_complete_structured_emits_response_format_extra_body(self):
captured: dict = {}
def fake_caller(**kwargs):
captured.update(kwargs)
return "openai", "gpt-4o", _fake_response('{"a": 1}')
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_TrustPolicy(plugin_id="my-plugin"),
sync_caller=fake_caller,
)
schema = {"type": "object"}
llm.complete_structured(
instructions="Test",
input=[PluginLlmTextInput(text="x")],
json_schema=schema,
)
rf = captured["extra_body"]["response_format"]
assert rf["type"] == "json_schema"
assert rf["json_schema"]["schema"] == schema
def test_complete_structured_with_image_passes_image_url_part(self):
captured: dict = {}
def fake_caller(**kwargs):
captured.update(kwargs)
return "openai", "gpt-4o", _fake_response('{"caption": "ok"}')
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_TrustPolicy(plugin_id="my-plugin"),
sync_caller=fake_caller,
)
png = b"fake-bytes"
llm.complete_structured(
instructions="Caption this",
input=[PluginLlmImageInput(data=png, mime_type="image/png")],
json_mode=True,
)
msgs = captured["messages"]
user_msg = next(m for m in msgs if m["role"] == "user")
image_parts = [p for p in user_msg["content"] if p.get("type") == "image_url"]
assert len(image_parts) == 1
assert image_parts[0]["image_url"]["url"].startswith("data:image/png;base64,")
# ---------------------------------------------------------------------------
# Async surface
# ---------------------------------------------------------------------------
class TestAsyncSurface:
def test_acomplete_uses_async_caller(self):
async def fake_async(**_kwargs):
return "openai", "gpt-4o", _fake_response("async hello")
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_TrustPolicy(plugin_id="my-plugin"),
async_caller=fake_async,
)
async def _run() -> PluginLlmCompleteResult:
return await llm.acomplete([{"role": "user", "content": "hi"}])
result = asyncio.run(_run())
assert result.text == "async hello"
assert result.provider == "openai"
def test_acomplete_structured_parses_json(self):
async def fake_async(**_kwargs):
return "openai", "gpt-4o", _fake_response('{"x": 42}')
llm = make_plugin_llm_for_test(
plugin_id="my-plugin",
policy=_TrustPolicy(plugin_id="my-plugin"),
async_caller=fake_async,
)
async def _run() -> PluginLlmStructuredResult:
return await llm.acomplete_structured(
instructions="Extract x",
input=[PluginLlmTextInput(text="data")],
json_mode=True,
)
result = asyncio.run(_run())
assert result.parsed == {"x": 42}
assert result.content_type == "json"
# ---------------------------------------------------------------------------
# Config-driven trust gate (round-trip via plugins.entries.<id>.llm)
# ---------------------------------------------------------------------------
class TestConfigDrivenPolicy:
def test_policy_loaded_from_yaml(self, tmp_path, monkeypatch):
from agent.plugin_llm import _resolve_trust_policy
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"""
plugins:
entries:
my-plugin:
llm:
allow_provider_override: true
allowed_providers: [openrouter, anthropic]
allow_model_override: true
allowed_models:
- openai/gpt-4o-mini
- anthropic/claude-3-5-haiku
allow_profile_override: false
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from hermes_cli import config as _config_mod
_config_mod._config_cache = None # type: ignore[attr-defined]
policy = _resolve_trust_policy("my-plugin")
assert policy.allow_provider_override is True
assert policy.allow_model_override is True
assert policy.allow_profile_override is False
assert policy.allowed_providers == frozenset({"openrouter", "anthropic"})
assert policy.allowed_models == frozenset({
"openai/gpt-4o-mini", "anthropic/claude-3-5-haiku",
})
def test_missing_plugin_entry_yields_default_deny(self, tmp_path, monkeypatch):
from agent.plugin_llm import _resolve_trust_policy
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("plugins: {}\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from hermes_cli import config as _config_mod
_config_mod._config_cache = None # type: ignore[attr-defined]
policy = _resolve_trust_policy("never-configured")
assert policy.allow_provider_override is False
assert policy.allow_model_override is False
assert policy.allow_profile_override is False
assert policy.allow_agent_id_override is False
# ---------------------------------------------------------------------------
# Plugin context wiring
# ---------------------------------------------------------------------------
class TestPluginContextIntegration:
def test_ctx_llm_is_lazy_singleton(self):
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
manifest = PluginManifest(name="test-plugin", source="test", key="test-plugin")
manager = PluginManager()
ctx = PluginContext(manifest, manager)
first = ctx.llm
second = ctx.llm
assert first is second
assert isinstance(first, PluginLlm)
assert first._plugin_id == "test-plugin" # type: ignore[attr-defined]
def test_ctx_llm_uses_manifest_key_for_policy(self):
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
manifest = PluginManifest(
name="bare-name", source="test", key="image_gen/openai"
)
manager = PluginManager()
ctx = PluginContext(manifest, manager)
assert ctx.llm._plugin_id == "image_gen/openai" # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# Attribution (result.provider / result.model / audit log)
# ---------------------------------------------------------------------------
class TestAttribution:
"""Verifies that the result object and the audit log carry the real
provider/model that ``call_llm`` ended up using, NOT the placeholder
fallbacks ('auto', 'default') from earlier drafts."""
def test_explicit_overrides_recorded_when_no_response_model(self):
from agent.plugin_llm import _resolve_attribution
# Response with no .model attribute — overrides win.
response = SimpleNamespace(choices=[], usage=None)
provider, model = _resolve_attribution(
provider_override="openrouter",
model_override="anthropic/claude-3-5-sonnet",
response=response,
)
assert provider == "openrouter"
assert model == "anthropic/claude-3-5-sonnet"
def test_response_model_wins_over_model_override(self):
"""Providers often canonicalise the model name (e.g. ``gpt-4o``
→ ``gpt-4o-2024-08-06``). Whatever they actually returned wins
for the recorded model so the audit log reflects reality."""
from agent.plugin_llm import _resolve_attribution
response = SimpleNamespace(model="gpt-4o-2024-08-06", choices=[])
provider, model = _resolve_attribution(
provider_override="openrouter",
model_override="openai/gpt-4o",
response=response,
)
assert model == "gpt-4o-2024-08-06"
# Provider override is unaffected by response.model.
assert provider == "openrouter"
def test_falls_back_to_main_provider_and_model_when_no_overrides(self, monkeypatch):
"""When the plugin doesn't override anything, attribution
reflects the user's active main provider/model rather than
misleading placeholders."""
from agent import plugin_llm
import agent.auxiliary_client as ac
monkeypatch.setattr(ac, "_read_main_provider", lambda: "openrouter")
monkeypatch.setattr(ac, "_read_main_model", lambda: "anthropic/claude-3-5-sonnet")
response = SimpleNamespace(choices=[]) # no .model attribute
provider, model = plugin_llm._resolve_attribution(
provider_override=None,
model_override=None,
response=response,
)
assert provider == "openrouter"
assert model == "anthropic/claude-3-5-sonnet"
def test_response_model_used_even_when_no_overrides(self, monkeypatch):
"""The provider's canonical model name should still flow through
when no overrides are set."""
from agent import plugin_llm
import agent.auxiliary_client as ac
monkeypatch.setattr(ac, "_read_main_provider", lambda: "openrouter")
monkeypatch.setattr(ac, "_read_main_model", lambda: "openai/gpt-4o")
response = SimpleNamespace(model="openai/gpt-4o-2024-08-06", choices=[])
provider, model = plugin_llm._resolve_attribution(
provider_override=None,
model_override=None,
response=response,
)
assert provider == "openrouter"
assert model == "openai/gpt-4o-2024-08-06"
def test_placeholder_fallback_only_when_everything_is_empty(self, monkeypatch):
"""If main_provider/main_model are unset AND there's no override
AND the response has no .model, fall through to the safety
placeholders so the result object never has empty strings."""
from agent import plugin_llm
import agent.auxiliary_client as ac
monkeypatch.setattr(ac, "_read_main_provider", lambda: "")
monkeypatch.setattr(ac, "_read_main_model", lambda: "")
response = SimpleNamespace(choices=[])
provider, model = plugin_llm._resolve_attribution(
provider_override=None,
model_override=None,
response=response,
)
assert provider == "auto"
assert model == "default"
# ---------------------------------------------------------------------------
# Hook-mode integration (ctx.llm called from a post_tool_call callback)
# ---------------------------------------------------------------------------
class TestHookMode:
"""The docs page promises ``ctx.llm`` works from inside lifecycle
hooks. This exercises that path: register a ``post_tool_call``
callback that calls ``ctx.llm.complete``, fire the hook through
the real ``invoke_hook`` machinery, and check the call landed."""
def test_complete_works_from_post_tool_call_hook(self):
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
manifest = PluginManifest(name="hook-plugin", source="test", key="hook-plugin")
manager = PluginManager()
ctx = PluginContext(manifest, manager)
# Replace ctx.llm with a stub that records what the hook called.
captured: list = []
def fake_caller(**kwargs):
captured.append(kwargs)
return "openrouter", "openai/gpt-4o", _fake_response("rewrote it")
ctx._llm = make_plugin_llm_for_test( # type: ignore[attr-defined]
plugin_id="hook-plugin",
policy=_TrustPolicy(plugin_id="hook-plugin"),
sync_caller=fake_caller,
)
# Plugin registers a hook that runs ctx.llm.complete on every tool call.
def rewrite_error_hook(*, tool_name, args, result, **_):
if "Traceback" in (result or ""):
rewritten = ctx.llm.complete(
messages=[
{"role": "system", "content": "Rewrite errors plainly."},
{"role": "user", "content": result},
],
max_tokens=64,
purpose="hook-plugin.rewrite",
)
# Real hook would return the rewritten text via
# transform_tool_result; here we just capture for the assert.
captured.append({"hook_returned": rewritten.text})
ctx.register_hook("post_tool_call", rewrite_error_hook)
# Fire the hook the same way the agent core does it.
manager.invoke_hook(
"post_tool_call",
tool_name="terminal",
args={"command": "boom"},
result="Traceback (most recent call last):\n RuntimeError",
)
# Verify ctx.llm.complete fired through the hook.
assert len(captured) == 2 # one llm call + one hook return record
llm_call = captured[0]
assert "messages" in llm_call
assert any("rewrite" in m.get("content", "").lower()
for m in llm_call["messages"] if isinstance(m, dict))
hook_record = captured[1]
assert hook_record["hook_returned"] == "rewrote it"
def test_complete_works_from_post_tool_call_hook_when_async_caller_set(self):
"""Hooks fired synchronously should still work with sync
ctx.llm.complete even if other callsites use async."""
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
manifest = PluginManifest(name="hook-async", source="test", key="hook-async")
manager = PluginManager()
ctx = PluginContext(manifest, manager)
def fake_caller(**_):
return "openrouter", "model-x", _fake_response("ok")
ctx._llm = make_plugin_llm_for_test( # type: ignore[attr-defined]
plugin_id="hook-async",
policy=_TrustPolicy(plugin_id="hook-async"),
sync_caller=fake_caller,
)
called: list = []
def hook(**kwargs):
r = ctx.llm.complete(messages=[{"role": "user", "content": "x"}])
called.append(r.text)
ctx.register_hook("post_tool_call", hook)
manager.invoke_hook("post_tool_call", tool_name="x", args={}, result="y")
assert called == ["ok"]
+131
View File
@@ -6,6 +6,8 @@ import pytest
from agent.prompt_caching import (
_apply_cache_marker,
apply_anthropic_cache_control,
apply_anthropic_cache_control_long_lived,
mark_tools_for_long_lived_cache,
)
@@ -141,3 +143,132 @@ class TestApplyAnthropicCacheControl:
elif "cache_control" in msg:
count += 1
assert count <= 4
class TestMarkToolsForLongLivedCache:
def test_returns_unchanged_for_empty_tools(self):
assert mark_tools_for_long_lived_cache(None) is None
assert mark_tools_for_long_lived_cache([]) == []
def test_marks_only_last_tool(self):
tools = [
{"type": "function", "function": {"name": "a"}},
{"type": "function", "function": {"name": "b"}},
{"type": "function", "function": {"name": "c"}},
]
out = mark_tools_for_long_lived_cache(tools)
assert "cache_control" not in out[0]
assert "cache_control" not in out[1]
assert out[2]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
def test_does_not_mutate_input(self):
tools = [{"type": "function", "function": {"name": "a"}}]
mark_tools_for_long_lived_cache(tools)
assert "cache_control" not in tools[0]
def test_5m_ttl_drops_ttl_field(self):
tools = [{"type": "function", "function": {"name": "a"}}]
out = mark_tools_for_long_lived_cache(tools, long_lived_ttl="5m")
assert out[0]["cache_control"] == {"type": "ephemeral"}
class TestApplyAnthropicCacheControlLongLived:
def test_empty_messages(self):
assert apply_anthropic_cache_control_long_lived([]) == []
def test_marks_first_block_of_split_system(self):
msgs = [
{"role": "system", "content": [
{"type": "text", "text": "STABLE"},
{"type": "text", "text": "CONTEXT"},
{"type": "text", "text": "VOLATILE"},
]},
{"role": "user", "content": "msg1"},
{"role": "assistant", "content": "msg2"},
]
out = apply_anthropic_cache_control_long_lived(msgs)
sys_blocks = out[0]["content"]
assert sys_blocks[0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
assert "cache_control" not in sys_blocks[1]
assert "cache_control" not in sys_blocks[2]
def test_rolling_marker_on_last_2_messages(self):
msgs = [
{"role": "system", "content": [{"type": "text", "text": "S"}]},
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "a1"},
{"role": "user", "content": "u2"},
{"role": "assistant", "content": "a2"},
]
out = apply_anthropic_cache_control_long_lived(msgs)
def has_marker(m):
c = m.get("content")
if isinstance(c, list) and c and isinstance(c[-1], dict):
return "cache_control" in c[-1]
return "cache_control" in m
# u1 and a1 (older messages) should NOT be marked
assert not has_marker(out[1])
assert not has_marker(out[2])
# u2 and a2 (last 2) SHOULD be marked
assert has_marker(out[3])
assert has_marker(out[4])
def test_rolling_marker_uses_5m_ttl(self):
msgs = [
{"role": "system", "content": [{"type": "text", "text": "S"}]},
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "a1"},
]
out = apply_anthropic_cache_control_long_lived(
msgs, long_lived_ttl="1h", rolling_ttl="5m",
)
# Last user message: cache_control on the wrapped text part should be 5m
last = out[-1]
c = last["content"]
assert isinstance(c, list)
assert c[-1]["cache_control"] == {"type": "ephemeral"} # 5m has no ttl key
def test_string_system_falls_back_to_envelope_marker(self):
"""When the caller didn't split the system message, we still place a marker."""
msgs = [
{"role": "system", "content": "Single string system"},
{"role": "user", "content": "u1"},
]
out = apply_anthropic_cache_control_long_lived(msgs)
sys_content = out[0]["content"]
# Wrapped into a list and the (now sole) block gets the 1h marker
assert isinstance(sys_content, list)
assert sys_content[0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
def test_does_not_mutate_input(self):
msgs = [
{"role": "system", "content": [{"type": "text", "text": "S"}]},
{"role": "user", "content": "u1"},
]
before = copy.deepcopy(msgs)
apply_anthropic_cache_control_long_lived(msgs)
assert msgs == before
def test_max_4_breakpoints_with_split_system(self):
msgs = [
{"role": "system", "content": [{"type": "text", "text": "S"}, {"type": "text", "text": "V"}]},
] + [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg{i}"}
for i in range(10)
]
out = apply_anthropic_cache_control_long_lived(msgs)
count = 0
for m in out:
c = m.get("content")
if isinstance(c, list):
for item in c:
if isinstance(item, dict) and "cache_control" in item:
count += 1
elif "cache_control" in m:
count += 1
# 1 system block + last 2 messages = 3 breakpoints from this function.
# tools[-1] is marked separately (not via this function), so a 4th
# breakpoint can be added at API-call time.
assert count == 3
+112
View File
@@ -0,0 +1,112 @@
"""Live E2E: long-lived prefix caching on Claude via OpenRouter.
Run only when LIVE_OR_KEY env var is set. Skipped under the normal hermetic
test suite (which unsets credentials).
"""
import os, sys, tempfile, time, shutil, pytest
# Probe for the key BEFORE conftest unsets it
_LIVE_KEY = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("LIVE_OR_KEY")
if not _LIVE_KEY:
# Try to read directly from .env
env_path = os.path.expanduser("~/.hermes/.env")
if os.path.exists(env_path):
with open(env_path) as f:
for line in f:
if line.startswith("OPENROUTER_API_KEY="):
_LIVE_KEY = line.strip().split("=", 1)[1].strip().strip('"').strip("'")
break
pytestmark = pytest.mark.skipif(
not _LIVE_KEY,
reason="set OPENROUTER_API_KEY (or LIVE_OR_KEY) to run live cache test",
)
def test_long_lived_prefix_cache_e2e_openrouter(tmp_path, monkeypatch):
"""Two AIAgent runs in fresh sessions: call 1 writes cache, call 2 reads it."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# The hermetic conftest unsets OPENROUTER_API_KEY — restore for this test
monkeypatch.setenv("OPENROUTER_API_KEY", _LIVE_KEY)
# Minimal config — but with enough toolset/guidance to exceed Anthropic's
# ~1024-token minimum-cacheable-prefix threshold. Anthropic silently
# ignores cache_control markers on small blocks.
import yaml
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text(yaml.safe_dump({
"model": {"provider": "openrouter", "default": "anthropic/claude-haiku-4.5"},
"prompt_caching": {"long_lived_prefix": True, "long_lived_ttl": "1h", "cache_ttl": "5m"},
"agent": {"tool_use_enforcement": True}, # adds substantial guidance text
"memory": {"provider": ""},
"compression": {"enabled": False},
}))
from run_agent import AIAgent
def make_agent():
return AIAgent(
api_key=_LIVE_KEY,
base_url="https://openrouter.ai/api/v1",
provider="openrouter",
model="anthropic/claude-haiku-4.5",
api_mode="chat_completions",
# Use the default toolset roster — the tools array (~13k tokens
# for ~35 tools) is what carries the bulk of the cross-session
# cache value. With a tiny toolset the cached prefix can fall
# below Anthropic Haiku's 2048-token minimum cacheable size and
# the marker is silently ignored.
enabled_toolsets=None,
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
save_trajectories=False,
)
a1 = make_agent()
assert a1._use_prompt_caching is True, "policy should enable caching for Claude on OR"
assert a1._use_long_lived_prefix_cache is True, "long-lived path should activate"
parts = a1._build_system_prompt_parts()
print(f"\nstable={len(parts['stable']):,} ctx={len(parts['context']):,} volatile={len(parts['volatile']):,} chars")
print(f"tool count: {len(a1.tools or [])}")
# Use distinct user messages each call so OpenRouter's response cache
# doesn't short-circuit the upstream Anthropic call (we need real
# Anthropic billing visibility to verify cache_creation/cache_read).
USER_1 = "Reply with the single word ALPHA."
USER_2 = "Reply with the single word BRAVO."
print("\n--- Call 1 (cold) ---")
r1 = a1.run_conversation(USER_1, conversation_history=[])
print(f"final_response[:80]: {(r1.get('final_response') or '')[:80]!r}")
cr1 = a1.session_cache_read_tokens
cw1 = a1.session_cache_write_tokens
print(f"call1: cache_read={cr1} cache_write={cw1}")
# Wait so cache settles, then fresh agent (NEW SESSION) for cross-session read
time.sleep(2)
a2 = make_agent()
assert a2.session_id != a1.session_id, "second agent must have a new session"
print("\n--- Call 2 (warm, NEW session, different user msg) ---")
r2 = a2.run_conversation(USER_2, conversation_history=[])
print(f"final_response[:80]: {(r2.get('final_response') or '')[:80]!r}")
cr2 = a2.session_cache_read_tokens
cw2 = a2.session_cache_write_tokens
print(f"call2: cache_read={cr2} cache_write={cw2}")
print(f"\n=== VERDICT ===")
print(f" call1 wrote {cw1:,} cache tokens, read {cr1:,}")
print(f" call2 wrote {cw2:,} cache tokens, read {cr2:,}")
if cw1:
print(f" cross-session read fraction: cr2/cw1 = {cr2/cw1:.2%}")
# Assertions
assert cw1 > 0, f"call 1 must write cache (got {cw1}); long-lived layout not reaching wire"
assert cr2 > 0, (
f"call 2 must read cache cross-session (got {cr2}); "
f"stable prefix is not byte-stable across sessions"
)
assert cr2 >= 1000, f"cache_read on call 2 ({cr2}) too small to indicate real reuse"
@@ -180,6 +180,119 @@ class TestCodexBuildKwargs:
# "minimal" should be clamped to "low" for xAI as well
assert kw.get("reasoning", {}).get("effort") == "low"
# --- Grok reasoning-effort capability allowlist ---
# api.x.ai 400s with "Model X does not support parameter reasoningEffort"
# on grok-4 / grok-4-fast / grok-3 / grok-code-fast / grok-4.20-0309-*.
# Those models reason natively but don't expose the dial. The transport
# must omit the `reasoning` key for them while keeping the encrypted
# reasoning content include so we can capture native reasoning tokens.
def test_xai_grok_4_omits_reasoning_effort(self, transport):
"""grok-4 / grok-4-0709 reject reasoning.effort with HTTP 400."""
messages = [{"role": "user", "content": "Hi"}]
for model in ("grok-4", "grok-4-0709"):
kw = transport.build_kwargs(
model=model, messages=messages, tools=[],
is_xai_responses=True,
reasoning_config={"effort": "high"},
)
assert "reasoning" not in kw, (
f"{model} must not receive a reasoning key (xAI rejects it)"
)
# Still capture native reasoning tokens
assert "reasoning.encrypted_content" in kw.get("include", [])
def test_xai_grok_4_fast_omits_reasoning_effort(self, transport):
"""grok-4-fast and grok-4-1-fast variants reject reasoning.effort."""
messages = [{"role": "user", "content": "Hi"}]
for model in (
"grok-4-fast-reasoning",
"grok-4-fast-non-reasoning",
"grok-4-1-fast-reasoning",
"grok-4-1-fast-non-reasoning",
):
kw = transport.build_kwargs(
model=model, messages=messages, tools=[],
is_xai_responses=True,
reasoning_config={"effort": "low"},
)
assert "reasoning" not in kw, (
f"{model} must not receive a reasoning key (xAI rejects it)"
)
def test_xai_grok_3_non_mini_omits_reasoning_effort(self, transport):
"""Plain grok-3 rejects reasoning.effort — only grok-3-mini accepts it."""
messages = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="grok-3", messages=messages, tools=[],
is_xai_responses=True,
reasoning_config={"effort": "medium"},
)
assert "reasoning" not in kw
def test_xai_grok_3_mini_keeps_reasoning_effort(self, transport):
"""grok-3-mini and -fast variants do accept the effort dial."""
messages = [{"role": "user", "content": "Hi"}]
for model in ("grok-3-mini", "grok-3-mini-fast"):
kw = transport.build_kwargs(
model=model, messages=messages, tools=[],
is_xai_responses=True,
reasoning_config={"effort": "high"},
)
assert kw.get("reasoning") == {"effort": "high"}
def test_xai_grok_4_20_0309_variants_omit_reasoning_effort(self, transport):
"""grok-4.20-0309-(non-)reasoning reject the effort dial.
Counterintuitively, only grok-4.20-multi-agent-0309 accepts it.
"""
messages = [{"role": "user", "content": "Hi"}]
for model in ("grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning"):
kw = transport.build_kwargs(
model=model, messages=messages, tools=[],
is_xai_responses=True,
reasoning_config={"effort": "high"},
)
assert "reasoning" not in kw, f"{model} must not receive reasoning"
def test_xai_grok_4_20_multi_agent_keeps_reasoning_effort(self, transport):
"""grok-4.20-multi-agent-0309 is the one grok-4.20 variant that accepts effort."""
messages = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="grok-4.20-multi-agent-0309", messages=messages, tools=[],
is_xai_responses=True,
reasoning_config={"effort": "low"},
)
assert kw.get("reasoning") == {"effort": "low"}
def test_xai_grok_code_fast_omits_reasoning_effort(self, transport):
"""grok-code-fast-1 rejects reasoning.effort."""
messages = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="grok-code-fast-1", messages=messages, tools=[],
is_xai_responses=True,
reasoning_config={"effort": "high"},
)
assert "reasoning" not in kw
def test_xai_aggregator_prefix_stripped(self, transport):
"""`x-ai/grok-3-mini` (OpenRouter-style slug) still resolves correctly."""
messages = [{"role": "user", "content": "Hi"}]
# Effort-capable
kw = transport.build_kwargs(
model="x-ai/grok-3-mini", messages=messages, tools=[],
is_xai_responses=True,
reasoning_config={"effort": "high"},
)
assert kw.get("reasoning") == {"effort": "high"}
# Effort-incapable
kw = transport.build_kwargs(
model="x-ai/grok-4-0709", messages=messages, tools=[],
is_xai_responses=True,
reasoning_config={"effort": "high"},
)
assert "reasoning" not in kw
class TestCodexValidateResponse: