Merge remote-tracking branch 'origin/main' into copilot-seams/integration

This commit is contained in:
Teknium committed 2026-10-26 00:40:32 -07:00
commit 1c4cbb2222
93 files changed
+6002 -573

No files matched your search

+5 -105
View File
@@ -34,6 +34,7 @@ from agent.model_metadata import (
MINIMUM_CONTEXT_LENGTH, fetch_model_metadata, is_local_endpoint
)
from agent.agent_init_ollama import _clamp_compressor_to_ollama_num_ctx, _configure_ollama_num_ctx
from agent.agent_init_compression_notice import _emit_compression_summary
from agent.primary_runtime_snapshot import _snapshot_primary_runtime
from agent.process_bootstrap import _install_safe_stdio
from agent.subdirectory_hints import SubdirectoryHintTracker
@@ -138,31 +139,6 @@ def _custom_provider_runtime_ids(value: Any) -> set[str]:
return {normalized, f"custom:{normalized}"}
def _build_codex_gpt5_autoraise_notice(
autoraise: dict[str, Any], context_length: Optional[int] = None
) -> str:
"""One-time notice when Codex gpt-5.x raises compaction (``autoraise``: model/from/to).
``context_length`` is the live-resolved window (Codex's catalog shifts server-side) so the
banner reports what this session got. Printed for CLI and replayed via status_callback for
gateway users, so it must be self-contained and include the exact opt-back-out command.
"""
model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1]
if isinstance(context_length, int) and context_length > 0:
cap = f"{round(context_length / 1000)}K"
else:
# Static fallback: codex-spark is natively 128K; gpt-5.4/5.5/5.6 are capped at 272K.
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
from_pct = round(autoraise["from"] * 100)
to_pct = round(autoraise["to"] * 100)
return (
f"ℹ Codex {model} caps context at {cap}, so auto-compaction was raised "
f"to {to_pct}% (from {from_pct}%) to use more of the window before "
f"summarizing.\n"
f" Opt back out: hermes config set compression.codex_gpt55_autoraise false"
)
def _resolve_compression_threshold(
global_threshold: float, model_cthresh: Optional[float], *, model: Optional[str] = None,
is_codex_autoraise: bool,
@@ -182,42 +158,6 @@ def _resolve_compression_threshold(
return model_cthresh, None
def _codex_gpt55_autoraise_notice_marker():
"""Per-profile marker path (``$HERMES_HOME`` is profile-scoped; not a config key)."""
return get_hermes_home() / ".codex_gpt55_autoraise_notice"
def _codex_gpt55_autoraise_notice_state(autoraise: dict[str, Any]) -> str:
"""Notice identity keyed on what it displays (model + from→to percentages).
An unchanged threshold stays silent across restarts; a changed global threshold or a
different autoraised Codex model re-notifies once.
"""
model = str(autoraise.get("model") or "").strip().lower().rsplit("/", 1)[-1]
from_pct = round(float(autoraise["from"]) * 100)
to_pct = round(float(autoraise["to"]) * 100)
return f"{model}:{from_pct}:{to_pct}"
def _codex_gpt55_autoraise_notice_seen(autoraise: dict[str, Any]) -> bool:
"""True if this exact notice was already shown for this profile (unreadable = unseen)."""
try:
current = _codex_gpt55_autoraise_notice_state(autoraise)
return _codex_gpt55_autoraise_notice_marker().read_text(
encoding="utf-8"
).strip() == current
except (OSError, KeyError, TypeError, ValueError):
return False
def _record_codex_gpt55_autoraise_notice(autoraise: dict[str, Any]) -> None:
"""Persist that the notice was shown. Best-effort: a failure only re-shows it later."""
with suppress(OSError, KeyError, TypeError, ValueError):
marker = _codex_gpt55_autoraise_notice_marker()
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(_codex_gpt55_autoraise_notice_state(autoraise), encoding="utf-8")
def _normalized_custom_base_url(value: Any) -> str:
if not isinstance(value, str):
return ""
@@ -1554,6 +1494,7 @@ def _parse_compression_config(agent, _agent_cfg) -> CompressionSettings:
codex_responses_native=responses_native,
codex_responses_compact_threshold=compact_threshold,
idle_compact_after_seconds=idle_compact_after_seconds,
prepare_ahead=is_truthy_value(cfg.get("prepare_ahead"), default=False),
)
@@ -1994,6 +1935,9 @@ def _build_context_engine(agent, _agent_cfg, cs, _custom_providers, _effective_c
):
if hasattr(_cc, _attr):
setattr(_cc, _attr, _value)
if cs.prepare_ahead and isinstance(_cc, ContextCompressor):
from agent.prepared_compaction import PreparedCompaction
_cc.prepared_compaction = PreparedCompaction()
agent.compression_checkpoint_required = cs.checkpoint_required
from agent.conversation_compression_checkpoint import _warn_checkpoint_required_without_capable_provider
_warn_checkpoint_required_without_capable_provider(agent)
@@ -2121,50 +2065,6 @@ def _inject_context_engine_tools(agent):
_ra().logger.debug("Context engine on_session_start: %s", _ce_err)
def _emit_compression_summary(agent, cs):
# Codex autoraise notice: once per profile/config state (persisted marker; the gateway
# rebuilds the agent per message). The display gate hides the banner, not the autoraise.
_autoraise = agent._compression_threshold_autoraised or {}
_autoraise_notice = None
if (
# A change in the raised threshold (or the autoraised model) updates the marker state and
# re-notifies once. The config display gate (compression.codex_gpt55_autoraise_notice) still
# suppresses the banner entirely without disabling the threshold autoraise. See #54432.
bool(_autoraise)
and cs.enabled
and cs.autoraise_notice_enabled
and not _codex_gpt55_autoraise_notice_seen(_autoraise)
):
_autoraise_notice = _build_codex_gpt5_autoraise_notice(
_autoraise, context_length=getattr(agent.context_compressor, "context_length", None)
)
if not agent.quiet_mode:
_cc = agent.context_compressor
if cs.enabled:
# The active engine's own threshold — a plugin's differs from cs.threshold.
_pct = getattr(_cc, "threshold_percent", cs.threshold)
_cap = getattr(_cc, "threshold_tokens_cap", None)
# Name the cap only when it is what set the trigger; on small windows the ratio already sits below it.
_eff_cap = getattr(_cc, "_effective_threshold_cap", lambda _ctx: None)(_cc.context_length)
_cap_binds = _eff_cap is not None and _cc.threshold_tokens == _eff_cap
_cap_note = f" (capped at {_cap:,} tokens)" if _cap_binds else ""
print(f"📊 Context limit: {_cc.context_length:,} tokens (compress at {int(_pct*100)}% = {_cc.threshold_tokens:,}{_cap_note})")
else:
print(f"📊 Context limit: {_cc.context_length:,} tokens (auto-compression disabled)")
# Gateway users get the same text via _compression_warning on turn 1.
if _autoraise_notice:
agent._safe_print(_autoraise_notice, diagnostic=True)
# status_callback isn't wired yet: stash for replay on the first turn; mark shown so
# repeated inits stay silent.
agent._compression_warning = _autoraise_notice
if _autoraise_notice:
_record_codex_gpt55_autoraise_notice(_autoraise)
# Feasibility check deferred to the first turn near threshold (eager costs ~400ms cold).
agent._compression_feasibility_checked = False
def _init_usage_state(agent):
from agent.runtime_cwd import scope_terminal_cwd
# Prefer the session's explicitly adopted workspace (a Desktop session created under the
+113
View File
@@ -0,0 +1,113 @@
"""Init-time compression banner and the once-per-profile Codex gpt-5.x autoraise notice."""
from __future__ import annotations
from contextlib import suppress
from typing import Any, Optional
from hermes_constants import get_hermes_home
def _build_codex_gpt5_autoraise_notice(
autoraise: dict[str, Any], context_length: Optional[int] = None
) -> str:
"""One-time notice when Codex gpt-5.x raises compaction (``autoraise``: model/from/to).
``context_length`` is the live-resolved window (Codex's catalog shifts server-side) so the
banner reports what this session got. Printed for CLI and replayed via status_callback for
gateway users, so it must be self-contained and include the exact opt-back-out command.
"""
model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1]
if isinstance(context_length, int) and context_length > 0:
cap = f"{round(context_length / 1000)}K"
else:
# Static fallback: codex-spark is natively 128K; gpt-5.4/5.5/5.6 are capped at 272K.
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
from_pct = round(autoraise["from"] * 100)
to_pct = round(autoraise["to"] * 100)
return (
f"ℹ Codex {model} caps context at {cap}, so auto-compaction was raised "
f"to {to_pct}% (from {from_pct}%) to use more of the window before "
f"summarizing.\n"
f" Opt back out: hermes config set compression.codex_gpt55_autoraise false"
)
def _codex_gpt55_autoraise_notice_marker():
"""Per-profile marker path (``$HERMES_HOME`` is profile-scoped; not a config key)."""
return get_hermes_home() / ".codex_gpt55_autoraise_notice"
def _codex_gpt55_autoraise_notice_state(autoraise: dict[str, Any]) -> str:
"""Notice identity keyed on what it displays (model + from→to percentages).
An unchanged threshold stays silent across restarts; a changed global threshold or a
different autoraised Codex model re-notifies once.
"""
model = str(autoraise.get("model") or "").strip().lower().rsplit("/", 1)[-1]
from_pct = round(float(autoraise["from"]) * 100)
to_pct = round(float(autoraise["to"]) * 100)
return f"{model}:{from_pct}:{to_pct}"
def _codex_gpt55_autoraise_notice_seen(autoraise: dict[str, Any]) -> bool:
"""True if this exact notice was already shown for this profile (unreadable = unseen)."""
try:
current = _codex_gpt55_autoraise_notice_state(autoraise)
return _codex_gpt55_autoraise_notice_marker().read_text(
encoding="utf-8"
).strip() == current
except (OSError, KeyError, TypeError, ValueError):
return False
def _record_codex_gpt55_autoraise_notice(autoraise: dict[str, Any]) -> None:
"""Persist that the notice was shown. Best-effort: a failure only re-shows it later."""
with suppress(OSError, KeyError, TypeError, ValueError):
marker = _codex_gpt55_autoraise_notice_marker()
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(_codex_gpt55_autoraise_notice_state(autoraise), encoding="utf-8")
def _emit_compression_summary(agent, cs):
# Codex autoraise notice: once per profile/config state (persisted marker; the gateway
# rebuilds the agent per message). The display gate hides the banner, not the autoraise.
_autoraise = agent._compression_threshold_autoraised or {}
_autoraise_notice = None
if (
# A change in the raised threshold (or the autoraised model) updates the marker state and
# re-notifies once. The config display gate (compression.codex_gpt55_autoraise_notice) still
# suppresses the banner entirely without disabling the threshold autoraise. See #54432.
bool(_autoraise)
and cs.enabled
and cs.autoraise_notice_enabled
and not _codex_gpt55_autoraise_notice_seen(_autoraise)
):
_autoraise_notice = _build_codex_gpt5_autoraise_notice(
_autoraise, context_length=getattr(agent.context_compressor, "context_length", None)
)
if not agent.quiet_mode:
_cc = agent.context_compressor
if cs.enabled:
# The active engine's own threshold — a plugin's differs from cs.threshold.
_pct = getattr(_cc, "threshold_percent", cs.threshold)
_cap = getattr(_cc, "threshold_tokens_cap", None)
# Name the cap only when it is what set the trigger; on small windows the ratio already sits below it.
_eff_cap = getattr(_cc, "_effective_threshold_cap", lambda _ctx: None)(_cc.context_length)
_cap_binds = _eff_cap is not None and _cc.threshold_tokens == _eff_cap
_cap_note = f" (capped at {_cap:,} tokens)" if _cap_binds else ""
print(f"📊 Context limit: {_cc.context_length:,} tokens (compress at {int(_pct*100)}% = {_cc.threshold_tokens:,}{_cap_note})")
else:
print(f"📊 Context limit: {_cc.context_length:,} tokens (auto-compression disabled)")
# Gateway users get the same text via _compression_warning on turn 1.
if _autoraise_notice:
agent._safe_print(_autoraise_notice, diagnostic=True)
# status_callback isn't wired yet: stash for replay on the first turn; mark shown so
# repeated inits stay silent.
agent._compression_warning = _autoraise_notice
if _autoraise_notice:
_record_codex_gpt55_autoraise_notice(_autoraise)
# Feasibility check deferred to the first turn near threshold (eager costs ~400ms cold).
agent._compression_feasibility_checked = False
+14 -14
View File
@@ -448,8 +448,8 @@ def _run_protected_sync_provider_call(callback: Callable[[dict[str, Any]], Any],
Aux clients are process-shared and cannot be closed to wake one request, so the callback (incl.
stream aggregation) runs in a daemon while the owner polls cancellation; on cancel the owner
unwinds at once and the daemon finishes under the provider timeout in ``kwargs`` (it owns no
transcript/commit state, never holds the session lock). Unprotected / no cancel source: direct.
unwinds at once; the daemon stops at its next streamed chunk, else under the provider timeout in ``kwargs``
(it owns no transcript/commit state, never holds the session lock). Unprotected / no cancel source: direct.
"""
source_cancel_check = _capture_aux_cancel_check()
if not _aux_interrupt_protected() or not callable(source_cancel_check):
@@ -7160,11 +7160,8 @@ class _ChatStreamAccumulator:
# llama-server prompt reading doesn't count toward the ceiling (``local_prompt_progress``).
self._prompt_reading = PromptReadingClock(self._started)
self._total_ceiling = total_ceiling
# Absolute instant the waiting host gives up; checked alongside (not instead of) the
# ceiling, and unaffected by pre-construction dispatch/TTFT.
# Checked as well as (not instead of) the ceiling above: the ceiling still bounds callers with no
# host deadline, and the host deadline is absolute, so it is unaffected by however long dispatch and
# TTFT took before this accumulator was constructed. See #99692.
# Absolute instant the waiting host gives up, checked as well as (not instead of) the ceiling, which
# still bounds callers with no host deadline; unaffected by pre-construction dispatch/TTFT (#99692).
self._host_deadline = host_deadline
self.content_parts: list[str] = []
self.reasoning_parts: list[str] = []
@@ -7175,7 +7172,10 @@ class _ChatStreamAccumulator:
self.resp_model = model or ""
def _check_deadlines(self) -> None:
"""Raise TimeoutError past the total ceiling or the host deadline."""
"""Raise TimeoutError past the total ceiling or the host deadline; a host hard cancel (/stop, session
boundary) stops the stream at its next chunk instead of letting the daemon bill on to the deadline."""
if _aux_interrupt_cancel_requested():
raise AuxiliaryExplicitCancellation()
now = time.monotonic()
if self._total_ceiling is not None and (now - self._started - self._prompt_reading.seconds) >= self._total_ceiling:
raise TimeoutError(f"Auxiliary streamed call timed out after {self._total_ceiling:.0f}s "
@@ -7819,12 +7819,9 @@ def _ladder_provider_fallback(first_err: Exception, route: _LadderRoute):
regardless of user intent. Auth errors from an explicit provider may only use the task's
own configured fallback_chain; they never imply an unconfigured provider hop."""
task, tag, resolved_provider = route.task, route.tag, route.resolved_provider
# Respect explicit provider choice for transient errors (auth, request validation, etc.) but allow
# fallback when the provider clearly cannot serve the request due to capacity: payment/quota exhaustion
# and connection failures are capacity problems, not request constraints. See #26803: daily token quota
# (429 + "too many tokens per day") must fall back just like a 402 credit error.
# Rate limits are included: after retries are exhausted, a 429 means the provider is at capacity. See
# #52228. See #26803: daily token quota must fall back like a 402 credit error.
# Respect explicit provider choice for transient errors (auth, request validation, etc.) but fall back when
# the provider cannot serve due to capacity: payment/quota exhaustion (a daily-token 429 like a 402, #26803),
# connection failures, and a 429 after retries are exhausted (#52228) are not request constraints.
is_auto = resolved_provider in {"auto", "", None}
reason = next((label for predicate, label in _FALLBACK_REASONS if predicate(first_err)), None)
is_capacity_error = any(
@@ -7893,6 +7890,9 @@ def _ladder_provider_fallback(first_err: Exception, route: _LadderRoute):
_record_route_info(route.route_info, _fallback_provider_from_label(fb_label), fb_model)
fb_resp = yield _LadderStep("fallback", (fb_client, fb_model, fb_label))
if fb_resp is not None:
if route.route_info is not None: # tell the caller its own route failed, not only where it landed
route.route_info.update(failed_model=route.final_model or "", failure=f"{reason}: {first_err}",
fallback_label=fb_label)
return fb_resp
fb_client, fb_model, fb_label = _next_fallback_after_quarantine(
task, resolved_provider, is_auto, route, _chain_failed_model, _chain_failure_scope,
+41 -45
View File
@@ -32,11 +32,12 @@ from agent.auxiliary_client import (
)
from agent.context_engine import ContextEngine, sanitize_memory_context
from agent.context_compressor_prellm import PreLlmSkipMixin
from agent.context_compressor_window import CompactionWindowMixin
from agent.context_compressor_prune import ProactivePruneMixin
from agent.context_compressor_summary import SummaryDispatchMixin
from agent.context_compressor_retry import retryable_user_text
from agent.context_compressor_tail import (
demote_stale_tail_tools, lean_tail_budget, is_demoted_stub, skill_view_names_by_call_id,
demote_stale_tail_tools, lean_tail_budget, is_demoted_stub, skill_view_names_by_call_id, tail_cut_pricer,
elide_keeping_skill_safety_rule, with_skill_safety_rule, without_skill_safety_rule,
)
from agent.context_compressor_telemetry import CompressionTelemetryMixin
@@ -2147,7 +2148,7 @@ Describe agent/tool work only as completed actions, state, or historical work.]"
class ContextCompressor(
SummaryDispatchMixin, PreLlmSkipMixin, CompressionTelemetryMixin, MicroCompactionMixin,
ProactivePruneMixin, ContextEngine,
ProactivePruneMixin, CompactionWindowMixin, ContextEngine,
):
"""Default context engine: prune tool results, protect head/tail, summarize the middle
with an LLM, and iteratively update the previous summary on later compactions."""
@@ -2278,6 +2279,8 @@ class ContextCompressor(
def _reset_session_compaction_state(self) -> None:
"""Shared per-session reset for /new, /reset and session end."""
if self.prepared_compaction is not None:
self.prepared_compaction.discard("session boundary")
# A handoff may carry role="user" only for alternation, so role alone can't prove a human turn existed.
self._previous_summary = self._summary_has_user_turn = self._last_summary_error = None
self._last_aux_model_failure_error = self._last_aux_model_failure_model = None
@@ -2321,6 +2324,8 @@ class ContextCompressor(
def bind_session_state(self, session_db: Any = None, session_id: str = "") -> None:
"""Bind the current session row so durable cooldowns can round-trip."""
if self.prepared_compaction is not None and (session_id or "") != self._session_id:
self.prepared_compaction.discard("session rebind")
self._session_db = session_db
self._session_id = session_id or ""
self._summary_failure_cooldown_until = 0.0
@@ -3119,14 +3124,14 @@ class ContextCompressor(
def _walk_tail_budget(
self, messages: list[dict[str, Any]], head_end: int, ceiling: int, min_tail: int, *, cut_at_break: bool,
price: Optional[Callable[[int], int]] = None,
) -> tuple[int, int]:
"""Accumulate message tokens newest-first until ``ceiling`` (once ``min_tail`` rows are kept).
Returns ``(cut_idx, accumulated)``; ``cut_idx`` is the first protected index. On the budget
break the cut stays at the last accepted row, or moves onto the breaking row when
``cut_at_break``. Only the newest assistant turn's thinking is charged (#73624) unless the route
echoes stale thinking every turn — must agree with the preflight estimate (#84371)."""
n = len(messages)
price = self._tail_row_pricer(messages)
echoes stale thinking every turn — must agree with the preflight estimate (#84371); ``price`` overrides it."""
n, price = len(messages), price or self._tail_row_pricer(messages)
accumulated = 0
cut = n # start from beyond the end
for i in range(n - 1, head_end - 1, -1):
@@ -3822,7 +3827,6 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
self.summary_model or self.model, f"{len(prompt):,}", _latency_info["prompt_build_ms"],
)
try:
# Compression is atomic: shield the summary call from gateway interrupts. Re-entrant.
with aux_interrupt_protection():
response = call_llm(**call_kwargs)
finally:
@@ -3843,6 +3847,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
)
if self._compression_cancelled():
raise AuxiliaryExplicitCancellation()
self._note_aux_route_fallback(_aux_route)
# Reasoning-field fallback (DeepSeek/Qwen/Kimi put the summary in reasoning_content); capped.
content = extract_content_or_reasoning(response, max_reasoning_chars=8000)
where = f"(provider={self.provider or 'auto'} model={self.summary_model or self.model})"
@@ -3936,17 +3941,21 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
from agent.conversation_compression import _raise_if_stale_attempt
_raise_if_stale_attempt(self)
self._previous_summary = summary
self._clear_compression_failure_cooldown()
self._summary_model_fallen_back = False
self._last_summary_error = None
self._clear_terminal_summary_failures()
# The provider answered a summary again: the sustained-overload budget restarts (#123167).
self._reset_consecutive_overload_aborts()
self._record_summary_success(summary)
return self._with_summary_prefix(summary)
except Exception as e:
except Exception as e: # health: allow BLE001 -- unchanged summary boundary: every failure goes to _on_summary_failure
return self._on_summary_failure(e, turns_to_summarize, focus_topic, memory_context)
def _record_summary_success(self, summary: str) -> None:
"""A healthy summary becomes the iterative base and clears the failure state."""
self._previous_summary = summary
self._clear_compression_failure_cooldown()
self._summary_model_fallen_back = False
self._last_summary_error = None
self._clear_terminal_summary_failures()
# The provider answered a summary again: the sustained-overload budget restarts (#123167).
self._reset_consecutive_overload_aborts()
def _build_summary_prompt(
self, content_to_summarize: str, summary_budget: int, focus_topic: Optional[str],
memory_context: str, has_user_turn: bool,
@@ -4999,11 +5008,11 @@ Write only the summary body. Do not include any preamble or prefix."""
# token-respecting floor exists then. Required user/assistant anchors and atomic tool groups
# are applied below and may still necessarily exceed the ceiling.
walk_floor = 0 if soft_ceiling >= min_tail * _estimate_msg_budget_tokens({}) else min_tail
cut_idx, accumulated = self._walk_tail_budget(messages, head_end, soft_ceiling, walk_floor, cut_at_break=False)
# Whole transcript fits soft_ceiling: re-cut with the raw budget so a worthwhile middle
# exists (else #40803 loop).
price = tail_cut_pricer(self, messages, head_end)
cut_idx, accumulated = self._walk_tail_budget(messages, head_end, soft_ceiling, walk_floor, cut_at_break=False, price=price)
# Whole transcript fits soft_ceiling: re-cut with the raw budget so a worthwhile middle exists (else #40803 loop).
if cut_idx <= head_end and 0 < accumulated <= soft_ceiling:
cut_idx, _ = self._walk_tail_budget(messages, head_end, token_budget, min_tail, cut_at_break=True)
cut_idx, _ = self._walk_tail_budget(messages, head_end, token_budget, min_tail, cut_at_break=True, price=price)
fallback_cut = n - min_tail
# The newest row never leaves the tail, whatever its role. When it alone exceeds the ceiling the walk accepts
@@ -5198,24 +5207,6 @@ Write only the summary body. Do not include any preamble or prefix."""
blank = self._blank_echo_indices_after(messages, self._find_last_user_message_idx(messages, 0))
return [m for idx, m in enumerate(messages) if idx not in blank] if blank else messages
def _compress_window(self, messages: list[dict[str, Any]]) -> tuple[int, int]:
"""Return ``(compress_start, compress_end)`` for the summarizable middle."""
compress_start = self._align_boundary_forward(messages, self._protect_head_size(messages))
compress_end = self._find_tail_cut_by_tokens(messages, compress_start)
# A role collision can merge the summary into the first tail row; keep an actionable user
# event out of that slot by retaining an older assistant/tool bridge.
latest_actionable_idx = self._find_last_user_message_idx(messages, 0)
if compress_end == latest_actionable_idx:
bridge_idx = latest_actionable_idx - 1
bridge_role = messages[bridge_idx].get("role") if bridge_idx >= 0 else None
if bridge_role == "tool":
bridge_idx = self._align_boundary_backward(messages, latest_actionable_idx)
elif bridge_role != "assistant":
bridge_idx = -1
if bridge_idx > compress_start:
compress_end = bridge_idx
return compress_start, compress_end
def _log_compression_start(
self, display_tokens: int, compress_start: int, compress_end: int, n_turns: int, tail_msgs: int,
) -> None:
@@ -5480,16 +5471,12 @@ Write only the summary body. Do not include any preamble or prefix."""
# Unpruned copy for no-op/abort returns (history stays lossless) and finalize; head/tail are
# assembled and measured from the pruned copy (#61932).
canonical_messages = self._drop_blank_echoes(messages)
# Phase 1: Prune old tool results (cheap, no LLM call)
messages, pruned_count = self._prune_old_tool_results(
messages, protect_tail_count=self.protect_last_n, protect_tail_tokens=self.tail_token_budget,
)
# Phases 1-2: prune (cheap, no LLM call), drop blank echoes, determine boundaries on the pruned
# copy so a pressure-demoted tail can compress (#61932)
messages, pruned_count, compress_start, compress_end = self._plan_compaction_window(messages)
if pruned_count and not self.quiet_mode:
logger.info("Pre-compression: pruned %d old tool result(s)", pruned_count)
messages = self._drop_blank_echoes(messages)
n_messages = len(messages)
# Phase 2: Determine boundaries (on the pruned copy so a pressure-demoted tail can compress, #61932)
compress_start, compress_end = self._compress_window(messages)
if compress_start >= compress_end:
self._record_compression_regions(
head_messages=messages[:compress_start], middle_messages=[], tail_messages=messages[compress_end:],
@@ -5500,6 +5487,12 @@ Write only the summary body. Do not include any preamble or prefix."""
n_messages,
)
return canonical_messages
prepared = self._take_prepared_summary(
messages, compress_start, fresh=bool(force or focus_topic or bypass_cooldown),
provider_context=bool(memory_context.strip()),
)
if prepared:
compress_end = prepared.compress_end
turns_to_summarize = messages[compress_start:compress_end]
# Lean mode demotes stale tail tool results before summary generation so stubs exist even if it aborts.
if getattr(self, "tail_mode", "lean") == "lean":
@@ -5531,12 +5524,15 @@ Write only the summary body. Do not include any preamble or prefix."""
from agent.conversation_compression import _raise_if_stale_attempt
_raise_if_stale_attempt(self)
feasibility_skip = not force and (
feasibility_skip = not (force or prepared) and (
self._fallback_streak_skip(telemetry)
or self._feasibility_skip(telemetry, turns_to_summarize, compress_start, compress_end)
)
summary = None # pre-LLM summary skip (feasibility or benched model): no LLM call; Phase 4 inserts the deterministic fallback
if not feasibility_skip:
if prepared:
# The pass ran this window through _generate_summary; adopt its result as an inline call would.
summary = self._adopt_prepared_summary(prepared)
elif not feasibility_skip:
summary = self._summarize_window(
messages, turns_to_summarize, scan, focus_topic, memory_context, bypass_cooldown,
)
+14
View File
@@ -118,6 +118,20 @@ SKILL_SAFETY_RULE = (
)
def tail_cut_pricer(compressor: Any, messages: list[dict[str, Any]], head_end: int):
"""Tail-cut walk price ``index -> tokens`` in which rows at or before the newest handoff cost nothing.
The handoff scan folds every row up to the newest in-transcript handoff into the summary window (the tail starts
past it), so none of them can stay in the protected tail. Charging them let the handoff row alone break the soft
ceiling: the walk stopped on it, the window held only that handoff, and compress() returned the
``empty_post_handoff_window`` no-op at every trigger while the session grew past its window."""
price = compressor._tail_row_pricer(messages)
newest = next(
(i for i in range(len(messages) - 1, head_end - 1, -1) if compressor._is_context_summary_message(messages[i])), -1,
)
return price if newest < 0 else (lambda i: 0 if i <= newest else price(i))
def with_skill_safety_rule(
summary: str, head: list[dict[str, Any]], tail: list[dict[str, Any]],
) -> tuple[str, list[dict[str, Any]], list[dict[str, Any]]]:
+20
View File
@@ -56,6 +56,8 @@ class CompressionTelemetryMixin:
"method": "none", "items_dropped": 0, "messages_before": None, "messages_after": None,
"tokens_before": None, "tokens_after": None, "tokens_reclaimed": None, "token_count_method": None,
"tool_results_pruned": None, "reasoning_items_pruned": None, "has_focus_topic": False,
# True when the summary came from an opt-in prepared (background) pass (compression.prepare_ahead).
"prepared_summary": False,
}
self._active_compression_telemetry = self._last_compression_telemetry = telemetry
return telemetry
@@ -100,6 +102,24 @@ class CompressionTelemetryMixin:
accumulate = key in {"queue_wait_ms", "summary_generation_ms"} and value is not None
telemetry[key] = (telemetry.get(key) or 0) + value if accumulate else value
def _note_aux_route_fallback(self, route: dict[str, str]) -> None:
"""``call_llm`` answered the summary from a fallback route after the configured one failed (dead
endpoint, quota): record it like the compressor's own main-model fallback, so telemetry says
``fallback_used`` and, when the main model answered, the user is told their compression model is broken."""
failed_model = route.get("failed_model")
if not failed_model:
return
telemetry = getattr(self, "_active_compression_telemetry", None)
if isinstance(telemetry, dict):
telemetry["fallback_used"] = True
telemetry["failure_class"] = telemetry.get("failure_class") or "aux_model_fallback"
# The user warning says "summarised with your main model": only the main-agent route earns it, never a
# fallback_chain entry that happens to share the model name on another provider.
if str(route.get("fallback_label") or "").startswith("main-agent("):
self._last_aux_model_failure_model = failed_model
failure = route.get("failure") or "unknown error"
self._last_aux_model_failure_error = failure if len(failure) <= 220 else failure[:217].rstrip() + "..."
def _compression_method(self) -> str:
"""How the summary that replaced the middle window was produced."""
if getattr(self, "_last_summary_fallback_used", False):
+66
View File
@@ -0,0 +1,66 @@
"""Compaction window planning for ContextCompressor: the head/middle/tail boundaries that compress() and
the opt-in prepared-compaction pass (agent/prepared_compaction.py) both plan, and the splice of a prepared
summary into that window."""
from __future__ import annotations
from typing import Any, Optional
from agent.prepared_compaction import PreparedCompaction, PreparedSummary
class CompactionWindowMixin:
"""Window planning shared by compress() and the background pass."""
# Set by agent_init when compression.prepare_ahead is on; None leaves compress() inline as before.
prepared_compaction: Optional[PreparedCompaction] = None
def _compress_window(self, messages: list[dict[str, Any]]) -> tuple[int, int]:
"""Return ``(compress_start, compress_end)`` for the summarizable middle."""
compress_start = self._align_boundary_forward(messages, self._protect_head_size(messages))
compress_end = self._find_tail_cut_by_tokens(messages, compress_start)
# A role collision can merge the summary into the first tail row; keep an actionable user
# event out of that slot by retaining an older assistant/tool bridge.
latest_actionable_idx = self._find_last_user_message_idx(messages, 0)
if compress_end == latest_actionable_idx:
bridge_idx = latest_actionable_idx - 1
bridge_role = messages[bridge_idx].get("role") if bridge_idx >= 0 else None
if bridge_role == "tool":
bridge_idx = self._align_boundary_backward(messages, latest_actionable_idx)
elif bridge_role != "assistant":
bridge_idx = -1
if bridge_idx > compress_start:
compress_end = bridge_idx
return compress_start, compress_end
def _plan_compaction_window(self, messages: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int, int, int]:
"""Phases 1-2 of compress(): ``(working, pruned_count, compress_start, compress_end)``.
``working`` is a pruned copy without blank echoes; the boundaries index into it."""
working, pruned_count = self._prune_old_tool_results(
messages, protect_tail_count=self.protect_last_n, protect_tail_tokens=self.tail_token_budget,
)
working = self._drop_blank_echoes(working)
compress_start, compress_end = self._compress_window(working)
return working, pruned_count, compress_start, compress_end
def _take_prepared_summary(
self, messages: list[dict[str, Any]], compress_start: int, *, fresh: bool, provider_context: bool,
) -> Optional[PreparedSummary]:
"""The summary prepared in the background for this window, which an automatic compaction splices at
ITS boundary; every newer message stays outside the summary, in the tail. ``fresh`` compactions
(manual, focus, overflow recovery, memory-provider context) summarize anew, and once a memory
provider supplies context no further summaries are prepared (each would be thrown away)."""
if self.prepared_compaction is None:
return None
if provider_context:
self.prepared_compaction.stop_for_provider_context()
return self.prepared_compaction.take(
messages, self._session_id, compress_start, eligible=not (fresh or provider_context),
)
def _adopt_prepared_summary(self, prepared: PreparedSummary) -> str:
"""Adopt a prepared summary as an inline summary call would have left it; returns the handoff text."""
from agent.prepared_compaction import adopt
adopt(self, prepared)
return prepared.summary
@@ -146,6 +146,13 @@ def _emit_compression_attempt_telemetry(
finish_compression_attempt(
commit_status, payload.get("failure_class"), getattr(agent.context_compressor, "context_length", None), agent=agent,
)
state = getattr(compressor, "prepared_compaction", None)
spliced = payload.get("prepared_summary") or (
state is not None and state.spliced_attempt_id is not None and state.spliced_attempt_id == seed["attempt_id"])
if spliced and commit_status != "committed":
from hermes_cli.observability.shared_metrics_prepared_compaction import record_prepared_compaction
record_prepared_compaction("compaction_failed", failure_class=payload.get("failure_class") or "")
except Exception as exc:
logger.debug("failed to emit compression attempt telemetry: %s", exc, exc_info=True)
+558
View File
@@ -0,0 +1,558 @@
"""Opt-in prepared compaction (``compression.prepare_ahead``, default off).
When the provider-reported prompt size comes within ``PREPARE_BAND_RATIO`` of
the compaction trigger, the turn loop (before a tool batch runs, and at turn
end) starts ONE background pass. The pass plans the same window
``ContextCompressor.compress`` would plan and runs the existing summariser on a
copy of the compressor and of that window's messages. The live transcript and
compressor are untouched, so the prompt cache is too.
At the next automatic threshold compaction ``compress`` takes the completed
summary if the messages it covers are still the same messages: same session,
same head boundary, and the same message structure (roles, tool call and result ids) up to that boundary, each
message vouched for by its durable ``message_uid``. The text of a persisted
message is not compared: plugins rewrite text in place, and a summary written
before such a rewrite still summarises the same messages. Only a message that
has no uid on one side (caller-supplied history that is never persisted) is
compared by text, since nothing else can tell it from a different message. On a hit the summary replaces
``messages[start:candidate_end]`` and every message after the candidate
boundary, however many arrived since the pass started, stays verbatim in the
tail. Where the fresh plan would cut does not matter, earlier or later: the
summary is never redone over a moved cut, and the candidate boundary is the
tail's start. On a miss compaction runs inline exactly as without the flag.
When no completed summary exists but a running pass fits, compaction waits for
that pass instead of starting the same summary over: the pass's streamed
progress and local prompt reading feed the waiting compaction's hooks, so the
host's idle watchdog and deadline extension see a live summary exactly as they
would inline, and the wait ends at the host's deadline (the compression aux
timeout without one), on cancellation, or when the pass is discarded. A pass
that compaction or a session boundary supersedes is cancelled: its summary
stream is closed instead of being billed to completion for nothing.
State is per compressor and in memory only; every session boundary (reset,
end, or a rebind to another session id) discards it. Manual, focus,
overflow-recovery and memory-provider-context compactions always summarise
fresh; the first compaction that needs provider context stops further passes
for the agent, since every later one would need it too. A failed pass backs off
further passes but never touches the live compressor's cooldown or failure
state; a pass that recovered on the main model hands that fallback to the live
compressor on a splice, as an inline summary would.
"""
from __future__ import annotations
import copy
import hashlib
import logging
import threading
import time
from dataclasses import dataclass, field, replace
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
# Start a pass once the real prompt count is within this fraction of the
# compaction trigger (a band below ``threshold_tokens``).
PREPARE_BAND_RATIO = 0.12
# Minimum pause after a failed pass (the summariser's own cooldown applies when longer).
_FAILED_PASS_BACKOFF_SECONDS = 60.0
# Background passes running across the process (a cancelled pass frees its slot as it unwinds; its
# provider stream closes on the aux cancel path, normally within one chunk).
_slots = threading.BoundedSemaphore(2)
# How often a compaction waiting on a running pass re-checks cancellation and the deadline.
_WAIT_POLL_SECONDS = 0.5
# Compressor state a summary call leaves behind besides the summary: the main-model fallback after a
# failed summary model and the warning it raises (the next compaction tries the summary model again,
# inline or prepared alike). A splice adopts the pass's.
_SUMMARY_CALL_STATE = (
"summary_model", "_last_aux_model_failure_error", "_last_aux_model_failure_model", "_last_aux_resolved_model",
)
# Attempt telemetry the summary call fills; a splice copies the pass's values onto the attempt.
_SUMMARY_CALL_TELEMETRY = (
"aux_prompt_tokens", "aux_output_reservation", "aux_provider", "aux_model", "effective_aux_context", "fit_margin",
"aux_call_duration_ms", "queue_wait_ms", "prompt_build_ms", "time_to_first_progress_ms", "summary_generation_ms",
"summary_input_chars", "summary_input_sampled_chars", "summary_input_omitted_chars", "summary_input_record_count",
"summary_input_sampled_record_count", "summary_input_elided_record_count", "fallback_used", "failure_class",
)
# Per message: (structure, durable uid or None, text digest).
_Identity = tuple[str, Optional[str], str]
# Why a compaction stopped waiting on a running pass; the pass then records it as its cancel reason.
_WAIT_CANCEL_REASONS = frozenset({"wait_timeout", "host_cancelled"})
class _Miss(str):
"""A splice-miss reason: the log text, plus the closed code opt-in shared metrics count."""
code: str
def __new__(cls, code: str, text: str) -> _Miss:
miss = super().__new__(cls, text)
miss.code = code
return miss
def _record(event: str, **dims: Any) -> None:
"""Count one event in opt-in shared metrics (a no-op when off; never raises)."""
from hermes_cli.observability.shared_metrics_prepared_compaction import record_prepared_compaction
record_prepared_compaction(event, **dims)
@dataclass(frozen=True)
class _Window:
session_id: str
compress_start: int
compress_end: int
identities: tuple[_Identity, ...] # one per message of ``messages[:compress_end]``
@dataclass(frozen=True)
class PreparedSummary(_Window):
summary: str # prefixed handoff text, exactly as ``_generate_summary`` returns it
body: str # bare body the next compaction extends iteratively
call_state: dict[str, Any] = field(default_factory=dict, compare=False) # _SUMMARY_CALL_STATE after the pass
telemetry: dict[str, Any] = field(default_factory=dict, compare=False) # _SUMMARY_CALL_TELEMETRY of the pass
waited: Optional[float] = field(default=None, compare=False) # seconds the taking compaction waited on the pass
class _PendingPass:
"""The one running pass allowed to publish: its planned window, completion and cancel events, and
the progress and prompt-reading hooks of a compaction waiting on it."""
def __init__(self, window: _Window) -> None:
self.window = window
self.done = threading.Event()
# Set once nothing can use the pass any more; its summary stream is closed (the aux hard-cancel path).
self.cancelled = threading.Event()
self.cancel_reason = "superseded" # set by whoever cancels first; the pass records it
self.on_progress: Optional[Callable[[], None]] = None
self.on_reading: Optional[Callable[[float], None]] = None
def cancel(self, reason: str) -> None:
if not self.cancelled.is_set():
self.cancel_reason = reason
self.cancelled.set()
def touch(self) -> None:
hook = self.on_progress
if hook is not None:
hook()
def read(self, seconds: float) -> None:
hook = self.on_reading
if hook is not None:
hook(seconds)
def _message_identity(msg: Any) -> _Identity:
"""Role, tool call ids/names and tool result ids; the durable ``message_uid`` when the message has
been persisted; and a digest of its text for messages no uid vouches for. Tool arguments and result
bodies are left out: the no-LLM prune rewrites them in place."""
if not isinstance(msg, dict):
return "?", None, ""
from agent.memory_manager import sanitize_context
from agent.message_content import flatten_message_text
from agent.message_metadata import message_uid_or_none
role = str(msg.get("role") or "")
uid = message_uid_or_none(msg)
if role == "tool":
return f"tool\x1f{msg.get('tool_call_id') or ''}", uid, ""
# Normalised the way a SessionDB round trip normalises it.
text = sanitize_context(flatten_message_text(msg.get("content"))).strip()
digest = hashlib.blake2b(text.encode("utf-8", errors="replace"), digest_size=8).hexdigest()
parts = [role]
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
fn = tc.get("function") or {}
parts.append(f"{tc.get('id', '')}:{fn.get('name', '') if isinstance(fn, dict) else ''}")
else:
parts.append(f"{getattr(tc, 'id', '')}:{getattr(getattr(tc, 'function', None), 'name', '')}")
return "\x1f".join(parts), uid, digest
def prefix_identities(messages: list[dict[str, Any]], end: int) -> tuple[_Identity, ...]:
"""Identity of each message in ``messages[:end]``."""
return tuple(_message_identity(msg) for msg in messages[:end])
def _mismatch(window: _Window, messages: list[dict[str, Any]], session_id: str, start: int) -> Optional[_Miss]:
"""Why *window* cannot splice into ``messages`` with the head ending at ``start``, or None. The fresh tail
cut is not consulted: the candidate's boundary is where the tail starts (one summary, never redone)."""
if window.session_id != session_id:
return _Miss("other_session", "another session")
if window.compress_start != start:
return _Miss("head_moved", f"head moved ({window.compress_start} -> {start})")
if window.compress_end > len(messages):
return _Miss("transcript_shorter", "transcript shorter than the prepared window")
for idx, ((structure, uid, digest), msg) in enumerate(zip(window.identities, messages)):
now_structure, now_uid, now_digest = _message_identity(msg)
if now_structure != structure:
return _Miss("shape_changed", f"message {idx} changed shape")
if uid and now_uid:
if uid != now_uid:
return _Miss("rewound", f"message {idx} is a different message (rewound or branched)")
elif digest != now_digest:
return _Miss("text_changed", f"message {idx} changed and has no durable id to vouch for it")
return None
def _worker_copy(compressor: Any) -> Any:
"""A compressor copy whose writes stay off the live one: no durable row, private attempt
telemetry, no host cancel consult (those belong to a foreground attempt), and the per-call
fallback fields cleared as a compaction attempt clears them."""
worker = copy.copy(compressor)
worker._session_db = None
worker._compression_cancelled_check = None
worker._last_aux_model_failure_error = worker._last_aux_model_failure_model = None
worker._begin_compression_telemetry(current_tokens=None)
return worker
def _blocked_for_platform(agent: Any, origin: str) -> bool:
"""A turn-end pass in a session that ends with its turn (a cron run, a delegated child, a one-shot) is never
used."""
if origin != "turn-end":
return False
if getattr(agent, "platform", None) in ("cron", "subagent") or getattr(agent, "_delegate_depth", 0) > 0:
return True
from agent.oneshot_footprint import agent_is_oneshot
return agent_is_oneshot(agent) # ``chat -q``: the agent is released right after the reply
class PreparedCompaction:
"""Prepared-summary state owned by one ``ContextCompressor``."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._entry: Optional[PreparedSummary] = None
self._pending: Optional[_PendingPass] = None # the one pass allowed to publish
self._retry_after = 0.0 # monotonic; set by a failed pass
# Set by the first compaction that needed memory-provider context; such compactions summarise fresh.
self._needs_provider_context = False
self._busy_window: Optional[tuple[str, int, int]] = None # last window the slot cap refused (counted once)
self._taken: Optional[PreparedSummary] = None # handed to compress(), not yet spliced by adopt()
# The attempt that spliced the last summary. Lives here, not on the compressor, because a pre-commit
# cancel restores the compressor's attempt telemetry (and its prepared_summary flag) before the
# attempt record is emitted.
self.spliced_attempt_id: Optional[str] = None
self._boundaries = 0 # discard() calls that dropped something; a wait that spans one was discarded
def _count_unspliced(self) -> None:
"""A summary take() handed out that adopt() never spliced (the compaction aborted or no-opped after
taking it) is counted once, at the next touch, so every summary ends as used or discarded."""
with self._lock:
taken, self._taken = self._taken, None
if taken is not None:
_record("discarded", reason="compaction_aborted", wait_seconds=taken.waited)
def discard(self, reason: str, code: str = "session_boundary") -> None:
"""Drop the candidate and cancel a running pass (it can no longer publish); ``code`` is the closed
reason opt-in shared metrics count."""
self._count_unspliced()
with self._lock:
entry, pending, self._entry, self._pending = self._entry, self._pending, None, None
if pending is not None:
pending.cancel(code) # under the lock: the pass reads its cancel reason after it
if entry is not None or pending is not None:
self._boundaries += 1
if entry is not None or pending is not None:
_record("discarded", reason=code)
dropped = entry or (pending.window if pending is not None else None)
if dropped is not None:
logger.info(
"prepared compaction: dropped session=%s window=%d-%d reason=%s%s", dropped.session_id,
dropped.compress_start, dropped.compress_end, reason, "" if entry is not None else " (pass running)",
)
def stop_for_provider_context(self) -> None:
"""A memory provider supplied summary context: stop preparing summaries that would be discarded."""
if not self._needs_provider_context:
self._needs_provider_context = True
logger.info("prepared compaction: off for this agent (memory provider supplies compaction context)")
_record("auto_disabled", reason="provider_context")
def take(
self, messages: list[dict[str, Any]], session_id: str, start: int, *, eligible: bool,
) -> Optional[PreparedSummary]:
"""The completed candidate when it still covers the same messages after ``compress()``'s head, else
what a running pass whose window does publishes while this compaction waits for it, else None. Either way the state is
cleared and a running pass is cancelled: this compaction consumes or supersedes it."""
self._count_unspliced()
with self._lock:
entry, pending = self._entry, self._pending
self._entry = None
boundaries = self._boundaries
reason = None if eligible else _Miss(
"provider_context" if self._needs_provider_context else "fresh_required", "fresh summary required",
)
if reason is None and entry is not None:
reason = _mismatch(entry, messages, session_id, start)
waited = None
try:
if eligible and (entry is None or reason is not None):
if pending is not None and _mismatch(pending.window, messages, session_id, start) is None:
wait_started = time.monotonic()
entry, reason = self._await(pending)
waited = time.monotonic() - wait_started
if entry is None and self._boundaries != boundaries:
# The pass published, then a boundary dropped it before this wait read it: a discard,
# already counted by the boundary, not a failed pass.
reason = _Miss("discarded_while_waiting", "running pass discarded")
elif entry is None:
reason = _Miss(
"running_not_fit", "running pass does not fit",
) if pending is not None else _Miss("none", "no candidate")
finally:
with self._lock:
if self._pending is pending:
self._pending = None
# A pass that published after the snapshot above planned on the pre-compaction transcript.
self._entry = None
if pending is not None:
# Done already, or superseded: stop paying for it.
code = getattr(reason, "code", None)
pending.cancel(code if code in _WAIT_CANCEL_REASONS else "superseded")
if reason is not None or entry is None:
if entry is not None or pending is not None:
logger.info("prepared compaction: splice miss session=%s reason=%s", session_id, reason)
if reason.code != "discarded_while_waiting": # the discard() that woke the wait counted it
_record("discarded", reason=reason.code, wait_seconds=waited)
return None
logger.info(
"prepared compaction: splice hit session=%s window=%d-%d", session_id, entry.compress_start,
entry.compress_end,
)
taken = replace(entry, waited=waited) # counted as used once adopt() splices it
with self._lock:
self._taken = taken
return taken
def _await(self, pending: _PendingPass) -> tuple[Optional[PreparedSummary], Optional[_Miss]]:
"""Wait on the compacting thread for *pending*: what it published, or None and why (the host's
deadline, or the compression aux timeout without one; a host cancel; a discard; a failed pass).
Meanwhile the pass's streamed progress and local prompt reading feed this thread's hooks, so the
host's idle watchdog and deadline extension see a live summary exactly as they would inline; the
deadline is re-read on every poll because prompt reading pushes it back."""
from agent.auxiliary_client import (
_aux_progress, _capture_aux_cancel_check, _captured_aux_cancel_requested, _current_aux_stream_deadline,
_effective_aux_timeout,
)
from agent.local_prompt_progress import current_prompt_reading_hook
# Hooks first: the pass may stream while the rest of the setup runs (the timeout loads config).
pending.on_progress = getattr(_aux_progress, "hook", None)
pending.on_reading = current_prompt_reading_hook()
cancel_check = _capture_aux_cancel_check()
fallback = time.monotonic() + float(_effective_aux_timeout("compression", None))
logger.info(
"prepared compaction: waiting for the running pass session=%s window=%d-%d",
pending.window.session_id, pending.window.compress_start, pending.window.compress_end,
)
try:
while not pending.done.wait(
max(0.0, min(_WAIT_POLL_SECONDS, (_current_aux_stream_deadline() or fallback) - time.monotonic())),
):
if pending.cancelled.is_set():
return None, _Miss("discarded_while_waiting", "running pass discarded")
if cancel_check is not None and _captured_aux_cancel_requested(cancel_check):
return None, _Miss("host_cancelled", "host cancelled the wait")
if time.monotonic() >= (_current_aux_stream_deadline() or fallback):
return None, _Miss("wait_timeout", "running pass did not finish in time")
finally:
pending.on_progress = pending.on_reading = None
with self._lock: # a discard() between the pass publishing and this wake took the entry and cancelled
entry, self._entry = self._entry, None
discarded = pending.cancelled.is_set()
if discarded:
return None, _Miss("discarded_while_waiting", "running pass discarded")
if entry is None or entry.identities != pending.window.identities:
return None, _Miss("pass_failed_while_waiting", "running pass failed")
return entry, None
def _blocked(self, agent: Any, compressor: Any, origin: str) -> bool:
if not getattr(agent, "compression_enabled", False) or getattr(agent, "_persist_disabled", False):
return True # compression off, or a background-review fork of the live session
if (
getattr(agent, "api_mode", None) == "codex_app_server"
or getattr(agent, "codex_responses_native_compaction", False)
or getattr(compressor, "_micro_compact_enabled", False)
):
return True # another path owns compaction, or history is rewritten every turn
if self._needs_provider_context or _blocked_for_platform(agent, origin):
return True
if compressor.awaiting_real_usage_after_compression or compressor._fallback_compression_streak >= 2:
return True # no real usage yet, or the summary model is benched after repeated fallbacks
tokens = compressor.last_prompt_tokens
band_floor = int(compressor.threshold_tokens * (1 - PREPARE_BAND_RATIO))
if tokens <= 0 or compressor.threshold_tokens <= 0 or tokens < band_floor:
return True
now = time.monotonic()
return now < compressor._summary_failure_cooldown_until or now < self._retry_after
def maybe_prepare(
self, agent: Any, compressor: Any, messages: list[dict[str, Any]], origin: str,
) -> Optional[threading.Thread]:
"""Start the pass when the next compaction is near and none ran yet; returns the started thread.
Never raises."""
token: Optional[_PendingPass] = None
try:
self._count_unspliced()
if self._blocked(agent, compressor, origin):
return None
with self._lock:
if self._pending is not None or self._entry is not None:
return None # one summary per compaction: later messages join the tail at the splice
working, _, start, end = compressor._plan_compaction_window(messages)
if start >= end:
return None
worker = _worker_copy(compressor)
# Same handoff rehydration and window rows compress() uses (mutates only the worker).
scan = worker._scan_window_handoffs(working, start, end, working[start:end])
if scan.tail_start != end:
return None # a handoff inside the protected tail: leave it to compress()
turns = scan.turns_to_summarize
if not turns:
return None
focus = worker._derive_auto_focus_topic(working)
window = _Window(compressor._session_id, start, end, prefix_identities(working, end))
# Never queue threads that retain transcripts while every slot is busy.
if not _slots.acquire(blocking=False):
if self._busy_window != (window.session_id, start, end): # count refused passes, not tool batches
self._busy_window = (window.session_id, start, end)
_record("skipped_busy", origin=origin)
return None
with self._lock:
if self._pending is not None or self._entry is not None:
_slots.release()
return None
token = self._pending = _PendingPass(window)
from agent.memory_provider import spawn_context_thread
# Profile and secret scope ride the contextvars into the pass.
thread = spawn_context_thread(
self._run_pass, name="compaction-prepare", args=(worker, copy.deepcopy(turns), focus, token, origin),
)
thread.start()
_record("pass_started", origin=origin)
return thread
except Exception:
if token is not None:
with self._lock:
if self._pending is token:
self._pending = None
token.done.set()
_slots.release()
logger.debug("prepared compaction: maybe_prepare failed", exc_info=True)
_record("internal_error", origin=origin, failure_class="exception")
return None
def _run_pass(
self, worker: Any, turns: list[dict[str, Any]], focus: Optional[str], token: _PendingPass, origin: str,
) -> None:
from agent.auxiliary_client import AuxiliaryExplicitCancellation, aux_interrupt_protection, aux_progress_hook
from agent.local_prompt_progress import prompt_reading_hook
from agent.thread_scoped_output import thread_scoped_silence
window = token.window
entry: Optional[PreparedSummary] = None
event, failure_class = "pass_cancelled", None # a published summary records pass_completed instead
started = time.monotonic()
try:
logger.info(
"prepared compaction: pass started session=%s origin=%s window=%d-%d turns=%d",
window.session_id, origin, window.compress_start, window.compress_end, len(turns),
)
with (
thread_scoped_silence(), aux_interrupt_protection(cancel_event=token.cancelled),
aux_progress_hook(token.touch), prompt_reading_hook(token.read),
):
summary = worker._generate_summary(turns, focus_topic=focus)
if token.cancelled.is_set():
logger.info("prepared compaction: pass cancelled session=%s after %.1fs (%s)", window.session_id,
time.monotonic() - started, token.cancel_reason)
elif summary:
telemetry = worker._active_compression_telemetry or {}
entry = PreparedSummary(
window.session_id, window.compress_start, window.compress_end, window.identities,
summary, worker._previous_summary,
call_state={name: getattr(worker, name, None) for name in _SUMMARY_CALL_STATE},
telemetry={key: telemetry.get(key) for key in _SUMMARY_CALL_TELEMETRY},
)
logger.info("prepared compaction: pass completed session=%s window=%d-%d in %.1fs",
window.session_id, window.compress_start, window.compress_end, time.monotonic() - started)
else:
logger.warning("prepared compaction: pass failed session=%s after %.1fs: %s", window.session_id,
time.monotonic() - started,
worker._last_summary_error or "summariser returned nothing")
event, failure_class = "pass_failed", _pass_failure_class(worker)
except AuxiliaryExplicitCancellation:
# The closed stream raises a BaseException; it must not reach the thread's excepthook (stderr).
logger.info("prepared compaction: pass cancelled session=%s after %.1fs (%s)", window.session_id,
time.monotonic() - started, token.cancel_reason)
except Exception:
logger.warning("prepared compaction: pass crashed session=%s", window.session_id, exc_info=True)
event, failure_class = "internal_error", "exception"
finally:
with self._lock:
if entry is not None and self._pending is token:
event = "pass_completed"
if self._pending is token:
self._pending = None
if entry is not None:
self._entry = entry
else:
self._retry_after = max(
worker._summary_failure_cooldown_until, time.monotonic() + _FAILED_PASS_BACKOFF_SECONDS,
)
token.done.set()
_slots.release()
_record(
event, origin=origin, reason=token.cancel_reason if event == "pass_cancelled" else None,
failure_class=failure_class,
)
def _pass_failure_class(worker: Any) -> str:
"""The class an inline attempt logs for this failed summary call: a terminal failure first."""
from agent.context_compressor import _TERMINAL_SUMMARY_FAILURES
terminal = next((cls for flag, cls, _ in _TERMINAL_SUMMARY_FAILURES if getattr(worker, flag, False)), None)
return terminal or (worker._active_compression_telemetry or {}).get("failure_class") or "summary_generation_failed"
def release_for_agent(agent: Any) -> None:
"""The agent is being released (cache eviction, rewind, close): no compaction of it can splice the summary,
so stop paying for a running pass."""
prepared = getattr(getattr(agent, "context_compressor", None), "prepared_compaction", None)
if prepared is not None:
prepared.discard("agent released", "agent_released")
def adopt(compressor: Any, prepared: PreparedSummary) -> None:
"""Leave the live compressor as the inline summary call would have: the summary is the iterative base,
failure state cleared, the pass's main-model fallback (if any) carried over, its call in the telemetry."""
state = getattr(compressor, "prepared_compaction", None)
telemetry = getattr(compressor, "_active_compression_telemetry", None)
if state is not None:
with state._lock:
if state._taken is prepared:
state._taken = None
state.spliced_attempt_id = telemetry.get("attempt_id") if isinstance(telemetry, dict) else None
compressor._record_summary_success(prepared.body)
for name, value in prepared.call_state.items():
setattr(compressor, name, value)
if isinstance(telemetry, dict):
telemetry.update({key: value for key, value in prepared.telemetry.items() if value is not None})
telemetry["prepared_summary"] = True
_record("used", reason="aux_fallback_main" if prepared.telemetry.get("fallback_used") else None,
wait_seconds=prepared.waited)
def maybe_prepare(agent: Any, messages: list[dict[str, Any]], *, origin: str) -> Optional[threading.Thread]:
"""Turn-loop hook; a no-op unless ``compression.prepare_ahead`` gave the compressor state."""
compressor = getattr(agent, "context_compressor", None)
state = getattr(compressor, "prepared_compaction", None)
return state.maybe_prepare(agent, compressor, messages, origin) if state is not None else None
+41
View File
@@ -254,6 +254,47 @@ def _db_flush_row(agent, msg: dict, is_current_turn_user: bool) -> dict[str, Any
return row
def sync_rewritten_tool_rows(agent, batch: list[dict], contents_before: list[Any]) -> int:
"""Carry an end-of-batch rewrite of already-flushed tool results (the turn budget's spill
stubs) onto their durable rows; returns the rows rewritten.
Each result was flushed as it landed, so its row holds the full body. Replay reads the
store, so a row left stale reloads the spilled bytes next turn: the request grows by
everything the budget removed and its cached prefix diverges at the first spilled row.
The full body survives as the archived twin ``rewrite_pruned_rows`` keeps, beside the
spill file. A store without the in-place writer, or a row another writer moved, keeps
today's row.
"""
session_db, session_id = getattr(agent, "_session_db", None), getattr(agent, "session_id", None)
rewrite = getattr(session_db, "rewrite_pruned_rows", None)
if getattr(agent, "_persist_disabled", False) or not session_id or not callable(rewrite):
return 0
changed = [
(msg, before) for msg, before in zip(batch, contents_before)
if msg.get(_DB_PERSISTED_MARKER) and msg.get("role") == "tool" and msg.get("content") != before]
if not changed:
return 0
rows = [(_db_flush_row(agent, {**msg, "content": before}, False), _db_flush_row(agent, msg, False))
for msg, before in changed]
from hermes_state_errors import PruneRowUnresolvedError, SessionTurnLeaseLostError
try:
with _persist_lock(agent):
written = rewrite(
session_id, rows, turn_lease_holder=getattr(agent, "_active_session_turn_lease_holder", None),
turn_lease_ttl_seconds=getattr(agent, "_active_session_turn_lease_ttl_seconds", 300.0) or 300.0,
)
except (PruneRowUnresolvedError, SessionTurnLeaseLostError) as exc: # another writer owns the row or turn
logger.info("Tool-result rewrite not persisted; the stored row keeps the full body: %s", exc)
return 0
except Exception as exc: # health: allow BLE001 -- a store failure must not fail the turn; the row keeps its body
logger.warning("Tool-result rewrite not persisted: %s", exc)
return 0
for (msg, _before), (_original, replacement) in zip(changed, rows):
if isinstance(replacement.get(DB_ROW_SNAPSHOT), str):
msg[DB_ROW_SNAPSHOT] = replacement[DB_ROW_SNAPSHOT]
return int(written)
def _db_flush_collect(agent, messages: list[dict], conversation_history: Optional[list[dict]],
replay_history: bool = False):
"""Scan for un-flushed messages; returns ``(rows, msgs)`` to write in one transaction. ``replay_history``
+2
View File
@@ -44,6 +44,7 @@ from agent.inline_tool_executors import (
tool_hook_ids,
)
from agent.message_metadata import stamp_data_use
from agent.session_persistence import sync_rewritten_tool_rows
from agent.tool_dispatch_helpers import (
_NEVER_PARALLEL_TOOLS,
_is_destructive_command,
@@ -1225,6 +1226,7 @@ def _finalize_tool_batch(agent, messages: list, effective_task_id: str, num_tool
contents_before = [message.get("content") for message in batch]
enforce_turn_budget(batch, env=get_active_env(effective_task_id), config=budget)
plugin_live_notes.keep_after_turn_budget(agent, batch)
sync_rewritten_tool_rows(agent, batch, contents_before)
record_tool_batch(agent, batch, contents_before)
agent._apply_pending_steer_to_tool_results(messages, num_tools)
+5
View File
@@ -858,6 +858,11 @@ def finalize_turn(
review_skills=_should_review_skills,
)
# Opt-in (compression.prepare_ahead): near the trigger, write the next compaction summary
# between turns. No-op otherwise; never raises.
from agent.prepared_compaction import maybe_prepare
maybe_prepare(agent, messages, origin="turn-end")
# Memory provider on_session_end()/shutdown_all() are NOT called here:
# run_conversation() runs once per message; CLI/gateway own session-end cleanup.
if not getattr(agent, "_persist_disabled", False):
+5
View File
@@ -163,6 +163,11 @@ def run_tool_round(
with suppress(Exception):
agent.stream_delta_callback(None)
# Opt-in (compression.prepare_ahead): when this response put the session near the trigger,
# write the next compaction summary while the tools run. No-op otherwise; never raises.
from agent.prepared_compaction import maybe_prepare
maybe_prepare(agent, messages, origin="tool-batch")
agent._execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count)
from hermes_cli.observability.shared_metrics_harness import finish_tool_round
+5 -1
View File
@@ -498,7 +498,11 @@ class GatewayAuthorizationMixin:
if adapter is None:
return False
extra = _adapter_config_extra(adapter)
adapter_allow = extra.get("group_allow_from" if is_group else "allow_from")
# ``group_allow_from`` is a separate group list (Telegram); an adapter with a single user
# allowlist (Discord) declares ``allow_from_covers_groups`` so its groups read ``allow_from``.
groups_use_allow_from = getattr(adapter, "allow_from_covers_groups", False) is True
group_key = "allow_from" if groups_use_allow_from else "group_allow_from"
adapter_allow = extra.get(group_key if is_group else "allow_from")
if not adapter_allow:
# Plugin platforms (Buzz, DingTalk) spell their env allowlist as ``extra.allowed_users``;
# under multiplex only the default profile's list reaches the env (first-writer-wins
+15 -16
View File
@@ -5183,22 +5183,17 @@ def main():
def _exit_after_graceful_shutdown(exit_code: int) -> None:
"""Flush stdio, release the PID file + runtime lock, then hard-exit.
"""Flush stdio, release the PID file + lock, stamp the clean exit, flush opt-in metrics, hard-exit.
``os._exit`` (not ``sys.exit``): SystemExit runs ``Py_FinalizeEx``, which joins every non-daemon
thread — exactly the hang a wedged worker causes. It bypasses ``atexit``, so PID/lock release and the
bounded log drain (file handlers sit behind a ``QueueListener`` thread) are done here explicitly.
Graceful teardown is already complete by the time this runs, so there is nothing left that needs a clean
interpreter shutdown. See #53107.
``os._exit`` bypasses ``atexit`` handlers, so we cannot rely on the ``atexit``-registered
``remove_pid_file`` / ``release_gateway_runtime_lock`` (registered in ``start_gateway``) to run. The
full-shutdown path releases both explicitly in ``_stop_impl``, but the EARLY exit paths —
clean-fatal-config (#51228) and startup-aborted-before-running — raise ``SystemExit`` right after
``runner.start()`` without going through ``_stop_impl``, so on those paths ``atexit`` was the only thing
releasing them. Now that those paths are routed through this backstop (#53107), release both here
explicitly. Both calls are idempotent — ``remove_pid_file`` only unlinks a PID file that belongs to this
process, and ``release_gateway_runtime_lock`` no-ops when the lock is already released — so this is a
no-op on the normal shutdown path and the actual cleanup on the early-exit paths.
thread — exactly the hang a wedged worker causes. It bypasses ``atexit``, so the metrics flush,
PID/lock release and the bounded log drain (file handlers sit behind a ``QueueListener`` thread)
are done here explicitly; graceful teardown is already complete by the time this runs. See #53107.
The atexit-registered ``remove_pid_file`` / ``release_gateway_runtime_lock`` (``start_gateway``) never
run either. ``_stop_impl`` releases both on the full shutdown, but the EARLY exits — clean-fatal-config
(#51228) and startup-aborted-before-running — raise ``SystemExit`` right after ``runner.start()``
without ``_stop_impl``, so atexit was all that released them there; this backstop does now (#53107).
Both are idempotent (``remove_pid_file`` only unlinks this process's PID file; the lock release no-ops
once released): a no-op on the normal shutdown, the actual cleanup on the early exits.
"""
for stream in (sys.stdout, sys.stderr):
with suppress(Exception):
@@ -5217,13 +5212,17 @@ def _exit_after_graceful_shutdown(exit_code: int) -> None:
from gateway.lifecycle_ledger import mark_exited
mark_exited(exit_code, reason="graceful_shutdown")
def _flush_metrics() -> None: # after the clean stamp: a supervisor kill mid-flush is no unclean death
from hermes_cli.observability.shared_metrics_process import flush_before_hard_exit
flush_before_hard_exit() # the runtimes' atexit flush: open sessions + queued opt-in rows (bounded)
def _drain_logs() -> None:
# os._exit bypasses the listener's atexit drain. Bounded, no restart — NOT flush_log_queue():
# a listener wedged on the rotation lock would re-freeze shutdown in an unbounded stop() join.
from hermes_logging import drain_log_queue
drain_log_queue(timeout=1.0)
for _step in (_release_locks, _mark_exited, _drain_logs):
for _step in (_release_locks, _mark_exited, _flush_metrics, _drain_logs):
_best_effort(_step)
os._exit(exit_code)
+18 -8
View File
@@ -1882,6 +1882,23 @@ class GatewayStartupMixin:
thread_sessions_per_user=extra.get("thread_sessions_per_user", False), profile=handoff_profile,
)
async def _handoff_repoint(self, dest, session_key: str, cli_session_id: str) -> None:
"""Point the destination route at the handed-off conversation; ``switch_session`` ends the
route's prior session and reopens this one. A handoff carries the conversation the user is
looking at: a local chat's latest ``/new`` segment, then that segment's compression tip
(``handoff_head``), so its whole chain moves onto the lane together with the local receipt
that guards it, and a viewer still holding it open (the Desktop that ran /handoff) follows
it (#143286)."""
from gateway.session_route_recovery import conversation_head, follow_hand_off
authority, local_owner, target = await conversation_head(self, cli_session_id)
# Ensure a session_store entry exists for this key; switch_session then re-points it.
await self.async_session_store.get_or_create_session(dest.source)
switched = await self.async_session_store.switch_session(session_key, target, preserve_prompt_pin=False)
if switched is None:
raise RuntimeError(f"could not switch session key {session_key} → {target}")
if authority is not None:
follow_hand_off(authority, dest.source, session_key, authority.logical_owner(target), local_owner)
async def _process_handoff(self, row: dict[str, Any], profile_name: Optional[str] = None) -> None:
"""Execute one handoff row; raises on failure (caller marks failed). ``profile_name`` (None =
root) is the profile whose store queued it — load-bearing under multiplex: secondaries live in
@@ -1889,14 +1906,7 @@ class GatewayStartupMixin:
cli_session_id = row["id"]
dest = await self._handoff_resolve_destination(row, profile_name)
session_key = self._handoff_session_key(dest, profile_name)
# Ensure a session_store entry exists for this key; switch_session then re-points it.
await self.async_session_store.get_or_create_session(dest.source)
# switch_session ends the prior session and reopens the CLI session under the new key.
switched = await self.async_session_store.switch_session(
session_key, cli_session_id, preserve_prompt_pin=False,
)
if switched is None:
raise RuntimeError(f"could not switch session key {session_key} → {cli_session_id}")
await self._handoff_repoint(dest, session_key, cli_session_id)
# Evict the cached AIAgent (rebuild against the CLI session_id, like /resume) and clear stale
# running-agent state so the synthetic turn isn't queued behind it.
self._evict_cached_agent(session_key)
+60 -46
View File
@@ -805,6 +805,9 @@ class SessionStore(
self._fast_persisted_entries: dict[str, tuple[int, str]] = {}
self._inflight_lock = threading.Lock()
self._inflight_sessions: dict[str, _SessionFlight] = {}
# Ids a route names before their state.db row is live: a route transition publishes under
# _lock and inserts/reopens the row after releasing it. Guarded by _lock.
self._rows_pending: set[str] = set()
# An unscoped legacy Slack key is claimed once per process (two workspaces must not both
# revive one session).
self._legacy_slack_claim_lock = threading.Lock()
@@ -968,17 +971,21 @@ class SessionStore(
create_kwargs = self._route_create(
decision, session_key, source, now, force_new, observed
)
if decision.needs_save:
if decision.metadata_only_save:
self._save_entry(session_key)
else:
self._save_entries()
try:
if decision.needs_save:
if decision.metadata_only_save:
self._save_entry(session_key)
else:
self._save_entries()
self._finish_route_transition(
session_key, end_session_id=decision.prev_session_id,
end_reason=decision.reset_reason or "session_reset", create_kwargs=create_kwargs,
origin=source, display_name=decision.entry.display_name,
)
self._finish_route_transition(
session_key, end_session_id=decision.prev_session_id,
end_reason=decision.reset_reason or "session_reset", create_kwargs=create_kwargs,
origin=source, display_name=decision.entry.display_name,
)
finally:
if create_kwargs:
self._row_settled(create_kwargs["session_id"])
return decision.entry
def _apply_route_checks(
@@ -1054,19 +1061,19 @@ class SessionStore(
auto_reset_reason=decision.reset_reason, reset_had_activity=decision.reset_had_activity,
prev_session_id=decision.prev_session_id, transport_profile=transport_profile_of(source),
)
with self._lock:
current = self._entries.get(session_key)
if current is None or (force_new and current is observed):
self._entries[session_key] = current = candidate
decision.entry = current
decision.needs_save = True
if current is not candidate:
return None
return self._session_create_kwargs(
create_kwargs = self._session_create_kwargs(
session_id=session_id, session_key=session_key, origin=source,
source_value=source.platform.value, display_name=source.chat_name,
parent_session_id=decision.prev_session_id,
)
with self._lock:
current = self._entries.get(session_key)
if current is None or (force_new and current is observed):
self._entries[session_key] = current = candidate
self._rows_pending.add(session_id) # the caller settles it once create_kwargs land
decision.entry = current
decision.needs_save = True
return create_kwargs if current is candidate else None
def update_session(
self, session_key: str, last_prompt_tokens: int | None = None, touch_activity: bool = True,
@@ -1148,21 +1155,24 @@ class SessionStore(
if old_entry.origin and old_entry.origin.platform == Platform.LOCAL:
from gateway.session_local_recovery import reset_local_session
return reset_local_session(self, old_entry, session_id, now, display_name)
new_entry = self._replace_route_locked(
session_key, old_entry, session_id, now,
display_name=display_name if display_name is not None else old_entry.display_name,
is_fresh_reset=True,
)
db_create_kwargs = self._session_create_kwargs(
session_id=session_id, session_key=session_key, origin=old_entry.origin,
source_value=old_entry.platform.value if old_entry.platform else "unknown",
display_name=old_entry.display_name, parent_session_id=old_entry.session_id,
)
self._finish_route_transition(
session_key, end_session_id=old_entry.session_id, end_reason="session_reset",
create_kwargs=db_create_kwargs, origin=old_entry.origin,
display_name=new_entry.display_name, during=" during reset",
)
new_entry = self._replace_route_locked(
session_key, old_entry, session_id, now,
display_name=display_name if display_name is not None else old_entry.display_name,
is_fresh_reset=True,
)
try:
self._finish_route_transition(
session_key, end_session_id=old_entry.session_id, end_reason="session_reset",
create_kwargs=db_create_kwargs, origin=old_entry.origin,
display_name=new_entry.display_name, during=" during reset",
)
finally:
self._row_settled(session_id)
return new_entry
def _replace_route_locked(self, session_key, old_entry, session_id, now, **fields) -> SessionEntry:
@@ -1174,6 +1184,7 @@ class SessionStore(
)
self._entries[session_key] = new_entry
self._save()
self._rows_pending.add(session_id) # after the save: a raising save leaves nothing pending
return new_entry
def rekey_profile_routing(self, old_name: str, new_name: str) -> int:
@@ -1287,23 +1298,26 @@ class SessionStore(
),
)
if self._db_for_key(session_key) and old_entry.session_id:
self._promote_session_reset(
session_key, old_entry.session_id, "session_switch",
log=lambda e: logger.debug("Session DB end_session failed: %s", e),
)
if self._db_for_key(session_key):
# An explicit /resume/handoff/branch repoint is real user activity: the row is
# reopened (a repoint onto a finalized row must resume it, not write into a dead row).
# Mount-time reads (TUI session.resume) no longer reopen (#85303) — only this path does.
self._reopen_session_row(
session_key, target_session_id, log_prefix="Session DB reopen_session failed"
)
self._record_gateway_session_peer(
target_session_id, session_key, new_entry.origin,
display_name=new_entry.display_name, include_compression_ancestors=True,
transport_profile=new_entry.transport_profile,
)
try:
if self._db_for_key(session_key) and old_entry.session_id:
self._promote_session_reset(
session_key, old_entry.session_id, "session_switch",
log=lambda e: logger.debug("Session DB end_session failed: %s", e),
)
if self._db_for_key(session_key):
# An explicit /resume/handoff/branch repoint is real user activity: the row is
# reopened (a repoint onto a finalized row must resume it, not write into a dead row).
# Mount-time reads (TUI session.resume) no longer reopen (#85303) — only this path does.
self._reopen_session_row(
session_key, target_session_id, log_prefix="Session DB reopen_session failed"
)
self._record_gateway_session_peer(
target_session_id, session_key, new_entry.origin,
display_name=new_entry.display_name, include_compression_ancestors=True,
transport_profile=new_entry.transport_profile,
)
finally:
self._row_settled(target_session_id)
return new_entry
def list_sessions(self, active_minutes: Optional[int] = None) -> list[SessionEntry]:
+32 -5
View File
@@ -80,6 +80,11 @@ class LiveSession:
# Mounted by that cold path (not published by its lane): the lane publishing ANOTHER conversation
# on the same route later supersedes it too.
history_mount: bool = False
# A local conversation a messaging lane took over (``/handoff``, a platform ``/resume``): its
# viewers follow it onto the lane, and once that lane publishes another conversation (``/new``,
# another handoff) it is superseded like a history mount, whatever the lane published since.
# Set from the row (``moved_local``) on every mount of it, however constructed (#143286).
handed_off: bool = False
# The background-review guard of a cold ``/refine`` (gateway/session_refine.py): its side agent is
# per call, so the run token lives here; a second /refine joins it and the next turn cancels it.
review: SimpleNamespace = field(default_factory=SimpleNamespace)
@@ -268,14 +273,30 @@ class SessionAuthority:
return sid, entry.session_key
def _publish_route(self, source, sid, route):
from gateway.config import Platform
self._require_admission_open()
live = self.sessions.get(sid)
# *sid* takes the route over: new here, moved onto it, or back after another conversation had it.
took_over = live is None or live.route != route or live.superseded
if live is None:
live = self.sessions[sid] = LiveSession(source, route)
# The lane moved on to *sid*: a cold history mount still on its route is now another
# conversation's, so it goes read-only (gateway/session_route_recovery.py).
from gateway.session_route_recovery import mount
live = self.sessions[sid] = mount(self, sid, source, route)
elif live.route != route:
# *sid* moved lanes: ``/handoff`` or a platform ``/resume`` took a local conversation over
# (its repoint retired the local receipt), or a ``/resume`` moved it on to another chat.
# Its open viewers follow it, so a turn or a Stop through this mount reaches the lane that
# holds it now, never the conversation that replaced it on the old one (#143286).
from gateway.session_route_recovery import moved_local
left_local = live.source is not None and live.source.platform == Platform.LOCAL
live.handed_off = (live.handed_off or (left_local and source.platform != Platform.LOCAL)
or moved_local(self, sid, source))
live.source, live.route = source, route
if took_over:
# The lane moved on to *sid*: a cold history mount or a chat handed off earlier that is
# still on its route is now another conversation's, so it goes read-only
# (gateway/session_route_recovery.py): a Stop or turn through it would act on *sid*.
for other in self.sessions.values():
if other.history_mount and other.route == route and other is not live:
if (other.history_mount or other.handed_off) and other.route == route and other is not live:
other.superseded = True
# The lane itself names *sid* (again): a historic mount becomes its live session.
live.superseded = live.history_mount = False
@@ -635,7 +656,9 @@ class SessionAuthority:
check_local_input(
self, SessionRef(self.profile_id, sid), row, source=source)
self._require_admission_open()
self.sessions.setdefault(sid, LiveSession(source, route))
if sid not in self.sessions: # ``mount``: a moved local chat keeps ``handed_off`` here too
from gateway.session_route_recovery import mount
self.sessions[sid] = mount(self, sid, source, route)
if any(row['status'] == 'unknown' for row in rows):
raise RuntimeStoreError('unknown_execution')
self._schedule(SessionRef(self.profile_id, sid))
@@ -1454,6 +1477,10 @@ async def initialize_session_authority(runner, *, profile_id, instance_id, db=No
await asyncio.to_thread(settle_recovered_unattended, db, epoch)
await asyncio.to_thread(backfill_legacy_session_owners, db)
await asyncio.to_thread(backfill_compression_created_sources, db)
from gateway.session_local_recovery import release_handed_off_receipts
await asyncio.to_thread(release_handed_off_receipts, db, epoch=epoch)
from gateway.session_bot import release_superseded_bot_chat_at_boot
await asyncio.to_thread(release_superseded_bot_chat_at_boot, db, runner.session_store)
authority = SessionAuthority(runner, profile_id=profile_id, instance_id=instance_id, db=db, epoch=epoch)
# A receipt's compression catch-up (#140060) writes: it commits off the loop too, before the
# authority is published, so the recovery below only reads.
+3 -2
View File
@@ -485,7 +485,6 @@ async def admit_automation(authority, adapter, event, identity, replaces=None):
own transaction, so neither lands without the other."""
authority._require_admission_open()
payload, entry = snapshot_automation(authority, adapter, event, identity)
from gateway.session_authority import LiveSession
sid = (payload.get('local_automation_v1') or payload['native_text_v1']['automation'])['owner']
ref = SessionRef(authority.profile_id, sid)
source, envelope = event.source, payload.get('native_text_v1')
@@ -494,7 +493,9 @@ async def admit_automation(authority, adapter, event, identity, replaces=None):
# drain resolves no adapter for this session after a restart.
from gateway.session_envelope import restore_native_source
source = restore_native_source(payload, authority.runner)
authority.sessions.setdefault(ref.session_id, LiveSession(source, entry.session_key))
if ref.session_id not in authority.sessions: # the route's conversation may be a moved local chat
from gateway.session_route_recovery import mount
authority.sessions[ref.session_id] = mount(authority, ref.session_id, source, entry.session_key)
from functools import partial
from gateway.session_runtime_workers import tracked_write
admitted, replaced = partial(authority._admitted, ref, event), []
+69 -15
View File
@@ -180,24 +180,32 @@ def _home(authority, actor, profile):
async def _target(authority, actor):
ref = None
ref = lane = None
row = authority.db.get_session_by_title('Bot Chat')
if row is None or (displaced := displaceable_bot_chat(authority, row)) is not None:
# None: a concurrent delivery created it first. A title held by an API conversation no FIFO
# can own is moved aside first, under the same lock (MSG-21, #140769).
ref = await _create_bot_chat(authority, actor, displace=None if row is None else displaced)
if ref is None and (row := authority.db.get_session_by_title('Bot Chat')) is None:
# Moved aside since it was read: a lane's /new frees a handed-off Bot Chat's title off the
# titled-create lock (``release_superseded_bot_chat``). None again: a concurrent delivery minted.
if (ref := await _create_bot_chat(authority, actor)) is None:
row = authority.db.get_session_by_title('Bot Chat')
if ref is None:
row = authority.db.get_session_by_title('Bot Chat')
tip = authority.db.get_compression_tip(row['id'])
target = authority.db.get_session(tip)
if target is None:
raise RuntimeStoreError('not_found')
from gateway.session_local_migration import resolve_local_target
ref = resolve_local_target(authority, actor, target['id'])
from gateway.session_route_recovery import handed_off_ref
# A Bot Chat handed off to a messaging lane stays the bot's chat there: delivery follows it.
ref = lane = handed_off_ref(authority, target['id'])
if ref is None:
ref = resolve_local_target(authority, actor, target['id'])
authority.authorize(actor, ref, 'session:submit')
live = authority.sessions[ref.session_id]
entry = authority.runner.session_store.lookup_by_session_key(live.route)
if live.source.platform != Platform.LOCAL or entry is None:
if (live.source.platform != Platform.LOCAL and lane is None) or entry is None:
raise RuntimeStoreError('admission_conflict')
return ref, live, entry
@@ -216,6 +224,11 @@ def displaceable_bot_chat(authority, row):
return None # every delivery asks: a bound local chat answers from its own row, with no read
db = authority.db
tip = db.get_session(db.get_compression_tip(row['id']) or row['id'])
from gateway.session_route_recovery import handed_off_ref, superseded_hand_off
if tip is not None and (lane := handed_off_ref(authority, tip['id'])) is not None:
# Handed off (``/handoff``): still the bot's chat while its lane holds it; history once that
# lane moved on (``/new``), so a fresh Bot Chat takes the title (#143286).
return row['id'] if superseded_hand_off(authority, lane) else None
for candidate in (row, tip):
if candidate is None or str(candidate.get('chat_id') or '').startswith('local-'):
return None
@@ -230,6 +243,34 @@ def displaceable_bot_chat(authority, row):
return row['id']
def release_superseded_bot_chat(db, store, route, session_id):
"""The lane on *route* moved off *session_id* (its ``/new``, a reset, another ``/resume`` or
``/handoff``). A Bot Chat handed off there (``/handoff``) is that lane's history now: free the
canonical title at once (``_displace_bot_chat``), so the Bots roster and its registry lookup both
stop naming it and the next click or delivery mints a fresh Bot Chat (#143286). Every route
transition calls this, and so does boot (an older build's transition)."""
from gateway.session_route_recovery import _messaging_platform
from hermes_state_local_migration import LEGACY_LOCAL_SOURCES
root = (db.get_compression_lineage(session_id) or [session_id])[0]
row = db.get_session(root)
if (row is None or row.get('title') != 'Bot Chat' or row.get('created_source') not in LEGACY_LOCAL_SOURCES
or _messaging_platform(row.get('source')) is None):
return
with store._lock:
entry = store._entry_locked(route)
if entry is None or (db.get_compression_lineage(entry.session_id) or [entry.session_id])[0] == root:
return # the lane still holds it
if _displace_bot_chat(db, root):
logger.info("Bot Chat %s was handed off to %s and that lane moved on: moved it aside", root, route)
def release_superseded_bot_chat_at_boot(db, store):
row = db.get_session_by_title('Bot Chat')
if row is not None:
tip = db.get_session(db.get_compression_tip(row['id']) or row['id']) or row
release_superseded_bot_chat(db, store, tip.get('session_key'), row['id'])
def _kept_local_history(db, row):
"""Whether an unbound row is local history the Bot Chat door must leave in place: a CLI, TUI
or Desktop row a native open adopts, or one a login stamped as its owner. A local-source row
@@ -299,7 +340,11 @@ async def _create_bot_chat(authority, actor, *, displace=None):
if displace is None or held['id'] != displace or displaceable_bot_chat(authority, held) != displace:
return None
logger.warning("Bot Chat title held by %s, a conversation no FIFO can own: moving it aside", displace)
if not await tracked_write(authority, partial(_displace_bot_chat, authority.db, displace)):
# False: the title moved since the check. A lane's ``/new`` frees a handed-off Bot Chat's
# title from the store's worker thread, outside this lock (``release_superseded_bot_chat``):
# nobody holds it then, so this delivery mints the replacement.
if (not await tracked_write(authority, partial(_displace_bot_chat, authority.db, displace))
and authority.db.get_session_by_title('Bot Chat') is not None):
return None
# The Bot Chat follows its profile whoever mints it (a Bot or cron delivery, a peer DM),
# the same contract Desktop's create asks for. A later profile change reaches it.
@@ -320,20 +365,24 @@ async def _create_bot_chat(authority, actor, *, displace=None):
def _bot_chat_creation_id(authority, actor):
"""The first creation identity whose session is not spent. Generation 0 keeps the historical
``bot-chat:<profile>`` id; a generation is spent once its session was deleted (retired: a retry
must never resurrect it) or renamed (titled, but not "Bot Chat" — or the lookup had found it),
and a replacement takes the next one. Derived from durable rows only, so a restart and two
concurrent deliveries (serialized by the titled-create lock) pick the same identity. An
existing UNtitled session is the same creation interrupted before its title: it is reused."""
must never resurrect it), renamed (titled, but not "Bot Chat" — or the lookup had found it) or
handed off to a messaging lane, and a replacement takes the next one. Derived from durable rows
only, so a restart and two concurrent deliveries (serialized by the titled-create lock) pick the
same identity. An existing UNtitled local session is the same creation interrupted before its
title: it is reused."""
from gateway.session_local_recovery import local_identity
from gateway.session_route_recovery import _messaging_platform
from hermes_state_mutation_retirement import first_generation
base = 'bot-chat:' + authority.profile_id
request_id = lambda generation: base if generation == 0 else f'{base}:{generation}'
def titled(conn, sid):
row = conn.execute('SELECT title FROM sessions WHERE id=?', (sid,)).fetchone()
return row is not None and bool(row[0])
def spent(conn, sid):
row = conn.execute('SELECT title, source FROM sessions WHERE id=?', (sid,)).fetchone()
# Handed off (``/handoff``), it is a messaging lane's conversation: its receipt went with it,
# and a reset's head carried its name there (``hermes_state_local.hand_off_conversations``).
return row is not None and (bool(row[0]) or _messaging_platform(row[1]) is not None)
generation, _sid = first_generation(
authority.db, lambda n: local_identity(authority.profile_id, actor.subject, request_id(n)), spent=titled)
authority.db, lambda n: local_identity(authority.profile_id, actor.subject, request_id(n)), spent=spent)
return request_id(generation)
@@ -342,13 +391,18 @@ def _admission_outcome(authority, admission_id, fallback=None):
row = get_session_admission(authority.db, admission_id=admission_id)
if row is None:
return _expired_outcome(authority, fallback)
from gateway.session_results import admission_result
saved = admission_result(authority.db, admission_id)
if saved is not None and row['status'] != 'terminal':
# The settlement committed (from its worker thread) between these two reads. Its result and
# terminal outcome commit together, so re-read the row: the stale ``started`` row has no
# outcome, and a completed turn would project as a terminal ``failed`` receipt.
row = get_session_admission(authority.db, admission_id=admission_id)
# A terminal row without a result blob is still definitive when the outcome says the
# input never ran (cancelled) or was refused/failed; ambiguous is reserved for the
# durable unknown state and for missing evidence (completed/interrupted without result).
status = {'queued': 'queued', 'started': 'claimed', 'unknown': 'ambiguous',
'terminal': {'cancelled': 'cancelled', 'rejected': 'failed', 'failed': 'failed'}.get(row['outcome'], 'ambiguous')}[row['status']]
from gateway.session_results import admission_result
saved = admission_result(authority.db, admission_id)
reply = (fallback or {}).get('reply', '')
if saved is not None:
reply = saved['result'].get('final_response', '')
+3 -1
View File
@@ -610,7 +610,9 @@ class AuthorityConnection:
# reopens it (``reopen_local_session`` in the drain), so opening a finished chat
# never re-lights DB-derived liveness with no new activity.
self.authority.authorize(self.actor, ref, 'session:read')
if 'session:control' in self.actor.capabilities:
mounted = self.authority.sessions[ref.session_id].source
# Only a local route has a stored creation policy to refresh; a platform lane's turns read the profile.
if 'session:control' in self.actor.capabilities and getattr(mounted, 'platform', None) == Platform.LOCAL:
# A client attaching (main's `hermes -c` was a new process) re-reads the config-derived settings
# (CFG-2); a snapshot poll on this attachment does not. Any open still adopts a frozen Bot Chat
# whose earlier open found it busy (Desktop's warm `session.activate` resumes on one connection).
+11 -3
View File
@@ -348,19 +348,26 @@ def worker_exit_code(path, params):
return worker_result(path, params).get('exit_code', 1)
# Who wrote a card attempt's prompts: the dispatcher, never the operator, so memory providers do not
# file "work kanban task t_..." and the worker's progress notes as the human's own turns (#137406).
# Not a ``bot:<profile>`` id: a Bot Mode profile named ``kanban`` would share its memory key.
KANBAN_TURN_AUTHOR = {'id': 'kanban:dispatcher', 'name': 'Kanban dispatcher', 'is_bot': True}
def _run_task_turns(agent, frame, history, context):
from agent.skill_commands import build_preloaded_skills_prompt
skills, loaded, missing = build_preloaded_skills_prompt(context['skills'], task_id=agent.session_id)
if missing and not loaded:
raise ValueError('kanban_skills_unavailable')
prompt = '\n\n'.join(p for p in (skills, context['context'], frame['text']) if p)
result = agent.run_conversation(prompt, conversation_history=history)
result = agent.run_conversation(prompt, conversation_history=history, turn_author=KANBAN_TURN_AUTHOR)
def recover(nudge):
nonlocal result
prior = result.get('messages') if isinstance(result, dict) else None
result = agent.run_conversation(nudge, conversation_history=prior or
agent._session_db.get_messages_as_conversation(agent.session_id))
agent._session_db.get_messages_as_conversation(agent.session_id),
turn_author=KANBAN_TURN_AUTHOR)
# Same in-place retry as the one-shot CLI worker (agent/kanban_turn_recovery.py), but the
# carrier is this frame's bound claim, never the owner process's environment.
from agent.kanban_turn_recovery import recover_failed_kanban_turns
@@ -382,7 +389,8 @@ def _run_task_turns(agent, frame, history, context):
kb.block_task(conn, context['task_id'], reason=reason, expected_run_id=context['run_id'])
def turn(prompt):
nonlocal result
result = agent.run_conversation(prompt, conversation_history=agent._session_db.get_messages_as_conversation(agent.session_id))
result = agent.run_conversation(prompt, conversation_history=agent._session_db.get_messages_as_conversation(agent.session_id),
turn_author=KANBAN_TURN_AUTHOR)
return result.get('final_response', '')
run_kanban_goal_loop(task_id=context['task_id'], goal_text=context['goal_text'], run_turn=turn,
task_status_fn=status, block_fn=block, max_turns=context['goal_max_turns'] or DEFAULT_MAX_TURNS,
+6
View File
@@ -80,6 +80,12 @@ class SessionLifecycleMixin:
after the self-heal re-homes the key, the id has no owner and would resolve to the launch
store, which never holds a routed profile's rows (#118862).
"""
with self._lock:
if session_id in self._rows_pending:
# A route transition published this id and has not inserted/reopened its row yet
# (a /new racing a turn that resolves the route as it finishes): healing it here
# would hand the route back to the conversation the user just left.
return False
db = self._db_for_key(session_key) if session_key else self._db_for_session_id(session_id)
if not db or not session_id:
return False
+21
View File
@@ -388,6 +388,27 @@ def restore_completion_route(runner, session_key):
restore_local_session(authority, parsed['chat_id'])
def release_handed_off_receipts(db, *, epoch):
"""Boot heal for #143286: an older build's ``/handoff`` (or a platform ``/resume``) moved a local
session onto a messaging lane but kept its local receipt, so every open, pin or rename refused it
(``stored identity mismatch``) and its title never resolved. Retire each such receipt, so the row
restores from its route like any other platform session, and move the rest of its conversation
there with it (``hand_off_conversations``). Only receipts whose CURRENT target a messaging lane
now holds: any other refused receipt stays as it is, logged when it is touched."""
from gateway.session_route_recovery import _messaging_platform
from hermes_state_local import hand_off_conversations, moved_receipts
from hermes_state_runtime import _epoch
def write(conn):
_epoch(conn, epoch)
moved = {owner: (target, source) for owner, (target, source) in moved_receipts(conn).items()
if _messaging_platform(source) is not None}
return hand_off_conversations(conn, moved)
for owner, (target, source) in db._execute_write(write).items():
logging.getLogger(__name__).info(
'Local session %s was handed off to %s (row %s); released its local receipt', owner, source, target)
def local_receipt_ids(authority):
from gateway.session_viewer_close import CLOSE_PREFIX
start = len(POLICY_PREFIX) + 1
+7 -1
View File
@@ -12,7 +12,8 @@ from hermes_state_runtime import RuntimeStoreError
def resolve_titled_session(authority, actor, name, *, missing_ok=False):
"""Exact session id first, then title (latest ``"<title> #N"`` continuation wins over
the bare title), projected forward to the compression tip. Only a local session the
actor may read is returned; a foreign or non-local match is ``not_found`` so another
actor may read is returned, or one a messaging lane took over from a local surface
(``handed_off_ref``); a foreign or other non-local match is ``not_found`` so another
principal's titles are never confirmed."""
if not isinstance(name, str) or not name.strip():
raise RuntimeStoreError('invalid_params')
@@ -24,7 +25,12 @@ def resolve_titled_session(authority, actor, name, *, missing_ok=False):
if missing_ok:
return None
raise RuntimeStoreError('not_found')
from gateway.session_route_recovery import handed_off_ref
moved = handed_off_ref(authority, tip['id'])
try:
if moved is not None:
authority.authorize(actor, moved, 'session:read')
return moved
from gateway.session_local_migration import resolve_local_target
ref = resolve_local_target(authority, actor, tip['id'])
except RuntimeStoreError as exc:
+8
View File
@@ -300,6 +300,8 @@ class SessionRecoveryMixin:
promote(session_id, reason)
else:
db.end_session(session_id, reason)
from gateway.session_bot import release_superseded_bot_chat
release_superseded_bot_chat(db, self, session_key, session_id)
# Stop the departed conversation's schedule in its owning profile, even when
# the in-memory watch still holds a pre-reset session id.
heartbeat_key = f"heartbeat:{session_id}"
@@ -410,6 +412,12 @@ class SessionRecoveryMixin:
create_kwargs.get("session_id"), session_key, during, e),
)
def _row_settled(self, session_id: str) -> None:
"""The route transition that published *session_id* returned (its state.db half done or
failed): from here a missing or ended row is real again (see ``_is_session_ended_in_db``)."""
with self._lock:
self._rows_pending.discard(session_id)
@staticmethod
def _session_create_kwargs(
*, session_id, session_key, origin, source_value, display_name, parent_session_id,
+75 -2
View File
@@ -110,7 +110,6 @@ def _mount_plan(authority, sid):
def restore_routed_session(authority, sid):
"""Publish *sid* as its lane would after a restart; None when this row is not one a route or a
messaging-platform row can stand for."""
from gateway.session_authority import LiveSession
from gateway.session_contract import SessionRef
plan = _mount_plan(authority, sid)
if plan is None:
@@ -121,10 +120,31 @@ def restore_routed_session(authority, sid):
# Re-pin the persisted receiving bot so delivery after a restart goes through it or nowhere,
# never the default bot by heuristic (gateway/AGENTS.md § Identity survives the process).
source = authority.runner._restored_source(entry)
authority.sessions.setdefault(sid, LiveSession(source, route, superseded=superseded, history_mount=True))
authority.sessions.setdefault(sid, mount(authority, sid, source, route, superseded=superseded,
history_mount=True))
return SessionRef(authority.profile_id, sid)
def mount(authority, sid, source, route, **flags):
"""The live entry of *sid* on *route*, however it is mounted (the lane's inbound, startup recovery
of its queued input, a cold open, an automation wake): ``handed_off`` comes from the row
(``moved_local``), so a moved local conversation goes read-only once its lane takes up another
one, whichever path mounted it first (#143286)."""
from gateway.session_authority import LiveSession
return LiveSession(source, route, handed_off=moved_local(authority, sid, source), **flags)
def moved_local(authority, sid, source):
"""*sid* is a local conversation (created by a local surface) published on a messaging lane:
``/handoff`` or a platform ``/resume`` moved it there. That provenance is the row's, not the
path that mounts it (the Desktop that followed it, the lane's inbound, a cold open), so every
mount of it goes read-only once the lane takes up another conversation (#143286)."""
from hermes_state_local_migration import LEGACY_LOCAL_SOURCES
if source is None or source.platform == Platform.LOCAL:
return False
return (authority.db.get_session(sid) or {}).get('created_source') in LEGACY_LOCAL_SOURCES
def mountable(authority, sid):
"""``restore_routed_session`` would mount *sid* (this profile's messaging lineage root)."""
return _mount_plan(authority, sid) is not None
@@ -139,3 +159,56 @@ def messaging_owner_ref(authority, ref):
from gateway.session_contract import SessionRef
owner = authority.logical_owner(ref.session_id)
return ref if owner == ref.session_id else SessionRef(ref.profile_id, owner)
def handed_off_ref(authority, session_id):
"""The mount of *session_id* when it is a local conversation a messaging lane took over
(``/handoff``, a platform ``/resume``): created by a local surface, now routed on a lane this
authority mounts. Its title stays the conversation's, so ``/resume <title>`` and the Bots roster's
registry lookup open it on that lane (#143286). None for any other row."""
from gateway.session_contract import SessionRef
from hermes_state_local_migration import LEGACY_LOCAL_SOURCES
ref = messaging_owner_ref(authority, SessionRef(authority.profile_id, session_id))
row = authority.db.get_session(ref.session_id) or {}
if row.get('created_source') not in LEGACY_LOCAL_SOURCES or not mountable(authority, ref.session_id):
return None
return ref
def superseded_hand_off(authority, ref):
"""Whether the lane that took over *ref* (a ``handed_off_ref``) has since moved on to another
conversation (``/new``, a reset): the chat is history now, read-only on that lane."""
live = authority.sessions.get(ref.session_id)
route = (authority.db.get_session(ref.session_id) or {}).get('session_key')
# The routing entry moves at the platform's /new; the lane publishes its new conversation (and so
# supersedes a mounted one) only at its next inbound.
return bool(live is not None and live.superseded) or _names_another_conversation(authority, ref.session_id, route)
async def conversation_head(runner, session_id):
"""``(authority, local owner or None, row id)`` a repoint of *session_id* onto a messaging lane
carries (``/handoff``, a platform ``/resume``): a local conversation's current head
(``hermes_state_local.handoff_head``), never an earlier ``/new`` segment, which would move that
history onto the lane while the live conversation stays local, its receipt released (#143286)."""
import asyncio
from gateway.session_authorities import active_authority
from hermes_state_local import handoff_head
authority = active_authority(runner)
db = authority.db if authority is not None else getattr(runner._session_db, '_db', runner._session_db)
if db is None:
return authority, None, session_id
return (authority, *await asyncio.to_thread(handoff_head, db, session_id))
def follow_hand_off(authority, source, route, owner, local_owner):
"""After ``/handoff`` repointed *route* at *owner* (the lane's logical id of the conversation's
head): the local mount a viewer still holds (*local_owner*, the Desktop that ran it) moves onto
the lane, as its receipt did. When the head is a later ``/new`` segment, that mount is the
conversation's earlier history there, superseded once the head publishes (#143286)."""
held = authority.sessions.get(local_owner) if local_owner else None
if held is None or held.source is None or held.source.platform != Platform.LOCAL:
held = None
elif local_owner != owner:
held.source, held.route, held.handed_off = source, route, True
if held is not None or owner in authority.sessions:
authority._publish_route(source, owner, route)
+8
View File
@@ -1076,6 +1076,12 @@ class GatewaySessionCommandsMixin:
if isinstance(resolved, str):
return resolved
target_id, name = resolved
# A local conversation moves onto this lane from its current head, as /handoff carries it: a
# title naming its first segment (a local /new moved on) would strand the live one (#143286).
from gateway.session_route_recovery import conversation_head, follow_hand_off
authority, local_owner, head = await conversation_head(self, target_id)
if local_owner is not None:
target_id = head
denied = await self._resume_access_denied_reply(source, target_id, name, allow_all, allow_cross_room)
if denied is not None:
return denied
@@ -1088,6 +1094,8 @@ class GatewaySessionCommandsMixin:
)
if not new_entry:
return t("gateway.resume.switch_failed")
if local_owner is not None and authority is not None: # a Desktop still viewing it follows it
follow_hand_off(authority, source, session_key, authority.logical_owner(target_id), local_owner)
# Conversation boundary: all conversation-scoped state + security state in one funnel call.
# Conversation boundary: clear ALL conversation-scoped per-session state (model/reasoning overrides
# #10702, one-turn restores, model notes, last-resolved cache #58403, /queue overflow) + security
+17 -1
View File
@@ -47,7 +47,8 @@ PERMANENT_REFUSAL_REASONS = frozenset({'not_found', 'invalid_params'})
class WakeNotAccepted(RuntimeError):
"""No adapter admission: retry without treating a healthy chat as dead. ``reason`` is the
store's refusal code when the session authority refused it (None: the adapter did not accept);
store's refusal code when the session authority refused it, ``runtime_draining`` when the
adapter path refused it during a drain (None: the adapter did not accept);
``permanent`` is set once the refusal is proven final (``refusal_proven_permanent``)."""
def __init__(self, message: str = "internal wake not accepted by adapter", *, reason: Optional[str] = None):
@@ -100,9 +101,24 @@ async def admit_internal_event(adapter: Any, event: Any) -> None:
not model execution, authorization of a later turn, or successful outbound delivery.
"""
event._gateway_accepted = False
# A draining gateway answers new work with a drain notice and discards queued follow-ups,
# so a wake admitted now would never run. Refuse it so the caller keeps it for a retry.
if _runner_draining(adapter):
raise WakeNotAccepted("gateway is draining; retry after restart", reason="runtime_draining")
await adapter.handle_message(event)
if event._gateway_accepted is not True:
raise WakeNotAccepted()
# handle_message can await before it claims or queues the event (Telegram topic recovery, the
# busy handler's profile scope). A drain seen here dooms the stored event: the drain gate refuses
# a claimed turn and the pending discard drops a queued follow-up. Refuse it so the producer
# retries it after the restart.
if _runner_draining(adapter):
raise WakeNotAccepted("gateway began draining during admission; retry after restart",
reason="runtime_draining")
def _runner_draining(adapter: Any) -> bool:
return getattr(getattr(adapter, "gateway_runner", None), "_draining", False) is True
async def deliver_wake(adapter: Any, *, text: str, session_id: str = "", source: Any = None,
+6
View File
@@ -334,6 +334,11 @@ class CLIChatTurnMixin:
_persist_clean_user_message = message if (turn.voice_prefix or agent_message != message) else None
_one_turn_model_restore = getattr(self, "_pending_one_turn_model_restore", None)
self._pending_one_turn_model_restore = None
# Set only by a dispatcher-spawned `-q` run; a human turn keeps today's call shape.
from agent.interrupt_compat import _accepts_keyword
_author = getattr(self, "_single_query_turn_author", None)
_author_kwargs = ({"turn_author": _author}
if _author is not None and _accepts_keyword(self.agent.run_conversation, "turn_author") else {})
try:
from agent.notification_presentation import notification_turn
muted = getattr(turn, "mute_notification_reply", False)
@@ -343,6 +348,7 @@ class CLIChatTurnMixin:
conversation_history=self.conversation_history[:-1],
stream_callback=None if muted else turn.stream_callback, task_id=self.session_id,
persist_user_message=_persist_clean_user_message, moa_config=_moa_cfg,
**_author_kwargs,
)
if getattr(self, "_pending_moa_disable_after_turn", False):
_restore = getattr(self, "_pending_moa_restore_model", None) or {}
+8
View File
@@ -443,6 +443,9 @@ def _install_single_query_signal_handlers(cli):
# store here or the worker's turn (and its usage deltas) never become durable (#88583 /
# #50881 class). Best-effort under the SIGALRM deadman above.
_flush_one_shot_session_store(cli)
# The one-shot exit's shared_metrics shutdown_runtimes is skipped too: bounded inside the deadman.
from hermes_cli.observability.shared_metrics_process import flush_before_hard_exit
flush_before_hard_exit()
_flush_logging_and_stdio()
_kill_foreground_and_exit()
raise KeyboardInterrupt()
@@ -543,6 +546,11 @@ def _run_single_query_mode(cli, query, image, quiet, oneshot, stream_json: bool
if _query_label:
cli.console.print(f"[bold blue]{t('cli.single_query.query_label')}[/] {_query_label}")
cli._show_security_advisories()
# Same author contract as `-Q`: a spawner that sets HERMES_TURN_AUTHOR names who wrote this run's
# prompts. Consumed before the first turn so tool subprocesses never inherit it; every turn of
# the run (recovery and goal follow-ups included) carries it to memory providers.
from agent.turn_author import take_turn_author_from_env
cli._single_query_turn_author = take_turn_author_from_env()
response = cli.chat(query, images=single_query_images or None)
# Kanban worker: a failed-silently turn used to end the run as rc=0 with no
# terminal kanban call, so the dispatcher booked a protocol violation and
+2 -1
View File
@@ -407,7 +407,8 @@ def _build_hermes_tools_mcp_entry() -> dict:
env["HERMES_REDACT_SECRETS"] = env.get("HERMES_REDACT_SECRETS", "true")
return {
"command": sys.executable,
"args": ["-m", "agent.transports.hermes_tools_mcp_server"],
# -P: codex starts this in the user's workspace, which must not shadow Hermes' own packages.
"args": ["-P", "-m", "agent.transports.hermes_tools_mcp_server"],
"env": env,
# Generous timeouts — image_generate or delegate_task can take a while.
"startup_timeout_sec": 30.0,
+8 -8
View File
@@ -678,21 +678,21 @@ DEFAULT_CONFIG = {
# safety margin; explicit values only clamp downward so the server goes first.
"codex_responses_compact_threshold": None,
# in_place: compaction rewrites the message list and system prompt WITHOUT rotating the
# session id (no parent_session_id chain, no `name #N` renumbering), avoiding the
# session-rotation bug cluster. Pre-compaction turns are soft-archived under the same id
# (active=0, compacted=1) — still session_search-able. False = legacy rotating-compaction
# path.
# session id (no parent_session_id chain, no `name #N` renumbering), avoiding the session-rotation
# bug cluster. Pre-compaction turns are soft-archived under the same id (active=0, compacted=1) —
# still session_search-able. False = legacy rotating-compaction path.
"in_place": True,
# Per-model threshold overrides: keys substring-match the model name (longest wins), values
# replace the global `threshold`, e.g. {"glm-5.2": 0.40}. Prefix a key with "<provider>:" to
# scope it to one route ({"openai-codex:astra": 0.85} leaves Astra on OpenRouter/Nous at the
# global value). The <512K floor (0.75) still applies raise-only on top.
"model_thresholds": {},
# Opt-in idle compaction (0 = off): a session resuming after this many idle seconds compacts
# up front, before the first reply. Time-based complement to `threshold`; skipped when
# already at/below threshold × target_ratio; honors the same cooldown/ anti-thrash/lock
# guards. Example: 1800 = 30 min.
# Opt-in idle compaction (0 = off): a session resuming after this many idle seconds compacts up
# front, before the first reply. Time-based complement to `threshold`; skipped when already at/below
# threshold × target_ratio; honors the same cooldown/anti-thrash/lock guards. Example: 1800 = 30 min.
"idle_compact_after_seconds": 0,
# Opt-in: write the next summary in the background near the trigger; compaction uses (or waits for) it if it still covers the same messages.
"prepare_ahead": False,
},
# Anthropic prompt caching (Claude via OpenRouter or native API). cache_ttl: "5m" | "1h" | "auto"
# (auto = 1h for human-paced sessions — cli/tui/desktop/messaging — and 5m for subagent, cron,
+13 -13
View File
@@ -1086,18 +1086,19 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str], *, host: b
# cwd/env overlay are embedded as JSON literals in the watcher source (no extra argv plumbing).
watcher = textwrap.dedent(
"""
import sys
if sys.path[:1] == [""]: del sys.path[0] # -c puts the cwd first: never import from it
import os
import subprocess
import sys
import time
# Stdlib-only imports: the watcher runs on the updater's interpreter, which after the
# package-manager handoff is the bare store Python without the dependency environment.
# ``-c`` only puts the cwd on sys.path, so name the checkout explicitly.
# Stdlib-only imports: after the package-manager handoff this is the bare store Python;
# name the checkout explicitly.
sys.path.insert(0, {project_root_literal})
from hermes_cli._subprocess_compat import (
_WINDOWS_GATEWAY_BREAKAWAY_ENV, pid_exists_stdlib, windows_detach_flags,
windows_detach_flags_without_breakaway,
)
from hermes_cli.gateway_launchd_runtime import start_gateway_job
pid = int(sys.argv[1])
cmd = sys.argv[2:]
@@ -1110,9 +1111,8 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str], *, host: b
break
time.sleep(0.2)
# Route the respawned gateway's stray stdout/stderr to the same sidecar log _spawn_detached
# uses: with DEVNULL a gateway killed moments after respawn (parent Job Object teardown when
# breakaway is denied) left ZERO trace. Best-effort: DEVNULL when the log dir is unavailable.
# Respawned stdout/stderr go to _spawn_detached's sidecar log: with DEVNULL a gateway killed moments
# after respawn (Job Object teardown when breakaway is denied) left ZERO trace. Best-effort.
_stdio_target = subprocess.DEVNULL
_stdio_fh = None
try:
@@ -1125,13 +1125,10 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str], *, host: b
except Exception:
pass
# Platform-appropriate detach for the respawned gateway: POSIX start_new_session (setsid);
# Windows needs explicit creationflags. CREATE_BREAKAWAY_FROM_JOB is critical: the watcher may
# itself sit inside a job object (Electron/Tauri parent) and without breakaway the respawned
# gateway dies when that job tears down. See _subprocess_compat.windows_detach_flags().
# Detach the respawn: POSIX setsid; Windows needs CREATE_BREAKAWAY_FROM_JOB, since the watcher may sit in a job
# object (Electron/Tauri parent) whose teardown would otherwise kill it (_subprocess_compat.windows_detach_flags).
_popen_kwargs = {{"stdout": _stdio_target, "stderr": _stdio_target}}
# Anchor at the stable working dir and overlay the env (VIRTUAL_ENV / PYTHONPATH /
# HERMES_HOME) the windowless base interpreter needs to import hermes_cli. Empty on POSIX.
# Stable cwd + env overlay (VIRTUAL_ENV/PYTHONPATH/HERMES_HOME) the windowless interpreter needs; empty on POSIX.
if _respawn_cwd:
_popen_kwargs["cwd"] = _respawn_cwd
_base_env = {{**os.environ, **_respawn_env_overlay}}
@@ -1150,6 +1147,9 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str], *, host: b
_popen_kwargs["creationflags"] = windows_detach_flags_without_breakaway()
_popen_kwargs["env"] = {{**_base_env, _WINDOWS_GATEWAY_BREAKAWAY_ENV: "0"}}
subprocess.Popen(cmd, **_popen_kwargs)
elif sys.platform == "darwin" and _stdio_fh and start_gateway_job(
cmd, env=_base_env, cwd=os.getcwd(), log_path=_log_dir / "gateway-stdio.log"):
pass # a launchd job: its LAN access outlives whoever ran this one
else:
if _respawn_env_overlay:
_popen_kwargs["env"] = _base_env
+124
View File
@@ -0,0 +1,124 @@
"""macOS: start an unmanaged gateway as a one-off launchd job so it keeps Local Network access.
Local Network Privacy attributes a process to its responsible code. A gateway that Desktop, a
terminal or the updater starts as a plain child is attributed to that app, and once the app quits
every surviving descendant becomes responsible for itself: a bare venv Python, which macOS denies
with ``EHOSTUNREACH`` on any LAN connect (a model server on another machine). A job that launchd
spawns through ``/usr/bin/osascript`` is attributed to osascript, an exempt Apple platform binary,
for its whole life, whoever asked for it (#71206; ``gateway_launchd.launchd_program_arguments``).
Nothing is installed: the plist is loaded once and deleted, it is never in ~/Library/LaunchAgents
and has no KeepAlive, so the job never starts at login and launchd never restarts the gateway.
Stdlib only, because the restart watcher imports this from a bare interpreter.
"""
from __future__ import annotations
import os
import plistlib
import re
import subprocess
import time
import uuid
from pathlib import Path
# Never ``ai.hermes.*``: that prefix is what the gateway's restart route, its control-socket
# supervisor declaration and every fleet scan read as "an installed, KeepAlive service".
LABEL_PREFIX = "com.nousresearch.hermes.runtime."
_POLL_INTERVAL_S = 0.5
def _budget(deadline: float | None, cap: float) -> float:
"""``cap`` seconds, never past the caller's startup deadline (0 once it has passed)."""
return cap if deadline is None else max(0.0, min(cap, deadline - time.monotonic()))
def _launchctl(*args: str, timeout: float) -> subprocess.CompletedProcess[str] | None:
if timeout <= 0:
return None
try:
return subprocess.run(["launchctl", *args], capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=timeout, check=False, stdin=subprocess.DEVNULL)
except (OSError, subprocess.TimeoutExpired):
return None
class GatewayJob:
"""The ``poll()``/``pid`` view of a launched job that ``ensure_gateway_runtime`` reads."""
def __init__(self, target: str, deadline: float | None):
self.target = target
self._deadline = deadline
self.pid: int | None = None
self._status: int | None = None
self._checked = 0.0
def poll(self) -> int | None:
if self._status is not None:
return self._status
if time.monotonic() - self._checked < _POLL_INTERVAL_S:
return None
self._checked = time.monotonic()
done = _launchctl("print", self.target, timeout=_budget(self._deadline, 5))
if done is None:
return None
if done.returncode != 0:
self._status = 1 # the job is gone without a recorded exit
return self._status
if match := re.search(r"^\s*pid = (\d+)", done.stdout, re.MULTILINE):
self._record_gateway_pid(int(match.group(1)))
return None
if match := re.search(r"^\s*last exit code = (-?\d+)", done.stdout, re.MULTILINE):
self._status = int(match.group(1))
return self._status
def _record_gateway_pid(self, wrapper_pid: int) -> None:
# The job's own process is osascript; the gateway is its only child (the shell execs it).
# Its pid is what gateway_state.json names, so a startup-failure report can match it.
if self.pid not in (None, wrapper_pid) or not (timeout := _budget(self._deadline, 5)):
return
try:
out = subprocess.run(["pgrep", "-P", str(wrapper_pid)], capture_output=True, text=True, encoding="utf-8",
timeout=timeout, check=False, stdin=subprocess.DEVNULL).stdout.split()
except (OSError, subprocess.TimeoutExpired):
out = []
self.pid = int(out[0]) if out else wrapper_pid
def _remove_finished_jobs(deadline: float | None) -> None:
"""Unload earlier jobs whose gateway has exited; a running one is never touched."""
listed = _launchctl("list", timeout=_budget(deadline, 10))
for line in (listed.stdout.splitlines() if listed and listed.returncode == 0 else []):
fields = line.split()
if len(fields) == 3 and fields[0] == "-" and fields[2].startswith(LABEL_PREFIX):
_launchctl("remove", fields[2], timeout=_budget(deadline, 10))
def start_gateway_job(command: list[str], *, env: dict[str, str], cwd: str | Path,
log_path: str | Path, deadline: float | None = None) -> GatewayJob | None:
"""Run ``command`` as a one-off job in this user's GUI launchd domain. ``None`` when launchd
cannot take it (no GUI session, e.g. over SSH): the caller starts it as a plain child instead.
``deadline`` (monotonic) bounds every launchctl call, here and in the job's ``poll()``."""
from hermes_cli.gateway_launchd import launchd_program_arguments
_remove_finished_jobs(deadline)
label = f"{LABEL_PREFIX}{uuid.uuid4().hex[:12]}"
target = f"gui/{os.getuid()}/{label}" # windows-footgun: ok — macOS-only module
log_path = Path(log_path)
# launchd stamps XPC_SERVICE_NAME=<label> on the job; the gateway must still read as the
# unsupervised process it is, or /restart would exit for a respawn no KeepAlive provides.
wrapped = ["/usr/bin/env", "XPC_SERVICE_NAME=0", *command]
plist = log_path.parent / f".{label}.plist"
fd = os.open(plist, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) # holds the launch env
try:
with os.fdopen(fd, "wb") as handle:
plistlib.dump({
"Label": label,
"ProgramArguments": launchd_program_arguments(wrapped, log_path, log_path),
"EnvironmentVariables": dict(env),
"WorkingDirectory": str(cwd),
"RunAtLoad": True,
}, handle)
done = _launchctl("bootstrap", f"gui/{os.getuid()}", str(plist), # windows-footgun: ok — macOS-only
timeout=_budget(deadline, 10))
finally:
plist.unlink(missing_ok=True)
return GatewayJob(target, deadline) if done is not None and done.returncode == 0 else None
+6 -1
View File
@@ -7,6 +7,7 @@ import subprocess
import sys
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs, _WINDOWS_GATEWAY_BREAKAWAY_ENV
from hermes_cli.gateway_launchd_runtime import GatewayJob, start_gateway_job
from hermes_cli.gateway_runtime_service import RuntimeStartError, remaining
@@ -33,7 +34,7 @@ DAEMON_STRIPPED_ENV = (
def spawn_unmanaged_gateway(profile_home: Path, *, deadline: float, idle_exit: bool = False,
force: bool = False) -> subprocess.Popen:
force: bool = False) -> subprocess.Popen | GatewayJob:
"""Request a daemon, not readiness. Refuse Windows no-breakaway fallback.
No --replace, persistence, login changes, elevation, shell, or inherited stdio.
@@ -72,6 +73,10 @@ def spawn_unmanaged_gateway(profile_home: Path, *, deadline: float, idle_exit: b
remaining(deadline)
logs = home / "logs"
logs.mkdir(parents=True, exist_ok=True)
if sys.platform == "darwin": # LAN access must outlive the app that asked for the gateway
if job := start_gateway_job(command, env=env, cwd=root, log_path=logs / "gateway-stdio.log",
deadline=deadline):
return job
try:
with (logs / "gateway-stdio.log").open("ab", buffering=0) as output:
remaining(deadline)
@@ -11,9 +11,10 @@ import uuid
from collections import OrderedDict, deque
from dataclasses import dataclass, field
from time import monotonic_ns
from typing import Any, Callable
from typing import Any, Callable, Iterator
from agent import relay_runtime
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from agent.portal_tags import get_conversation_context
from hermes_cli.version_info import get_version_info
@@ -629,6 +630,16 @@ class _Runtime:
_forget(self._sessions, session.session_id, session)
def shutdown(self) -> None:
# Callers (the atexit hook, an os._exit flush, a multiplex host's teardown) hold the launch
# profile's scope: this profile's own collection gate and send consent decide its exit.
with self._owner_scope():
if not enabled(): # revoked since its last turn: drop the data, never export it
if self._registered:
self.deactivate()
return
self._close_and_export()
def _close_and_export(self) -> None:
with self._sessions_lock:
self._active = False
session_ids = list(self._sessions)
@@ -644,6 +655,15 @@ class _Runtime:
self._deregister()
self._release()
@contextlib.contextmanager
def _owner_scope(self) -> Iterator[None]:
"""Bind this runtime's profile home for work that can run outside its own turns."""
token = set_hermes_home_override(self.host.profile_key)
try:
yield
finally:
reset_hermes_home_override(token)
def _deregister(self) -> None:
self._safe(self.relay.subscribers.deregister, self._subscriber_name)
self.host.release_managed_execution(self._subscriber_name)
@@ -752,13 +772,15 @@ class _Runtime:
self._flush_and_export(failure_message)
def _flush_and_export(self, failure_message: str) -> None:
"""Flush the Relay subscriber, then export; a failed flush skips the export."""
"""Flush the Relay subscriber, then export under this profile's scope (its send consent,
whoever triggered the flush); a failed flush skips the export."""
try:
self.relay.subscribers.flush()
except Exception:
logger.warning(failure_message, exc_info=True)
else:
self._export()
with self._owner_scope():
self._export()
def _abort_session(self, session: _MetricsSession, base_event: dict[str, Any]) -> bool:
"""Mark the session closing and system-abort its open tasks; False if already closing."""
@@ -1273,9 +1295,11 @@ class _Runtime:
with self._send_lock:
# One in-flight pass per process; the next hook fire picks up what is pending.
if self._send_thread is None or not self._send_thread.is_alive():
self._send_thread = threading.Thread(
target=self._run_send_pass, args=(resolved.endpoint,),
name="hermes-shared-metrics-send", daemon=True,
from agent.memory_provider import spawn_context_thread
# Context copy: the pass re-checks consent (still_consented) in THIS profile's scope.
self._send_thread = spawn_context_thread(
self._run_send_pass, name="hermes-shared-metrics-send", args=(resolved.endpoint,)
)
self._send_thread.start()
@@ -1394,10 +1418,30 @@ def _reconcile_send_consent_once() -> None:
logger.warning("Unable to reconcile shared-metrics send consent", exc_info=True)
# Hooks whose only effect is a process mark (no task, no scope): gated like record_process_mark.
_PROCESS_MARK_HOOKS = frozenset({"post_auxiliary_call"})
def _process_marks_admitted() -> bool:
"""Whether the inherited turn lets a process-level mark through.
A process mark has no owning task and opens no scope, so a turn that has CLOSED does not gate
it: background work spawned under the turn (a turn-end compaction pass, title generation)
inherits that turn through its copied context and still reports after it ends. A turn Relay
skipped (a concurrent turn on the session) keeps gating, open or closed."""
turn = relay_runtime.current_turn()
return turn is None or turn.relay_enabled
def observe_lifecycle(hook_name: str, **kwargs: Any) -> None:
"""Project one Hermes lifecycle event into the core Relay integration."""
_reconcile_send_consent_once()
if not handles_hook(hook_name) or not relay_runtime.relay_instrumentation_enabled():
if not handles_hook(hook_name):
return
if not (
_process_marks_admitted() if hook_name in _PROCESS_MARK_HOOKS
else relay_runtime.relay_instrumentation_enabled()
):
return
runtime = _get_runtime()
if runtime is None:
@@ -1467,7 +1511,7 @@ def start_task_run(
def shutdown_runtimes() -> None:
"""Run each live runtime's exit flush now, for a process that leaves through ``os._exit`` (the
``hermes -z`` exit) and so never reaches the atexit hook that closes sessions and writes
``task_run.finished`` / ``session.count``."""
``task_run.finished`` / ``session.count``. Each runtime binds its own profile while it does."""
with _RUNTIME_LOCK:
runtimes = [runtime for runtime in _RUNTIMES.values() if isinstance(runtime, _Runtime)]
for runtime in runtimes:
@@ -1507,7 +1551,7 @@ def finish_task_run(
def record_process_mark(mark: str, data: dict[str, Any]) -> None:
"""Emit one process-level decision mark when shared metrics are on (never raises)."""
if not enabled() or not relay_runtime.relay_instrumentation_enabled():
if not enabled() or not _process_marks_admitted():
return
runtime = _get_runtime(retry_failed=True)
if runtime is not None:
@@ -1518,7 +1562,7 @@ def record_process_marks_saved(marks: list[tuple[str, dict[str, Any]]]) -> int:
"""``record_process_mark`` for facts recovered from a file the caller deletes only once they are
saved (a busy store would otherwise lose them for good). How many rows are settled: saved, rejected
by the contract, or nothing to record because collection is off."""
if not enabled() or not relay_runtime.relay_instrumentation_enabled():
if not enabled() or not _process_marks_admitted():
return len(marks)
runtime = _get_runtime(retry_failed=True)
return (runtime._safe(runtime.record_process_marks_saved, marks) or 0) if runtime is not None else 0
@@ -286,6 +286,9 @@
},
{
"$ref": "#/$defs/connector_chain_counter"
},
{
"$ref": "#/$defs/prepared_compaction_counter"
}
]
}
@@ -1219,9 +1222,15 @@
"gte_90d",
"unknown"
]
},
"compression_prepare_ahead": {
"enum": [
"off",
"on"
]
}
},
"description": "The five decision-data fields are absent on snapshots counted before they existed. The six version-lag/channel/hardware fields are absent on snapshots counted before they existed."
"description": "The five decision-data fields are absent on snapshots counted before they existed. The six version-lag/channel/hardware fields are absent on snapshots counted before they existed. compression_prepare_ahead is absent on snapshots counted before it existed."
},
"value": {
"const": 1
@@ -5257,6 +5266,128 @@
"minimum": 1
}
}
},
"prepared_compaction_counter": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"type",
"dimensions",
"value"
],
"properties": {
"name": {
"const": "hermes.prepared_compaction.count"
},
"type": {
"const": "counter"
},
"dimensions": {
"type": "object",
"additionalProperties": false,
"required": [
"event",
"failure_class",
"origin",
"reason",
"wait_bucket"
],
"properties": {
"event": {
"enum": [
"auto_disabled",
"compaction_failed",
"discarded",
"internal_error",
"pass_cancelled",
"pass_completed",
"pass_failed",
"pass_started",
"skipped_busy",
"used"
]
},
"failure_class": {
"enum": [
"attempt_superseded",
"aux_model_fallback",
"commit_fence_cancelled",
"empty_post_handoff_window",
"empty_transcript",
"exception",
"explicit_interrupt",
"feasibility_skip",
"insufficient_messages",
"lock_contended",
"no_compressible_window",
"no_progress",
"none",
"other",
"pool_saturated",
"rollback",
"session_split_failed",
"snapshot_stale",
"stall_deterministic_fallback",
"stall_interrupted",
"summary_auth_failure",
"summary_empty_content_failure",
"summary_generation_aborted",
"summary_generation_failed",
"summary_network_failure",
"summary_overload_degraded",
"summary_overload_failure",
"summary_truncated_failure",
"unknown",
"would_grow"
]
},
"origin": {
"enum": [
"none",
"tool_batch",
"turn_end"
]
},
"reason": {
"enum": [
"agent_released",
"aux_fallback_main",
"compaction_aborted",
"fresh_required",
"head_moved",
"host_cancelled",
"none",
"other_session",
"pass_failed_while_waiting",
"provider_context",
"rewound",
"running_not_fit",
"session_boundary",
"shape_changed",
"superseded",
"text_changed",
"transcript_shorter",
"wait_timeout"
]
},
"wait_bucket": {
"enum": [
"none",
"lt_1s",
"1s_to_5s",
"5s_to_30s",
"30s_to_120s",
"gte_120s"
]
}
}
},
"value": {
"type": "integer",
"minimum": 1
}
}
}
}
}
+27 -1
View File
@@ -7,6 +7,7 @@ import json
import logging
import sqlite3
import threading
import time
import uuid
from collections.abc import Callable, Iterator
from contextlib import contextmanager
@@ -155,6 +156,25 @@ _SEND_COLUMNS = (
logger = logging.getLogger(__name__)
# Every store in this process that ever deferred a write, the per-profile runtimes' stores and the
# ones no runtime owns alike (the root profile's host-level active-profiles accumulator, the Desktop
# store). ``os._exit`` skips each store's atexit drain, so a hard exit drains this set instead.
_DEFERRING_STORES: list[SharedMetricsStore] = []
_DEFERRING_STORES_LOCK = threading.Lock()
def drain_deferring_stores(deadline: float) -> None:
"""Write every store's deferred rows before an ``os._exit``, waiting on a busy store no later
than ``deadline`` (``time.monotonic``). Each deferred write runs against the store that deferred
it, so one profile's rows never land in another's database."""
with _DEFERRING_STORES_LOCK:
stores = list(_DEFERRING_STORES)
for store in stores:
remaining_ms = int((deadline - time.monotonic()) * 1000)
if remaining_ms <= 0:
return
store.drain_deferred(busy_timeout_ms=remaining_ms)
def _utc_now() -> datetime:
return datetime.now(UTC)
@@ -429,6 +449,8 @@ class SharedMetricsStore:
register, self._drain_at_exit = not self._drain_at_exit, True
if register:
atexit.register(self.drain_deferred)
with _DEFERRING_STORES_LOCK:
_DEFERRING_STORES.append(self)
if not busy:
raise
@@ -442,7 +464,11 @@ class SharedMetricsStore:
with self._write(busy_timeout_ms=busy_timeout_ms) as connection:
for item in pending:
item(connection)
except Exception:
except Exception as exc:
if isinstance(exc, sqlite3.OperationalError) and ("locked" in str(exc) or "busy" in str(exc)):
with self._deferred_lock: # still contended: keep them for the next write or exit drain
self._deferred = [*pending, *self._deferred][-_DEFERRED_WRITES_MAX:]
return
logger.warning("Unable to write %d deferred shared-metrics updates", len(pending), exc_info=True)
def create_and_export_package_if_due(self) -> list[Path]:
@@ -16,6 +16,11 @@ from hermes_cli.platforms import PLATFORMS
from tools.connectors.gateway.names import CONNECTOR_NAME_PREFIX
from toolsets import BUILTIN_TOOL_NAMES, BUILTIN_TOOLSET_NAMES
from .shared_metrics_prepared_compaction import (
PREPARED_COMPACTION_DIMENSIONS, PREPARED_COMPACTION_MARK, PREPARED_COMPACTION_METRIC,
PREPARED_COMPACTION_SNAPSHOT_DIMENSIONS,
)
SCHEMA_KEY = "hermes.metrics.schema_version"
# A random per-emit token (no payload) on rows whose producer waits to learn they were SAVED: facts
# recovered from a file that is deleted only then. Events persist on the Relay thread.
@@ -902,7 +907,7 @@ _COUNTER_DIMENSION_VALUES: dict[str, dict[str, frozenset[str]]] = {
"memory_provider": MEMORY_PROVIDERS, "messaging_platform_count_bucket": SIZE_BUCKETS,
"plugin_count_bucket": SIZE_BUCKETS, "profile_count_bucket": SIZE_BUCKETS,
"skill_count_bucket": SIZE_BUCKETS, "terminal_backend": TERMINAL_BACKENDS,
**_INSTALL_V4_SNAPSHOT_DIMENSIONS,
**_INSTALL_V4_SNAPSHOT_DIMENSIONS, **PREPARED_COMPACTION_SNAPSHOT_DIMENSIONS,
},
SESSION_METRIC: {
"active_duration_bucket": SESSION_DURATION_BUCKETS, "entrypoint": TASK_ENTRYPOINTS,
@@ -1057,6 +1062,7 @@ _COUNTER_DIMENSION_VALUES: dict[str, dict[str, frozenset[str]]] = {
"described_first": YES_NO, "outcome": TOOL_OUTCOMES, "searches_before_bucket": COUNT_BUCKETS,
},
# ---- end v4 connectors ----
PREPARED_COMPACTION_METRIC: {**PREPARED_COMPACTION_DIMENSIONS, "failure_class": COMPRESSION_FAILURE_CLASSES},
}
_MODEL_ROUTE_MAX_LENGTHS = {
"model": MODEL_IDENTIFIER_MAX_LENGTH, "provider": PROVIDER_IDENTIFIER_MAX_LENGTH,
@@ -1136,10 +1142,11 @@ _LEGACY_METRIC_FIELDS: dict[str, tuple[frozenset[str], ...]] = {
SKILL_LOAD_METRIC: (_METRIC_FIELDS[SKILL_LOAD_METRIC] - {"skill_name"},),
INSTALL_SNAPSHOT_METRIC: (_METRIC_FIELDS[INSTALL_SNAPSHOT_METRIC] - {
"display_language", "install_age_bucket", "main_provider", "messaging_platform_count_bucket",
"terminal_backend", *_INSTALL_V4_SNAPSHOT_DIMENSIONS,
"terminal_backend", *_INSTALL_V4_SNAPSHOT_DIMENSIONS, *PREPARED_COMPACTION_SNAPSHOT_DIMENSIONS,
},
# ---- v4 install ----
_METRIC_FIELDS[INSTALL_SNAPSHOT_METRIC] - set(_INSTALL_V4_SNAPSHOT_DIMENSIONS),
_METRIC_FIELDS[INSTALL_SNAPSHOT_METRIC] - {*_INSTALL_V4_SNAPSHOT_DIMENSIONS, *PREPARED_COMPACTION_SNAPSHOT_DIMENSIONS},
_METRIC_FIELDS[INSTALL_SNAPSHOT_METRIC] - set(PREPARED_COMPACTION_SNAPSHOT_DIMENSIONS),
),
}
# ---- v5 engagement ----
@@ -1207,7 +1214,7 @@ _DECISION_MARK_METRICS = {
INSTALL_RUN_MARK: INSTALL_RUN_METRIC,
# ---- end iuf c2 ----
TOOL_SEARCH_MARK: TOOL_SEARCH_METRIC, TOOL_BATCH_MARK: TOOL_BATCH_METRIC,
CONNECTOR_CHAIN_MARK: CONNECTOR_CHAIN_METRIC,
CONNECTOR_CHAIN_MARK: CONNECTOR_CHAIN_METRIC, PREPARED_COMPACTION_MARK: PREPARED_COMPACTION_METRIC,
}
@@ -0,0 +1,87 @@
"""Prepared compaction (``compression.prepare_ahead``): adoption and every way a prepared pass is wasted.
The daily snapshot carries ``compression_prepare_ahead`` (``on``/``off``), read with the agent's own
truthiness, so "active installs with the feature on" needs no join. ``hermes.prepared_compaction.count``
counts each pass and splice outcome from ``agent/prepared_compaction.py`` (and a spliced compaction that
then failed, from ``agent/conversation_compression_telemetry.py``) over closed sets only: the event, the
hook that started the pass, a reason code (never the free-text miss reason), the compression failure
class, and how long a compaction waited on a running pass, bucketed.
The contract imports this module at load time, so it never imports the contract at module level.
"""
from __future__ import annotations
from typing import Any
PREPARED_COMPACTION_MARK = PREPARED_COMPACTION_METRIC = "hermes.prepared_compaction.count"
PREPARED_COMPACTION_EVENTS = frozenset({
"auto_disabled", "compaction_failed", "discarded", "internal_error", "pass_cancelled", "pass_completed",
"pass_failed", "pass_started", "skipped_busy", "used",
})
PREPARED_COMPACTION_ORIGINS = frozenset({"none", "tool_batch", "turn_end"})
PREPARED_COMPACTION_REASONS = frozenset({
"agent_released", "aux_fallback_main", "compaction_aborted", "fresh_required", "head_moved", "host_cancelled",
"none", "other_session", "pass_failed_while_waiting", "provider_context", "rewound",
"running_not_fit", "session_boundary", "shape_changed", "superseded", "text_changed",
"transcript_shorter", "wait_timeout",
})
PREPARED_COMPACTION_WAIT_BUCKETS = frozenset({"none", "lt_1s", "1s_to_5s", "5s_to_30s", "30s_to_120s", "gte_120s"})
_WAIT_THRESHOLDS = ((1, "lt_1s"), (5, "1s_to_5s"), (30, "5s_to_30s"), (120, "30s_to_120s"))
# failure_class (the compression vocabulary) is added by the contract, which owns that set.
PREPARED_COMPACTION_DIMENSIONS = {
"event": PREPARED_COMPACTION_EVENTS, "origin": PREPARED_COMPACTION_ORIGINS,
"reason": PREPARED_COMPACTION_REASONS, "wait_bucket": PREPARED_COMPACTION_WAIT_BUCKETS,
}
PREPARED_COMPACTION_SNAPSHOT_DIMENSIONS = {"compression_prepare_ahead": frozenset({"off", "on"})}
def prepare_ahead_state(config: Any) -> str:
"""``on`` when the profile config turns ``compression.prepare_ahead`` on, read as the agent reads it."""
from utils import is_truthy_value
compression = config.get("compression") if isinstance(config, dict) else None
value = compression.get("prepare_ahead") if isinstance(compression, dict) else None
return "on" if is_truthy_value(value, default=False) else "off"
def wait_bucket(seconds: Any) -> str:
if isinstance(seconds, bool) or not isinstance(seconds, (int, float)) or seconds < 0:
return "none"
return next((label for limit, label in _WAIT_THRESHOLDS if seconds < limit), "gte_120s")
def _closed(value: Any, allowed: frozenset[str], fallback: str) -> str:
text = str(value or "").strip().lower().replace("-", "_")
return text if text in allowed else fallback
def prepared_compaction_fields(
*, event: Any, origin: Any, reason: Any, failure_class: Any, wait_seconds: Any,
) -> dict[str, str] | None:
"""Closed dimensions for one event; ``None`` for an event outside the set (never recorded)."""
from .shared_metrics_fields import compression_failure_class
name = _closed(event, PREPARED_COMPACTION_EVENTS, "")
if not name:
return None
return {
"event": name,
"failure_class": "none" if failure_class is None else compression_failure_class("failed", failure_class),
"origin": _closed(origin, PREPARED_COMPACTION_ORIGINS, "none"),
"reason": _closed(reason, PREPARED_COMPACTION_REASONS, "none"),
"wait_bucket": wait_bucket(wait_seconds),
}
def record_prepared_compaction(
event: str, *, origin: str | None = None, reason: str | None = None, failure_class: str | None = None,
wait_seconds: float | None = None,
) -> None:
"""Count one prepared-compaction event (no-op unless shared metrics are on; never raises)."""
from .shared_metrics_events import _emit
_emit(
PREPARED_COMPACTION_MARK, prepared_compaction_fields, event=event, origin=origin, reason=reason,
failure_class=failure_class, wait_seconds=wait_seconds,
)
@@ -17,6 +17,7 @@ import os
import shutil
import sys
import threading
import time
from pathlib import Path
from typing import Any
@@ -29,6 +30,9 @@ _CRASH_CLASSES = (
(MemoryError, "memory_error"), (ImportError, "import_error"), (OSError, "os_error"),
(RuntimeError, "runtime_error"),
)
#: Ceiling on ``flush_before_hard_exit``: a wedged flush costs its pending rows, never the exit.
EXIT_FLUSH_TIMEOUT_S = 3.0
_DRAIN_MARGIN_S = 0.1
_STATE: dict[str, Any] = {}
_STATE_LOCK = threading.Lock()
@@ -62,6 +66,43 @@ def stamp_exit(exit_kind: str, crash: str = "none") -> None:
logger.debug("Process exit marker not stamped", exc_info=True)
def flush_before_hard_exit(timeout_s: float = EXIT_FLUSH_TIMEOUT_S) -> bool:
"""For a graceful stop about to ``os._exit``: run the shared-metrics exit flush its skipped atexit
hooks would have run (close each live runtime's sessions under its own profile, write queued rows,
and write every store's deferred rows, the root profile's host-level store included, each into
the store that deferred it).
Bounded by ``timeout_s`` on a daemon thread, because the Relay flush waits on every managed call
still in flight and a wedged one must not hold the exit. Never imports the runtime module:
collection off (or never started) means nothing to flush and nothing created. Never raises; True
when the flush finished in time."""
runtimes = sys.modules.get("hermes_cli.observability.relay_shared_metrics")
stores = sys.modules.get("hermes_cli.observability.shared_metrics")
if runtimes is None and stores is None:
return True
deadline = time.monotonic() + max(0.0, timeout_s)
def _flush() -> None:
if runtimes is not None:
runtimes.shutdown_runtimes()
if stores is not None: # every store that deferred a write, root/Desktop ones included
stores.drain_deferring_stores(deadline - _DRAIN_MARGIN_S) # done before the join gives up
try:
from agent.memory_provider import spawn_context_thread
worker = spawn_context_thread(_flush, name="hermes-shared-metrics-exit-flush")
worker.start()
worker.join(max(0.0, deadline - time.monotonic()))
except Exception:
logger.debug("Shared-metrics exit flush not started", exc_info=True)
return False
if worker.is_alive():
logger.warning("Shared-metrics exit flush still running after %.1fs; exiting without it", timeout_s)
return False
return True
def _excepthook(previous: Any) -> Any:
def hook(exc_type: type[BaseException], exc: BaseException, tb: Any) -> None:
if not issubclass(exc_type, (KeyboardInterrupt, SystemExit)):
@@ -127,6 +127,7 @@ def collect_install_snapshot(config: dict[str, Any]) -> dict[str, str]:
with contextlib.suppress(Exception):
language = _display_language()
from .shared_metrics_install import install_v4_snapshot_fields
from .shared_metrics_prepared_compaction import prepare_ahead_state
return {**contract.install_snapshot_fields(
memory_provider=_sub(_sub(config, "memory"), "provider"),
@@ -140,4 +141,4 @@ def collect_install_snapshot(config: dict[str, Any]) -> dict[str, str]:
main_provider=_sub(model, "provider") if isinstance(model, dict) else None,
terminal_backend=_sub(_sub(config, "terminal"), "backend"),
display_language=language,
), **install_v4_snapshot_fields(config)}
), **install_v4_snapshot_fields(config), "compression_prepare_ahead": prepare_ahead_state(config)}
+8 -4
View File
@@ -91,16 +91,20 @@ def _extract(plugin_dir: Path, module_name: str) -> dict[str, Any]:
from hermes_cli.plugin_isolation import HOST_PROCESS_ENV, host_launcher
from hermes_constants import get_hermes_home
repo_root = str(Path(__file__).resolve().parents[1])
# Absolute: the child starts in the home, so a relative HERMES_HOME or plugin dir would resolve twice.
home = str(get_hermes_home().resolve())
env = {key: os.environ[key] for key in ("PATH", "LANG", "LC_ALL", "TZ", "SYSTEMROOT") if key in os.environ}
# Windows resolves Path.home() from USERPROFILE, never HOME: without it the child cannot start.
env.update(HOME=str(Path.home()), USERPROFILE=str(Path.home()), HERMES_HOME=str(get_hermes_home()),
env.update(HOME=str(Path.home()), USERPROFILE=str(Path.home()), HERMES_HOME=home,
PYTHONPATH=repo_root,
**{HOST_PROCESS_ENV: "1"})
argv = [*host_launcher(), sys.executable, "-m", "hermes_cli.plugin_host_child", "--extract-profiles",
str(plugin_dir), module_name]
# -P and a trusted cwd: ``-m`` otherwise puts the launch directory ahead of PYTHONPATH, so a
# ``hermes_cli/`` package in the directory Hermes was started from would run instead of ours.
argv = [*host_launcher(), sys.executable, "-P", "-m", "hermes_cli.plugin_host_child", "--extract-profiles",
str(plugin_dir.resolve()), module_name]
try:
done = subprocess.run(argv, stdin=subprocess.DEVNULL, capture_output=True, env=env,
timeout=_EXTRACT_TIMEOUT_SECS, check=False)
cwd=home, timeout=_EXTRACT_TIMEOUT_SECS, check=False)
except (OSError, subprocess.TimeoutExpired) as exc:
return {"error": f"profile extraction failed: {exc}"}
try:
+6 -1
View File
@@ -354,10 +354,15 @@ def _run_capability_probe(
with tempfile.TemporaryDirectory(prefix="hermes-validate-") as scratch:
env = dict(probe[1] if probe else os.environ)
env["HERMES_HOME"] = scratch
# The probe imports this tree's ``providers``; name the tree instead of relying on the cwd.
repo_root = str(Path(__file__).resolve().parents[1])
env["PYTHONPATH"] = os.pathsep.join(p for p in (repo_root, env.get("PYTHONPATH", "")) if p)
python, *options = probe[0] if probe else [sys.executable]
try:
result = subprocess.run(
[
*(probe[0] if probe else [sys.executable]),
# -P: ``-c`` would put the user's cwd ahead of Hermes and the plugin's imports.
python, "-P", *options,
"-c",
_PROBE_SCRIPT,
str(plugin_dir),
+76 -51
View File
@@ -38,12 +38,36 @@ def _size_delta_label(saved_mb: float) -> str:
return f"reclaimed {saved_mb:.1f} MB" if saved_mb >= 0 else f"grew by {-saved_mb:.1f} MB"
def _confirm_prompt(prompt: str) -> bool:
"""Prompt for y/N confirmation, safe against non-TTY environments."""
def _confirm_prompt(prompt: str) -> bool | None:
"""Prompt for y/N confirmation. ``None`` when stdin is not a terminal and hits EOF (/dev/null, an empty
pipe: cron, a wrapper), which is not the same as the user saying no; Ctrl-D at a terminal is a no.
Ctrl-C propagates: an interrupt is neither a no nor a success."""
try:
return input(prompt).strip().lower() in {"y", "yes"}
except (EOFError, KeyboardInterrupt):
return False
except EOFError:
print()
# isatty() alone is True for Windows NUL; the console check keeps `< NUL` unattended (#113977).
from hermes_cli.gateway_windows import _stdin_console_mode_ok, _stdin_is_interactive
return False if _stdin_is_interactive(isatty=sys.stdin.isatty(), console_mode_ok=_stdin_console_mode_ok()) else None
def _confirm_or_exit_code(prompt: str, *, declined: str = "Cancelled.", unattended: str | None = "--yes") -> int | None:
"""``None`` to proceed, else the exit code after saying why not. A decline exits 0; a run nobody could
answer exits 1, so a cron job or wrapper that forgot *unattended* does not read "nothing happened" as
success."""
try:
answer = _confirm_prompt(prompt)
except KeyboardInterrupt:
print("\nInterrupted; nothing was changed.")
return 130
if answer:
return None
if answer is None:
hint = f" Pass {unattended} to run it unattended." if unattended else " Run it from an interactive terminal."
print(f"No answer on a non-interactive stdin; nothing was changed.{hint}")
return 1
print(declined)
return 0
def _not_found(session_id) -> int:
@@ -156,6 +180,7 @@ def _cmd_repair(args):
f" hermes sessions recover --source {source_hint} \\\n"
" --output recovered-state.db"
)
return 1
def _cmd_recover(args):
@@ -349,7 +374,7 @@ def _cmd_export(db, args):
filters = build_prune_filters(args)
except ValueError as e:
print(f"Error: {e}")
return
return 1
# A backup includes protected rows; prune's keep rules must not omit them.
filters["archived"] = None
filters["include_pinned"] = True
@@ -387,29 +412,30 @@ def _cmd_export(db, args):
return False
def _collect_sessions():
"""--session-id / filters / bare export -> redacted session dicts, or None after printing an error."""
"""--session-id / filters / bare export -> redacted session dicts, None after a dry-run preview,
or an exit code after printing an error."""
def _one(session_id):
return _redact(db.export_session(session_id, **projection))
if args.session_id:
resolved = db.resolve_session_id(args.session_id)
if resolved and _too_large([resolved]):
return None
return 1
data = _one(resolved) if resolved else None
if not data:
_not_found(args.session_id)
return None
return _not_found(args.session_id)
return [data]
if filters:
candidates = db.list_prune_candidates(**filters)
if args.dry_run:
return _print_dry_run_preview(candidates, filters)
if _too_large([row["id"] for row in candidates]):
return None
return 1
return [s for s in (_one(row["id"]) for row in candidates) if s]
if args.dry_run:
return print("--dry-run requires at least one filter.")
print("--dry-run requires at least one filter.")
return 1
if _too_large():
return None
return 1
return [_redact(s) for s in db.export_all(source=None, **projection)]
if getattr(args, "only", None):
return _export_flat("only", args, _collect_sessions)
@@ -456,8 +482,10 @@ def _export_flat(kind, args, collect):
unusable, message, render = _FLAT_EXPORTERS[kind]
if unusable(args):
print(message)
return
return 1
sessions = collect()
if isinstance(sessions, int):
return sessions
if sessions is not None:
from hermes_cli.session_export import default_save_filename
name = (default_save_filename(sessions[0].get("id", ""), args.format) if len(sessions) == 1
@@ -475,13 +503,13 @@ def _export_trace(db, args, filters):
session_id = rows[0].get("id") if rows else None
if not session_id:
print("No session found to export. Pass --session-id.")
return
return 1
from agent.trace_upload import TraceRedactionError, build_trace_jsonl, upload_session_trace
redact_trace = not getattr(args, "no_redact", False)
if getattr(args, "upload", False):
if not session_id:
print("--upload exports one session: pass --session-id (or drop filters to use the most recent).")
return
return 1
resolved = db.resolve_session_id(session_id)
db.close()
print(upload_session_trace(resolved, cwd="", redact=redact_trace, private=not getattr(args, "public", False)))
@@ -505,7 +533,7 @@ def _export_trace(db, args, filters):
jsonl = _render_trace(ids[0])
if not jsonl:
print(f"No transcript to export for session '{ids[0]}'.")
return
return 1
args.output = _output_file_in_dir(args.output, f"{ids[0]}.trace.jsonl")
_write_output(args.output, jsonl, f"Exported 1 session trace to {args.output}")
else:
@@ -520,6 +548,7 @@ def _export_trace(db, args, filters):
print(f"Exported {exported} session trace(s) to {out_dir}")
except TraceRedactionError:
print("Redaction failed; refusing to export unredacted trace content.")
return 1
def _export_markdown(db, args, filters, redact):
@@ -527,7 +556,7 @@ def _export_markdown(db, args, filters, redact):
from hermes_cli.session_export_md import append_manifest_entry, write_session_markdown
if args.output == "-":
print("Markdown/QMD export writes files; stdout (-) is only supported with --format jsonl.")
return
return 1
output_dir = _export_dir(args.output)
def _export_one(session_id: str, *, include_lineage: bool = False):
@@ -547,17 +576,17 @@ def _export_markdown(db, args, filters, redact):
return data, path, snapshots
if args.delete_after_verified and not args.yes:
print("--delete-after-verified requires --yes.")
return
return 1
if args.delete_after_verified and not args.session_id:
print("--delete-after-verified is only supported with --session-id.")
return
return 1
lineage_is_logical = getattr(args, "lineage", "single") == "logical"
if args.session_id:
return _export_markdown_single(db, args, _export_one, output_dir, lineage_is_logical)
if not filters:
print("Refusing bulk export without a filter. Pass --session-id or "
"at least one filter (e.g. --older-than 90, --source telegram).")
return
return 1
candidates = db.list_prune_candidates(**filters)
if args.dry_run:
return _print_dry_run_preview(candidates, filters)
@@ -588,10 +617,10 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical
)
except FileExistsError as e:
print(f"Export already exists: {e}. Pass --force to overwrite.")
return
return 1
if not data or not exported_path:
print(f"Session '{target_id}' disappeared during export; nothing was deleted.")
return
return 1
exported_items.append((data, exported_path, snapshots))
message_count = sum(len(data.get("messages") or []) for data, _path, _ in exported_items)
n = len(exported_items)
@@ -606,7 +635,7 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical
ok, reason = verify_export_file(exported_path, data)
if not ok:
print(f"Export verification failed; not deleting session '{data.get('id')}': {reason}")
return
return 1
expected_messages.update(snapshots)
try:
if not db.delete_session(
@@ -615,10 +644,10 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical
):
print(f"Exported, but session '{resolved_session_id}' was not deleted because its history or delegate set "
"changed after export.")
return
return 1
except SessionActiveWriteGuardError as exc:
print(f"Exported, but not deleted: {exc}")
return
return 1
delegates = len(delete_target_ids) - 1
delegate_suffix = f" and {delegates} delegate session{'' if delegates == 1 else 's'}" if delegates else ""
print(f"Deleted exported session '{resolved_session_id}'{delegate_suffix}.")
@@ -633,9 +662,9 @@ def _cmd_delete(db, args):
# The delete is honored (explicit id), but a pin is a "keep" flag: say so instead of silently destroying it.
_pinned_note = " (this session is PINNED)" if (db.get_session(resolved_session_id) or {}).get("pinned") else ""
if not args.yes:
if not _confirm_prompt(f"Delete session '{resolved_session_id}'{_pinned_note} and all its messages? [y/N] "):
print("Cancelled.")
return
refused = _confirm_or_exit_code(f"Delete session '{resolved_session_id}'{_pinned_note} and all its messages? [y/N] ")
if refused is not None:
return refused
elif _pinned_note:
print(f"Warning: deleting a pinned session '{resolved_session_id}'.")
from hermes_state_runtime import RuntimeStoreError
@@ -679,7 +708,7 @@ def _prune_never_active_keyed(db, args):
if seconds is None:
print(f"Error: --older-than '{older_than}' is not a duration. "
"Use a bare number of days or a form like '2d' / '1w'.")
return
return 1
days = seconds / 86400.0
candidates = db.list_never_active_keyed_sessions(older_than_days=days)
if not candidates:
@@ -696,9 +725,9 @@ def _prune_never_active_keyed(db, args):
if args.dry_run:
print("Dry run — nothing deleted.")
return
if not args.yes and not _confirm_prompt(f"Delete {len(candidates)} session(s)? [y/N] "):
print("Aborted.")
return
if not args.yes and (refused := _confirm_or_exit_code(f"Delete {len(candidates)} session(s)? [y/N] ",
declined="Aborted.")) is not None:
return refused
owner = getattr(args, "owner_endpoint", None)
if owner is not None:
from hermes_cli.gateway_client import GatewayClientError
@@ -753,7 +782,7 @@ def _cmd_prune_or_archive(db, args, action):
if not prune and not any(v for k, v in filters.items() if k != "older_than_days"):
print("Refusing to archive every ended session: pass at least one "
"filter (e.g. --newer-than 5h, --source cli, --title codex).")
return
return 1
# Prune skips archived rows unless --include-archived; archive only targets not-yet-archived rows.
filters["archived"] = None if prune and getattr(args, "include_archived", False) else False
@@ -803,9 +832,9 @@ def _cmd_prune_or_archive(db, args, action):
print(f"Dry run — nothing {'deleted' if prune else 'archived'}.")
return
verb = "Delete" if prune else "Archive"
if not args.yes and not _confirm_prompt(f"{verb} these {len(candidates)} session(s) ({_span})? [y/N] "):
print("Cancelled.")
return
if not args.yes and (refused := _confirm_or_exit_code(
f"{verb} these {len(candidates)} session(s) ({_span})? [y/N] ")) is not None:
return refused
if prune and getattr(args, "owner_endpoint", None) is not None:
# The live gateway owns the store: it deletes, through the housekeeping retirement path.
from hermes_cli.gateway_client import GatewayClientError
@@ -962,7 +991,7 @@ def _cmd_optimize(db, args):
n = db.vacuum() # merges FTS5 segments then VACUUMs; returns indexes merged
except Exception as e:
print(f"Error: optimization failed: {e}")
return
return 1
print(f"Optimized {n} FTS index(es).")
_print_size_change(db, before_mb)
@@ -1059,14 +1088,8 @@ def _cmd_optimize_storage(db, args):
if before_mb > 500:
print(" This may take a while on a large database. It runs in the foreground with progress below; "
"safe to Ctrl-C and re-run (it resumes).")
if not getattr(args, "yes", False):
try:
resp = input("Proceed? [y/N] ").strip().lower()
except EOFError:
resp = ""
if resp not in ("y", "yes"):
print("Cancelled.")
return
if not getattr(args, "yes", False) and (refused := _confirm_or_exit_code("Proceed? [y/N] ")) is not None:
return refused
# optimize_fts_storage re-scans for holders itself (--force past one, or a writer that opens the
# store mid-run) and paces while one is there; the progress line says so.
progress = _OptimizeProgress()
@@ -1115,9 +1138,10 @@ def _cmd_repair_routing(db, args):
"Re-run with --apply to perform them.")
return
print("\nStop the gateway before applying — a running gateway still holds the old routing mapping in memory.")
if not _confirm_prompt(f"Adopt {len(adoptable)} orphaned session(s)? [y/N] "):
print("Aborted — nothing was changed.")
return
refused = _confirm_or_exit_code(f"Adopt {len(adoptable)} orphaned session(s)? [y/N] ",
declined="Aborted — nothing was changed.", unattended=None)
if refused is not None:
return refused
repaired = 0
for record in adoptable:
if db.adopt_orphaned_gateway_session(record["orphan_id"], record["donor_id"]):
@@ -1263,9 +1287,10 @@ def _cmd_repair_prompts(db, args):
return 0
# JSON mode is the non-interactive automation surface; --apply is the explicit mutation opt-in.
if not as_json and not _confirm_prompt(f"Clear {len(findings)} stored prompt(s)? [y/N] "):
print("Aborted - nothing was changed.")
return 0
if not as_json and (refused := _confirm_or_exit_code(
f"Clear {len(findings)} stored prompt(s)? [y/N] ", declined="Aborted - nothing was changed.",
unattended="--json")) is not None:
return refused
cleared = []
for finding in findings:
+6 -4
View File
@@ -88,10 +88,12 @@ def cmd_repair_profiles(args) -> int:
return 0
if not getattr(args, "yes", False) and not as_json:
from hermes_cli.sessions_cmd import _confirm_prompt
if not _confirm_prompt(f"\nApply {len(repairable)} repair(s)? A snapshot of every store is taken first. [y/N] "):
print("Aborted — nothing was changed.")
return 0
from hermes_cli.sessions_cmd import _confirm_or_exit_code
refused = _confirm_or_exit_code(
f"\nApply {len(repairable)} repair(s)? A snapshot of every store is taken first. [y/N] ",
declined="Aborted — nothing was changed.")
if refused is not None:
return refused
result = plan.apply(session, snapshot=default_snapshot)
finally:
session.close()
+1 -1
View File
@@ -136,7 +136,7 @@ def _run_import(source: ImportSource, hermes_home: Path) -> bool:
selector = [] if hermes_home.resolve().parent.name == "profiles" else ["--profile", "default"]
# --from-setup: the plugin's import speaks as a step of this setup (no "run hermes setup" pointers,
# OpenClaw's skipped items folded into one line).
argv = [sys.executable, "-m", "hermes_cli.main", *selector, "import-agent", source.name, *source.extra_args,
argv = [sys.executable, "-P", "-m", "hermes_cli.main", *selector, "import-agent", source.name, *source.extra_args,
"--from-setup"]
env = served_profile_child_env(target_home=hermes_home)
if os.environ.get("PYTHONPATH"): # this same interpreter running Hermes: keep the source tree
+1 -1
View File
@@ -616,7 +616,7 @@ def _uninstall_profile(profile) -> None:
log_info(f"Uninstalling profile '{name}'...")
# 1. Gateway service, via `python -m hermes_cli.main` (the `hermes` wrapper may be half-gone).
hermes_invocation = [sys.executable, "-m", "hermes_cli.main", "--profile", name]
hermes_invocation = [sys.executable, "-P", "-m", "hermes_cli.main", "--profile", name]
for subcmd in ("stop", "uninstall"):
try:
subprocess.run(
+8
View File
@@ -267,6 +267,14 @@ class SessionGatewayMixin:
query_params,
)
if ancestors:
# An explicit repoint moved these rows to this lane: the local conversation whose receipt
# names one of them goes with it in the same write, or it would match neither lane (#143286).
from gateway.session_route_recovery import _messaging_platform
from hermes_state_local import hand_off_conversations, moved_receipts
moved = {row[0] for row in conn.execute(
f"{_COMPRESSION_LINEAGE_CTE} SELECT id FROM compression_lineage", (session_id,))}
hand_off_conversations(conn, {owner: found for owner, found in moved_receipts(conn, moved).items()
if _messaging_platform(found[1]) is not None})
return
# The UPDATE silently no-ops on a missing row — insert it with full identity.
if conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone() is None:
+98
View File
@@ -167,6 +167,104 @@ def retire_local_receipts(conn, session_ids):
conn.executemany('DELETE FROM state_meta WHERE key=?', [(key,) for key in gone])
def moved_receipts(conn, target_ids=None):
"""``{owner id: (moved row id, its source)}`` of local receipts whose current physical target row
now carries another lane's route. An explicit repoint (``/handoff``, a platform ``/resume``)
rewrote that row's routing identity, so the receipt can never restore the conversation again
(the stored identity check in ``gateway.session_local_recovery._checked_entry``). Only the
current target counts: an earlier ``/new`` segment that moved leaves the conversation the
receipt guards local, and releasing it would strand that transcript. *target_ids* narrows the
scan to the rows one repoint rewrote."""
rows = conn.execute(
"SELECT substr(m.key, ?), s.id, s.source FROM state_meta m JOIN sessions s "
"ON s.id = CASE WHEN json_valid(m.value) THEN json_extract(m.value, '$.entry.session_id') END "
"WHERE m.key GLOB ? AND s.session_key IS NOT NULL "
"AND s.session_key IS NOT CASE WHEN json_valid(m.value) THEN json_extract(m.value, '$.route') END",
(len(POLICY_PREFIX) + 1, POLICY_PREFIX + '*')).fetchall()
return {owner: (target, source) for owner, target, source in rows
if target_ids is None or target in target_ids}
def release_receipts(conn, owners):
"""Retire *owners*' local receipts (creation policy and legacy binding): the row they guarded now
belongs to a messaging lane, which restores it from its route like any other platform session."""
from hermes_state_local_migration import LEGACY_PREFIX
conn.executemany('DELETE FROM state_meta WHERE key IN (?,?)',
[(POLICY_PREFIX + owner, LEGACY_PREFIX + owner) for owner in owners])
def handoff_head(db, session_id):
"""``(local owner or None, row)`` a ``/handoff`` of *session_id* moves: the conversation the user
is looking at. A local conversation continues from its receipt's current target (its latest
``/new`` segment, then that segment's compression tip), never from an earlier segment; any other
row from its compression tip."""
with db._read_ctx() as conn:
owner = local_lineage_owner(conn, session_id)
saved = conn.execute('SELECT value FROM state_meta WHERE key=?', (POLICY_PREFIX + owner,)).fetchone()
target = _policy_target(saved[0]) if saved else None
start = target or session_id
return (owner if target else None), db.get_compression_tip(start) or start
_LANE_IDENTITY = 'session_key, source, user_id, chat_id, chat_type, thread_id, display_name, origin_json, transport_profile'
def hand_off_conversations(conn, moved):
"""Retire the receipts in *moved* (``moved_receipts``: a messaging lane took over each one's
current target) and move the rest of each conversation onto that lane with it. Its earlier
``/new`` segments take the lane's identity, so they open there as read-only history (a lane
moved past them) instead of staying local rows no receipt restores; the conversation's name
follows its head, which the lane continues as its own conversation. Returns the receipts it
handed off: one whose lineage is malformed stays as it is (its own reads refuse it, logged)."""
handed = {}
for owner, (target, source) in moved.items():
saved = conn.execute('SELECT value FROM state_meta WHERE key=?', (POLICY_PREFIX + owner,)).fetchone()
lineage = (_receipt(saved[0]) or {}).get('lineage', [owner]) if saved else [owner]
if not isinstance(lineage, list) or not all(isinstance(sid, str) for sid in lineage):
continue
segments = list(dict.fromkeys([owner, *lineage]))
conn.execute(f'UPDATE sessions SET ({_LANE_IDENTITY}) = (SELECT {_LANE_IDENTITY} FROM sessions WHERE id=?) '
'WHERE id IN (SELECT value FROM json_each(?)) AND id != ?', (target, json.dumps(segments), target))
_title_follows_head(conn, owner, _segment_root(conn, segments, target))
handed[owner] = (target, source)
release_receipts(conn, handed)
return handed
def _receipt(raw):
try:
value = json.loads(raw)
except (TypeError, ValueError):
return None
return value if isinstance(value, dict) else None
def _segment_root(conn, segments, target):
"""The first row of *target*'s ``/new`` segment: back through the compressions behind it."""
if target not in segments:
return target
index = segments.index(target)
while index > 0 and (row := conn.execute('SELECT end_reason FROM sessions WHERE id=?',
(segments[index - 1],)).fetchone()) and row[0] == 'compression':
index -= 1
return segments[index]
def _title_follows_head(conn, owner, head):
"""The conversation's name (a Bot Chat's registry title included) moves to its head, over a
title of lower provenance there (a generated one); a name of equal rank the head carries stays
(``SessionDB._title_rank``: a pre-provenance title ranks as the user's)."""
from hermes_state import SessionDB
row = conn.execute('SELECT title, title_source, hidden FROM sessions WHERE id=?', (owner,)).fetchone()
if head == owner or row is None or not row[0]:
return
kept = conn.execute('SELECT title, title_source FROM sessions WHERE id=?', (head,)).fetchone()
if kept is None or (kept[0] and SessionDB._title_rank(kept[1]) >= SessionDB._title_rank(row[1])):
return
conn.execute('UPDATE sessions SET title=NULL WHERE id IN (?,?)', (owner, head))
conn.execute('UPDATE sessions SET title=?, title_source=?, hidden=? WHERE id=?', (*row, head))
def local_lineage_owner(conn, session_id):
"""The creation id whose receipt lineage holds physical segment *session_id* (a reset or
compression child, which carries the owner as ``chat_id``); *session_id* itself otherwise."""
+8 -1
View File
@@ -131,7 +131,8 @@ class SessionPruneRewriteMixin:
return mismatches
def rewrite_pruned_rows(self, session_id: str, changes: list[tuple[dict[str, Any], dict[str, Any]]], *,
model_config_patch: Optional[dict[str, Any]] = None) -> int:
model_config_patch: Optional[dict[str, Any]] = None, turn_lease_holder: Optional[str] = None,
turn_lease_ttl_seconds: float = 300.0) -> int:
"""Proactive-prune commit that rewrites only the changed rows, in place (#124102).
``archive_and_compact`` is a generation writer: it archives every active row and re-inserts the whole
@@ -155,6 +156,8 @@ class SessionPruneRewriteMixin:
when the held dict carries the digest — it covers every owned column including assistant
``tool_calls`` arguments — and the representation-safe loader-lens identity compare otherwise
(raw storage bytes are never compared against a loaded/normalized view; #124102).
``turn_lease_holder`` (a writer inside a turn) refuses with :class:`SessionTurnLeaseLostError`, before
writing anything, when another process owns the session's turn lease, as ``append_message`` does.
Returns the rows rewritten. Each rewritten dict is stamped with its row's new digest, so a second
prune of the same transcript compares against what this commit wrote, not what it replaced.
"""
@@ -206,6 +209,10 @@ class SessionPruneRewriteMixin:
return row
def _do(conn):
if turn_lease_holder:
# A writer inside a turn commits only while it still owns the turn, like its appends.
self._check_transcript_write_guards(conn, session_id, None, turn_lease_holder=turn_lease_holder,
turn_lease_ttl_seconds=turn_lease_ttl_seconds)
patch = model_config_patch is not None
patched_model_config = self._merge_model_config_json(
conn, session_id, model_config_patch, on_missing="raise") if patch else None
@@ -0,0 +1,30 @@
name: elevenlabs-sound-effects
repo: https://github.com/NousResearch/hermes-official-plugins
sha: e4e40398c4d46f86dba3bbdcbfa5ef02d2675988
subdir: elevenlabs-sound-effects
title: ElevenLabs Sound Effects
description: 'ElevenLabs sound effects for Hermes: one tool, elevenlabs_sound_effect, that turns a text
description ("glass shattering on a tile floor", "90s hip-hop drum loop, 90 BPM") into an audio file,
0.5 to 30 seconds, optionally a seamless loop, and sends it to you in chat. Bundles a sound-design skill
with a local ffmpeg script (trim, loudness, mono, loop fixing, game variation packs) and, in Hermes Desktop,
an inline sound card and a sound library tab. Model, audio format and endpoint are plugin settings.
Disclosure: uses your ElevenLabs API key (ELEVENLABS_API_KEY, the same key as Hermes''s ElevenLabs voice;
a restricted key needs the Sound Effects permission); every call is a paid generation billed to your
ElevenLabs account. Only the sound description and its parameters are sent, to api.elevenlabs.io (or the
data-residency endpoint you set). Files are written to cache/generated/audio under the Hermes home and
never overwritten. One Python dependency (httpx, already part of Hermes).'
maintainer: NousResearch
tier: official
category: tools
requires_hermes: ">=0.22.0"
version: "1.2.0"
image: https://raw.githubusercontent.com/NousResearch/hermes-official-plugins/e4e40398c4d46f86dba3bbdcbfa5ef02d2675988/assets/elevenlabs-sound-effects.jpg
docs_url: https://github.com/NousResearch/hermes-official-plugins/tree/main/elevenlabs-sound-effects
platforms: []
capabilities:
provides_tools:
- elevenlabs_sound_effect
provides_hooks: []
provides_middleware: []
requires_env:
- ELEVENLABS_API_KEY
+1 -1
View File
@@ -101,7 +101,7 @@ def start(url: str, *, out_dir: Optional[Path] = None, headed: bool = False,
log_path = out / "bot.log"
# Detach: stdout/stderr → log file, new session so parent signals don't propagate.
with open(log_path, "ab", buffering=0) as log_fh:
proc = subprocess.Popen([sys.executable, "-m", "plugins.google_meet.meet_bot"], stdin=subprocess.DEVNULL,
proc = subprocess.Popen([sys.executable, "-P", "-m", "plugins.google_meet.meet_bot"], stdin=subprocess.DEVNULL,
stdout=log_fh, stderr=subprocess.STDOUT, env=env, start_new_session=True,
close_fds=True)
record = {"pid": proc.pid, "meeting_id": meeting_id, "out_dir": str(out), "url": url,
+4 -4
View File
@@ -1225,6 +1225,7 @@ class DiscordAdapter(DiscordAuthorizationMixin, DiscordMediaMixin, DiscordThread
MAX_MESSAGE_LENGTH = 2000
_SPLIT_THRESHOLD = 1900 # near the 2000-char split point
supports_code_blocks = True # Discord markdown renders fenced code blocks natively
allow_from_covers_groups = True # no group_allow_from: allow_from is the one user allowlist
splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)
# Safety ceiling on split deliveries: chunks beyond the cap become a notice (degenerate turns).
# Safety ceiling on split deliveries (#86581): a degenerate turn can produce tens of thousands of
@@ -5046,11 +5047,10 @@ class DiscordAdapter(DiscordAuthorizationMixin, DiscordMediaMixin, DiscordThread
def _component_live_auth(self, interaction) -> Optional[bool]:
"""The gateway's live allowlist verdict for a component click (None when no check is wired):
an out-of-process revoke never reaches the connect-time ``_allowed_user_ids`` snapshot."""
from plugins.platforms.discord.adapter_component_auth import component_chat_context
user_id = str(getattr(getattr(interaction, "user", None), "id", "") or "")
channel_id = getattr(interaction, "channel_id", None)
chat_type = "dm" if getattr(interaction, "guild", None) is None else "group"
return self._is_sender_authorized(
user_id, chat_type, str(channel_id) if channel_id is not None else None)
chat_type, chat_id, thread_id = component_chat_context(interaction, discord.Thread)
return self._is_sender_authorized(user_id, chat_type, chat_id, thread_id=thread_id)
def resolved_allowlist_user_ids(self) -> set:
"""Numeric IDs from connect-time username resolution.
@@ -6,6 +6,18 @@ from typing import Any, Callable, Optional
from gateway.platforms._shared import platform_gate_env as _scoped_gate_env
def component_chat_context(interaction, thread_cls) -> tuple[str, Optional[str], Optional[str]]:
"""``(chat_type, chat_id, thread_id)`` of a click, labelled like message intake labels the
same channel: a click in a thread gets the thread's verdict, not a channel's."""
channel_id = getattr(interaction, "channel_id", None)
chat_id = str(channel_id) if channel_id is not None else None
if getattr(interaction, "guild", None) is None:
return "dm", chat_id, None
if isinstance(getattr(interaction, "channel", None), thread_cls):
return "thread", chat_id, chat_id
return "group", chat_id, None
def _component_check_auth(
interaction, allowed_user_ids: Optional[set], allowed_role_ids: Optional[set],
live_auth: Optional[Callable[[Any], Optional[bool]]] = None,
+4
View File
@@ -931,6 +931,8 @@ class AIAgent(
_quietly(self._close_codex_session)
if getattr(self, "_memory_manager", None) is not None:
_quietly(self._memory_manager.release_soft)
from agent.prepared_compaction import release_for_agent
_quietly(release_for_agent, self)
def close(self) -> None:
"""Release every resource this agent holds (idempotent); each phase is guarded so one failure never
@@ -939,6 +941,8 @@ class AIAgent(
_quietly(self.shutdown_memory_provider, session_messages if isinstance(session_messages, list) else None)
self._close_task_resources(getattr(self, "session_id", None) or "")
self._close_active_children(soft=False)
from agent.prepared_compaction import release_for_agent
_quietly(release_for_agent, self)
_quietly(self._drop_shared_client, lambda c: self._close_openai_client(c, reason="agent_close", shared=True))
self._close_request_clients("agent_close")
_quietly(self._close_codex_session)
+162 -77
View File
@@ -12,6 +12,7 @@ import sys
import tempfile
import threading
import time
from contextlib import closing
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
@@ -255,6 +256,31 @@ def _arguments() -> argparse.Namespace:
return parser.parse_args()
def _stop_auto_started_gateway(home: Path, timeout: float = 60.0) -> None:
"""Stop the gateway the one-shot chat auto-started for ``home`` and wait for it to exit.
Left alone it lingers for ``gateway.unmanaged_idle_exit_seconds`` after every smoke run. Only the
live process the gateway's own identity check proves is ``home``'s gateway (start time, gateway
command line, home) is signalled: a stale ``gateway.pid`` whose PID was recycled is left alone.
"""
import psutil
from gateway.status import live_gateway_pid_for_home
pid = live_gateway_pid_for_home(home)
if pid is None:
return
try:
gateway = psutil.Process(pid) # binds the create time: terminate() refuses a reused PID
gateway.terminate()
gateway.wait(timeout=timeout)
except psutil.NoSuchProcess:
return
except psutil.TimeoutExpired as exc:
raise AssertionError(
f"Auto-started gateway {pid} did not exit within {timeout:.0f}s"
) from exc
def _write_config(home: Path, port: int) -> None:
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
@@ -276,8 +302,21 @@ telemetry:
# ---- iuf c1 ----
# The final receipt of an update whose fetch failed before the checkout moved: the plan and snapshot
# stages carry end marks, the apply stage it died in carries none.
UPDATE_RECEIPT: dict[str, Any] = {
"schema": 1, "update_id": "0123456789abcdef",
"started_at": "2026-10-06T10:00:00+00:00", "finished_at": "2026-10-06T10:00:01+00:00",
"outcome": "failed", "exit_code": 1, "stop_reason": "sys.exit(1)", "pre_update": {},
"stop_class": "fetch_failed",
"stages": [
{"name": "plan", "outcome": "success", "at": "2026-10-06T10:00:00+00:00"},
{"name": "snapshot", "outcome": "skipped", "at": "2026-10-06T10:00:00+00:00"},
],
"steps": [], "fleet": [],
}
# The offline probes in the skill-lifecycle subprocess: one failed URL plugin install and one failed
# update run (its fetch failed before the checkout moved), identical in SQLite and in the export.
# update run (UPDATE_RECEIPT), identical in SQLite and in the export.
FAILURE_EXPECTED_DIMENSIONS = {
"hermes.extension.install.count": {
"failure_class": "clone_failed", "kind": "plugin", "name": "custom", "outcome": "failed",
@@ -291,12 +330,24 @@ FAILURE_EXPECTED_DIMENSIONS = {
}
def _validate_failure_rows(rows: dict[str, list[dict[str, Any]]]) -> None:
for name, dimensions in FAILURE_EXPECTED_DIMENSIONS.items():
def expected_update_stages() -> list[tuple[str, str]]:
"""One row per marked stage of UPDATE_RECEIPT, plus a failed row for the stage the run died in:
stage marks are end marks, so the apply stage the fetch failure stopped has none of its own."""
marked = [(stage["name"], stage["outcome"]) for stage in UPDATE_RECEIPT["stages"]]
died_in = FAILURE_EXPECTED_DIMENSIONS["hermes.update.run"]["failed_stage"]
return sorted([*marked, (died_in, "failed")])
def _validate_exact_rows(rows: dict[str, list[dict[str, Any]]], expected: dict[str, dict[str, str]]) -> None:
for name, dimensions in expected.items():
if [row["dimensions"] for row in rows[name]] != [dimensions] or rows[name][0]["value"] != 1:
raise AssertionError(f"Unexpected {name}: {rows[name]}")
def _validate_failure_rows(rows: dict[str, list[dict[str, Any]]]) -> None:
_validate_exact_rows(rows, FAILURE_EXPECTED_DIMENSIONS)
stages = sorted((row["dimensions"]["stage"], row["dimensions"]["outcome"]) for row in rows["hermes.update.stage"])
if stages != [("plan", "success"), ("snapshot", "skipped")]:
if stages != expected_update_stages():
raise AssertionError(f"Unexpected update stages: {rows['hermes.update.stage']}")
# ---- end iuf c1 ----
@@ -315,9 +366,7 @@ V5_EXPECTED_DIMENSIONS = {
def _validate_v5_rows(rows: dict[str, list[dict[str, Any]]]) -> None:
for name, dimensions in V5_EXPECTED_DIMENSIONS.items():
if [row["dimensions"] for row in rows[name]] != [dimensions] or rows[name][0]["value"] != 1:
raise AssertionError(f"Unexpected {name}: {rows[name]}")
_validate_exact_rows(rows, V5_EXPECTED_DIMENSIONS)
[overhead] = rows["hermes.tool_overhead.count"] # the file toolset's 4 tools, schema tokens estimated
if (
overhead["value"] != 1
@@ -328,10 +377,65 @@ def _validate_v5_rows(rows: dict[str, list[dict[str, Any]]]) -> None:
raise AssertionError(f"Unexpected tool overhead: {overhead}")
def _validate_store(database_path: Path) -> list[dict[str, Any]]:
if not database_path.is_file():
raise AssertionError(f"Metrics database was not created: {database_path}")
with sqlite3.connect(database_path) as connection:
# A one-shot chat is served by the gateway it auto-starts for the home (unified gateway runtime): that
# gateway reports its own boot latency and the local platform's delivery and first-reply latency for
# the turn. The turn's single read_file call is one tool batch.
GATEWAY_TURN_EXPECTED_DIMENSIONS = {
"hermes.platform.delivery": {"failure_class": "none", "outcome": "sent", "platform": "local"},
"hermes.tool_batch.count": {"connector_share": "none", "model": "custom", "provider": "custom", "size_bucket": "1"},
}
# Each process that launched once reports one startup row: the chat client and the gateway it started.
EXPECTED_STARTUP_SURFACES = ("cli", "gateway_boot")
def _validate_gateway_turn_rows(rows: dict[str, list[dict[str, Any]]]) -> None:
_validate_exact_rows(rows, GATEWAY_TURN_EXPECTED_DIMENSIONS)
replies = rows["hermes.gateway.reply_latency"] # the bucket is whatever the host's load made it
if any(row["dimensions"]["platform"] != "local" for row in replies) or sum(row["value"] for row in replies) != 1:
raise AssertionError(f"Unexpected gateway reply latency: {replies}")
startup = rows["hermes.startup.latency"]
if sorted(row["dimensions"]["surface"] for row in startup) != sorted(EXPECTED_STARTUP_SURFACES) or any(
row["value"] != 1 for row in startup
):
raise AssertionError(f"Unexpected startup latency: {startup}")
# The metric names the scenario must produce, exactly, in SQLite and in the export.
EXPECTED_METRIC_NAMES = frozenset({
"hermes.client.active",
"hermes.context_peak.count",
"hermes.extension.install.count", # iuf c1
"hermes.gateway.reply_latency",
"hermes.install.milestone",
"hermes.install.snapshot",
"hermes.model_reply_issue.count",
"hermes.model_route.count",
"hermes.model_tokens.sum",
"hermes.model_tool_quality.count",
"hermes.platform.delivery",
"hermes.session.count",
"hermes.skill.lifecycle.count",
"hermes.skill.load.count",
"hermes.startup.latency",
"hermes.task_cost.count",
"hermes.task_run.duration",
"hermes.task_run.finished",
"hermes.task_run.started",
"hermes.tool.usage.count",
"hermes.tool_batch.count",
"hermes.tool_call.count",
"hermes.tool_call.latency",
"hermes.tool_enabled_unused.count",
"hermes.tool_output_truncation.count",
"hermes.tool_overhead.count",
"hermes.update.run", # iuf c1
"hermes.update.stage", # iuf c1
})
def _read_counters(database_path: Path) -> list[dict[str, Any]]:
# Read-only: polling must never create the store before the gateway does.
with closing(sqlite3.connect(f"{database_path.as_uri()}?mode=ro", uri=True)) as connection:
rows = connection.execute(
"""
SELECT metric_name, dimensions_json, value, packaged_value
@@ -339,7 +443,7 @@ def _validate_store(database_path: Path) -> list[dict[str, Any]]:
ORDER BY metric_name, dimensions_json
"""
).fetchall()
counters = [
return [
{
"name": name,
"dimensions": json.loads(dimensions),
@@ -348,36 +452,39 @@ def _validate_store(database_path: Path) -> list[dict[str, Any]]:
}
for name, dimensions, value, packaged_value in rows
]
def _by_name(counters: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
by_name: dict[str, list[dict[str, Any]]] = {}
for counter in counters:
by_name.setdefault(counter["name"], []).append(counter)
if set(by_name) != {
"hermes.client.active",
"hermes.context_peak.count",
"hermes.extension.install.count", # iuf c1
"hermes.install.milestone",
"hermes.install.snapshot",
"hermes.model_reply_issue.count",
"hermes.model_route.count",
"hermes.model_tokens.sum",
"hermes.model_tool_quality.count",
"hermes.session.count",
"hermes.skill.lifecycle.count",
"hermes.skill.load.count",
"hermes.startup.latency",
"hermes.task_cost.count",
"hermes.task_run.duration",
"hermes.task_run.finished",
"hermes.task_run.started",
"hermes.tool.usage.count",
"hermes.tool_call.count",
"hermes.tool_call.latency",
"hermes.tool_enabled_unused.count",
"hermes.tool_output_truncation.count",
"hermes.tool_overhead.count",
"hermes.update.run", # iuf c1
"hermes.update.stage", # iuf c1
}:
return by_name
def _wait_for_gateway_turn_rows(database_path: Path, timeout: float = 30.0) -> bool:
"""Poll the probe home's store until the auto-started gateway's turn rows have landed.
The gateway records them after the reply reached the chat client, and stopping it does not
settle them: its graceful exit ends in ``os._exit``, which skips the shared-metrics flush. So the
smoke waits for the rows before it stops the gateway. False when ``timeout`` passed first.
"""
deadline = time.monotonic() + timeout
while True:
try:
_validate_gateway_turn_rows(_by_name(_read_counters(database_path)))
return True
except (sqlite3.Error, KeyError, AssertionError):
if time.monotonic() >= deadline:
return False
time.sleep(0.25)
def _validate_store(database_path: Path) -> list[dict[str, Any]]:
if not database_path.is_file():
raise AssertionError(f"Metrics database was not created: {database_path}")
counters = _read_counters(database_path)
by_name = _by_name(counters)
if set(by_name) != EXPECTED_METRIC_NAMES:
raise AssertionError(
f"Unexpected SQLite counters:\n{json.dumps(counters, indent=2)}"
)
@@ -450,7 +557,11 @@ def _validate_store(database_path: Path) -> list[dict[str, Any]]:
]:
raise AssertionError(f"Unexpected tool usage: {by_name['hermes.tool.usage.count']}")
[snapshot] = by_name["hermes.install.snapshot"]
if snapshot["value"] != 1 or snapshot["dimensions"]["memory_provider"] != "builtin":
if (
snapshot["value"] != 1 or snapshot["dimensions"]["memory_provider"] != "builtin"
# The smoke config leaves compression.prepare_ahead at its default.
or snapshot["dimensions"]["compression_prepare_ahead"] != "off"
):
raise AssertionError(f"Unexpected install snapshot: {snapshot}")
[session] = by_name["hermes.session.count"]
if (session["dimensions"]["turn_count_bucket"], session["dimensions"]["last_outcome"]) != (
@@ -471,9 +582,6 @@ def _validate_store(database_path: Path) -> list[dict[str, Any]]:
"custom", "custom", "no",
):
raise AssertionError(f"Unexpected context peak: {peak}")
[startup] = by_name["hermes.startup.latency"]
if startup["dimensions"]["surface"] != "cli":
raise AssertionError(f"Unexpected startup latency: {startup}")
milestones = {c["dimensions"]["milestone"] for c in by_name["hermes.install.milestone"]}
if not {"first_task_started", "first_task_success", "first_tool_success"} <= milestones:
raise AssertionError(f"Missing install milestones: {sorted(milestones)}")
@@ -527,6 +635,7 @@ def _validate_store(database_path: Path) -> list[dict[str, Any]]:
):
raise AssertionError(f"Unexpected skill load counters: {loads}")
_validate_v5_rows(by_name)
_validate_gateway_turn_rows(by_name)
_validate_failure_rows(by_name) # iuf c1
return counters
@@ -579,33 +688,7 @@ def _validate_packages(
for package in packages:
for metric in package.get("metrics", []):
metrics.setdefault(metric["name"], []).append(metric)
if set(metrics) != {
"hermes.client.active",
"hermes.context_peak.count",
"hermes.extension.install.count", # iuf c1
"hermes.install.milestone",
"hermes.install.snapshot",
"hermes.model_reply_issue.count",
"hermes.model_route.count",
"hermes.model_tokens.sum",
"hermes.model_tool_quality.count",
"hermes.session.count",
"hermes.skill.lifecycle.count",
"hermes.skill.load.count",
"hermes.startup.latency",
"hermes.task_cost.count",
"hermes.task_run.duration",
"hermes.task_run.finished",
"hermes.task_run.started",
"hermes.tool.usage.count",
"hermes.tool_call.count",
"hermes.tool_call.latency",
"hermes.tool_enabled_unused.count",
"hermes.tool_output_truncation.count",
"hermes.tool_overhead.count",
"hermes.update.run", # iuf c1
"hermes.update.stage", # iuf c1
}:
if set(metrics) != EXPECTED_METRIC_NAMES:
raise AssertionError(
f"Unexpected package metrics:\n{json.dumps(metrics, indent=2)}"
)
@@ -675,6 +758,7 @@ def _validate_packages(
}:
raise AssertionError(f"Unexpected skill load metrics: {loads}")
_validate_v5_rows(metrics)
_validate_gateway_turn_rows(metrics)
_validate_failure_rows(metrics) # iuf c1
return package_paths, packages
@@ -682,6 +766,9 @@ def _validate_packages(
def main() -> int:
args = _arguments()
hermes_repo = args.hermes_repo.resolve()
# The gateway identity check must come from the tree under test, not whatever tree the venv's
# editable install points at.
sys.path.insert(0, str(hermes_repo))
relay_python = args.relay_python.resolve() if args.relay_python else None
hermes = _resolve_hermes_executable(hermes_repo)
if relay_python is not None and not any(
@@ -770,6 +857,10 @@ def main() -> int:
server.shutdown()
server.server_close()
thread.join(timeout=5)
metrics_db = home / "telemetry" / "shared_metrics" / "metrics.sqlite3"
if not _wait_for_gateway_turn_rows(metrics_db):
print(f"Gateway turn rows did not reach {metrics_db} in time", file=sys.stderr)
_stop_auto_started_gateway(home)
(root / "hermes.stdout.txt").write_text(result.stdout, encoding="utf-8")
(root / "hermes.stderr.txt").write_text(result.stderr, encoding="utf-8")
@@ -829,13 +920,7 @@ def main() -> int:
"except PluginOperationError:",
" pass",
"from hermes_cli.observability.shared_metrics_update import record_update_receipt",
"record_update_receipt({'schema': 1, 'update_id': '0123456789abcdef',",
" 'started_at': '2026-10-06T10:00:00+00:00', 'finished_at': '2026-10-06T10:00:01+00:00',",
" 'outcome': 'failed', 'exit_code': 1, 'stop_reason': 'sys.exit(1)', 'pre_update': {},",
" 'stop_class': 'fetch_failed',",
" 'stages': [{'name': 'plan', 'outcome': 'success', 'at': '2026-10-06T10:00:00+00:00'},",
" {'name': 'snapshot', 'outcome': 'skipped', 'at': '2026-10-06T10:00:00+00:00'}],",
" 'steps': [], 'fleet': []})",
f"record_update_receipt({UPDATE_RECEIPT!r})",
"runtime = relay_shared_metrics._get_runtime()",
"assert runtime is not None",
"runtime.shutdown()",
@@ -0,0 +1,95 @@
"""A hard cancel (/stop, session boundary) of a compression summary closes the provider stream.
The owner unwinds at once, but the streamed summary runs in an isolated daemon whose chunk consumer only
checked the total ceiling and the host deadline, so the provider stream kept generating (and billing) until
that deadline, up to ten minutes later. It must close at the next chunk. Real HTTP path against a loopback
server that trickles a chunk every 50 ms, with a temp ``HERMES_HOME``.
"""
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
import agent.auxiliary_client as aux
def _chunk(text, finish=None):
payload = {"id": "c", "object": "chat.completion.chunk", "created": 1, "model": "m",
"choices": [{"index": 0, "delta": {"content": text} if text else {}, "finish_reason": finish}]}
return f"data: {json.dumps(payload)}\n\n".encode()
def _serve(handler):
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
thread.start()
return server, thread
def _stop(server, thread):
aux.shutdown_cached_clients()
server.shutdown()
server.server_close()
thread.join(timeout=5)
def _write_config(home, config):
(home / "config.yaml").write_text(json.dumps(config), encoding="utf-8")
@pytest.fixture
def trickle_endpoint():
"""Streams one content chunk every 50 ms for up to 30 s; records when the client hangs up."""
sent_some, client_closed = threading.Event(), threading.Event()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_args):
pass
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length") or 0))
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Connection", "close")
self.end_headers()
try:
for n in range(600):
self.wfile.write(_chunk("word "))
self.wfile.flush()
if n == 3:
sent_some.set()
time.sleep(0.05) # health: allow HX013 -- the trickle pace is the slow provider under test
except OSError:
client_closed.set()
self.close_connection = True
aux.shutdown_cached_clients()
aux._reset_aux_unhealthy_cache()
server, thread = _serve(Handler)
try:
yield f"http://127.0.0.1:{server.server_port}/v1", sent_some, client_closed
finally:
_stop(server, thread)
def test_hard_cancel_closes_the_streamed_summary_at_its_next_chunk(tmp_path, monkeypatch, trickle_endpoint):
url, sent_some, client_closed = trickle_endpoint
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost")
_write_config(tmp_path, {"auxiliary": {"compression": {
"provider": "custom", "base_url": url, "api_key": "dummy", "model": "slow-summarizer"}}})
stop = threading.Event()
threading.Thread(target=lambda: sent_some.wait(10) and stop.set(), daemon=True).start()
# The compression attempt's seams: a progress hook (streams the call) and the hard-cancel source.
with aux.aux_progress_hook(lambda: None), aux.aux_interrupt_protection(cancel_check=stop.is_set):
with pytest.raises(aux.AuxiliaryExplicitCancellation):
aux.call_llm(task="compression", messages=[{"role": "user", "content": "Summarize."}], timeout=20)
assert stop.is_set()
assert client_closed.wait(5), "the cancelled summary stream kept streaming after the hard stop"
@@ -24,7 +24,7 @@ from hermes_constants import get_hermes_home
from hermes_state import SessionDB
from run_agent import AIAgent
from agent.agent_init import (
from agent.agent_init_compression_notice import (
_codex_gpt55_autoraise_notice_marker,
_codex_gpt55_autoraise_notice_seen,
_record_codex_gpt55_autoraise_notice,
@@ -0,0 +1,126 @@
"""A compression summary that ``call_llm`` recovered on a fallback route is reported as a fallback.
When ``auxiliary.compression`` points at a dead endpoint, ``call_llm``'s recovery ladder answers the summary
with the main model. The compressor saw a plain success: no user warning, and telemetry
``method=llm_summary fallback_used=false``. Real HTTP path (closed port + loopback main model, temp ``HERMES_HOME``).
"""
import json
import socket
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
import agent.auxiliary_client as aux
from agent.context_compressor import ContextCompressor
SUMMARY = "## Goal\nAnswer the numbered questions.\n## Progress\nQuestions answered.\n## Next Steps\nContinue.\n"
def _chunk(text, finish=None):
payload = {"id": "c", "object": "chat.completion.chunk", "created": 1, "model": "m",
"choices": [{"index": 0, "delta": {"content": text} if text else {}, "finish_reason": finish}]}
return f"data: {json.dumps(payload)}\n\n".encode()
def _serve(handler):
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
thread.start()
return server, thread
def _stop(server, thread):
aux.shutdown_cached_clients()
server.shutdown()
server.server_close()
thread.join(timeout=5)
def _write_config(home, config):
(home / "config.yaml").write_text(json.dumps(config), encoding="utf-8")
@pytest.fixture
def main_model_endpoint():
"""The main model: answers every chat completion (streamed or not) with a summary."""
seen = []
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_args):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
seen.append(body.get("model"))
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(_chunk(SUMMARY) + _chunk("", "stop") + b"data: [DONE]\n\n")
self.wfile.flush()
self.close_connection = True
aux.shutdown_cached_clients()
aux._reset_aux_unhealthy_cache()
server, thread = _serve(Handler)
try:
yield f"http://127.0.0.1:{server.server_port}/v1", seen
finally:
_stop(server, thread)
def _closed_port_url():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
return f"http://127.0.0.1:{port}/v1"
def _history():
messages = []
for i in range(30):
messages.append({"role": "user", "content": f"Question {i}: explain topic {i}. " + "lorem ipsum " * 200})
messages.append({"role": "assistant", "content": f"Answer {i}: " + "dolor sit amet " * 200})
return messages
def test_summary_recovered_on_the_main_model_by_call_llm_is_reported(tmp_path, monkeypatch, main_model_endpoint):
main_url, seen = main_model_endpoint
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost")
_write_config(tmp_path, {
"model": {"provider": "custom", "base_url": main_url, "api_key": "dummy", "default": "main-fake",
"context_length": 64000},
"auxiliary": {"compression": {"provider": "custom", "base_url": _closed_port_url(), "api_key": "dummy",
"model": "dead-summarizer", "timeout": 20}},
})
compressor = ContextCompressor(model="main-fake", provider="custom", base_url=main_url, api_key="dummy",
config_context_length=64000, quiet_mode=True)
compressed = compressor.compress(_history(), force=True)
assert len(compressed) < 60 and seen == ["main-fake"] # the main model wrote the summary
telemetry = compressor._last_compression_telemetry
assert telemetry["fallback_used"] is True
assert telemetry["method"] == "aux_fallback_main"
# The user warning (``_warn_summary_or_aux_fallback``) keys on these.
assert compressor._last_aux_model_failure_model == "dead-summarizer"
assert "connection error" in compressor._last_aux_model_failure_error
@pytest.mark.parametrize("label, warns", [("main-agent(custom)", True), ("fallback_chain[0](openrouter)", False)])
def test_only_a_main_model_recovery_names_the_broken_compression_model(label, warns):
compressor = ContextCompressor(model="main-fake", config_context_length=64000, quiet_mode=True)
compressor._active_compression_telemetry = telemetry = {}
# Same model name on both routes: only the route that answered decides the "main model" warning.
compressor._note_aux_route_fallback(
{"provider": "custom", "model": "main-fake", "failed_model": "dead", "failure": "connection error: x",
"fallback_label": label})
assert telemetry["fallback_used"] is True
assert (compressor._last_aux_model_failure_model == "dead") is warns
@@ -0,0 +1,80 @@
"""A handoff row never stays in the protected tail, so it must not decide where the tail cut lands.
The handoff scan folds every row up to the newest in-transcript handoff into the summary window. When the
tail walk charged that handoff, a summary row that alone pushed the transcript over the soft ceiling ended
the walk on itself: the planned window held only the handoff and compress() returned the
``empty_post_handoff_window`` no-op at every trigger (live: four no-ops in a row, prompt 47k -> 66.7k on a
64k window, after a manual compress followed by one automatic compaction). Rolling micro-compaction plans
its window with the same walk, so the same marker stalled it: no exchange after the marker was ever absorbed.
"""
from unittest.mock import patch
from agent.context_compressor import (
COMPRESSED_SUMMARY_METADATA_KEY, MICRO_COMPACT_MARKER_KEY, SUMMARY_PREFIX, ContextCompressor,
_estimate_msg_budget_tokens,
)
def _compressor() -> ContextCompressor:
with patch("agent.context_compressor.get_model_context_length", return_value=64_000):
return ContextCompressor(
model="test-model", quiet_mode=True, config_context_length=64_000, protect_first_n=3,
protect_last_n=20, summary_target_ratio=0.20, threshold_percent=0.50, tail_mode="lean",
)
def _handoff(body_words: int = 1_800, role: str = "user", **meta: bool) -> dict:
return {"role": role, "content": f"{SUMMARY_PREFIX}\n## Goal\n" + " ".join(["earlier"] * body_words),
COMPRESSED_SUMMARY_METADATA_KEY: True, **meta}
def _turns(n: int, words: int = 330) -> list[dict]:
rows = []
for i in range(n):
rows.append({"role": "user", "content": f"question {i} " + " ".join(["ask"] * 40)})
rows.append({"role": "assistant", "content": f"answer {i} " + " ".join(["reply"] * words)})
return rows
def _over_ceiling_only_with(cc: ContextCompressor, handoff: dict, head: list[dict]) -> list[dict]:
"""Rows after the handoff fit the soft ceiling; adding the handoff breaks it."""
soft = cc._tail_soft_ceiling(cc.tail_token_budget)
for n in range(4, 80):
turns = _turns(n)
after = sum(map(_estimate_msg_budget_tokens, turns))
if after <= soft < after + _estimate_msg_budget_tokens(handoff):
return [*head, handoff, *turns]
raise AssertionError("fixture could not straddle the soft ceiling")
def test_leading_handoff_does_not_collapse_the_window_to_itself():
cc = _compressor()
messages = _over_ceiling_only_with(cc, _handoff(), head=[])
with patch.object(cc, "_generate_summary", return_value="NEW SUMMARY BODY"):
result = cc.compress(messages, current_tokens=60_000)
telemetry = cc._last_compression_telemetry or {}
assert telemetry.get("failure_class") is None
assert cc._last_compression_made_progress is True
assert len(result) < len(messages)
assert sum(1 for m in result if cc._is_context_summary_message(m)) == 1
assert result[-1]["content"] == messages[-1]["content"]
def test_rolling_micro_compaction_absorbs_the_exchange_after_its_marker():
cc = _compressor()
cc._micro_compact_enabled = True
# Under the defrag threshold, yet large enough to break the soft ceiling on its own.
marker = _handoff(body_words=500, role="assistant", **{MICRO_COMPACT_MARKER_KEY: True})
head = [{"role": "system", "content": "system prompt"}, {"role": "user", "content": "first request"}]
messages = _over_ceiling_only_with(cc, marker, head=head)
with patch.object(cc, "_micro_summarize_one", return_value="ROLLING SUMMARY"):
result = cc._micro_compact([dict(m) for m in messages])
contents = [str(m.get("content")) for m in result]
assert not any(c.startswith("answer 0 ") for c in contents)
assert any("question 0 " in c for c in contents) # user text is never absorbed
assert sum(1 for m in result if m.get(MICRO_COMPACT_MARKER_KEY)) == 1
assert result[-1]["content"] == messages[-1]["content"]
-107
View File
@@ -1246,113 +1246,6 @@ class TestAuthFailureAborts:
assert c._last_summary_empty_content_failure is True
class TestSustainedOverloadEscalation:
"""#123167: a sustained summary-provider overload must not guarantee a session wipe.
One overload aborts so a retry can still win (#115906). But when every attempt keeps
aborting, the transcript only grows until the session exits compression_exhausted and
the gateway auto-resets — total loss, deferred. After 3 consecutive overload aborts the
overload stops being terminal and compress() commits the deterministic fallback instead
(the same bounded degrade the repeated-stall ladder takes, #112420).
"""
def _err(self):
return StubProviderError(
"Our servers are currently overloaded. Please try again later.",
status_code=503,
)
def _compressor(self, **kwargs):
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test",
quiet_mode=True,
protect_first_n=2,
protect_last_n=2,
**kwargs,
)
c.summary_model = "test/auxiliary"
return c
@pytest.mark.parametrize("access_error", [False, True], ids=["overload", "403_overloaded"])
def test_first_overloads_still_abort_then_third_commits_fallback(self, access_error):
c = self._compressor(abort_on_summary_failure=False)
# A stale terminal flag from one earlier network failure (only a success clears it) must
# not pin the long-lived compressor in abort mode: the latest failure class decides.
c._last_summary_network_failure = True
msgs = self._msgs(12)
err = StubProviderError("Error code: 403 - provider overloaded", status_code=403) if access_error else self._err()
with patch("agent.context_compressor.call_llm", side_effect=err):
first = c.compress(msgs, current_tokens=999999, force=True)
second = c.compress(msgs, current_tokens=999999, force=True)
# First two: preserve the transcript unchanged (#115906 semantics).
assert first == msgs and second == msgs
assert c._last_compress_aborted is True
third = c.compress(msgs, current_tokens=999999, force=True)
if access_error:
# An auth/quota error that also says "overloaded" never escalates (#29559).
assert third == msgs
assert c._last_compress_aborted is True
assert c._last_compression_telemetry["failure_class"] == "summary_auth_failure"
# ...and never bumps the overload budget, so the next real 503 keeps its grace (#115906).
assert c._consecutive_overload_aborts == 0
return
# Third: sustained overload escalates — bounded fallback beats a deferred total wipe.
assert third != msgs
assert c._last_compress_aborted is False
assert c._last_summary_fallback_used is True
assert c._last_summary_dropped_count > 0
assert c._last_compression_telemetry["failure_class"] == "summary_overload_degraded"
def test_overload_budget_survives_a_fresh_compressor_bound_to_the_same_session(self, tmp_path):
"""Review P1: the N=3 budget must be session-scoped, not object-local.
The gateway binds a fresh compressor to the existing session on every turn /
cache eviction, and restart/resume constructs one too. Each of those used to
restart the budget at zero, so a sustained outage never escalated (#123167;
same contract as the durable fallback streak, #100185).
"""
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "OVERLOAD_FRESH_BIND"
db.create_session(session_id, source="telegram")
msgs = self._msgs(12)
first = self._compressor(abort_on_summary_failure=False)
first.bind_session_state(db, session_id)
with patch("agent.context_compressor.call_llm", side_effect=self._err()):
for _ in range(2): # exactly two aborts — one below the escalation threshold
assert first.compress(msgs, current_tokens=999999, force=True) == msgs
assert first._consecutive_overload_aborts == 2
assert db.get_compression_overload_streak(session_id) == 2
# Fresh agent, same session (eviction / restart / API-server request construction).
second = self._compressor(abort_on_summary_failure=False)
second.bind_session_state(db, session_id)
assert second._consecutive_overload_aborts == 2 # inherited, not restarted
with patch("agent.context_compressor.call_llm", side_effect=self._err()):
third = second.compress(msgs, current_tokens=999999, force=True)
# Third consecutive abort IN THE SESSION escalates even though this object saw one.
# A fresh object's first overload burns the aux→main one-shot retry, which labels the
# attempt aux_model_fallback first; the escalated commit must still say what happened.
assert third != msgs
assert second._last_summary_fallback_used is True
assert second._last_summary_overload_degraded is True
assert second._last_compression_telemetry["failure_class"] == "summary_overload_degraded"
# The committed degraded fallback settles the budget so recovery gets a fresh run
# (the boundary caller records the completed compaction, as compress_context does).
second.record_completed_compaction(used_fallback=True)
assert db.get_compression_overload_streak(session_id) == 0
def _msgs(self, n=10):
return [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
for i in range(n)
]
class TestSummaryFallbackToMainModel:
"""When ``summary_model`` differs from the main model and the summary LLM
call fails, the compressor should retry once on the main model before
@@ -0,0 +1,136 @@
"""Sustained summary-provider overload: the abort budget, its escalation and its resets."""
from unittest.mock import MagicMock, patch
import pytest
from agent.context_compressor import ContextCompressor
class StubProviderError(Exception):
def __init__(self, message, *, status_code=None, response=None):
super().__init__(message)
self.status_code = status_code
self.response = response
class TestSustainedOverloadEscalation:
"""#123167: a sustained summary-provider overload must not guarantee a session wipe.
One overload aborts so a retry can still win (#115906). But when every attempt keeps
aborting, the transcript only grows until the session exits compression_exhausted and
the gateway auto-resets — total loss, deferred. After 3 consecutive overload aborts the
overload stops being terminal and compress() commits the deterministic fallback instead
(the same bounded degrade the repeated-stall ladder takes, #112420).
"""
def _err(self):
return StubProviderError(
"Our servers are currently overloaded. Please try again later.",
status_code=503,
)
def _compressor(self, **kwargs):
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test",
quiet_mode=True,
protect_first_n=2,
protect_last_n=2,
**kwargs,
)
c.summary_model = "test/auxiliary"
return c
@pytest.mark.parametrize("access_error", [False, True], ids=["overload", "403_overloaded"])
def test_first_overloads_still_abort_then_third_commits_fallback(self, access_error):
c = self._compressor(abort_on_summary_failure=False)
# A stale terminal flag from one earlier network failure (only a success clears it) must
# not pin the long-lived compressor in abort mode: the latest failure class decides.
c._last_summary_network_failure = True
msgs = self._msgs(12)
err = StubProviderError("Error code: 403 - provider overloaded", status_code=403) if access_error else self._err()
with patch("agent.context_compressor.call_llm", side_effect=err):
first = c.compress(msgs, current_tokens=999999, force=True)
second = c.compress(msgs, current_tokens=999999, force=True)
# First two: preserve the transcript unchanged (#115906 semantics).
assert first == msgs and second == msgs
assert c._last_compress_aborted is True
third = c.compress(msgs, current_tokens=999999, force=True)
if access_error:
# An auth/quota error that also says "overloaded" never escalates (#29559).
assert third == msgs
assert c._last_compress_aborted is True
assert c._last_compression_telemetry["failure_class"] == "summary_auth_failure"
# ...and never bumps the overload budget, so the next real 503 keeps its grace (#115906).
assert c._consecutive_overload_aborts == 0
return
# Third: sustained overload escalates — bounded fallback beats a deferred total wipe.
assert third != msgs
assert c._last_compress_aborted is False
assert c._last_summary_fallback_used is True
assert c._last_summary_dropped_count > 0
assert c._last_compression_telemetry["failure_class"] == "summary_overload_degraded"
def test_a_healthy_summary_restarts_the_overload_budget(self):
"""A summary that lands resets the strikes even when its compaction is later refused, so one
isolated overload afterwards aborts instead of escalating to the destructive fallback."""
c = self._compressor(abort_on_summary_failure=False)
msgs = self._msgs(12)
with patch("agent.context_compressor.call_llm", side_effect=self._err()):
for _ in range(2):
assert c.compress(msgs, current_tokens=999999, force=True) == msgs
assert c._consecutive_overload_aborts == 2
ok = MagicMock()
ok.choices[0].message.content = "Healthy summary."
with patch("agent.context_compressor.call_llm", return_value=ok):
assert c._generate_summary(msgs[1:6], bypass_cooldown=True)
assert c._consecutive_overload_aborts == 0
def test_overload_budget_survives_a_fresh_compressor_bound_to_the_same_session(self, tmp_path):
"""Review P1: the N=3 budget must be session-scoped, not object-local.
The gateway binds a fresh compressor to the existing session on every turn /
cache eviction, and restart/resume constructs one too. Each of those used to
restart the budget at zero, so a sustained outage never escalated (#123167;
same contract as the durable fallback streak, #100185).
"""
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "OVERLOAD_FRESH_BIND"
db.create_session(session_id, source="telegram")
msgs = self._msgs(12)
first = self._compressor(abort_on_summary_failure=False)
first.bind_session_state(db, session_id)
with patch("agent.context_compressor.call_llm", side_effect=self._err()):
for _ in range(2): # exactly two aborts — one below the escalation threshold
assert first.compress(msgs, current_tokens=999999, force=True) == msgs
assert first._consecutive_overload_aborts == 2
assert db.get_compression_overload_streak(session_id) == 2
# Fresh agent, same session (eviction / restart / API-server request construction).
second = self._compressor(abort_on_summary_failure=False)
second.bind_session_state(db, session_id)
assert second._consecutive_overload_aborts == 2 # inherited, not restarted
with patch("agent.context_compressor.call_llm", side_effect=self._err()):
third = second.compress(msgs, current_tokens=999999, force=True)
# Third consecutive abort IN THE SESSION escalates even though this object saw one.
# A fresh object's first overload burns the aux→main one-shot retry, which labels the
# attempt aux_model_fallback first; the escalated commit must still say what happened.
assert third != msgs
assert second._last_summary_fallback_used is True
assert second._last_summary_overload_degraded is True
assert second._last_compression_telemetry["failure_class"] == "summary_overload_degraded"
# The committed degraded fallback settles the budget so recovery gets a fresh run
# (the boundary caller records the completed compaction, as compress_context does).
second.record_completed_compaction(used_fallback=True)
assert db.get_compression_overload_streak(session_id) == 0
def _msgs(self, n=10):
return [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
for i in range(n)
]
+1 -1
View File
@@ -493,7 +493,7 @@ def owner_worker(tmp_path, clear_kanban_env, monkeypatch):
def get_messages_as_conversation(self, _sid):
return [{"role": "user", "content": "from-store"}]
def run_conversation(self, message, conversation_history=None):
def run_conversation(self, message, conversation_history=None, **_kwargs):
entered.append(message)
result = script() if callable(script) else script[seen["n"]]
seen["n"] += 1
+719
View File
@@ -0,0 +1,719 @@
"""Opt-in prepared compaction (``compression.prepare_ahead``, agent/prepared_compaction.py).
Behaviour contracts, with the summariser stubbed at its model boundary:
* flag off (the default) leaves the whole compression path as it was: no state, no pass, inline summary;
* a pass never changes the live transcript or compressor, and runs under the caller's profile scope;
* one pass per compaction: the candidate splices at its own boundary and every message after it, however
many, stays verbatim in the tail, wherever the fresh plan would cut;
* in-place text rewrites (plugins, the no-LLM prune, blank-echo drop) keep the candidate; a changed message
structure, a rewound transcript, a moved head or another session's transcript never splices;
* without a completed candidate, compaction waits for a running pass whose window fits (forwarding its
progress to the host) until the host's deadline, and a pass overtaken by compaction or a session boundary
cannot publish (a same-session rebind keeps the candidate);
* manual, focus, overflow-recovery and memory-context compactions summarise fresh, and memory-provider
context stops further passes;
* a splice leaves the live compressor as the inline call would: main-model fallback state and telemetry.
"""
from __future__ import annotations
import copy
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from agent import prepared_compaction as pc
from agent.context_compressor import COMPRESSED_SUMMARY_METADATA_KEY, ContextCompressor
from agent.model_metadata import estimate_messages_tokens_rough
from agent.prepared_compaction import PreparedCompaction
from tests.waiting import wait_until
class _StubCompressor(ContextCompressor):
"""Records every summariser call; ``gate`` holds background passes only (shared with the worker copy)."""
def _generate_summary(self, turns_to_summarize, focus_topic=None, memory_context="", bypass_cooldown=False):
from hermes_constants import get_hermes_home
worker = threading.current_thread().name == "compaction-prepare"
if worker and self.read_seconds:
from agent.local_prompt_progress import current_prompt_reading_hook
pending = self.prepared_compaction._pending
wait_until(lambda: pending.on_reading is not None, what="compaction waiting on the pass")
current_prompt_reading_hook()(self.read_seconds) # a local server reading the prompt
self.after_read()
if worker and self.gate is not None:
from agent.auxiliary_client import AuxiliaryExplicitCancellation, _aux_interrupt_cancel_requested
wait_until(lambda: self.gate.is_set() or _aux_interrupt_cancel_requested(), what="pass released or cancelled")
if not self.gate.is_set():
raise AuxiliaryExplicitCancellation() # how the aux client ends a cancelled summary stream
if worker and self.stream_progress:
from agent.auxiliary_client import _aux_progress
_aux_progress.hook() # one streamed chunk
self.calls.append({
"worker": worker,
"n_turns": len(turns_to_summarize),
"first": turns_to_summarize[0].get("content", "")[:10],
"previous": self._previous_summary,
"focus": focus_topic,
"home": str(get_hermes_home()),
})
body = f"Summary v{len(self.calls)} of {len(turns_to_summarize)} turns."
self._previous_summary = body
return self._with_summary_prefix(body)
def _compressor(*, enabled: bool = True, session_id: str = "S1") -> _StubCompressor:
with patch("agent.context_compressor.get_model_context_length", return_value=40960):
cc = _StubCompressor(model="test/model", protect_first_n=3, protect_last_n=5, quiet_mode=True)
cc.context_length = 40960
cc.threshold_tokens = 20000
cc.tail_token_budget = 2500
cc.calls, cc.gate, cc.stream_progress, cc.read_seconds, cc.after_read = [], None, False, 0.0, None
cc._session_id = session_id
if enabled:
cc.prepared_compaction = PreparedCompaction()
return cc
def _messages(n: int, size: int = 1200, tag: str = "m") -> list:
msgs = [{"role": "system", "content": "sys"}]
for i in range(n):
msgs.append({"role": "user" if i % 2 == 0 else "assistant", "content": f"{tag}{i} " + "z" * size})
return msgs
def _grow(msgs: list, n: int, size: int = 1200, tag: str = "new") -> list:
return msgs + _messages(n, size=size, tag=tag)[1:]
def _agent(cc, tokens: int = 19500):
cc.last_prompt_tokens = tokens
return SimpleNamespace(context_compressor=cc, compression_enabled=True, api_mode="chat_completions")
def _prepare(cc, msgs, tokens: int = 19500):
thread = pc.maybe_prepare(_agent(cc, tokens), msgs, origin="tool-batch")
assert thread is not None, "expected a pass to start"
thread.join(5.0)
entry = cc.prepared_compaction._entry
assert entry is not None
return entry
def _summary_row(compressed: list) -> dict:
rows = [m for m in compressed if m.get(COMPRESSED_SUMMARY_METADATA_KEY)]
assert len(rows) == 1
return rows[0]
# --------------------------------------------------------------------------- default off
def test_flag_off_leaves_compression_inline_and_stateless():
cc = _compressor(enabled=False)
msgs = _messages(30)
assert pc.maybe_prepare(_agent(cc), msgs, origin="test") is None
compressed = cc.compress(msgs, current_tokens=cc.threshold_tokens + 1)
assert [c["worker"] for c in cc.calls] == [False]
assert cc.prepared_compaction is None
assert "Summary v1" in _summary_row(compressed)["content"]
def test_config_flag_reaches_the_compressor_through_the_loader(tmp_path, monkeypatch):
from agent.agent_init import _parse_compression_config
from hermes_cli.config import DEFAULT_CONFIG, load_config_readonly
agent = SimpleNamespace(model="m", provider="openrouter", api_mode="chat_completions", quiet_mode=True)
assert DEFAULT_CONFIG["compression"]["prepare_ahead"] is False
assert _parse_compression_config(agent, load_config_readonly()).prepare_ahead is False
home = tmp_path / "profile"
home.mkdir()
(home / "config.yaml").write_text("compression:\n prepare_ahead: true\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
assert _parse_compression_config(agent, load_config_readonly()).prepare_ahead is True
# --------------------------------------------------------------------------- the pass
def test_pass_leaves_live_transcript_and_compressor_untouched():
cc = _compressor()
msgs = _messages(30)
before = copy.deepcopy(msgs)
entry = _prepare(cc, msgs)
assert msgs == before
assert cc._previous_summary is None and cc.compression_count == 0
assert [c["worker"] for c in cc.calls] == [True]
_working, _, start, end = cc._plan_compaction_window(msgs)
assert (entry.compress_start, entry.compress_end) == (start, end)
assert entry.body == f"Summary v1 of {end - start} turns."
@pytest.mark.parametrize("setup", [
lambda a, cc: setattr(cc, "last_prompt_tokens", int(20000 * (1 - pc.PREPARE_BAND_RATIO)) - 1),
# A small absolute trigger on a large window: the band is sized from the trigger, not the window.
lambda a, cc: (setattr(cc, "context_length", 200_000), setattr(cc, "threshold_tokens", 20000),
setattr(cc, "last_prompt_tokens", 1000)),
lambda a, cc: setattr(cc, "awaiting_real_usage_after_compression", True),
lambda a, cc: setattr(a, "compression_enabled", False),
lambda a, cc: setattr(a, "_persist_disabled", True), # background-review fork
lambda a, cc: setattr(a, "api_mode", "codex_app_server"),
lambda a, cc: setattr(a, "codex_responses_native_compaction", True),
lambda a, cc: setattr(cc, "_micro_compact_enabled", True),
lambda a, cc: setattr(cc, "_summary_failure_cooldown_until", time.monotonic() + 60),
lambda a, cc: setattr(cc, "_fallback_compression_streak", 2), # summary model benched
], ids=["below-band", "small-trigger-large-window", "awaiting-usage", "disabled", "fork", "app-server", "native",
"micro", "cooldown", "benched"])
def test_gates_start_no_pass(setup):
cc = _compressor()
agent = _agent(cc)
setup(agent, cc)
assert cc.threshold_tokens == 20000
assert pc.maybe_prepare(agent, _messages(30), origin="test") is None
assert cc.calls == []
@pytest.mark.parametrize("agent_attrs", [
{"platform": "cron"}, {"platform": "subagent"}, {"_delegate_depth": 1}, {"_oneshot_session": True},
], ids=["cron", "subagent-platform", "delegated-child", "one-shot"])
def test_turn_end_pass_skipped_where_the_session_ends_with_the_turn(agent_attrs):
cc = _compressor()
agent = _agent(cc)
for name, value in agent_attrs.items():
setattr(agent, name, value)
assert pc.maybe_prepare(agent, _messages(30), origin="turn-end") is None
thread = pc.maybe_prepare(agent, _messages(30), origin="tool-batch") # mid-turn passes still run
assert thread is not None
thread.join(5.0)
def test_pass_runs_under_the_callers_profile_scope(tmp_path):
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
homes = [tmp_path / "a", tmp_path / "b", tmp_path / "a"]
compressors = []
for home in homes:
cc = _compressor()
token = set_hermes_home_override(home)
try:
_prepare(cc, _messages(30))
finally:
reset_hermes_home_override(token)
compressors.append(cc)
assert [cc.calls[0]["home"] for cc in compressors] == [str(h) for h in homes]
# --------------------------------------------------------------------------- the splice
def test_append_only_growth_splices_at_the_candidate_boundary_keeping_newer_messages():
cc = _compressor()
msgs = _messages(30)
entry = _prepare(cc, msgs)
later = _grow(msgs, 8)
assert cc._plan_compaction_window(later)[3] > entry.compress_end # a fresh plan would cut later
compressed = cc.compress(later, current_tokens=cc.threshold_tokens + 1)
assert [c["worker"] for c in cc.calls] == [True], "no summariser call on the turn"
assert entry.summary in _summary_row(compressed)["content"]
kept = later[entry.compress_end:]
for original, row in zip(kept, compressed[-len(kept):]):
assert original["content"] in row["content"]
assert cc._previous_summary == entry.body
assert cc.prepared_compaction._entry is None
@pytest.mark.parametrize("pass_state", ["completed", "running"])
def test_growth_past_the_candidate_beyond_the_tail_budget_still_splices_keeping_it_all_in_the_tail(pass_state):
"""However much arrived after the pass started, it joins the tail: no second summary call."""
cc = _compressor()
msgs = _messages(30)
if pass_state == "running":
cc.gate = threading.Event()
running = pc.maybe_prepare(_agent(cc), msgs, origin="test")
assert running is not None
pending = cc.prepared_compaction._pending
threading.Thread(target=lambda: wait_until(lambda: pending.on_progress is not None) and cc.gate.set()).start()
else:
_prepare(cc, msgs)
window = (cc.prepared_compaction._entry or cc.prepared_compaction._pending.window)
later = _grow(msgs, 24)
_, _, start, end = cc._plan_compaction_window(later)
assert start == window.compress_start and end > window.compress_end
assert estimate_messages_tokens_rough(later[window.compress_end:end]) > cc.tail_token_budget
from agent.auxiliary_client import aux_progress_hook
with aux_progress_hook(lambda: None): # a host hook marks the moment the wait starts
compressed = cc.compress(later, current_tokens=cc.threshold_tokens + 1)
if pass_state == "running":
running.join(5.0)
assert [c["worker"] for c in cc.calls] == [True], "one summary, written by the pass"
kept = later[window.compress_end:]
for original, row in zip(kept, compressed[-len(kept):]):
assert original["content"] in row["content"]
assert cc.prepared_compaction._entry is None
@pytest.mark.parametrize("pass_state", ["completed", "running"])
def test_a_fresh_cut_before_the_candidate_boundary_still_splices_and_the_boundary_starts_the_tail(pass_state):
"""The fresh plan cutting earlier than the pass did (cheaper tail rows) never redoes the summary."""
cc = _compressor()
msgs = _messages(30)
if pass_state == "running":
cc.gate = threading.Event()
running = pc.maybe_prepare(_agent(cc), msgs, origin="test")
assert running is not None
pending = cc.prepared_compaction._pending
threading.Thread(target=lambda: wait_until(lambda: pending.on_progress is not None) and cc.gate.set()).start()
else:
_prepare(cc, msgs)
window = (cc.prepared_compaction._entry or cc.prepared_compaction._pending.window)
# The tail rows got cheaper since the pass planned (the no-LLM prune), so the fresh plan keeps more rows.
later = [m if i < window.compress_end else {**m, "content": m["content"][:8]} for i, m in enumerate(_grow(msgs, 2))]
_, _, start, end = cc._plan_compaction_window(later)
assert start == window.compress_start and end < window.compress_end
from agent.auxiliary_client import aux_progress_hook
with aux_progress_hook(lambda: None):
compressed = cc.compress(later, current_tokens=cc.threshold_tokens + 1)
if pass_state == "running":
running.join(5.0)
assert [c["worker"] for c in cc.calls] == [True], "one summary, written by the pass"
kept = later[window.compress_end:]
assert kept, "the pass planned its own tail"
for original, row in zip(kept, compressed[-len(kept):]):
assert original["content"] in row["content"]
assert cc.prepared_compaction._entry is None
def test_in_place_text_rewrite_of_summarised_messages_keeps_the_candidate():
"""Plugins rewrite message text in place (redaction, pre-compaction notes): still the same messages."""
cc = _compressor()
msgs = _messages(30)
for i, msg in enumerate(msgs):
msg["message_uid"] = f"uid-{i}"
entry = _prepare(cc, msgs)
live = copy.deepcopy(msgs)
live[entry.compress_start + 1]["content"] = "rewritten by a plugin"
live[entry.compress_start + 2]["content"] = [{"type": "input_text", "text": "structured rewrite"}]
compressed = cc.compress(live, current_tokens=cc.threshold_tokens + 1)
assert [c["worker"] for c in cc.calls] == [True]
assert entry.summary in _summary_row(compressed)["content"]
def _drop_pair_and_regrow(cc, live, start):
# Caller-supplied history is never persisted, so no uid vouches for it; after regrowth the roles
# still alternate and line up, but the summarized messages are not the same messages.
del live[start + 1:start + 3]
live += _messages(2, tag="regrown")[1:]
def _drop_and_reshape(cc, live, start):
del live[start + 1:start + 3]
live[start + 1]["tool_calls"] = [{"id": "c1", "function": {"name": "x"}}]
_PREFIX_CHANGES = { # name -> (history persisted with uids?, change applied before compaction)
"message dropped": (True, _drop_and_reshape),
# A rewind into the window that regrew the same shape: the rows are new messages.
"rewound": (True, lambda cc, live, start: live[start + 1].update(message_uid="uid-after-rewind")),
"pair dropped from history without uids": (False, _drop_pair_and_regrow),
"head moved": (True, lambda cc, live, start: setattr(cc, "compression_count", 1)), # head protection decays
"other session": (True, lambda cc, live, start: setattr(cc, "_session_id", "S2")),
}
@pytest.mark.parametrize("change", list(_PREFIX_CHANGES))
def test_changed_prefix_never_splices(change):
persisted, apply_change = _PREFIX_CHANGES[change]
cc = _compressor()
msgs = _messages(30)
for i, msg in enumerate(msgs if persisted else []):
msg["message_uid"] = f"uid-{i}"
entry = _prepare(cc, msgs)
live = copy.deepcopy(msgs)
apply_change(cc, live, entry.compress_start)
compressed = cc.compress(live, current_tokens=cc.threshold_tokens + 1)
assert [c["worker"] for c in cc.calls] == [True, False], "inline summary expected"
assert "Summary v2" in _summary_row(compressed)["content"]
@pytest.mark.parametrize("kwargs", [
{"force": True}, {"focus_topic": "auth bug"}, {"bypass_cooldown": True}, {"memory_context": "provider note"},
], ids=["manual", "focus", "overflow-recovery", "memory-context"])
def test_fresh_summary_paths_ignore_and_drop_the_candidate(kwargs):
cc = _compressor()
msgs = _messages(30)
_prepare(cc, msgs)
cc.compress(msgs, current_tokens=cc.threshold_tokens + 1, **kwargs)
assert [c["worker"] for c in cc.calls] == [True, False]
assert cc.prepared_compaction._entry is None
# Provider context would be needed by every later compaction too: no more passes paid for and dropped.
stopped = pc.maybe_prepare(_agent(cc), msgs, origin="test") is None
assert stopped is ("memory_context" in kwargs)
def test_prune_and_blank_echo_drop_do_not_invalidate_the_candidate():
cc = _compressor()
msgs = [{"role": "system", "content": "sys"}]
for i in range(12): # tool rounds whose bodies and arguments the prune rewrites
msgs += [
{"role": "user", "content": f"u{i} " + "q" * 400},
{"role": "assistant", "content": f"a{i}", "tool_calls": [{
"id": f"c{i}", "type": "function",
"function": {"name": "read_file", "arguments": '{"path": "%s"}' % ("p" * 900)},
}]},
{"role": "tool", "tool_call_id": f"c{i}", "name": "read_file", "content": f"r{i} " + "r" * 3000},
{"role": "assistant", "content": f"done {i} " + "d" * 400},
]
msgs += [{"role": "user", "content": "latest ask"}, {"role": "user", "content": " "},
{"role": "assistant", "content": "working on it"}]
working, pruned, _, _ = cc._plan_compaction_window(msgs)
assert pruned > 0 and len(working) == len(msgs) - 1, "prune ran and the trailing blank echo was dropped"
entry = _prepare(cc, msgs)
live = _grow(msgs, 4) # a newer user turn: the old echo is no longer trailing, so it stays
live_working, live_pruned, start, end = cc._plan_compaction_window(live)
assert len(live_working) == len(live) and live_pruned >= pruned
assert start == entry.compress_start and end >= entry.compress_end
cc.compress(live, current_tokens=cc.threshold_tokens + 1)
assert [c["worker"] for c in cc.calls] == [True], "the candidate must still splice"
def test_identity_survives_a_session_db_round_trip_and_catches_a_rewind(tmp_path):
from hermes_state import SessionDB
msgs = _messages(20)
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("S1", source="cli")
for m in msgs[1:]:
db.append_message("S1", role=m["role"], content=m["content"])
replayed = [msgs[0]] + db.get_messages_as_conversation("S1")
window = pc._Window("S1", 4, 15, pc.prefix_identities(replayed, 15))
assert pc._mismatch(window, msgs, "S1", 4) is None, "the live dicts before their first persist"
assert pc._mismatch(window, replayed, "S1", 4) is None
rewound = copy.deepcopy(replayed)
rewound[6]["message_uid"] = "a-new-row"
assert "different message" in pc._mismatch(window, rewound, "S1", 4)
# --------------------------------------------------------------------------- running passes
def test_compaction_waits_for_a_running_pass_and_forwards_its_progress():
"""No candidate yet: the compaction waits for the running pass instead of summarising again, and each
streamed chunk of the pass touches the compacting thread's progress hook (the host's idle watchdog)."""
from agent.auxiliary_client import aux_progress_hook
cc = _compressor()
cc.gate, cc.stream_progress = threading.Event(), True
msgs = _messages(30)
running = pc.maybe_prepare(_agent(cc), msgs, origin="test")
assert running is not None
host_progress = []
pending = cc.prepared_compaction._pending
# Release the pass only once the compaction is waiting on it.
threading.Thread(target=lambda: wait_until(lambda: pending.on_progress is not None) and cc.gate.set()).start()
with aux_progress_hook(lambda: host_progress.append(threading.current_thread().name)):
compressed = cc.compress(msgs, current_tokens=cc.threshold_tokens + 1)
running.join(5.0)
assert [c["worker"] for c in cc.calls] == [True], "the pass's summary was spliced, no inline call"
assert "Summary v1" in _summary_row(compressed)["content"]
assert host_progress == ["compaction-prepare"], "pass progress reached the waiting host"
assert cc.prepared_compaction._pending is None and cc.prepared_compaction._entry is None
def test_wait_ends_at_the_host_deadline_then_summarises_inline_and_the_pass_cannot_publish():
from agent.auxiliary_client import aux_stream_deadline
cc = _compressor()
cc.gate = threading.Event()
msgs = _messages(30)
running = pc.maybe_prepare(_agent(cc), msgs, origin="test")
assert running is not None
with aux_stream_deadline(time.monotonic() + 0.2):
cc.compress(msgs, current_tokens=cc.threshold_tokens + 1)
assert [c["worker"] for c in cc.calls] == [False], "inline summary after the deadline"
cc.gate.set()
running.join(5.0)
assert cc.prepared_compaction._entry is None
def test_prompt_reading_in_the_pass_extends_the_deadline_the_wait_honours():
"""A local server still reading the pass's prompt pushes the host deadline back exactly as it would
inline, and the wait follows the live deadline instead of the one it started with."""
from agent.auxiliary_client import aux_stream_deadline
from agent.local_prompt_progress import prompt_reading_hook
cc = _compressor()
initial = []
# The pass finishes only after the deadline the wait started with has passed.
cc.read_seconds = 5.0
cc.after_read = lambda: wait_until(lambda: initial and time.monotonic() > initial[0] + 0.3, timeout=10)
msgs = _messages(30)
running = pc.maybe_prepare(_agent(cc), msgs, origin="test")
assert running is not None
# The deadline starts once the pass is running, with room for a busy runner to reach the wait.
deadline = [time.monotonic() + 2.0]
initial.append(deadline[0])
with aux_stream_deadline(lambda: deadline[0]), prompt_reading_hook(lambda s: deadline.__setitem__(0, deadline[0] + s)):
compressed = cc.compress(msgs, current_tokens=cc.threshold_tokens + 1)
running.join(5.0)
assert [c["worker"] for c in cc.calls] == [True], "the extended wait outlived the initial deadline"
assert "Summary v1" in _summary_row(compressed)["content"]
def test_host_cancel_ends_the_wait_and_cancels_the_superseded_pass(monkeypatch):
from agent.auxiliary_client import aux_interrupt_protection, aux_progress_hook
escaped = []
monkeypatch.setattr(threading, "excepthook", escaped.append)
cc = _compressor()
cc.gate = threading.Event()
msgs = _messages(30)
running = pc.maybe_prepare(_agent(cc), msgs, origin="test")
assert running is not None
pending, host_cancel = cc.prepared_compaction._pending, threading.Event()
threading.Thread(target=lambda: wait_until(lambda: pending.on_progress is not None)
and host_cancel.set()).start()
with aux_interrupt_protection(cancel_event=host_cancel), aux_progress_hook(lambda: None):
cc.compress(msgs, current_tokens=cc.threshold_tokens + 1)
assert [c["worker"] for c in cc.calls] == [False], "inline summary after the host cancelled the wait"
assert pending.cancelled.is_set(), "the pass nobody can use is told to stop streaming"
running.join(5.0)
assert not running.is_alive() and escaped == [], "the cancelled stream ends the pass quietly"
def test_session_reset_during_the_wait_wakes_it_without_waiting_for_the_pass():
from agent.auxiliary_client import aux_progress_hook
cc = _compressor()
cc.gate = threading.Event()
msgs = _messages(30)
running = pc.maybe_prepare(_agent(cc), msgs, origin="test")
assert running is not None
pending = cc.prepared_compaction._pending
threading.Thread(target=lambda: wait_until(lambda: pending.on_progress is not None)
and cc.prepared_compaction.discard("session reset")).start()
with aux_progress_hook(lambda: None): # a host hook marks the moment the wait starts
cc.compress(msgs, current_tokens=cc.threshold_tokens + 1)
assert [c["worker"] for c in cc.calls] == [False]
cc.gate.set()
running.join(5.0)
assert cc.prepared_compaction._entry is None
@pytest.mark.parametrize("boundary", ["reset", "end", "rebind", "agent-released"])
def test_session_boundary_discards_candidate_and_running_pass(boundary):
def cross(cc, msgs):
if boundary == "reset":
cc.on_session_reset()
elif boundary == "end":
cc.on_session_end("S1", msgs)
elif boundary == "rebind": # /resume or /branch onto another session
cc.bind_session_state(None, "S2")
else: # gateway cache eviction / rewind / close release the agent without a session boundary
pc.release_for_agent(_agent(cc))
msgs = _messages(30)
completed = _compressor()
_prepare(completed, msgs)
cross(completed, msgs)
assert completed.prepared_compaction._entry is None
cc = _compressor()
cc.gate = threading.Event()
running = pc.maybe_prepare(_agent(cc), msgs, origin="test")
assert running is not None
cross(cc, msgs)
cc.gate.set()
running.join(5.0)
assert cc.prepared_compaction._entry is None and cc.prepared_compaction._pending is None
def test_same_session_rebind_keeps_the_candidate():
cc = _compressor()
msgs = _messages(30)
entry = _prepare(cc, msgs)
cc.bind_session_state(None, "S1") # e.g. on_session_start after construction already bound S1
assert cc.prepared_compaction._entry is entry
cc.compress(_grow(msgs, 4), current_tokens=cc.threshold_tokens + 1)
assert [c["worker"] for c in cc.calls] == [True]
# --------------------------------------------------------------------------- one pass, failures, fallback
def test_a_held_candidate_starts_no_further_pass_however_far_the_prompt_grows(monkeypatch):
cc = _compressor()
msgs = _messages(30)
first = _prepare(cc, msgs)
plans, plan = [], cc._plan_compaction_window
monkeypatch.setattr(cc, "_plan_compaction_window", lambda m: plans.append(1) or plan(m))
assert pc.maybe_prepare(_agent(cc, cc.threshold_tokens - 1), _grow(msgs, 30, size=2600), origin="test") is None
assert plans == [] and len(cc.calls) == 1 and cc.prepared_compaction._entry is first
def test_failed_pass_backs_off_without_touching_live_failure_state(tmp_path, monkeypatch):
from hermes_state import SessionDB
import agent.context_compressor as module
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("S1", source="cli")
cc = _compressor()
cc.bind_session_state(db, "S1")
msgs = _messages(30)
monkeypatch.setattr(_StubCompressor, "_generate_summary", module.ContextCompressor._generate_summary)
call = MagicMock(side_effect=RuntimeError("No LLM provider configured"))
monkeypatch.setattr(module, "call_llm", call)
thread = pc.maybe_prepare(_agent(cc), msgs, origin="test")
thread.join(5.0)
assert call.call_count == 1
assert cc.prepared_compaction._entry is None
assert cc._summary_failure_cooldown_until == 0.0 and cc._last_summary_error is None
assert db.get_compression_failure_cooldown("S1") is None
assert pc.maybe_prepare(_agent(cc), msgs, origin="test") is None, "backs off"
assert call.call_count == 1
def test_splice_carries_the_passs_main_model_fallback_and_call_telemetry(monkeypatch):
"""The summary model failed inside the pass and the main model answered: the live compressor ends up
as the inline call would leave it (sticky main-model route, the user warning's source, the call's
telemetry), not as if the broken summary model had worked."""
import agent.context_compressor as module
cc = _compressor()
cc.summary_model = "broken/summary-model"
msgs = _messages(30)
monkeypatch.setattr(_StubCompressor, "_generate_summary", module.ContextCompressor._generate_summary)
calls = []
def call_llm(**kwargs):
calls.append(threading.current_thread().name)
if len(calls) == 1:
raise RuntimeError("model not found") # the configured summary model is broken
message = SimpleNamespace(content="Recovered summary.", reasoning_content=None, reasoning=None)
return SimpleNamespace(choices=[SimpleNamespace(message=message, finish_reason="stop")])
monkeypatch.setattr(module, "call_llm", call_llm)
_prepare(cc, msgs)
cc.compress(msgs, current_tokens=cc.threshold_tokens + 1)
assert calls == ["compaction-prepare"] * 2, "summary model failed, main model answered, both in the pass"
assert cc.summary_model == "", "later compactions go straight to the main model, as inline"
assert cc._last_aux_model_failure_model == "broken/summary-model"
telemetry = cc._last_compression_telemetry
assert telemetry["prepared_summary"] is True and telemetry["fallback_used"] is True
assert telemetry["aux_model"] and telemetry["aux_call_duration_ms"] is not None
assert telemetry["method"] == "aux_fallback_main"
# --------------------------------------------------------------------------- the real turn loop
def _loop_response(*, prompt_tokens: int, tool_call_id: str | None = None, content=None):
tool_calls = [SimpleNamespace(
id=tool_call_id, type="function", function=SimpleNamespace(name="web_search", arguments='{"query": "q"}'),
)] if tool_call_id else None
msg = SimpleNamespace(content=content, tool_calls=tool_calls, reasoning_content=None, reasoning=None)
resp = SimpleNamespace(
choices=[SimpleNamespace(message=msg, finish_reason="tool_calls" if tool_calls else "stop")], model="test/model",
)
resp.usage = SimpleNamespace(prompt_tokens=prompt_tokens, completion_tokens=50, total_tokens=prompt_tokens + 50)
return resp
@pytest.mark.parametrize("prepare_ahead", [True, False])
def test_turn_that_crosses_the_trigger_splices_the_summary_prepared_during_its_tools(prepare_ahead):
"""Real run_conversation with the provider and tool boundaries mocked; config via the real loader.
Response 1 lands in the band: with the flag, the pass runs while that batch's tool executes.
Response 2 crosses the trigger, so the loop compacts after the second batch: a splice with the
flag (and no second pass), an inline summary without it.
"""
import os
from pathlib import Path
from run_agent import AIAgent
if prepare_ahead:
Path(os.environ["HERMES_HOME"], "config.yaml").write_text(
"compression:\n prepare_ahead: true\n", encoding="utf-8",
)
tool_defs = [{"type": "function", "function": {
"name": "web_search", "description": "search", "parameters": {"type": "object", "properties": {}},
}}]
with (
patch("model_tools.get_tool_definitions", return_value=tool_defs),
patch("model_tools.check_toolset_requirements", return_value={}),
patch("agent.process_bootstrap.OpenAI"),
):
agent = AIAgent(api_key="test-key-1234567890", base_url="https://openrouter.ai/api/v1",
quiet_mode=True, skip_context_files=True, skip_memory=True)
agent.client = MagicMock()
agent._cached_system_prompt = "You are helpful."
agent._use_prompt_caching = False
agent.save_trajectories = False
cc = agent.context_compressor
assert (cc.prepared_compaction is not None) is prepare_ahead
cc.context_length, cc.threshold_tokens, cc.tail_token_budget = 40960, 20000, 2500
band_floor = int(cc.threshold_tokens * (1 - pc.PREPARE_BAND_RATIO))
summaries, tool_started = [], threading.Event()
def summarise(turns, focus_topic=None, **_):
on_worker = threading.current_thread().name == "compaction-prepare"
if on_worker: # the pass is meant to overlap the tool
assert tool_started.wait(5.0), "no tool ran while the pass was pending"
summaries.append(on_worker)
return cc._with_summary_prefix(f"Summary of {len(turns)} turns.")
cc._generate_summary = summarise
pass_during_tool = []
def run_tool(*_a, **_k):
state = cc.prepared_compaction
pass_during_tool.append(state is not None and state._pending is not None)
tool_started.set()
if state is not None:
wait_until(lambda: state._pending is None, what="the pass to finish")
return '{"ok": true}'
agent.client.chat.completions.create.side_effect = [
_loop_response(prompt_tokens=band_floor + 1000, tool_call_id="c1"),
_loop_response(prompt_tokens=cc.threshold_tokens + 1000, tool_call_id="c2"),
_loop_response(prompt_tokens=6000, content="done"),
]
with (
patch("model_tools.handle_function_call", side_effect=run_tool),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("continue", conversation_history=_messages(30)[1:])
assert result["completed"] is True and result["final_response"] == "done"
assert len([m for m in result["messages"] if m.get(COMPRESSED_SUMMARY_METADATA_KEY)]) == 1
assert pass_during_tool == [prepare_ahead, False]
assert summaries == [prepare_ahead], "one summariser call: on the worker with the flag, inline without"
@@ -0,0 +1,139 @@
"""A tool result the per-turn budget spills must replay as the stub it was sent as.
Each tool result is flushed to the session store as it lands; the turn budget then swaps the
largest ones for ``<persisted-output>`` previews in memory. The gateway, the TUI resume path and
``--resume`` rebuild history from the store, so a row left holding the full body comes back on the
next turn: the request grows by everything the budget removed (past ``context_length`` in the live
repro) and its cached prefix diverges at the first spilled row.
"""
import contextlib
import os
import sqlite3
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from tools.budget_config import BudgetConfig
from tools.tool_result_storage import PERSISTED_OUTPUT_TAG
_LARGE = "L" * 4_000
def _tc(name, arguments, call_id):
return SimpleNamespace(id=call_id, type="function", function=SimpleNamespace(name=name, arguments=arguments))
def _tool_defs(*names):
return [{"type": "function", "function": {"name": n, "description": n,
"parameters": {"type": "object", "properties": {}}}}
for n in names]
@pytest.fixture()
def agent_and_db(tmp_path):
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
with (
patch("model_tools.get_tool_definitions", return_value=_tool_defs("web_search", "terminal")),
patch("model_tools.check_toolset_requirements", return_value={}),
patch("agent.process_bootstrap.OpenAI"),
patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}),
):
from run_agent import AIAgent
agent = AIAgent(
api_key="test-key-1234567890", base_url="https://openrouter.ai/api/v1", model="test/model",
quiet_mode=True, skip_context_files=True, skip_memory=True,
session_db=db, session_id=f"sess-{uuid.uuid4().hex[:8]}",
)
agent.client = MagicMock()
yield agent, db
db.close()
def _run_batch(agent, calls):
messages = [{"role": "user", "content": "read them"},
{"role": "assistant", "content": "", "tool_calls": [
{"id": c.id, "type": "function",
"function": {"name": c.function.name, "arguments": c.function.arguments}} for c in calls]}]
budget = BudgetConfig(default_result_size=100_000, turn_budget=6_000, preview_size=64)
def fake_handle(name, args, task_id, **kwargs):
return _LARGE if "large" in kwargs["tool_call_id"] else "small"
with (
patch("model_tools.handle_function_call", side_effect=fake_handle),
patch("agent.tool_executor._budget_for_agent", return_value=budget),
):
agent._execute_tool_calls(SimpleNamespace(content="", tool_calls=calls), messages, "task-1")
return messages
@pytest.mark.parametrize(
"calls",
[
[_tc("web_search", '{"query":"a"}', "p-large-1"), _tc("web_search", '{"query":"b"}', "p-large-2"),
_tc("web_search", '{"query":"c"}', "p-small")],
[_tc("terminal", '{"command":"a"}', "s-large-1"), _tc("terminal", '{"command":"b"}', "s-large-2"),
_tc("terminal", '{"command":"c"}', "s-small")],
[_tc("web_search", '{"query":"a"}', "m-large-1"), _tc("terminal", '{"command":"b"}', "m-large-2"),
_tc("terminal", '{"command":"c"}', "m-small")],
],
ids=["parallel", "sequential", "mixed"],
)
def test_replayed_history_carries_the_budget_stub_that_was_sent(agent_and_db, calls):
agent, db = agent_and_db
messages = _run_batch(agent, calls)
live = {m["tool_call_id"]: m["content"] for m in messages if m.get("role") == "tool"}
spilled = [cid for cid, content in live.items() if PERSISTED_OUTPUT_TAG in content]
assert spilled, "the fixture must make the turn budget spill at least one result"
# The gateway's live-replay read (gateway/session_transcript.load_transcript).
replay = db.get_messages_as_conversation(agent.session_id, repair_alternation=True)
replayed = {m["tool_call_id"]: m["content"] for m in replay if m.get("role") == "tool"}
assert replayed == live
def test_spilled_full_body_stays_searchable_and_next_flush_appends_once(agent_and_db):
agent, db = agent_and_db
calls = [_tc("web_search", '{"query":"a"}', "q-large-1"), _tc("web_search", '{"query":"b"}', "q-large-2")]
messages = _run_batch(agent, calls)
with contextlib.closing(sqlite3.connect(db.db_path)) as conn:
full_bodies = conn.execute(
"SELECT active FROM messages WHERE session_id = ? AND role = 'tool' AND instr(content, ?) > 0 "
"ORDER BY id", (agent.session_id, _LARGE)).fetchall()
# The result the budget kept inline, plus the archived twin of the one it spilled.
assert full_bodies == [(1,), (0,)]
messages.append({"role": "assistant", "content": "done"})
assert agent._flush_messages_to_session_db(messages) is True
replay = db.get_messages_as_conversation(agent.session_id, repair_alternation=True)
assert [m["role"] for m in replay] == ["user", "assistant", "tool", "tool", "assistant"]
def test_a_process_that_lost_the_turn_lease_leaves_the_rows_alone(agent_and_db):
agent, db = agent_and_db
assert db.try_acquire_session_turn_lease(agent.session_id, "process-a", ttl_seconds=60)
agent._active_session_turn_lease_holder = "process-a"
rewrite = db.rewrite_pruned_rows
def taken_over_first(*args, **kwargs):
# Another process takes the turn after A flushed the full rows and before A's rewrite commits.
db.release_session_turn_lease(agent.session_id, "process-a")
assert db.try_acquire_session_turn_lease(agent.session_id, "process-b", ttl_seconds=60)
return rewrite(*args, **kwargs)
db.rewrite_pruned_rows = taken_over_first
calls = [_tc("web_search", '{"query":"a"}', "l-large-1"), _tc("web_search", '{"query":"b"}', "l-large-2")]
_run_batch(agent, calls)
stored = [m["content"] for m in db.get_messages_as_conversation(agent.session_id) if m.get("role") == "tool"]
assert stored and all(_LARGE in body and PERSISTED_OUTPUT_TAG not in body for body in stored), (
"a rewrite under another process's lease must not land")
+30
View File
@@ -80,6 +80,36 @@ async def test_paused_receipt_rearms_on_resume_without_polling_rpc(bot, monkeypa
assert (await asyncio.to_thread(read_delivery_result, bot.home, key))['reply'] == 'pong'
@pytest.mark.asyncio
async def test_receipt_read_straddling_the_settlement_commit_projects_the_reply(bot, monkeypatch):
"""The drain settles from a worker thread while a receipt refresh reads the admission on the
loop: a commit landing between the row read and the result read is still a settled reply,
never a terminal ``failed`` receipt that no later wake re-reads."""
from gateway import session_bot, session_results
from gateway.session_contract import SessionRef
from hermes_state_runtime import claim_session_input, settle_session_input
from tools.bot_live_delivery import read_delivery_result
monkeypatch.setattr(bot.authority, '_schedule', lambda ref: None)
key = 'f' * 32
receipt = await session_bot.deliver(bot.connection, dict(id=key, profile='default', message='straddle'))
bot.authority._pause(SessionRef(bot.authority.profile_id, receipt['session_id']), 'unknown_execution')
await asyncio.wait_for(asyncio.gather(*list(bot.authority._bot_receipt_tasks)), 5)
row = claim_session_input(bot.authority.db, epoch=bot.authority.epoch, session_id=receipt['session_id'])
read, settled = session_results.admission_result, []
def commit_then_read(db, admission_id):
if admission_id == row['admission_id'] and not settled:
settled.append(settle_session_input(db, epoch=bot.authority.epoch, admission_id=admission_id,
generation=row['generation'], outcome='completed',
result={'result': {'final_response': 'pong'}, 'usage': {}}))
return read(db, admission_id)
monkeypatch.setattr(session_results, 'admission_result', commit_then_read)
await session_bot.refresh_receipt(bot.authority, bot.home, key)
record = await asyncio.to_thread(read_delivery_result, bot.home, key)
assert settled, 'the refresh never read the admission result'
assert (record['status'], record['reply']) == ('settled', 'pong'), record
@pytest.mark.asyncio
async def test_adopted_result_during_unknown_projection_is_not_lost(bot, monkeypatch):
from gateway import session_bot
@@ -0,0 +1,209 @@
"""A Discord allowlist written only in config.yaml (``discord.allow_from``) under multiplex.
Multiplexed authorization reads each profile's own secret scope. The default profile's
``DISCORD_ALLOWED_USERS`` (bridged from ``allow_from``) reaches it, but a secondary profile running
its own bot has its list only in its adapter's config. Either way the user must be admitted wherever
the adapter's own intake admits them: guild channels, threads and component clicks. Real
``GatewayRunner`` auth callback and real adapter; the default-profile cases also use the real config
loader.
"""
import os
from pathlib import Path
from types import SimpleNamespace
import pytest
from gateway.config import Platform
USER = "202965502740398090"
STRANGER = "111111111111111111"
CHANNEL = "1501061216548683885"
THREAD = "1554296921646960760"
@pytest.fixture
def config_only_discord(monkeypatch):
"""Multiplexed default profile; the Discord allowlist lives only in config.yaml."""
from agent import secret_scope
home = Path(os.environ["HERMES_HOME"])
(home / "config.yaml").write_text(
"gateway:\n multiplex_profiles: true\n"
f"discord:\n require_mention: true\n allow_from: {USER}\n",
encoding="utf-8",
)
(home / ".env").write_text("DISCORD_BOT_TOKEN=test-token\n", encoding="utf-8")
monkeypatch.delenv("DISCORD_ALLOWED_USERS", raising=False)
prev = secret_scope.is_multiplex_active()
secret_scope.set_multiplex_active(True)
yield home
secret_scope.set_multiplex_active(prev)
def _wire(home):
from gateway.config import load_gateway_config
from gateway.pairing import PairingStore
from gateway.run import GatewayRunner
from plugins.platforms.discord.adapter import DiscordAdapter
runner = object.__new__(GatewayRunner)
runner.config = load_gateway_config()
runner.pairing_store = PairingStore(profile="default")
runner.pairing_stores = {"default": runner.pairing_store}
runner._primary_profile_name = "default"
runner._profile_adapters = {}
adapter = DiscordAdapter(runner.config.platforms[Platform.DISCORD])
runner.adapters = {Platform.DISCORD: adapter}
adapter.set_authorization_check(runner._make_adapter_auth_check(Platform.DISCORD))
return runner, adapter
def _click(user_id, channel):
return SimpleNamespace(
user=SimpleNamespace(id=int(user_id), roles=[]),
channel_id=int(getattr(channel, "id", THREAD)),
guild=SimpleNamespace(id=1),
channel=channel,
)
def _thread():
import discord
thread = object.__new__(discord.Thread)
return thread
def test_button_click_in_a_thread_follows_the_thread_message_verdict(config_only_discord):
"""Guard: a thread click is checked like the thread's messages, so an allowlisted user who can
chat in a thread is not told "This bot is private" when clicking Allow Once there."""
from plugins.platforms.discord.adapter_component_auth import _component_check_auth
_runner, adapter = _wire(config_only_discord)
snapshot = adapter._get_allowed_users()
assert USER in snapshot
click = _click(USER, _thread())
assert _component_check_auth(click, snapshot, set(), live_auth=adapter._component_live_auth) is True
stranger = _click(STRANGER, _thread())
assert _component_check_auth(stranger, snapshot, set(), live_auth=adapter._component_live_auth) is False
@pytest.mark.parametrize("user_id,expected", [(USER, True), (STRANGER, False)])
def test_guild_channel_message_and_button_honor_config_allow_from(config_only_discord, user_id, expected):
"""Guard: ``allow_from`` is Discord's only user allowlist, so it governs guild channels too:
the default bot admits the allowlisted user's channel messages and button clicks."""
from plugins.platforms.discord.adapter_component_auth import _component_check_auth
runner, adapter = _wire(config_only_discord)
source = adapter.build_source(
chat_id=CHANNEL, chat_type="group", user_id=user_id, user_name="u", guild_id="1")
runner._canonicalize(source, primary_home=config_only_discord)
assert runner._is_user_authorized_for_source(source) is expected
click = _click(user_id, SimpleNamespace(id=int(CHANNEL)))
snapshot = adapter._get_allowed_users()
assert _component_check_auth(click, snapshot, set(), live_auth=adapter._component_live_auth) is expected
def test_telegram_group_still_requires_group_allow_from(monkeypatch):
"""Guard: Telegram's ``allow_from`` is DM-only and must not admit the user in a group."""
from gateway.config import GatewayConfig, PlatformConfig
from gateway.pairing import PairingStore
from gateway.run import GatewayRunner
from gateway.session import SessionSource
from plugins.platforms.telegram.adapter import TelegramAdapter
for key in ("TELEGRAM_ALLOWED_USERS", "TELEGRAM_GROUP_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS",
"GATEWAY_ALLOW_ALL_USERS", "TELEGRAM_ALLOW_ALL_USERS"):
monkeypatch.delenv(key, raising=False)
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig()
adapter = object.__new__(TelegramAdapter)
adapter.config = PlatformConfig(enabled=True, extra={"allow_from": "777"})
runner.config.platforms = {Platform.TELEGRAM: adapter.config}
runner.adapters = {Platform.TELEGRAM: adapter}
runner.pairing_store = PairingStore(profile="default")
def source(chat_type):
return SessionSource(platform=Platform.TELEGRAM, chat_id="-100555", chat_type=chat_type,
user_id="777")
assert runner._is_user_authorized(source("dm")) is True
assert runner._is_user_authorized(source("group")) is False
def test_button_click_in_a_thread_resolves_a_thread_scoped_route():
"""A click carries the thread id, so the live verdict resolves a ``thread_id`` profile route
the same way the thread's messages do (here: the routed profile's pairing store)."""
from agent import secret_scope
from gateway.pairing import PairingStore
home = Path(os.environ["HERMES_HOME"])
(home / "profiles" / "kody").mkdir(parents=True)
(home / "profiles" / "kody" / ".env").write_text("", encoding="utf-8")
(home / "config.yaml").write_text(
"gateway:\n multiplex_profiles: true\ndiscord:\n require_mention: true\n"
f"profile_routes:\n - name: kody-thread\n platform: discord\n profile: kody\n"
f" thread_id: \"{THREAD}\"\n",
encoding="utf-8",
)
(home / ".env").write_text("DISCORD_BOT_TOKEN=test-token\nDISCORD_ALLOWED_USERS=999\n", encoding="utf-8")
prev = secret_scope.is_multiplex_active()
secret_scope.set_multiplex_active(True)
try:
runner, adapter = _wire(home)
kody = PairingStore(profile="kody")
kody._save_json(kody._approved_path("discord"), {USER: {}})
runner.pairing_stores["kody"] = kody
message = adapter.build_source(chat_id=THREAD, chat_type="thread", user_id=USER,
user_name="u", thread_id=THREAD, guild_id="1")
runner._canonicalize(message, primary_home=home)
assert message.profile == "kody"
assert runner._is_user_authorized_for_source(message) is True
assert adapter._component_live_auth(_click(USER, _thread())) is True
finally:
secret_scope.set_multiplex_active(prev)
@pytest.mark.parametrize("user_id,expected", [(USER, True), (STRANGER, False)])
def test_secondary_profiles_own_bot_honors_its_config_allow_from_in_guild_channels(
monkeypatch, user_id, expected
):
"""A profile running its own Discord bot keeps its allowlist in its own config.yaml.
Under multiplex only the default profile's list reaches the env, so the guild-channel verdict for
the secondary's bot can come only from its adapter's ``allow_from`` (``PlatformConfig.extra``,
where the config loader puts it), as its DMs already do.
"""
from agent import secret_scope
from gateway.config import GatewayConfig, PlatformConfig
from gateway.pairing import PairingStore
from gateway.run import GatewayRunner
from plugins.platforms.discord.adapter import DiscordAdapter
home = Path(os.environ["HERMES_HOME"])
(home / "profiles" / "kody").mkdir(parents=True)
(home / ".env").write_text("DISCORD_BOT_TOKEN=test-token\nDISCORD_ALLOWED_USERS=999\n", encoding="utf-8")
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "999") # the default profile's live env
prev = secret_scope.is_multiplex_active()
secret_scope.set_multiplex_active(True)
try:
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner.pairing_store = PairingStore(profile="default")
runner.pairing_stores = {"default": runner.pairing_store, "kody": PairingStore(profile="kody")}
runner._primary_profile_name = "default"
runner.adapters = {}
adapter = DiscordAdapter(PlatformConfig(
enabled=True, token="kody-token", extra={"allow_from": USER, "require_mention": True}))
adapter._hermes_profile_name = "kody"
runner._profile_adapters = {"kody": {Platform.DISCORD: adapter}}
adapter.set_authorization_check(runner._make_adapter_auth_check(Platform.DISCORD, profile_name="kody"))
assert adapter._is_sender_authorized(user_id, "dm", CHANNEL) is expected
assert adapter._is_sender_authorized(user_id, "group", CHANNEL) is expected
finally:
secret_scope.set_multiplex_active(prev)
+686
View File
@@ -0,0 +1,686 @@
"""A local chat handed off to a messaging lane keeps opening there (#143286).
``/handoff`` repoints the platform's route at the local session (``SessionStore.switch_session``),
which rewrites the row's routing identity. Its local receipt must go with that write, or the row
matches neither lane: every open, pin or rename refused ``storage_unavailable`` (``stored identity
mismatch``) and its title (a Bot Chat's registry lookup) answered ``not_found``. A store an older
build left that way heals when the gateway starts.
"""
import json
from types import SimpleNamespace
import pytest
from tests.gateway.test_authority_cold_route_recovery import _Runner, _source
@pytest.fixture(autouse=True)
def _home(tmp_path, monkeypatch):
from gateway import run
monkeypatch.setenv('HERMES_HOME', str(tmp_path))
monkeypatch.setattr(run, '_load_gateway_config', lambda *_path: {'platform_toolsets': {'cli': []}, 'model': {'default': 'fixture'}})
def _compress(authority, sid):
"""Two real compression splits of the local chat, as its turns publish them."""
db, live = authority.db, authority.sessions[sid]
store = authority.runner.session_store
for parent, child in ((sid, sid + '-c1'), (sid + '-c1', sid + '-c2')):
store._record_gateway_session_peer(parent, live.route, live.source)
assert db.try_acquire_compression_lock(parent, 'fixture')
db.publish_compression_child(parent_session_id=parent, child_session_id=child, source='local',
messages=[{'role': 'user', 'content': 'kept'}], compression_lock_holder='fixture')
db.release_compression_lock(parent, 'fixture')
store._record_gateway_session_peer(child, live.route, live.source)
async def _handoff(runner, authority, sid, source):
"""The handoff watcher's repoint (``GatewayStartupMixin._handoff_repoint``) on the real store."""
from gateway.run_startup import GatewayStartupMixin
from gateway.session import AsyncSessionStore
from hermes_state import AsyncSessionDB
route = runner.session_store._generate_session_key(source)
gateway = SimpleNamespace(_session_db=AsyncSessionDB(authority.db), session_authority=authority,
async_session_store=AsyncSessionStore(runner.session_store))
await GatewayStartupMixin._handoff_repoint(gateway, SimpleNamespace(source=source), route, sid)
return route
def _as_an_older_build(monkeypatch):
"""An older build's repoint: the row's routing identity moves, its local receipt stays."""
import hermes_state_local
for name in ('hand_off_conversations', 'release_receipts'):
monkeypatch.setattr(hermes_state_local, name, lambda *_args: None, raising=False)
@pytest.mark.asyncio
@pytest.mark.parametrize('history', ['plain', 'compressed', 'reset'])
@pytest.mark.parametrize('handed_off_by', ['this build', 'an older build'])
async def test_a_handed_off_bot_chat_opens_on_its_lane_after_a_restart(tmp_path, monkeypatch, handed_off_by, history):
"""``reset``: a local ``/new`` moved the chat to a new segment and its receipt names that one. A
handoff carries the conversation the user is looking at: the lane continues the post-``/new``
segment (the title follows it), and the earlier segment is that lane's read-only history, never a
local row no receipt restores."""
from gateway.config import Platform
from gateway.session_authority import initialize_session_authority
from gateway.session_contract import Principal, SessionRef
from gateway.session_local import create_local_session
from gateway.session_local_recovery import transcript_target
from gateway.session_local_title import resolve_titled_session
from hermes_state_local import POLICY_PREFIX, local_receipt
from hermes_state_local_migration import LEGACY_PREFIX
from hermes_state_runtime import RuntimeStoreError
runner = _Runner(tmp_path)
warm = await initialize_session_authority(runner, profile_id='fixture', instance_id='warm')
owner = Principal('owner', 'fixture', frozenset({'session:create', 'session:read', 'session:control'}), 'socket')
ref = create_local_session(warm, owner, {'request_id': 'bot-chat', 'source': 'gui', 'cwd': str(tmp_path),
'model': 'frozen', 'toolsets': []})
warm.db.set_session_title(ref.session_id, 'Bot Chat')
if history == 'reset':
runner.session_store.reset_session(warm.sessions[ref.session_id].route)
# Its first turn there gets the segment a generated title of its own.
warm.db.set_auto_title(local_receipt(warm.db, ref.session_id)['entry']['session_id'], 'Generated', source='llm')
if history == 'compressed':
_compress(warm, ref.session_id)
runner = _Runner(tmp_path) # a restart records the steps on the receipt (#140060)
warm = await initialize_session_authority(runner, profile_id='fixture', instance_id='adopted')
warm.authorize(owner, ref, 'session:read') # the receipt now names the tip
keys = (POLICY_PREFIX + ref.session_id, LEGACY_PREFIX + ref.session_id)
with warm.db._read_ctx() as conn:
assert conn.execute('SELECT 1 FROM state_meta WHERE key IN (?,?)', keys).fetchone()
# The lane's conversation: the reset segment the receipt now names, else the chat itself.
head = SessionRef('fixture', local_receipt(warm.db, ref.session_id)['entry']['session_id']
if history == 'reset' else ref.session_id)
telegram = _source()
with monkeypatch.context() as patch:
if handed_off_by == 'an older build':
_as_an_older_build(patch)
route = await _handoff(runner, warm, ref.session_id, telegram)
assert warm.logical_owner(runner.session_store.lookup_by_session_key(route).session_id) == head.session_id
if handed_off_by == 'this build':
# The Desktop that ran /handoff still holds the chat open.
warm.authorize(owner, ref, 'session:read')
if history == 'reset': # its pre-/new segment is the lane's history now
with pytest.raises(RuntimeStoreError, match='session_superseded'):
warm.authorize(owner, ref, 'session:control')
else:
warm.authorize(owner, ref, 'session:control')
assert transcript_target(warm, ref) == warm.physical_target(ref)
cold = await initialize_session_authority(_Runner(tmp_path), profile_id='fixture', instance_id='cold')
# A browser Webapp viewer: never a native legacy transport, so legacy adoption is not its way in.
webapp = Principal('webapp', 'fixture', frozenset({'session:read', 'session:control'}), 'browser')
assert resolve_titled_session(cold, webapp, 'Bot Chat') == head
cold.authorize(webapp, head, 'session:control') # pin, rename, archive
live = cold.sessions[head.session_id]
assert (live.source.platform, live.route) == (Platform.TELEGRAM, route)
with cold.db._read_ctx() as conn:
assert conn.execute('SELECT 1 FROM state_meta WHERE key IN (?,?)', keys).fetchone() is None
tip = cold.db.get_session(cold.physical_target(head))
assert json.loads(tip['origin_json'])['platform'] == 'telegram'
if history == 'reset': # the earlier segment opens read-only on that lane
cold.authorize(webapp, ref, 'session:read')
assert cold.sessions[ref.session_id].route == route
with pytest.raises(RuntimeStoreError, match='session_superseded'):
cold.authorize(webapp, ref, 'session:control')
@pytest.mark.asyncio
async def test_a_receipt_whose_current_segment_is_still_local_is_never_released(tmp_path, monkeypatch):
"""An older build handed off a chat's FIRST segment after a local ``/new``: that row moved, but
the receipt names the post-``/new`` segment, still local. Releasing it would leave that
transcript reachable from nowhere, so the boot heal keeps it and the conversation still opens."""
from gateway.session_authority import initialize_session_authority
from gateway.session_contract import Principal
from gateway.session_local import create_local_session
from hermes_state_local import local_receipt
runner = _Runner(tmp_path)
warm = await initialize_session_authority(runner, profile_id='fixture', instance_id='warm')
owner = Principal('owner', 'fixture', frozenset({'session:create', 'session:read', 'session:control'}), 'socket')
ref = create_local_session(warm, owner, {'request_id': 'chat', 'source': 'gui', 'cwd': str(tmp_path),
'model': 'frozen', 'toolsets': []})
runner.session_store.reset_session(warm.sessions[ref.session_id].route)
head = local_receipt(warm.db, ref.session_id)['entry']['session_id']
store, telegram = runner.session_store, _source()
with monkeypatch.context() as patch: # the older build's watcher: switch_session(route, <the id>)
_as_an_older_build(patch)
store.get_or_create_session(telegram)
store.switch_session(store._generate_session_key(telegram), ref.session_id, preserve_prompt_pin=False)
cold = await initialize_session_authority(_Runner(tmp_path), profile_id='fixture', instance_id='cold')
assert local_receipt(cold.db, ref.session_id)['entry']['session_id'] == head
assert cold.db.get_session(head)['source'] == 'gui'
async def _handed_off_bot_chat(tmp_path):
"""A Bot Chat the Desktop minted and a ``/handoff`` moved to Telegram, still open in that Desktop."""
from gateway.session_authority import initialize_session_authority
from gateway.session_contract import Principal
from gateway.session_local import create_local_session
runner = _Runner(tmp_path)
warm = await initialize_session_authority(runner, profile_id='fixture', instance_id='warm')
owner = Principal('owner', 'fixture', frozenset({'session:create', 'session:read', 'session:control',
'session:submit'}), 'socket')
ref = create_local_session(warm, owner, {'request_id': 'bot-chat', 'source': 'gui', 'cwd': str(tmp_path),
'model': 'frozen', 'toolsets': []})
warm.db.set_session_title(ref.session_id, 'Bot Chat')
telegram = _source()
route = await _handoff(runner, warm, ref.session_id, telegram)
warm.authorize(owner, ref, 'session:control')
return runner, warm, owner, ref, telegram, route
@pytest.mark.asyncio
async def test_a_handed_off_bot_chat_stays_the_bots_chat_while_its_lane_holds_it(tmp_path):
"""Bot Mode's one canonical chat per profile: a Bot or cron delivery after the restart goes to
the handed-off Bot Chat on its lane; it is never renamed aside for a freshly minted one."""
from gateway.session_authority import initialize_session_authority
from gateway.session_bot import _target, displaceable_bot_chat
from gateway.session_local_title import resolve_titled_session
_runner, _warm, owner, ref, _telegram, route = await _handed_off_bot_chat(tmp_path)
cold = await initialize_session_authority(_Runner(tmp_path), profile_id='fixture', instance_id='cold')
assert displaceable_bot_chat(cold, cold.db.get_session_by_title('Bot Chat')) is None
target, live, entry = await _target(cold, owner)
assert target == ref and live.route == route == entry.session_key
assert resolve_titled_session(cold, owner, 'Bot Chat') == ref
assert cold.db.get_session(ref.session_id)['title'] == 'Bot Chat'
async def _bot_row(authority, actor):
"""What the Desktop Bots row reads for the profile: the roster's ``canonical_session`` (the
``profiles.list`` reader of that profile's store) and the registry lookup ``session.list
title="Bot Chat"`` its click runs. ``canonical-chat.ts`` refuses to mint when the lookup is
empty but the roster names a chat, so the two must agree."""
from pathlib import Path
from gateway.session_controls import AuthorityConnection
from tui_gateway.server import _profile_session_fields
from tui_gateway.profile_roster_cache import invalidate
invalidate()
roster = {}
_profile_session_fields(roster, Path(authority.db.db_path).parent)
canonical = roster['canonical_session']
listed = await AuthorityConnection(authority, object(), {'user_id': actor.subject}).list_sessions(
None, {'title': 'Bot Chat', 'include_hidden': True, 'limit': 50})
found = [row['id'] for row in listed['sessions']]
assert (canonical and canonical['id']) == (found[0] if found else None), (canonical, found)
return found[0] if found else None
@pytest.mark.asyncio
@pytest.mark.parametrize('door', ['Desktop create', 'Bot delivery'])
async def test_a_handed_off_bot_chat_whose_lane_moved_on_is_moved_aside(tmp_path, door):
"""After the platform's own ``/new`` the handed-off chat is that lane's history: the canonical
title moves aside at once, so the Bots row's roster and its registry lookup agree at every step
(warm, cold) and its click mints a fresh Bot Chat; the Desktop still viewing the old one is
read-only (its route names the new conversation)."""
from gateway.session_authority import initialize_session_authority
from gateway.session_bot import _target
from gateway.session_controls import AuthorityConnection
from hermes_state_runtime import RuntimeStoreError
runner, warm, owner, ref, telegram, route = await _handed_off_bot_chat(tmp_path)
assert await _bot_row(warm, owner) == ref.session_id
runner.session_store.reset_session(route) # Telegram's /new
assert await _bot_row(warm, owner) is None
newer = warm.register(telegram)
assert newer != ref and warm.sessions[newer.session_id].route == route
assert warm.sessions[ref.session_id].superseded and warm.agent(ref) is None
with pytest.raises(RuntimeStoreError, match='session_superseded'):
warm.authorize(owner, ref, 'session:control')
cold_runner = _Runner(tmp_path)
cold = await initialize_session_authority(cold_runner, profile_id='fixture', instance_id='cold')
cold_runner._adapter_for_source = lambda source: cold_runner.adapters.get(source.platform)
cold_runner._cached_agent_for = lambda route: None
assert await _bot_row(cold, owner) is None
if door == 'Desktop create': # the click: nothing found, so it mints
opened = await AuthorityConnection(cold, object(), {'user_id': owner.subject}).create(
None, {'title': 'Bot Chat', 'request_id': 'bot-chat-2', 'source': 'gui', 'cwd': str(tmp_path),
'model': 'frozen', 'toolsets': [], 'hidden': True})
fresh = opened['session_id']
else: # the bot's own delivery, as the Desktop's principal (whose roster reads it)
from gateway.session_contract import Principal
desktop = AuthorityConnection(cold, object(), {'user_id': owner.subject}).actor
fresh = (await _target(cold, Principal(desktop.subject, 'fixture', owner.capabilities, 'socket')))[0].session_id
assert fresh not in (ref.session_id, newer.session_id)
assert await _bot_row(cold, owner) == fresh
moved = cold.db.get_session(ref.session_id)
assert moved['title'] == f'Bot Chat (telegram {ref.session_id})' and not moved['hidden']
@pytest.mark.asyncio
async def test_a_second_chat_handed_off_to_the_same_home_chat_supersedes_the_first(tmp_path):
"""Desktop A handed off its Bot Chat H to the Telegram home chat; Desktop B then hands off its
own open chat Y there. The lane names Y now: H is history, read-only for the Desktop still
viewing it (a Stop or turn through it would act on Y), and its canonical title moves aside."""
from gateway.session_authority import initialize_session_authority
from gateway.session_local import create_local_session
from hermes_state_runtime import RuntimeStoreError
runner, warm, owner, ref, telegram, route = await _handed_off_bot_chat(tmp_path)
other = create_local_session(warm, owner, {'request_id': 'other-chat', 'source': 'gui', 'cwd': str(tmp_path),
'model': 'frozen', 'toolsets': []})
warm.authorize(owner, other, 'session:control')
assert await _handoff(runner, warm, other.session_id, telegram) == route
assert warm.logical_owner(runner.session_store.lookup_by_session_key(route).session_id) == other.session_id
warm.authorize(owner, other, 'session:submit')
with pytest.raises(RuntimeStoreError, match='session_superseded'):
warm.authorize(owner, ref, 'session:submit')
assert warm.agent(ref) is None
assert await _bot_row(warm, owner) is None
# H handed off there again: the lane names H once more, and Y is the one that is history now.
await _handoff(runner, warm, ref.session_id, telegram)
warm.authorize(owner, ref, 'session:submit')
with pytest.raises(RuntimeStoreError, match='session_superseded'):
warm.authorize(owner, other, 'session:submit')
cold = await initialize_session_authority(_Runner(tmp_path), profile_id='fixture', instance_id='cold')
cold.authorize(owner, ref, 'session:control')
cold.authorize(owner, other, 'session:read')
with pytest.raises(RuntimeStoreError, match='session_superseded'):
cold.authorize(owner, other, 'session:control')
@pytest.mark.asyncio
async def test_the_bots_own_bot_chat_after_a_reset_handoff_and_the_lanes_new_is_replaced(tmp_path):
"""The Bot Chat a delivery minted (its automatic creation id), reset locally, handed off, then the
platform's ``/new``: the next delivery mints the next generation. The handed-off creation row
(its name went to the head) is spent, never reused as an unfinished creation."""
from gateway.session_authority import initialize_session_authority
from gateway.session_bot import _target
from gateway.session_contract import Principal
runner = _Runner(tmp_path)
runner._adapter_for_source = lambda source: runner.adapters.get(source.platform)
runner._cached_agent_for = lambda route: None
warm = await initialize_session_authority(runner, profile_id='fixture', instance_id='warm')
owner = Principal('owner', 'fixture', frozenset({'session:create', 'session:read', 'session:control',
'session:submit'}), 'socket')
first = (await _target(warm, owner))[0]
runner.session_store.reset_session(warm.sessions[first.session_id].route)
telegram = _source()
route = await _handoff(runner, warm, first.session_id, telegram)
head = runner.session_store.lookup_by_session_key(route).session_id
assert warm.db.get_session_by_title('Bot Chat')['id'] == head
runner.session_store.reset_session(route) # Telegram's /new
fresh = (await _target(warm, owner))[0].session_id
assert fresh not in (first.session_id, head)
assert warm.db.get_session_by_title('Bot Chat')['id'] == fresh
@pytest.mark.asyncio
@pytest.mark.parametrize('lands', ['after the title read', 'before the displace write'])
async def test_a_delivery_racing_the_lanes_new_mints_the_fresh_bot_chat(tmp_path, monkeypatch, lands):
"""The lane's ``/new`` frees the handed-off Bot Chat's title from the store's worker thread,
outside the titled-create lock. A delivery it lands in the middle of still lands in exactly one
canonical Bot Chat, a fresh one, instead of failing on the title that vanished under it."""
from gateway import session_bot
runner, warm, owner, ref, _telegram, route = await _handed_off_bot_chat(tmp_path)
runner._adapter_for_source = lambda source: runner.adapters.get(source.platform)
runner._cached_agent_for = lambda route: None
release, displaceable = session_bot.release_superseded_bot_chat, session_bot.displaceable_bot_chat
if lands == 'before the displace write': # the /new switched the lane; its rename is still pending
monkeypatch.setattr(session_bot, 'release_superseded_bot_chat', lambda *_args: None)
runner.session_store.reset_session(route)
seen = []
def racing(authority, row):
found = displaceable(authority, row)
seen.append(found)
if lands == 'after the title read' and len(seen) == 1:
runner.session_store.reset_session(route) # the lane held it when the delivery read it
elif lands == 'before the displace write' and len(seen) == 2: # under the lock
release(warm.db, runner.session_store, route, ref.session_id)
return found
monkeypatch.setattr(session_bot, 'displaceable_bot_chat', racing)
fresh = (await session_bot._target(warm, owner))[0].session_id
assert fresh != ref.session_id
assert warm.db.get_session_by_title('Bot Chat')['id'] == fresh
assert warm.db.get_session(ref.session_id)['title'] == f'Bot Chat (telegram {ref.session_id})'
def _resume_gateway(runner, authority):
"""The platform's ``/resume`` handler (``GatewayRunner._handle_resume_command``) on the real store,
run by an admin (``--all`` widens it to every titled conversation)."""
from gateway.run import GatewayRunner
from hermes_state import AsyncSessionDB
gateway = object.__new__(GatewayRunner)
gateway.adapters, gateway.config, gateway._voice_mode, gateway._running_agents = {}, runner.config, {}, {}
gateway._session_db, gateway.session_store = AsyncSessionDB(authority.db), runner.session_store
gateway.session_authority = authority
gateway._is_user_authorized = gateway._resume_caller_is_admin = lambda _source: True
return gateway
@pytest.mark.asyncio
async def test_a_platform_resume_of_a_local_conversation_continues_its_current_head(tmp_path):
"""Bot Chat X, then a local ``/new`` (the receipt names the new segment X2), then an admin's
``/resume --all Bot Chat`` on Telegram: the title still names X, the first segment. The lane
continues the conversation's head, as ``/handoff`` carries it, so X2 never stays a local row
whose receipt was released (refused ``storage_unavailable`` after a restart)."""
from gateway.platforms.event import MessageEvent
from gateway.session_authority import initialize_session_authority
from gateway.session_contract import Principal, SessionRef
from gateway.session_local import create_local_session
from gateway.session_local_title import resolve_titled_session
from hermes_state_local import local_receipt
runner = _Runner(tmp_path)
warm = await initialize_session_authority(runner, profile_id='fixture', instance_id='warm')
owner = Principal('owner', 'fixture', frozenset({'session:create', 'session:read', 'session:control'}), 'socket')
ref = create_local_session(warm, owner, {'request_id': 'bot-chat', 'source': 'gui', 'cwd': str(tmp_path),
'model': 'frozen', 'toolsets': []})
warm.db.set_session_title(ref.session_id, 'Bot Chat')
runner.session_store.reset_session(warm.sessions[ref.session_id].route)
head = SessionRef('fixture', local_receipt(warm.db, ref.session_id)['entry']['session_id'])
telegram = _source()
reply = await _resume_gateway(runner, warm)._handle_resume_command(
MessageEvent(text='/resume --all Bot Chat', source=telegram))
route = runner.session_store._generate_session_key(telegram)
assert runner.session_store.lookup_by_session_key(route).session_id == head.session_id, reply
cold = await initialize_session_authority(_Runner(tmp_path), profile_id='fixture', instance_id='cold')
cold.authorize(owner, head, 'session:control')
assert resolve_titled_session(cold, owner, 'Bot Chat') == head
assert cold.sessions[head.session_id].route == route
@pytest.mark.asyncio
@pytest.mark.parametrize('opened', ['followed by the Desktop', 'a viewer after a restart', 'the lane after a restart'])
@pytest.mark.parametrize('takeover', ['another chat handed off', "the platform's /new"])
@pytest.mark.parametrize('door', ['/handoff', 'platform /resume'])
@pytest.mark.parametrize('history', ['plain', 'reset'])
async def test_a_moved_conversation_is_read_only_once_its_lane_is_taken_over(tmp_path, history, door, takeover,
opened):
"""Bot Chat X (``reset``: a local ``/new`` moved it to X2) moves onto a Telegram lane, which
continues its head, writable there. Once that lane is taken over (another chat handed off to
it, or the platform's ``/new`` and its next inbound) the head is the lane's history, however it
was mounted: the Desktop that followed it, a viewer or the lane's inbound after a restart. Its
only route is the lane's, so a turn or a Stop through it would act on the new conversation:
read stays open, submit and control are refused, warm and after a restart."""
from gateway.platforms.event import MessageEvent
from gateway.session_authority import initialize_session_authority
from gateway.session_contract import Principal, SessionRef
from gateway.session_local import create_local_session
from hermes_state_local import local_receipt
from hermes_state_runtime import RuntimeStoreError
runner = _Runner(tmp_path)
warm = await initialize_session_authority(runner, profile_id='fixture', instance_id='warm')
owner = Principal('owner', 'fixture', frozenset({'session:create', 'session:read', 'session:control',
'session:submit'}), 'socket')
ref = create_local_session(warm, owner, {'request_id': 'bot-chat', 'source': 'gui', 'cwd': str(tmp_path),
'model': 'frozen', 'toolsets': []})
warm.db.set_session_title(ref.session_id, 'Bot Chat')
if history == 'reset':
runner.session_store.reset_session(warm.sessions[ref.session_id].route)
head = SessionRef('fixture', local_receipt(warm.db, ref.session_id)['entry']['session_id'])
telegram = _source()
if door == '/handoff':
route = await _handoff(runner, warm, ref.session_id, telegram)
else:
await _resume_gateway(runner, warm)._handle_resume_command(
MessageEvent(text='/resume --all Bot Chat', source=telegram))
route = runner.session_store._generate_session_key(telegram)
assert runner.session_store.lookup_by_session_key(route).session_id == head.session_id
if opened != 'followed by the Desktop':
runner = _Runner(tmp_path)
warm = await initialize_session_authority(runner, profile_id='fixture', instance_id='restarted')
if opened == 'a viewer after a restart':
warm.authorize(owner, head, 'session:read')
warm.register(telegram) # the lane's next inbound
warm.authorize(owner, head, 'session:submit') # the lane's conversation: writable there
if takeover == 'another chat handed off':
other = create_local_session(warm, owner, {'request_id': 'other-chat', 'source': 'gui',
'cwd': str(tmp_path), 'model': 'frozen', 'toolsets': []})
await _handoff(runner, warm, other.session_id, telegram)
else:
runner.session_store.reset_session(route)
warm.register(telegram)
assert warm.logical_owner(runner.session_store.lookup_by_session_key(route).session_id) != head.session_id
cold = await initialize_session_authority(_Runner(tmp_path), profile_id='fixture', instance_id='cold')
for authority in (warm, cold):
authority.authorize(owner, head, 'session:read')
for capability in ('session:submit', 'session:control'):
with pytest.raises(RuntimeStoreError, match='session_superseded'):
authority.authorize(owner, head, capability)
assert authority.agent(head) is None # never the lane's running agent
@pytest.mark.asyncio
async def test_a_moved_conversation_mounted_by_startup_recovery_is_read_only_once_its_lane_is_taken_over(
tmp_path, monkeypatch):
"""Bot Chat A moves onto a Telegram chat and stays there for months: its idle local routing entry
is pruned (``session_store_max_age_days``). A restart finds the lane's input still queued, so
startup recovery is what mounts A, and the lane's next inbound reuses that mount. Once another
chat is handed off to the lane, A is its history: a viewer's turn would run as that chat's."""
from datetime import datetime, timedelta
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.event import MessageEvent
from gateway.run import GatewayRunner
from gateway.run_runtime import recover_profile_native_sessions
from gateway.session_authority import initialize_session_authority
from gateway.session_contract import Principal
from gateway.session_ingress_context import native_callback, register_transport_home
from gateway.session_local import create_local_session
from hermes_state_runtime import RuntimeStoreError
from tests.gateway.test_command_bypass_active_session import _StubAdapter
monkeypatch.setenv('TELEGRAM_ALLOWED_USERS', '42')
monkeypatch.setattr('gateway.run._hermes_home', tmp_path)
monkeypatch.setattr('hermes_state.DEFAULT_DB_PATH', tmp_path / 'state.db')
def boot():
runner = GatewayRunner(GatewayConfig(multiplex_profiles=False, sessions_dir=tmp_path / 'sessions'))
adapter = _StubAdapter(PlatformConfig(enabled=True, token='fixture'), Platform.TELEGRAM)
adapter.gateway_runner, runner.adapters = runner, {Platform.TELEGRAM: adapter}
register_transport_home(runner, None, tmp_path)
return runner, adapter.build_source(chat_id='42', chat_type='dm', user_id='42')
def chat(authority, title):
ref = create_local_session(authority, owner, {'request_id': title, 'source': 'gui', 'cwd': str(tmp_path),
'model': 'frozen', 'toolsets': []})
authority.db.set_session_title(ref.session_id, title)
return ref
runner, telegram = boot()
warm = await initialize_session_authority(runner, profile_id=str(tmp_path), instance_id='warm')
warm._schedule = lambda ref: None
owner = Principal('owner', warm.profile_id, frozenset({'session:create', 'session:read', 'session:control',
'session:submit'}), 'socket')
a = chat(warm, 'Chat A')
route = await _handoff(runner, warm, a.session_id, telegram)
queued = MessageEvent(text='still queued at the restart', source=telegram, message_id='queued')
with native_callback(runner, queued, tmp_path):
assert (await warm.admit_native(queued)).ref == a
store = runner.session_store
for entry in store._entries.values():
if entry.session_key != route:
entry.updated_at = datetime.now() - timedelta(days=120)
assert store.prune_old_entries(90) == 1
store.close_all_db_handles()
runner, telegram = boot()
try:
cold = await initialize_session_authority(runner, profile_id=warm.profile_id, instance_id='restarted')
cold._schedule = lambda ref: None
assert await recover_profile_native_sessions(runner, cold) == {a.session_id: 'ready'}
cold.register(telegram)
cold.authorize(owner, a, 'session:submit') # the lane's conversation: writable there
await _handoff(runner, cold, chat(cold, 'Chat B').session_id, telegram)
cold.authorize(owner, a, 'session:read')
for capability in ('session:submit', 'session:control'):
with pytest.raises(RuntimeStoreError, match='session_superseded'):
cold.authorize(owner, a, capability)
finally:
runner.session_store.close_all_db_handles()
@pytest.mark.asyncio
async def test_a_moved_conversation_resumed_on_another_chat_moves_there(tmp_path):
"""Bot Chat A moves onto Telegram chat 4242, chat B is handed off there in turn (A is that chat's
history now), then an admin's ``/resume --all Chat A`` brings A to chat 77. Its mount follows it:
a turn or a Stop through it acts on chat 77, never on B, which holds 4242; and once chat 77 moves
on (its ``/new``) A is that chat's history, read-only again."""
from gateway.platforms.event import MessageEvent
from gateway.session_authority import initialize_session_authority
from gateway.session_contract import Principal
from gateway.session_local import create_local_session
from hermes_state_runtime import RuntimeStoreError
runner = _Runner(tmp_path)
warm = await initialize_session_authority(runner, profile_id='fixture', instance_id='warm')
owner = Principal('owner', 'fixture', frozenset({'session:create', 'session:read', 'session:control',
'session:submit'}), 'socket')
def chat(title):
ref = create_local_session(warm, owner, {'request_id': title, 'source': 'gui', 'cwd': str(tmp_path),
'model': 'frozen', 'toolsets': []})
warm.db.set_session_title(ref.session_id, title)
return ref
first, second = _source(), _source(chat_id='77')
a = chat('Chat A')
await _handoff(runner, warm, a.session_id, first)
await _handoff(runner, warm, chat('Chat B').session_id, first)
with pytest.raises(RuntimeStoreError, match='session_superseded'):
warm.authorize(owner, a, 'session:submit')
await _resume_gateway(runner, warm)._handle_resume_command(
MessageEvent(text='/resume --all Chat A', source=second))
route = runner.session_store._generate_session_key(second)
assert runner.session_store.lookup_by_session_key(route).session_id == a.session_id
warm.register(second) # that chat's next inbound
warm.authorize(owner, a, 'session:submit')
live = warm.sessions[a.session_id]
assert (live.source.chat_id, live.route) == ('77', route)
runner.session_store.reset_session(route)
warm.register(second)
for capability in ('session:submit', 'session:control'):
with pytest.raises(RuntimeStoreError, match='session_superseded'):
warm.authorize(owner, a, capability)
@pytest.mark.asyncio
async def test_a_moved_receipt_with_a_malformed_lineage_never_stops_the_boot(tmp_path, monkeypatch):
from gateway.session_authority import initialize_session_authority
from gateway.session_contract import Principal
from gateway.session_local import create_local_session
from hermes_state_local import POLICY_PREFIX
runner = _Runner(tmp_path)
warm = await initialize_session_authority(runner, profile_id='fixture', instance_id='warm')
owner = Principal('owner', 'fixture', frozenset({'session:create', 'session:read', 'session:control'}), 'socket')
ref = create_local_session(warm, owner, {'request_id': 'chat', 'source': 'gui', 'cwd': str(tmp_path),
'model': 'frozen', 'toolsets': []})
with monkeypatch.context() as patch:
_as_an_older_build(patch)
await _handoff(runner, warm, ref.session_id, _source())
key = POLICY_PREFIX + ref.session_id
warm.db._execute_write(lambda conn: conn.execute(
"UPDATE state_meta SET value=json_set(value, '$.lineage', json(?)) WHERE key=?",
(json.dumps([ref.session_id, {}]), key)))
cold = await initialize_session_authority(_Runner(tmp_path), profile_id='fixture', instance_id='cold')
with cold.db._read_ctx() as conn:
assert conn.execute('SELECT 1 FROM state_meta WHERE key=?', (key,)).fetchone()
def _serve(tmp_path, monkeypatch):
"""A multiplexed gateway's homes: the default profile and a named ``bot`` profile, each with its
own ``state.db``; ``bot`` delivers to a Telegram home channel."""
from pathlib import Path
import hermes_state
root = tmp_path / '.hermes'
bot = root / 'profiles' / 'bot'
bot.mkdir(parents=True, mode=0o700)
(bot / 'config.yaml').write_text(
'platforms:\n telegram:\n enabled: true\n token: fixture\n'
' home_channel: {platform: telegram, chat_id: "-1001", name: Bot Home}\n')
monkeypatch.setattr(Path, 'home', lambda: tmp_path) # the profiles root is HOME-anchored
monkeypatch.setenv('HERMES_HOME', str(root))
monkeypatch.setattr(hermes_state, 'DEFAULT_DB_PATH', hermes_state._IMPORT_DEFAULT_DB_PATH)
return root, bot
async def _boot(root, bot):
"""One gateway process: a real runner whose runtime builds one authority per served home."""
from gateway.config import GatewayConfig
from gateway.run import GatewayRunner
from gateway.run_runtime import initialize_gateway_runtime
config = GatewayConfig()
config.multiplex_profiles = True
config._runtime_profile_homes = (('default', root), ('bot', bot))
runner = GatewayRunner(config)
await initialize_gateway_runtime(runner)
return runner
def _shutdown(runner):
for authority in list(runner.session_authorities):
authority.db.close()
runner.session_store.close_all_db_handles()
runner.close_all_session_db_handles()
@pytest.mark.asyncio
@pytest.mark.parametrize('handed_off_by', ['this build', 'an older build'])
async def test_a_named_profiles_handed_off_bot_chat_opens_on_its_lane_after_a_restart(
tmp_path, monkeypatch, handed_off_by):
"""The reporter's topology: a named bot profile under a multiplexed gateway. The chat lives in
that profile's own store, the handoff watcher's ``_process_handoff`` runs in its scope and keys
the lane ``agent:bot:...``; after a restart the Webapp still finds and opens it there."""
import sqlite3
from unittest.mock import AsyncMock
from gateway.config import Platform
from gateway.run import _async_profile_runtime_scope
from gateway.runtime_ownership import process_ownership
from gateway.session_authorities import active_authority
from gateway.session_contract import Principal
from gateway.session_local import create_local_session
from gateway.session_local_recovery import transcript_target
from gateway.session_local_title import resolve_titled_session
from hermes_state_local import POLICY_PREFIX
from hermes_state_local_migration import LEGACY_PREFIX
root, bot = _serve(tmp_path, monkeypatch)
process_ownership.reserve([root, bot])
try:
runner = await _boot(root, bot)
runner._profile_adapters = {'bot': {Platform.TELEGRAM: SimpleNamespace(
create_handoff_thread=AsyncMock(return_value=None))}}
runner._handle_message = AsyncMock(return_value='') # the synthetic handoff turn itself
async with _async_profile_runtime_scope(bot):
warm = active_authority(runner)
caps = frozenset({'session:create', 'session:read', 'session:control'})
owner = Principal('owner', warm.profile_id, caps, 'socket')
ref = create_local_session(warm, owner, {'request_id': 'bot-chat', 'source': 'gui', 'cwd': str(tmp_path),
'model': 'frozen', 'toolsets': []})
warm.db.set_session_title(ref.session_id, 'Bot Chat')
keys = (POLICY_PREFIX + ref.session_id, LEGACY_PREFIX + ref.session_id)
with warm.db._read_ctx() as conn:
receipt = conn.execute('SELECT key, value FROM state_meta WHERE key IN (?,?)', keys).fetchall()
assert receipt
await runner._process_handoff({'id': ref.session_id, 'title': 'Bot Chat', 'handoff_platform': 'telegram'},
'bot')
if handed_off_by == 'an older build':
warm.db._execute_write(lambda conn: conn.executemany(
'INSERT OR REPLACE INTO state_meta(key, value) VALUES(?,?)', [tuple(row) for row in receipt]))
else:
warm.authorize(owner, ref, 'session:control') # the Desktop that ran /handoff
assert transcript_target(warm, ref) == warm.physical_target(ref)
_shutdown(runner)
runner = await _boot(root, bot)
async with _async_profile_runtime_scope(bot):
cold = active_authority(runner)
webapp = Principal('webapp', cold.profile_id, frozenset({'session:read', 'session:control'}), 'browser')
assert resolve_titled_session(cold, webapp, 'Bot Chat') == ref
cold.authorize(webapp, ref, 'session:control') # pin, rename, archive
live = cold.sessions[ref.session_id]
assert live.source.platform == Platform.TELEGRAM and live.route.startswith('agent:bot:telegram:')
with cold.db._read_ctx() as conn:
assert conn.execute('SELECT 1 FROM state_meta WHERE key IN (?,?)', keys).fetchone() is None
_shutdown(runner)
with sqlite3.connect(root / 'state.db') as conn: # never the default profile's store
assert conn.execute('SELECT 1 FROM sessions WHERE id=?', (ref.session_id,)).fetchone() is None
finally:
for home in (root, bot):
process_ownership.release(home)
@@ -0,0 +1,131 @@
"""A chat's /new (or /resume) wins over a turn that finishes on its previous conversation.
A Desktop turn runs on a Telegram chat's conversation A when the platform user sends ``/new``. The
reset publishes the chat's new conversation B on its route, then writes state.db (A ended, B's row
inserted). The finishing turn resolves the same route for its post-turn bookkeeping; if that lands
between the two halves, B has no row yet, and treating it as deleted hands the route back to A, so
the chat's next message continues the conversation the user just left. ``/resume`` has the same
window: the resumed row is reopened only after the route names it.
Real GatewayRunner, SessionAuthority, SessionStore and SessionDB; the commands run through their own
handlers. The window is held open at the real SQL statement that ends the predecessor row.
"""
import asyncio
import threading
import weakref
from pathlib import Path
import pytest
from gateway.config import GatewayConfig, Platform
from gateway.platforms.event import MessageEvent, MessageType
from gateway.run import GatewayRunner
from gateway.run_runtime import initialize_gateway_runtime
from gateway.runtime_ownership import process_ownership
from gateway.session import SessionSource
from gateway.session_ingress_context import register_transport_home
from tests.waiting import async_wait_until
class _Adapter:
pass
@pytest.fixture
def served(tmp_path, monkeypatch):
root = tmp_path / '.hermes'
root.mkdir(mode=0o700)
monkeypatch.setattr(Path, 'home', lambda: tmp_path)
monkeypatch.setenv('HERMES_HOME', str(root))
import hermes_state
monkeypatch.setattr(hermes_state, 'DEFAULT_DB_PATH', hermes_state._IMPORT_DEFAULT_DB_PATH)
config = GatewayConfig()
config._runtime_profile_homes = (('default', root),)
runner = GatewayRunner(config)
process_ownership.reserve([root])
yield runner, root
for served_authority in list(getattr(runner, 'session_authorities', []) or []):
served_authority.db.close()
process_ownership.release(root)
async def _start(runner, root):
await initialize_gateway_runtime(runner)
register_transport_home(runner, None, root)
adapter = _Adapter()
runner.adapters = {Platform.TELEGRAM: adapter}
runner._is_user_authorized_for_source = lambda *a, **k: True
from gateway.session_authorities import active_authority
return runner, active_authority(runner), adapter
def _chat(adapter):
source = SessionSource(platform=Platform.TELEGRAM, chat_id='4242', chat_type='dm', user_id='u1',
user_name='probe')
source._transport_adapter_ref = weakref.ref(adapter)
return source
def _command(runner, source, text):
event = MessageEvent(text=text, message_type=MessageType.TEXT, source=source, message_id='m-' + text)
handler = runner._handle_reset_command if text.startswith('/new') else runner._handle_resume_command
return handler(event)
class _HoldPredecessorEnd:
"""Parks the route transition at the SQL that ends the route's previous row: the route already
names the new conversation, and nothing of it is in state.db yet."""
def __init__(self, db):
self.conn, self.reached, self.release = db._conn, threading.Event(), threading.Event()
def _trace(self, sql):
if 'SET ended_at' in sql and not self.reached.is_set():
self.reached.set()
self.release.wait(30)
def __enter__(self):
self.conn.set_trace_callback(self._trace)
return self
def __exit__(self, *exc):
self.release.set()
self.conn.set_trace_callback(None)
@pytest.mark.asyncio
@pytest.mark.parametrize('command', ['/new', '/resume First chat'])
async def test_a_turn_finishing_mid_transition_leaves_the_chat_on_the_conversation_it_named(served, command):
runner, authority, adapter = await _start(*served)
store, chat = runner.session_store, _chat(adapter)
key = runner._session_key_for_source(chat)
a = authority.register(chat).session_id # the chat's first message
authority.db.set_session_title(a, 'First chat')
if command.startswith('/resume'):
await _command(runner, chat, '/new')
authority.register(chat) # the chat talks in its second conversation, then resumes the first
before = store.peek_session_id(key)
with _HoldPredecessorEnd(authority.db) as hold:
transition = asyncio.create_task(_command(runner, chat, command))
await async_wait_until(hold.reached.is_set, what='the transition reaches state.db')
named = store.lookup_by_session_key(key)
assert named.session_id not in (None, before)
stamp = named.updated_at
# The Desktop turn on the previous conversation returns: its post-turn bookkeeping resolves
# the chat's route while the transition is still writing state.db.
finishing = asyncio.create_task(runner._run_post_turn_hooks(
agent_result={'final_response': 'the answer on the old conversation'}, source=chat,
is_internal=False))
await async_wait_until(
lambda: store.peek_session_id(key) != named.session_id or named.updated_at != stamp,
what='the finishing turn resolved the route')
hold.release.set()
await transition
await finishing
label = {named.session_id: 'the conversation the command named', before: 'the one it left'}.get
assert label(store.peek_session_id(key), 'a fresh one') == 'the conversation the command named'
# Where the chat's next message lands.
assert label(authority.register(chat).session_id, 'a fresh one') == 'the conversation the command named'
assert authority.db.get_session(before)['end_reason'] is not None
@@ -15,14 +15,15 @@ class _Agent:
session_id = "s1"
def __init__(self, results):
self.results, self.calls = list(results), []
self.results, self.calls, self.authors = list(results), [], []
self._session_db = self
def get_messages_as_conversation(self, _sid):
return [{"role": "user", "content": "from-store"}]
def run_conversation(self, message, conversation_history=None):
def run_conversation(self, message, conversation_history=None, turn_author=None):
self.calls.append((message, conversation_history))
self.authors.append(turn_author)
return self.results.pop(0)
@@ -63,6 +64,8 @@ def test_failed_turn_retries_in_place_from_frame_carrier(tmp_path, monkeypatch):
assert context["task_id"] in nudge and "t_foreign" not in nudge
assert history == FAILED["messages"] # same session context, not a cold restart
assert session_kanban.worker_exit_code(path, context) == 0
# Both the first turn and the in-place retry are the dispatcher's, never the operator's (#137406).
assert agent.authors == [{"id": "kanban:dispatcher", "name": "Kanban dispatcher", "is_bot": True}] * 2
def test_unprovable_claim_denies_retry_and_goal_continuation(tmp_path, monkeypatch):
@@ -0,0 +1,162 @@
"""A wake that arrives while the gateway drains for a restart must be refused, not lost.
A draining gateway answers new work with a drain notice and discards queued follow-ups, so a
wake it admitted would never run, while the Kanban notifier had already advanced its cursor.
"""
import asyncio
import threading
import pytest
from gateway.config import Platform
from gateway.kanban_watchers_notifier import _KanbanNotification, _notifier_collect
from gateway.platforms.event import MessageEvent
from gateway.wake import WakeNotAccepted, deliver_wake
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source
async def drain(adapter):
while adapter._background_tasks:
await asyncio.gather(*list(adapter._background_tasks), return_exceptions=True)
await asyncio.sleep(0)
def wake_route():
runner, adapter = make_restart_runner()
adapter.gateway_runner = runner
adapter.set_message_handler(runner._handle_message)
runner._delivery_adapter_for = lambda source: adapter
runner._kanban_dispatcher_lock_handle = object()
turns = []
async def agent_turn(event, source, quick_key, run_generation):
turns.append(event.text)
runner._handle_message_with_agent = agent_turn
return runner, adapter, make_restart_source(), turns
@pytest.mark.asyncio
async def test_kanban_wake_during_restart_drain_runs_after_restart(
tmp_path, monkeypatch
):
monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "board.db"))
runner, adapter, source, turns = wake_route()
with kbc.connect() as conn:
tid = kb.create_task(conn, title="long task", assignee="worker")
kbn.add_notify_sub(
conn,
task_id=tid,
platform="telegram",
chat_id=source.chat_id,
user_id=source.user_id,
chat_type="dm",
delivery_mode="notify+wake",
)
kb.complete_task(conn, tid, summary="PR merged")
async def notifier_tick():
deliveries = await asyncio.to_thread(
_notifier_collect,
runner,
kb,
notifier_profile=None,
gc_due=False,
gc_retention_days=30,
)
for delivery in deliveries:
await _KanbanNotification(
runner, delivery, platform_cls=Platform, sub_fail_counts={}
).deliver()
await drain(adapter)
def undelivered():
with kbc.connect() as conn:
return kbn.unseen_events_for_sub(
conn,
task_id=tid,
platform="telegram",
chat_id=source.chat_id,
kinds=["completed"],
)[1]
runner._draining = True # /restart is waiting for other sessions to finish
await notifier_tick()
assert turns == []
assert undelivered(), "the completion must stay claimable for the restarted gateway"
pings = list(adapter.sent)
assert len(pings) == 1 and tid in pings[0] # the passive ping still lands once
runner._draining = False # the restarted gateway's notifier retries the wake
await notifier_tick()
assert len(turns) == 1 and tid in turns[0]
assert not undelivered()
assert adapter.sent == pings # no repeated ping, no drain notice
@pytest.mark.asyncio
async def test_wake_for_a_busy_session_is_refused_while_draining():
runner, adapter, source, _turns = wake_route()
key = adapter._event_session_key(MessageEvent(text="", source=source))
adapter._active_sessions[key] = (
asyncio.Event()
) # a turn is still running in this chat
runner._draining = True
with pytest.raises(WakeNotAccepted) as refused:
await deliver_wake(adapter, text="[kanban] Task t_1 completed.", source=source)
assert refused.value.reason == "runtime_draining" and not refused.value.permanent
assert key not in adapter._pending_messages # would be discarded when the turn ends
@pytest.mark.asyncio
async def test_wake_is_refused_when_drain_starts_during_admission():
runner, adapter, source, turns = wake_route()
entered, release = threading.Event(), threading.Event()
def slow_topic_recovery(
_source,
): # Telegram DM admission awaits this before it claims
entered.set()
release.wait(5)
adapter._topic_recovery_fn = slow_topic_recovery
admission = asyncio.create_task(
deliver_wake(adapter, text="[kanban] Task t_1 completed.", source=source)
)
assert await asyncio.to_thread(entered.wait, 5)
runner._draining = True # /restart begins while admission is parked
release.set()
with pytest.raises(WakeNotAccepted) as refused:
await admission
assert refused.value.reason == "runtime_draining"
await drain(adapter)
assert turns == []
@pytest.mark.asyncio
async def test_wake_is_refused_when_drain_starts_after_the_busy_handler_queued_it():
"""Guard for a busy handler that awaits after it stores the wake (no in-tree handler does today):
a drain that begins there dooms the queued follow-up, so the wake must be refused and retried,
not receipted and discarded."""
runner, adapter, source, _turns = wake_route()
key = adapter._event_session_key(MessageEvent(text="", source=source))
adapter._active_sessions[key] = asyncio.Event() # a turn is still running in this chat
store = adapter._busy_session_handler
async def store_then_drain(event, session_key):
handled = await store(event, session_key) # the wake is now the pending follow-up
runner._draining = True # /restart begins after the store, before the handler returns
return handled
adapter._busy_session_handler = store_then_drain
with pytest.raises(WakeNotAccepted) as refused:
await deliver_wake(adapter, text="[kanban] Task t_1 completed.", source=source)
assert "during admission" in str(refused.value) # the post-admission re-check fired
event, text = await runner._run_agent_drain_pending(
{"final_response": "done"}, adapter, source, key
)
assert (event, text) == (None, None) # discarded by the drain, so it runs only on the retry
@@ -0,0 +1,126 @@
"""A wake that arrives while the gateway drains must be refused retryably on the authority route too.
Once a chat has a committed human turn, ``deliver_wake`` commits a Kanban wake to the session
authority's FIFO (``admit_route_wake``) instead of the classic adapter path the sibling file covers.
That FIFO's admission gate already refuses during a drain, re-checked inside the write; these pin it
so both routes keep the same contract: a refused wake carries ``runtime_draining``, which the
notifier retries, and nothing is half-admitted. Real GatewayRunner, DiscordAdapter and
SessionAuthority over SQLite.
"""
import asyncio
import pytest
from tests.waiting import async_wait_until
WAKE = "[Kanban task completed] t_1"
class Hold:
"""Keeps the human turn running until ``finish`` is set."""
def __init__(self):
self.running, self.finish = asyncio.Event(), asyncio.Event()
async def authority_route(monkeypatch, executed, hold=None):
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import SendResult
from gateway.run import GatewayRunner
from gateway.session_authority import initialize_session_authority
from gateway.session_results import execution_result
from plugins.platforms.discord.adapter import DiscordAdapter
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "42")
runner = GatewayRunner(GatewayConfig())
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="fixture-token", typing_indicator=False))
async def send(chat_id, content, reply_to=None, metadata=None):
return SendResult(success=True, message_id="sent")
monkeypatch.setattr(adapter, "send", send)
adapter.gateway_runner = runner # as GatewayRunner._create_adapter binds every adapter
runner.adapters = {Platform.DISCORD: adapter}
authority = await initialize_session_authority(runner, profile_id="default", instance_id="owner")
runner._wire_adapter_handlers(adapter)
async def turn(event, source, quick_key, run_generation):
executed.append(event.text)
if hold is not None and event.text == "A":
hold.running.set()
await hold.finish.wait()
captured = execution_result.get()
if captured is not None:
captured["result"] = {"final_response": "reply " + event.text, "messages": []}
return "reply " + event.text
monkeypatch.setattr(runner, "_handle_message_with_agent", turn)
return runner, adapter, authority, adapter.build_source(chat_id="42", chat_type="dm", user_id="42")
def admissions(runner, authority, source):
from hermes_state_runtime import list_session_admissions
owner = runner.session_store.peek_session_id(runner.session_store._generate_session_key(source))
rows = list_session_admissions(authority.db, session_id=owner, pending_only=False)
return [(row["principal_id"].split(":")[0], row["status"]) for row in rows]
async def start_human_turn(runner, adapter, authority, source, hold):
from gateway.platforms.event import MessageEvent
await adapter.handle_message(MessageEvent(text="A", source=source, message_id="A"))
if hold is not None:
await asyncio.wait_for(hold.running.wait(), 10)
else:
await async_wait_until(
lambda: admissions(runner, authority, source) == [("messaging", "terminal")],
what="the human turn to settle",
)
@pytest.mark.asyncio
@pytest.mark.parametrize("busy", [False, True], ids=["idle", "busy"])
async def test_authority_route_refuses_a_wake_while_draining(monkeypatch, busy):
from gateway.wake import WakeNotAccepted, deliver_wake
executed, hold = [], Hold() if busy else None
runner, adapter, authority, source = await authority_route(monkeypatch, executed, hold)
await start_human_turn(runner, adapter, authority, source, hold)
runner._draining = True # /restart is waiting for other sessions to finish
with pytest.raises(WakeNotAccepted) as refused:
await asyncio.wait_for(deliver_wake(adapter, text=WAKE, source=source), 10)
assert refused.value.reason == "runtime_draining" and not refused.value.permanent
if hold is not None:
hold.finish.set()
await async_wait_until(
lambda: admissions(runner, authority, source) == [("messaging", "terminal")],
what="the human turn to settle",
)
assert admissions(runner, authority, source) == [("messaging", "terminal")] # no wake row
assert executed == ["A"]
@pytest.mark.asyncio
async def test_authority_route_refuses_a_wake_when_the_drain_starts_during_admission(monkeypatch):
import gateway.session_automation as automation
from gateway.wake import WakeNotAccepted, deliver_wake
executed = []
runner, adapter, authority, source = await authority_route(monkeypatch, executed)
await start_human_turn(runner, adapter, authority, source, None)
snapshot = automation.snapshot_automation
def snapshot_then_drain(*args, **kwargs):
# admit_automation has passed its first admission check; /restart begins before the write.
result = snapshot(*args, **kwargs)
runner._draining = True
return result
monkeypatch.setattr(automation, "snapshot_automation", snapshot_then_drain)
with pytest.raises(WakeNotAccepted) as refused:
await asyncio.wait_for(deliver_wake(adapter, text=WAKE, source=source), 10)
assert refused.value.reason == "runtime_draining" and not refused.value.permanent
assert admissions(runner, authority, source) == [("messaging", "terminal")]
assert executed == ["A"]
@@ -0,0 +1,63 @@
"""An unmanaged macOS gateway runs as a one-off launchd job through osascript, not as its caller's child.
Local Network Privacy attributes a process to its responsible app; a gateway left running after the
app that started it quits becomes responsible for itself and macOS refuses its LAN connects (a model
server on another machine answers ``No route to host``). A launchd-spawned osascript stays the
responsible code for the job's whole life (#71206).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import pytest
from hermes_cli.gateway_launchd_runtime import start_gateway_job
from tests.waiting import wait_until
def _command_of(pid: int) -> str:
return subprocess.run(["ps", "-o", "command=", "-p", str(pid)], capture_output=True, text=True, encoding="utf-8",
check=False, stdin=subprocess.DEVNULL).stdout.strip()
@pytest.mark.platforms("macos")
def test_gateway_job_is_a_launchd_osascript_child_that_reads_as_unsupervised(tmp_path):
report = tmp_path / "report.json"
release = tmp_path / "release"
child = (
"import json, os, pathlib, time; "
f"pathlib.Path({str(report)!r}).write_text(json.dumps({{'ppid': os.getppid(), "
"'xpc': os.environ.get('XPC_SERVICE_NAME'), 'marker': os.environ.get('PROBE_MARKER')})); "
f"[time.sleep(0.1) for _ in range(300) if not pathlib.Path({str(release)!r}).exists()]"
)
job = start_gateway_job([sys.executable, "-c", child], env={**os.environ, "PROBE_MARKER": "env-carried"},
cwd=tmp_path, log_path=tmp_path / "gateway-stdio.log")
assert job is not None, "launchd refused the job in this user's GUI domain"
try:
try:
wait_until(lambda: report.exists() or job.poll() is not None, timeout=30)
except AssertionError:
pass # the assert below names what the job did instead
log = tmp_path / "gateway-stdio.log"
state = subprocess.run(["launchctl", "print", job.target], capture_output=True, text=True, encoding="utf-8",
check=False, stdin=subprocess.DEVNULL).stdout
assert report.exists(), (f"tmp={tmp_path}\nlog={log.read_text(encoding='utf-8') if log.exists() else None!r}\n"
f"launchctl print:\n{state[:3000]}")
seen = json.loads(report.read_text(encoding="utf-8-sig"))
wrapper = seen["ppid"]
# The caller is not an ancestor: launchd (pid 1) spawned osascript, osascript the command.
assert _command_of(wrapper).startswith("/usr/bin/osascript"), _command_of(wrapper)
assert subprocess.run(["ps", "-o", "ppid=", "-p", str(wrapper)], capture_output=True, text=True, encoding="utf-8",
check=False, stdin=subprocess.DEVNULL).stdout.strip() == "1"
assert seen["marker"] == "env-carried"
# Read as unsupervised, or /restart would exit for a respawn no KeepAlive provides.
assert seen["xpc"] == "0"
assert not list(tmp_path.glob(".*.plist")), "the plist holding the launch env must not stay on disk"
assert job.poll() is None
finally:
release.write_text("", encoding="utf-8")
assert wait_until(lambda: job.poll() is not None, timeout=30)
assert job.poll() == 0
@@ -3,8 +3,10 @@ import asyncio
from contextlib import contextmanager, suppress
import os
from pathlib import Path
import select
import socket
import threading
import time
import pytest
@@ -21,12 +23,26 @@ class Socket:
self.closed.append(code)
def read_pty_through(fd, marker, timeout=3.0):
"""What a pseudo-terminal's slave reads up to and including ``marker``, written to the master
after everything else. A write to the master returns before the slave can read it: the kernel
hands the bytes over later (Linux from a worker thread), so earlier writes can still be on
their way when the first read returns. They arrive in order, so the marker comes last."""
got = bytearray()
deadline = time.monotonic() + timeout
while not got.endswith(marker):
readable, _, _ = select.select([fd], [], [], max(0.0, deadline - time.monotonic()))
if not readable:
break
got += os.read(fd, 1024)
return bytes(got)
@pytest.mark.platforms("posix")
@pytest.mark.asyncio
@pytest.mark.parametrize("pause_at_sink", [False, True])
async def test_takeover_revokes_waiting_workers_and_waits_for_an_admitted_write(tmp_path, monkeypatch, pause_at_sink):
import pty
import select
import tty
from hermes_cli import pty_bridge
@@ -82,9 +98,9 @@ async def test_takeover_revokes_waiting_workers_and_waits_for_an_admitted_write(
assert await session.write(new, b"NEW\n", fence=owner.admit)
release.set()
assert await asyncio.to_thread(finished.wait, 3)
readable, _, _ = await asyncio.to_thread(select.select, [slave], [], [], 3)
assert readable
assert os.read(slave, 1024) == (b"OLD\nNEW\n" if pause_at_sink else b"NEW\n")
real_write(master, b"END\n")
expected = b"OLD\nNEW\n" if pause_at_sink else b"NEW\n"
assert await asyncio.to_thread(read_pty_through, slave, b"END\n") == expected + b"END\n"
assert session.alive
finally:
release.set()
@@ -0,0 +1,42 @@
"""Plugin-host profile extraction runs the Hermes tree's code, never a same-named package in the cwd."""
import json
import pytest
from hermes_cli.plugin_host_profiles import load_hosted_profiles
PROVIDER_PLUGIN = '''
from providers import register_provider
from providers.base import ProviderProfile
register_provider(ProviderProfile(name="cwdprobe", base_url="https://cwdprobe.example/v1"))
'''
# A launch directory (e.g. a cloned repository) that happens to contain a ``hermes_cli`` package.
SHADOW_INIT = '''
from pathlib import Path
Path(__file__).resolve().parents[1].joinpath("shadow-imported.txt").write_text("imported")
'''
SHADOW_CHILD = 'import json\nprint(json.dumps({"profiles": []}))\n'
@pytest.mark.platforms("any") # a real child interpreter: sys.path rules are per-OS
def test_profile_extraction_ignores_a_hermes_cli_package_in_the_launch_directory(tmp_path, monkeypatch):
home = tmp_path / "hermes-home"
plugin_dir = home / "plugins" / "model-providers" / "cwdprobe"
plugin_dir.mkdir(parents=True)
(plugin_dir / "__init__.py").write_text(PROVIDER_PLUGIN, encoding="utf-8")
(home / "config.yaml").write_text(json.dumps({"plugins": {"isolation": "host"}}), encoding="utf-8")
launch_dir = tmp_path / "cloned-repo"
(launch_dir / "hermes_cli").mkdir(parents=True)
(launch_dir / "hermes_cli" / "__init__.py").write_text(SHADOW_INIT, encoding="utf-8")
(launch_dir / "hermes_cli" / "plugin_host_child.py").write_text(SHADOW_CHILD, encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.chdir(launch_dir)
profiles = load_hosted_profiles(plugin_dir, "_hermes_test_cwdprobe")
assert not (launch_dir / "shadow-imported.txt").exists()
assert [p.name for p in profiles] == ["cwdprobe"]
assert profiles[0].base_url == "https://cwdprobe.example/v1"
@@ -2863,7 +2863,7 @@ def test_install_snapshot_is_daily_and_carries_only_bucketed_counts(
"main_provider": "none", "mcp_server_count_bucket": "1", "memory_provider": "plugin",
"messaging_platform_count_bucket": "0", "plugin_count_bucket": "3_to_5",
"profile_count_bucket": snapshot["profile_count_bucket"], "skill_count_bucket": "0",
"terminal_backend": "local", "local_model_provider_used": "no",
"terminal_backend": "local", "local_model_provider_used": "no", "compression_prepare_ahead": "off",
**{k: snapshot[k] for k in ("behind_bucket", "gpu_class", "ram_bucket", "release_channel", "version_age_bucket")},
}
@@ -82,11 +82,13 @@ def test_jsonl_backup_refuses_a_session_over_max_export_messages(tmp_path, monke
lambda self, *a, **kw: scans.append(1) or real_search(self, *a, **kw))
backup = tmp_path / "backup.jsonl"
_export(monkeypatch, backup, *selection)
if disabled:
_export(monkeypatch, backup, *selection)
assert backup.exists() and len(scans) == 1
return
with pytest.raises(SystemExit) as refused:
_export(monkeypatch, backup, *selection)
assert refused.value.code == 1
assert not backup.exists()
out = capsys.readouterr().out
assert SID in out and "max_export_messages" in out
@@ -0,0 +1,146 @@
"""`hermes sessions` exit status tracks the outcome: a subcommand that fails, refuses, or could not ask for
its confirmation exits non-zero, so cron/CI wrappers and `hermes sessions repair && ...` chains can tell."""
import sqlite3
import sys
import time
import pytest
def _run_cli(monkeypatch, argv_tail):
import hermes_cli.main as main_mod
monkeypatch.setattr(sys, "argv", ["hermes", "sessions", *argv_tail])
try:
main_mod.main()
except SystemExit as exc:
return exc.code or 0
return 0
def _old_ended_session(db_path):
from hermes_state import SessionDB
with SessionDB(db_path=db_path) as db:
db.create_session("s-old", "cli")
db.append_message("s-old", "user", "hello")
db.end_session("s-old", "user_exit")
old = time.time() - 100 * 86400
raw = sqlite3.connect(str(db_path))
with raw:
raw.execute("UPDATE sessions SET started_at = ?, ended_at = ?", (old, old))
raw.execute("UPDATE messages SET timestamp = ?", (old,))
raw.close()
def _garbage_store(db_path):
db_path.write_bytes(b"not a sqlite database" * 200)
def _legacy_inline_fts_store(db_path):
from hermes_state_common import LEGACY_FTS_SQL, LEGACY_FTS_TRIGRAM_SQL, SCHEMA_SQL
raw = sqlite3.connect(str(db_path))
with raw:
raw.executescript(SCHEMA_SQL + LEGACY_FTS_SQL + LEGACY_FTS_TRIGRAM_SQL)
raw.execute("INSERT INTO sessions (id, source, started_at) VALUES ('s-old', 'cli', ?)", (time.time(),))
raw.execute("INSERT INTO messages (session_id, timestamp, role, content) VALUES ('s-old', ?, 'user', 'hi')",
(time.time(),))
raw.close()
def _vacuum_fails(monkeypatch):
from hermes_state import SessionDB
def vacuum(self):
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(SessionDB, "vacuum", vacuum)
def _store(monkeypatch, tmp_path, build):
import hermes_state
from hermes_constants import get_hermes_home
db_path = get_hermes_home() / "state.db"
build(db_path)
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", db_path)
monkeypatch.chdir(tmp_path)
return db_path
@pytest.mark.parametrize("build, failing, valid, break_it", [
(_garbage_store, ["repair"], None, None),
(_old_ended_session, ["optimize"], ["optimize"], _vacuum_fails),
(_old_ended_session, ["archive"], ["archive", "--source", "cli", "--yes"], None),
(_old_ended_session, ["prune", "--never-active", "--older-than", "not-a-duration"],
["prune", "--never-active", "--older-than", "2d", "--yes"], None),
(_old_ended_session, ["export", "exports", "--format", "md"],
["export", "exports", "--format", "md", "--older-than", "90"], None),
], ids=["repair-failed", "optimize-vacuum-fails", "archive-no-filter", "never-active-bad-duration",
"export-md-no-filter"])
def test_failed_or_refused_subcommand_exits_1_where_the_valid_form_exits_0(
monkeypatch, tmp_path, build, failing, valid, break_it):
_store(monkeypatch, tmp_path, build)
if valid:
assert _run_cli(monkeypatch, valid) == 0
if break_it:
break_it(monkeypatch)
assert _run_cli(monkeypatch, failing) == 1
@pytest.mark.parametrize("build, argv", [
(_legacy_inline_fts_store, ["optimize-storage"]),
(_old_ended_session, ["delete", "s-old"]),
(_old_ended_session, ["archive", "--source", "cli"]),
(_old_ended_session, ["prune", "--older-than", "30"]),
], ids=["optimize-storage", "delete", "archive", "prune"])
def test_confirmation_nobody_can_answer_exits_1_and_changes_nothing(monkeypatch, tmp_path, capsys, build, argv):
"""No TTY (cron, a wrapper): EOF at the prompt is not a decline. An explicit "n" still exits 0."""
db_path = _store(monkeypatch, tmp_path, build)
def snapshot():
raw = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
try:
return (raw.execute("SELECT id, archived FROM sessions ORDER BY id").fetchall(),
raw.execute("SELECT name FROM sqlite_master ORDER BY name").fetchall())
finally:
raw.close()
monkeypatch.setattr("builtins.input", lambda _prompt="": "n")
assert _run_cli(monkeypatch, argv) == 0
before = snapshot() # after the first open: SessionDB's own schema reconcile is not the command's work
def no_stdin(_prompt=""):
raise EOFError
monkeypatch.setattr("builtins.input", no_stdin)
capsys.readouterr()
assert _run_cli(monkeypatch, argv) == 1
assert "Pass --yes" in capsys.readouterr().out
assert snapshot() == before
def ctrl_c(_prompt=""):
raise KeyboardInterrupt
monkeypatch.setattr("builtins.input", ctrl_c) # an interrupt is not a decline either: `&& next` must not run
assert _run_cli(monkeypatch, argv) == 130
assert snapshot() == before
@pytest.mark.platforms("any")
def test_redirected_null_stdin_is_unattended_on_every_os(tmp_path):
"""A real ``stdin=DEVNULL`` child: Windows reports isatty() True for NUL, which must still read as
nobody-could-answer (exit 1), never as a decline that lets ``&& next`` run."""
import os
import subprocess
from pathlib import Path
home = tmp_path / ".hermes"
home.mkdir()
_old_ended_session(home / "state.db")
root = str(Path(__file__).resolve().parents[2])
env = {**os.environ, "HERMES_HOME": str(home), "PYTHONPATH": root}
proc = subprocess.run([sys.executable, "-m", "hermes_cli.main", "sessions", "delete", "s-old"], cwd=root,
env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=120, check=False)
assert proc.returncode == 1, proc.stdout + proc.stderr
assert "Pass --yes" in proc.stdout
@@ -0,0 +1,119 @@
"""Process-level shared-metrics marks from background work that outlives the turn it was spawned in.
A thread started with the turn's contextvars (a turn-end compaction pass, title generation) still
holds that turn in ``relay_runtime._CURRENT_TURN`` after the turn closes. A process mark has no
owning task and opens no scope, so the closed turn must not swallow it; a turn Relay skipped
(a concurrent turn on the same session) keeps gating.
"""
from __future__ import annotations
import contextvars
import threading
import pytest
from agent import relay_runtime
from hermes_cli import plugins
from hermes_cli.observability import relay_shared_metrics
from hermes_cli.observability import shared_metrics_events as events
from hermes_cli.observability.shared_metrics import SharedMetricsStore
from hermes_cli.plugins import PluginManager
_AUX_USAGE = {"input_tokens": 1000, "output_tokens": 10}
@pytest.fixture
def relay_home(tmp_path, monkeypatch):
relay = pytest.importorskip("nemo_relay")
if getattr(relay, "_native", None) is None:
pytest.skip("NeMo Relay native binding is unavailable on this platform")
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home"))
monkeypatch.setattr(
"hermes_cli.config.read_raw_config_readonly",
lambda: {"telemetry": {"shared_metrics": {"enabled": True}}},
)
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
manager = PluginManager()
manager._discovered = True # a runtime with zero plugins loaded (#64178)
monkeypatch.setattr(plugins, "_plugin_manager", manager)
yield tmp_path / "hermes-home"
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
def _emit_process_marks() -> None:
events.record_slash_command(command="help", surface="cli")
relay_shared_metrics.observe_lifecycle(
"post_auxiliary_call", aux_task="title_generation", model="google/gemini-2.5-flash",
provider="openrouter", usage=_AUX_USAGE,
)
def _recorded(home) -> dict[str, int]:
"""Slash-command count and title-generation input tokens persisted in the store."""
relay_shared_metrics.shutdown_runtimes()
root = home / "telemetry" / "shared_metrics"
found = {"slash": 0, "aux_input": 0}
for row in SharedMetricsStore(root / "metrics.sqlite3", root / "outbox").counter_snapshot():
dims = row["dimensions"]
if row["metric_name"] == "hermes.slash_command.count" and dims.get("command") == "help":
found["slash"] += row["value"]
elif row["metric_name"] == "hermes.model_tokens.sum" and dims.get("token_type") == "input":
found["aux_input"] += row["value"]
return found
def _run_after(context: contextvars.Context, before_emit) -> None:
"""Run the marks on a thread holding ``context``, after ``before_emit`` ran on this thread."""
go, released = threading.Event(), []
worker = threading.Thread(target=context.run, args=(lambda: (released.append(go.wait(30)), _emit_process_marks()),))
worker.start()
before_emit()
go.set()
worker.join(40)
assert not worker.is_alive()
assert released == [True], "the marks must be emitted only after before_emit ran"
def test_marks_from_a_thread_that_outlives_its_turn_are_recorded(relay_home):
coordinator = relay_runtime.SESSION_COORDINATOR
lease = coordinator.acquire_conversation(
profile_key=relay_runtime.current_profile_key(), session_id="s1", platform="cli",
)
turn = coordinator.begin_turn(lease, turn_id="turn-1", task_id="t1")
relay_shared_metrics.start_task_run(session_id="s1", task_id="t1", platform="cli")
spawned_in_turn = contextvars.copy_context()
def close_turn() -> None:
relay_shared_metrics.finish_task_run(
session_id="s1", task_id="t1", platform="cli", result={"completed": True},
)
coordinator.end_turn(turn, outcome="success")
coordinator.release_conversation(lease)
_run_after(spawned_in_turn, close_turn)
assert spawned_in_turn.run(relay_runtime.current_turn) is turn and turn.closed
assert _recorded(relay_home) == {"slash": 1, "aux_input": 1000}
def test_a_turn_relay_skipped_still_gates_marks(relay_home):
coordinator = relay_runtime.SESSION_COORDINATOR
lease = coordinator.acquire_conversation(
profile_key=relay_runtime.current_profile_key(), session_id="s1", platform="cli",
)
first = coordinator.begin_turn(lease, turn_id="turn-1", task_id="t1")
second = coordinator.begin_turn(lease, turn_id="turn-2", task_id="t2")
assert second.relay_enabled is False
inside_skipped = contextvars.copy_context()
def close_turns() -> None:
coordinator.end_turn(second, outcome="success")
coordinator.end_turn(first, outcome="success")
coordinator.release_conversation(lease)
_run_after(inside_skipped, close_turns)
assert _recorded(relay_home) == {"slash": 0, "aux_input": 0}
@@ -0,0 +1,315 @@
"""Graceful ``os._exit`` paths run the shared-metrics exit flush their skipped atexit hook would have run.
The gateway (SIGTERM, update restart, idle exit all funnel through ``_exit_after_graceful_shutdown``),
the compute host, a SIGTERMed kanban worker and an orphaned slash worker leave via ``os._exit``, so a
session still open at the stop never wrote its summary rows to the opt-in local store. The flush is
bounded, creates nothing when collection is off, and runs every profile's runtime under that profile's
own consent (a multiplex gateway exits from the launch profile's scope).
"""
from __future__ import annotations
import functools
import json
import os
import sqlite3
import threading
import time
from pathlib import Path
import pytest
from agent import relay_runtime
from hermes_cli import lifecycle, plugins
from hermes_cli.observability import relay_shared_metrics
from hermes_cli.observability import shared_metrics_process as process_metrics
from hermes_cli.plugins import PluginManager
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from tests.waiting import wait_until
class _Exited(Exception):
pass
def _fake_exit(code):
raise _Exited(code)
def _store(home: Path) -> Path:
return home / "telemetry" / "shared_metrics" / "metrics.sqlite3"
def _metric_names(home: Path) -> set[str]:
database = _store(home)
if not database.exists():
return set()
with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True) as conn:
return {name for (name,) in conn.execute("SELECT metric_name FROM counter_aggregates")}
def _open_consent_windows(home: Path) -> list[tuple]:
with sqlite3.connect(_store(home).as_uri() + "?mode=ro", uri=True) as conn:
return conn.execute("SELECT opened_at FROM send_consent_windows WHERE closed_at IS NULL").fetchall()
def _require_native_relay() -> None:
relay = pytest.importorskip("nemo_relay")
if getattr(relay, "_native", None) is None:
pytest.skip("NeMo Relay native binding is unavailable on this platform")
def _zero_plugin_runtimes(monkeypatch) -> None:
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
manager = PluginManager()
manager._discovered = True # a runtime with zero plugins (#64178)
monkeypatch.setattr(plugins, "_plugin_manager", manager)
@pytest.fixture
def metrics_home(tmp_path, monkeypatch):
"""Real NeMo Relay runtime over a temp HERMES_HOME; ``policy['on']`` is the opt-in answer."""
_require_native_relay()
home = tmp_path / "hermes-home"
policy = {"on": True}
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(
"hermes_cli.config.read_raw_config_readonly",
lambda: {"telemetry": {"shared_metrics": {"enabled": policy["on"], "send": False}}},
)
_zero_plugin_runtimes(monkeypatch)
yield home, policy
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
def _finished_task_in_open_session(session_id: str = "open-session") -> None:
"""One completed task in a session that stays open, as a messaging or Desktop chat does."""
event = {"session_id": session_id, "task_id": "task-1", "platform": "cli"}
lifecycle.invoke_hook("on_session_start", **event, turn_id="turn-1")
relay_shared_metrics.start_task_run(**event)
lifecycle.invoke_hook("pre_llm_call", **event, turn_id="turn-1", messages=["hello"]) # a user turn
relay_shared_metrics.finish_task_run(**event, result={"completed": True, "final_response": "ok"})
def _wait_for(home: Path, metric: str) -> None:
wait_until(lambda: metric in _metric_names(home), what=f"{metric} in the store")
def test_graceful_gateway_exit_writes_the_open_sessions_summary(metrics_home, monkeypatch):
import gateway.run as gateway_run
home, _ = metrics_home
_finished_task_in_open_session()
_wait_for(home, "hermes.task_run.finished") # the task flush runs on its own worker
assert "hermes.session.count" not in _metric_names(home)
monkeypatch.setattr(gateway_run.os, "_exit", _fake_exit)
with pytest.raises(_Exited):
gateway_run._exit_after_graceful_shutdown(1)
assert "hermes.session.count" in _metric_names(home)
def test_compute_host_shutdown_frame_writes_the_open_sessions_summary(metrics_home):
import io
from tui_gateway.compute_host import ComputeHost
home, _ = metrics_home
_finished_task_in_open_session()
_wait_for(home, "hermes.task_run.finished")
host = ComputeHost(stdout=io.StringIO(), heartbeat_secs=0)
host.handle_frame({"type": "shutdown"}) # the reader thread os._exit(0)s right after this
assert "hermes.session.count" in _metric_names(home)
def test_sigtermed_kanban_worker_writes_the_open_sessions_summary(metrics_home, monkeypatch):
import signal
import cli
from hermes_cli import cli_single_query
home, _ = metrics_home
_finished_task_in_open_session()
_wait_for(home, "hermes.task_run.finished")
handlers = {}
monkeypatch.setenv("HERMES_KANBAN_TASK", "t-1")
monkeypatch.setattr(signal, "signal", lambda signum, handler: handlers.__setitem__(signum, handler))
monkeypatch.setattr(signal, "alarm", lambda seconds: 0)
for name in ("_arm_exit_watchdog_on_shutdown_signal", "_flush_logging_and_stdio"):
monkeypatch.setattr(cli, name, lambda: None)
monkeypatch.setattr(cli, "_interrupt_agent_for_signal", lambda agent, signum: None)
monkeypatch.setattr(cli, "_flush_one_shot_session_store", lambda c: None)
monkeypatch.setattr(cli_single_query.os, "_exit", _fake_exit)
cli_single_query._install_single_query_signal_handlers(object())
with pytest.raises(_Exited):
handlers[signal.SIGTERM](signal.SIGTERM, None)
assert "hermes.session.count" in _metric_names(home)
def test_orphaned_slash_worker_writes_the_open_sessions_summary(metrics_home, monkeypatch):
from tui_gateway import slash_worker
home, _ = metrics_home
_finished_task_in_open_session()
_wait_for(home, "hermes.task_run.finished")
exited = threading.Event()
monkeypatch.setattr(slash_worker, "_is_orphaned", lambda ppid: True)
monkeypatch.setattr(slash_worker.os, "_exit", lambda code: exited.set())
slash_worker._start_parent_death_watchdog(1)
assert exited.wait(10)
assert "hermes.session.count" in _metric_names(home)
def test_graceful_exit_with_collection_off_creates_no_store(metrics_home, monkeypatch):
import gateway.run as gateway_run
home, policy = metrics_home
policy["on"] = False
_finished_task_in_open_session()
monkeypatch.setattr(gateway_run.os, "_exit", _fake_exit)
with pytest.raises(_Exited):
gateway_run._exit_after_graceful_shutdown(0)
assert not (home / "telemetry").exists()
def _write_policy(home: Path, **policy) -> None:
config = home / "config.yaml"
config.write_text(json.dumps({"telemetry": {"shared_metrics": policy}}))
later = time.time() + 5 # a rewrite inside one mtime tick must still read as a change
os.utime(config, (later, later))
def test_exit_flush_applies_each_profiles_own_consent(tmp_path, monkeypatch):
"""A multiplex gateway exits from the launch profile's scope. A routed profile with sending off
keeps it off (no consent window opened on its store), and one that turned collection off after
its turn gets no exit rows; both rulings are that profile's own, not the launch profile's."""
import gateway.run as gateway_run
_require_native_relay()
launch, send_off, revoked = (tmp_path / name for name in ("launch", "send-off", "revoked"))
for home in (launch, send_off, revoked):
home.mkdir()
_write_policy(launch, enabled=True, send=True, endpoint="http://127.0.0.1:9/never")
_write_policy(send_off, enabled=True, send=False)
_write_policy(revoked, enabled=True, send=False)
monkeypatch.setenv("HERMES_HOME", str(launch))
_zero_plugin_runtimes(monkeypatch)
try:
for home in (send_off, revoked): # routed turns, each under its own profile scope
token = set_hermes_home_override(str(home))
try:
_finished_task_in_open_session(f"open-{home.name}")
_wait_for(home, "hermes.task_run.finished")
finally:
reset_hermes_home_override(token)
_write_policy(revoked, enabled=False, send=False)
rows_before_exit = _metric_names(revoked)
monkeypatch.setattr(gateway_run.os, "_exit", _fake_exit)
with pytest.raises(_Exited):
gateway_run._exit_after_graceful_shutdown(0)
assert "hermes.session.count" in _metric_names(send_off) # its own consent still flushes it
assert _open_consent_windows(send_off) == []
assert _metric_names(revoked) == rows_before_exit
finally:
relay_shared_metrics._reset_for_tests()
relay_runtime._reset_for_tests()
def test_a_wedged_exit_flush_is_bounded_and_runs_after_the_clean_exit_stamp(metrics_home, monkeypatch):
"""A managed tool call still in flight holds the Relay flush barrier (#53107): the exit still
leaves within the flush bound, and the clean-exit sentinel is already written, so a supervisor
killing the process during the flush never records a graceful stop as an unclean death."""
import gateway.run as gateway_run
from agent import relay_tools
from gateway.lifecycle_ledger import get_lifecycle_sentinel_path
home, _ = metrics_home
_finished_task_in_open_session()
_wait_for(home, "hermes.task_run.finished")
entered, release = threading.Event(), threading.Event()
def wedged_tool(_args):
entered.set()
release.wait(30)
return "late"
threading.Thread(
target=relay_tools.execute, args=("terminal", {"command": "x"}, wedged_tool),
kwargs={"session_id": "other-session"}, daemon=True,
).start()
assert entered.wait(10)
phase_at_flush, finished_in_time = [], []
bounded_flush = functools.partial(process_metrics.flush_before_hard_exit, timeout_s=0.5)
def observed_flush():
sentinel = get_lifecycle_sentinel_path()
phase_at_flush.append(json.loads(sentinel.read_text()).get("phase") if sentinel.exists() else None)
finished_in_time.append(bounded_flush())
monkeypatch.setattr(process_metrics, "flush_before_hard_exit", observed_flush)
monkeypatch.setattr(gateway_run.os, "_exit", _fake_exit)
started = time.monotonic()
try:
with pytest.raises(_Exited):
gateway_run._exit_after_graceful_shutdown(0)
finally:
release.set()
assert finished_in_time == [False] # the barrier really was wedged
assert phase_at_flush == ["exited"]
assert time.monotonic() - started < 5
def _engagement_state(home: Path) -> dict | None:
with sqlite3.connect(_store(home).as_uri() + "?mode=ro", uri=True) as conn:
row = conn.execute("SELECT value FROM telemetry_state WHERE key = 'engagement_day_state'").fetchone()
return json.loads(row[0]) if row else None
def test_graceful_exit_drains_the_root_profiles_deferred_host_count(tmp_path, monkeypatch):
"""The host-level active-profiles accumulator lives in the ROOT profile's store, a store no
per-profile runtime owns. When another process held it past the busy timeout, the profile's visit
was deferred in memory; the graceful exit still writes it there (and only there) once the store
frees, instead of losing it to ``os._exit``."""
import gateway.run as gateway_run
from hermes_cli.observability import shared_metrics_engagement as engagement
from hermes_cli.observability.shared_metrics import SharedMetricsStore
root = (tmp_path / "root").resolve()
profile = root / "profiles" / "work"
profile.mkdir(parents=True)
_write_policy(root, enabled=True, send=False)
monkeypatch.setenv("HERMES_HOME", str(profile))
resource = {"architecture": "x86_64", "hermes_version": "0.0.0", "install_method": "git", "os_family": "linux"}
profile_store = SharedMetricsStore(_store(profile), _store(profile).parent / "outbox")
engagement._root_store(root) # the store exists; another process now holds its write lock
holder = sqlite3.connect(_store(root), isolation_level=None)
holder.execute("BEGIN IMMEDIATE")
try:
engagement.record(profile_store, resource, surface="cli", route=None)
finally:
holder.execute("ROLLBACK")
holder.close()
assert _engagement_state(root) is None # deferred, not written
profile_state = _engagement_state(profile)
monkeypatch.setattr(gateway_run.os, "_exit", _fake_exit)
with pytest.raises(_Exited):
gateway_run._exit_after_graceful_shutdown(0)
root_state = wait_until(lambda: _engagement_state(root), what="the root's deferred host count")
assert root_state["owner"] is True and len(root_state["profiles"]) == 1
assert _engagement_state(profile) == profile_state # the profile's own store never got the root's row
@@ -0,0 +1,213 @@
"""Opt-in shared metrics for prepared compaction (``compression.prepare_ahead``).
* Adoption: the daily snapshot says whether the profile turned the feature on, read as the agent reads it,
and snapshots counted before the field existed still package against the v4 schema.
* ``hermes.prepared_compaction.count``: every pass and splice outcome lands as one closed-enum row in the
profile that owns the compressor, including the rows the background pass thread records, and every
waste mode (a summary nothing uses, a wait that timed out, a failed pass, a spliced compaction that did
not commit) says why.
"""
from __future__ import annotations
import json
import threading
import time
from pathlib import Path
import pytest
from agent import prepared_compaction as pc
from hermes_cli.observability import shared_metrics_contract as contract
from hermes_cli.observability.shared_metrics import SharedMetricsStore
from hermes_cli.observability.shared_metrics_snapshot import collect_install_snapshot
from tests.agent.test_prepared_compaction import _agent, _compressor, _grow, _messages
SCHEMA_PATH = (
Path(__file__).resolve().parents[2] / "hermes_cli" / "observability" / "schemas"
/ "hermes.shared_metrics.v4.schema.json"
)
RESOURCE = {"architecture": "x86_64", "hermes_version": "test", "install_method": "git", "os_family": "linux"}
def test_snapshot_reports_prepare_ahead_and_older_snapshots_still_package(tmp_path, monkeypatch):
jsonschema = pytest.importorskip("jsonschema")
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
states = {
value: collect_install_snapshot({"compression": {"prepare_ahead": value}})["compression_prepare_ahead"]
for value in (True, "yes", "on", False, "false", None)
}
assert states == {True: "on", "yes": "on", "on": "on", False: "off", "false": "off", None: "off"}
current = collect_install_snapshot({})
assert current["compression_prepare_ahead"] == "off", "absent reads off, as the agent's default"
store = SharedMetricsStore(tmp_path / "metrics.sqlite3", tmp_path / "outbox")
before_upgrade = {k: v for k, v in current.items() if k != "compression_prepare_ahead"}
store.record_counter(contract.INSTALL_SNAPSHOT_METRIC, before_upgrade, RESOURCE)
store.record_counter(contract.INSTALL_SNAPSHOT_METRIC, {**current, "compression_prepare_ahead": "on"}, RESOURCE)
[package_path] = store.create_and_export_package()
package = json.loads(package_path.read_text(encoding="utf-8"))
jsonschema.Draft202012Validator(json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))).validate(package)
snapshots = [m["dimensions"] for m in package["metrics"] if m["name"] == contract.INSTALL_SNAPSHOT_METRIC]
assert sorted(d.get("compression_prepare_ahead", "absent") for d in snapshots) == ["absent", "on"]
@pytest.fixture
def marks(monkeypatch):
"""Rows as the real producer emits them, with the profile and thread each was recorded under."""
from hermes_cli.observability import relay_shared_metrics
from hermes_constants import get_hermes_home
rows = []
lock = threading.Lock()
def record(mark, data):
with lock:
rows.append((mark, dict(data), str(get_hermes_home()), threading.current_thread().name))
monkeypatch.setattr(relay_shared_metrics, "enabled", lambda: True)
monkeypatch.setattr(relay_shared_metrics, "record_process_mark", record)
return rows
def _events(rows):
for mark, data, _home, _thread in rows:
assert mark == contract.PREPARED_COMPACTION_METRIC
assert contract.counter_dimensions_are_valid(contract.PREPARED_COMPACTION_METRIC, data), data
return sorted(
(d["event"], d["origin"], d["reason"], d["failure_class"], d["wait_bucket"]) for _m, d, _h, _t in rows
)
def _run_pass(cc, msgs, origin):
thread = pc.maybe_prepare(_agent(cc), msgs, origin=origin)
assert thread is not None
thread.join(5.0)
def test_pass_and_splice_outcomes_are_counted_in_the_owning_profile(tmp_path, marks):
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
owner = tmp_path / "owner"
token = set_hermes_home_override(owner)
try:
msgs = _messages(30)
spliced = _compressor()
_run_pass(spliced, msgs, "tool-batch")
spliced.compress(_grow(msgs, 4), current_tokens=spliced.threshold_tokens + 1)
memory = _compressor()
_run_pass(memory, msgs, "turn-end")
memory.compress(msgs, current_tokens=memory.threshold_tokens + 1, memory_context="provider note")
moved = _compressor()
_run_pass(moved, msgs, "tool-batch")
moved._session_id = "S2"
moved.compress(msgs, current_tokens=moved.threshold_tokens + 1)
reset = _compressor()
_run_pass(reset, msgs, "tool-batch")
reset.on_session_reset()
finally:
reset_hermes_home_override(token)
pass_rows = [("pass_completed", "tool_batch", "none", "none", "none"),
("pass_started", "tool_batch", "none", "none", "none")]
assert _events(marks) == sorted([
*pass_rows, ("used", "none", "none", "none", "none"),
("pass_completed", "turn_end", "none", "none", "none"), ("pass_started", "turn_end", "none", "none", "none"),
("auto_disabled", "none", "provider_context", "none", "none"),
("discarded", "none", "provider_context", "none", "none"),
*pass_rows, ("discarded", "none", "other_session", "none", "none"),
*pass_rows, ("discarded", "none", "session_boundary", "none", "none"),
])
assert {home for _m, _d, home, _t in marks} == {str(owner)}, "every row lands in the owning profile"
assert {t for _m, d, _h, t in marks if d["event"] == "pass_completed"} == {"compaction-prepare"}
def test_waste_modes_say_why(monkeypatch, marks):
from agent.auxiliary_client import aux_stream_deadline
from agent.conversation_compression_telemetry import _emit_compression_attempt_telemetry
import agent.context_compressor as module
msgs = _messages(30)
# A compaction gives up waiting on a pass that runs past the host's deadline; the pass is told why it stops.
slow = _compressor()
slow.gate = threading.Event()
running = pc.maybe_prepare(_agent(slow), msgs, origin="tool-batch")
with aux_stream_deadline(time.monotonic() + 0.2):
slow.compress(msgs, current_tokens=slow.threshold_tokens + 1)
running.join(5.0)
# A compaction that spliced the prepared summary and then did not commit.
spliced = _compressor()
_run_pass(spliced, msgs, "turn-end")
spliced.compress(msgs, current_tokens=spliced.threshold_tokens + 1)
attempt = spliced._last_compression_telemetry
assert attempt["prepared_summary"] is True
# A pre-commit cancel restores the previous attempt's telemetry (no prepared_summary) before the record.
spliced._last_compression_telemetry = spliced._active_compression_telemetry = {"attempt_id": "previous"}
_emit_compression_attempt_telemetry(
type("Agent", (), {"context_compressor": spliced, "session_id": "S1"})(), started_at=time.monotonic(),
commit_status="aborted", split_status="aborted", failure_class="session_split_failed",
attempt_seed={"attempt_id": attempt["attempt_id"], "session_id": "S1", "trigger_source": "auto"},
)
# A session boundary that wakes a waiting compaction is one discard, counted by the boundary.
woken = _compressor()
woken.gate = threading.Event()
before = len(marks)
running = pc.maybe_prepare(_agent(woken), msgs, origin="tool-batch")
threading.Timer(0.2, woken.prepared_compaction.discard, args=("session reset",)).start()
woken.compress(msgs, current_tokens=woken.threshold_tokens + 1)
woken.gate.set()
running.join(5.0)
woken_rows = _events(marks[before:])
assert [row for row in woken_rows if row[0] == "discarded"] == [
("discarded", "none", "session_boundary", "none", "none"),
]
# A boundary between the pass publishing and the woken wait reading it: one discard, by the boundary.
raced = _compressor()
raced.gate = threading.Event()
running = pc.maybe_prepare(_agent(raced), msgs, origin="tool-batch")
state, real_await = raced.prepared_compaction, raced.prepared_compaction._await
def publish_then_discard(pending):
raced.gate.set()
assert pending.done.wait(5) # the pass published its summary
state.discard("session reset") # dropped before the wait reads it
return real_await(pending)
state._await = publish_then_discard
before = len(marks)
raced.compress(msgs, current_tokens=raced.threshold_tokens + 1)
running.join(5.0)
assert [row for row in _events(marks[before:]) if row[0] == "discarded"] == [
("discarded", "none", "session_boundary", "none", "none"),
]
# A compaction that took the summary and then aborted before splicing it: counted at the next touch.
aborted = _compressor()
_run_pass(aborted, msgs, "tool-batch")
held = aborted.prepared_compaction._entry
taken = aborted.prepared_compaction.take(msgs, held.session_id, held.compress_start, eligible=True)
assert taken is not None
before = len(marks)
aborted.on_session_end(held.session_id, msgs)
assert [row for row in _events(marks[before:]) if row[0] == "discarded"] == [
("discarded", "none", "compaction_aborted", "none", "none"),
]
# A pass whose summary call fails with a terminal access error.
failed = _compressor()
monkeypatch.setattr(type(failed), "_generate_summary", module.ContextCompressor._generate_summary)
denied = RuntimeError("invalid api key")
denied.status_code = 401
monkeypatch.setattr(module, "call_llm", lambda **_kw: (_ for _ in ()).throw(denied))
_run_pass(failed, msgs, "tool-batch")
rows = _events(marks)
assert ("discarded", "none", "wait_timeout", "none", "lt_1s") in rows
assert ("pass_cancelled", "tool_batch", "wait_timeout", "none", "none") in rows
assert ("compaction_failed", "none", "none", "session_split_failed", "none") in rows
assert ("pass_failed", "tool_batch", "none", "summary_auth_failure", "none") in rows
@@ -0,0 +1,73 @@
"""A dispatcher-authored ``chat -q`` run carries its turn author, as ``chat -Q`` already does.
``HERMES_TURN_AUTHOR`` names who wrote a one-shot run's prompts. The non-quiet ``-q`` path ignored it,
so the turn reached memory providers as the operator's and tool subprocesses inherited the variable.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
import cli
from agent.turn_author import TURN_AUTHOR_ENV
def _one_shot_cli(monkeypatch):
"""A real HermesCLI whose ``chat`` runs the real agent-thread body against a recording agent."""
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
monkeypatch.delenv("HERMES_KANBAN_GOAL_MODE", raising=False)
monkeypatch.setattr(cli, "_should_seed_interactive", lambda *a, **k: False)
monkeypatch.setattr(cli, "_collect_query_images", lambda q, i: (q, []))
monkeypatch.setattr(cli, "_collect_kanban_task_images", lambda imgs: [])
monkeypatch.setattr(cli, "_finalize_single_query", lambda c: None)
calls = []
def run_conversation(**kwargs):
calls.append({**kwargs, "env_author": os.environ.get(TURN_AUTHOR_ENV)})
return {"final_response": "done"}
the_cli = cli.HermesCLI.__new__(cli.HermesCLI)
the_cli.session_id = "s-1"
the_cli.conversation_history = [{"role": "user", "content": "summarize the report"}]
the_cli.agent = SimpleNamespace(run_conversation=Mock(side_effect=run_conversation))
the_cli._flush_credit_notices = lambda: None
the_cli._single_query_mode = False
the_cli._claim_active_session = lambda *a, **k: True
the_cli.console = SimpleNamespace(print=lambda *a, **k: None)
the_cli._show_security_advisories = lambda: None
the_cli._print_exit_summary = lambda **k: None
the_cli._last_turn_result = {"failed": False}
def chat(message, images=None):
the_cli._chat_run_agent(cli._ChatTurn(), message)
return "done"
the_cli.chat = chat
return the_cli, calls
def test_dispatcher_author_reaches_the_turn_and_is_consumed(monkeypatch):
monkeypatch.setenv(TURN_AUTHOR_ENV, '{"id": "bot:relay", "name": "relayed teammate", "is_bot": true}')
the_cli, calls = _one_shot_cli(monkeypatch)
with pytest.raises(SystemExit):
cli._run_single_query_mode(the_cli, "summarize the report", None, False, True)
assert len(calls) == 1
assert calls[0]["turn_author"] == {"id": "bot:relay", "name": "relayed teammate", "is_bot": True}
# Tool subprocesses started during the turn must not inherit the dispatcher's author.
assert calls[0]["env_author"] is None
def test_human_one_shot_keeps_an_unattributed_turn(monkeypatch):
monkeypatch.delenv(TURN_AUTHOR_ENV, raising=False)
the_cli, calls = _one_shot_cli(monkeypatch)
with pytest.raises(SystemExit):
cli._run_single_query_mode(the_cli, "hello", None, False, True)
assert len(calls) == 1
assert "turn_author" not in calls[0]
@@ -1,11 +1,25 @@
"""Tests for the shared-metrics smoke artifact."""
import json
import subprocess
from pathlib import Path
import pytest
from gateway import status
from hermes_cli.observability import shared_metrics_contract as contract
from hermes_cli.observability.shared_metrics import SharedMetricsStore
from hermes_cli.observability.shared_metrics_update import update_receipt_fields
from scripts import smoke_nemo_relay_shared_metrics as smoke
# The smoke spawns a real chat and is not run by CI; these pin its expectations to the contract, so a
# renamed metric, a changed field set or a new projection rule fails here instead of rotting there.
_EXACT_EXPECTATIONS = {
**smoke.V5_EXPECTED_DIMENSIONS,
**smoke.GATEWAY_TURN_EXPECTED_DIMENSIONS,
**smoke.FAILURE_EXPECTED_DIMENSIONS,
}
@pytest.mark.parametrize(
"relative_path",
@@ -32,3 +46,64 @@ def test_resolve_hermes_executable_reports_missing_binary(tmp_path, monkeypatch)
with pytest.raises(SystemExit, match="or on PATH"):
smoke._resolve_hermes_executable(tmp_path)
def test_smoke_expects_only_contract_metrics_with_their_current_fields():
assert smoke.EXPECTED_METRIC_NAMES <= contract.COUNTER_METRICS
assert set(_EXACT_EXPECTATIONS) <= smoke.EXPECTED_METRIC_NAMES
for name, dimensions in _EXACT_EXPECTATIONS.items():
assert frozenset(dimensions) == contract._METRIC_FIELDS[name], name
assert contract.counter_dimensions_are_valid(name, dimensions), name
assert set(smoke.EXPECTED_STARTUP_SURFACES) <= contract.STARTUP_SURFACES
@pytest.mark.platforms("posix") # flock-held gateway lock
@pytest.mark.parametrize("start_time_shift", [0, 10_000], ids=["live-non-gateway", "recycled-pid"])
def test_gateway_stop_spares_a_process_that_is_not_the_homes_live_gateway(tmp_path, start_time_shift):
"""A held lock and a gateway record naming this home do not make the live PID a gateway."""
import fcntl
decoy = subprocess.Popen(["sleep", "300"])
try:
record = json.dumps({
"pid": decoy.pid, "kind": "hermes-gateway", "argv": ["hermes", "gateway", "run"],
"start_time": status._get_process_start_time(decoy.pid) + start_time_shift,
"hermes_home": str(tmp_path),
})
(tmp_path / "gateway.pid").write_text(record)
with open(tmp_path / "gateway.lock", "w") as lock:
lock.write(record)
lock.flush()
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
smoke._stop_auto_started_gateway(tmp_path, timeout=5)
assert decoy.poll() is None
finally:
decoy.kill()
decoy.wait()
def test_gateway_turn_wait_settles_only_on_the_rows_in_the_real_store(tmp_path):
"""The stop does not flush the gateway's rows, so the smoke waits for them in the store itself."""
database = tmp_path / "metrics.sqlite3"
assert not smoke._wait_for_gateway_turn_rows(database, timeout=0)
assert not database.exists() # polling never creates the store ahead of the gateway
resource = {"architecture": "x86_64", "hermes_version": "0.0.0", "install_method": "git", "os_family": "linux"}
store = SharedMetricsStore(database, tmp_path / "outbox")
for name, dimensions in [
*smoke.GATEWAY_TURN_EXPECTED_DIMENSIONS.items(),
("hermes.gateway.reply_latency", {"first_response_bucket": "lt_2s", "platform": "local"}),
("hermes.startup.latency", {"latency_bucket": "lt_500ms", "surface": "cli"}),
]:
store.record_counter(name, dimensions, resource)
assert not smoke._wait_for_gateway_turn_rows(database, timeout=0) # gateway_boot still pending
store.record_counter("hermes.startup.latency", {"latency_bucket": "2s_to_5s", "surface": "gateway_boot"}, resource)
assert smoke._wait_for_gateway_turn_rows(database, timeout=0)
def test_smoke_update_expectations_match_the_receipt_projection():
run, stage_rows = update_receipt_fields(smoke.UPDATE_RECEIPT)
assert run == smoke.FAILURE_EXPECTED_DIMENSIONS["hermes.update.run"]
assert sorted((row["stage"], row["outcome"]) for row in stage_rows) == smoke.expected_update_stages()
+3
View File
@@ -107,6 +107,9 @@ class ComputeHost:
with contextlib.suppress(Exception):
from tools.environments.base import kill_live_foreground_processes
kill_live_foreground_processes()
# ...and the opt-in shared-metrics rows that atexit hook would have written (bounded).
from hermes_cli.observability.shared_metrics_process import flush_before_hard_exit
flush_before_hard_exit()
def shutdown(self, *, reason: str = "shutdown", wait: float = 10.0) -> None:
"""Drain in-flight turns, then finalize every session.
+1 -1
View File
@@ -455,7 +455,7 @@ def _(rid, params: dict) -> dict:
# Hermes-owned strip (no-boot-through-venv).
_compat = _tools_mod("hermes_cli._subprocess_compat")
return _captured_exec(
rid, [sys.executable, "-m", "hermes_cli.main", *argv], min(int(params.get("timeout", 240)), 600),
rid, [sys.executable, "-P", "-m", "hermes_cli.main", *argv], min(int(params.get("timeout", 240)), 600),
on_result=lambda r: _ok(rid, {
"blocked": False, "code": r.returncode, "output": (_joined_output(r) or "(no output)")[:48_000]}),
timeout_err=(5016, "cli.exec: timeout"), fail_code=5017,
+2 -2
View File
@@ -263,7 +263,7 @@ class _SlashWorker:
# ``--provider`` pins the child to the parent agent's virtual provider: without it the
# worker re-resolves provider from config, so a MoA session (provider=moa, model=<preset>)
# dispatched its preset NAME to the configured real provider and 402/503'd (#57283).
argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key,
argv = [sys.executable, "-P", "-m", "tui_gateway.slash_worker", "--session-key", session_key,
"--parent-pid", str(os.getpid())] \
+ (["--model", model] if model else []) \
+ (["--provider", provider] if provider else [])
@@ -285,7 +285,7 @@ class _SlashWorker:
env = _prepend_tool_paths(served_profile_child_env(
target_home=profile_home or (_hermes_home if is_multiplex_active() else None),
inherit_credentials=True))
# Internal slash workers must import the same checkout as their parent.
# Internal slash workers must import the same checkout as their parent; -P keeps their cwd off sys.path.
module_root = str(Path(__file__).resolve().parent.parent)
env["PYTHONPATH"] = os.pathsep.join(
part for part in (module_root, env.get("PYTHONPATH", "")) if part
+2
View File
@@ -76,6 +76,8 @@ def _start_parent_death_watchdog(original_ppid) -> None:
deadline = time.monotonic() + _ORPHAN_GRACE_S
while _in_flight.is_set() and time.monotonic() < deadline:
time.sleep(0.05) # let an in-flight command finish/flush
from hermes_cli.observability.shared_metrics_process import flush_before_hard_exit
flush_before_hard_exit() # os._exit skips the opt-in metrics runtimes' atexit flush (bounded)
os._exit(0)
threading.Thread(target=_loop, daemon=True).start()
@@ -297,6 +297,7 @@ compression:
codex_responses_native: false # Opt-in server compaction: gpt-5.6 on OpenAI/Codex; Astra on Codex OAuth
codex_responses_compact_threshold: null # Server compaction trigger; only used when codex_responses_native: true
in_place: true # Compact on the same session id, no rotation (default: true)
prepare_ahead: false # Opt-in: prepare the next summary in the background (default: false)
# Summarization model/provider configured under auxiliary:
auxiliary:
@@ -325,6 +326,7 @@ auxiliary:
| `codex_responses_native` | `false` | bool | Opt in to OpenAI's server-side compaction on the Responses API. Engages for gpt-5.6-family models on the direct OpenAI API or a ChatGPT Codex subscription, and `gpt-6-astra` (including its `-900k` picker alias) on official Codex OAuth (see below) |
| `codex_responses_compact_threshold` | `null` | `null` or positive integer | Server-side compaction trigger, read **only when `codex_responses_native: true`** — it never changes when local compression fires; the local trigger is `threshold` (ratio) capped by `threshold_tokens`. `null` follows the resolved local compression trigger with an 8,192 token safety margin. A positive integer remains absolute and only clamps downward when required. Invalid values use automatic behavior. Automatic mode falls back to `200000` when no usable local trigger exists |
| `in_place` | `true` | bool | Compact on the same session id instead of rotating to a new one (see below) |
| `prepare_ahead` | `false` | bool | Opt-in: near the trigger, write the next summary in the background and splice it at the next automatic compaction when it still covers the same messages (see below) |
### In-place compaction (single stable session id)
@@ -669,6 +671,57 @@ The `_previous_summary` field on the compressor instance stores the last summary
text for this purpose. A deterministic fallback summary is stored there too, since
it is the handoff the transcript now carries.
### Prepared compaction (opt-in)
With `compression.prepare_ahead: true` (read at agent construction, so it takes effect on the next
session or gateway agent rebuild), `agent/prepared_compaction.py` writes the next summary before
the trigger fires. When the last provider-reported prompt count is within 12% of the trigger
(`threshold_tokens`) below it, the turn loop starts one background pass, before a tool batch runs or
at turn end. Turn-end passes are skipped for cron runs, delegated children and one-shot sessions
(`hermes chat -q`, `-z`), whose agents end with the turn. The pass plans the same window `compress()` plans (`_plan_compaction_window`) and calls
the same `_generate_summary` on a copy of the compressor and of the window's messages, so the live
transcript, the system prompt and the prompt cache are untouched until compaction. There is one pass
per compaction: while a summary is held, no further pass starts, however much the prompt grows.
At the next automatic threshold compaction `compress()` splices the completed summary when it still
covers the same messages: same session, unchanged head boundary, and the same message structure up to that boundary (role, tool call ids and
names, tool result ids), each message vouched for by its durable `message_uid`. The text of a
persisted message, tool arguments and tool bodies are not compared: the no-LLM prune and plugins
rewrite them in place, and the summary still describes the same messages. A message without a uid on
one side (caller-supplied `conversation_history` that is never persisted) is compared by text, since
nothing else can tell it from a different message. A dropped or inserted message, or a rewind or
branch into the window (new `message_uid`s), is a miss. The summary then covers
`[head, candidate boundary)` and every later message, however many arrived after the pass started, is
kept as tail; tail rules such as lean-mode tool stubbing still apply to them. Where the fresh plan
would cut does not matter: a cut that moved earlier (the prune made tail rows cheaper, tool-group
alignment) or later never discards the summary, because one prepared summary per compaction is never
redone. The pass planned its own window with a full tail, so the tail is never empty. Summary placement and
alternation handling are the normal Phase 4 assembly. The splice leaves the live compressor as the
inline call would have: a pass that recovered on the main model after the summary model failed hands
over that fallback (later compactions use the main model, and the user sees the broken-model
warning), and the attempt telemetry carries the pass's aux call with `prepared_summary: true`.
When no completed candidate fits but the pass still running would, compaction waits for that pass
instead of writing the same summary again. Each streamed chunk of the pass touches the waiting
compaction's progress hook, so the host's idle watchdog treats the wait like an inline summary
stream, and time a llama.cpp server spends reading the pass's prompt pushes the host's deadline back
as it would inline. The wait ends at the host's deadline (the compression aux timeout when there is none), on
cancellation, or when a session boundary discards the pass, and compaction then summarizes inline.
Everything else runs inline exactly as without the flag: no candidate, a candidate or running pass
that no longer fits, manual `/compress`, focus compaction, provider-overflow recovery, and
compactions where a memory provider contributes summary context (the first such compaction also stops
further passes for that agent, since each would be thrown away). A running pass overtaken by compaction or a session
boundary (including a rebind to another session id on `/resume` or `/branch`) is cancelled: it
cannot publish, and its summary stream is closed rather than billed to completion. A failed pass pauses further passes but never arms the live compressor's failure cooldown. State is per compressor and in memory only, so a gateway agent
eviction or process restart simply means the next compaction runs inline; releasing the agent (cache
eviction, a rewind, `close()`) also cancels a running pass. A held summary therefore helps only an
agent that lives across turns (interactive CLI, TUI/Desktop and messaging sessions); a `-q --resume`
chain builds a new agent per run and gets no head start. Pass log lines carry `session=<id>`, and
opt-in shared metrics count adoption and every pass and splice outcome
(`hermes.prepared_compaction.count`, see the shared-metrics guide). Plugin context engines,
Codex app-server sessions, native Responses compaction and micro-compaction are not prepared.
## Before/After Example
@@ -365,7 +365,12 @@ like `interactive`, as they did before the value existed; `background` and `dele
runs are unattended and excluded there. Packages written before `one_shot` existed
carry these runs as `interactive` and still validate. `hermes -z` leaves through
`os._exit`, so it closes its metrics session before exiting rather than relying on the
atexit hook.
atexit hook. Graceful gateway, compute-host, SIGTERMed kanban-worker and orphaned
slash-worker stops also leave through `os._exit` and run the same flush, bounded at 3 s.
Every runtime's exit flush, export and send pass binds that runtime's own profile, so a
process serving several profiles applies each profile's own collection and send consent:
a profile that turned collection off since its last turn drops its in-memory rows
instead of writing them.
Each tool invocation is represented by a Relay tool lifecycle named
`hermes.tool_call`. The terminal counter contains only bounded tool category,
@@ -453,6 +458,11 @@ offline (no network call, no subprocess):
auxiliary task runs on a local or self-hosted server (a local provider id such
as Ollama/LM Studio/llama.cpp, or a loopback/private-network base URL). The
URL itself stays local.
- `compression_prepare_ahead` (`on`/`off`): whether the profile's config turns on
`compression.prepare_ahead` (prepared compaction), read with the same truthiness
the agent uses (absent or false reads `off`), from the profile's own `config.yaml` like the other
snapshot fields (a managed-scope overlay is not read). Snapshots counted before this field
existed carry no value for it and still package.
### Decision-data metrics
@@ -556,6 +566,17 @@ Added inside package schema v4. No connector slug, tool name or query text appea
| `hermes.tool_batch.count` | provider, model, size bucket (logical calls; each `tool_call` entry counts once), connector share (`none`, `some`, `all`) | How many calls a model packs into one reply, and how much of it is connector work. One row per model reply that calls tools. |
| `hermes.connector_chain.count` | searches-before bucket (`tool_search` calls since the previous connector execute), described first (`yes`/`no`: the tool was named in an earlier `tool_describe` of the conversation), outcome (the tool outcome) | Do models search and describe before they execute a connector tool, and does skipping it fail more. One row per executed connector entry. |
#### Prepared compaction
Added inside package schema v4. Answers whether installs that turn on `compression.prepare_ahead`
(see `compression_prepare_ahead` on the snapshot) get a usable summary, or pay for passes nothing uses,
wait on them, or see a spliced compaction fail. No transcript text, window index, summary or miss text
appears in any dimension: the free-text splice-miss reason is reduced to a closed code.
| Metric | Dimensions | Question it answers |
|---|---|---|
| `hermes.prepared_compaction.count` | event (`pass_started`, `pass_completed`, `pass_failed`, `pass_cancelled`, `used`, `discarded`, `compaction_failed`, `auto_disabled`, `skipped_busy`, `internal_error`), origin (`tool_batch`, `turn_end`: the hook that started the pass; `none` on compaction-side events), reason (`none`, `fresh_required`, `provider_context`, `other_session`, `head_moved`, `transcript_shorter`, `shape_changed`, `rewound`, `text_changed`, `running_not_fit`, `wait_timeout`, `host_cancelled`, `pass_failed_while_waiting`, `superseded`, `session_boundary`, `agent_released`, `compaction_aborted`, `aux_fallback_main`), failure class (the `hermes.compression.count` vocabulary; `none` unless the event is a failure), wait bucket (`none`, `lt_1s`, `1s_to_5s`, `5s_to_30s`, `30s_to_120s`, `gte_120s`: how long a compaction waited on a running pass) | Do prepared passes pay off, and if not, why. Pass side (from the background thread): `pass_started`, `pass_completed` (summary published), `pass_failed` (the summariser returned nothing; its class is the one an inline attempt would log, a terminal summary failure first), `pass_cancelled` (the pass saw its cancel event; reason `superseded`, `session_boundary`, `agent_released`, `wait_timeout` or `host_cancelled`, set by whoever cancelled it), `skipped_busy` (the process-wide cap of two concurrent passes refused a pass; counted once per refused window, not per tool batch) and `internal_error` (failure class `exception`). Compaction side: `used` (the summary was spliced into the compacted transcript, counted at the splice itself; reason `aux_fallback_main` when the pass fell back from the summary model to the main model), `discarded` (a completed or running summary the compaction did not use, with the splice-miss code and any wait; a session boundary dropping one reads `session_boundary`, releasing the agent (cache eviction, rewind, close, a one-shot or managed-worker turn ending) reads `agent_released`, and a summary the compaction took but then aborted or no-opped before splicing reads `compaction_aborted`), `compaction_failed` (a compaction that spliced a prepared summary but did not commit, with its attempt class) and `auto_disabled` (the first compaction needing memory-provider context turned passes off for the agent). One pass can count on both sides, so never add sides together: `discarded` + `pass_cancelled` for a running pass that did not fit or was dropped at a boundary (a boundary that wakes a waiting compaction counts `discarded` once, from the boundary). |
<!-- ---- v5 harness ---- -->
#### Agent-harness accuracy
@@ -679,7 +700,8 @@ Package schemas v1, v2 and v3 remain unchanged for existing outbox files. New
packages use v4, which adds `failure_class` to `hermes.compression.count` and
`hermes.memory.op.count` and still accepts their v3 field sets (and, extended in place with additive values only, also after v4 shipped in a stable release,
`failure_class` on `hermes.extension.install.count` / `hermes.update.run` plus `registry` on the
former, still accepting their earlier v4 field sets), as v3 accepted the v2
former, still accepting their earlier v4 field sets; `hermes.prepared_compaction.count` and the
snapshot's optional `compression_prepare_ahead` arrived the same way), as v3 accepted the v2
field sets of `hermes.model_route.count`, `hermes.tool_call.count` and the task
counters, so counters recorded before an upgrade drain safely.
Vocabularies derived from in-repo registries (tool names, platforms, memory
+3
View File
@@ -1028,6 +1028,7 @@ compression:
protect_first_n: 3 # Non-system head messages pinned across compactions (0 = pin nothing)
in_place: true # Compact on the same session id (no rotation) — see below
idle_compact_after_seconds: 0 # Opt-in idle compaction (0 = disabled) — see below
prepare_ahead: false # Opt-in: prepare the next summary in the background — see below
hygiene_hard_message_limit: 5000 # Gateway safety valve — see below
hygiene_timeout_seconds: 30 # Max seconds of NO summary-model output before hygiene compression is cut off
hygiene_total_ceiling_seconds: 600 # Absolute cap on the hygiene wait even while tokens are still streaming
@@ -1079,6 +1080,8 @@ The value is the **first rung** of an escalating ladder, not a fixed interval: c
`idle_compact_after_seconds` is an **opt-in, time-based** trigger that complements the size-based `threshold`. Default `0` (disabled). When set above 0, a session that resumes after at least that many seconds of inactivity compacts its accumulated history up front, before the first reply — so a long-lived thread (e.g. a Telegram conversation you come back to hours later) doesn't re-read its full stale context on every subsequent turn. It never fires when the context is already at or below the post-compression target (`threshold × target_ratio`), and it honors the same failure-cooldown, anti-thrash, and per-session lock guards as every automatic compaction. Example: `idle_compact_after_seconds: 1800` compacts after 30 minutes idle.
`prepare_ahead` (default `false`) writes the next compaction summary in the background once the session is close to the trigger, while tools run and between turns. One summary is prepared per compaction. When automatic compaction fires and the summarized messages are still the same messages (text edits by plugins are fine; a dropped message or a rewind is not), the prepared summary is used instead of a summary call at that moment, and every message newer than it stays outside it, in the uncompressed tail. If the prepared summary is still being written when compaction fires, compaction waits for it (within the usual compression time limit) rather than writing it again. If there is no prepared summary or it no longer matches, compaction runs exactly as it does without the option. It spends an auxiliary summary call that may go unused, and it does not apply to manual `/compress`, plugin context engines, or native/app-server compaction. With a memory provider that adds context to compaction summaries it switches itself off after the first compaction, since every prepared summary would be discarded.
`proactive_prune_tokens` enables a deterministic, no-LLM prune of old tool-result payloads that runs independently of `threshold`. On large-window models the `threshold` compaction (≈50% of the window) rarely fires, so bulky tool outputs (terminal dumps, file reads, web extracts) ride along in history and get re-sent on every subsequent turn. When re-sent history exceeds `proactive_prune_tokens` (default `0` = off; try `48000` to enable), the prune dedupes identical results and summarizes older oversized tool results, protecting the most recent `protect_last_n` messages and never calling the model. Tool-call arguments are execution records and are never rewritten by pruning; the summary model has a separate bounded serializer for copies included in its prompt. The pressure pass likewise demotes tool-result bodies only. During full semantic compaction the carried head/tail rows keep those tool-result demotions (so an oversized tail can still compress), while tool-call arguments stay byte-exact. The opt-in proactive prune still commits eligible tool-result-body demotions, so `proactive_prune_min_reclaim_tokens` (default `4096`) keeps those cache-breaking commits episodic; `proactive_prune_min_result_chars` (default `8000`, clamped to ≥ 200) sets the size below which a tool result is left untouched. This runs only under the built-in `compressor` engine; other context engines inherit a no-op.
:::tip Gateway hot-reload of compression and context length
@@ -682,6 +682,8 @@ The plist sets `RunAtLoad`, so loading it starts the gateway. `hermes gateway in
:::info Local Network access (LAN devices fail with "No route to host")
macOS Local Network Privacy attributes a socket to the executable launchd spawned for the job. A bare venv Python has no application identity, so a launchd-run gateway could not reach LAN hosts (Home Assistant, local model servers) — every connect failed with `errno 65 No route to host` while the same URL worked from Terminal, and no prompt was ever shown to grant it. The generated plist therefore runs the gateway through `/usr/bin/osascript`; a JXA `system()` call starts the gateway without an interactive event-polling loop, and macOS treats its children as osascript's own — an Apple platform binary, exempt from the check. `ps` shows `osascript → stderr_timestamp → gateway run`; stop/restart/KeepAlive behave exactly as before. A plist installed by an older Hermes is refreshed by `hermes gateway install` (or on the next `hermes gateway start`).
Without an installed service, the gateway Hermes starts for you (Hermes Desktop, a chat or TUI client, or the restart after `hermes update`) runs the same way, as a one-off launchd job labelled `com.nousresearch.hermes.runtime.<id>`. A plain child process would lose LAN access as soon as the app that started it quits: macOS then holds the gateway itself responsible, and denies it. The one-off job is never installed in `~/Library/LaunchAgents`, never starts at login and is never restarted by launchd. Because osascript is the responsible app, macOS file-access prompts for that gateway (Documents, Downloads, …) name **osascript**, not Hermes.
:::
:::tip Picking up new credentials after `hermes auth add` / `hermes auth reset`