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
8 changes: 3 additions & 5 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,15 +851,13 @@ def init_agent(
try:
from hermes_cli.config import load_config_readonly as _load_pc_cfg

from agent.agent_runtime_helpers import cache_ttl_means_disabled

_pc_cfg = _load_pc_cfg().get("prompt_caching", {}) or {}
_ttl = _pc_cfg.get("cache_ttl", "5m")
if _ttl in {"5m", "1h"}:
agent._cache_ttl = _ttl
elif (
_ttl is False
or _ttl is None
or str(_ttl).lower() in ("off", "false", "disabled", "no", "none")
):
elif cache_ttl_means_disabled(_ttl):
agent._use_prompt_caching = False
agent._use_native_cache_layout = False
agent._cache_ttl = None
Expand Down
73 changes: 70 additions & 3 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1857,6 +1857,66 @@ def _direct_native_anthropic_tool_cache_capability(
)


def cache_ttl_means_disabled(ttl: Any) -> bool:
"""Return True when a ``prompt_caching.cache_ttl`` value means caching off.

Single source of truth for the disable-synonym detection shared by
``agent_init`` (live-agent ``_cache_disabled`` flag) and the stub policy
paths below. Keeping one predicate prevents the two sites from drifting
(a synonym added in only one place would recreate #76085).

Unknown values (e.g. ``"2h"``, integers) are NOT a disable — callers keep
caching enabled with the default TTL, matching ``agent_init``.
"""
if ttl in ("5m", "1h"):
return False
if ttl is False or ttl is None:
return True
return str(ttl).lower() in ("off", "false", "disabled", "no", "none")


def prompt_caching_disabled_from_config() -> bool:
"""Return True when ``prompt_caching.cache_ttl`` is configured as off.

Same disable detection as ``agent_init`` (via ``cache_ttl_means_disabled``)
so stub-based policy paths (MoA slot decoration, auxiliary fallback
replan) honor the same config contract without holding a live
``AIAgent`` (#76085 / #33555).
"""
try:
from hermes_cli.config import load_config_readonly

pc_cfg = load_config_readonly().get("prompt_caching", {}) or {}
ttl = pc_cfg.get("cache_ttl", "5m")
except Exception:
return False
return cache_ttl_means_disabled(ttl)


def blank_cache_policy_stub(cache_disabled: Optional[bool] = None):
"""Build the destination-identity-blank stub for ``anthropic_prompt_cache_policy``.

Single sanctioned constructor for that stub. Callers that resolve cache
policy against a destination identified out-of-band (not a live
``AIAgent``) must go through here so ``_cache_disabled`` is never left
off a hand-rolled ``SimpleNamespace`` (#76085).

When ``cache_disabled`` is omitted, falls back to the global config so
stub paths without an agent snapshot still honor an operator disable.
"""
from types import SimpleNamespace

if cache_disabled is None:
cache_disabled = prompt_caching_disabled_from_config()
return SimpleNamespace(
provider="",
base_url="",
api_mode="",
model="",
_cache_disabled=bool(cache_disabled),
)


def plan_cache_sections_for_destination(
messages: list,
tools: Optional[list],
Expand All @@ -1865,6 +1925,7 @@ def plan_cache_sections_for_destination(
base_url: str,
api_mode: str,
model: str,
cache_disabled: Optional[bool] = None,
) -> Tuple[list, list]:
"""Plan request-local cache sections for one resolved destination.

Expand All @@ -1877,16 +1938,19 @@ def plan_cache_sections_for_destination(

Never mutates ``messages`` or ``tools`` — both return values are
request-local copies.
"""
from types import SimpleNamespace

``cache_disabled`` threads the operator's ``prompt_caching.cache_ttl``
disable into the blank policy stub. When omitted, the live config is
consulted so MoA/auxiliary paths cannot re-enable markers after the
user turned caching off (#76085).
"""
from agent.prompt_caching import (
build_prompt_cache_plan,
strip_anthropic_cache_control,
strip_anthropic_tool_cache_control,
)

stub = SimpleNamespace(provider="", base_url="", api_mode="", model="")
stub = blank_cache_policy_stub(cache_disabled)
should_cache, native_layout = anthropic_prompt_cache_policy(
stub,
provider=provider,
Expand Down Expand Up @@ -3895,6 +3959,9 @@ def force_close_tcp_sockets(client: Any) -> int:
"restore_primary_runtime",
"extract_reasoning",
"dump_api_request_debug",
"prompt_caching_disabled_from_config",
"blank_cache_policy_stub",
"plan_cache_sections_for_destination",
"anthropic_prompt_cache_policy",
"create_openai_client",
"switch_model",
Expand Down
62 changes: 54 additions & 8 deletions agent/moa_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,8 @@ def _merge_slot_extra_body(
def _maybe_apply_moa_cache_control(
messages: list[dict[str, Any]],
runtime: dict[str, Any],
*,
cache_disabled: bool | None = None,
) -> list[dict[str, Any]]:
"""Decorate an advisor or aggregator request with cache_control when its
route honors it.
Expand All @@ -396,17 +398,27 @@ def _maybe_apply_moa_cache_control(

Returns the messages unchanged on any resolution error or when the
policy says the route doesn't honor markers.

``cache_disabled`` (or the live config when omitted) is stamped onto the
policy stub so ``prompt_caching.cache_ttl: off`` is not bypassed by the
blank-agent pattern (#76085).
"""
try:
from types import SimpleNamespace

from agent.agent_runtime_helpers import anthropic_prompt_cache_policy
from agent.agent_runtime_helpers import (
anthropic_prompt_cache_policy,
blank_cache_policy_stub,
)
from agent.prompt_caching import apply_anthropic_cache_control

# Prefer an explicit kwarg, then a snapshot on the runtime dict
# (threaded from the live agent), else config via the stub factory.
if cache_disabled is None and "_cache_disabled" in runtime:
cache_disabled = runtime.get("_cache_disabled")

# The policy function reads agent.* only as fallbacks for kwargs we
# don't pass; provide a stub so the slot is judged purely on its own
# resolved runtime.
stub = SimpleNamespace(provider="", base_url="", api_mode="", model="")
# don't pass; blank_cache_policy_stub is the only sanctioned stub
# so _cache_disabled cannot be left off again (#76085).
stub = blank_cache_policy_stub(cache_disabled)
should_cache, native_layout = anthropic_prompt_cache_policy(
stub,
provider=runtime.get("provider") or "",
Expand All @@ -432,6 +444,7 @@ def _run_reference(
max_tokens: int | None = None,
reference_timeout: float | None = None,
context_length_cache: Any = None,
cache_disabled: bool | None = None,
) -> tuple[str, str, Any]:
"""Call one reference model and return ``(label, text, accounting)``.

Expand Down Expand Up @@ -493,7 +506,12 @@ def _run_reference(
# caching is opt-in per request. OpenAI-family advisors are untouched
# (their caching is automatic; markers are ignored harmlessly, but we
# only decorate when the policy says the route honors them).
messages = _maybe_apply_moa_cache_control(messages, runtime)
# Pin the live agent disable onto the runtime so advisor decoration
# tracks conversation state, not a fresh config re-read (#76085).
cache_runtime = runtime
if cache_disabled is not None:
cache_runtime = {**runtime, "_cache_disabled": cache_disabled}
messages = _maybe_apply_moa_cache_control(messages, cache_runtime)
# Per-slot max_tokens takes precedence over the preset-level
# reference_max_tokens passed in by the caller. This lets each
# reference model have its own output cap independently.
Expand Down Expand Up @@ -797,6 +815,9 @@ def _run_references_parallel(
# instead of re-probing metadata sources per reference (dict get/set is
# GIL-atomic; a rare duplicate probe on a first-use race is harmless).
_ctx_len_cache: dict[tuple[str, str], int | None] = {}
cache_disabled = (
getattr(agent, "_cache_disabled", None) if agent is not None else None
)
try:
for idx, slot in enumerate(reference_models):
if slot.get("provider") == "moa":
Expand All @@ -815,6 +836,7 @@ def _run_references_parallel(
max_tokens=max_tokens,
reference_timeout=reference_timeout,
context_length_cache=_ctx_len_cache,
cache_disabled=cache_disabled,
)
] = idx

Expand Down Expand Up @@ -1262,6 +1284,19 @@ def aggregate_moa_context(

agg_label = _slot_label(aggregator)
agg_runtime = _slot_runtime(aggregator)
# Pin the live agent disable onto synthesis decoration so mid-session
# config flips cannot re-enable markers on this path alone (#76085).
# Same not-None guard as _run_reference: stamping None would be a no-op
# (present-None falls through to the config fallback anyway).
agg_cache_runtime = agg_runtime
_agg_cache_disabled = (
getattr(agent, "_cache_disabled", None) if agent is not None else None
)
if _agg_cache_disabled is not None:
agg_cache_runtime = {
**agg_runtime,
"_cache_disabled": _agg_cache_disabled,
}
try:
# Same cache_control decoration as _run_reference's advisor calls
# (see _maybe_apply_moa_cache_control) — this synthesis call is a
Expand All @@ -1274,7 +1309,7 @@ def aggregate_moa_context(
# breakpoints, even when the resolved aggregator slot is a
# cache-honoring route (e.g. Claude on OpenRouter/native Anthropic).
agg_messages = _maybe_apply_moa_cache_control(
[{"role": "user", "content": synth_prompt}], agg_runtime
[{"role": "user", "content": synth_prompt}], agg_cache_runtime
)
response = call_llm(
task="moa_aggregator",
Expand Down Expand Up @@ -1673,13 +1708,24 @@ def _call_prepared_aggregator(
# plan_cache_sections_for_destination never mutates its inputs
# and always returns request-local copies, so the prepared
# state stays canonical.
# Tri-state: only pass a bool when a live agent snapshot exists.
# Prepared-aggregator facades built via __new__ have no _agent;
# getattr(self._agent, ...) raises and bool(None-agent) would
# force False and suppress the planner's config fallback (#76085).
_agent = getattr(self, "_agent", None)
_cache_disabled = (
getattr(_agent, "_cache_disabled", None)
if _agent is not None
else None
)
agg_messages, tools = plan_cache_sections_for_destination(
planning_messages,
tools,
provider=agg_runtime.get("provider") or "",
base_url=agg_runtime.get("base_url") or "",
api_mode=agg_runtime.get("api_mode") or "",
model=agg_runtime.get("model") or "",
cache_disabled=_cache_disabled,
)
if guidance:
_attach_reference_guidance(agg_messages, str(guidance))
Expand Down
Loading
Loading