Files
hermes-agent/agent/turn_context.py
kshitijk4poor 5c4c31cf4d refactor(agent): fail loudly on a missing turn clock; single lowercase in is_dangerous_confirmation
- build_api_messages reads agent._current_turn_timestamp directly: a caller that skipped the
  turn prologue now raises instead of silently falling back to per-request wall time, which
  would re-create the mid-turn drift the fix removes. Only production caller
  (assemble_api_request) runs after _reset_per_turn_agent_state; cross-reference to the
  tripwire _inflight_turn_started so the two clocks are not "unified" by mistake.
- is_dangerous_confirmation lowercases once instead of once per pattern (now on the per-request path).
- Tests: one _send(idx=) helper instead of three spellings of the builder call; the
  untrustworthy-stamp contract is its own test.
2026-09-13 19:23:09 +05:30

1169 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Per-turn setup for ``run_conversation`` (the turn prologue).
``build_turn_context`` runs the once-per-turn setup (stdio guard, sanitization, prompt
restore-or-build, session row, idle/preflight compaction via ``turn_context_compaction``,
pre_llm_call hook, prefetch, persistence), mutating ``agent`` as the loop expects, and
returns a ``TurnContext`` with only the locals the loop reads back.
``build_api_messages`` builds the wire copy for one API call."""
from __future__ import annotations
import logging
import sys
import threading
import time
import uuid
from contextlib import suppress
from dataclasses import dataclass
from typing import Any, Dict, List, Mapping, Optional, Tuple
from agent.conversation_compression import recover_rotated_compression_session
from agent.iteration_budget import IterationBudget
from agent.memory_manager import build_memory_context_block
from agent.memory_provider import is_trivial_prompt
from agent.message_metadata import append_message, stamp_message_timestamp
from agent.model_metadata import estimate_messages_tokens_rough, estimate_request_tokens_rough
from agent.image_token_cost import bind_image_token_cost
from agent.usage_anchor import anchored_context_tokens, restore_usage_anchor
from agent.turn_author import parse_turn_author
logger = logging.getLogger(__name__)
def _str_attr(agent: Any, name: str) -> str:
"""``getattr(agent, name, "") or ""`` — route facts read off partial agents/doubles."""
return getattr(agent, name, "") or ""
def _preflight_request_tokens(
agent: Any, messages: List[Dict[str, Any]], system_prompt: str
) -> int:
"""Token estimate for automatic preflight compression: a valid provider usage anchor,
else the checkpoint-pruned native wire payload, else the generic estimator."""
anchored = anchored_context_tokens(messages, getattr(agent, "_usage_anchor", None))
agent._request_pressure_anchored = anchored is not None
if anchored is not None:
return anchored
tools = getattr(agent, "tools", None) or None
try:
from agent.codex_responses_adapter import estimate_native_responses_preflight_tokens
native = estimate_native_responses_preflight_tokens(
agent, messages, system_prompt=system_prompt or "", tools=tools
)
if isinstance(native, int) and not isinstance(native, bool) and native >= 0:
return native
except Exception:
logger.debug(
"native Responses preflight estimate unavailable; "
"using generic transcript estimate",
exc_info=True,
)
return estimate_request_tokens_rough(
messages, system_prompt=system_prompt or "", tools=tools,
charge_stale_thinking=_agent_stale_thinking_on_wire(agent),
)
def _agent_stale_thinking_on_wire(agent: Any) -> bool:
"""Whether the active route replays stale thinking text; ``True`` (conservative full
charge) when route facts are unavailable."""
try:
from agent.message_sanitization import stale_thinking_reaches_wire
return stale_thinking_reaches_wire(
*(_str_attr(agent, k) for k in ("api_mode", "provider", "model", "base_url"))
)
except Exception:
return True
def compose_user_api_content(
content: Any, ext_prefetch_cache: str, plugin_user_context: str
) -> Optional[str]:
"""Compose the API-bound content of the current turn's user message.
Single source for the ``api_content`` sidecar and the wire bytes so they never drift
(what turn N sends is what turn N+1 replays). ``None`` when nothing is injected."""
if not isinstance(content, str):
return None
fenced = build_memory_context_block(ext_prefetch_cache) if ext_prefetch_cache else ""
injections = [part for part in (fenced, plugin_user_context) if part]
if not injections:
return None
return content + "\n\n" + "\n\n".join(injections)
def substitute_api_content(api_msg: Dict[str, Any]) -> Optional[str]:
"""Pop the ``api_content`` sidecar and substitute it into ``content`` (keeps the
prompt-cache prefix byte-stable). Returns the popped sidecar, or ``None``."""
sidecar = api_msg.pop("api_content", None)
if isinstance(sidecar, str) and sidecar and api_msg.get("role") in ("user", "assistant"):
api_msg["content"] = sidecar
return sidecar
def drop_stale_api_content(msg: Dict[str, Any]) -> None:
"""Drop the ``api_content`` sidecar from a message whose content was rewritten
(replaying it would resend what the rewrite removed; cost is one cache miss)."""
msg.pop("api_content", None)
def extract_api_content_sidecar(msg: Mapping[str, Any]) -> Optional[str]:
"""Extract the ``api_content`` sidecar; ``None`` when absent/non-string."""
v = msg.get("api_content")
return v if isinstance(v, str) else None
def _pop_turn_note(agent: Any, attr: str) -> str:
"""One-shot per-turn note: read and clear, so the system prompt stays byte-stable and a
cached agent never replays a stale note."""
note = getattr(agent, attr, "") or ""
if hasattr(agent, attr):
with suppress(Exception):
setattr(agent, attr, "")
return note if isinstance(note, str) else ""
def consume_gateway_turn_context_notes(agent: Any) -> str:
"""Pop the gateway's per-turn must-deliver notes."""
return _pop_turn_note(agent, "_gateway_turn_context_notes")
def consume_surface_switch_note(agent: Any) -> str:
"""Pop the surface-switch note staged by the system-prompt restore (#104414); rides the same
user-message channel as the gateway notes, behind the cached prefix."""
return _pop_turn_note(agent, "_surface_switch_note")
def append_notes_to_multimodal_content(content: Any, notes: str) -> bool:
"""Append must-deliver notes as a durable text part on a multimodal (list) user
message (the sidecar path returns ``None`` for non-string content)."""
if not notes or not isinstance(content, list):
return False
with suppress(Exception):
content.append({"type": "text", "text": notes})
return True
return False
# Surfaces whose sessions must not be auto-titled: cron names its own session and
# its opener is a delivery hint; subagent sessions are hidden from every picker.
_UNTITLED_PLATFORMS = frozenset({"cron", "subagent"})
def _maybe_title_session_at_turn_start(agent: Any, messages: List[Any]) -> None:
"""Kick off auto-titling for the session's first user message; never fatal."""
session_db = getattr(agent, "_session_db", None)
session_id = getattr(agent, "session_id", None)
if not session_db or not session_id:
return
if str(getattr(agent, "platform", "") or "").lower() in _UNTITLED_PLATFORMS:
return
try:
from agent.message_content import flatten_message_text
from agent.title_generator import maybe_auto_title
# Turn's user message as text; image-only turns yield "" and are skipped.
user_text = ""
for msg in reversed(messages or []):
if isinstance(msg, dict) and msg.get("role") == "user":
user_text = flatten_message_text(msg.get("content")).strip()
break
if not user_text:
return
# The session row is created lazily; force it now or the title write matches
# zero rows.
if not getattr(agent, "_session_db_created", False):
ensure = getattr(agent, "_ensure_db_session", None)
if callable(ensure):
ensure()
if not getattr(agent, "_session_db_created", False):
return
# Snapshot runtime identity so the background titler can skip if the user
# switches models before it fires.
main_runtime = {
k: getattr(agent, k, None) for k in ("model", "provider", "base_url", "api_key", "api_mode")
}
# See #19027.
maybe_auto_title(
session_db,
session_id,
user_text,
conversation_history=messages,
failure_callback=(
getattr(agent, "_title_failure_callback", None)
or getattr(agent, "_emit_auxiliary_failure", None)
),
main_runtime=main_runtime,
title_callback=getattr(agent, "_on_session_title", None),
runtime_validator=lambda: (
getattr(agent, "model", None) == main_runtime["model"]
and getattr(agent, "provider", None) == main_runtime["provider"]
),
)
except Exception:
logger.debug("Turn-start auto-title dispatch failed", exc_info=True)
def reanchor_current_turn_user_idx(messages: List[Any], user_message: Any) -> int:
"""Locate this turn's user message after compaction rebuilt ``messages``.
Prefers the LAST user message whose content exactly matches this turn's text, else
the last user-originated turn; compaction handoffs are never the fallback.
Returns -1 when there is no user-originated message.
Compression replaces list entries with fresh copies (and may append a todo-snapshot user message or a
restored user turn AFTER the surviving copy of the current turn's message), so a pre-compression index
is meaningless. Prefer the LAST user message whose content exactly matches this turn's text — the
surviving copy in the common case — so the injection stamp and the #48677 persist override can't land on
a todo-snapshot or historical row. Fall back to the last *user-originated* turn when no exact match
survives (merge-summary-into-tail rewrites the content but the trackers still need a live anchor).
Compaction handoffs must never become the fallback anchor (#80622) — they are reference-only
scaffolding, not the active ask.
"""
from agent.context_compressor import user_originated_turn_view
fallback = -1
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if not (isinstance(msg, dict) and msg.get("role") == "user"):
continue
# Typed synthetic current events keep their persistence anchor when raw
# content is unchanged; not eligible for the human-only fallback below.
if msg.get("content") == user_message:
return i
live_view = user_originated_turn_view(msg)
if live_view is None:
continue
if live_view.get("content") == user_message:
return i
# Prefer a real human turn over a synthetic handoff / continuation marker
# when the exact content was rewritten by merge-into-tail.
if fallback < 0:
fallback = i
return fallback
def export_current_turn_boundary(agent: Any, result: Any, user_message: Any) -> Any:
"""Stamp ``{turn_id, current_turn_user_idx}`` on a result envelope, proven against the
exact ``result["messages"]`` projection it travels with.
Hosts that settle their own transcript by index (hermes-webui) must never guess which
row is the current user turn after this loop rewrote history (alternation repair,
compaction, post-turn micro-compaction): a guessed index or a text match can relabel an
identical historical prompt and claim its old answer as this turn's. So the producer
exports the coordinate, computed on the final list, only when the addressed row is this
turn's user message verbatim. Otherwise the keys are omitted and hosts fail closed.
A preflight-timeout envelope carries the prior history without this turn's row (#7100), so a
repeated prompt would resolve to its historical copy: nothing is exported there.
"""
if not isinstance(result, dict) or result.get("turn_exit_reason") == "context_compression_timeout":
return result
messages = result.get("messages")
turn_id = str(getattr(agent, "_current_turn_id", "") or "")
if not isinstance(messages, list) or not turn_id or user_message is None:
return result
idx = reanchor_current_turn_user_idx(messages, user_message)
if idx < 0 or idx >= len(messages):
return result
row = messages[idx]
if not (isinstance(row, dict) and row.get("role") == "user"):
return result
from agent.context_compressor import user_originated_turn_view
live_view = user_originated_turn_view(row)
if row.get("content") != user_message and not (
isinstance(live_view, dict) and live_view.get("content") == user_message
):
return result # rewritten (merge-into-tail) row: not a proven boundary
result["turn_id"] = turn_id
result["current_turn_user_idx"] = idx
return result
def compression_made_progress(
orig_len: int, new_len: int, orig_tokens: int, new_tokens: int
) -> bool:
"""``True`` if a compression pass materially reduced the request: fewer rows, or a
>5% token cut with the same rows (same floor as the overflow-handler retry).
Compression can succeed by summarising message contents — reducing the estimated request token count —
without reducing the message row count. Treating row count as the sole progress signal false-positives
on size-only wins and surfaces a misleading "Cannot compress further" failure even when post-compression
tokens are well below the model context window. See issue #39548 for an observed case: 220 → 220
messages, ~288k → ~183k tokens on a 1M-context model still triggered auto-reset.
The token reduction must be *material* (>5%) to count as progress — the same floor the overflow-handler
retry path uses (conversation_loop.py, 39550) — so a sub-5% wobble doesn't keep the multi-pass loop
spinning. See #39550.
"""
return new_len < orig_len or (orig_tokens > 0 and new_tokens < orig_tokens * 0.95)
class PreflightCompressionTimedOut(RuntimeError):
"""Raised when an oversized turn cannot safely finish preflight."""
def _fail_closed_after_preflight_timeout(agent, request_tokens: int) -> None:
"""Stop an oversized turn instead of sending its unchanged provider payload."""
from agent.conversation_compression import context_compression_timed_out
if not context_compression_timed_out(agent):
return
raise PreflightCompressionTimedOut(
"Context compression timed out before it could commit while the request "
f"was still approximately {request_tokens:,} tokens. The provider call "
"was not sent. Run /compress and wait for it to finish, then retry."
)
def _review_fork_first_request_pending(agent: Any) -> bool:
"""Whether a detached review fork has yet to send its first provider request: it
replays the parent's FULL snapshot as a warm cache read, so compaction must wait
for that first response. Dormant without the attribute."""
return bool(
getattr(agent, "_review_defer_compaction_before_first_response", False)
and not getattr(agent, "_turn_received_provider_response", False)
)
def _compression_warrants_another_preflight_pass(
orig_tokens: int, new_tokens: int, threshold_tokens: int
) -> bool:
"""Another immediate summary only if still over threshold AND the previous pass cut
tokens by >5%."""
return new_tokens >= threshold_tokens and orig_tokens > 0 and new_tokens < orig_tokens * 0.95
def _should_run_preflight_estimate(
messages: List[Dict[str, Any]], protect_first_n: int, protect_last_n: int, threshold_tokens: int
) -> bool:
"""Cheap gate for the (expensive) full preflight estimate: message count exceeds the
protected ranges OR a rough char-based estimate crosses the threshold (few-but-huge
case). The estimator undercounts by design (omits system/tools) so one large base64
image is not mistaken for ~250K tokens."""
return (
len(messages) > protect_first_n + protect_last_n + 1
or estimate_messages_tokens_rough(messages) >= threshold_tokens
)
def _should_idle_compact(
*, enabled: bool, idle_after_seconds: int, idle_gap_seconds: float, tokens: int,
floor_tokens: int, cooldown_active: bool, last_compaction_tokens: int = 0,
) -> bool:
"""Pure predicate: idle compaction fires after a wall-clock gap of
``idle_after_seconds`` (opt-in, <= 0 disables), independent of ``threshold_tokens``;
never at/below ``floor_tokens`` or during a compression-failure cooldown.
``floor_tokens`` (``threshold_tokens × summary_target_ratio``) is a theoretical target a
real pass routinely misses (system prompt, tool schemas and protected head/tail are
incompressible), so a session compacted to above it would re-summarise on every idle
resume without growing. ``last_compaction_tokens`` — what the previous pass actually
produced (``ContextCompressor.last_compression_rough_tokens``, same rough shape as
``tokens``) — raises the floor to ``last + floor_tokens`` so the transcript must gain a
floor's worth of NEW content first. ``0`` (nothing compacted yet / counter reset) keeps
the original semantics exactly.
A session that compacted to well above that target therefore stays above it forever, so every later idle
resume re-runs a full summarisation over a transcript that has not grown — minutes of silently blocked
prompt on a slow route, reclaiming nothing (#97239).
"""
if not enabled or idle_after_seconds <= 0 or idle_gap_seconds < idle_after_seconds or cooldown_active:
return False
effective_floor = floor_tokens
if last_compaction_tokens > 0:
effective_floor = max(effective_floor, last_compaction_tokens + floor_tokens)
return tokens > effective_floor
@dataclass
class TurnContext:
"""Values produced by the turn prologue and consumed by the turn loop."""
user_message: str # sanitized inbound message (surrogates stripped)
original_user_message: Any # clean text for transcripts / memory queries (no nudges)
messages: List[Dict[str, Any]] # working list for this turn (loop appends to it)
conversation_history: Optional[List[Dict[str, Any]]] # None after rotation
active_system_prompt: Optional[str] # may be rebuilt by compression
effective_task_id: str
turn_id: str
current_turn_user_idx: int # index of the current user turn within ``messages``
should_review_memory: bool = False # post-turn memory review should fire
plugin_user_context: str = "" # ``pre_llm_call`` context (appended to user message)
ext_prefetch_cache: str = "" # external-memory prefetch, reused across iterations
preflight_compression_blocked: bool = False # immediate retry proved ineffective
def _persist_under_lock(agent: Any, fn, failure_msg: str, pending_cli_message: Any) -> None:
"""Run ``fn`` under the session persist lock (when the agent has one), log-and-swallow
failures, then drop staged CLI input — unless it is an unmarked handoff kept for a
close retry (once ``_db_persisted`` the close path must not treat it as pre-worker
UI input). Eager clearing keeps a preflight crash from leaking stale input."""
try:
lock = getattr(agent, "_session_persist_lock", None)
if lock is None:
fn()
else:
with lock:
fn()
except Exception:
logger.warning(failure_msg, agent.session_id or "none", exc_info=True)
finally:
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
agent._pending_cli_user_message = None
def _publish_runtime_main(agent: Any) -> None:
"""Tell auxiliary_client the live main provider/model for this turn (after primary
restoration settled the runtime). Never raises: failure loses only the scope."""
with suppress(Exception):
from agent.auxiliary_client import set_runtime_main
from agent.prompt_cache_scope import resolve_prompt_cache_scope_safe
# Rotation-stable prompt-cache scope (lineage root), memoized per segment; a new
# session uses the physical id until build_api_kwargs re-resolves.
# Memoized per segment on the agent, so this is a DB walk at most once per segment — except a
# brand-new session whose row lands later in turn setup (_ensure_db_session); that first turn falls
# back to the physical id here and the first build_api_kwargs re-resolves. Stays valid through a
# mid-turn compression rotation because the lineage root is by definition rotation-invariant
# (#79017). Resolved with the never-raising variant OUTSIDE the argument list, so a resolution
# failure can only lose the scope — never the whole runtime binding.
_cache_scope = resolve_prompt_cache_scope_safe(agent) or ""
set_runtime_main(
_str_attr(agent, "provider"), _str_attr(agent, "model"),
**{k: _str_attr(agent, k) for k in (
"requested_provider", "base_url", "api_key", "api_mode", "auth_mode", "session_id"
)},
cache_scope=_cache_scope,
)
def _refresh_mcp_tools_between_turns(agent: Any) -> None:
"""Late-connecting MCP servers land in THIS turn's snapshot, before the first API
call assembles ``tools=``. ``preserve_prefix`` keeps the tool array append-only so a
flapping ``check_fn`` can't fork the cache."""
try:
# Import-cost gate: MCP tools are only registered by code that already imported
# ``tools.mcp_tool`` (~0.4s); not in sys.modules => nothing to do.
if not getattr(agent, "_skip_mcp_refresh", False) and "tools.mcp_tool" in sys.modules:
from tools.mcp_tool_discovery import has_registered_mcp_tools
from tools.mcp_tool_agent import refresh_agent_mcp_tools
if has_registered_mcp_tools():
refresh_agent_mcp_tools(agent, quiet_mode=True, preserve_prefix=True)
except Exception:
logger.debug("between-turns MCP tool refresh skipped", exc_info=True)
def _bind_turn_identity(
agent: Any, task_id: Optional[str], stream_callback, persist_user_message: Any,
persist_user_timestamp: Optional[float], persist_user_platform_id: Optional[str],
) -> Tuple[str, str]:
"""Stage callback/persist overrides on the agent and bind this turn's task and turn
ids. Returns ``(effective_task_id, turn_id)``."""
agent._stream_callback = stream_callback # picked up by _interruptible_api_call
agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message
agent._persist_user_message_timestamp = persist_user_timestamp
agent._persist_user_message_platform_id = persist_user_platform_id
# Unique task_id when not provided isolates VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id
agent._process_owner_task_ids = {*getattr(agent, "_process_owner_task_ids", ()), effective_task_id}
turn_id = str(getattr(agent, "_relay_pending_turn_id", "") or "") or (
f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
)
agent._relay_pending_turn_id = None
agent._current_turn_id = turn_id
agent._current_api_request_id = ""
# Tripwire: warn when this turn starts before the previous turn-end persist
# (concurrent turns interleave transcript writes). Cleared in _persist_session.
from agent.agent_runtime_helpers import note_turn_start
note_turn_start(agent, turn_id)
return effective_task_id, turn_id
# Per-turn agent state reset at turn start (retry counters, guardrail halt, file-mutation
# verifier). ``_turns_since_memory`` / ``_iters_since_skill`` are deliberately NOT reset.
_PER_TURN_RESET_STATE: Tuple[Tuple[str, Any], ...] = (
("_invalid_tool_retries", 0), ("_invalid_json_retries", 0), ("_empty_content_retries", 0),
("_incomplete_scratchpad_retries", 0), ("_codex_incomplete_retries", 0),
("_thinking_prefill_retries", 0), ("_post_tool_empty_retried", False),
("_last_content_with_tools", None), ("_last_content_tools_all_housekeeping", False),
("_mute_post_response", False), ("_unicode_sanitization_passes", 0),
("_tool_guardrail_halt_decision", None), ("_vision_supported", True),
("_iteration_budget_warning_injected", False),
("_run_budget_wrapup_injected", False), ("_verification_stop_nudges", 0),
("_pre_verify_nudges", 0),
)
def _reset_per_turn_agent_state(agent: Any) -> None:
"""Reset retry counters, guardrails, iteration and run budgets at turn start."""
for name, value in _PER_TURN_RESET_STATE:
setattr(agent, name, value)
agent._turn_failed_file_mutations = {}
agent._turn_file_mutation_paths = set()
agent._tool_guardrails.reset_for_turn()
_reset_consol = getattr(agent._memory_store, "reset_consolidation_failures", None)
if callable(_reset_consol):
_reset_consol()
# Expiry clock for build_api_messages: admission time (not the input's platform-event
# stamp, which can predate admission by minutes), frozen so every request this turn
# sends identical bytes. Distinct from note_turn_start's _inflight_turn_started, a
# tripwire slot cleared at persist.
agent._current_turn_timestamp = time.time()
# Pre-turn connection health check: clean up dead TCP connections.
if agent.api_mode != "anthropic_messages":
with suppress(Exception):
if agent._cleanup_dead_connections():
agent._emit_status(
"🔌 Detected stale connections from a previous provider "
"issue — cleaned up automatically. Proceeding with fresh "
"connection."
)
# Replay compression warning through status_callback for gateway platforms.
if agent._compression_warning:
agent._replay_compression_warning()
agent._compression_warning = None # send once
agent.iteration_budget = IterationBudget(agent.max_iterations)
# Wall-clock run budget: stamped only when configured (one wrap-up notice per run).
agent._run_budget_started_at = (
time.time() if getattr(agent, "run_budget_seconds", None) else None
)
# Reset the streaming context / think scrubbers at the top of each turn.
for name in ("_stream_context_scrubber", "_stream_think_scrubber"):
scrubber = getattr(agent, name, None)
if scrubber is not None:
scrubber.reset()
def _stage_turn_user_message(
agent: Any, user_message: Any, persist_user_message: Any,
persist_user_timestamp: Optional[float], persist_user_platform_id: Optional[str],
persist_user_display_kind: Optional[str],
persist_user_display_metadata: Optional[Dict[str, Any]],
) -> Tuple[Dict[str, Any], Any]:
"""Build this turn's user dict, reusing CLI-staged input only when its clean text
matches this turn (a stale handoff must not replace later input; voice turns
compare the clean override). Returns ``(user_msg, pending_cli_message)``."""
pending_cli_message = getattr(agent, "_pending_cli_user_message", None)
expected_persist_content = (
persist_user_message if persist_user_message is not None else user_message
)
if (
isinstance(pending_cli_message, dict)
and pending_cli_message.get("content") == expected_persist_content
):
user_msg = pending_cli_message
# CLI-staged value is the clean text; restore the API-facing variant (e.g. voice
# prefix) on the same dict, keeping any close-path durable marker.
user_msg["content"] = user_message
else:
user_msg = {"role": "user", "content": user_message}
if isinstance(pending_cli_message, dict):
agent._pending_cli_user_message = None
# CLI input is stamped when staged; gateway input may carry the platform event
# time. Preserve either value and cover any legacy unstamped handoff.
stamp_message_timestamp(user_msg, timestamp=persist_user_timestamp)
# Synthesized turns stamp their transcript type so the crash persist writes a typed
# row; the model still receives role/content unchanged (api_messages strips both).
if persist_user_display_kind:
user_msg["display_kind"] = persist_user_display_kind
if persist_user_display_metadata:
user_msg["display_metadata"] = persist_user_display_metadata
# The platform message id survives the turn-start flush; restart drain-window
# recovery dedups via ``has_platform_message_id`` against this row.
if persist_user_platform_id is not None:
user_msg["platform_message_id"] = persist_user_platform_id
return user_msg, pending_cli_message
def _hydrate_from_history(agent: Any, conversation_history: Optional[List[Any]]) -> None:
"""Hydrate process-local state from persisted history on the first resumed turn."""
if not conversation_history:
return
if not agent._todo_store.has_items():
agent._hydrate_todo_store(conversation_history)
# A live native checkpoint arms this latch while its response is captured. A
# restarted agent must recover the same one-response deferral before turn-start
# compression can rewrite the restored opaque checkpoint. Reuse the adapter's
# exact route/issuer/replay filtering and tolerate plugin compressors without the
# optional hook.
if agent._user_turn_count == 0:
# A fresh process has no in-memory anchor; the persisted one is honored only while the
# restored transcript still carries the priced prefix (see agent/usage_anchor.py).
restore_usage_anchor(agent, conversation_history)
note_checkpoint = getattr(
getattr(agent, "context_compressor", None),
"note_native_compaction_checkpoint",
None,
)
if callable(note_checkpoint):
try:
from agent.codex_responses_adapter import (
has_replayable_native_compaction_checkpoint,
)
if has_replayable_native_compaction_checkpoint(
agent, conversation_history
):
note_checkpoint()
except Exception:
logger.debug(
"restored native checkpoint hydration skipped", exc_info=True
)
# Hydrate per-session nudge counters from persisted history.
prior_user_turns = sum(1 for m in conversation_history if m.get("role") == "user")
if prior_user_turns > 0:
agent._user_turn_count = prior_user_turns
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval
def _tick_memory_nudge(agent: Any) -> bool:
"""Advance the turn-based memory nudge counter; ``True`` when the review should fire."""
if (agent._memory_nudge_interval > 0
and "memory" in agent.valid_tool_names
and agent._memory_store):
agent._turns_since_memory += 1
if agent._turns_since_memory >= agent._memory_nudge_interval:
agent._turns_since_memory = 0
return True
return False
def _emit_reaction(agent: Any, original_user_message: Any) -> None:
"""Cosmetic side-signal: detect an affection reaction so the host can play hearts.
Token-free, never touches the conversation, never fatal."""
reaction_callback = getattr(agent, "reaction_callback", None)
if reaction_callback is None:
return
with suppress(Exception):
from agent.reactions import detect_reaction
kind = detect_reaction(original_user_message)
if kind:
reaction_callback(kind)
def _ensure_session_row(agent: Any, pending_cli_message: Any) -> None:
"""Create the DB row now (system prompt populated => non-NULL) and BEFORE preflight
compression: compaction/rotation INSERTs reference this row under PRAGMA
foreign_keys=ON. Idempotent; the user-turn crash persist runs later."""
_persist_under_lock(
agent, agent._ensure_db_session,
"Turn-start session row creation failed for session=%s", pending_cli_message,
)
def _collect_pre_llm_call_context(
agent: Any, *, effective_task_id: str, turn_id: str, original_user_message: Any,
messages: List[Any], conversation_history: Optional[List[Any]],
) -> str:
"""Run ``pre_llm_call`` plugins; their context is injected into the user message
(never the system prompt). Oversized per-hook context is spilled to disk so a
runaway plugin can't inflate every subsequent turn's prompt."""
if getattr(agent, "_persist_disabled", False):
return ""
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_pre_results = _invoke_hook(
"pre_llm_call",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
user_message=original_user_message,
conversation_history=list(messages),
is_first_turn=(not bool(conversation_history)),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
parent_session_id=getattr(agent, "_parent_session_id", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
)
try:
# Spill oversized per-hook context to disk so a runaway plugin can't inflate every subsequent
# turn's prompt. Ported from openai/codex PR #21069 ("Spill large hook outputs from context").
from tools.hook_output_spill import (
get_spill_config as _spill_cfg, spill_if_oversized as _spill_if_oversized
)
_spill_config_cached = _spill_cfg()
except Exception:
_spill_if_oversized = None # type: ignore[assignment]
_spill_config_cached = None
_ctx_parts: list[str] = []
for r in _pre_results:
if isinstance(r, dict) and r.get("context"):
_piece = str(r["context"])
elif isinstance(r, str) and r.strip():
_piece = r
else:
continue
if _spill_if_oversized is not None:
try:
_piece = _spill_if_oversized(
_piece, session_id=agent.session_id, source="plugin hook",
config=_spill_config_cached,
)
except Exception as _spill_exc:
logger.warning("hook context spill failed: %s", _spill_exc)
_ctx_parts.append(_piece)
return "\n\n".join(_ctx_parts)
except Exception as exc:
logger.warning("pre_llm_call hook failed: %s", exc)
return ""
def _merge_gateway_notes(
agent: Any, messages: List[Any], current_turn_user_idx: int, plugin_user_context: str
) -> str:
"""Must-deliver per-turn notes ride the user-message injection channel (one-shot) so the
ephemeral system prompt stays byte-stable: the gateway's staged notes, then the
surface-switch correction. Multimodal (list) content can't take the string sidecar —
append a durable text part instead."""
_turn_notes = "\n\n".join(
part for part in (consume_gateway_turn_context_notes(agent),
consume_surface_switch_note(agent)) if part
)
if not _turn_notes:
return plugin_user_context
_gw_turn_content = (
messages[current_turn_user_idx].get("content")
if 0 <= current_turn_user_idx < len(messages)
and isinstance(messages[current_turn_user_idx], dict)
else None
)
if isinstance(_gw_turn_content, list):
append_notes_to_multimodal_content(_gw_turn_content, _turn_notes)
return plugin_user_context
return (
plugin_user_context + "\n\n" + _turn_notes if plugin_user_context else _turn_notes
)
def _bind_interrupt_scope(agent: Any, ra) -> None:
"""Record the execution thread so interrupt()/clear_interrupt() scope the tool-level
signal to THIS agent's thread; clear stale state, preserving a pending interrupt."""
agent._execution_thread_id = threading.current_thread().ident
ra()._set_interrupt(False, agent._execution_thread_id)
if agent._interrupt_requested:
ra()._set_interrupt(
True, agent._execution_thread_id, reason=getattr(agent, "_tool_interrupt_reason", None)
)
else:
agent._interrupt_message = None
agent._tool_interrupt_reason = None
agent._interrupt_thread_signal_pending = False
def _memory_turn_start_and_prefetch(
agent: Any, original_user_message: Any, turn_author: Optional[Dict[str, Any]] = None,
) -> str:
"""Notify memory providers of the new turn, then prefetch external memory once
before the tool loop (skipped on trivial prompts with no semantic signal).
Returns the prefetch text (``""`` when nothing was injected)."""
if not agent._memory_manager:
return ""
_query = original_user_message if isinstance(original_user_message, str) else ""
# The author rides along so a provider can attribute THIS turn, not whoever opened the session.
_author = turn_author if isinstance(turn_author, dict) else {}
with suppress(Exception):
agent._memory_manager.on_turn_start(
agent._user_turn_count, _query,
author_id=_author.get("id") or None, author_name=_author.get("name") or None,
author_is_bot=bool(_author.get("is_bot")),
)
ext_prefetch_cache = ""
with suppress(Exception):
if not is_trivial_prompt(_query):
ext_prefetch_cache = agent._memory_manager.prefetch_all(_query, session_id=agent.session_id) or ""
# Deterministic recall indicator via _emit_status so the model can't silently
# drop injected memory.
if ext_prefetch_cache:
with suppress(Exception):
_recall_indicator = agent._memory_manager.describe_recall()
if _recall_indicator:
agent._emit_status(_recall_indicator)
return ext_prefetch_cache
def _stamp_api_content_sidecar(
agent: Any, messages: List[Any], current_turn_user_idx: int, ext_prefetch_cache: str,
plugin_user_context: str, *, preflight_compressed: bool,
) -> None:
"""api_content sidecar — persist what you send: injected context lives only in the
API copy, so stamp the exact sent bytes on the live dict for replay."""
_turn_user_msg = messages[current_turn_user_idx]
live_content = _turn_user_msg.get("content")
from agent.session_persistence import _persist_lock, durable_user_row_content
# Match the row the flush wrote (persist override = clean transcript), not the live bytes.
durable_content, _api_content = durable_user_row_content(
agent, _turn_user_msg, live_content,
compose_user_api_content(live_content or "", ext_prefetch_cache, plugin_user_context),
)
if _api_content is None or _api_content == durable_content:
return
_turn_user_msg["api_content"] = _api_content
# When another writer materialized this turn's user row BEFORE the sidecar existed — in-place
# preflight compaction, or a close/early flush that raced the prologue (#102194) — the crash
# persist marker-skips the message and the stamp never reaches the DB, so the next turn replays
# clean content and the request prefix diverges here. Both writers stamp ``_row_id`` on the live
# dict, which is at once the proof a row exists and the address to update.
#
# Never widen this to an unconditional positional backfill — see set_latest_user_api_content.
#
# ``_row_id`` is read under ``_session_persist_lock``: a close flush holds it while it commits
# the row and only then writes ``_row_id`` back (``sync_flushed_message_markers``). Read outside
# it, the stamp can land in between, see no id, return — and the flush then marks the message
# persisted with ``api_content = NULL``, leaving no writer to correct the row.
with _persist_lock(agent):
_row_id = _turn_user_msg.get("_row_id")
_in_place_compacted = preflight_compressed and bool(getattr(agent, "_last_compaction_in_place", False))
_db = getattr(agent, "_session_db", None)
if _db is None or not (isinstance(_row_id, int) or _in_place_compacted):
return
try:
if isinstance(_row_id, int):
_db.set_message_api_content(agent.session_id, _row_id, durable_content, _api_content)
else:
# Compacted copies carry no row id; positional is safe only because
# archive_and_compact just made this message the newest active user row.
_db.set_latest_user_api_content(agent.session_id, durable_content, _api_content)
except Exception:
logger.warning("api_content backfill failed for session=%s", agent.session_id or "none", exc_info=True)
def _persist_turn_start(
agent: Any, messages: List[Any], conversation_history: Optional[List[Any]],
pending_cli_message: Any,
) -> None:
"""Crash-resilience: persist the inbound user turn once, with final api_content,
before the first LLM call. Same critical section as CLI close persistence; retries
the row create if the pre-compression attempt failed transiently."""
def _ensure_and_persist() -> None:
agent._ensure_db_session()
agent._persist_session(messages, conversation_history)
_persist_under_lock(
agent, _ensure_and_persist,
"Early turn-start session persistence failed for session=%s", pending_cli_message,
)
def build_turn_context(
agent, user_message: Any, system_message: Optional[str],
conversation_history: Optional[List[Dict[str, Any]]], task_id: Optional[str], stream_callback,
persist_user_message: Optional[Any], persist_user_timestamp: Optional[float]=None,
persist_user_platform_id: Optional[str]=None, *, persist_user_display_kind: Optional[str]=None,
persist_user_display_metadata: Optional[Dict[str, Any]]=None, turn_author: Optional[Dict[str, Any]]=None,
restore_or_build_system_prompt,
install_safe_stdio, sanitize_surrogates, summarize_user_message_for_log, set_session_context,
set_current_write_origin, ra, moa_active: bool=False,
) -> TurnContext:
"""Run the once-per-turn setup and return the loop's input context.
Helpers are passed in to avoid an import cycle with ``agent.conversation_loop``.
Order matters: the DB session row is created only AFTER the system prompt is built
(else it persists system_prompt=NULL and costs a cache miss) and BEFORE preflight
compression."""
from agent.turn_context_compaction import run_turn_start_compaction
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
install_safe_stdio()
# Reset first: a cached gateway agent must never carry the previous turn's bot author into a human turn.
turn_author = parse_turn_author(turn_author)
agent._turn_author = turn_author
# Recover a rotated session before binding log/turn ids or copying client history so
# everything in this turn belongs to the canonical child.
recovered_history = recover_rotated_compression_session(agent)
if recovered_history is not None:
conversation_history = recovered_history
# Tag log records on this thread with the session ID for ``hermes logs``; bind the
# skill write-origin ContextVar; restore the primary runtime after a fallback turn.
# NOTE: the DB session row is created later, AFTER the system prompt is restored/built (see
# _ensure_db_session() below the system-prompt block). Creating it here — before _cached_system_prompt
# is populated — inserts a row with system_prompt=NULL on a fresh API/gateway agent that carries
# client-managed history, which then trips the "stored system prompt is null; rebuilding from scratch"
# warning and a needless first-turn prefix cache miss. (Issue #45499.)
set_session_context(agent.session_id)
set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool"))
from tools.skill_provenance import set_review_attended
set_review_attended(getattr(agent, "_review_attended", False))
agent._restore_primary_runtime()
_publish_runtime_main(agent)
_refresh_mcp_tools_between_turns(agent)
if isinstance(user_message, str):
user_message = sanitize_surrogates(user_message)
if isinstance(persist_user_message, str):
persist_user_message = sanitize_surrogates(persist_user_message)
effective_task_id, turn_id = _bind_turn_identity(
agent, task_id, stream_callback, persist_user_message,
persist_user_timestamp, persist_user_platform_id,
)
_reset_per_turn_agent_state(agent)
_preview_text = summarize_user_message_for_log(user_message)
_msg_preview = _preview_text[:80] + ("..." if len(_preview_text) > 80 else "")
logger.info(
"conversation turn: session=%s model=%s provider=%s platform=%s history=%d msg=%r",
agent.session_id or "none", agent.model, agent.provider or "unknown",
agent.platform or "unknown", len(conversation_history or []),
_msg_preview.replace("\n", " "),
)
# Copy so the caller's list is never mutated.
messages = list(conversation_history) if conversation_history else []
user_msg, pending_cli_message = _stage_turn_user_message(
agent, user_message, persist_user_message, persist_user_timestamp,
persist_user_platform_id, persist_user_display_kind, persist_user_display_metadata,
)
_hydrate_from_history(agent, conversation_history)
# Every estimator this turn prices images at the cost learned from this model's real usage.
bind_image_token_cost(agent)
# Append the user message now that close persistence is safe.
append_message(messages, user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
agent._user_turn_count += 1
# Copilot x-initiator: the first API call of this user turn is user-initiated;
# tool-loop follow-ups revert to "agent".
agent._is_user_initiated_turn = True
# Preserve the original user message (no nudge injection).
original_user_message = persist_user_message if persist_user_message is not None else user_message
should_review_memory = _tick_memory_nudge(agent)
_emit_reaction(agent, original_user_message)
if not agent.quiet_mode:
agent._safe_print(
f"💬 Starting conversation: '{_preview_text[:60]}"
f"{'...' if len(_preview_text) > 60 else ''}'"
)
# System prompt is cached per session for prefix caching.
if agent._cached_system_prompt is None:
restore_or_build_system_prompt(agent, system_message, conversation_history)
active_system_prompt = agent._cached_system_prompt
# Bot Mode DM tool — injected ONLY into a bot's canonical "Bot Chat" session (same
# gate as the protocol section); gate is session-stable, so cache-safe.
try:
from tools.bot_mode_dm import ensure_message_agent_tool
ensure_message_agent_tool(agent)
except Exception:
logger.debug("message_agent injection skipped", exc_info=True)
_ensure_session_row(agent, pending_cli_message)
compaction = run_turn_start_compaction(
agent, messages=messages, system_message=system_message,
active_system_prompt=active_system_prompt, conversation_history=conversation_history,
current_turn_user_idx=current_turn_user_idx, user_message=user_message,
effective_task_id=effective_task_id,
)
messages = compaction.messages
active_system_prompt = compaction.active_system_prompt
conversation_history = compaction.conversation_history
current_turn_user_idx = compaction.current_turn_user_idx
plugin_user_context = _collect_pre_llm_call_context(
agent, effective_task_id=effective_task_id, turn_id=turn_id,
original_user_message=original_user_message, messages=messages,
conversation_history=conversation_history,
)
plugin_user_context = _merge_gateway_notes(
agent, messages, current_turn_user_idx, plugin_user_context
)
_bind_interrupt_scope(agent, ra)
ext_prefetch_cache = _memory_turn_start_and_prefetch(agent, original_user_message, turn_author)
# Sidecar skipped for codex_app_server/MoA.
if (
not moa_active
and getattr(agent, "api_mode", None) != "codex_app_server"
and 0 <= current_turn_user_idx < len(messages)
and messages[current_turn_user_idx].get("role") == "user"
):
_stamp_api_content_sidecar(
agent, messages, current_turn_user_idx, ext_prefetch_cache,
plugin_user_context, preflight_compressed=compaction.compressed,
)
_persist_turn_start(agent, messages, conversation_history, pending_cli_message)
# Title the session now: the row exists and titling depends only on the user's ask,
# so it runs concurrently with the turn. Daemon thread, no-op once titled.
_maybe_title_session_at_turn_start(agent, messages)
return TurnContext(
user_message=user_message, original_user_message=original_user_message, messages=messages,
conversation_history=conversation_history, active_system_prompt=active_system_prompt,
effective_task_id=effective_task_id, turn_id=turn_id,
current_turn_user_idx=current_turn_user_idx, should_review_memory=should_review_memory,
plugin_user_context=plugin_user_context, ext_prefetch_cache=ext_prefetch_cache,
preflight_compression_blocked=compaction.blocked,
)
def _sanitize_model_for(agent: Any, moa_config: Any) -> Any:
"""Model name for strict-API tool-call sanitization. In MoA mode ``agent.model`` is
the virtual preset name; use the resolved aggregator so Gemini keeps
thought_signature (extra_content)."""
_sanitize_model = agent.model
if agent.provider == "moa":
if moa_config:
_agg = moa_config.get("aggregator") or {}
if _agg.get("model"):
_sanitize_model = _agg["model"]
if _sanitize_model == agent.model:
# Virtual-provider mode: no moa_config is threaded through; ask the facade
# for the aggregator slot from the previous create().
_agg_slot = getattr(getattr(agent, "client", None), "last_aggregator_slot", None)
if _agg_slot and _agg_slot.get("model"):
_sanitize_model = _agg_slot["model"]
return _sanitize_model
def build_api_messages(
agent: Any, messages: List[Dict[str, Any]], *, current_turn_user_idx: Any,
ext_prefetch_cache: Any, plugin_user_context: Any, moa_config: Any, active_system_prompt: Any,
) -> Tuple[List[Dict[str, Any]], str]:
"""Build the wire copy of ``messages`` for one API call plus the effective system
message. Returns ``(api_messages, effective_system)``.
Prompt-cache invariant: historical user/assistant rows replay their ``api_content``
sidecar (the exact bytes sent live) so the prefix stays byte-stable; the current
user turn reuses the prologue's stamp (or composes live when a caller bypassed the
prologue). Ephemeral context (prefetch, ``pre_llm_call`` hooks,
``ephemeral_system_prompt``) is added at API time only — ``messages`` stays untouched
beyond the sidecar stamp, and the system prompt is built ONCE per session and
replayed verbatim."""
from agent.agent_runtime_helpers import fill_empty_non_final_wire_payload
from agent.conversation_loop import _clone_message_for_send
from agent.replay_cleanup import canonicalize_replay_history
has_current = isinstance(current_turn_user_idx, int) and 0 <= current_turn_user_idx < len(messages)
current_turn_message = messages[current_turn_user_idx] if has_current else None
# Replay consumers canonicalize the persisted prefix on read; the request copy must
# carry the same bytes or a resume diverges mid-prefix. Only the rows BEFORE this
# turn's user message are the replayed prefix — rows this turn appended (its tool
# calls/results) are live and must never be rewritten between iterations. The
# expiry clock is the turn's admission time, frozen in _reset_per_turn_agent_state.
# Without an anchor (compaction found no surviving user row) there is no provable
# persisted prefix, so nothing is canonicalized. The clock is stamped once per turn in
# _reset_per_turn_agent_state; a caller that skipped the prologue fails loudly here
# rather than silently un-freezing it.
turn_now = agent._current_turn_timestamp
split = current_turn_user_idx if has_current else 0
canonical_messages = canonicalize_replay_history(messages[:split], now=turn_now) + messages[split:]
api_messages = []
for idx, msg in enumerate(canonical_messages):
# Structural clone, NOT msg.copy(): in-place transforms below must not reach
# persisted history via nested containers; see _clone_message_for_send.
api_msg = _clone_message_for_send(msg)
# api_content is bookkeeping (exact bytes sent), never a provider field — pop
# it from EVERY outgoing copy. display_* is display-only timeline metadata
# (strict OpenAI backends reject unknown keys); _row_id is the durable row id
# from _rows_to_conversation and only chat-completions strips underscore keys.
_api_content = api_msg.pop("api_content", None)
for key in ("display_kind", "display_metadata", "_row_id"):
api_msg.pop(key, None)
# Inject ephemeral context (memory prefetch + pre_llm_call user hooks)
# at API time only; `messages` is untouched beyond the api_content stamp.
if msg is current_turn_message and msg.get("role") == "user":
if isinstance(_api_content, str) and _api_content:
# Reuse the prologue's stamp so sidecar and wire cannot drift
# and every pass this turn sends identical bytes.
api_msg["content"] = _api_content
else:
# Callers that bypass the prologue stamping: compose live.
_composed = compose_user_api_content(
api_msg.get("content", ""), ext_prefetch_cache, plugin_user_context
)
if _composed is not None:
api_msg["content"] = _composed
elif (
isinstance(_api_content, str) and _api_content
and msg.get("role") in ("user", "assistant")
):
# Historical row: replay the exact bytes sent live so the prompt-cache
# prefix stays byte-stable. User rows carry the injection sidecar; user
# and assistant rows may carry a sanitize-divergence sidecar.
api_msg["content"] = _api_content
# Pass reasoning back to the API for ALL assistant messages so multi-turn
# reasoning context is preserved.
agent._copy_reasoning_content_for_api(msg, api_msg)
# 'reasoning' is trajectory-only (copied to 'reasoning_content' above);
# finish_reason is rejected by strict APIs (e.g. Mistral).
api_msg.pop("reasoning", None)
api_msg.pop("finish_reason", None)
# Fill empty non-final user/assistant wire copies so the pre-call sanitizer
# stops re-healing and flooding errors.log; durable history is untouched.
# After the reasoning copy so thinking-only turns keep payload.
fill_empty_non_final_wire_payload(api_msg, is_final=(idx == len(canonical_messages) - 1))
# _thinking_prefill survives intentionally: the drop pass below needs it.
# Strip length-continuation marks; some transports keep underscore keys.
api_msg.pop("_length_continuation_fragment", None)
api_msg.pop("_length_continuation_nudge", None)
# Strip Codex Responses fields (call_id, response_item_id): strict providers
# reject unknown fields. New dicts keep the internal list intact for Codex.
if agent._should_sanitize_tool_calls():
agent._sanitize_tool_calls_for_strict_api(
api_msg, model=_sanitize_model_for(agent, moa_config)
)
# 'reasoning_details' is kept: OpenRouter uses it for multi-turn reasoning
# continuity.
api_messages.append(api_msg)
# Final system message = cached prompt + ephemeral additions (API-time only).
# Plugin/recall context goes into the user message, never the system prompt: the
# prompt is built ONCE per session and replayed verbatim (stable cache prefix).
effective_system = active_system_prompt or ""
if agent.ephemeral_system_prompt:
effective_system = (effective_system + "\n\n" + agent.ephemeral_system_prompt).strip()
if effective_system:
api_messages = [{"role": "system", "content": effective_system}] + api_messages
return api_messages, effective_system
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
_PLUGIN_COMPAT_LAZY = {
'IDLE_COMPACTION_STATUS_TEMPLATE': ('agent.conversation_compression', 'IDLE_COMPACTION_STATUS_TEMPLATE'),
'PREFLIGHT_COMPRESSION_STATUS_TEMPLATE': ('agent.conversation_compression', 'PREFLIGHT_COMPRESSION_STATUS_TEMPLATE'),
'automatic_compaction_status_message': ('agent.context_engine', 'automatic_compaction_status_message'),
'compression_skipped_due_to_lock': ('agent.conversation_compression', 'compression_skipped_due_to_lock'),
'conversation_history_after_compression': ('agent.conversation_compression', 'conversation_history_after_compression'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----