Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,9 @@ def init_agent(
from hermes_cli.config import load_config as _load_pc_cfg

_pc_cfg = _load_pc_cfg().get("prompt_caching", {}) or {}
_ttl = _pc_cfg.get("cache_ttl", "5m")
# prompt_caching.enabled=false is honored in _anthropic_prompt_cache_policy
# (applied above and on every re-derivation), so no override is needed here.
_ttl = _pc_cfg.get("cache_ttl", "5m") if isinstance(_pc_cfg, dict) else "5m"
if _ttl in {"5m", "1h"}:
agent._cache_ttl = _ttl
except Exception:
Expand Down
15 changes: 15 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1443,6 +1443,21 @@ def anthropic_prompt_cache_policy(
eff_api_mode = api_mode if api_mode is not None else (agent.api_mode or "")
eff_model = (model if model is not None else agent.model) or ""

# Global kill switch: prompt_caching.enabled=false disables cache_control
# markers on every path (init, /model switch, fallback re-derivation).
# Escape hatch for strict Anthropic-compatible proxies that inject their
# own markers server-side — stacking ours on top exceeds Anthropic's
# 4-breakpoint limit and 400s. Gating here (not just at init) keeps the
# switch honored after a model switch or fallback re-evaluates the policy.
try:
from hermes_cli.config import load_config as _load_pc_cfg

_pc_cfg = _load_pc_cfg().get("prompt_caching", {}) or {}
if isinstance(_pc_cfg, dict) and _pc_cfg.get("enabled") is False:
return False, False
except Exception:
pass

model_lower = eff_model.lower()
provider_lower = eff_provider.lower()
is_claude = "claude" in model_lower
Expand Down
13 changes: 4 additions & 9 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1391,8 +1391,11 @@ def _ensure_hermes_home_managed(home: Path):
},

# Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).
# cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored.
# Set enabled: false as an escape hatch for strict providers that reject
# cache_control markers; cache_ttl must be "5m" or "1h" (Anthropic-supported
# tiers), other values are ignored.
"prompt_caching": {
"enabled": True,
"cache_ttl": "5m",
},

Expand Down Expand Up @@ -2155,14 +2158,6 @@ def _ensure_hermes_home_managed(home: Path):
"moa": {
"default_preset": "default",
"active_preset": "",
# When true, every MoA turn that runs the reference fan-out writes the
# FULL turn (each reference's exact input messages + output + usage/cost,
# and the aggregator's exact input + output) to a JSONL file at
# <hermes_home>/moa-traces/<session_id>.jsonl. Off by default — turn it
# on to audit / improve MoA behavior from real runs. Set trace_dir to
# override the output directory.
"save_traces": False,
"trace_dir": "",
"presets": {
"default": {
"reference_models": [
Expand Down
4 changes: 4 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,14 @@

# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"janrenz@Mac.fritz.box": "janrenz", # PR #35862 salvage (prompt_caching.enabled escape hatch for strict providers)
"syahidfrd@gmail.com": "syahidfrd", # PR #17059 salvage (tag unverified senders in Slack thread context to mitigate indirect prompt injection)
"5823452+sgabel@users.noreply.github.com": "sgabel", # PR #13139 salvage (redact secrets in user-facing approval prompts)
"130270192+CRWuTJ@users.noreply.github.com": "CRWuTJ", # PR #17082 salvage (cancel delayed Telegram deliveries on disconnect so buffered flushes don't dispatch into a torn-down session)
"cyb3rwr3n@users.noreply.github.com": "cyb3rwr3n", # PR #11333 salvage (sanitize FTS5 queries for natural-language recall in holographic memory)
"9350182+codexGW@users.noreply.github.com": "codexGW", # PR #12302 salvage (Discord raw <@!ID> mention detection + drop bare mention-only pings)
"chufengfan@jackroooc-2.local": "jackroofan", # PR #54609 salvage (add anthropic to MoA _slot_runtime name-preserve set; OAuth sk-ant-oat* needs Bearer + anthropic-beta header)
"igor.izotov@gmail.com": "iizotov", # PR #54912 salvage (add bedrock to MoA _slot_runtime name-preserve set; SigV4-signed client, placeholder aws-sdk api_key)
"186512915+lEWFkRAD@users.noreply.github.com": "lEWFkRAD", # PR #53848 salvage (stream the MoA aggregator response to the user)
"193368749+jimmyjohansson84@users.noreply.github.com": "jimmyjohansson84", # PR #27123 salvage (Kanban unknown-skill warn-instead-of-crash; #27136)
"gxalong@gmail.com": "Jeffgithub0029", # PR #28558 salvage (chunk Telegram text *after* MarkdownV2/HTML formatting so escaping inflation can't push a send over the 4096 UTF-16 limit; #28557)
Expand Down
86 changes: 85 additions & 1 deletion tests/run_agent/test_anthropic_prompt_cache_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from __future__ import annotations

from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch

from run_agent import AIAgent

Expand Down Expand Up @@ -326,7 +326,91 @@ def test_fallback_target_evaluated_independently(self):
assert (should, native) == (True, False)


# ─────────────────────────────────────────────────────────────────────
# prompt_caching.enabled=false global kill switch
# ─────────────────────────────────────────────────────────────────────


class TestPromptCachingDisabledKillSwitch:
"""prompt_caching.enabled=false must disable cache_control markers on
every endpoint class and every re-derivation path (init, /model switch,
fallback). This is the correct escape hatch for a strict Anthropic-
compatible proxy that injects its own markers server-side — a single
per-setup toggle, not a blanket strip that would regress the many
well-behaved third-party gateways the policy deliberately caches on.
"""

def _disabled_cfg(self):
return patch(
"hermes_cli.config.load_config",
return_value={"prompt_caching": {"enabled": False}},
)

def test_disables_native_anthropic(self):
agent = _make_agent(
provider="anthropic",
base_url="https://api.anthropic.com",
api_mode="anthropic_messages",
model="claude-sonnet-4-6",
)
with self._disabled_cfg():
assert agent._anthropic_prompt_cache_policy() == (False, False)

def test_disables_openrouter_claude(self):
agent = _make_agent(
provider="openrouter",
base_url="https://openrouter.ai/api/v1",
api_mode="chat_completions",
model="anthropic/claude-sonnet-4.6",
)
with self._disabled_cfg():
assert agent._anthropic_prompt_cache_policy() == (False, False)

def test_disables_third_party_anthropic_gateway(self):
# llm.echo.tech-style LiteLLM proxy — the reported failure case.
agent = _make_agent(
provider="anthropic",
base_url="https://llm.echo.tech",
api_mode="anthropic_messages",
model="claude-sonnet-4-6",
)
with self._disabled_cfg():
assert agent._anthropic_prompt_cache_policy() == (False, False)

def test_survives_model_switch_re_derivation(self):
# Start native Anthropic, /model switch to a proxy — disable must hold.
agent = _make_agent(
provider="anthropic",
base_url="https://api.anthropic.com",
api_mode="anthropic_messages",
model="claude-opus-4.6",
)
with self._disabled_cfg():
assert agent._anthropic_prompt_cache_policy(
provider="anthropic",
base_url="https://llm.echo.tech",
api_mode="anthropic_messages",
model="claude-sonnet-4-6",
) == (False, False)

def test_enabled_true_keeps_third_party_caching_on(self):
# The well-behaved third-party gateways a blanket strip would break
# must keep caching by default.
agent = _make_agent(
provider="anthropic",
base_url="https://llm.echo.tech",
api_mode="anthropic_messages",
model="claude-sonnet-4-6",
)
with patch(
"hermes_cli.config.load_config",
return_value={"prompt_caching": {"enabled": True}},
):
assert agent._anthropic_prompt_cache_policy() == (True, True)


# ─────────────────────────────────────────────────────────────────────
# Long-lived prefix cache policy (cross-session 1h tier)
# ─────────────────────────────────────────────────────────────────────


24 changes: 24 additions & 0 deletions tests/run_agent/test_run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,30 @@ def test_prompt_caching_cache_ttl_invalid_falls_back(self):
)
assert a._cache_ttl == "5m"

def test_prompt_caching_enabled_false_disables_cache_markers(self):
"""prompt_caching.enabled=false is an escape hatch for strict providers."""
with (
patch("run_agent.get_tool_definitions", return_value=[]),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("agent.anthropic_adapter._anthropic_sdk"),
patch(
"hermes_cli.config.load_config",
return_value={"prompt_caching": {"enabled": False}},
),
):
a = AIAgent(
api_key="test-key-1234567890",
provider="anthropic",
model="claude-sonnet-4-6",
base_url="https://api.anthropic.com/v1/",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
assert a.api_mode == "anthropic_messages"
assert a._use_prompt_caching is False
assert a._use_native_cache_layout is False

def test_valid_tool_names_populated(self):
"""valid_tool_names should contain names from loaded tools."""
tools = _make_tool_defs("web_search", "terminal")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ Prompt caching is automatically enabled when:
```yaml
# config.yaml — TTL is configurable (must be "5m" or "1h")
prompt_caching:
enabled: true # set false to stop sending cache_control markers (strict-proxy escape hatch)
cache_ttl: "5m"
```

Expand Down
Loading
Loading