mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Merge remote-tracking branch 'origin/main' into copilot-seams/integration
This commit is contained in:
commit
1c4cbb2222
93 files changed
+6002
-573
No files matched your search
+5
-105
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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]]]:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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``
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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', '')
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user