mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Merge origin/main (#35: 133 commits to f925b01791) into feat/unified-gateway-runtime
Three conflicts: - apps/desktop/electron/updater/checkout-source.test.ts: main's354fe11d7eis the same releases/latest fixture fix this branch carried asc300c295c1(#135944); main's version (it also drops the unused release-candidates route) is taken. - agent/context_compressor.py: both sides added one import line; both kept. - gateway/run_turn.py: main's4c7ab8b2a0added trigger="gateway_hygiene" to the hygiene _compress_context call this branch moved to gateway/run_turn_hygiene.py; the branch side of run_turn.py is kept and the trigger is carried onto the moved call.
This commit is contained in:
commit
cfaafec502
159 files changed
+4778
-646
No files matched your search
@@ -251,6 +251,10 @@ def _record_codex_app_server_compaction(agent, turn, *, approx_tokens: int | Non
|
||||
"compression_count": getattr(compressor, "compression_count", 0) if compressor is not None else 0,
|
||||
"runtime": "codex_app_server", "thread_id": thread_id, "turn_id": turn_id,
|
||||
}))
|
||||
if not force:
|
||||
# Hermes-forced compactions are recorded by their compress_context attempt; this one Codex ran alone.
|
||||
from agent.compaction_events import publish_provider_native
|
||||
publish_provider_native(agent)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Content-free ``hermes.compaction`` v1 records for every history rewrite, and their fan-out.
|
||||
|
||||
One record per ``compress_context`` attempt (built from the attempt telemetry seam), per
|
||||
micro-compaction pass, per committed proactive tool-result prune, and per Codex-native thread
|
||||
compaction Hermes observes. A persistence-detached fork (background review) shares the live session id
|
||||
but rewrites only its own throwaway transcript, so it publishes nothing. The payload is flat because OpenTelemetry exporters flatten only top-level
|
||||
keys; every categorical field except the free-form ``summarizer_provider`` / ``summarizer_model`` identifiers
|
||||
comes from a closed set with an ``other`` fallback; no field carries message, summary, focus-topic, error or
|
||||
path text. Publishing never raises into compaction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from agent.model_metadata import estimate_messages_tokens_rough
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_NAME = "hermes.compaction"
|
||||
SCHEMA_VERSION = "1"
|
||||
# Relay's reserved mark name: it resets the owning agent scope's LLM-history freshness, so it is used only
|
||||
# for a committed rewrite of what the model will see next. Everything else is an ordinary attempt mark.
|
||||
COMPACTION_MARK = "compaction"
|
||||
ATTEMPT_MARK = "compaction.attempt"
|
||||
|
||||
# The v1 sets list only values this module emits. Later versions may add request-scope truncation kinds.
|
||||
_KINDS = frozenset({"summarize", "micro_summarize", "prune_tool_results", "provider_native"})
|
||||
_SCOPES = frozenset({"history", "provider"})
|
||||
_METHODS = frozenset({
|
||||
"llm_summary", "aux_fallback_main", "deterministic_fallback", "deterministic_prune", "provider", "none",
|
||||
})
|
||||
_TRIGGERS = frozenset({
|
||||
"manual", "turn_start_threshold", "pre_api", "post_tool", "idle", "engine_preflight", "overflow",
|
||||
"gateway_hygiene", "between_turns", "proactive_prune", "provider", "unknown",
|
||||
})
|
||||
# Every other known trigger is automatic; an unknown or unlisted one asserts nothing.
|
||||
_TRIGGER_CLASSES = {
|
||||
"manual": "manual", "overflow": "overflow", "provider": "provider", "unknown": "unknown", "other": "unknown",
|
||||
}
|
||||
_TRIGGER_CLASS_VALUES = frozenset({"auto", "manual", "overflow", "provider", "unknown"})
|
||||
# The error-classifier reasons whose recovery compresses with ``trigger="overflow"``.
|
||||
_OVERFLOW_REASONS = frozenset({"context_overflow", "payload_too_large", "long_context_tier"})
|
||||
_OUTCOMES = frozenset({"committed", "aborted", "failed", "skipped", "blocked"})
|
||||
_SPLIT_STATUSES = frozenset({
|
||||
"not_applicable", "in_place_committed", "rotated_committed", "failed_not_indexed", "aborted",
|
||||
})
|
||||
_TOKEN_COUNT_METHODS = frozenset({"estimate_rough"})
|
||||
# Attempt classes beyond the shared-metrics closed set: guards, Codex route exits, lease races, an engine's
|
||||
# empty transcript, and the micro-compaction pass outcomes.
|
||||
_EXTRA_FAILURE_CLASSES = frozenset({
|
||||
"blocked:cooldown", "blocked:structural_backoff", "blocked:ineffective", "blocked:unknown",
|
||||
"codex_auto_native", "codex_auto_off", "codex_no_thread", "codex_compaction_failed",
|
||||
"session_ownership_lost", "session_ownership_unreadable", "cooldown_state_unreadable", "empty_transcript",
|
||||
"summary_model_benched",
|
||||
"summarize_failed", "exchange_skipped", "defrag_failed", "stale_generation", "watermark_unavailable",
|
||||
})
|
||||
# An aborted attempt the compressor ran but deliberately did not commit; other aborts are failures.
|
||||
_ABORTED_CLASSES = frozenset({"would_grow", "no_progress", "commit_fence_cancelled"})
|
||||
_COMMIT_MODES = frozenset({"in_place_committed", "rotated_committed"})
|
||||
_MICRO_OUTCOMES = {
|
||||
"absorbed": "committed", "defrag": "committed", "stale_generation": "skipped", "watermark_unavailable": "skipped",
|
||||
}
|
||||
|
||||
# Every v1 key, so consumers see a stable shape; unknown values stay null.
|
||||
_FIELDS = (
|
||||
"harness", "kind", "scope", "official", "method", "trigger", "trigger_class", "overflow_reason", "outcome",
|
||||
"failure_class", "attempt_id", "session_id", "tokens_before", "tokens_after", "tokens_reclaimed",
|
||||
"token_count_method", "messages_before", "messages_after", "items_dropped", "context_limit", "threshold_tokens",
|
||||
"protected_head_tokens", "middle_window_tokens", "protected_tail_tokens", "duration_ms",
|
||||
"summary_generation_ms", "aux_call_duration_ms", "queue_wait_ms", "commit_ms", "summarizer_provider",
|
||||
"summarizer_model", "tool_results_pruned", "reasoning_items_pruned", "summary_input_omitted_chars",
|
||||
"compression_count", "in_place", "session_rotated", "split_status", "cache_break", "has_focus_topic",
|
||||
)
|
||||
# Integer fields copied as-is from the attempt record (record key -> payload key).
|
||||
_ATTEMPT_INTS = {
|
||||
"tokens_before": "tokens_before", "tokens_after": "tokens_after", "tokens_reclaimed": "tokens_reclaimed",
|
||||
"messages_before": "messages_before", "messages_after": "messages_after", "items_dropped": "items_dropped",
|
||||
"main_context_limit": "context_limit", "effective_threshold": "threshold_tokens",
|
||||
"protected_head_tokens": "protected_head_tokens", "middle_window_tokens": "middle_window_tokens",
|
||||
"protected_tail_tokens": "protected_tail_tokens", "total_duration_ms": "duration_ms",
|
||||
"summary_generation_ms": "summary_generation_ms", "aux_call_duration_ms": "aux_call_duration_ms",
|
||||
"queue_wait_ms": "queue_wait_ms", "commit_ms": "commit_ms", "tool_results_pruned": "tool_results_pruned",
|
||||
"reasoning_items_pruned": "reasoning_items_pruned", "summary_input_omitted_chars": "summary_input_omitted_chars",
|
||||
}
|
||||
|
||||
_warned_lock = threading.Lock()
|
||||
_warned = False
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _failure_classes() -> frozenset[str]:
|
||||
from hermes_cli.observability.shared_metrics_contract import COMPRESSION_FAILURE_CLASSES
|
||||
|
||||
return COMPRESSION_FAILURE_CLASSES | _EXTRA_FAILURE_CLASSES
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _skip_classes() -> frozenset[str]:
|
||||
from hermes_cli.observability.shared_metrics_contract import COMPRESSION_SKIP_CLASSES
|
||||
|
||||
return COMPRESSION_SKIP_CLASSES
|
||||
|
||||
|
||||
def _closed(value: Any, allowed: frozenset[str], default: str | None = "other") -> str | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
return value if value in allowed else default
|
||||
|
||||
|
||||
def _int(value: Any) -> int | None:
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
||||
|
||||
|
||||
def failure_class(value: Any) -> str:
|
||||
"""Closed failure class: ``exception:<Type>`` / ``rollback:<Type>`` keep only the prefix (the type name
|
||||
may be a plugin's); a missing class reads ``none``."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return "none"
|
||||
if value.startswith(("exception:", "rollback:")):
|
||||
value = value.split(":", 1)[0]
|
||||
return value if value in _failure_classes() else "other"
|
||||
|
||||
|
||||
def event_name(payload: dict[str, Any]) -> str:
|
||||
"""``compaction`` only for a committed history or provider rewrite; ``compaction.attempt`` otherwise."""
|
||||
committed = payload.get("outcome") == "committed" and payload.get("scope") in {"history", "provider"}
|
||||
return COMPACTION_MARK if committed else ATTEMPT_MARK
|
||||
|
||||
|
||||
def _payload(**values: Any) -> dict[str, Any]:
|
||||
payload = dict.fromkeys(_FIELDS)
|
||||
payload.update(values, harness="hermes")
|
||||
payload["kind"] = _closed(payload["kind"], _KINDS)
|
||||
payload["scope"] = _closed(payload["scope"], _SCOPES)
|
||||
payload["method"] = _closed(payload["method"], _METHODS) or "none"
|
||||
payload["trigger"] = _closed(payload["trigger"], _TRIGGERS) or "unknown"
|
||||
payload["trigger_class"] = (
|
||||
_closed(payload["trigger_class"], _TRIGGER_CLASS_VALUES, None) or _TRIGGER_CLASSES.get(payload["trigger"], "auto")
|
||||
)
|
||||
payload["outcome"] = _closed(payload["outcome"], _OUTCOMES) or "other"
|
||||
payload["token_count_method"] = _closed(payload["token_count_method"], _TOKEN_COUNT_METHODS)
|
||||
payload["attempt_id"] = payload["attempt_id"] or uuid.uuid4().hex
|
||||
payload["session_id"] = payload["session_id"] or ""
|
||||
if payload["cache_break"] is None and payload["scope"] == "history":
|
||||
payload["cache_break"] = payload["outcome"] == "committed"
|
||||
return payload
|
||||
|
||||
|
||||
def _attempt_outcome(commit_status: Any, failure: str, history_rewritten: Any) -> str:
|
||||
# A split that failed after publishing the compacted history still rewrote what the model sees next: it is
|
||||
# a committed compaction whose failure_class records the failure.
|
||||
if history_rewritten is True:
|
||||
return "committed"
|
||||
if commit_status != "aborted":
|
||||
return commit_status if commit_status in _OUTCOMES else "other"
|
||||
if failure in _skip_classes():
|
||||
return "skipped"
|
||||
return "aborted" if failure in _ABORTED_CLASSES else "failed"
|
||||
|
||||
|
||||
def _attempt_trigger(trigger_source: Any) -> tuple[Any, str | None]:
|
||||
"""``(trigger, trigger_class)``. ``auto`` is what an unlabelled automatic caller records: the reason is
|
||||
unknown, but the attempt is still known to be automatic."""
|
||||
if trigger_source == "auto":
|
||||
return "unknown", "auto"
|
||||
return trigger_source or "unknown", None
|
||||
|
||||
|
||||
def attempt_payload(record: dict[str, Any], *, compression_count: Any = None) -> dict[str, Any]:
|
||||
"""Map one attempt telemetry record (the attempt log line's dict) onto the v1 schema."""
|
||||
codex = record.get("route") == "codex_app_server"
|
||||
failure = failure_class(record.get("failure_class"))
|
||||
split_status = _closed(record.get("split_status"), _SPLIT_STATUSES)
|
||||
outcome = _attempt_outcome(record.get("commit_status"), failure, record.get("history_rewritten"))
|
||||
trigger, trigger_class = _attempt_trigger(record.get("trigger_source"))
|
||||
values: dict[str, Any] = {key: _int(record.get(src)) for src, key in _ATTEMPT_INTS.items()}
|
||||
values.update(
|
||||
kind="provider_native" if codex else "summarize", scope="provider" if codex else "history", official=True,
|
||||
method=record.get("method"), trigger=trigger, trigger_class=trigger_class,
|
||||
overflow_reason=_closed(record.get("overflow_reason"), _OVERFLOW_REASONS), outcome=outcome,
|
||||
failure_class=failure,
|
||||
attempt_id=record.get("attempt_id"), session_id=record.get("session_id"),
|
||||
token_count_method=record.get("token_count_method"),
|
||||
summarizer_provider=record.get("aux_provider") or None, summarizer_model=record.get("aux_model") or None,
|
||||
compression_count=_int(compression_count), split_status=split_status,
|
||||
# Commit mode is known only for a fully committed split; anything else leaves both null.
|
||||
in_place=split_status == "in_place_committed" if split_status in _COMMIT_MODES else None,
|
||||
session_rotated=split_status == "rotated_committed" if split_status in _COMMIT_MODES else None,
|
||||
has_focus_topic=record.get("has_focus_topic") if isinstance(record.get("has_focus_topic"), bool) else None,
|
||||
)
|
||||
if codex:
|
||||
values["cache_break"] = None # the Codex thread's provider cache is not Hermes's prompt cache
|
||||
return _payload(**values)
|
||||
|
||||
|
||||
def micro_payload(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map one micro-compaction telemetry record onto the v1 schema."""
|
||||
outcome = _MICRO_OUTCOMES.get(record.get("outcome"), "failed")
|
||||
before, after = _int(record.get("tokens_before")), _int(record.get("tokens_after"))
|
||||
return _payload(
|
||||
kind="micro_summarize", scope="history", official=False, trigger="between_turns", outcome=outcome,
|
||||
method="llm_summary" if outcome == "committed" else "none",
|
||||
failure_class="none" if outcome == "committed" else failure_class(record.get("outcome")),
|
||||
session_id=record.get("session_id"), tokens_before=before, tokens_after=after,
|
||||
tokens_reclaimed=before - after if before is not None and after is not None else None,
|
||||
token_count_method="estimate_rough", messages_before=_int(record.get("messages_before")),
|
||||
messages_after=_int(record.get("messages_after")), duration_ms=_int(record.get("duration_ms")),
|
||||
threshold_tokens=_int(record.get("threshold_tokens")), context_limit=_int(record.get("context_limit")),
|
||||
summarizer_model=record.get("aux_model") or None,
|
||||
)
|
||||
|
||||
|
||||
def prune_payload(
|
||||
*, session_id: str, tokens_before: int, tokens_after: int, messages: int, tool_results_pruned: int,
|
||||
) -> dict[str, Any]:
|
||||
"""A committed proactive tool-result prune: deterministic, no LLM, message count unchanged."""
|
||||
return _payload(
|
||||
kind="prune_tool_results", scope="history", official=False, trigger="proactive_prune", outcome="committed",
|
||||
method="deterministic_prune", failure_class="none", session_id=session_id, tokens_before=tokens_before,
|
||||
tokens_after=tokens_after, tokens_reclaimed=tokens_before - tokens_after, token_count_method="estimate_rough",
|
||||
messages_before=messages, messages_after=messages, tool_results_pruned=tool_results_pruned,
|
||||
)
|
||||
|
||||
|
||||
def provider_native_payload(*, session_id: str, compression_count: Any = None) -> dict[str, Any]:
|
||||
"""A compaction the provider ran on its own thread and Hermes only observed."""
|
||||
return _payload(
|
||||
kind="provider_native", scope="provider", official=False, trigger="provider", outcome="committed",
|
||||
method="provider", failure_class="none", session_id=session_id, compression_count=_int(compression_count),
|
||||
)
|
||||
|
||||
|
||||
def _publish(
|
||||
build: Any, *args: Any, turn_session_id: str | None = None, require_target_session_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Build one record and fan it out to the built-in Relay integration. Never raises into compaction: the
|
||||
first failure logs at WARNING with its traceback, later ones at DEBUG."""
|
||||
global _warned
|
||||
try:
|
||||
if require_target_session_id is not None:
|
||||
from agent.relay_compaction import compaction_target_available
|
||||
|
||||
if not compaction_target_available(require_target_session_id, turn_session_id=turn_session_id):
|
||||
return
|
||||
payload = build(*args, **kwargs)
|
||||
from agent.relay_compaction import emit_compaction_mark
|
||||
|
||||
emit_compaction_mark(payload["session_id"], event_name(payload), payload, turn_session_id=turn_session_id)
|
||||
except Exception:
|
||||
with _warned_lock:
|
||||
first, _warned = not _warned, True
|
||||
logger.log(logging.WARNING if first else logging.DEBUG, "compaction event publish failed", exc_info=True)
|
||||
|
||||
|
||||
def _publishes_for(agent: Any) -> bool:
|
||||
"""A persistence-detached fork (background review) rewrites only its own throwaway transcript."""
|
||||
return not getattr(agent, "_persist_disabled", False)
|
||||
|
||||
|
||||
def _turn_session_id(agent: Any) -> str | None:
|
||||
"""The session the agent's current turn started in; a rotating commit moves ``agent.session_id`` off it
|
||||
mid-turn. ``None`` outside a turn."""
|
||||
value = getattr(agent, "_inflight_turn_session_id", None)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def publish_attempt(agent: Any, record: dict[str, Any]) -> None:
|
||||
"""Publish one ``compress_context`` attempt record (the attempt telemetry seam calls this)."""
|
||||
if _publishes_for(agent):
|
||||
_publish(lambda: attempt_payload(
|
||||
record, compression_count=getattr(getattr(agent, "context_compressor", None), "compression_count", None),
|
||||
), turn_session_id=_turn_session_id(agent))
|
||||
|
||||
|
||||
def publish_micro(record: dict[str, Any], *, turn_session_id: str | None = None) -> None:
|
||||
"""Publish one micro-compaction pass record. The finalizer never runs micro-compaction for a fork, and passes
|
||||
the session its turn started in (``_turn_session_id``) because the pass may run in a rotated child."""
|
||||
_publish(micro_payload, record, turn_session_id=turn_session_id)
|
||||
|
||||
|
||||
def publish_prune(agent: Any, messages: list, pruned: list, tool_results_pruned: int) -> None:
|
||||
"""Publish one committed proactive tool-result prune, measured like the attempt record (rough estimate of
|
||||
the message list the model sees next)."""
|
||||
session_id = getattr(agent, "session_id", None) or ""
|
||||
turn_session_id = _turn_session_id(agent)
|
||||
if _publishes_for(agent):
|
||||
_publish(lambda: prune_payload(
|
||||
session_id=session_id, tokens_before=estimate_messages_tokens_rough(messages),
|
||||
tokens_after=estimate_messages_tokens_rough(pruned), messages=len(pruned),
|
||||
tool_results_pruned=tool_results_pruned,
|
||||
), turn_session_id=turn_session_id, require_target_session_id=session_id)
|
||||
|
||||
|
||||
def publish_provider_native(agent: Any) -> None:
|
||||
"""Publish one provider-native compaction Hermes observed but did not run."""
|
||||
if _publishes_for(agent):
|
||||
_publish(
|
||||
provider_native_payload, session_id=getattr(agent, "session_id", None) or "",
|
||||
compression_count=getattr(getattr(agent, "context_compressor", None), "compression_count", None),
|
||||
turn_session_id=_turn_session_id(agent),
|
||||
)
|
||||
@@ -222,7 +222,7 @@ class CompressionFacadeMixin:
|
||||
self, messages: list, system_message: str, *, approx_tokens: int | None = None, task_id: str = "default",
|
||||
focus_topic: str | None = None, force: bool = False, bypass_cooldown: bool = False,
|
||||
defer_context_engine_notification: bool = False, commit_fence=None, verbatim_tail: list | None = None,
|
||||
trigger: str | None = None, snapshot_is_current=None,
|
||||
trigger: str | None = None, snapshot_is_current=None, overflow_reason: str | None = None,
|
||||
) -> tuple:
|
||||
"""Forwarder — see ``agent.conversation_compression.compress_context``.
|
||||
``force=True`` (manual /compress) bypasses the summary-failure cooldown; ``bypass_cooldown=True``
|
||||
@@ -283,6 +283,7 @@ class CompressionFacadeMixin:
|
||||
bypass_cooldown=bypass_cooldown or same_turn_fallback_recovery,
|
||||
defer_context_engine_notification=(defer_context_engine_notification), commit_fence=fence,
|
||||
verbatim_tail=verbatim_tail, trigger=trigger, snapshot_is_current=snapshot_is_current,
|
||||
overflow_reason=overflow_reason,
|
||||
)
|
||||
|
||||
# Callers that already own a progress-aware wait (gateway session
|
||||
|
||||
@@ -10,7 +10,6 @@ import logging
|
||||
import sqlite3
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
@@ -35,6 +34,7 @@ from agent.context_engine import ContextEngine, sanitize_memory_context
|
||||
from agent.context_compressor_prellm import PreLlmSkipMixin
|
||||
from agent.context_compressor_summary import SummaryDispatchMixin
|
||||
from agent.context_compressor_retry import retryable_user_text
|
||||
from agent.context_compressor_telemetry import CompressionTelemetryMixin, _safe_int
|
||||
from agent.error_classifier import FailoverReason, classify_api_error
|
||||
from agent.micro_compaction import MicroCompactionMixin
|
||||
from agent.prompt_builder import STEER_DISPLAY_KIND
|
||||
@@ -49,14 +49,6 @@ from tools.todo_tool import TODO_INJECTION_HEADER
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
"""Best-effort integer coercion for telemetry fields."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# Summary-route pin lives in a ContextVar (not on the shared compressor) so the retry after a stalled
|
||||
# summary sees it while the detached stalled worker does not. A stall raises nothing, so the aux client's
|
||||
# exception-path fallback never fires; the host pins a fallback route for exactly ONE retry (the sole aux
|
||||
@@ -2175,7 +2167,9 @@ Describe agent/tool work only as completed actions, state, or historical work.]"
|
||||
}
|
||||
|
||||
|
||||
class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMixin, ContextEngine):
|
||||
class ContextCompressor(
|
||||
SummaryDispatchMixin, PreLlmSkipMixin, CompressionTelemetryMixin, MicroCompactionMixin, 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."""
|
||||
|
||||
@@ -2197,75 +2191,6 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
self._micro_compact_consecutive_failures = 0
|
||||
self._micro_compact_last_failure_cursor = -1
|
||||
|
||||
def _begin_compression_telemetry(
|
||||
self, *, current_tokens: int | None, attempt_id: str | None = None, session_id: str | None = None,
|
||||
trigger_source: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Initialize content-free per-attempt compression telemetry."""
|
||||
seed = getattr(self, "_compression_telemetry_seed", None)
|
||||
seed = seed if isinstance(seed, dict) else {}
|
||||
attempt_id = attempt_id or seed.get("attempt_id")
|
||||
session_id = session_id or seed.get("session_id")
|
||||
trigger_source = trigger_source or seed.get("trigger_source")
|
||||
telemetry: dict[str, Any] = {
|
||||
"event": "compression_attempt", "attempt_id": attempt_id or uuid.uuid4().hex,
|
||||
"session_id": session_id or "", "trigger_source": trigger_source or "unknown",
|
||||
"main_provider": self.provider or "", "main_model": self.model or "",
|
||||
"main_context_limit": _safe_int(self.context_length),
|
||||
"current_estimated_tokens": _safe_int(current_tokens),
|
||||
"effective_threshold": _safe_int(self.threshold_tokens), "protected_head_tokens": None,
|
||||
"protected_tail_tokens": None, "middle_window_tokens": None, "prellm_skip_count": 0,
|
||||
"aux_prompt_tokens": None, "aux_output_reservation": None, "aux_provider": "", "aux_model": "",
|
||||
"effective_aux_context": None, "fit_margin": None, "chunking": False, "chunk_count": 0,
|
||||
"total_duration_ms": None, "aux_call_duration_ms": None, "queue_wait_ms": None, "prompt_build_ms": None,
|
||||
"time_to_first_progress_ms": None, "summary_generation_ms": None, "commit_ms": None,
|
||||
"fallback_used": False, "commit_status": "unknown", "split_status": "unknown", "failure_class": None,
|
||||
# Lean-sampling coverage (filled by _record_summary_input_coverage; None on the legacy path).
|
||||
"summary_input_chars": None, "summary_input_sampled_chars": None, "summary_input_omitted_chars": None,
|
||||
"summary_input_record_count": None, "summary_input_sampled_record_count": None,
|
||||
"summary_input_elided_record_count": None,
|
||||
}
|
||||
self._active_compression_telemetry = self._last_compression_telemetry = telemetry
|
||||
return telemetry
|
||||
|
||||
def _record_compression_regions(
|
||||
self, *, head_messages: list[dict[str, Any]], middle_messages: list[dict[str, Any]],
|
||||
tail_messages: list[dict[str, Any]],
|
||||
) -> None:
|
||||
telemetry = getattr(self, "_active_compression_telemetry", None)
|
||||
if isinstance(telemetry, dict):
|
||||
telemetry["protected_head_tokens"] = estimate_messages_tokens_rough(head_messages)
|
||||
telemetry["middle_window_tokens"] = estimate_messages_tokens_rough(middle_messages)
|
||||
telemetry["protected_tail_tokens"] = estimate_messages_tokens_rough(tail_messages)
|
||||
|
||||
def _record_aux_compression_call(
|
||||
self, *, prompt_messages: list[dict[str, Any]], max_tokens: int | None, duration_ms: int,
|
||||
aux_provider: str | None = None, aux_model: str | None = None,
|
||||
effective_aux_context: int | None = None, phase_timings: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
telemetry = getattr(self, "_active_compression_telemetry", None)
|
||||
if not isinstance(telemetry, dict):
|
||||
return
|
||||
telemetry["aux_prompt_tokens"] = estimate_messages_tokens_rough(prompt_messages)
|
||||
telemetry["aux_output_reservation"] = _safe_int(max_tokens)
|
||||
if aux_provider:
|
||||
telemetry["aux_provider"] = aux_provider
|
||||
if aux_model:
|
||||
telemetry["aux_model"] = aux_model
|
||||
if effective_aux_context is not None:
|
||||
telemetry["effective_aux_context"] = _safe_int(effective_aux_context)
|
||||
if telemetry["effective_aux_context"] is not None and telemetry["aux_prompt_tokens"] is not None:
|
||||
telemetry["fit_margin"] = (telemetry["effective_aux_context"] - telemetry["aux_prompt_tokens"]
|
||||
- (telemetry["aux_output_reservation"] or 0))
|
||||
telemetry["aux_call_duration_ms"] = (telemetry.get("aux_call_duration_ms") or 0) + max(0, int(duration_ms))
|
||||
for key in ("queue_wait_ms", "prompt_build_ms", "time_to_first_progress_ms", "summary_generation_ms", "commit_ms"):
|
||||
if not isinstance(phase_timings, dict) or key not in phase_timings:
|
||||
continue
|
||||
value = _safe_int(phase_timings[key])
|
||||
# Wait and generation phases accumulate across retries; the rest are point readings.
|
||||
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 _emit_init_summary_once(self) -> None:
|
||||
"""Emit the init log line once, on first context-length resolution (keeps __init__ non-blocking)."""
|
||||
if not getattr(self, "_log_init_summary", False):
|
||||
@@ -5581,7 +5506,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
|
||||
def _finalize_compressed(
|
||||
self, compressed: list[dict[str, Any]], messages: list[dict[str, Any]], n_messages: int,
|
||||
spare_pending_images: bool,
|
||||
spare_pending_images: bool, pruned_count: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Post-assembly cleanup: orphan pairs, media, savings, markers, replay prune, mem trim."""
|
||||
# Single-prompt cron shape: the only live instruction sits in the protected head, BEFORE the
|
||||
@@ -5618,6 +5543,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
_pruned_replay = _prune_stale_reasoning_replay(compressed)
|
||||
if _pruned_replay and not self.quiet_mode:
|
||||
logger.info("Pruned stale replay items from %d assistant message(s) during compaction", _pruned_replay)
|
||||
self._record_compression_effect(n_messages, compressed, pre_estimate, pruned_count, _pruned_replay)
|
||||
self._last_compression_made_progress = True
|
||||
|
||||
# Compaction frees the biggest allocation: hand pages back to the OS (glibc/config-gated,
|
||||
@@ -5663,6 +5589,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
|
||||
_raise_if_stale_attempt(self)
|
||||
telemetry = self._begin_compress_attempt(current_tokens, force)
|
||||
telemetry["has_focus_topic"] = bool(focus_topic)
|
||||
n_messages = len(messages)
|
||||
# Only need head + 3 tail messages minimum (token budget decides the real tail size)
|
||||
_min_for_compress = self._protect_head_size(messages) + 3 + 1
|
||||
@@ -5747,7 +5674,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
)
|
||||
# Phase 4: Assemble compressed message list
|
||||
compressed = self._assemble_compressed(messages, compress_start, compress_end, scan, summary)
|
||||
return self._finalize_compressed(compressed, canonical_messages, n_messages, spare_pending_images)
|
||||
return self._finalize_compressed(compressed, canonical_messages, n_messages, spare_pending_images, pruned_count)
|
||||
|
||||
def _assemble_compressed(
|
||||
self, messages: list[dict[str, Any]], compress_start: int, compress_end: int, scan: _HandoffScan, summary: str,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Content-free per-attempt compression telemetry for ContextCompressor.
|
||||
|
||||
Split from ``agent/context_compressor.py`` (file size cap), which imports it at module level; this module
|
||||
must never import that module at module level (import cycle).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from agent.model_metadata import estimate_messages_tokens_rough
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
"""Best-effort integer coercion for telemetry fields."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class CompressionTelemetryMixin:
|
||||
"""Per-attempt telemetry dict: seeded at attempt start, filled by each compression phase."""
|
||||
|
||||
def _begin_compression_telemetry(
|
||||
self, *, current_tokens: int | None, attempt_id: str | None = None, session_id: str | None = None,
|
||||
trigger_source: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Initialize content-free per-attempt compression telemetry."""
|
||||
seed = getattr(self, "_compression_telemetry_seed", None)
|
||||
seed = seed if isinstance(seed, dict) else {}
|
||||
attempt_id = attempt_id or seed.get("attempt_id")
|
||||
session_id = session_id or seed.get("session_id")
|
||||
trigger_source = trigger_source or seed.get("trigger_source")
|
||||
telemetry: dict[str, Any] = {
|
||||
"event": "compression_attempt", "attempt_id": attempt_id or uuid.uuid4().hex,
|
||||
"session_id": session_id or "", "trigger_source": trigger_source or "unknown",
|
||||
"overflow_reason": seed.get("overflow_reason"),
|
||||
"main_provider": self.provider or "", "main_model": self.model or "",
|
||||
"main_context_limit": _safe_int(self.context_length),
|
||||
"current_estimated_tokens": _safe_int(current_tokens),
|
||||
"effective_threshold": _safe_int(self.threshold_tokens), "protected_head_tokens": None,
|
||||
"protected_tail_tokens": None, "middle_window_tokens": None, "prellm_skip_count": 0,
|
||||
"aux_prompt_tokens": None, "aux_output_reservation": None, "aux_provider": "", "aux_model": "",
|
||||
"effective_aux_context": None, "fit_margin": None, "chunking": False, "chunk_count": 0,
|
||||
"total_duration_ms": None, "aux_call_duration_ms": None, "queue_wait_ms": None, "prompt_build_ms": None,
|
||||
"time_to_first_progress_ms": None, "summary_generation_ms": None, "commit_ms": None,
|
||||
"fallback_used": False, "commit_status": "unknown", "split_status": "unknown", "failure_class": None,
|
||||
# Lean-sampling coverage (filled by _record_summary_input_coverage; None on the legacy path).
|
||||
"summary_input_chars": None, "summary_input_sampled_chars": None, "summary_input_omitted_chars": None,
|
||||
"summary_input_record_count": None, "summary_input_sampled_record_count": None,
|
||||
"summary_input_elided_record_count": None,
|
||||
# Candidate effect is captured by the compressor, then replaced with the exact committed shape.
|
||||
"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,
|
||||
}
|
||||
self._active_compression_telemetry = self._last_compression_telemetry = telemetry
|
||||
return telemetry
|
||||
|
||||
def _record_compression_regions(
|
||||
self, *, head_messages: list[dict[str, Any]], middle_messages: list[dict[str, Any]],
|
||||
tail_messages: list[dict[str, Any]],
|
||||
) -> None:
|
||||
telemetry = getattr(self, "_active_compression_telemetry", None)
|
||||
if isinstance(telemetry, dict):
|
||||
telemetry["protected_head_tokens"] = estimate_messages_tokens_rough(head_messages)
|
||||
telemetry["middle_window_tokens"] = estimate_messages_tokens_rough(middle_messages)
|
||||
telemetry["protected_tail_tokens"] = estimate_messages_tokens_rough(tail_messages)
|
||||
|
||||
def _record_aux_compression_call(
|
||||
self, *, prompt_messages: list[dict[str, Any]], max_tokens: int | None, duration_ms: int,
|
||||
aux_provider: str | None = None, aux_model: str | None = None,
|
||||
effective_aux_context: int | None = None, phase_timings: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
telemetry = getattr(self, "_active_compression_telemetry", None)
|
||||
if not isinstance(telemetry, dict):
|
||||
return
|
||||
telemetry["aux_prompt_tokens"] = estimate_messages_tokens_rough(prompt_messages)
|
||||
telemetry["aux_output_reservation"] = _safe_int(max_tokens)
|
||||
if aux_provider:
|
||||
telemetry["aux_provider"] = aux_provider
|
||||
if aux_model:
|
||||
telemetry["aux_model"] = aux_model
|
||||
if effective_aux_context is not None:
|
||||
telemetry["effective_aux_context"] = _safe_int(effective_aux_context)
|
||||
if telemetry["effective_aux_context"] is not None and telemetry["aux_prompt_tokens"] is not None:
|
||||
telemetry["fit_margin"] = (telemetry["effective_aux_context"] - telemetry["aux_prompt_tokens"]
|
||||
- (telemetry["aux_output_reservation"] or 0))
|
||||
telemetry["aux_call_duration_ms"] = (telemetry.get("aux_call_duration_ms") or 0) + max(0, int(duration_ms))
|
||||
for key in ("queue_wait_ms", "prompt_build_ms", "time_to_first_progress_ms", "summary_generation_ms", "commit_ms"):
|
||||
if not isinstance(phase_timings, dict) or key not in phase_timings:
|
||||
continue
|
||||
value = _safe_int(phase_timings[key])
|
||||
# Wait and generation phases accumulate across retries; the rest are point readings.
|
||||
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 _compression_method(self) -> str:
|
||||
"""How the summary that replaced the middle window was produced."""
|
||||
if getattr(self, "_last_summary_fallback_used", False):
|
||||
return "deterministic_fallback"
|
||||
if getattr(self, "_last_aux_model_failure_error", None):
|
||||
return "aux_fallback_main"
|
||||
return "llm_summary"
|
||||
|
||||
def _record_compression_effect(
|
||||
self, messages_before: int, compressed: list[dict[str, Any]], tokens_before: int, tool_results_pruned: int,
|
||||
reasoning_items_pruned: int,
|
||||
) -> None:
|
||||
"""Record what a finished rewrite did. ``tokens_before`` and the after count are rough message-only
|
||||
estimates (system prompt and tool schemas excluded), so they compare like for like."""
|
||||
telemetry = getattr(self, "_active_compression_telemetry", None)
|
||||
if not isinstance(telemetry, dict):
|
||||
return
|
||||
tokens_after = estimate_messages_tokens_rough(compressed)
|
||||
telemetry.update(
|
||||
method=self._compression_method(), items_dropped=getattr(self, "_last_summary_dropped_count", 0) or 0,
|
||||
messages_before=messages_before, messages_after=len(compressed), tokens_before=tokens_before,
|
||||
tokens_after=tokens_after, tokens_reclaimed=tokens_before - tokens_after,
|
||||
token_count_method="estimate_rough", tool_results_pruned=tool_results_pruned,
|
||||
reasoning_items_pruned=reasoning_items_pruned,
|
||||
)
|
||||
|
||||
def _record_committed_compression_effect(
|
||||
self, messages_before: list[dict[str, Any]], messages_after: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Replace candidate counts with the exact transcript shape that crossed the commit boundary."""
|
||||
telemetry = getattr(self, "_active_compression_telemetry", None)
|
||||
if not isinstance(telemetry, dict):
|
||||
return
|
||||
tokens_before = estimate_messages_tokens_rough(messages_before)
|
||||
tokens_after = estimate_messages_tokens_rough(messages_after)
|
||||
telemetry.update(
|
||||
messages_before=len(messages_before), messages_after=len(messages_after),
|
||||
tokens_before=tokens_before, tokens_after=tokens_after,
|
||||
tokens_reclaimed=tokens_before - tokens_after, token_count_method="estimate_rough",
|
||||
)
|
||||
+132
-147
@@ -30,8 +30,11 @@ from typing import Any, Callable, Dict, List, Literal, Optional, Tuple
|
||||
|
||||
from agent.auxiliary_client import AuxiliaryExplicitCancellation
|
||||
from agent.context_engine import automatic_compaction_status_message, sanitize_memory_context
|
||||
from agent.conversation_compression_codex import _codex_compaction_cooldown_remaining
|
||||
from agent.conversation_compression_telemetry import _emit_aborted_attempt_telemetry, _emit_compression_attempt_telemetry
|
||||
from agent.conversation_compression_codex import _compress_context_via_codex_app_server
|
||||
from agent.conversation_compression_telemetry import (
|
||||
_emit_aborted_attempt_telemetry, _emit_blocked_attempt_telemetry, _emit_bypassed_attempt_telemetry,
|
||||
_emit_compression_attempt_telemetry, _record_committed_attempt_effect, _snapshot_compression_effect_input,
|
||||
)
|
||||
from agent.memory_provider import PRE_COMPRESS_CHECKPOINT_API_VERSION
|
||||
from agent.model_metadata import estimate_messages_tokens_rough, estimate_request_tokens_rough
|
||||
from agent.session_activity import ActivityProvenance, normalize_activity_provenance
|
||||
@@ -1226,7 +1229,7 @@ def run_compress_context_with_progress_timeout(
|
||||
if telemetry_agent is not None:
|
||||
_emit_compression_attempt_telemetry(
|
||||
telemetry_agent, started_at=time.monotonic(), commit_status="aborted", split_status="aborted",
|
||||
failure_class="pool_saturated",
|
||||
failure_class="pool_saturated", include_last_telemetry=False,
|
||||
)
|
||||
return messages, _resolve_fallback_prompt()
|
||||
|
||||
@@ -2693,13 +2696,13 @@ def _resolve_lock_api(lock_db: Any) -> tuple[Any, Optional[Exception]]:
|
||||
|
||||
|
||||
def _abort_lease(
|
||||
agent: Any, lifecycle: _CompactionLifecycle, system_message: str, attempt_started_at: float,
|
||||
agent: Any, lifecycle: _CompactionLifecycle, system_message: str, attempt: _Attempt,
|
||||
failure_class: str, prompt: Optional[str] = None,
|
||||
) -> tuple[None, str]:
|
||||
"""Sit-out return for lease acquisition: prompt, aborted telemetry, terminal status edge."""
|
||||
if prompt is None:
|
||||
prompt = _existing_system_prompt(agent, system_message)
|
||||
_emit_aborted_attempt_telemetry(agent, attempt_started_at, failure_class)
|
||||
_emit_aborted_attempt_telemetry(agent, attempt.started_at, failure_class, attempt.seed)
|
||||
lifecycle.complete(force_terminal=True)
|
||||
return None, prompt
|
||||
|
||||
@@ -2740,7 +2743,7 @@ def _try_acquire_durable_lock(lease: _CompressionLease, try_acquire: Any, commit
|
||||
|
||||
def _sit_out_lock_contention(
|
||||
agent: Any, lease: _CompressionLease, lifecycle: _CompactionLifecycle, system_message: str,
|
||||
approx_tokens: Optional[int], attempt_started_at: float,
|
||||
approx_tokens: Optional[int], attempt: _Attempt,
|
||||
) -> tuple[None, str]:
|
||||
"""Another path holds the lock: publish the lock-skip signal, warn once, sit out."""
|
||||
existing = None
|
||||
@@ -2766,12 +2769,12 @@ def _sit_out_lock_contention(
|
||||
with contextlib.suppress(Exception):
|
||||
if hasattr(agent.context_compressor, "_begin_compression_telemetry"):
|
||||
agent.context_compressor._begin_compression_telemetry(current_tokens=approx_tokens)
|
||||
return _abort_lease(agent, lifecycle, system_message, attempt_started_at, "lock_contended", _existing_sp)
|
||||
return _abort_lease(agent, lifecycle, system_message, attempt, "lock_contended", _existing_sp)
|
||||
|
||||
|
||||
def _acquire_compression_lease(
|
||||
agent: Any, *, commit_fence: Optional[CompressionCommitFence], lifecycle: _CompactionLifecycle,
|
||||
system_message: str, approx_tokens: Optional[int], attempt_started_at: float,
|
||||
system_message: str, approx_tokens: Optional[int], attempt: _Attempt,
|
||||
) -> tuple[Optional[_CompressionLease], Optional[str]]:
|
||||
"""Take the per-session compression lock; ``(None, prompt)`` means sit out.
|
||||
Two AIAgents sharing a session_id (e.g. background review fork) would both rotate and orphan a child.
|
||||
@@ -2821,12 +2824,12 @@ def _acquire_compression_lease(
|
||||
"Compression commit cancelled before lock acquisition (session=%s).", agent.session_id or "none"
|
||||
)
|
||||
agent._last_compaction_in_place = False
|
||||
return _abort_lease(agent, lifecycle, system_message, attempt_started_at, "commit_fence_cancelled")
|
||||
return _abort_lease(agent, lifecycle, system_message, attempt, "commit_fence_cancelled")
|
||||
_lock_acquired = _try_acquire_durable_lock(lease, _try_acquire_lock, commit_fence)
|
||||
if not _lock_acquired:
|
||||
lease.finish_lock_setup()
|
||||
return _sit_out_lock_contention(
|
||||
agent, lease, lifecycle, system_message, approx_tokens, attempt_started_at
|
||||
agent, lease, lifecycle, system_message, approx_tokens, attempt
|
||||
)
|
||||
if lease.holder is not None:
|
||||
agent._active_compression_lock_holder = lease.holder
|
||||
@@ -2837,19 +2840,20 @@ def _acquire_compression_lease(
|
||||
)
|
||||
agent._last_compaction_in_place = False
|
||||
_existing_sp = _existing_system_prompt(agent, system_message)
|
||||
_emit_aborted_attempt_telemetry(agent, attempt_started_at, "commit_fence_cancelled")
|
||||
_emit_aborted_attempt_telemetry(agent, attempt.started_at, "commit_fence_cancelled", attempt.seed)
|
||||
lease.release()
|
||||
return None, _existing_sp
|
||||
return lease, None
|
||||
|
||||
|
||||
def _adopt_if_parent_rotated(
|
||||
agent: Any, lease: _CompressionLease, messages: list, system_message: str
|
||||
agent: Any, lease: _CompressionLease, messages: list, system_message: str, *, attempt: _Attempt,
|
||||
approx_tokens: Optional[int],
|
||||
) -> Optional[tuple[list, str]]:
|
||||
"""Sit out (or adopt the live child) when the parent was already rotated.
|
||||
A late contender can take the parent lock after the winner released it and rotated; holding the lock does
|
||||
not prove this agent still owns a live parent. Returns the ``compress_context`` result to hand back, or
|
||||
None to proceed."""
|
||||
None to proceed. Each returning exit logs its own attempt record."""
|
||||
if lease.db is None or not lease.sid:
|
||||
return None
|
||||
try:
|
||||
@@ -2860,11 +2864,19 @@ def _adopt_if_parent_rotated(
|
||||
lease.sid, type(_session_err).__name__, _session_err,
|
||||
)
|
||||
lease.release()
|
||||
_emit_bypassed_attempt_telemetry(
|
||||
agent, attempt.started_at, commit_status="aborted", failure_class="session_ownership_unreadable",
|
||||
approx_tokens=approx_tokens, attempt_seed=attempt.seed,
|
||||
)
|
||||
return messages, _existing_system_prompt(agent, system_message)
|
||||
if not _parent_already_rotated:
|
||||
return None
|
||||
recovered_messages = _adopt_live_compression_child(agent, lease.db, lease.sid)
|
||||
lease.release()
|
||||
_emit_bypassed_attempt_telemetry(
|
||||
agent, attempt.started_at, commit_status="skipped", failure_class="session_ownership_lost",
|
||||
approx_tokens=approx_tokens, attempt_seed=attempt.seed,
|
||||
)
|
||||
_existing_sp = _existing_system_prompt(agent, system_message)
|
||||
if recovered_messages is not None:
|
||||
logger.warning("compression recovery: stale session=%s adopted live child=%s", lease.sid, agent.session_id)
|
||||
@@ -3179,8 +3191,7 @@ def _rebuild_system_prompt_at_boundary(agent: Any, system_message: str) -> str:
|
||||
|
||||
|
||||
def _salvage_or_refuse_grown_transcript(
|
||||
agent: Any, messages: list, compressed: list, *, system_message: str, attempt_started_at: float,
|
||||
attempt_snapshot: dict,
|
||||
agent: Any, original_messages: list, compressed: list, *, system_message: str, attempt: _Attempt,
|
||||
) -> tuple[Optional[list], Optional[str]]:
|
||||
"""Anti-growth guard at the COMMIT SITE (in-place commits before the gateway can inspect).
|
||||
Compares like-for-like rough estimates; on growth tries one mechanical salvage pass, else treats the
|
||||
@@ -3193,13 +3204,13 @@ def _salvage_or_refuse_grown_transcript(
|
||||
# (#83339), but in-place compaction commits inside this method via archive_and_compact — before the
|
||||
# gateway can inspect the result — so the guard must live here to protect both paths. On growth, treat
|
||||
# the attempt as a no-op: the original transcript stays untouched and durable.
|
||||
_rough_in = estimate_messages_tokens_rough(messages)
|
||||
_rough_in = estimate_messages_tokens_rough(original_messages)
|
||||
_rough_out = estimate_messages_tokens_rough(compressed)
|
||||
if _rough_out > _rough_in:
|
||||
# Todo refresh and user-turn anchoring run after the compressor's own size check
|
||||
# and can tip a break-even candidate; give it one mechanical salvage pass.
|
||||
from agent.context_compressor import salvage_grown_transcript
|
||||
_salvaged = salvage_grown_transcript(messages, compressed, budget=_rough_in)
|
||||
_salvaged = salvage_grown_transcript(original_messages, compressed, budget=_rough_in)
|
||||
if _salvaged is not None:
|
||||
_salv_est = estimate_messages_tokens_rough(_salvaged)
|
||||
if _salv_est < _rough_in:
|
||||
@@ -3226,7 +3237,7 @@ def _salvage_or_refuse_grown_transcript(
|
||||
"shrinking it. No messages were dropped — conversation continues unchanged."
|
||||
)
|
||||
_existing_sp = _existing_system_prompt(agent, system_message)
|
||||
_emit_aborted_attempt_telemetry(agent, attempt_started_at, "would_grow")
|
||||
_emit_aborted_attempt_telemetry(agent, attempt.started_at, "would_grow", attempt.seed)
|
||||
# Count the refusal as an ineffective-compaction strike so the anti-thrash
|
||||
# breaker latches; otherwise auto-compress retries the same summary every turn.
|
||||
with _swallow('could not record rejected-compaction strike', exc_info=True):
|
||||
@@ -3234,7 +3245,7 @@ def _salvage_or_refuse_grown_transcript(
|
||||
# compression retries the identical summary request on every turn (#88568). Manual /compress
|
||||
# keeps bypassing the latch (force=True skips the guards).
|
||||
agent.context_compressor.record_rejected_compaction()
|
||||
_restore_prune_rearm_tokens(agent.context_compressor, attempt_snapshot)
|
||||
_restore_prune_rearm_tokens(agent.context_compressor, attempt.snapshot)
|
||||
return None, _existing_sp
|
||||
return compressed, None
|
||||
|
||||
@@ -3504,7 +3515,7 @@ def _finish_compaction_boundary(
|
||||
|
||||
def _candidate_rejected(
|
||||
agent: Any, compressed: Any, messages: list, messages_before_compression: list, *,
|
||||
attempt_generation: Any, attempt_started_at: float,
|
||||
attempt: _Attempt,
|
||||
) -> bool:
|
||||
"""Reject an unusable compression candidate before any session mutation.
|
||||
Order matters: compressor-reported abort, no progress, empty transcript, superseded attempt. Each branch surfaces
|
||||
@@ -3523,7 +3534,7 @@ def _candidate_rejected(
|
||||
"Run /compress to retry, or /new to start a fresh session."
|
||||
)
|
||||
_emit_aborted_attempt_telemetry(
|
||||
agent, attempt_started_at, _summary_error and "summary_generation_aborted"
|
||||
agent, attempt.started_at, _summary_error and "summary_generation_aborted", attempt.seed
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -3536,8 +3547,6 @@ def _candidate_rejected(
|
||||
if compressed == messages_before_compression or (
|
||||
_strip_marker_for_comparison(compressed) == _strip_marker_for_comparison(messages_before_compression)
|
||||
):
|
||||
if messages != messages_before_compression:
|
||||
messages[:] = copy.deepcopy(messages_before_compression)
|
||||
logger.info(
|
||||
"Compression made no progress (session=%s) — skipping boundary rewrite.", agent.session_id or "none"
|
||||
)
|
||||
@@ -3546,7 +3555,7 @@ def _candidate_rejected(
|
||||
with _swallow('no-progress backoff arm failed', exc_info=True):
|
||||
if callable(_recorder := getattr(agent.context_compressor, "_record_structural_no_op", None)):
|
||||
_recorder("compaction returned the transcript unchanged (no_progress)")
|
||||
_emit_aborted_attempt_telemetry(agent, attempt_started_at, "no_progress")
|
||||
_emit_aborted_attempt_telemetry(agent, attempt.started_at, "no_progress", attempt.seed)
|
||||
return True
|
||||
if not compressed:
|
||||
logger.error(
|
||||
@@ -3557,6 +3566,7 @@ def _candidate_rejected(
|
||||
agent._emit_warning(
|
||||
"⚠ Compression returned an empty transcript. No session split was performed; conversation continues unchanged."
|
||||
)
|
||||
_emit_aborted_attempt_telemetry(agent, attempt.started_at, "empty_transcript", attempt.seed)
|
||||
return True
|
||||
|
||||
# A newer WORKING attempt supersedes us; discard the late candidate. No-op
|
||||
@@ -3564,20 +3574,19 @@ def _candidate_rejected(
|
||||
# discards a completed candidate and livelocks compression. Without a
|
||||
# published working marker, fence poison alone misses a successor that
|
||||
# minted its own fence — fall back to the entry-generation check.
|
||||
if not _working_attempt_is_current(agent.context_compressor, attempt_generation):
|
||||
if not _working_attempt_is_current(agent.context_compressor, attempt.generation):
|
||||
_working_gen = getattr(
|
||||
agent.context_compressor, "_compression_working_attempt_generation", None
|
||||
)
|
||||
logger.warning(
|
||||
"Discarding late compression candidate: attempt generation "
|
||||
"%s was superseded by a newer working attempt (current working: %s) (session=%s).",
|
||||
attempt_generation,
|
||||
attempt.generation,
|
||||
_working_gen,
|
||||
agent.session_id or "none",
|
||||
)
|
||||
_restore_messages_snapshot(messages, messages_before_compression)
|
||||
agent._last_compaction_in_place = False
|
||||
_emit_aborted_attempt_telemetry(agent, attempt_started_at, "attempt_superseded")
|
||||
_emit_aborted_attempt_telemetry(agent, attempt.started_at, "attempt_superseded", attempt.seed)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -3658,6 +3667,23 @@ def held_archive_watermark(
|
||||
return min(newest_held, watermark)
|
||||
|
||||
|
||||
_ATTEMPT_TELEMETRY_FIELDS = (
|
||||
"_last_compression_telemetry", "_active_compression_telemetry", "_compression_telemetry_seed",
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _hold_attempt_telemetry(compressor: Any):
|
||||
"""Restore the live attempt's telemetry after a call that may reset it (the memory flush)."""
|
||||
held = tuple(getattr(compressor, name, None) for name in _ATTEMPT_TELEMETRY_FIELDS)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for name, value in zip(_ATTEMPT_TELEMETRY_FIELDS, held):
|
||||
with contextlib.suppress(Exception):
|
||||
setattr(compressor, name, value)
|
||||
|
||||
|
||||
def _commit_compaction(
|
||||
agent: Any, messages: list, compressed: list, *, in_place: bool, lease: _CompressionLease,
|
||||
new_system_prompt: str, system_message: str, compressed_user_turn_outcome: str,
|
||||
@@ -3679,21 +3705,22 @@ def _commit_compaction(
|
||||
if agent._session_db:
|
||||
split_status = "pending"
|
||||
try:
|
||||
# Memory extraction runs in BOTH modes: pre-compaction turns are summarized
|
||||
# away whether or not the id rotates.
|
||||
agent.commit_memory_session(messages)
|
||||
|
||||
# Pop _compaction_tail tags before the size estimate / rotation: they must not
|
||||
# inflate anti-growth or reach the provider. Track ids: salvage may subset list.
|
||||
_tail_tagged_ids = {id(m) for m in compressed if isinstance(m, dict) and m.pop("_compaction_tail", None)}
|
||||
original_messages = messages_before_compression if messages_before_compression is not None else messages
|
||||
compressed, _refused_sp = _salvage_or_refuse_grown_transcript(
|
||||
agent, messages, compressed, system_message=system_message, attempt_started_at=attempt.started_at,
|
||||
attempt_snapshot=attempt.snapshot,
|
||||
agent, original_messages, compressed, system_message=system_message, attempt=attempt,
|
||||
)
|
||||
if compressed is None:
|
||||
return _CommitOutcome(
|
||||
compressed=messages, refused_prompt=_refused_sp, commit_started_at=commit_started_at
|
||||
)
|
||||
# Publish memory only after every refusal gate, from the immutable pre-compression snapshot.
|
||||
# The engine's session-end reset nulls the in-flight attempt telemetry the success emit
|
||||
# still needs (#118580), so hold it across the call.
|
||||
with _hold_attempt_telemetry(agent.context_compressor):
|
||||
agent.commit_memory_session(original_messages)
|
||||
from agent.context_compressor import PROACTIVE_PRUNE_REARM_MODEL_CONFIG_KEY, stamp_db_persisted_markers
|
||||
from hermes_cli.partial_compress import rejoin_compressed_head_and_tail
|
||||
if in_place:
|
||||
@@ -3866,7 +3893,7 @@ def _run_summary_phase(
|
||||
) -> _SummaryPhase:
|
||||
"""Adopt a grown durable parent, gather memory context and run the summarizer.
|
||||
A hard cancel restores the compressor snapshot + live list, records a stall backoff while the lease is
|
||||
still held, and aborts; any other failure releases the lease and re-raises."""
|
||||
still held, and aborts; any other failure restores the live list, releases the lease and re-raises."""
|
||||
pre_msg_count = len(messages)
|
||||
_activity_heartbeat: Optional[_CompressionActivityHeartbeat] = None
|
||||
messages_before_compression = None
|
||||
@@ -3911,7 +3938,9 @@ def _run_summary_phase(
|
||||
_restore_messages_snapshot(messages, messages_before_compression)
|
||||
_stop_heartbeat("context compression rollback failed")
|
||||
lease.release()
|
||||
_emit_aborted_attempt_telemetry(agent, attempt.started_at, f"rollback:{type(_rollback_exc).__name__}")
|
||||
_emit_aborted_attempt_telemetry(
|
||||
agent, attempt.started_at, f"rollback:{type(_rollback_exc).__name__}", attempt.seed
|
||||
)
|
||||
raise
|
||||
_restore_messages_snapshot(messages, messages_before_compression)
|
||||
# Record after restore so rollback cannot wipe a stall backoff, and
|
||||
@@ -3930,14 +3959,16 @@ def _run_summary_phase(
|
||||
_emit_aborted_attempt_telemetry(
|
||||
agent, attempt.started_at,
|
||||
STALL_INTERRUPTED_FAILURE_CLASS if _stall_backoff
|
||||
else "commit_fence_cancelled" if _host_cancel else "explicit_interrupt",
|
||||
else "commit_fence_cancelled" if _host_cancel else "explicit_interrupt", attempt.seed,
|
||||
)
|
||||
return _SummaryPhase(messages=messages, abort_prompt=_existing_system_prompt(agent, system_message))
|
||||
except BaseException as _compress_exc:
|
||||
# Any failure after lock acquisition must release it or the session is permanently blocked from compression.
|
||||
_restore_messages_snapshot(messages, messages_before_compression)
|
||||
_stop_heartbeat("context compression failed")
|
||||
lease.release()
|
||||
_emit_aborted_attempt_telemetry(agent, attempt.started_at, f"exception:{type(_compress_exc).__name__}")
|
||||
_emit_aborted_attempt_telemetry(
|
||||
agent, attempt.started_at, f"exception:{type(_compress_exc).__name__}", attempt.seed
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
_stop_heartbeat("context compression completed")
|
||||
@@ -3954,6 +3985,9 @@ class _Attempt:
|
||||
snapshot: dict
|
||||
generation: int
|
||||
started_at: float
|
||||
# This attempt's id, start session and trigger. Its emits pass it explicitly: the agent's copy is replaced
|
||||
# when a stall fallback begins while this attempt is still unwinding.
|
||||
seed: dict[str, Any]
|
||||
durable_cooldown_authoritative: Optional[bool] = None
|
||||
durable_cooldown_state: Optional[dict[str, Any]] = None
|
||||
|
||||
@@ -3967,7 +4001,7 @@ class _Attempt:
|
||||
|
||||
def _begin_compression_attempt(
|
||||
agent: Any, *, force: bool, defer_notification: bool, trigger: Optional[str] = None,
|
||||
approx_tokens: Optional[int] = None,
|
||||
approx_tokens: Optional[int] = None, overflow_reason: Optional[str] = None,
|
||||
) -> _Attempt:
|
||||
"""Snapshot + claim the compressor, reset per-attempt agent signals, seed telemetry.
|
||||
The claim stops a late-unwinding sibling (stall-fallback overlap) from restoring its snapshot over ours or
|
||||
@@ -3977,6 +4011,13 @@ def _begin_compression_attempt(
|
||||
``conversation_history_after_compression()``."""
|
||||
snapshot = _snapshot_compressor_attempt_state(agent.context_compressor)
|
||||
generation = _claim_compressor_attempt(agent.context_compressor)
|
||||
# A previous attempt's telemetry must not ride into this one (#118580 follow-up): the
|
||||
# commit-time hold keeps the trio for THIS attempt's own emit, but the next attempt (and
|
||||
# every emit before it re-seeds) must start clean. AFTER the snapshot so a late-unwind
|
||||
# restore still round-trips the full pre-attempt state.
|
||||
for _name in _ATTEMPT_TELEMETRY_FIELDS:
|
||||
with contextlib.suppress(Exception):
|
||||
setattr(agent.context_compressor, _name, None)
|
||||
if defer_notification and callable(getattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None)):
|
||||
raise RuntimeError("a compression notification is already pending")
|
||||
agent._last_compression_attempt_recorded = True
|
||||
@@ -3993,15 +4034,19 @@ def _begin_compression_attempt(
|
||||
started_at = time.monotonic()
|
||||
attempt_id = uuid.uuid4().hex
|
||||
trigger = trigger or ("manual" if force else "auto")
|
||||
seed = {
|
||||
"attempt_id": attempt_id, "session_id": getattr(agent, "session_id", None) or "", "trigger_source": trigger,
|
||||
"overflow_reason": overflow_reason,
|
||||
}
|
||||
with contextlib.suppress(Exception):
|
||||
agent._compression_attempt_id = attempt_id
|
||||
# The agent keeps its own copy: a pre-commit restore puts the previous seed back on the compressor.
|
||||
agent._compression_attempt_seed = dict(seed)
|
||||
from hermes_cli.observability.shared_metrics_events import begin_compression_attempt
|
||||
|
||||
begin_compression_attempt(trigger, approx_tokens or getattr(agent.context_compressor, "last_prompt_tokens", None))
|
||||
agent.context_compressor._compression_telemetry_seed = {
|
||||
"attempt_id": attempt_id, "session_id": agent.session_id or "", "trigger_source": trigger,
|
||||
}
|
||||
return _Attempt(snapshot, generation, started_at)
|
||||
agent.context_compressor._compression_telemetry_seed = dict(seed)
|
||||
return _Attempt(snapshot, generation, started_at, seed)
|
||||
|
||||
|
||||
def _route_codex_compaction(
|
||||
@@ -4011,10 +4056,15 @@ def _route_codex_compaction(
|
||||
"""Codex owns the real thread: run its own compact under the commit fence bracket."""
|
||||
if commit_fence is not None and not commit_fence.begin_commit(getattr(agent, "_hard_interrupt_requested", None)):
|
||||
attempt.restore_compressor(agent.context_compressor)
|
||||
_emit_bypassed_attempt_telemetry(
|
||||
agent, attempt.started_at, commit_status="aborted", failure_class="commit_fence_cancelled",
|
||||
approx_tokens=approx_tokens, route="codex_app_server", attempt_seed=attempt.seed,
|
||||
)
|
||||
return messages, _existing_system_prompt(agent, system_message)
|
||||
try:
|
||||
return _compress_context_via_codex_app_server(
|
||||
agent, messages, system_message, approx_tokens=approx_tokens, task_id=task_id, force=force
|
||||
agent, messages, system_message, approx_tokens=approx_tokens, task_id=task_id, force=force,
|
||||
started_at=attempt.started_at, attempt_seed=attempt.seed,
|
||||
)
|
||||
finally:
|
||||
if commit_fence is not None:
|
||||
@@ -4056,6 +4106,7 @@ def compress_context(
|
||||
bypass_cooldown: bool = False, defer_context_engine_notification: bool = False,
|
||||
commit_fence: Optional[CompressionCommitFence] = None, verbatim_tail: Optional[list] = None,
|
||||
trigger: Optional[str] = None, snapshot_is_current: Optional[Callable[[], bool]] = None,
|
||||
overflow_reason: Optional[str] = None,
|
||||
) -> tuple[list, str]:
|
||||
"""Compress conversation context and split the session in SQLite.
|
||||
``force`` (manual /compress) clears the summary-failure cooldown; ``bypass_cooldown`` (provider-proven
|
||||
@@ -4078,14 +4129,16 @@ def compress_context(
|
||||
cooperative fence for executor callers that may time out. It prevents a late worker from mutating
|
||||
session state after its caller has moved on. verbatim_tail: The exchanges ``/compress here N`` keeps
|
||||
after ``messages``; an in-place commit stores them after the compacted head and returns head + tail.
|
||||
trigger: Why this attempt runs (``"overflow"`` for provider-rejected requests); defaults to manual/auto
|
||||
from ``force``. Feeds attempt telemetry only.
|
||||
trigger: Why this attempt runs (``"overflow"``, ``"pre_api"``, ``"post_tool"``, ``"idle"``, ...); defaults
|
||||
to manual/auto from ``force``. Feeds attempt telemetry only; shared metrics bucket it coarsely.
|
||||
snapshot_is_current: Optional host snapshot validation after durable lease admission. This protects
|
||||
edits completed before admission; it is not a substitute for the host's final publication fence.
|
||||
overflow_reason: The error classifier's reason for the provider rejection an ``"overflow"`` attempt
|
||||
recovers from (``"context_overflow"``, ``"payload_too_large"``, ``"long_context_tier"``). Telemetry only.
|
||||
"""
|
||||
attempt = _begin_compression_attempt(
|
||||
agent, force=force, defer_notification=defer_context_engine_notification, trigger=trigger,
|
||||
approx_tokens=approx_tokens,
|
||||
approx_tokens=approx_tokens, overflow_reason=overflow_reason,
|
||||
)
|
||||
|
||||
# Codex owns the real thread; route compaction to its own compact (config
|
||||
@@ -4094,9 +4147,14 @@ def compress_context(
|
||||
checkpoint_required = getattr(agent, "compression_checkpoint_required", False) is True
|
||||
if getattr(agent, "api_mode", None) == "codex_app_server":
|
||||
if checkpoint_required:
|
||||
raise _checkpoint_blocked(
|
||||
blocked = _checkpoint_blocked(
|
||||
"codex_app_server owns the authoritative thread and does not expose a truthful pre-compaction transcript boundary"
|
||||
)
|
||||
_emit_bypassed_attempt_telemetry(
|
||||
agent, attempt.started_at, commit_status="aborted", failure_class=f"exception:{type(blocked).__name__}",
|
||||
approx_tokens=approx_tokens, route="codex_app_server", attempt_seed=attempt.seed,
|
||||
)
|
||||
raise blocked
|
||||
return _route_codex_compaction(
|
||||
agent, messages, system_message, commit_fence=commit_fence, attempt=attempt, approx_tokens=approx_tokens,
|
||||
task_id=task_id, force=force,
|
||||
@@ -4105,6 +4163,7 @@ def compress_context(
|
||||
# All automatic entrypoints honor compressor cooldown/breaker state; hygiene's
|
||||
# fresh AIAgent loads the persisted streak via bind_session_state() first.
|
||||
if not force and _automatic_compression_gate_blocks(agent, bypass_cooldown):
|
||||
_emit_blocked_attempt_telemetry(agent, attempt.started_at, approx_tokens, attempt.seed)
|
||||
return messages, _existing_system_prompt(agent, system_message)
|
||||
|
||||
_pre_msg_count = len(messages)
|
||||
@@ -4132,7 +4191,7 @@ def compress_context(
|
||||
agent._compression_feasibility_checked = True
|
||||
lease, _abort_prompt = _acquire_compression_lease(
|
||||
agent, commit_fence=commit_fence, lifecycle=lifecycle, system_message=system_message,
|
||||
approx_tokens=approx_tokens, attempt_started_at=attempt.started_at,
|
||||
approx_tokens=approx_tokens, attempt=attempt,
|
||||
)
|
||||
if lease is None:
|
||||
return messages, _abort_prompt
|
||||
@@ -4146,10 +4205,12 @@ def compress_context(
|
||||
lease.release()
|
||||
raise
|
||||
if not current:
|
||||
_emit_aborted_attempt_telemetry(agent, attempt.started_at, "snapshot_stale")
|
||||
_emit_aborted_attempt_telemetry(agent, attempt.started_at, "snapshot_stale", attempt.seed)
|
||||
lease.release()
|
||||
return messages, _existing_system_prompt(agent, system_message)
|
||||
_adopted = _adopt_if_parent_rotated(agent, lease, messages, system_message)
|
||||
_adopted = _adopt_if_parent_rotated(
|
||||
agent, lease, messages, system_message, attempt=attempt, approx_tokens=approx_tokens,
|
||||
)
|
||||
if _adopted is not None:
|
||||
return _adopted
|
||||
|
||||
@@ -4162,12 +4223,17 @@ def compress_context(
|
||||
# Durable cooldown read failed under a built-in compressor: force=True could
|
||||
# clear an unknown newer row before cancellation could restore it. Abort.
|
||||
lease.release()
|
||||
_emit_bypassed_attempt_telemetry(
|
||||
agent, attempt.started_at, commit_status="aborted", failure_class="cooldown_state_unreadable",
|
||||
approx_tokens=approx_tokens, attempt_seed=attempt.seed,
|
||||
)
|
||||
return messages, _existing_system_prompt(agent, system_message)
|
||||
|
||||
# Another path may have compacted this session in place since construction;
|
||||
# re-read breaker state under the lock, not the bind_session_state() snapshot.
|
||||
if not force and _automatic_compression_gate_blocks(agent, bypass_cooldown, include_cooldown=False):
|
||||
lease.release()
|
||||
_emit_blocked_attempt_telemetry(agent, attempt.started_at, approx_tokens, attempt.seed)
|
||||
return messages, _existing_system_prompt(agent, system_message)
|
||||
|
||||
# Interrupts/redirects must not tear a summary in half. Use the explicit stop
|
||||
@@ -4188,18 +4254,19 @@ def compress_context(
|
||||
messages, compressed = phase.messages, phase.compressed
|
||||
messages_before_compression = phase.messages_before_compression
|
||||
approx_tokens, _pre_msg_count = phase.approx_tokens, phase.pre_msg_count
|
||||
effect_messages_before = _snapshot_compression_effect_input(messages_before_compression, verbatim_tail)
|
||||
_commit_fence_entered = False
|
||||
try:
|
||||
# Capture the verdict before rotation callbacks: lifecycle hooks may reset
|
||||
# compressor fields on rebind; record only after the full boundary commits.
|
||||
# Capture the verdict before rotation callbacks reset compressor fields on rebind.
|
||||
_compression_made_progress, _compression_used_fallback, _compression_feasibility_skip = (
|
||||
bool(getattr(agent.context_compressor, name, False))
|
||||
for name in ("_last_compression_made_progress", "_last_summary_fallback_used", "_last_feasibility_skip")
|
||||
)
|
||||
if _candidate_rejected(
|
||||
agent, compressed, messages, messages_before_compression, attempt_generation=attempt.generation,
|
||||
attempt_started_at=attempt.started_at,
|
||||
agent, compressed, messages, messages_before_compression, attempt=attempt,
|
||||
):
|
||||
# Engines may mutate their input in place; a refused candidate must not leak that mutation.
|
||||
_restore_messages_snapshot(messages, messages_before_compression)
|
||||
return messages, _existing_system_prompt(agent, system_message)
|
||||
if commit_fence is not None:
|
||||
_commit_fence_entered = commit_fence.begin_commit(_hard_cancel_event)
|
||||
@@ -4217,14 +4284,13 @@ def compress_context(
|
||||
_existing_sp = _existing_system_prompt(agent, system_message)
|
||||
_emit_aborted_attempt_telemetry(
|
||||
agent, attempt.started_at,
|
||||
STALL_INTERRUPTED_FAILURE_CLASS if _stall_backoff else "commit_fence_cancelled",
|
||||
STALL_INTERRUPTED_FAILURE_CLASS if _stall_backoff else "commit_fence_cancelled", attempt.seed,
|
||||
)
|
||||
return messages, _existing_sp
|
||||
_warn_summary_or_aux_fallback(agent)
|
||||
# A just-delivered reply the engine folded away must stay live or the
|
||||
# next render drops it from the surface (#118900). It runs FIRST: the
|
||||
# todo fold rewrites the trailing user row (its follower would no longer
|
||||
# match) and both later passes place themselves around the tail, so the
|
||||
# todo fold rewrites the trailing user row, and both later passes place themselves around the tail, so the
|
||||
# reply has to be back in its chronological slot before they look.
|
||||
from agent.conversation_compression_reply_anchor import _ensure_compressed_keeps_last_assistant_reply
|
||||
|
||||
@@ -4251,10 +4317,11 @@ def compress_context(
|
||||
# The reinserted copy keeps the original's _row_id/timestamp (production flush stamps
|
||||
# both); carry exactly that one row so the commit rewinds the durable original instead
|
||||
# of archiving it compacted=1 next to a fresh twin (display would show it twice). The
|
||||
# todo fold / user-anchor rows added above are NOT carried: they keep their own class.
|
||||
# todo fold / user-anchor rows above keep their own class and are not carried.
|
||||
carried_messages=[reinserted_reply] if reinserted_reply is not None else None,
|
||||
)
|
||||
if commit.refused_prompt is not None:
|
||||
_restore_messages_snapshot(messages, messages_before_compression)
|
||||
return messages, commit.refused_prompt
|
||||
compressed = commit.compressed
|
||||
split_status = commit.split_status
|
||||
@@ -4273,15 +4340,17 @@ def compress_context(
|
||||
lifecycle.commit_status = (
|
||||
"committed" if split_status in {"not_applicable", "in_place_committed", "rotated_committed"} else "aborted"
|
||||
)
|
||||
if lifecycle.commit_status == "committed":
|
||||
_record_committed_attempt_effect(agent, effect_messages_before, compressed, verbatim_tail, split_status)
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent, started_at=attempt.started_at, commit_status=lifecycle.commit_status, split_status=split_status,
|
||||
failure_class=("session_split_failed" if split_status in {"failed_not_indexed", "aborted"} else None),
|
||||
commit_started_at=commit.commit_started_at,
|
||||
commit_started_at=commit.commit_started_at, attempt_seed=attempt.seed,
|
||||
history_rewritten=compressed is not messages,
|
||||
)
|
||||
return compressed, new_system_prompt
|
||||
finally:
|
||||
# Release the OLD session's lock only after rotation and all post-rotation
|
||||
# bookkeeping; a waking contender then sees the NEW id and acquires on that.
|
||||
# Release the old lock after rotation bookkeeping so a waking contender sees the new session id.
|
||||
try:
|
||||
lease.release()
|
||||
finally:
|
||||
@@ -4289,90 +4358,6 @@ def compress_context(
|
||||
commit_fence.finish_commit()
|
||||
|
||||
|
||||
def _record_codex_compaction_failure(agent: Any, error: str) -> None:
|
||||
"""Arm the shared compression-failure cooldown after a failed codex compaction.
|
||||
The codex path returns the transcript unchanged, so without a cooldown the still-over-threshold session
|
||||
would retry every turn."""
|
||||
from agent.context_compressor import _SUMMARY_FAILURE_COOLDOWN_SECONDS
|
||||
compressor = getattr(agent, "context_compressor", None)
|
||||
recorder = getattr(compressor, "_record_compression_failure_cooldown", None)
|
||||
if not callable(recorder):
|
||||
return
|
||||
with _swallow('codex compaction cooldown persist failed', exc_info=True):
|
||||
recorder(_SUMMARY_FAILURE_COOLDOWN_SECONDS, error)
|
||||
|
||||
|
||||
def _compress_context_via_codex_app_server(
|
||||
agent: Any, messages: list, system_message: Optional[str], *, approx_tokens: Optional[int] = None,
|
||||
task_id: str = "default", force: bool = False,
|
||||
) -> tuple[list, str]:
|
||||
"""Route compaction to Codex app-server for Codex-owned threads.
|
||||
Rewriting the local transcript would not shrink the Codex thread, so Codex compacts its own thread and
|
||||
Hermes' transcript is left unchanged."""
|
||||
_sid = getattr(agent, "session_id", None) or "none"
|
||||
_tokens = f"{approx_tokens:,}" if approx_tokens else "unknown"
|
||||
auto_mode = str(getattr(agent, "codex_app_server_auto_compaction", "native") or "native").lower()
|
||||
if auto_mode not in {"native", "hermes", "off"}:
|
||||
auto_mode = "native"
|
||||
skip_reason = None
|
||||
if not force and auto_mode != "hermes":
|
||||
skip_reason = f"mode={auto_mode} force=false"
|
||||
elif not force:
|
||||
# Automatic entrypoints honor the compressor-owned cooldown: a recent compaction
|
||||
# failed, and retrying every turn is what thrashes.
|
||||
_cooldown_remaining = _codex_compaction_cooldown_remaining(agent)
|
||||
if _cooldown_remaining > 0:
|
||||
skip_reason = f"failure cooldown active for {_cooldown_remaining:.0f}s"
|
||||
codex_session = getattr(agent, "_codex_session", None)
|
||||
if skip_reason is None and codex_session is None:
|
||||
skip_reason = "no active codex thread"
|
||||
if skip_reason is not None:
|
||||
logger.info(
|
||||
"codex app-server compaction skipped: %s (session=%s messages=%d tokens=~%s)", skip_reason, _sid,
|
||||
len(messages), _tokens,
|
||||
)
|
||||
return messages, _existing_system_prompt(agent, system_message)
|
||||
logger.info("codex app-server compaction started: session=%s messages=%d tokens=~%s", _sid, len(messages), _tokens)
|
||||
with contextlib.suppress(Exception):
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
_activity_heartbeat = _CompressionActivityHeartbeat(agent, emit_client_status=True).start()
|
||||
try:
|
||||
result = codex_session.compact_thread()
|
||||
except BaseException:
|
||||
_activity_heartbeat.stop("context compression failed")
|
||||
raise
|
||||
failed = bool(getattr(result, "interrupted", False) or getattr(result, "error", None))
|
||||
_activity_heartbeat.stop("context compression failed" if failed else "context compression completed")
|
||||
if getattr(result, "should_retire", False):
|
||||
with contextlib.suppress(Exception):
|
||||
codex_session.close()
|
||||
agent._codex_session = None
|
||||
if failed:
|
||||
with contextlib.suppress(Exception):
|
||||
agent._emit_warning(f"⚠ Codex app-server compaction failed: {result.error}")
|
||||
# The transcript is returned unchanged, so the session is still over
|
||||
# threshold. Without a brake the next turn retries immediately.
|
||||
_record_codex_compaction_failure(agent, str(getattr(result, "error", None) or "compaction interrupted"))
|
||||
return messages, _existing_system_prompt(agent, system_message)
|
||||
with _swallow('codex compaction bookkeeping failed', exc_info=True):
|
||||
from agent.codex_runtime import _record_codex_app_server_compaction, _record_codex_app_server_usage
|
||||
_record_codex_app_server_compaction(agent, result, approx_tokens=approx_tokens, force=True)
|
||||
# An empty usage report must consume the pending verdict, not leave deferral
|
||||
# armed until a later turn; minimal test engines may lack update_from_response.
|
||||
if hasattr(agent.context_compressor, "update_from_response"):
|
||||
_record_codex_app_server_usage(agent, result, messages=messages)
|
||||
_reset_read_dedup_caches(task_id, session_id=agent.session_id or "")
|
||||
logger.info(
|
||||
"codex app-server compaction done: session=%s thread=%s turn=%s", _sid,
|
||||
getattr(result, "thread_id", None) or "", getattr(result, "turn_id", None) or "",
|
||||
)
|
||||
existing_prompt = _existing_system_prompt(agent, system_message)
|
||||
# Terminal edge only on success — failure/interrupt paths above return
|
||||
# without it, matching the main compress_context() gating.
|
||||
_emit_compaction_done(agent)
|
||||
return messages, existing_prompt
|
||||
|
||||
|
||||
# 4 MB leaves headroom under Anthropic's 5 MB; shrinking loses quality but only
|
||||
# runs after a confirmed provider rejection, so the alternative is failure.
|
||||
_IMAGE_SHRINK_TARGET_BYTES = 4 * 1024 * 1024
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""Codex app-server compaction cooldown lookup.
|
||||
"""Codex app-server compaction: the route ``compress_context`` takes for Codex-owned threads.
|
||||
|
||||
Split from ``agent/conversation_compression.py`` (facade size cap), which imports it at module level;
|
||||
this module must never import the facade (import cycle). It logs under the facade's logger name so log
|
||||
consumers keep one source.
|
||||
this module must never import the facade at module level (import cycle), so it late-imports the
|
||||
facade's shared helpers inside functions. It logs under the facade's logger name so log consumers keep
|
||||
one source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.conversation_compression_telemetry import _emit_bypassed_attempt_telemetry
|
||||
|
||||
logger = logging.getLogger("agent.conversation_compression")
|
||||
|
||||
@@ -28,3 +32,105 @@ def _codex_compaction_cooldown_remaining(agent: Any) -> float:
|
||||
return max(0.0, float(state.get("remaining_seconds") or 0.0)) if state else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _record_codex_compaction_failure(agent: Any, error: str) -> None:
|
||||
"""Arm the shared compression-failure cooldown after a failed codex compaction.
|
||||
The codex path returns the transcript unchanged, so without a cooldown the still-over-threshold session
|
||||
would retry every turn."""
|
||||
from agent.context_compressor import _SUMMARY_FAILURE_COOLDOWN_SECONDS
|
||||
from agent.conversation_compression import _swallow
|
||||
compressor = getattr(agent, "context_compressor", None)
|
||||
recorder = getattr(compressor, "_record_compression_failure_cooldown", None)
|
||||
if not callable(recorder):
|
||||
return
|
||||
with _swallow('codex compaction cooldown persist failed', exc_info=True):
|
||||
recorder(_SUMMARY_FAILURE_COOLDOWN_SECONDS, error)
|
||||
|
||||
|
||||
def _compress_context_via_codex_app_server(
|
||||
agent: Any, messages: list, system_message: Optional[str], *, approx_tokens: Optional[int] = None,
|
||||
task_id: str = "default", force: bool = False, started_at: float, attempt_seed: dict[str, Any],
|
||||
) -> tuple[list, str]:
|
||||
"""Route compaction to Codex app-server for Codex-owned threads.
|
||||
Rewriting the local transcript would not shrink the Codex thread, so Codex compacts its own thread and
|
||||
Hermes' transcript is left unchanged. Every exit, raising ones included, logs one attempt record."""
|
||||
from agent.conversation_compression import (
|
||||
COMPACTION_STATUS, _CompressionActivityHeartbeat, _emit_compaction_done, _existing_system_prompt,
|
||||
_reset_read_dedup_caches, _swallow,
|
||||
)
|
||||
|
||||
def _record(commit_status: str, failure_class: Optional[str], method: str = "none") -> None:
|
||||
_emit_bypassed_attempt_telemetry(
|
||||
agent, started_at, commit_status=commit_status, failure_class=failure_class, approx_tokens=approx_tokens,
|
||||
route="codex_app_server", method=method, attempt_seed=attempt_seed,
|
||||
)
|
||||
|
||||
_sid = getattr(agent, "session_id", None) or "none"
|
||||
_tokens = f"{approx_tokens:,}" if approx_tokens else "unknown"
|
||||
auto_mode = str(getattr(agent, "codex_app_server_auto_compaction", "native") or "native").lower()
|
||||
if auto_mode not in {"native", "hermes", "off"}:
|
||||
auto_mode = "native"
|
||||
skip_reason = skip_status = skip_class = None
|
||||
if not force and auto_mode != "hermes":
|
||||
skip_reason, skip_status, skip_class = f"mode={auto_mode} force=false", "skipped", f"codex_auto_{auto_mode}"
|
||||
elif not force:
|
||||
# Automatic entrypoints honor the compressor-owned cooldown: a recent compaction
|
||||
# failed, and retrying every turn is what thrashes.
|
||||
_cooldown_remaining = _codex_compaction_cooldown_remaining(agent)
|
||||
if _cooldown_remaining > 0:
|
||||
skip_reason = f"failure cooldown active for {_cooldown_remaining:.0f}s"
|
||||
skip_status, skip_class = "blocked", "blocked:cooldown"
|
||||
codex_session = getattr(agent, "_codex_session", None)
|
||||
if skip_reason is None and codex_session is None:
|
||||
skip_reason, skip_status, skip_class = "no active codex thread", "skipped", "codex_no_thread"
|
||||
if skip_reason is not None:
|
||||
logger.info(
|
||||
"codex app-server compaction skipped: %s (session=%s messages=%d tokens=~%s)", skip_reason, _sid,
|
||||
len(messages), _tokens,
|
||||
)
|
||||
_record(skip_status, skip_class)
|
||||
return messages, _existing_system_prompt(agent, system_message)
|
||||
logger.info("codex app-server compaction started: session=%s messages=%d tokens=~%s", _sid, len(messages), _tokens)
|
||||
with contextlib.suppress(Exception):
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
_activity_heartbeat = _CompressionActivityHeartbeat(agent, emit_client_status=True).start()
|
||||
try:
|
||||
result = codex_session.compact_thread()
|
||||
except BaseException as exc:
|
||||
_activity_heartbeat.stop("context compression failed")
|
||||
# Same class shape as the Hermes route's raising exits; the caller still gets the original error.
|
||||
_record("aborted", f"exception:{type(exc).__name__}")
|
||||
raise
|
||||
failed = bool(getattr(result, "interrupted", False) or getattr(result, "error", None))
|
||||
_activity_heartbeat.stop("context compression failed" if failed else "context compression completed")
|
||||
if getattr(result, "should_retire", False):
|
||||
with contextlib.suppress(Exception):
|
||||
codex_session.close()
|
||||
agent._codex_session = None
|
||||
if failed:
|
||||
with contextlib.suppress(Exception):
|
||||
agent._emit_warning(f"⚠ Codex app-server compaction failed: {result.error}")
|
||||
# The transcript is returned unchanged, so the session is still over
|
||||
# threshold. Without a brake the next turn retries immediately.
|
||||
_record_codex_compaction_failure(agent, str(getattr(result, "error", None) or "compaction interrupted"))
|
||||
_record("failed", "codex_compaction_failed")
|
||||
return messages, _existing_system_prompt(agent, system_message)
|
||||
with _swallow('codex compaction bookkeeping failed', exc_info=True):
|
||||
from agent.codex_runtime import _record_codex_app_server_compaction, _record_codex_app_server_usage
|
||||
_record_codex_app_server_compaction(agent, result, approx_tokens=approx_tokens, force=True)
|
||||
# An empty usage report must consume the pending verdict, not leave deferral
|
||||
# armed until a later turn; minimal test engines may lack update_from_response.
|
||||
if hasattr(agent.context_compressor, "update_from_response"):
|
||||
_record_codex_app_server_usage(agent, result, messages=messages)
|
||||
_reset_read_dedup_caches(task_id, session_id=agent.session_id or "")
|
||||
logger.info(
|
||||
"codex app-server compaction done: session=%s thread=%s turn=%s", _sid,
|
||||
getattr(result, "thread_id", None) or "", getattr(result, "turn_id", None) or "",
|
||||
)
|
||||
existing_prompt = _existing_system_prompt(agent, system_message)
|
||||
_record("committed", None, method="provider")
|
||||
# Terminal edge only on success — failure/interrupt paths above return
|
||||
# without it, matching the main compress_context() gating.
|
||||
_emit_compaction_done(agent)
|
||||
return messages, existing_prompt
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Content-free per-attempt compression telemetry (attempt log line + shared metric).
|
||||
"""Content-free per-attempt compression telemetry (attempt log line, Relay mark, shared metric).
|
||||
|
||||
Sibling of ``agent/conversation_compression.py`` (the facade), which imports it at module level; this
|
||||
module must never import the facade (import cycle). It logs under the facade's logger name so log
|
||||
@@ -7,12 +7,15 @@ consumers keep one source.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from agent.compaction_events import publish_attempt
|
||||
|
||||
logger = logging.getLogger("agent.conversation_compression")
|
||||
|
||||
|
||||
@@ -26,40 +29,111 @@ logger = logging.getLogger("agent.conversation_compression")
|
||||
_GENERIC_ABORT_VERDICTS = frozenset({"no_progress", "summary_generation_aborted"})
|
||||
|
||||
|
||||
def _snapshot_compression_effect_input(messages: list, verbatim_tail: list | None) -> list | None:
|
||||
"""Content-only snapshot for final effect accounting; telemetry must never break compression."""
|
||||
try:
|
||||
if not verbatim_tail:
|
||||
return messages
|
||||
from hermes_cli.partial_compress import rejoin_compressed_head_and_tail
|
||||
|
||||
return rejoin_compressed_head_and_tail(messages, copy.deepcopy(verbatim_tail))
|
||||
except Exception:
|
||||
logger.debug("failed to snapshot compression effect input", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _record_committed_attempt_effect(
|
||||
agent: Any, messages_before: list | None, messages_after: list, verbatim_tail: list | None, split_status: str,
|
||||
) -> None:
|
||||
"""Finalize effect fields from the transcript shape that crossed the commit boundary."""
|
||||
if messages_before is None:
|
||||
return
|
||||
try:
|
||||
if verbatim_tail and split_status == "not_applicable":
|
||||
from hermes_cli.partial_compress import rejoin_compressed_head_and_tail
|
||||
|
||||
# Without a SessionDB, the manual-compression caller performs this rejoin after compress_context returns.
|
||||
messages_after = rejoin_compressed_head_and_tail(messages_after, verbatim_tail)
|
||||
recorder = getattr(agent.context_compressor, "_record_committed_compression_effect", None)
|
||||
if callable(recorder):
|
||||
recorder(messages_before, messages_after)
|
||||
except Exception:
|
||||
logger.debug("failed to record committed compression effect", exc_info=True)
|
||||
|
||||
|
||||
def _attempt_seed(
|
||||
agent: Any, *, attempt_began: bool = True, attempt_seed: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""This attempt's id, the session it started in, and its trigger.
|
||||
|
||||
``attempt_seed`` is the attempt's own copy and wins: the agent's copy belongs to the newest attempt, so a
|
||||
stalled attempt unwinding after its fallback began would otherwise log the fallback's identity. Without
|
||||
it, read the agent's copy (a pre-commit restore puts the previous attempt's seed back on the compressor).
|
||||
An emit with no attempt begun (pool saturation) gets a fresh id and an unknown trigger."""
|
||||
if attempt_seed:
|
||||
return dict(attempt_seed)
|
||||
attempt_id = getattr(agent, "_compression_attempt_id", None) if attempt_began else None
|
||||
seed = getattr(agent, "_compression_attempt_seed", None)
|
||||
if attempt_id and isinstance(seed, dict) and seed.get("attempt_id") == attempt_id:
|
||||
return dict(seed)
|
||||
return {
|
||||
"attempt_id": attempt_id or uuid.uuid4().hex, "session_id": getattr(agent, "session_id", "") or "",
|
||||
"trigger_source": "unknown",
|
||||
}
|
||||
|
||||
|
||||
def _emit_compression_attempt_telemetry(
|
||||
agent: Any, *, started_at: float, commit_status: str, split_status: str, failure_class: str | None = None,
|
||||
commit_started_at: float | None = None,
|
||||
commit_started_at: float | None = None, include_last_telemetry: bool = True,
|
||||
attempt_seed: dict[str, Any] | None = None, history_rewritten: bool | None = None,
|
||||
) -> None:
|
||||
"""Emit one content-free JSON log line for a compression attempt."""
|
||||
"""Emit one content-free JSON log line for a compression attempt.
|
||||
|
||||
``include_last_telemetry=False`` is for emits that fire without an attempt having begun
|
||||
(pool-saturation refusals): they must not hydrate from the previous attempt's numbers.
|
||||
``attempt_seed`` is the emitting attempt's own seed (see ``_attempt_seed``).
|
||||
``history_rewritten`` (commit path only) says whether the returned transcript replaced the input, which
|
||||
a split that failed after publishing the compacted history still did."""
|
||||
try:
|
||||
compressor = agent.context_compressor
|
||||
telemetry = getattr(compressor, "_last_compression_telemetry", None)
|
||||
if not isinstance(telemetry, dict):
|
||||
telemetry = {}
|
||||
telemetry = getattr(compressor, "_last_compression_telemetry", None) if include_last_telemetry else None
|
||||
seed = _attempt_seed(agent, attempt_began=include_last_telemetry, attempt_seed=attempt_seed)
|
||||
own = isinstance(telemetry, dict) and telemetry.get("attempt_id") == seed["attempt_id"]
|
||||
if not own:
|
||||
# The attempt-start clear leaves no dict before compress() seeds one, a pre-commit restore puts
|
||||
# the previous attempt's back, and a newer attempt may have seeded its own: describe THIS attempt
|
||||
# from its seed, never another's numbers.
|
||||
telemetry = {**seed, "method": "none"}
|
||||
payload = dict(telemetry)
|
||||
payload.setdefault("event", "compression_attempt")
|
||||
payload.setdefault("attempt_id", getattr(agent, "_compression_attempt_id", "") or uuid.uuid4().hex)
|
||||
payload.setdefault("session_id", getattr(agent, "session_id", "") or "")
|
||||
payload.setdefault("route", "hermes")
|
||||
payload.setdefault("attempt_id", seed["attempt_id"])
|
||||
payload.setdefault("session_id", seed["session_id"])
|
||||
payload.update(
|
||||
total_duration_ms=int((time.monotonic() - started_at) * 1000), commit_status=commit_status,
|
||||
split_status=split_status,
|
||||
)
|
||||
if history_rewritten is not None:
|
||||
payload["history_rewritten"] = history_rewritten
|
||||
if commit_started_at is not None:
|
||||
telemetry["commit_ms"] = payload["commit_ms"] = max(0, int((time.monotonic() - commit_started_at) * 1000))
|
||||
# Defer only to THIS attempt's class: an abort restore can put the previous attempt's telemetry back.
|
||||
_own_class = telemetry.get("attempt_id") == getattr(agent, "_compression_attempt_id", None)
|
||||
if failure_class and not (failure_class in _GENERIC_ABORT_VERDICTS and _own_class and payload.get("failure_class")):
|
||||
if failure_class and not (failure_class in _GENERIC_ABORT_VERDICTS and own and payload.get("failure_class")):
|
||||
payload["failure_class"] = failure_class
|
||||
payload.setdefault("chunking", False)
|
||||
payload.setdefault("chunk_count", 0)
|
||||
# The compressor's fallback flags are this attempt's only when its telemetry is.
|
||||
payload["fallback_used"] = bool(
|
||||
payload.get("fallback_used")
|
||||
or getattr(compressor, "_last_summary_fallback_used", False)
|
||||
or getattr(compressor, "_last_aux_model_failure_model", None)
|
||||
or own and (
|
||||
getattr(compressor, "_last_summary_fallback_used", False)
|
||||
or getattr(compressor, "_last_aux_model_failure_model", None)
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"context compression attempt telemetry: %s", json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
)
|
||||
publish_attempt(agent, payload)
|
||||
from hermes_cli.observability.shared_metrics_events import finish_compression_attempt
|
||||
|
||||
finish_compression_attempt(
|
||||
@@ -69,7 +143,67 @@ def _emit_compression_attempt_telemetry(
|
||||
logger.debug("failed to emit compression attempt telemetry: %s", exc, exc_info=True)
|
||||
|
||||
|
||||
def _emit_aborted_attempt_telemetry(agent: Any, started_at: float, failure_class: str | None) -> None:
|
||||
def _emit_aborted_attempt_telemetry(
|
||||
agent: Any, started_at: float, failure_class: str | None, attempt_seed: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent, started_at=started_at, commit_status="aborted", split_status="aborted", failure_class=failure_class
|
||||
agent, started_at=started_at, commit_status="aborted", split_status="aborted", failure_class=failure_class,
|
||||
attempt_seed=attempt_seed,
|
||||
)
|
||||
|
||||
|
||||
def _emit_bypassed_attempt_telemetry(
|
||||
agent: Any, started_at: float, *, commit_status: str, failure_class: str | None, approx_tokens: Any,
|
||||
route: str = "hermes", method: str = "none", attempt_seed: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Log one attempt that never reached the local compressor: an automatic gate blocked it, Codex owns the
|
||||
thread, or the session lease showed another path already owns the work. The compressor's telemetry still
|
||||
describes an earlier attempt, so this record starts from the attempt seed. Shared metrics have never
|
||||
counted these exits and still do not."""
|
||||
try:
|
||||
compressor = getattr(agent, "context_compressor", None)
|
||||
payload = {
|
||||
"event": "compression_attempt", "route": route, "method": method, "failure_class": failure_class,
|
||||
**_attempt_seed(agent, attempt_seed=attempt_seed),
|
||||
"main_provider": getattr(agent, "provider", "") or "", "main_model": getattr(agent, "model", "") or "",
|
||||
# Private caches only: the public properties can trigger a synchronous context-length probe.
|
||||
"main_context_limit": getattr(compressor, "_resolved_context_length", None),
|
||||
"effective_threshold": getattr(compressor, "_threshold_tokens", None),
|
||||
"current_estimated_tokens": approx_tokens if isinstance(approx_tokens, int) else None,
|
||||
"total_duration_ms": int((time.monotonic() - started_at) * 1000), "commit_status": commit_status,
|
||||
"split_status": "not_applicable", "fallback_used": False,
|
||||
}
|
||||
logger.info(
|
||||
"context compression attempt telemetry: %s", json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
)
|
||||
publish_attempt(agent, payload)
|
||||
except Exception as exc:
|
||||
logger.debug("failed to emit compression attempt telemetry: %s", exc, exc_info=True)
|
||||
finally:
|
||||
# compress_context opened the thread-local shared-metric lifecycle before reaching this exit.
|
||||
# Bypassed attempts are log-only, so retire that lifecycle explicitly; otherwise an unrelated
|
||||
# pool-saturation emit on the same thread can consume its trigger and token count.
|
||||
try:
|
||||
from hermes_cli.observability.shared_metrics_events import discard_compression_attempt
|
||||
|
||||
discard_compression_attempt()
|
||||
except Exception:
|
||||
logger.debug("failed to discard bypassed compression metric", exc_info=True)
|
||||
|
||||
|
||||
def _emit_blocked_attempt_telemetry(
|
||||
agent: Any, started_at: float, approx_tokens: Any, attempt_seed: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Record an automatic attempt the breaker gate refused. The class keeps only the guard's name
|
||||
(``blocked:cooldown``, ``blocked:structural_backoff``, ``blocked:ineffective``), never its seconds."""
|
||||
reason = None
|
||||
try:
|
||||
reason_fn = getattr(getattr(agent, "context_compressor", None), "_compression_block_reason", None)
|
||||
reason = reason_fn() if callable(reason_fn) else None
|
||||
except Exception:
|
||||
logger.debug("compression block-reason read failed", exc_info=True)
|
||||
guard = reason.split(":", 1)[0] if isinstance(reason, str) and reason else "unknown"
|
||||
_emit_bypassed_attempt_telemetry(
|
||||
agent, started_at, commit_status="blocked", failure_class=f"blocked:{guard}", approx_tokens=approx_tokens,
|
||||
attempt_seed=attempt_seed,
|
||||
)
|
||||
@@ -625,7 +625,7 @@ def _set_provider_tool_id(tc: Any, key: str, value: str) -> None:
|
||||
|
||||
|
||||
# -- reasoning_content policy: single owner of strip-vs-re-pad; adapters keep only SYNTAX --
|
||||
# Require side (echo-back enforced; replays 400 without the field): the families below. Kimi
|
||||
# Require side (echo-back enforced or consumed by the template): the families below. Kimi
|
||||
# is host-driven on purpose (aggregators re-exporting kimi reject it); DeepSeek V4 rejects
|
||||
# empty-string pads → " ". Strict side (400/422 "Extra inputs are not permitted"): everyone
|
||||
# else — Mistral, Cerebras, Groq, SambaNova, … Strip the key entirely, even a one-space pad.
|
||||
@@ -636,11 +636,13 @@ def _set_provider_tool_id(tc: Any, key: str, value: str) -> None:
|
||||
# (2b3a4f0af8 strip for strict providers, b5495db701 re-pad for require-side, 94b3131be7/9a9f8a6d99 kimi
|
||||
# pad). The POLICY — which provider direction gets which treatment — lives here as one rule table + apply
|
||||
# functions; adapters keep only SYNTAX mapping (e.g. anthropic_adapter turning reasoning_content into a
|
||||
# thinking block). Direction table: require-side (echo-back enforced; replays 400 without the field): kimi
|
||||
# thinking block). Direction table: require-side (echo-back enforced, or consumed by the template): kimi
|
||||
# — provider kimi-coding/kimi-coding-cn, or host api.kimi.com / moonshot.ai / moonshot.cn. Host-driven on
|
||||
# purpose: aggregators re-exporting kimi models reject the echo. deepseek — provider "deepseek", model
|
||||
# contains "deepseek", or host api.deepseek.com (#15250; V4 rejects empty-string pads, hence the " "
|
||||
# single-space pad, #17341). mimo — provider "xiaomi", model contains "mimo", or host *.xiaomimimo.com.
|
||||
# ollama — provider "ollama-cloud", host ollama.com, or a local ":cloud"/"-cloud" tag (Ollama's /v1 feeds the
|
||||
# field back into the template's thinking slot; stripping it drops the model's own CoT mid tool loop).
|
||||
# strict side (field rejected with 400/422 "Extra inputs are not permitted"): everyone else — Mistral,
|
||||
# Cerebras, Groq, SambaNova, … (#45655). Strip the key entirely, even a single-space pad.
|
||||
_REASONING_ECHO_RULES: tuple = (
|
||||
@@ -648,6 +650,7 @@ _REASONING_ECHO_RULES: tuple = (
|
||||
("kimi", frozenset({"kimi-coding", "kimi-coding-cn"}), frozenset(), (), ("api.kimi.com", "moonshot.ai", "moonshot.cn")),
|
||||
("deepseek", frozenset(), frozenset({"deepseek"}), ("deepseek",), ("api.deepseek.com",)),
|
||||
("mimo", frozenset(), frozenset({"xiaomi"}), ("mimo",), ("api.xiaomimimo.com", "xiaomimimo.com")),
|
||||
("ollama", frozenset(), frozenset({"ollama-cloud"}), (":cloud",), ("ollama.com",)),
|
||||
)
|
||||
_REASONING_ECHO_RULE_BY_FAMILY = {rule[0]: rule for rule in _REASONING_ECHO_RULES}
|
||||
|
||||
@@ -659,6 +662,8 @@ def matches_reasoning_echo_family(family: str, provider: Any, model: Any, base_u
|
||||
|
||||
_, raw_providers, lowered_providers, model_subs, hosts = _REASONING_ECHO_RULE_BY_FAMILY[family]
|
||||
model_lower = (model or "").lower()
|
||||
if ":" in model_lower and model_lower.endswith("-cloud"):
|
||||
model_lower += ":cloud" # Ollama's "<size>-cloud" tag (gpt-oss:120b-cloud) == ":cloud"
|
||||
return (
|
||||
provider in raw_providers or (provider or "").lower() in lowered_providers
|
||||
or any(sub in model_lower for sub in model_subs) or any(base_url_host_matches(base_url, host) for host in hosts)
|
||||
@@ -666,7 +671,7 @@ def matches_reasoning_echo_family(family: str, provider: Any, model: Any, base_u
|
||||
|
||||
|
||||
def reasoning_echo_family(provider: Any, model: Any, base_url: Any) -> str | None:
|
||||
"""``"kimi"`` / ``"deepseek"`` / ``"mimo"`` (first match in table order) when the
|
||||
"""``"kimi"`` / ``"deepseek"`` / ``"mimo"`` / ``"ollama"`` (first match in table order) when the
|
||||
endpoint enforces reasoning_content echo-back, else ``None`` (strip side)."""
|
||||
families = (rule[0] for rule in _REASONING_ECHO_RULES)
|
||||
return next((f for f in families if matches_reasoning_echo_family(f, provider, model, base_url)), None)
|
||||
|
||||
@@ -11,6 +11,7 @@ import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.compaction_events import publish_micro
|
||||
from agent.message_metadata import record_absorbed_message
|
||||
from agent.model_metadata import estimate_messages_tokens_rough, estimate_tokens_rough
|
||||
|
||||
@@ -200,10 +201,13 @@ class MicroCompactionMixin:
|
||||
self._micro_compact_consecutive_failures = 0
|
||||
self._micro_compact_last_failure_cursor = -1
|
||||
|
||||
def _micro_compact(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def _micro_compact(
|
||||
self, messages: list[dict[str, Any]], *, turn_session_id: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Run one round of micro-compaction (entry point from ``finalize_turn()``). Returns the
|
||||
(possibly modified) list and syncs the session DB via ``archive_and_compact`` (the
|
||||
append-only flush alone would double-load on resume)."""
|
||||
append-only flush alone would double-load on resume). ``turn_session_id`` is the session the
|
||||
caller's turn started in; it parents the pass's Relay mark when this session has no scope yet."""
|
||||
if not self._micro_compact_enabled:
|
||||
return messages
|
||||
|
||||
@@ -234,7 +238,8 @@ class MicroCompactionMixin:
|
||||
def _telemetry(outcome: str, result: list[dict[str, Any]], **extra: Any) -> None:
|
||||
self._emit_micro_compaction_telemetry(
|
||||
outcome=outcome, messages_before=n_messages, messages_after=len(result),
|
||||
tokens_before=_tokens_before, duration_ms=int((time.monotonic() - _started_at) * 1000), **extra,
|
||||
tokens_before=_tokens_before, duration_ms=int((time.monotonic() - _started_at) * 1000),
|
||||
turn_session_id=turn_session_id, **extra,
|
||||
)
|
||||
|
||||
if _skip: # unanswerable watermark (unbounded archive) or a stale generation (no lease): skip
|
||||
@@ -340,6 +345,7 @@ class MicroCompactionMixin:
|
||||
def _emit_micro_compaction_telemetry(
|
||||
self, *, outcome: str, messages_before: int, messages_after: int, tokens_before: int | None,
|
||||
tokens_after: int | None, exchange_tokens: int | None = None, duration_ms: int | None = None,
|
||||
turn_session_id: str | None = None,
|
||||
) -> None:
|
||||
"""Emit one content-free JSON log line for a micro-compaction pass.
|
||||
``tokens_delta`` < 0 means the pass shrank the transcript; ``*_total`` fields accumulate."""
|
||||
@@ -368,6 +374,7 @@ class MicroCompactionMixin:
|
||||
"occupancy_pct": occupancy, "main_model": self.model or "", "aux_model": self.summary_model or "",
|
||||
}
|
||||
logger.info("micro compaction telemetry: %s", json.dumps(payload, sort_keys=True, separators=(",", ":")))
|
||||
publish_micro(payload, turn_session_id=turn_session_id)
|
||||
except Exception as exc:
|
||||
logger.debug("failed to emit micro-compaction telemetry: %s", exc)
|
||||
|
||||
|
||||
+10
-4
@@ -1244,14 +1244,14 @@ def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
|
||||
return manifest
|
||||
|
||||
|
||||
def _load_skills_snapshot(skills_dir: Path) -> Optional[dict]:
|
||||
def _load_skills_snapshot(skills_dir: Path, manifest: Optional[dict] = None) -> Optional[dict]:
|
||||
"""The disk snapshot if it exists, is current-version, and its manifest still matches."""
|
||||
try:
|
||||
snapshot = json.loads(_skills_prompt_snapshot_path().read_text(encoding="utf-8-sig"))
|
||||
except Exception: # missing, unreadable or corrupt -> rebuild
|
||||
return None
|
||||
if (isinstance(snapshot, dict) and snapshot.get("version") == _SKILLS_SNAPSHOT_VERSION
|
||||
and snapshot.get("manifest") == _build_skills_manifest(skills_dir)):
|
||||
and snapshot.get("manifest") == (manifest if manifest is not None else _build_skills_manifest(skills_dir))):
|
||||
return snapshot
|
||||
return None
|
||||
|
||||
@@ -1514,14 +1514,20 @@ def _build_skills_system_prompt_inner(
|
||||
# Plugin-registered skills (ctx.register_skill) are registry state, not files under any scanned
|
||||
# root — the snapshot manifest can't see them change, so they participate in the cache key.
|
||||
plugin_rows = _plugin_skill_prompt_rows(disabled, available_tools, available_toolsets, _platform_hint or None)
|
||||
# Skill files are part of the key: another process (a hub install in a second terminal, the curator
|
||||
# inside the gateway, a git pull of an external dir) never clears this process's LRU, so a key of
|
||||
# config alone served the pre-change index to every running session until restart (#92313).
|
||||
manifest = _build_skills_manifest(skills_dir)
|
||||
files = frozenset((rel, *sig) for rel, sig in manifest.items())
|
||||
files |= {(str(d), rel, *sig) for _, d in extra_roots for rel, sig in _build_skills_manifest(d).items()}
|
||||
cache_key = (
|
||||
str(skills_dir), tuple((t, str(d)) for t, d in extra_roots),
|
||||
str(skills_dir), tuple((t, str(d)) for t, d in extra_roots), files,
|
||||
tuple(sorted(str(t) for t in (available_tools or set()))),
|
||||
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
|
||||
_platform_hint, tuple(sorted(disabled)), tuple(sorted(compact_categories or ())),
|
||||
_oneshot_prompt_variant(), tuple(plugin_rows),
|
||||
)
|
||||
snapshot = _load_skills_snapshot(skills_dir)
|
||||
snapshot = _load_skills_snapshot(skills_dir, manifest)
|
||||
app_gated = snapshot is not None and any(
|
||||
entry.get("requires_apps") for entry in snapshot.get("skills", []) if isinstance(entry, dict)
|
||||
)
|
||||
|
||||
@@ -156,7 +156,7 @@ class ReasoningParamsMixin:
|
||||
|
||||
def _needs_thinking_reasoning_pad(self) -> bool:
|
||||
"""True when the provider enforces ``reasoning_content`` echo-back on tool-call replays (DeepSeek, Kimi,
|
||||
MiMo thinking all 400 without it). Cached per (provider, model, base_url), invalidated by
|
||||
MiMo thinking all 400 without it; Ollama Cloud feeds it to the template). Cached per (provider, model, base_url), invalidated by
|
||||
``switch_model()`` / ``_try_activate_fallback()`` — called ~16× per turn.
|
||||
|
||||
DeepSeek v4 thinking and Kimi / Moonshot thinking both reject replays of assistant tool-call
|
||||
@@ -168,7 +168,8 @@ class ReasoningParamsMixin:
|
||||
if cached is not None and cached[0] == key:
|
||||
return cached[1]
|
||||
result = (self._needs_deepseek_tool_reasoning() or self._needs_kimi_tool_reasoning()
|
||||
or self._needs_mimo_tool_reasoning() or self._reasoning_echo_opt_in())
|
||||
or self._needs_mimo_tool_reasoning() or self._needs_ollama_tool_reasoning()
|
||||
or self._reasoning_echo_opt_in())
|
||||
self._thinking_pad_cache = (key, result)
|
||||
return result
|
||||
|
||||
@@ -205,6 +206,10 @@ class ReasoningParamsMixin:
|
||||
"""True when the current provider is Xiaomi MiMo thinking mode."""
|
||||
return matches_reasoning_echo_family("mimo", (self.provider or "").lower(), self.model, self.base_url)
|
||||
|
||||
def _needs_ollama_tool_reasoning(self) -> bool:
|
||||
"""True for Ollama Cloud (provider, host, or a local ``:cloud``/``-cloud`` tag)."""
|
||||
return matches_reasoning_echo_family("ollama", (self.provider or "").lower(), self.model, self.base_url)
|
||||
|
||||
_copy_reasoning_content_for_api = _forward("agent.agent_runtime_helpers", "copy_reasoning_content_for_api")
|
||||
|
||||
_reapply_reasoning_echo_for_provider = _forward("agent.agent_runtime_helpers", "reapply_reasoning_echo_for_provider")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Emit ``hermes.compaction`` records as NeMo Relay marks on the owning session's scope stack.
|
||||
|
||||
The mark goes under the live turn of the same session when there is one, else under that session's
|
||||
scope (gateway hygiene and other out-of-turn compactions), else under the live turn the compacting agent
|
||||
started in (a child session a rotating commit created mid-turn), and never from a turn Relay does not
|
||||
instrument. The session's own stack matters: Relay resets LLM-history freshness only for the agent scope
|
||||
that owns a ``compaction`` mark, so the next LLM start after a committed rewrite records the full history
|
||||
again. No live Relay host or session scope means
|
||||
there is nothing to record. Relay exporters are configured by the user, so the mark adds no outbound
|
||||
path of its own.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent import relay_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COMPACTION_DATA_SCHEMA = {"name": "hermes.compaction", "version": "1"}
|
||||
# Marks are observability: after one stalled publish, stop waiting on Relay for the rest of the process
|
||||
# so a wedged pipeline costs one bounded timeout, not one per compaction under the compression lease.
|
||||
_stalled = False
|
||||
|
||||
|
||||
def _turn_target(session_id: str) -> tuple[relay_runtime.RelayRuntime, relay_runtime.RelaySession, Any] | None:
|
||||
turn = relay_runtime.active_turn(session_id)
|
||||
host = turn.lease.live_runtime() if turn is not None else None
|
||||
if host is None:
|
||||
return None
|
||||
session = turn.lease.session
|
||||
return host, session, turn.handle or session.handle
|
||||
|
||||
|
||||
def _session_target(session_id: str) -> tuple[relay_runtime.RelayRuntime, relay_runtime.RelaySession, Any] | None:
|
||||
host = relay_runtime.HOST_REGISTRY.for_profile(create=False)
|
||||
if not isinstance(host, relay_runtime.RelayRuntime):
|
||||
return None
|
||||
session = host.get_session(session_id)
|
||||
if session is None or session.handle is None:
|
||||
return None
|
||||
return host, session, session.handle
|
||||
|
||||
|
||||
def _resolve_target(
|
||||
session_id: str, turn_session_id: str | None = None,
|
||||
) -> tuple[relay_runtime.RelayRuntime, relay_runtime.RelaySession, Any] | None:
|
||||
"""``(host, session, parent handle)`` for ``session_id``: its live turn, then its session scope, then the
|
||||
live turn of ``turn_session_id``.
|
||||
|
||||
``turn_session_id`` is the session the compacting agent's turn started in. A rotating commit moves the
|
||||
agent to a child session that has no Relay scope until the next turn opens one, so a later rotation in
|
||||
the same turn is marked on that turn. When the child already has a scope, that scope owns the next LLM
|
||||
starts for the child and keeps the mark. A turn Relay does not instrument (a second concurrent turn on
|
||||
the session, such as a background-review fork) gets nothing: its compaction rewrote its own transcript,
|
||||
not the live session's."""
|
||||
if not relay_runtime.relay_instrumentation_enabled():
|
||||
return None
|
||||
target = _turn_target(session_id) or _session_target(session_id)
|
||||
if target is None and turn_session_id:
|
||||
target = _turn_target(turn_session_id)
|
||||
return target
|
||||
|
||||
|
||||
def compaction_target_available(session_id: str, *, turn_session_id: str | None = None) -> bool:
|
||||
"""Whether a mark for this session has a live Relay scope to receive it."""
|
||||
return bool(session_id and _resolve_target(session_id, turn_session_id))
|
||||
|
||||
|
||||
def emit_compaction_mark(
|
||||
session_id: str, name: str, data: dict[str, Any], *, turn_session_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Emit one mark; returns False when no Relay scope owns ``session_id`` (see ``_resolve_target``)."""
|
||||
target = _resolve_target(session_id, turn_session_id) if session_id else None
|
||||
if target is None:
|
||||
logger.debug("no live Relay session for %s; %s mark not recorded", session_id or "none", name)
|
||||
return False
|
||||
global _stalled
|
||||
if _stalled:
|
||||
return False
|
||||
host, session, handle = target
|
||||
try:
|
||||
host.run_in_session(
|
||||
session, host.relay.scope.event, name, handle=handle, data=data,
|
||||
data_schema=dict(COMPACTION_DATA_SCHEMA), metadata=relay_runtime.runtime_metadata(host.runtime_id),
|
||||
timeout=relay_runtime.SCOPE_OP_TIMEOUT,
|
||||
)
|
||||
except TimeoutError:
|
||||
_stalled = True
|
||||
raise
|
||||
return True
|
||||
@@ -36,7 +36,7 @@ _PROFILE_KEY_CACHE: dict[str, str] = {}
|
||||
|
||||
# Bound for native scope ops gating turn/session completion: a wedged pipeline costs one
|
||||
# lost span, never a blocked agent.
|
||||
_SCOPE_OP_TIMEOUT = 10.0
|
||||
SCOPE_OP_TIMEOUT = 10.0
|
||||
|
||||
|
||||
|
||||
@@ -462,7 +462,7 @@ class RelayRuntime:
|
||||
self, session: RelaySession, scope_metadata: dict[str, Any], *, resolve_parent: bool,
|
||||
exit_fallback: bool = False, **push_kwargs: Any,
|
||||
) -> None:
|
||||
"""Push a fresh SESSION_SCOPE for ``session`` (bounded by ``_SCOPE_OP_TIMEOUT``); record handle + context.
|
||||
"""Push a fresh SESSION_SCOPE for ``session`` (bounded by ``SCOPE_OP_TIMEOUT``); record handle + context.
|
||||
Subagents parent under their spawning turn/session handle (``resolve_parent`` creates the parent when
|
||||
unknown). ``exit_fallback``: at interpreter shutdown the executor refuses futures; push synchronously."""
|
||||
parent_handle = None
|
||||
@@ -486,7 +486,7 @@ class RelayRuntime:
|
||||
else:
|
||||
# future.result() re-raises push's own RuntimeError; keeping it outside the
|
||||
# except prevents a real push failure from retrying via the exit fallback.
|
||||
session.handle = future.result(timeout=_SCOPE_OP_TIMEOUT)
|
||||
session.handle = future.result(timeout=SCOPE_OP_TIMEOUT)
|
||||
session.context = context
|
||||
|
||||
def ensure_session(
|
||||
@@ -535,7 +535,7 @@ class RelayRuntime:
|
||||
try:
|
||||
self.run_in_session(
|
||||
session, self.relay.scope.pop, old_handle, output={"hermes.session.segment_reason": reason},
|
||||
metadata=runtime_metadata(self.runtime_id), timeout=_SCOPE_OP_TIMEOUT,
|
||||
metadata=runtime_metadata(self.runtime_id), timeout=SCOPE_OP_TIMEOUT,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
@@ -620,7 +620,7 @@ class RelayRuntime:
|
||||
) -> Any:
|
||||
"""Run a Relay operation against a session's isolated scope stack.
|
||||
``timeout`` bounds the native call on the daemon executor (``TimeoutError`` on breach); ``None``
|
||||
runs synchronously. Lifecycle ops pass ``_SCOPE_OP_TIMEOUT``: a wedged pipeline costs one span."""
|
||||
runs synchronously. Lifecycle ops pass ``SCOPE_OP_TIMEOUT``: a wedged pipeline costs one span."""
|
||||
with self._operation():
|
||||
return self._run_in_session_untracked(
|
||||
session, callback, *args, allow_closing=allow_closing, timeout=timeout, **kwargs
|
||||
@@ -758,7 +758,7 @@ class RelayRuntime:
|
||||
failure = run_in_session(
|
||||
session, self._pop_with_drain, handle, output=output or {},
|
||||
metadata=runtime_metadata(self.runtime_id), session_root=session.handle,
|
||||
drain_limit=drain_limit, allow_closing=allow_closing, timeout=_SCOPE_OP_TIMEOUT,
|
||||
drain_limit=drain_limit, allow_closing=allow_closing, timeout=SCOPE_OP_TIMEOUT,
|
||||
)
|
||||
except Exception as exc:
|
||||
failure = exc
|
||||
@@ -1082,7 +1082,7 @@ class RelaySessionCoordinator:
|
||||
TURN_SCOPE, host.relay.ScopeType.Function, handle=lease.session.handle,
|
||||
input=_scope_input(lease.turn_cwd),
|
||||
metadata=turn_metadata,
|
||||
timeout=_SCOPE_OP_TIMEOUT,
|
||||
timeout=SCOPE_OP_TIMEOUT,
|
||||
)
|
||||
turn._previous_turn = _CURRENT_TURN.get()
|
||||
_CURRENT_TURN.set(turn)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Skills that change mid-conversation reach the model without a prompt rebuild.
|
||||
|
||||
The ``<available_skills>`` index lives in the system prompt, and a conversation keeps those bytes
|
||||
for its whole life so the provider prefix cache stays warm (#104414). A skill installed, created or
|
||||
removed after the prompt was built (hub install, ``skill_manage``, the curator, a plugin going
|
||||
live, a change made by another process) therefore never reached the index the model routes on
|
||||
until compaction rebuilt the prompt. Every turn compares the index the prompt carries with the
|
||||
index the session would build now and delivers the difference on the per-turn user-message note
|
||||
channel, behind the cached prefix (ported from cloudflare/cloudflare-os#267).
|
||||
|
||||
Each note states the FULL difference against the prompt, so the prompt plus the newest note is the
|
||||
complete record of what the model was told. A fresh agent (the gateway builds one per turn) reads that record back
|
||||
from the transcript once; a long-lived agent (CLI, TUI, Desktop) keeps it on itself. A note is sent
|
||||
only when the difference changes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from agent.message_content import flatten_message_text
|
||||
|
||||
logger = logging.getLogger("run_agent")
|
||||
|
||||
_NOTE_PREFIX = "[System: The skills available in this conversation changed after its system prompt was built."
|
||||
_ADDED_HEAD = "Now available (load with skill_view(name) when relevant, like the skills listed in the system prompt):"
|
||||
_REMOVED_HEAD = "No longer available:"
|
||||
_ACCURATE_AGAIN = " The skills listed in the system prompt are accurate again; earlier skill-change notes are superseded."
|
||||
_ROW = " - "
|
||||
|
||||
_BLOCK_RE = re.compile(r"<available_skills>\n?(.*?)</available_skills>", re.DOTALL)
|
||||
_NAMES_ONLY_RE = re.compile(r"\[names only\]:\s*(.+)$")
|
||||
|
||||
_Delta = tuple[frozenset, frozenset]
|
||||
|
||||
|
||||
def _row_name(line: str) -> str:
|
||||
"""Skill name of an index row (`` - name: description`` / `` - name``). Split on ``": "``:
|
||||
plugin skills are qualified (``plugin:skill``), so a bare ``":"`` is part of the name."""
|
||||
return line.strip()[2:].split(": ", 1)[0].strip()
|
||||
|
||||
|
||||
def index_entries(prompt: Any) -> dict[str, str]:
|
||||
"""``{name: index row}`` for every skill in ``prompt``'s ``<available_skills>`` block; names-only
|
||||
category rows (focus mode) map to ``""``. ``{}`` without a block."""
|
||||
match = _BLOCK_RE.search(prompt) if isinstance(prompt, str) else None
|
||||
entries: dict[str, str] = {}
|
||||
for line in match.group(1).splitlines() if match else ():
|
||||
if line.strip().startswith("- ") and (name := _row_name(line)):
|
||||
entries.setdefault(name, line.rstrip())
|
||||
elif names_only := _NAMES_ONLY_RE.search(line):
|
||||
for name in filter(None, (n.strip() for n in names_only.group(1).split(","))):
|
||||
entries.setdefault(name, "")
|
||||
return entries
|
||||
|
||||
|
||||
def _render(added: dict[str, str], removed: set[str]) -> str:
|
||||
if not added and not removed:
|
||||
return f"{_NOTE_PREFIX}{_ACCURATE_AGAIN}]"
|
||||
parts = [_NOTE_PREFIX]
|
||||
if added:
|
||||
parts += [_ADDED_HEAD, *(added[name] or f"{_ROW}{name}" for name in sorted(added))]
|
||||
if removed:
|
||||
parts.append(f"{_REMOVED_HEAD} {', '.join(sorted(removed))}")
|
||||
return "\n".join(parts) + "]"
|
||||
|
||||
|
||||
def _parse(note: str) -> _Delta:
|
||||
"""Inverse of :func:`_render` for the text that follows ``_NOTE_PREFIX``."""
|
||||
lines = note.split("\n")
|
||||
added: set[str] = set()
|
||||
if _ADDED_HEAD in lines[1:2]:
|
||||
for line in lines[2:]:
|
||||
if not line.startswith(_ROW):
|
||||
break
|
||||
added.add(_row_name(line.removesuffix("]")))
|
||||
removed: set[str] = set()
|
||||
for line in lines[1:]:
|
||||
if line.startswith(_REMOVED_HEAD):
|
||||
tail = line[len(_REMOVED_HEAD):].removesuffix("]")
|
||||
removed = {n.strip() for n in tail.split(",") if n.strip()}
|
||||
break
|
||||
return frozenset(added), frozenset(removed)
|
||||
|
||||
|
||||
def _announced(conversation_history: Any) -> _Delta:
|
||||
"""The difference named by the newest skills note anywhere in the transcript: a note far back
|
||||
still describes the current state when nothing changed since it was sent."""
|
||||
for msg in reversed(conversation_history or []):
|
||||
if not isinstance(msg, dict) or msg.get("role") != "user":
|
||||
continue
|
||||
sidecar = msg.get("api_content")
|
||||
text = (sidecar if isinstance(sidecar, str) else "") + "\n" + flatten_message_text(msg.get("content"))
|
||||
if _NOTE_PREFIX in text:
|
||||
return _parse(text.rsplit(_NOTE_PREFIX, 1)[1])
|
||||
return frozenset(), frozenset()
|
||||
|
||||
|
||||
def stage_skills_index_note(agent: Any, prompt: Any, conversation_history: Any) -> bool:
|
||||
"""Stage a one-shot note when the skills this session can load differ from ``prompt``'s index
|
||||
by something the model has not been told yet. Returns whether it staged.
|
||||
|
||||
A prompt without an index is never compared: it has no baseline (skills tools off, a seeded or
|
||||
embedder-supplied prompt), and announcing the whole catalog there would only add noise. MoA and
|
||||
codex_app_server turns never stamp the ``api_content`` sidecar, so the note could not be read
|
||||
back from the transcript; those modes skip it, like the surface-switch note."""
|
||||
if getattr(agent, "provider", None) == "moa" or getattr(agent, "api_mode", None) == "codex_app_server":
|
||||
return False
|
||||
stored = index_entries(prompt)
|
||||
if not stored:
|
||||
return False
|
||||
try:
|
||||
from agent.system_prompt import _skills_prompt
|
||||
current = index_entries(_skills_prompt(agent))
|
||||
except Exception:
|
||||
logger.debug("skills index delta: current index unavailable", exc_info=True)
|
||||
return False
|
||||
told = getattr(agent, "_skills_index_told", None)
|
||||
key = hash(prompt)
|
||||
told_added, told_removed = told[1] if isinstance(told, tuple) and told[0] == key else _announced(conversation_history)
|
||||
# What the model believes it can load: the prompt's index as amended by the newest note. A rebuilt
|
||||
# prompt (compaction) that now lists what an earlier note announced needs no further note.
|
||||
if (set(stored) | told_added) - told_removed == set(current):
|
||||
agent._skills_index_told = (key, (told_added, told_removed))
|
||||
return False
|
||||
added = {name: row for name, row in current.items() if name not in stored}
|
||||
removed = set(stored) - set(current)
|
||||
agent._skills_index_told = (key, (frozenset(added), frozenset(removed)))
|
||||
agent._skills_index_note = _render(added, removed)
|
||||
logger.info(
|
||||
"Session %s: skills changed (+%d/-%d) since the system prompt was built; delivering the "
|
||||
"change as a turn note (prefix cache preserved).",
|
||||
getattr(agent, "session_id", None), len(added), len(removed),
|
||||
)
|
||||
return True
|
||||
+11
-1
@@ -22,6 +22,7 @@ from agent.iteration_budget import IterationBudget
|
||||
from agent.memory_manager import build_memory_context_block
|
||||
from agent.memory_provider import is_trivial_prompt
|
||||
from agent.message_content import flatten_message_text
|
||||
from agent.skills_index_delta import stage_skills_index_note
|
||||
from agent.message_metadata import PERSISTENCE_ONLY_MESSAGE_FIELDS, append_message, stamp_message_timestamp
|
||||
from agent.model_metadata import (
|
||||
estimate_messages_tokens_rough,
|
||||
@@ -171,6 +172,11 @@ def consume_surface_switch_note(agent: Any) -> str:
|
||||
return _pop_turn_note(agent, "_surface_switch_note")
|
||||
|
||||
|
||||
def consume_skills_index_note(agent: Any) -> str:
|
||||
"""Pop the skills-index delta staged by the system-prompt restore; same channel as above."""
|
||||
return _pop_turn_note(agent, "_skills_index_note")
|
||||
|
||||
|
||||
def append_notes_to_multimodal_content(content: Any, notes: Optional[str]) -> bool:
|
||||
"""Append must-deliver notes as a durable text part on a multimodal (list) user
|
||||
message (the sidecar path returns ``None`` for non-string content)."""
|
||||
@@ -850,7 +856,8 @@ def _merge_gateway_notes(
|
||||
append a durable text part instead."""
|
||||
_turn_notes = "\n\n".join(
|
||||
part for part in (consume_gateway_turn_context_notes(agent),
|
||||
consume_surface_switch_note(agent)) if part
|
||||
consume_surface_switch_note(agent),
|
||||
consume_skills_index_note(agent)) if part
|
||||
)
|
||||
if not _turn_notes:
|
||||
return plugin_user_context
|
||||
@@ -1167,6 +1174,9 @@ def build_turn_context(
|
||||
original_user_message=original_user_message, messages=messages,
|
||||
conversation_history=conversation_history,
|
||||
)
|
||||
# Every turn, not only on a prompt restore: a long-lived agent (CLI, TUI, Desktop) keeps its
|
||||
# prompt in memory for the whole conversation and would never re-check its skills index.
|
||||
stage_skills_index_note(agent, active_system_prompt, messages)
|
||||
plugin_user_context = _merge_gateway_notes(
|
||||
agent, messages, current_turn_user_idx, plugin_user_context
|
||||
)
|
||||
|
||||
@@ -203,7 +203,7 @@ def _idle_compaction(
|
||||
if _idle_status:
|
||||
agent._emit_status(_idle_status)
|
||||
out.messages, out.active_system_prompt = agent._compress_context(
|
||||
messages, system_message, approx_tokens=_idle_tokens, task_id=effective_task_id
|
||||
messages, system_message, approx_tokens=_idle_tokens, task_id=effective_task_id, trigger="idle",
|
||||
)
|
||||
# ``_compress_context`` returns the INPUT list object when it skips; only
|
||||
# re-baseline and re-anchor after a real compaction.
|
||||
@@ -386,7 +386,7 @@ def _run_preflight_passes(
|
||||
_orig_tokens = _preflight_tokens
|
||||
out.messages, out.active_system_prompt = agent._compress_context(
|
||||
_preflight_input, system_message, approx_tokens=_preflight_tokens,
|
||||
task_id=effective_task_id,
|
||||
task_id=effective_task_id, trigger="turn_start_threshold",
|
||||
)
|
||||
if out.messages is _preflight_input and compression_skipped_due_to_lock(agent):
|
||||
# Lock-skip: another path holds the lock, so this is a DEFER, not proof of
|
||||
@@ -463,7 +463,8 @@ def _engine_preflight_maintenance(
|
||||
)
|
||||
_engine_input = out.messages
|
||||
out.messages, out.active_system_prompt = agent._compress_context(
|
||||
_engine_input, system_message, approx_tokens=_preflight_tokens, task_id=effective_task_id
|
||||
_engine_input, system_message, approx_tokens=_preflight_tokens, task_id=effective_task_id,
|
||||
trigger="engine_preflight",
|
||||
)
|
||||
# ``_compress_context`` returns the INPUT list on every skip path and an engine
|
||||
# may no-op; re-baseline/re-anchor only after a REAL compaction.
|
||||
|
||||
@@ -303,7 +303,10 @@ def _micro_compact_after_turn(agent, messages, final_response, logger, task_id)
|
||||
and not getattr(agent, "_persist_disabled", False)
|
||||
):
|
||||
_before = len(messages)
|
||||
_compacted = _compressor._micro_compact(messages)
|
||||
# A rotating compaction earlier in this turn may have moved the agent to a child session.
|
||||
_compacted = _compressor._micro_compact(
|
||||
messages, turn_session_id=getattr(agent, "_inflight_turn_session_id", None),
|
||||
)
|
||||
# Defrag rewrites the newest MICRO marker in place and pops _db_persisted;
|
||||
# the compressor flags us to invalidate the flush-scan cursor, else the
|
||||
# rewritten row is identity-skipped (stale).
|
||||
|
||||
@@ -83,6 +83,7 @@ class _Recovery(OverflowVerdict):
|
||||
effective_task_id: Any
|
||||
api_call_count: int
|
||||
max_compression_attempts: int
|
||||
overflow_reason: Optional[str] = None # the classifier's reason, recorded on each compression attempt
|
||||
action: str = "fallthrough"
|
||||
result: Optional[dict[str, Any]] = None
|
||||
provider_overflow_recovery_pending: bool = False
|
||||
@@ -162,6 +163,7 @@ class _Recovery(OverflowVerdict):
|
||||
self.messages, self.active_system_prompt = agent._compress_context(
|
||||
before, self.system_message, approx_tokens=request_tokens,
|
||||
task_id=self.effective_task_id, bypass_cooldown=True, trigger="overflow",
|
||||
overflow_reason=self.overflow_reason,
|
||||
)
|
||||
if self.messages is before:
|
||||
deferred = None
|
||||
@@ -478,6 +480,7 @@ def recover_from_overflow(
|
||||
max_compression_attempts=max_compression_attempts, messages=messages,
|
||||
active_system_prompt=active_system_prompt, conversation_history=conversation_history,
|
||||
approx_tokens=approx_tokens, compression_attempts=compression_attempts,
|
||||
overflow_reason=getattr(classified.reason, "value", None),
|
||||
)
|
||||
|
||||
# GitHub Models free tier caps requests at 8K tokens, under the system prompt +
|
||||
|
||||
@@ -146,7 +146,7 @@ def run_preflight_compression(
|
||||
_pre_api_input = v.messages
|
||||
v.messages, v.active_system_prompt = agent._compress_context(
|
||||
v.messages, system_message, approx_tokens=request_pressure_tokens,
|
||||
task_id=effective_task_id,
|
||||
task_id=effective_task_id, trigger="pre_api",
|
||||
)
|
||||
if context_compression_timed_out(agent):
|
||||
# Progress-aware timeout: never reached the provider — refund the
|
||||
@@ -313,7 +313,7 @@ def compress_after_tool_results(
|
||||
# Pass overhead-aware _real_tokens, not last_prompt_tokens (0 in the
|
||||
# no-usage fallback), so the overflow guard sees the true size.
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message, approx_tokens=_real_tokens, task_id=effective_task_id
|
||||
messages, system_message, approx_tokens=_real_tokens, task_id=effective_task_id, trigger="post_tool",
|
||||
)
|
||||
if messages is _post_tool_input and compression_skipped_due_to_lock(agent):
|
||||
# Lock-skip no-op is a temporary defer, not evidence about compressibility:
|
||||
@@ -385,6 +385,9 @@ def compress_after_tool_results(
|
||||
# conversation_history: rows already carry _DB_PERSISTED_MARKER, and on a
|
||||
# stale in-place flag the helper could seed unpersisted rows.
|
||||
if _pruned_n and _pruned_msgs is not messages:
|
||||
from agent.compaction_events import publish_prune
|
||||
|
||||
publish_prune(agent, messages, _pruned_msgs, _pruned_n)
|
||||
messages = _pruned_msgs
|
||||
# A committed prune is a content-loss boundary like compaction: demoted skill_view /
|
||||
# read_file bodies survive only as one-line markers, so the repeat-read dedup must
|
||||
|
||||
@@ -1893,7 +1893,7 @@ def route_classified_error(
|
||||
# from the engine's overflow guard (upstream PR #77169 review).
|
||||
messages, system_message,
|
||||
approx_tokens=estimate_request_tokens_rough(api_messages, tools=agent.tools or None),
|
||||
task_id=effective_task_id, trigger="overflow",
|
||||
task_id=effective_task_id, trigger="overflow", overflow_reason=classified.reason.value,
|
||||
)
|
||||
conversation_history = conversation_history_after_compression(agent, messages, conversation_history)
|
||||
if len(messages) < original_len or old_ctx > _LONG_CONTEXT_TIER_CAP:
|
||||
|
||||
@@ -620,8 +620,8 @@ import { ChannelResolver, type ChannelTarget } from './updater/channel'
|
||||
import { inspectRunningChannelApp } from './updater/channel-native'
|
||||
import { ChannelStrategy } from './updater/channel-strategy'
|
||||
import { verifyPreparedChannelInstaller } from './updater/channel-windows-host'
|
||||
import { createCheckoutStrategy } from './updater/checkout'
|
||||
import { readSourceUpdate, type SourceUpdate } from './updater/checkout-source'
|
||||
import { type CheckoutStrategy, createCheckoutStrategy } from './updater/checkout'
|
||||
import { readSourceUpdate, sourceChannelName, type SourceUpdate } from './updater/checkout-source'
|
||||
import { ExternalStrategy } from './updater/external'
|
||||
import { readUpdatesFeedBaseFromConfig, resolveFeedBaseUrl } from './updater/feed-config'
|
||||
import { createChannelMacStrategy, createMacStrategy } from './updater/mac-client'
|
||||
@@ -3391,11 +3391,6 @@ function writeFileAtomic(targetPath, data, encoding?: BufferEncoding) {
|
||||
fs.renameSync(tmp, targetPath)
|
||||
}
|
||||
|
||||
function writeDesktopUpdateConfig(config) {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_UPDATE_CONFIG_PATH), { recursive: true })
|
||||
writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||||
}
|
||||
|
||||
// ─── Main-window geometry persistence (window-state.json) ──────────────────
|
||||
|
||||
function readWindowState() {
|
||||
@@ -3692,21 +3687,25 @@ function requireBundledPayload(mechanism: UpdaterStrategy['mechanism']): Payload
|
||||
* entrypoints dispatch only through this strategy — there is no other
|
||||
* production path to the checkout arms.
|
||||
*/
|
||||
function resolveCheckoutUpdateStrategy(): UpdaterStrategy {
|
||||
function resolveCheckoutUpdateStrategy(): CheckoutStrategy {
|
||||
return createCheckoutStrategy({
|
||||
hermesHome: HERMES_HOME,
|
||||
isWindows: IS_WINDOWS,
|
||||
isMac: IS_MAC,
|
||||
defaultUpdateBranch: DEFAULT_UPDATE_BRANCH,
|
||||
updateHandoffDwellMs: UPDATE_HANDOFF_DWELL_MS,
|
||||
readSourceUpdate: async (updateRoot: string, opts: { force?: boolean }): Promise<SourceUpdate | null> =>
|
||||
readSourceUpdate: async (
|
||||
updateRoot: string,
|
||||
opts: { force?: boolean; setChannel?: 'main' | 'stable' }
|
||||
): Promise<SourceUpdate | null> =>
|
||||
readSourceUpdate({
|
||||
python: await findPythonForRoot(updateRoot),
|
||||
git: resolveGitBinary(),
|
||||
updateRoot,
|
||||
hermesHome: HERMES_HOME,
|
||||
branchConfigPath: DESKTOP_UPDATE_CONFIG_PATH,
|
||||
force: opts.force
|
||||
force: opts.force,
|
||||
setChannel: opts.setChannel
|
||||
}),
|
||||
resolveUpdateRoot,
|
||||
resolveUpdaterBinary,
|
||||
@@ -17522,18 +17521,12 @@ ipcMain.handle('hermes:updates:apply', async (_event, payload) =>
|
||||
}))
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig())
|
||||
// About ▸ Updates channel selector; source checkouts only (packages bake their channel).
|
||||
ipcMain.handle('hermes:updates:channel:set', async (_event, name: unknown): Promise<UpdaterStatusWire> => {
|
||||
assertSourceUpdateChannel(INSTALL_STAMP)
|
||||
|
||||
ipcMain.handle(
|
||||
'hermes:updates:branch:set',
|
||||
async (_event: Electron.IpcMainInvokeEvent, name: unknown): Promise<{ branch: string }> => {
|
||||
assertSourceUpdateChannel(INSTALL_STAMP)
|
||||
const branch: string = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH
|
||||
writeDesktopUpdateConfig({ branch })
|
||||
|
||||
return { branch }
|
||||
}
|
||||
)
|
||||
return resolveCheckoutUpdateStrategy().check({ force: true, setChannel: sourceChannelName(name) })
|
||||
})
|
||||
|
||||
function resolveHermesVersion(scope: { connectionId?: string; profile?: string } = {}): Promise<string> {
|
||||
return resolveGatewayVersion(path => handleHermesApiRequest({ ...scope, path, timeoutMs: 5000 }))
|
||||
|
||||
@@ -658,8 +658,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
updates: {
|
||||
check: opts => ipcRenderer.invoke('hermes:updates:check', opts),
|
||||
apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts),
|
||||
getBranch: () => ipcRenderer.invoke('hermes:updates:branch:get'),
|
||||
setBranch: name => ipcRenderer.invoke('hermes:updates:branch:set', name),
|
||||
setChannel: name => ipcRenderer.invoke('hermes:updates:channel:set', name),
|
||||
onProgress: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:updates:progress', listener)
|
||||
|
||||
@@ -266,13 +266,12 @@ it('carries each install channel from Python publication checks into the source
|
||||
responses.set(`/repos/NousResearch/hermes-agent/commits/${tags[channel]}`, { sha })
|
||||
}
|
||||
|
||||
// Stable is GitHub's latest published release; R2 holds no stable record.
|
||||
// Stable IS the latest published GitHub release; no R2 record is consulted for it.
|
||||
responses.set('/repos/NousResearch/hermes-agent/releases/latest', {
|
||||
tag_name: tags.stable,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
responses.set('/releases/stable/release-candidates.json', { tag: tags.stable, commit: commits[1] })
|
||||
// The 'main' subscription is a source-branch channel record under the R2 protocol.
|
||||
responses.set(
|
||||
'/releases/channels/main.json',
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface SourceUpdateProbe {
|
||||
hermesHome: string
|
||||
branch?: string
|
||||
channel?: 'main' | 'stable' | 'canary'
|
||||
/** Persist this install's channel first (the About selector); the check then reports it. */
|
||||
setChannel?: 'main' | 'stable'
|
||||
force?: boolean
|
||||
cachePath?: string
|
||||
branchConfigPath?: string
|
||||
@@ -25,6 +27,31 @@ export interface SourceUpdateProbe {
|
||||
|
||||
const execute: typeof execFile.__promisify__ = promisify(execFile)
|
||||
|
||||
/** The two channels a source checkout can select from Settings. */
|
||||
export function sourceChannelName(name: unknown): 'main' | 'stable' {
|
||||
if (name !== 'stable' && name !== 'main') {
|
||||
throw new Error('The update channel must be stable or main.')
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
/** The probe's optional arguments, in the order source_check parses them. */
|
||||
function probeFlags(probe: SourceUpdateProbe): string[] {
|
||||
const flags: [string, string | undefined][] = [
|
||||
['--branch', probe.branch],
|
||||
['--channel', probe.channel],
|
||||
['--set-channel', probe.setChannel],
|
||||
['--cache-path', probe.cachePath],
|
||||
['--branch-config-path', probe.branchConfigPath]
|
||||
]
|
||||
|
||||
return [
|
||||
...flags.flatMap(([flag, value]): string[] => (value ? [flag, value] : [])),
|
||||
...(probe.force ? ['--force'] : [])
|
||||
]
|
||||
}
|
||||
|
||||
export function sourceUpdateEnvironment(updateRoot: string, hermesHome: string): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
@@ -71,11 +98,7 @@ export async function readSourceUpdate(probe: SourceUpdateProbe): Promise<Source
|
||||
probe.hermesHome,
|
||||
'--git',
|
||||
probe.git,
|
||||
...(probe.branch ? ['--branch', probe.branch] : []),
|
||||
...(probe.channel ? ['--channel', probe.channel] : []),
|
||||
...(probe.force ? ['--force'] : []),
|
||||
...(probe.cachePath ? ['--cache-path', probe.cachePath] : []),
|
||||
...(probe.branchConfigPath ? ['--branch-config-path', probe.branchConfigPath] : [])
|
||||
...probeFlags(probe)
|
||||
]
|
||||
|
||||
const command: string = (managed ? launcher : probe.python)!
|
||||
|
||||
@@ -38,7 +38,10 @@ import type { UpdaterApplyResultWire, UpdaterMechanism, UpdaterStatusWire, Updat
|
||||
*/
|
||||
export interface CheckoutStrategyDeps {
|
||||
resolveUpdateRoot: () => string
|
||||
readSourceUpdate: (root: string, opts: { force?: boolean }) => Promise<SourceUpdate | null>
|
||||
readSourceUpdate: (
|
||||
root: string,
|
||||
opts: { force?: boolean; setChannel?: 'main' | 'stable' }
|
||||
) => Promise<SourceUpdate | null>
|
||||
hermesHome: string
|
||||
isWindows: boolean
|
||||
isMac: boolean
|
||||
@@ -130,7 +133,11 @@ export function readStampedCommit(root: string): string | null {
|
||||
* The bodies are the production update flow; the mechanism stamp rides on
|
||||
* every result the way the wire contract expects.
|
||||
*/
|
||||
export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrategy {
|
||||
export interface CheckoutStrategy extends UpdaterStrategy {
|
||||
check(opts?: { force?: boolean; setChannel?: 'main' | 'stable' }): Promise<UpdaterStatusWire>
|
||||
}
|
||||
|
||||
export function createCheckoutStrategy(deps: CheckoutStrategyDeps): CheckoutStrategy {
|
||||
function markerHelper(op: MarkerHelperOp, runId: string | null): Promise<MarkerHelperVerdict> {
|
||||
return (deps.markerHelper ?? runMarkerHelper)(op, {
|
||||
updateRoot: deps.resolveUpdateRoot(),
|
||||
@@ -294,7 +301,7 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat
|
||||
|
||||
const mechanism: UpdaterMechanism = deps.isWindows ? 'windows-handoff' : 'posix-handoff'
|
||||
|
||||
async function check(opts: { force?: boolean } = {}): Promise<UpdaterStatusWire> {
|
||||
async function check(opts: { force?: boolean; setChannel?: 'main' | 'stable' } = {}): Promise<UpdaterStatusWire> {
|
||||
const root: string = deps.resolveUpdateRoot()
|
||||
|
||||
// A checkout without the source probe predates source channels, so it can
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface UpdaterStatusWire {
|
||||
currentSha?: string
|
||||
currentVersion?: string
|
||||
channel?: string
|
||||
/** source_check can persist a channel; runtimes older than the selector cannot. */
|
||||
channelSelectable?: boolean
|
||||
retirement?: ChannelRetirementStatus
|
||||
latestTag?: string | null
|
||||
targetSha?: string
|
||||
|
||||
@@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DesktopUpdateStatus, DesktopVersionInfo, HermesConnection } from '@/global'
|
||||
import { en } from '@/i18n/en'
|
||||
import { $confirmRequest, settleConfirm } from '@/store/confirm'
|
||||
import type * as SessionStore from '@/store/session'
|
||||
import { $connection } from '@/store/session'
|
||||
import type * as UpdatesStore from '@/store/updates'
|
||||
import {
|
||||
$backendUpdateStatus,
|
||||
$desktopVersion,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
checkBackendUpdates,
|
||||
checkUpdates,
|
||||
refreshDesktopVersion,
|
||||
setUpdateChannel,
|
||||
startActiveUpdate,
|
||||
type UpdateApplyState
|
||||
} from '@/store/updates'
|
||||
@@ -49,6 +52,8 @@ vi.mock('@/store/updates', async (): Promise<Record<string, unknown>> => {
|
||||
checkBackendUpdates: vi.fn<() => Promise<DesktopUpdateStatus | null>>().mockResolvedValue(null),
|
||||
checkUpdates: vi.fn<() => Promise<DesktopUpdateStatus | null>>().mockResolvedValue(null),
|
||||
refreshDesktopVersion: vi.fn<() => Promise<DesktopVersionInfo | null>>().mockResolvedValue(null),
|
||||
setUpdateChannel: vi.fn<() => Promise<DesktopUpdateStatus | null>>().mockResolvedValue(null),
|
||||
sourceUpdateChannel: (await vi.importActual<typeof UpdatesStore>('@/store/updates')).sourceUpdateChannel,
|
||||
openUpdateOverlayFor: vi.fn(),
|
||||
openUpdatesWindow: vi.fn(),
|
||||
startActiveUpdate: vi.fn()
|
||||
@@ -119,6 +124,51 @@ describe('AboutSettings', (): void => {
|
||||
expect(screen.queryByRole('combobox')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets a source checkout pick its channel and confirms the move to stable releases', async (): Promise<void> => {
|
||||
const about = en.settings.about.channel
|
||||
const source = { supported: true, behind: 0, mechanism: 'posix-handoff', channelSelectable: true } as const
|
||||
// The save resolves only when released, so the in-flight paint is observable.
|
||||
let release!: () => void
|
||||
vi.mocked(setUpdateChannel).mockImplementationOnce(
|
||||
() => new Promise(resolve => (release = () => resolve({ ...source, channel: 'stable' })))
|
||||
)
|
||||
$updateStatus.set({ ...source, branch: 'main' })
|
||||
render(<AboutSettings />)
|
||||
expect(screen.getByRole('button', { name: about.main }).getAttribute('aria-pressed')).toBe('true')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: about.stable }))
|
||||
await waitFor((): void => {
|
||||
expect($confirmRequest.get()?.title).toBe(about.stableConfirmTitle)
|
||||
})
|
||||
expect(setUpdateChannel).not.toHaveBeenCalled()
|
||||
settleConfirm(true)
|
||||
await waitFor((): void => {
|
||||
expect(setUpdateChannel).toHaveBeenCalledWith('stable')
|
||||
})
|
||||
// The confirmed choice shows while the save + check is still running.
|
||||
expect(screen.getByRole('button', { name: about.stable }).getAttribute('aria-pressed')).toBe('true')
|
||||
release()
|
||||
|
||||
// Back to every commit is never older code, so it needs no confirmation.
|
||||
$updateStatus.set({ ...source, channel: 'stable' })
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByRole('button', { name: about.stable }).getAttribute('aria-pressed')).toBe('true')
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: about.main }))
|
||||
await waitFor((): void => {
|
||||
expect(setUpdateChannel).toHaveBeenCalledWith('main')
|
||||
})
|
||||
expect($confirmRequest.get()).toBeNull()
|
||||
|
||||
// An older runtime cannot save a channel, and a packaged install owns its own.
|
||||
$updateStatus.set({ ...source, channel: 'stable', channelSelectable: undefined })
|
||||
await waitFor((): void => {
|
||||
expect(screen.queryByText(about.title)).toBeNull()
|
||||
})
|
||||
$updateStatus.set({ ...source, mechanism: 'electron-updater', channel: 'stable' })
|
||||
expect(screen.queryByText(about.title)).toBeNull()
|
||||
})
|
||||
|
||||
it('a commit client refuses updates without hiding an unrelated backend update', (): void => {
|
||||
const message: string = "This build doesn't get updates. Ask the developer who gave it to you for a new build."
|
||||
$connection.set(connection('remote'))
|
||||
|
||||
@@ -11,6 +11,7 @@ import { $desktopVersion, checkBackendUpdates, refreshDesktopVersion } from '@/s
|
||||
import { SectionHeading, SettingsContent } from './primitives'
|
||||
import { SETTING_IDS, settingElementId } from './settings-manifest'
|
||||
import { UninstallSection } from './uninstall-section'
|
||||
import { UpdateChannelRow } from './update-channel-row'
|
||||
import { useSettingDeepLink } from './use-setting-deep-link'
|
||||
|
||||
interface AboutSettingsProps {
|
||||
@@ -57,6 +58,7 @@ function AppUpdatesSettings({ includeUninstall }: AppUpdatesSettingsProps): Reac
|
||||
<SectionHeading icon={RefreshCw} title={t.settings.about.updates} />
|
||||
<div className="grid gap-3" id={settingElementId(SETTING_IDS.about.updates)}>
|
||||
<UpdateStatusCard target="client" />
|
||||
<UpdateChannelRow />
|
||||
{/* Client and remote backend updates are independent. Only the client has release notes. */}
|
||||
{remote && <UpdateStatusCard showReleaseNotes={false} target="backend" />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactElement, useState } from 'react'
|
||||
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { confirm } from '@/store/confirm'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $updateChecking, $updateStatus, checkUpdates, setUpdateChannel, type SourceUpdateChannel, sourceUpdateChannel } from '@/store/updates'
|
||||
|
||||
import { ListRow } from './primitives'
|
||||
|
||||
/**
|
||||
* Stable releases vs every commit on main, for a source checkout. Packaged
|
||||
* installs (Store, MSIX, bundled) bake their channel and never render this.
|
||||
*/
|
||||
export function UpdateChannelRow(): ReactElement | null {
|
||||
const { t } = useI18n()
|
||||
const a = t.settings.about.channel
|
||||
const status = useStore($updateStatus)
|
||||
const checking = useStore($updateChecking)
|
||||
// The confirmed choice paints at once; the check that follows the save can be slow.
|
||||
const [pending, setPending] = useState<SourceUpdateChannel | null>(null)
|
||||
const saved = sourceUpdateChannel(status)
|
||||
const channel = pending ?? saved
|
||||
|
||||
if (channel === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const change = async (next: SourceUpdateChannel): Promise<void> => {
|
||||
if (next === channel) {
|
||||
return
|
||||
}
|
||||
|
||||
// Main → stable can land on an older commit than the one running now.
|
||||
if (
|
||||
next === 'stable' &&
|
||||
!(await confirm({
|
||||
title: a.stableConfirmTitle,
|
||||
description: a.stableConfirmBody,
|
||||
confirmLabel: a.stableConfirm
|
||||
}))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('crisp')
|
||||
setPending(next)
|
||||
|
||||
try {
|
||||
await setUpdateChannel(next)
|
||||
} catch (error) {
|
||||
// A throw may still follow a save, so re-read the install instead of guessing.
|
||||
notifyError(error, a.failed)
|
||||
void checkUpdates()
|
||||
} finally {
|
||||
setPending(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
disabled={checking || pending !== null}
|
||||
onChange={id => void change(id)}
|
||||
options={[
|
||||
{ id: 'stable', label: a.stable },
|
||||
{ id: 'main', label: a.main }
|
||||
]}
|
||||
value={channel}
|
||||
/>
|
||||
}
|
||||
description={a.description}
|
||||
title={a.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Vendored
+4
-2
@@ -633,8 +633,8 @@ declare global {
|
||||
updates: {
|
||||
check: (opts?: { force?: boolean }) => Promise<DesktopUpdateStatus>
|
||||
apply: (opts?: DesktopUpdateApplyOptions) => Promise<DesktopUpdateApplyResult>
|
||||
getBranch: () => Promise<{ branch: string }>
|
||||
setBranch: (name: string) => Promise<{ branch: string }>
|
||||
/** Persist this source install's update channel and return the fresh check. */
|
||||
setChannel?: (name: 'main' | 'stable') => Promise<DesktopUpdateStatus>
|
||||
onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void
|
||||
/** Claim the pending packaged self-update run (null when none or already claimed). */
|
||||
takePendingRun?: () => Promise<UpdateRunReport | null>
|
||||
@@ -871,6 +871,8 @@ export interface DesktopUpdateStatus {
|
||||
currentVersion?: string
|
||||
/** The R2 channel name; independent of source branch and package version. */
|
||||
channel?: string
|
||||
/** The install's source_check can persist a channel (older runtimes cannot). */
|
||||
channelSelectable?: boolean
|
||||
/** The latest release tag on a release-feed channel, e.g. `v0.18.0`. */
|
||||
latestTag?: string | null
|
||||
targetSha?: string
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { arUpdateChannel } from './ar_update_channel'
|
||||
import type { TranslationOverrides } from './define-locale'
|
||||
|
||||
export const arSettings = {
|
||||
@@ -506,7 +507,8 @@ export const arSettings = {
|
||||
'عندما يحدّث Hermes نفسه من التطبيق دون موجه طرفية، احتفظ بتعديلات المصدر المحلية أو تجاهلها.'
|
||||
},
|
||||
about: {
|
||||
updates: 'التحديثات'
|
||||
updates: 'التحديثات',
|
||||
channel: arUpdateChannel
|
||||
},
|
||||
|
||||
config: {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const arUpdateChannel = {
|
||||
title: 'قناة التحديث',
|
||||
description: 'المستقر يثبّت الإصدارات المنشورة فقط. «كل التزام» يتبع فرع main مع وصول التغييرات.',
|
||||
stable: 'الإصدارات المستقرة',
|
||||
main: 'كل التزام',
|
||||
stableConfirmTitle: 'التبديل إلى الإصدارات المستقرة؟',
|
||||
stableConfirmBody: 'سينتقل Hermes إلى أحدث إصدار منشور، وقد يكون أقدم من الكود الذي تشغّله الآن. قد لا تُقرأ البيانات التي كتبها كود أحدث بشكل صحيح، لذا انسخها احتياطيًا أولًا.',
|
||||
stableConfirm: 'التبديل إلى المستقر',
|
||||
failed: 'تعذّر تغيير قناة التحديث'
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { deNotices } from './de_notices'
|
||||
import { deOnboarding } from './de_onboarding'
|
||||
import { deProjects } from './de_projects'
|
||||
import { deSharedMetrics } from './de_shared_metrics'
|
||||
import { deUpdateChannel } from './de_update_channel'
|
||||
import { defineLocale, type TranslationOverrides } from './define-locale'
|
||||
import { introDe } from './intro-de'
|
||||
|
||||
@@ -1550,9 +1551,7 @@ export const deOverrides = {
|
||||
screenRecording: 'Bildschirmaufnahme',
|
||||
driverHealth: 'Treiberstatus'
|
||||
},
|
||||
about: {
|
||||
updates: 'Updates'
|
||||
},
|
||||
about: { updates: 'Updates', channel: deUpdateChannel },
|
||||
config: {
|
||||
minimizeToTrayTitle: 'In den Infobereich minimieren',
|
||||
minimizeToTrayDesc:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const deUpdateChannel = {
|
||||
title: 'Update-Kanal',
|
||||
description: 'Stabil installiert nur veröffentlichte Releases. Jeder Commit folgt dem main-Branch, sobald Änderungen eintreffen.',
|
||||
stable: 'Stabile Releases',
|
||||
main: 'Jeder Commit',
|
||||
stableConfirmTitle: 'Zu stabilen Releases wechseln?',
|
||||
stableConfirmBody: 'Hermes wechselt zum neuesten veröffentlichten Release, das älter sein kann als der Code, den du gerade nutzt. Von neuerem Code geschriebene Daten werden eventuell nicht korrekt gelesen – sichere sie vorher.',
|
||||
stableConfirm: 'Zu Stabil wechseln',
|
||||
failed: 'Update-Kanal konnte nicht geändert werden'
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { enOnboarding } from './en_onboarding'
|
||||
import { enProjects } from './en_projects'
|
||||
import { enSharedMetrics } from './en_shared_metrics'
|
||||
import { enUninstallSection } from './en_uninstall_section'
|
||||
import { enUpdateChannel } from './en_update_channel'
|
||||
import type { Translations } from './types'
|
||||
|
||||
export const en: Translations = {
|
||||
@@ -1206,9 +1207,7 @@ export const en: Translations = {
|
||||
screenRecording: 'Screen Recording',
|
||||
driverHealth: 'Driver health'
|
||||
},
|
||||
about: {
|
||||
updates: 'Updates'
|
||||
},
|
||||
about: { updates: 'Updates', channel: enUpdateChannel },
|
||||
config: {
|
||||
minimizeToTrayTitle: 'Minimize to tray',
|
||||
minimizeToTrayDesc:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UpdateChannelCopy } from './types_update_channel'
|
||||
|
||||
export const enUpdateChannel: UpdateChannelCopy = {
|
||||
title: 'Update channel',
|
||||
description: 'Stable installs published releases only. Every commit follows the main branch as changes land.',
|
||||
stable: 'Stable releases',
|
||||
main: 'Every commit',
|
||||
stableConfirmTitle: 'Switch to stable releases?',
|
||||
stableConfirmBody: 'Hermes will move to the latest published release, which can be older than the code you run now. Data written by newer code may not read correctly, so back it up first.',
|
||||
stableConfirm: 'Switch to stable',
|
||||
failed: 'Could not change the update channel'
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { esNotices } from './es_notices'
|
||||
import { esOnboarding } from './es_onboarding'
|
||||
import { esProjects } from './es_projects'
|
||||
import { esSharedMetrics } from './es_shared_metrics'
|
||||
import { esUpdateChannel } from './es_update_channel'
|
||||
import { introEs } from './intro-es'
|
||||
|
||||
export const esOverrides = {
|
||||
@@ -1553,9 +1554,7 @@ export const esOverrides = {
|
||||
screenRecording: 'Grabación de pantalla',
|
||||
driverHealth: 'Estado del controlador'
|
||||
},
|
||||
about: {
|
||||
updates: 'Actualizaciones'
|
||||
},
|
||||
about: { updates: 'Actualizaciones', channel: esUpdateChannel },
|
||||
config: {
|
||||
minimizeToTrayTitle: 'Minimizar a la bandeja',
|
||||
minimizeToTrayDesc:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const esUpdateChannel = {
|
||||
title: 'Canal de actualizaciones',
|
||||
description: 'Estable instala solo versiones publicadas. Cada commit sigue la rama main a medida que llegan cambios.',
|
||||
stable: 'Versiones estables',
|
||||
main: 'Cada commit',
|
||||
stableConfirmTitle: '¿Cambiar a versiones estables?',
|
||||
stableConfirmBody: 'Hermes pasará a la última versión publicada, que puede ser más antigua que el código que usas ahora. Los datos escritos por código más nuevo podrían no leerse bien, así que haz una copia antes.',
|
||||
stableConfirm: 'Cambiar a estable',
|
||||
failed: 'No se pudo cambiar el canal de actualizaciones'
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { frNotices } from './fr_notices'
|
||||
import { frOnboarding } from './fr_onboarding'
|
||||
import { frProjects } from './fr_projects'
|
||||
import { frSharedMetrics } from './fr_shared_metrics'
|
||||
import { frUpdateChannel } from './fr_update_channel'
|
||||
import { introFr } from './intro-fr'
|
||||
|
||||
export const frOverrides = {
|
||||
@@ -1557,9 +1558,7 @@ export const frOverrides = {
|
||||
screenRecording: "Enregistrement de l'écran",
|
||||
driverHealth: 'État du pilote'
|
||||
},
|
||||
about: {
|
||||
updates: 'Mises à jour'
|
||||
},
|
||||
about: { updates: 'Mises à jour', channel: frUpdateChannel },
|
||||
config: {
|
||||
minimizeToTrayTitle: 'Réduire dans la barre d’état',
|
||||
minimizeToTrayDesc:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const frUpdateChannel = {
|
||||
title: 'Canal de mise à jour',
|
||||
description: 'Stable installe uniquement les versions publiées. Chaque commit suit la branche main au fil des changements.',
|
||||
stable: 'Versions stables',
|
||||
main: 'Chaque commit',
|
||||
stableConfirmTitle: 'Passer aux versions stables ?',
|
||||
stableConfirmBody: 'Hermes passera à la dernière version publiée, qui peut être plus ancienne que le code que vous utilisez. Les données écrites par un code plus récent risquent de mal se lire : sauvegardez-les d’abord.',
|
||||
stableConfirm: 'Passer en stable',
|
||||
failed: 'Impossible de changer le canal de mise à jour'
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { jaOnboarding } from './ja_onboarding'
|
||||
import { jaPluginSettings } from './ja_plugins'
|
||||
import { jaProjects } from './ja_projects'
|
||||
import { jaSharedMetrics } from './ja_shared_metrics'
|
||||
import { jaUpdateChannel } from './ja_update_channel'
|
||||
|
||||
export const jaOverrides = {
|
||||
externalOpenFailed: {
|
||||
@@ -925,9 +926,7 @@ export const jaOverrides = {
|
||||
'アプリから Hermes 自身を更新するとき、ローカルのソース変更を保持するか破棄するかを選びます。ターミナル更新では常に確認されます。'
|
||||
}
|
||||
}),
|
||||
about: {
|
||||
updates: '更新'
|
||||
},
|
||||
about: { updates: '更新', channel: jaUpdateChannel },
|
||||
config: {
|
||||
minimizeToTrayTitle: 'トレイに最小化',
|
||||
minimizeToTrayDesc:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const jaUpdateChannel = {
|
||||
title: 'アップデートチャンネル',
|
||||
description: '安定版は公開済みのリリースのみをインストールします。すべてのコミットは main ブランチの変更に追従します。',
|
||||
stable: '安定版リリース',
|
||||
main: 'すべてのコミット',
|
||||
stableConfirmTitle: '安定版リリースに切り替えますか?',
|
||||
stableConfirmBody: 'Hermes は最新の公開リリースに移動します。現在のコードより古い場合があります。新しいコードが書き込んだデータは正しく読めない可能性があるため、先にバックアップしてください。',
|
||||
stableConfirm: '安定版に切り替え',
|
||||
failed: 'アップデートチャンネルを変更できませんでした'
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ruOnboarding } from './ru_onboarding'
|
||||
import { ruPluginSettings } from './ru_plugins'
|
||||
import { ruProjects } from './ru_projects'
|
||||
import { ruSharedMetrics } from './ru_shared_metrics'
|
||||
import { ruUpdateChannel } from './ru_update_channel'
|
||||
|
||||
// RU_PLURAL: (count, one, few, many) — русские формы сущ. падежа
|
||||
// RU_NOUN: (count, one, few, many) — формы род. множественного
|
||||
@@ -943,9 +944,7 @@ export const ruOverrides = {
|
||||
'Когда Hermes обновляет себя из приложения (без запроса в терминале), сохранять локальные правки исходников (stash) или выбрасывать (discard). Обновления из терминала всегда спрашивают.'
|
||||
}
|
||||
}),
|
||||
about: {
|
||||
updates: 'Обновления'
|
||||
},
|
||||
about: { updates: 'Обновления', channel: ruUpdateChannel },
|
||||
config: {
|
||||
minimizeToTrayTitle: 'Сворачивать в трей',
|
||||
minimizeToTrayDesc:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const ruUpdateChannel = {
|
||||
title: 'Канал обновлений',
|
||||
description: 'Стабильный устанавливает только опубликованные релизы. «Каждый коммит» следует за веткой main по мере изменений.',
|
||||
stable: 'Стабильные релизы',
|
||||
main: 'Каждый коммит',
|
||||
stableConfirmTitle: 'Перейти на стабильные релизы?',
|
||||
stableConfirmBody: 'Hermes перейдёт на последний опубликованный релиз, который может быть старше текущего кода. Данные, записанные более новым кодом, могут читаться неправильно, поэтому сначала сделайте резервную копию.',
|
||||
stableConfirm: 'Перейти на стабильный',
|
||||
failed: 'Не удалось изменить канал обновлений'
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import type { OnboardingTranslations } from './types_onboarding'
|
||||
import type { SidebarProjectsTranslations } from './types_projects'
|
||||
import type { SharedMetricsTranslations } from './types_shared_metrics'
|
||||
import type { UninstallSectionTranslations } from './types_uninstall_section'
|
||||
import type { UpdateChannelCopy } from './types_update_channel'
|
||||
|
||||
/** The locales compiled into the app (`TRANSLATIONS`). */
|
||||
export type BundledLocale = 'en' | 'zh' | 'zh-hant' | 'ja' | 'ar' | 'ru' | 'fr' | 'de' | 'es'
|
||||
@@ -996,9 +997,7 @@ export interface Translations extends NoticeTranslations {
|
||||
screenRecording: string
|
||||
driverHealth: string
|
||||
}
|
||||
about: {
|
||||
updates: string
|
||||
}
|
||||
about: { updates: string; channel: UpdateChannelCopy }
|
||||
config: {
|
||||
minimizeToTrayTitle: string
|
||||
minimizeToTrayDesc: string
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** About ▸ Updates: the source checkout's update channel selector. */
|
||||
export interface UpdateChannelCopy {
|
||||
title: string
|
||||
description: string
|
||||
stable: string
|
||||
main: string
|
||||
stableConfirmTitle: string
|
||||
stableConfirmBody: string
|
||||
stableConfirm: string
|
||||
failed: string
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
|
||||
import type { TranslationOverrides } from './define-locale'
|
||||
import { zhHantUpdateChannel } from './zh-hant_update_channel'
|
||||
|
||||
export const zhHantSettings = {
|
||||
language: {
|
||||
@@ -756,7 +757,8 @@ export const zhHantSettings = {
|
||||
driverHealth: '驅動程式健康狀態'
|
||||
},
|
||||
about: {
|
||||
updates: '更新'
|
||||
updates: '更新',
|
||||
channel: zhHantUpdateChannel
|
||||
},
|
||||
config: {
|
||||
minimizeToTrayTitle: '最小化至系統匣',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const zhHantUpdateChannel = {
|
||||
title: '更新頻道',
|
||||
description: '穩定版只安裝已發布的版本。每個提交會隨 main 分支的變更持續更新。',
|
||||
stable: '穩定版發布',
|
||||
main: '每個提交',
|
||||
stableConfirmTitle: '切換到穩定版發布?',
|
||||
stableConfirmBody: 'Hermes 將切換到最新的已發布版本,它可能比你目前執行的程式碼更舊。較新程式碼寫入的資料可能無法正確讀取,請先備份。',
|
||||
stableConfirm: '切換到穩定版',
|
||||
failed: '無法變更更新頻道'
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { zhNotices } from './zh_notices'
|
||||
import { zhOnboarding } from './zh_onboarding'
|
||||
import { zhProjects } from './zh_projects'
|
||||
import { zhSharedMetrics } from './zh_shared_metrics'
|
||||
import { zhUpdateChannel } from './zh_update_channel'
|
||||
|
||||
export const zhOverrides = {
|
||||
externalOpenFailed: {
|
||||
@@ -1263,9 +1264,7 @@ export const zhOverrides = {
|
||||
screenRecording: '屏幕录制',
|
||||
driverHealth: '驱动健康状态'
|
||||
},
|
||||
about: {
|
||||
updates: '更新'
|
||||
},
|
||||
about: { updates: '更新', channel: zhUpdateChannel },
|
||||
config: {
|
||||
minimizeToTrayTitle: '最小化到托盘',
|
||||
minimizeToTrayDesc:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const zhUpdateChannel = {
|
||||
title: '更新渠道',
|
||||
description: '稳定版只安装已发布的版本。每个提交会随 main 分支的变更持续更新。',
|
||||
stable: '稳定版发布',
|
||||
main: '每个提交',
|
||||
stableConfirmTitle: '切换到稳定版发布?',
|
||||
stableConfirmBody: 'Hermes 将切换到最新的已发布版本,它可能比你当前运行的代码更旧。较新代码写入的数据可能无法正确读取,请先备份。',
|
||||
stableConfirm: '切换到稳定版',
|
||||
failed: '无法更改更新渠道'
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { DesktopUpdateStatus } from '@/global'
|
||||
|
||||
import { sourceUpdateChannel } from './updates'
|
||||
|
||||
const status = (fields: Partial<DesktopUpdateStatus>): DesktopUpdateStatus => ({
|
||||
supported: true,
|
||||
mechanism: 'posix-handoff',
|
||||
channelSelectable: true,
|
||||
...fields
|
||||
})
|
||||
|
||||
describe('sourceUpdateChannel', () => {
|
||||
it('offers the selector only where the choice is stable or main and can be saved', () => {
|
||||
expect(sourceUpdateChannel(status({ channel: 'stable' }))).toBe('stable')
|
||||
expect(sourceUpdateChannel(status({ mechanism: 'windows-handoff', branch: 'main' }))).toBe('main')
|
||||
// Packages bake their channel; a preview channel is not the user's to flip here.
|
||||
for (const mechanism of ['electron-updater', 'app-installer', 'microsoft-store', 'external'] as const) {
|
||||
expect(sourceUpdateChannel(status({ mechanism, channel: 'stable' }))).toBeNull()
|
||||
}
|
||||
|
||||
expect(sourceUpdateChannel(status({ channel: 'pm-preview' }))).toBeNull()
|
||||
expect(sourceUpdateChannel(status({ supported: false, channel: 'stable' }))).toBeNull()
|
||||
expect(sourceUpdateChannel(null)).toBeNull()
|
||||
// A runtime older than --set-channel answers checks but cannot save a channel.
|
||||
expect(sourceUpdateChannel(status({ channel: 'stable', channelSelectable: undefined }))).toBeNull()
|
||||
// A custom branch is neither stable releases nor every commit on main.
|
||||
expect(sourceUpdateChannel(status({ branch: 'feature/gui' }))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -637,6 +637,51 @@ export async function checkUpdates({ force = false }: UpdateCheckOptions = {}):
|
||||
}
|
||||
}
|
||||
|
||||
export type SourceUpdateChannel = 'main' | 'stable'
|
||||
|
||||
/**
|
||||
* The selectable channel of a source checkout, or null where the selector must not
|
||||
* appear: packages own their channel, runtimes older than the flag cannot save one,
|
||||
* and a custom branch is neither stable releases nor every commit on main.
|
||||
*/
|
||||
export function sourceUpdateChannel(status: DesktopUpdateStatus | null): SourceUpdateChannel | null {
|
||||
const source = status?.mechanism === 'posix-handoff' || status?.mechanism === 'windows-handoff'
|
||||
|
||||
if (!status?.supported || !source || !status.channelSelectable) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (status.channel === 'stable') {
|
||||
return 'stable'
|
||||
}
|
||||
|
||||
return status.channel === undefined && status.branch === 'main' ? 'main' : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the source checkout's channel; the reply is a fresh check against it.
|
||||
* An unreachable release still returns a status naming the saved channel.
|
||||
*/
|
||||
export async function setUpdateChannel(channel: SourceUpdateChannel): Promise<DesktopUpdateStatus> {
|
||||
const setChannel = window.hermesDesktop?.updates?.setChannel
|
||||
|
||||
if (!setChannel) {
|
||||
throw new Error('This installation cannot change its update channel.')
|
||||
}
|
||||
|
||||
$updateChecking.set(true)
|
||||
|
||||
try {
|
||||
const status = await setChannel(channel)
|
||||
$updateStatus.set(status)
|
||||
maybeNotifyUpdateAvailable(status, 'client')
|
||||
|
||||
return status
|
||||
} finally {
|
||||
$updateChecking.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promise<DesktopUpdateApplyResult> {
|
||||
if ($updateStatus.get()?.retirement) {
|
||||
openUpdateOverlayFor('client')
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Bonorinoa
|
||||
# PR #135902 catalog sweep 1010
|
||||
@@ -0,0 +1,2 @@
|
||||
Dezooyi
|
||||
# PR #135216 catalog sweep 1010
|
||||
@@ -0,0 +1,2 @@
|
||||
FeiyouG
|
||||
# PR #113661 catalog sweep 1010
|
||||
@@ -0,0 +1,2 @@
|
||||
FeiyouG
|
||||
# PR #113661 catalog sweep 1010
|
||||
@@ -0,0 +1,2 @@
|
||||
Agantila
|
||||
# PR #135216 catalog sweep 1010
|
||||
@@ -0,0 +1,2 @@
|
||||
Lokeshm24
|
||||
# PR #135598 catalog sweep 1010
|
||||
@@ -0,0 +1 @@
|
||||
obierlaire
|
||||
@@ -0,0 +1,2 @@
|
||||
ottosulin
|
||||
# PR #135813 catalog sweep 1010
|
||||
@@ -0,0 +1,2 @@
|
||||
loveRhythm1990
|
||||
# PR #135490 catalog sweep 1010
|
||||
@@ -0,0 +1 @@
|
||||
Kesuek
|
||||
@@ -1 +1,2 @@
|
||||
Kesuek
|
||||
# PR #134804 catalog: add iowap (IOWAP fleet plugin)
|
||||
@@ -0,0 +1,2 @@
|
||||
thepetenetwork
|
||||
# PR #135829 catalog sweep 1010
|
||||
@@ -0,0 +1,2 @@
|
||||
IT-dreamer
|
||||
# PR #135912 catalog sweep 1010
|
||||
+1
-1
@@ -254,7 +254,7 @@ async def run_codex_hygiene_compaction(
|
||||
# copy_context carries profile secret scope / HERMES_HOME override (executors don't propagate ContextVars).
|
||||
worker_future = asyncio.get_running_loop().run_in_executor(
|
||||
None, copy_context().run,
|
||||
lambda: agent._compress_context(history, "", approx_tokens=approx_tokens, task_id=session_id or "default"))
|
||||
lambda: agent._compress_context(history, "", approx_tokens=approx_tokens, task_id=session_id or "default", trigger="gateway_hygiene"))
|
||||
track_worker = getattr(gateway, "_track_deferred_agent_worker", None)
|
||||
if callable(track_worker):
|
||||
# ``wait_for`` only cancels the asyncio wrapper; keep the running executor thread visible to shutdown.
|
||||
|
||||
@@ -734,7 +734,7 @@ class GatewayTurnHygieneMixin:
|
||||
# task_id = the live turn's dedup bucket (session row id), never "" (= reset every task).
|
||||
lambda: _hyg_agent._compress_context(
|
||||
_hyg_msgs, "", approx_tokens=plan.approx_tokens, commit_fence=_hyg_commit_fence,
|
||||
task_id=session_entry.session_id or "default",
|
||||
task_id=session_entry.session_id or "default", trigger="gateway_hygiene",
|
||||
),
|
||||
)
|
||||
# Register the live worker with shutdown NOW, not only once it is deferred: the default
|
||||
|
||||
@@ -76,6 +76,11 @@ def begin_compression_attempt(trigger: str, tokens_before: Any) -> None:
|
||||
_compression_attempt.pending = (trigger, tokens_before)
|
||||
|
||||
|
||||
def discard_compression_attempt() -> None:
|
||||
"""Clear this thread's pending attempt without recording a shared metric."""
|
||||
_compression_attempt.pending = None
|
||||
|
||||
|
||||
def finish_compression_attempt(
|
||||
commit_status: str, failure_class: str | None, context_length: Any, agent: Any = None,
|
||||
) -> None:
|
||||
|
||||
@@ -118,6 +118,10 @@ def setup_completed_fields(*, surface: Any, provider: Any) -> dict[str, str]:
|
||||
|
||||
_COMPRESSION_TRIGGERS = {
|
||||
**dict.fromkeys(("auto", "threshold", "preflight", "auto_threshold"), "auto"),
|
||||
# The attempt log's precise automatic triggers stay one coarse bucket here.
|
||||
**dict.fromkeys(
|
||||
("idle", "turn_start_threshold", "engine_preflight", "pre_api", "post_tool", "gateway_hygiene"), "auto",
|
||||
),
|
||||
**dict.fromkeys(("overflow", "context_overflow", "overflow_error", "error"), "overflow"),
|
||||
**dict.fromkeys(("manual", "user", "command", "slash"), "manual"),
|
||||
}
|
||||
|
||||
@@ -84,12 +84,13 @@ def plugin_skills(plugin_key: str) -> list[dict[str, str]]:
|
||||
|
||||
|
||||
def live_notice(activation: dict[str, Any]) -> str:
|
||||
"""The note an open chat gets on its next turn: connected MCP servers with their tools, skills,
|
||||
and what waits for the next session. Empty when nothing became usable."""
|
||||
live = activation.get("live_now") or {}
|
||||
servers, skills = live.get("mcp_servers") or [], live.get("skills") or []
|
||||
"""The note an open chat gets on its next turn: connected MCP servers with their tools and what
|
||||
waits for the next session. Empty without servers. The plugin's skills are not listed here: every
|
||||
turn announces any skill the chat's system prompt does not list (``agent/skills_index_delta.py``),
|
||||
whichever way it arrived."""
|
||||
servers = (activation.get("live_now") or {}).get("mcp_servers") or []
|
||||
deferred = activation.get("deferred") or {}
|
||||
if not servers and not skills:
|
||||
if not servers:
|
||||
return ""
|
||||
lines = [f"[plugin installed: {activation.get('name') or activation.get('key')}]"]
|
||||
connected = [s for s in servers if s.get("connected")]
|
||||
@@ -102,10 +103,6 @@ def live_notice(activation: dict[str, Any]) -> str:
|
||||
lines.extend(f" {line}" for line in listing.splitlines())
|
||||
for server in (s for s in servers if not s.get("connected")):
|
||||
lines.append(f"- {server['name']}: not connected ({server.get('error') or 'unknown error'})")
|
||||
if skills:
|
||||
lines.append("Skills now available; load one with skill_view:")
|
||||
lines.extend(f"- {s['name']}: {s['description']}" if s.get("description") else f"- {s['name']}"
|
||||
for s in skills)
|
||||
later = [f"{len(deferred['tools'])} Python tools" if deferred.get("tools") else "",
|
||||
f"{len(deferred['prompt'])} prompt sections" if deferred.get("prompt") else ""]
|
||||
if any(later):
|
||||
|
||||
@@ -74,7 +74,10 @@ def relay_plugin_payload_from_legacy_env(env: Mapping[str, Any]) -> dict[str, An
|
||||
mode="overwrite" if mode == "overwrite" else "append",
|
||||
)
|
||||
atof = obs.AtofConfig(enabled=True, sinks=[sink])
|
||||
spec = obs.ComponentSpec(config=obs.ObservabilityConfig(atif=atif, atof=atof))
|
||||
# Relay 0.10 auto-exports gen_ai traces (prompt/response content) to any OTEL_EXPORTER_OTLP_*
|
||||
# endpoint in the process env; the legacy vars never exported OTLP, so keep that off.
|
||||
otel_off = obs.OpenTelemetrySectionConfig(enabled=False)
|
||||
spec = obs.ComponentSpec(config=obs.ObservabilityConfig(atif=atif, atof=atof, opentelemetry=otel_off))
|
||||
return {"version": 1, "components": [spec.to_dict()]}
|
||||
|
||||
|
||||
|
||||
@@ -267,14 +267,15 @@ def _write_cache(cache_file: Path, identity: dict, now: float, result: dict) ->
|
||||
logger.debug("Could not cache source check: %s", exc)
|
||||
|
||||
|
||||
def _resolve_channel(result: dict, channel: str, co: _Checkout):
|
||||
def _resolve_channel(result: dict, channel: str, co: _Checkout, *, forward_only: bool = False):
|
||||
"""Resolve a release channel's target into ``result``; the SourceTarget, or None on error.
|
||||
|
||||
A target with a pinned commit is final; one without names a branch to follow instead.
|
||||
"""
|
||||
try:
|
||||
source_target = resolve_source_target(channel, [co.git] if not co.embedded else None, co.root,
|
||||
repository=co.repository or OFFICIAL_REPOSITORY)
|
||||
repository=co.repository or OFFICIAL_REPOSITORY,
|
||||
forward_only=forward_only)
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||
result.update(error="release-unavailable", message=f"Could not resolve the {channel} source channel: {exc}")
|
||||
return None
|
||||
@@ -382,7 +383,7 @@ def check_for_updates(*, install_root: Path | None = None, home: Path | None = N
|
||||
"""
|
||||
from hermes_cli.config import get_project_root, require_readable_config_before_write
|
||||
from hermes_cli.steward import read_install_stamp
|
||||
from hermes_cli.update_channel import install_id, resolve_update_channel
|
||||
from hermes_cli.update_channel import channel_record, install_id, resolve_update_channel, rides_default_channel
|
||||
from hermes_cli.release_channels import validate_name
|
||||
|
||||
embedded = (os.environ.get("HERMES_REVISION") or None) if install_root is None else None
|
||||
@@ -397,6 +398,7 @@ def check_for_updates(*, install_root: Path | None = None, home: Path | None = N
|
||||
if passive and (config.get("updates") or {}).get("check") is False:
|
||||
return {**result, "reason": "disabled"}
|
||||
channel = resolve_update_channel(config, root) if channel is None else validate_name(channel)
|
||||
forward_only = rides_default_channel(channel_record(config, root), channel, root)
|
||||
co = _read_checkout(root, git, embedded)
|
||||
desktop_config = _read_json(branch_config_path) if branch_config_path else None
|
||||
configured_branch = _configured_branch(desktop_config)
|
||||
@@ -407,7 +409,8 @@ def check_for_updates(*, install_root: Path | None = None, home: Path | None = N
|
||||
else:
|
||||
result["branch"] = selected_branch
|
||||
identity = {"root": str(root), "home": str(home), "head": co.head, "origin": co.origin, "branch": selected_branch,
|
||||
"channel": channel, "embedded": embedded, "branchOverride": branch is not None, "channelProtocol": 1}
|
||||
"channel": channel, "embedded": embedded, "branchOverride": branch is not None, "channelProtocol": 1,
|
||||
"forwardOnly": forward_only}
|
||||
cache_file = Path(cache_path) if cache_path is not None else home / "source-checks" / f"{install_id(root)}.json"
|
||||
now = time.time()
|
||||
cached = None if force else _cached_status(cache_file, identity, now)
|
||||
@@ -418,7 +421,7 @@ def check_for_updates(*, install_root: Path | None = None, home: Path | None = N
|
||||
if not _is_full_sha(co.head):
|
||||
result.update(error="head-unavailable", message="Could not read the installed revision.")
|
||||
elif branch is None:
|
||||
source_target = _resolve_channel(result, channel, co)
|
||||
source_target = _resolve_channel(result, channel, co, forward_only=forward_only)
|
||||
if source_target is not None and not source_target.commit:
|
||||
# The record supplies a default, not permission to leave the user's branch.
|
||||
selected_branch = configured_branch or _checked_out_branch(co.current_branch, source_target.branch)
|
||||
@@ -445,9 +448,22 @@ def main() -> None:
|
||||
parser.add_argument("--cache-path", type=Path)
|
||||
parser.add_argument("--branch-config-path", type=Path)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
parser.add_argument("--set-channel", type=validate_name,
|
||||
help="Persist this install's channel (the Desktop selector), then report as usual.")
|
||||
args = parser.parse_args()
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
result = check_for_updates(**vars(args))
|
||||
if args.set_channel:
|
||||
from hermes_constants import set_hermes_home_override
|
||||
from hermes_cli.update_channel import set_install_channel
|
||||
|
||||
# The record belongs in the --home profile's config.yaml, as the check reads it.
|
||||
set_hermes_home_override(args.home)
|
||||
set_install_channel(args.set_channel, args.install_root)
|
||||
args.force = True
|
||||
result = check_for_updates(**{k: v for k, v in vars(args).items() if k != "set_channel"})
|
||||
if isinstance(result, dict):
|
||||
# Older runtimes reject --set-channel; Desktop offers its selector only on this flag.
|
||||
result["channelSelectable"] = True
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
|
||||
@@ -54,8 +54,13 @@ class SourceTarget:
|
||||
def retired(self) -> bool:
|
||||
return self.requested_channel != self.channel
|
||||
|
||||
ahead: bool = False
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
if self.ahead:
|
||||
return (f"{self.channel} v{self.version}; this checkout is newer and waits for the next release"
|
||||
" (`hermes update --set-channel main` follows every commit)")
|
||||
return f"{self.channel} v{self.version} ({self.commit[:12]})" if self.commit else self.channel
|
||||
|
||||
|
||||
@@ -72,12 +77,56 @@ def _resolve_channel(name: str, repository: str):
|
||||
return ChannelReader(_PUBLIC_BASE, repository=repository).resolve(name)
|
||||
|
||||
|
||||
def resolve_source_target(channel: str, git_cmd=None, cwd=None, *, repository=None) -> SourceTarget:
|
||||
"""Resolve every subscription, including default labels, through R2."""
|
||||
def resolve_source_target(channel: str, git_cmd=None, cwd=None, *, repository=None,
|
||||
forward_only: bool = False) -> SourceTarget:
|
||||
"""Resolve every subscription, including default labels, through R2.
|
||||
|
||||
``forward_only`` (an unchosen default subscription) pins a checkout that already
|
||||
contains the release to its own HEAD, so the update is a no-op instead of a downgrade.
|
||||
"""
|
||||
target = _resolve_source_target(channel, git_cmd, cwd, repository=repository)
|
||||
if forward_only and target.commit and git_cmd is not None and cwd is not None:
|
||||
head = _head_containing(git_cmd, cwd, target.commit, target.repository)
|
||||
if head is not None and head != target.commit:
|
||||
from dataclasses import replace
|
||||
|
||||
return replace(target, commit=head, ahead=True)
|
||||
return target
|
||||
|
||||
|
||||
def _head_containing(git_cmd, cwd, commit: str, repository: str) -> str | None:
|
||||
"""HEAD's sha unless HEAD is PROVEN not to contain ``commit``, then None.
|
||||
|
||||
An unchosen default must never move a checkout backward on a guess, so an
|
||||
unknown relation (shallow history, GitHub unreachable) keeps HEAD.
|
||||
"""
|
||||
from hermes_cli.source_check import _github_compare, source_git_env
|
||||
|
||||
def run(*args):
|
||||
return subprocess.run(
|
||||
[*git_cmd, *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace", timeout=10, stdin=subprocess.DEVNULL, env=source_git_env())
|
||||
|
||||
head = run("rev-parse", "HEAD").stdout.strip()
|
||||
if not _SHA.fullmatch(head):
|
||||
return None
|
||||
ancestry = run("merge-base", "--is-ancestor", commit, head).returncode
|
||||
if ancestry == 0:
|
||||
return head
|
||||
# A shallow boundary makes exit 1 a guess; full history makes it proof.
|
||||
if ancestry == 1 and run("rev-parse", "--is-shallow-repository").stdout.strip() == "false":
|
||||
return None
|
||||
status = (_github_compare(commit, head, repository) or {}).get("status")
|
||||
return None if status in ("behind", "diverged") else head
|
||||
|
||||
|
||||
def _resolve_source_target(channel: str, git_cmd=None, cwd=None, *, repository=None) -> SourceTarget:
|
||||
from hermes_cli.release_channels import ChannelNotFound, validate_name
|
||||
|
||||
validate_name(channel)
|
||||
repository = repository or source_repository(git_cmd, cwd)
|
||||
if channel == "stable":
|
||||
return _resolve_stable(repository, git_cmd, cwd)
|
||||
try:
|
||||
resolved = _resolve_channel(channel, repository)
|
||||
except ChannelNotFound:
|
||||
@@ -110,6 +159,17 @@ def resolve_source_target(channel: str, git_cmd=None, cwd=None, *, repository=No
|
||||
version=request["sourceVersion"], build_id=request["buildId"])
|
||||
|
||||
|
||||
def _resolve_stable(repository: str, git_cmd=None, cwd=None) -> SourceTarget:
|
||||
"""Stable IS the repository's latest published GitHub release: the tag a release
|
||||
publishes (non-draft, non-prerelease, strict vX.Y.Z), verified against origin.
|
||||
No R2 record or pointer is consulted; publishing the release is the only promotion.
|
||||
"""
|
||||
tag, commit = resolve_source_release("stable", git_cmd, cwd, repository=repository, pointer=False)
|
||||
if commit is None:
|
||||
raise ValueError("No published stable release could be verified")
|
||||
return SourceTarget("stable", "stable", repository, commit=commit, version=str(tag).removeprefix("v"))
|
||||
|
||||
|
||||
def _refuse_retirement_downgrade(request: dict, terminal: dict, git_cmd, cwd) -> None:
|
||||
"""Qualification of preview data is not permission to roll back newer source."""
|
||||
from pathlib import Path
|
||||
@@ -240,11 +300,13 @@ def _release_pointer(channel: str) -> tuple[str | None, str | None]:
|
||||
return page.tags[0], None
|
||||
|
||||
|
||||
def resolve_source_release(channel: str, git_cmd=None, cwd=None, *, repository=None) -> tuple[str | None, str | None]:
|
||||
"""Read historical stable/canary release metadata (not channel discovery).
|
||||
def resolve_source_release(channel: str, git_cmd=None, cwd=None, *, repository=None,
|
||||
pointer: bool = True) -> tuple[str | None, str | None]:
|
||||
"""Read published stable/canary release metadata (not channel discovery).
|
||||
|
||||
Runtime check/apply use ``resolve_source_target`` and never fall back here.
|
||||
Channel pointers outrank GitHub's release listing. A malformed pointer,
|
||||
The source updater's ``stable`` reads it with ``pointer=False``: GitHub's latest
|
||||
published release only. With ``pointer`` the R2 channel pointers outrank GitHub's
|
||||
release listing. A malformed pointer,
|
||||
draft, or tag/commit mismatch is not permission to select a different build.
|
||||
``git_cmd`` resolves the selected tag on origin; ZIP callers omit it and
|
||||
resolve the same tag through GitHub's commit endpoint.
|
||||
@@ -255,7 +317,7 @@ def resolve_source_release(channel: str, git_cmd=None, cwd=None, *, repository=N
|
||||
repository = repository or source_repository(git_cmd, cwd)
|
||||
base = f"https://api.github.com/repos/{repository}"
|
||||
tag, pinned_sha = (_release_pointer(channel)
|
||||
if repository.lower() == OFFICIAL_REPOSITORY.lower() else (None, None))
|
||||
if pointer and repository.lower() == OFFICIAL_REPOSITORY.lower() else (None, None))
|
||||
if tag is None:
|
||||
release = _published_fallback(channel, base)
|
||||
tag = release["tag_name"]
|
||||
|
||||
@@ -81,9 +81,10 @@ def build_update_parser(subparsers, *, cmd_update: Callable) -> None:
|
||||
help=(
|
||||
"Persist the update channel for THIS install (recorded per "
|
||||
"install in config.yaml under update.installs). Names are resolved "
|
||||
"from the release archive, not a built-in list. Source installs "
|
||||
"check out the published build's exact commit; main follows the "
|
||||
"source branch. Package channels are baked into their identities."
|
||||
"from the release archive, not a built-in list. Official source "
|
||||
"checkouts default to 'stable' (the latest published vX.Y.Z "
|
||||
"release, exact commit); 'main' follows the source branch. "
|
||||
"Package channels are baked into their identities."
|
||||
),
|
||||
)
|
||||
update_parser.add_argument(
|
||||
|
||||
@@ -22,6 +22,7 @@ same path, and the channel opt-in must survive that.
|
||||
(it knows its own id — the user never types a sha).
|
||||
* Shown by ``hermes update --install-id`` and the desktop About page.
|
||||
* Source installs select an R2 channel name, or use an explicit branch override.
|
||||
Unconfigured official checkouts track ``stable``; forks and mirrors keep ``main``.
|
||||
Bundles derive their channel from their baked identity, never these records.
|
||||
``external`` installs have no configurable channel; the steward owns updates.
|
||||
|
||||
@@ -139,7 +140,8 @@ def _package_channel(stamp: dict) -> bool:
|
||||
def default_channel(project_root: Optional[Path] = None) -> str:
|
||||
"""The channel an unconfigured install tracks.
|
||||
|
||||
``self`` source installs follow main (historical behavior).
|
||||
A source checkout of the official repository follows the latest published
|
||||
stable release. Forks, mirrors and checkout-less trees keep following main.
|
||||
``electron-updater`` and ``app-installer`` bundles report their artifact
|
||||
channel: a canary artifact tracks canary, every other bundle
|
||||
tracks stable. The stamp's ``tag`` is the authority, the same fact
|
||||
@@ -153,7 +155,7 @@ def default_channel(project_root: Optional[Path] = None) -> str:
|
||||
root = Path(project_root) if project_root is not None else install_root()
|
||||
stamp = _read_stamp(root)
|
||||
if not _package_channel(stamp):
|
||||
return CHANNEL_MAIN
|
||||
return CHANNEL_STABLE if _tracks_official_origin(root) else CHANNEL_MAIN
|
||||
if stamp.get("source") == "channel-build":
|
||||
request = stamp.get("channelBuild")
|
||||
if not isinstance(request, dict):
|
||||
@@ -162,6 +164,42 @@ def default_channel(project_root: Optional[Path] = None) -> str:
|
||||
return CHANNEL_CANARY if is_canary_tag(stamp.get("tag")) else CHANNEL_STABLE
|
||||
|
||||
|
||||
def _tracks_official_origin(root: Path) -> bool:
|
||||
"""True when ``root`` is a git checkout whose ``remote.origin.url`` is the official repo.
|
||||
|
||||
Read from the git config file (no git process: the banner and boot paths call this).
|
||||
Forks, mirrors and local-path origins publish no official releases.
|
||||
"""
|
||||
from hermes_cli.source_releases import _GITHUB_ORIGIN, OFFICIAL_REPOSITORY
|
||||
from hermes_cli.update_lock import _git_common_dir
|
||||
|
||||
common = _git_common_dir(root)
|
||||
try:
|
||||
lines = (common / "config").read_text(encoding="utf-8-sig").splitlines() if common else []
|
||||
except OSError:
|
||||
return False
|
||||
in_origin = False
|
||||
for line in lines:
|
||||
text = line.strip()
|
||||
if text.startswith("["):
|
||||
in_origin = text == '[remote "origin"]'
|
||||
elif in_origin and text.partition("=")[0].strip() == "url":
|
||||
match = _GITHUB_ORIGIN.fullmatch(text.partition("=")[2].strip())
|
||||
return bool(match) and match[1].lower() == OFFICIAL_REPOSITORY.lower()
|
||||
return False
|
||||
|
||||
|
||||
def rides_default_channel(record: dict, channel: str, project_root: Optional[Path] = None) -> bool:
|
||||
"""True when no channel was ever persisted (``record``) and ``channel`` is the default.
|
||||
|
||||
Such an install never consented to a release switch, so it only moves forward: a
|
||||
checkout already newer than the latest release waits for the next one. A transient
|
||||
``--channel`` naming the default (the Desktop passes the channel its check reported)
|
||||
is still the default; only ``--set-channel`` consents to a downgrade.
|
||||
"""
|
||||
return record.get("channel") is None and channel == default_channel(project_root)
|
||||
|
||||
|
||||
def resolve_update_channel(
|
||||
config: Optional[dict] = None,
|
||||
project_root: Optional[Path] = None,
|
||||
|
||||
+14
-45
@@ -1859,47 +1859,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
had_desktop_app_before_update, gateway_mode)
|
||||
branch = _m()._resolve_update_branch(args)
|
||||
completion_request["branch"] = branch
|
||||
target_ref = f"origin/{branch}"
|
||||
release_sha = None
|
||||
target_repository = None
|
||||
selected_channel = _update_run_channel(args)
|
||||
if not getattr(args, "branch", None):
|
||||
from hermes_cli.release_channels import retrying_reads
|
||||
from hermes_cli.source_releases import resolve_source_target
|
||||
|
||||
from copy import deepcopy
|
||||
from hermes_cli.config import require_readable_config_before_write
|
||||
from hermes_cli.update_channel import channel_record
|
||||
|
||||
original_record = deepcopy(channel_record(require_readable_config_before_write(
|
||||
Path(completion_request["home"]) / "config.yaml"), _m().PROJECT_ROOT))
|
||||
print(f"→ Update channel: {selected_channel}")
|
||||
try:
|
||||
with retrying_reads():
|
||||
target = resolve_source_target(
|
||||
selected_channel, None if use_zip_update else git_cmd, _m().PROJECT_ROOT)
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||
print(f"✗ Could not resolve the {selected_channel} source channel: {exc}. No update was applied.")
|
||||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||||
_record_stop("channel_unresolved")
|
||||
sys.exit(1)
|
||||
if target.retired:
|
||||
print(f"→ {selected_channel} retired; source destination: {target.channel}")
|
||||
if (not getattr(args, "channel", None)
|
||||
and original_record.get("channel", "main") == selected_channel):
|
||||
completion_request["channel_retirement"] = {
|
||||
"original": original_record, "destination": target.channel}
|
||||
target_repository = target.repository
|
||||
release_sha = target.commit
|
||||
if release_sha:
|
||||
print(f"→ Latest release: {target.label}")
|
||||
target_ref = release_sha
|
||||
completion_request["expected_sha"] = release_sha
|
||||
else:
|
||||
assert target.branch is not None # a SourceTarget without a commit names its branch
|
||||
branch = target.branch
|
||||
completion_request["branch"] = branch
|
||||
target_ref = f"origin/{branch}"
|
||||
target_ref, release_sha, target_is_head, target_repository = _check.select_apply_target(
|
||||
args, branch, completion_request, git_cmd=None if use_zip_update else git_cmd,
|
||||
stop=lambda: _m()._resume_windows_gateways_after_update(_windows_gateway_resume))
|
||||
branch = completion_request["branch"]
|
||||
|
||||
if use_zip_update:
|
||||
try:
|
||||
@@ -1934,15 +1897,21 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# (#123346, #124645). Unshallow it (commits only) first.
|
||||
_heal_stale_shallow_checkout(_m().PROJECT_ROOT, branch)
|
||||
|
||||
print("→ Fetching updates...")
|
||||
if release_sha:
|
||||
if release_sha and target_is_head:
|
||||
# Forward-only default: the checkout already contains the release; nothing to fetch.
|
||||
fetch_args = None
|
||||
elif release_sha:
|
||||
fetch_args = ["fetch", "--no-tags", "origin", target_ref]
|
||||
else:
|
||||
fetch_args = ["fetch", "origin", _check.tracking_refspec("origin", branch)]
|
||||
from hermes_cli.gitlock import fetch_with_partial_clone_recovery, is_partial_clone_pack_objects_crash
|
||||
# Marking the unmarked packs clears the git 2.53+ partial-clone pack-objects crash (#124272).
|
||||
fetch_result = fetch_with_partial_clone_recovery(
|
||||
lambda gc, a: _git_run(gc, a, network=True), git_cmd, fetch_args, _m().PROJECT_ROOT)
|
||||
if fetch_args is None:
|
||||
fetch_result = subprocess.CompletedProcess([], 0)
|
||||
else:
|
||||
print("→ Fetching updates...")
|
||||
fetch_result = fetch_with_partial_clone_recovery(
|
||||
lambda gc, a: _git_run(gc, a, network=True), git_cmd, fetch_args, _m().PROJECT_ROOT)
|
||||
if fetch_result.returncode != 0:
|
||||
if is_partial_clone_pack_objects_crash(fetch_result.stderr or ""):
|
||||
print("✗ git still crashed after marking this checkout's packs. See 'Fetch fails with"
|
||||
|
||||
@@ -66,13 +66,17 @@ def channel_compare_branch(selected_channel: str, git_cmd: list[str], root: Path
|
||||
"""Report a release-pinned channel's verdict, or return the branch to compare against.
|
||||
|
||||
``None`` means the verdict is printed (the channel pins a commit); exits 1 when the channel
|
||||
cannot be resolved.
|
||||
cannot be resolved. An install riding its default channel only ever moves forward.
|
||||
"""
|
||||
from hermes_cli.config import get_config_path, require_readable_config_before_write
|
||||
from hermes_cli.source_releases import resolve_source_target
|
||||
from hermes_cli.update_channel import channel_record, rides_default_channel
|
||||
|
||||
print(f"→ Update channel: {selected_channel}")
|
||||
record = channel_record(require_readable_config_before_write(get_config_path()), root)
|
||||
forward_only = rides_default_channel(record, selected_channel, root)
|
||||
try:
|
||||
target = resolve_source_target(selected_channel, git_cmd, root)
|
||||
target = resolve_source_target(selected_channel, git_cmd, root, forward_only=forward_only)
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||
print(f"✗ Could not resolve the {selected_channel} source channel: {exc}")
|
||||
sys.exit(1)
|
||||
@@ -80,7 +84,9 @@ def channel_compare_branch(selected_channel: str, git_cmd: list[str], root: Path
|
||||
return target.branch
|
||||
if target.retired:
|
||||
print(f"→ {selected_channel} retired; source destination: {target.channel}")
|
||||
if _uc()._capture_head_sha(git_cmd, root) == target.commit:
|
||||
if target.ahead:
|
||||
print(f"✓ No newer release: {target.label}.")
|
||||
elif _uc()._capture_head_sha(git_cmd, root) == target.commit:
|
||||
print(f"✓ Up to date with the latest release ({target.label}).")
|
||||
else:
|
||||
print(f"→ Selected release available: {target.label}")
|
||||
@@ -189,3 +195,47 @@ def report_rev_list_verdict(git_cmd: list[str], root: Path, compare_branch: str)
|
||||
from hermes_cli.config import recommended_update_command
|
||||
|
||||
print(f" Run '{recommended_update_command()}' to install.")
|
||||
|
||||
|
||||
def select_apply_target(args, branch: str, request: dict, *, git_cmd, stop) -> tuple:
|
||||
"""Resolve the update's target before any tree write: ``(target_ref, release_sha,
|
||||
target_is_head, repository)``; records branch/expected_sha/retirement on ``request``.
|
||||
|
||||
``target_is_head`` means an unchosen default subscription is already past the release,
|
||||
so HEAD itself is the target. Exits 1 (after ``stop()``) when the channel is unresolvable.
|
||||
"""
|
||||
from copy import deepcopy
|
||||
|
||||
from hermes_cli.config import require_readable_config_before_write
|
||||
from hermes_cli.release_channels import retrying_reads
|
||||
from hermes_cli.source_releases import resolve_source_target
|
||||
from hermes_cli.update_channel import channel_record, rides_default_channel
|
||||
from hermes_cli.update_cmd_common import _record_stop
|
||||
|
||||
if getattr(args, "branch", None):
|
||||
return f"origin/{branch}", None, False, None
|
||||
root = _uc()._m().PROJECT_ROOT
|
||||
selected = _uc()._update_run_channel(args)
|
||||
original = deepcopy(channel_record(require_readable_config_before_write(
|
||||
Path(request["home"]) / "config.yaml"), root))
|
||||
print(f"→ Update channel: {selected}")
|
||||
try:
|
||||
with retrying_reads():
|
||||
target = resolve_source_target(selected, git_cmd, root,
|
||||
forward_only=rides_default_channel(original, selected, root))
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||
print(f"✗ Could not resolve the {selected} source channel: {exc}. No update was applied.")
|
||||
stop()
|
||||
_record_stop("channel_unresolved")
|
||||
sys.exit(1)
|
||||
if target.retired:
|
||||
print(f"→ {selected} retired; source destination: {target.channel}")
|
||||
if not getattr(args, "channel", None) and original.get("channel", "main") == selected:
|
||||
request["channel_retirement"] = {"original": original, "destination": target.channel}
|
||||
if target.commit:
|
||||
print(f"→ {'Release' if target.ahead else 'Latest release'}: {target.label}")
|
||||
request["expected_sha"] = target.commit
|
||||
return target.commit, target.commit, target.ahead, target.repository
|
||||
assert target.branch is not None # a SourceTarget without a commit names its branch
|
||||
request["branch"] = target.branch
|
||||
return f"origin/{target.branch}", None, False, target.repository
|
||||
@@ -2514,6 +2514,14 @@
|
||||
"sessionImport.subtitle",
|
||||
"sessionImport.title",
|
||||
"sessionImport.you",
|
||||
"settings.about.channel.description",
|
||||
"settings.about.channel.failed",
|
||||
"settings.about.channel.main",
|
||||
"settings.about.channel.stable",
|
||||
"settings.about.channel.stableConfirm",
|
||||
"settings.about.channel.stableConfirmBody",
|
||||
"settings.about.channel.stableConfirmTitle",
|
||||
"settings.about.channel.title",
|
||||
"settings.about.updates",
|
||||
"settings.appearance.appActionsDesc",
|
||||
"settings.appearance.appActionsLeft",
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
name: alans-way-computer
|
||||
repo: https://github.com/capthvnsen/alans-way-agents
|
||||
sha: ce51733b66291e69f616db48ae8799c0de50db43
|
||||
sha: 78a7ddd781bbc7c1d9e13c0e24dc1a0847152553
|
||||
subdir: alans-way-computer
|
||||
version: "0.7.0"
|
||||
title: "Alan's Way Computer"
|
||||
description: "Computer-use provider for Hermes: the stock computer_use tool drives desktop apps on the user's computer through the Alan's Way app, and on the VM's own desktop when the computer is offline. Needs the alans-way plugin's workspace setup. Select it with computer_use.backend: alans-way-computer. Disclosure — starts the workspace router from the profile's mcp_servers.workspace_browser block (command and args from config.yaml, with a filtered environment plus HERMES_WORKSPACE_ALLOW_DESKTOP_ACTIONS=1, which lets this private router child serve desktop input) as a long-running node child per session. It talks to the router over stdio (app lists, element trees, screenshots and input actions). The router reaches the user's computer over SSH on the user's tailnet and falls back to the VM's desktop. The provider never takes the focused window, and every input action still goes through Hermes's computer_use approval gate (cua:*:background entries in command_allowlist, which alans-way's --allow-desktop-actions adds, skip the prompt). On Hermes 0.21.5, which has no provider API, it installs and does nothing."
|
||||
version: "0.21.0"
|
||||
title: "Alan's Way Plugin: Computer Use"
|
||||
description: "Computer-use provider for Hermes: the stock computer_use tool drives desktop apps on the user's computer through the Alan's Workspace app, and on the VM's own desktop when the computer is offline. Needs the Alan's Way Plugin's workspace setup. Select it with computer_use.backend: alans-way-computer (the Alan's Way Plugin's setup.sh sets this when it installs the provider; in the catalog flow it prints the command). Disclosure — starts the workspace router from the profile's mcp_servers.workspace_browser block (command and args from config.yaml, with a filtered environment plus HERMES_WORKSPACE_ALLOW_DESKTOP_ACTIONS=1, which lets this private router child serve desktop input) as a long-running node child per session. The filtered environment passes PATH, HOME, locale and OS basics, plus DISPLAY, XAUTHORITY and DBUS_SESSION_BUS_ADDRESS so the router's VM-desktop fallback finds the X display and session bus; no keys or tokens. It talks to the router over stdio (app lists, element trees, screenshots and input actions). The router reaches the user's computer over SSH on the user's tailnet and falls back to the VM's desktop, where it uses xdotool, a screenshot tool and the AT-SPI accessibility bus if they are installed (the provider installs none of them). The provider never takes the focused window on the user's computer, and every input action still goes through Hermes's computer_use approval gate (cua:*:background entries in command_allowlist, which the Alan's Way Plugin's --allow-desktop-actions adds, skip the prompt). The provider itself makes no network calls. On Hermes 0.21.5, which has no provider API, it installs and does nothing."
|
||||
maintainer: capthvnsen
|
||||
tier: community
|
||||
category: tools
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/capthvnsen/alans-way-agents/tree/ce51733b66291e69f616db48ae8799c0de50db43/alans-way-computer
|
||||
docs_url: https://github.com/capthvnsen/alans-way-agents/tree/78a7ddd781bbc7c1d9e13c0e24dc1a0847152553/alans-way-computer
|
||||
readme: true
|
||||
platforms: [linux, macos]
|
||||
known_issues:
|
||||
- "Needs a Hermes build with the pluggable computer-use API (merged to main after 0.21.5). On older Hermes the plugin installs and does nothing. Install alans-way first and run its workspace setup, which writes the workspace_browser block this provider reuses."
|
||||
- "Hermes asks for approval on every computer_use action type until it is granted. For an unattended Telegram bot, grant them once with Always in Telegram, or add cua:<action>:background entries to command_allowlist (see the README). Takes effect after a gateway restart."
|
||||
- "Runs only in the plugin host: it cannot load under plugins.isolation: host, because the provider hands Hermes a live router session."
|
||||
- "Typing without a target field is not available because the app never takes focus: the agent uses set_value on a field instead."
|
||||
- "Typing without a target field works only on the VM's own desktop, where it types at the focused window (still approval-gated). On the user's computer the app never takes focus, so the agent uses set_value on a field instead."
|
||||
- "The VM desktop path needs xdotool, a screenshot tool (scrot, maim or ImageMagick import), the AT-SPI bus and python3-gi on the VM. Without them those calls fail and the agent reports it; setup.sh warns about what is missing."
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
name: alans-way
|
||||
repo: https://github.com/capthvnsen/alans-way-agents
|
||||
sha: ce51733b66291e69f616db48ae8799c0de50db43
|
||||
sha: 78a7ddd781bbc7c1d9e13c0e24dc1a0847152553
|
||||
subdir: alans-way
|
||||
version: "0.7.0"
|
||||
title: "Alan's Way"
|
||||
description: "Idle check-ins from the primary bot of a Hermes gateway on a Linux machine or a macOS guest VM: when the bound Telegram chat has been quiet for two hours, during 08:00-22:00 in the user's timezone, the bot checks in once (does one safe step and reports it, suggests one, or asks one question), backing off when unanswered. The model tool can only make check-ins quieter; more often, resume and wider hours are operator-only via /proactivity or the CLI. After install, the workspace-setup skill walks the agent through a separate companion app and Tailscale setup on the user's computer (macOS, Windows or Linux); desktop input goes only through Hermes's approval-gated computer_use tool. Disclosure — once the gateway connects Telegram, a background thread checks every 60 seconds. When the bound Telegram DM has been quiet (2 h by default, doubling per unanswered check-in, 08:00-22:00 user time), it injects an internal user-role prompt into that chat (needs plugins.entries.alans-way.allow_gateway_injection, which setup sets), so the bot runs a turn on its own and may take one \"safe, reversible\" step; the user still approves tool calls in Telegram. Its pre_llm_call/post_llm_call hooks see every session's turns and store only the bound chat's timestamps in plugin state. On first load it reads $HERMES_HOME/companion/proactivity/proactivity.sqlite3 once, read-only. The workspace-setup skill has the agent clone this repo at the catalog pin and run setup.sh, which: installs long-running services (Linux systemd units hermes-alans-way-chromium/-browser with loginctl enable-linger and a mac-watch.service with Restart=always; a KeepAlive LaunchAgent on a macOS guest; logon Scheduled Tasks on Windows); adds a TimeoutStartSec=180 drop-in to the hermes-gateway unit and restarts the gateway from a transient systemd-run timer; appends the user's computer's public key to ~/.ssh/authorized_keys (administrators_authorized_keys on a Windows admin account), pins its host key in known_hosts and adds a ControlMaster block to ~/.ssh/config, for Tailscale hosts only; for the main profile and every profile with its own Telegram bot, writes a managed mcp_servers.workspace_browser block (removing other entries that run this router, with a backup), turns off the built-in browser toolset for Telegram and cron, and turns computer_use back on for Telegram and cron; and reads each profile's TELEGRAM_BOT_TOKEN only to derive the numeric bot id. The workspace router reaches the user's computer over SSH on their tailnet. The companion setup page has the human run the alans-way connect script from its main branch on their own computer, and the agent may install Tailscale with its official install script. Desktop input goes through Hermes's approval-gated computer_use tool. The manual --allow-desktop-actions setup flag, which the skill offers but never adds itself, adds cua:*:background entries to command_allowlist. On a Hermes without the computer-use provider API, including 0.21.5, it also exposes workspace_computer_action, which drives the user's desktop, Terminal included, with no approval."
|
||||
version: "0.21.0"
|
||||
title: "Alan's Way Plugin"
|
||||
description: "Idle check-ins from the primary bot of a Hermes gateway on a Linux machine or a macOS guest VM: when the bound Telegram chat has been quiet for two hours, during 08:00-22:00 in the user's timezone, the bot checks in once (does one safe step and reports it, suggests one, or asks one question), backing off when unanswered. The model tool can only make check-ins quieter; more often, resume and wider hours are operator-only via /proactivity or the CLI. After install, the workspace-setup skill walks the agent through setting up Alan's Workspace (a separate desktop app for the user's computer: macOS or Windows) and Tailscale; desktop input goes only through Hermes's approval-gated computer_use tool. Disclosure — once the gateway connects Telegram, a background thread checks every 60 seconds. When the bound Telegram DM has been quiet (2 h by default, doubling per unanswered check-in, 08:00-22:00 user time), it injects an internal user-role prompt into that chat (needs plugins.entries.alans-way.allow_gateway_injection, which setup sets), so the bot runs a turn on its own and may take one \"safe, reversible\" step; the user still approves tool calls in Telegram. Its pre_llm_call/post_llm_call hooks see every session's turns and store only the bound chat's timestamps in plugin state. On first load it reads $HERMES_HOME/companion/proactivity/proactivity.sqlite3 once, read-only. The plugin itself makes no network calls. The workspace-setup skill has the agent clone this repo at the catalog pin and run setup.sh, which does the following. (1) Installs long-running services (Alan's Tools: a managed Chrome/Chromium on a loopback-only debugging port, the first free port from 9223 up, plus a browser broker and an optional availability watcher): systemd units hermes-alans-way-chromium, -browser and mac-watch with loginctl enable-linger where systemd is running; supervisord programs alans-way-chromium, alans-way-browser and alans-way-mac-watch, written to a conf.d file, where it is not (VM images and containers); a KeepAlive LaunchAgent on a macOS guest; logon Scheduled Tasks on Windows. When no X display is running it also installs and runs Xvfb on :99 (unit hermes-alans-way-xvfb or program alans-way-xvfb, -nolisten tcp). (2) Installs packages when they are missing, only as root or with passwordless sudo, through apt, dnf or yum: Google Chrome stable (the current .deb downloaded from dl.google.com on amd64 apt hosts, which also adds Google's apt repository, so the host keeps taking Chrome updates from Google), else the distro chromium package, else Playwright 1.49.1 Chromium through npx (root only); and xvfb. It only warns, and installs nothing, for xdotool, scrot, at-spi2-core and python3-gi, which the VM desktop path needs. (3) Appends the user's computer's public key to ~/.ssh/authorized_keys (administrators_authorized_keys on a Windows admin account) with a from= restriction to the Tailscale ranges 100.64.0.0/10 and fd7a:115c:a1e0::/48, rewriting an existing unrestricted line for the same key; pins its host key in known_hosts and adds a ControlMaster block to ~/.ssh/config for Tailscale hosts only (with a tailscale nc ProxyCommand when Tailscale runs in userspace-networking mode). (4) Adds a TimeoutStartSec=180 drop-in to the hermes-gateway unit. Hermes refuses plugin file changes while a gateway is running, so setup drain-stops that profile's gateway (SIGUSR1 under supervisord, else systemctl stop or hermes gateway stop), replays the refused plugin install or update, and starts it again. With --restart it restarts the gateway only if setup changed something the gateway loads (or an earlier restart never happened), detached about ten seconds after printing its summary: by SIGUSR1 drain under supervisord, else a transient systemd-run timer or hermes gateway restart. (5) For the main profile and every profile with its own Telegram bot, writes a managed mcp_servers.workspace_browser block (removing other entries that run this router, with a backup; a re-run keeps the computer address and OS it already holds, and --mac-ssh none
|
||||
removes them), turns off the built-in browser toolset for Telegram and cron (--keep-browser leaves it on), turns computer_use on for Telegram and cron, and sets computer_use.backend to alans-way-computer when it installed or found that provider (under the skill's --skip-plugin flow it only warns and prints the config command). It reads each profile's TELEGRAM_BOT_TOKEN only to derive the numeric bot id. It warns, and changes nothing, when a copy of the workspace-operations skill in a profile's skills directory shadows the plugin's. The workspace router reaches the user's computer over SSH on their tailnet; on the user's computer it calls the local API of Alan's Workspace on 127.0.0.1 with that app's bearer token. The skill's moved-VM checklist has the agent read the app's preferences.json on the user's computer over the same SSH path and tell the human where the VNC password lives without pasting it. The Alan's Workspace setup page has the human run the alans-way connect script from its main branch on their own computer, and the agent may install Tailscale with its official install script. The manual --allow-desktop-actions setup flag, which the skill offers but never adds itself, adds cua:*:background entries to command_allowlist. On a Hermes without the computer-use provider API, including 0.21.5, it also exposes workspace_computer_action, which drives the user's desktop, Terminal included, with no approval. The repo also holds bootstrap-orgo.sh and migrate.sh, which the plugin, setup.sh and the skills never run: a user runs them by hand to provision a fresh supervisord Linux computer (it fetches the Hermes and Tailscale installers over HTTPS, pipes setup.sh from this repo's main branch into bash unless --repo-ref is given, installs openssh-server if absent, writes supervisord programs, and POSTs the Tailscale login URL with a shared secret to a --callback URL when one is given) or to copy ~/.hermes, including .env, auth.json and state.db, to another host over SSH (it also looks up each profile's bot username at api.telegram.org with that profile's bot token)."
|
||||
maintainer: capthvnsen
|
||||
tier: community
|
||||
category: automation
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/capthvnsen/alans-way-agents/tree/ce51733b66291e69f616db48ae8799c0de50db43/alans-way
|
||||
docs_url: https://github.com/capthvnsen/alans-way-agents/tree/78a7ddd781bbc7c1d9e13c0e24dc1a0847152553/alans-way
|
||||
readme: true
|
||||
platforms: [linux, macos]
|
||||
known_issues:
|
||||
- "Install this on the machine that runs Hermes (a Linux host or a macOS guest VM), not on the user's primary computer. Windows guests are new and untested on real hardware. If the plugin is already installed from the catalog, setup leaves it as it is, so this pin stays."
|
||||
- "Setup turns off Hermes's stock browser toolset for Telegram and cron so the agent uses the workspace browser (pass --keep-browser to leave it on). It never turns off computer_use: on Hermes with the computer-use provider API, desktop input goes through the gated computer_use tool and its alans-way-computer provider."
|
||||
- "Check-ins need plugins.entries.alans-way.allow_gateway_injection: true and a bound Telegram DM (setup does both). A background thread checks every 60 seconds once the gateway connects Telegram."
|
||||
- "Setup may install Chrome, Chromium, Playwright's Chromium or Xvfb with root or passwordless sudo, and may drain-stop and start the profile's gateway to apply a plugin change Hermes refuses while it runs. Pass --skip-services to skip the services and their packages."
|
||||
- "The computer's SSH key is only accepted from the tailnet (from= restriction). An existing unrestricted line for the same key is rewritten with it."
|
||||
- "A copy of the workspace-operations skill under a profile's skills directory shadows the plugin's and never gets its updates. Setup only warns; move anything worth keeping out of it, then delete it."
|
||||
capabilities:
|
||||
provides_tools:
|
||||
- proactivity
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
name: approval-ledger
|
||||
repo: https://github.com/olric3015/hermes-plugins
|
||||
sha: d029b8e9dafbf17ff85cef54030f4ee3b2565be3
|
||||
sha: f013a213c75cba8d44443c990b2f0c3b17c07eb2
|
||||
subdir: approval-ledger
|
||||
description: "Local ledger of Hermes's approval prompts (dangerous commands, protected writes, MCP consent, smart-mode verdicts): /approval-log shows how often each rule asked, how many prompts were approved, denied or left unanswered, and how long a person took to answer, per rule or per surface, for the last 24 hours, any number of hours or days, the latest session or everything recorded; /approval-log recent lists the newest decisions. Disclosure — writes decision metadata (session id, surface, rule label, answer, timings; no command, description or session key) to plugin-data/approval-ledger/approvals.jsonl, trimmed to 5,000 rows past 2 MB; observer only, cannot answer or change an approval; no network"
|
||||
description: "Local ledger of Hermes's approval prompts (dangerous commands, protected writes, MCP consent, smart-mode verdicts): /approval-log shows how often each rule asked, how many prompts were approved, denied or left unanswered, and how long a person took to answer, per rule, per surface or per day, for the last 24 hours, any number of hours or days, the latest session or everything recorded; /approval-log recent lists the newest decisions. Disclosure — writes decision metadata (session id, surface, rule label, answer, timings; no command, description or session key) to plugin-data/approval-ledger/approvals.jsonl, trimmed to 5,000 rows past 2 MB; observer only, cannot answer or change an approval; no network"
|
||||
maintainer: olric3015
|
||||
tier: community
|
||||
category: tools
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/olric3015/hermes-plugins/tree/main/approval-ledger
|
||||
version: "1.0.0"
|
||||
image: https://raw.githubusercontent.com/olric3015/hermes-plugins/d029b8e9dafbf17ff85cef54030f4ee3b2565be3/docs/approval-ledger-card.png
|
||||
version: "1.1.0"
|
||||
image: https://raw.githubusercontent.com/olric3015/hermes-plugins/f013a213c75cba8d44443c990b2f0c3b17c07eb2/docs/approval-ledger-card.png
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: beam
|
||||
repo: https://github.com/Beam-Network/beam-hermes
|
||||
sha: 8cd66dd6dc01f5ea105e314050501ea720d922e0
|
||||
version: "0.1.0"
|
||||
title: Beam
|
||||
description: "Beam (b1m.ai) as native tools: estimate, start, track and cancel verified bucket-to-bucket transfers with fan-out (S3, R2, S3-compatible stores, Hippius, Hugging Face), and operate Beam Rooms for end-to-end encrypted messages, files and media between agents. Disclosure — talks to Beam (https://beamcore.b1m.ai, and tls://orch-gateway.b1m.ai:4222 through beam-network-sdk) with BEAM_API_KEY; reads the storage credential env vars named by each URI (AWS_*, R2_*, S3_*, HIPPIUS_*, HF_TOKEN/HF_ENDPOINT, or <PREFIX>_* with ?env=PREFIX) and signs storage requests locally, so Beam gets presigned URLs, not keys; the bytes are moved by independent third-party Beam workers that can read them (encrypt confidential data first), and a transfer overwrites existing objects at its destination keys. Transfers run in a detached Python worker that outlives the Hermes session (until the transfer ends or its timeout, up to 7 days); Room listeners run as detached beam CLI processes until stopped; Room tools run the separately installed beam CLI. Transfers (0.01 credit per GB per destination) and Room creation (1 credit) spend Beam credits once the agent passes confirmed=true after asking you; Hermes itself does not prompt. Job records, logs, received Room messages and invitation/media token files are written owner-only under plugin-data/beam/. The SDK reports transfer performance timings to Beam. No self-updates."
|
||||
maintainer: Beam-Network
|
||||
tier: community
|
||||
category: tools
|
||||
requires_hermes: ">=0.21.6"
|
||||
docs_url: https://github.com/Beam-Network/beam-hermes#readme
|
||||
readme: true
|
||||
capabilities:
|
||||
provides_tools:
|
||||
- beam_account
|
||||
- beam_transfer_estimate
|
||||
- beam_transfer_start
|
||||
- beam_transfer_status
|
||||
- beam_transfer_list
|
||||
- beam_transfer_cancel
|
||||
- beam_rooms
|
||||
- beam_room_listen
|
||||
- beam_room_messages
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env:
|
||||
- BEAM_API_KEY
|
||||
@@ -1,13 +1,13 @@
|
||||
name: cheatsheet
|
||||
repo: https://github.com/Bonorinoa/hermes-cheatsheet
|
||||
sha: 266d739701203713367ed72491b5564b1140527b
|
||||
sha: d5a4f0121184ed789e138f423249b5c2f7c57eee
|
||||
subdir: ""
|
||||
description: "Title-bar cheatsheet. Live slash commands and plugins, plus cited passages from this install's docs. No runtime network. No self-update. Disclosure — reads the Markdown docs shipped with your Hermes install (website/docs next to hermes_cli) through a local dashboard route and lists your commands, plugins and MCP servers via read-only gateway calls; no network at runtime (doc and GitHub links open in your browser only when clicked)."
|
||||
description: "Title-bar cheatsheet. Describe what you want to do and it points to a real command or a cited passage; live slash commands and plugins, plus cited passages from this install's docs. No runtime network. No self-update. Disclosure — reads the Markdown docs shipped with your Hermes install (website/docs next to hermes_cli) through a local dashboard route and lists your commands, plugins and MCP servers via read-only gateway calls; no network at runtime (doc and GitHub links open in your browser only when clicked)."
|
||||
maintainer: Bonorinoa
|
||||
tier: community
|
||||
category: desktop
|
||||
docs_url: https://github.com/Bonorinoa/hermes-cheatsheet#readme
|
||||
version: "0.1.0"
|
||||
version: "0.2.0"
|
||||
readme: true
|
||||
platforms: []
|
||||
capabilities:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: claude-code-theme
|
||||
title: Claude Code Theme
|
||||
repo: https://github.com/aliilhami/hermes-claude-code-theme
|
||||
sha: d2430687cde455e470f5f75b951e488eb0938e61
|
||||
requires_hermes: ">=0.21.6"
|
||||
description: "Unofficial Claude Code-inspired theme for Hermes Desktop, with light and dark palettes, rounded composer and side-panel cards, softer sidebar and menus, and a context-window meter beside the model switcher. Not affiliated with, endorsed by, or sponsored by Anthropic or Claude Code. Disclosure — unofficial Claude Code lookalike theme; its stylesheet restyles native Desktop surfaces (user bubbles, composer, file panel, sidebar rows, dropdown menus) and hides the chat backdrop image; the context meter reads session.usage through the SDK; an opt-in accent setting uses setAccentOverride; no network calls of its own."
|
||||
maintainer: aliilhami
|
||||
tier: community
|
||||
category: desktop
|
||||
docs_url: https://github.com/aliilhami/hermes-claude-code-theme#readme
|
||||
version: "0.1.0"
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/aliilhami/hermes-claude-code-theme/d2430687cde455e470f5f75b951e488eb0938e61/docs/overview-light.png
|
||||
- https://raw.githubusercontent.com/aliilhami/hermes-claude-code-theme/d2430687cde455e470f5f75b951e488eb0938e61/docs/sidebar-menus.png
|
||||
- https://raw.githubusercontent.com/aliilhami/hermes-claude-code-theme/d2430687cde455e470f5f75b951e488eb0938e61/docs/context-meter.png
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -1,15 +1,15 @@
|
||||
name: command-ledger
|
||||
repo: https://github.com/olric3015/hermes-plugins
|
||||
sha: f2aa337e6c730d6efd93331940c6d3d6a0fd481b
|
||||
sha: 12263b1fd89e8d647549e39d349add157c7570b4
|
||||
subdir: command-ledger
|
||||
description: "Local ledger of the slash commands you run in Hermes (built-in commands in the CLI, built-in and plugin commands on messaging platforms): /command-log shows how often each command was used, its share, in how many sessions and when last, per command, platform or surface, for the last 24 hours, any number of hours or days, the latest session or everything recorded; /command-log recent lists the newest commands with the alias typed. Disclosure — writes command metadata (command name, alias typed, surface, platform, a 12-digit hash of the session key; no arguments, session key or user id) to plugin-data/command-ledger/commands.jsonl, trimmed to 5,000 rows past 2 MB; observer only, cannot block or change a command; no network"
|
||||
description: "Local ledger of the slash commands you run in Hermes (built-in commands in the CLI, built-in and plugin commands on messaging platforms): /command-log shows how often each command was used, its share, in how many sessions and when last, per command, platform, surface or day, for the last 24 hours, any number of hours or days, the latest session or everything recorded; /command-log recent lists the newest commands with the alias typed. Disclosure — writes command metadata (command name, alias typed, surface, platform, a 12-digit hash of the session key; no arguments, session key or user id) to plugin-data/command-ledger/commands.jsonl, trimmed to 5,000 rows past 2 MB; observer only, cannot block or change a command; no network"
|
||||
maintainer: olric3015
|
||||
tier: community
|
||||
category: tools
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/olric3015/hermes-plugins/tree/main/command-ledger
|
||||
version: "1.0.0"
|
||||
image: https://raw.githubusercontent.com/olric3015/hermes-plugins/f2aa337e6c730d6efd93331940c6d3d6a0fd481b/docs/command-ledger-card.png
|
||||
version: "1.1.0"
|
||||
image: https://raw.githubusercontent.com/olric3015/hermes-plugins/12263b1fd89e8d647549e39d349add157c7570b4/docs/command-ledger-card.png
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
name: crew
|
||||
repo: https://github.com/macd2/Crew
|
||||
sha: 317c08ce760bc17665d34d595d962d5a6b524583
|
||||
sha: e698850c5c0cea74eae005e5d2d02375cbca1def
|
||||
description: "One /crew message becomes verified work on the kanban board. A coordinator owns every card until its proof command exits 0 - one writer per card, an independent verifier, a hard token budget per card, and a live local dashboard of every card, run and tool call. Disclosure — install.py creates four crew-* profiles cloned from your chat profile (including its .env provider keys and memories), deletes non-crew skills in them, updates crew in other profiles and starts a loopback dashboard service (127.0.0.1:8799, transcript excerpts; --publish serves it to one tailnet login); a detached coordinator makes read-only model decisions and runs card proof commands unattended with a scrubbed env, flagged or changed proofs only after the owner answers /crew-proof; card reports and questions are sent to the originating chat via hermes send; agent-written per-role lessons are injected into later crew turns."
|
||||
maintainer: macd2
|
||||
tier: community
|
||||
category: automation
|
||||
requires_hermes: ">=0.21"
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/macd2/Crew#readme
|
||||
version: "0.7.9"
|
||||
image: https://raw.githubusercontent.com/macd2/Crew/317c08ce760bc17665d34d595d962d5a6b524583/assets/banner.png
|
||||
version: "0.8.1"
|
||||
image: https://raw.githubusercontent.com/macd2/Crew/e698850c5c0cea74eae005e5d2d02375cbca1def/assets/banner.png
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/macd2/Crew/317c08ce760bc17665d34d595d962d5a6b524583/assets/dashboard.webp
|
||||
- https://raw.githubusercontent.com/macd2/Crew/e698850c5c0cea74eae005e5d2d02375cbca1def/assets/dashboard.webp
|
||||
platforms: [linux]
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: custom-dangerous-patterns
|
||||
repo: https://github.com/scross01/hermes-custom-dangerous-patterns-plugin
|
||||
sha: 788dc41c50201877d353e847626c8a096ab6968f
|
||||
sha: 256ff08d0be04baf1916f5e7d841b1c2718aa1e4
|
||||
description: >-
|
||||
Add custom dangerous command patterns to Hermes's built-in approval
|
||||
system. Users define regex patterns in YAML; a pre_tool_call hook sends
|
||||
@@ -10,7 +10,8 @@ maintainer: scross01
|
||||
tier: community
|
||||
category: tools
|
||||
requires_hermes: ">=0.21.4"
|
||||
version: "0.5.0"
|
||||
version: "0.5.1"
|
||||
image: https://raw.githubusercontent.com/scross01/hermes-custom-dangerous-patterns-plugin/256ff08d0be04baf1916f5e7d841b1c2718aa1e4/assets/card.png
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
name: dashboard-auth-feishu
|
||||
repo: https://github.com/xielevi/hermes-dashboard-auth-feishu
|
||||
sha: 8d69b43dcbe63d9925eb5724c046a72ac6e1260a
|
||||
description: "Feishu / Lark OAuth sign-in for the web dashboard, admitted by tenant + open_id allow-list. Registers the `feishu` dashboard auth provider once plugins.entries.dashboard-auth-feishu.settings (app_id, tenant_key, owner_open_ids) and an app secret are configured. Disclosure — calls only the fixed Feishu or Lark authorize, token and user_info endpoints; reads HERMES_DASHBOARD_FEISHU_APP_SECRET or, as a fallback, the Feishu gateway's FEISHU_APP_SECRET, plus HERMES_DASHBOARD_FEISHU_SESSION_KEY; stores tenant, open_id and display name per sign-in in a 0600 SQLite file under plugin-data (no upstream tokens); every admitted identity gets full dashboard access; upstream PKCE is not used (confidential client with single-use cookie-bound state); pending logins are in memory, so one dashboard worker only; WebSockets opened before logout may stay open until the session key is rotated and the dashboard restarted."
|
||||
sha: 724715f6b87cec86f147b0ff9854abd343c00013
|
||||
description: "Feishu / Lark OAuth sign-in for the web dashboard, admitted by tenant + open_id allow-list. Registers the `feishu` dashboard auth provider once plugins.entries.dashboard-auth-feishu.settings (app_id, tenant_key, owner_open_ids) and an app secret are configured. Disclosure — calls only the fixed Feishu or Lark authorize, token and user_info endpoints; reads HERMES_DASHBOARD_FEISHU_APP_SECRET or, as a fallback, the Feishu gateway's FEISHU_APP_SECRET, plus HERMES_DASHBOARD_FEISHU_SESSION_KEY; stores tenant, open_id and display name per sign-in in a 0600 SQLite file under plugin-data (no upstream tokens); a refused sign-in logs the tenant_key and open_id Feishu returned (identifiers only, for first-time setup); every admitted identity gets full dashboard access; upstream PKCE is not used (confidential client with single-use cookie-bound state); pending logins are in memory, so one dashboard worker only; WebSockets opened before logout may stay open until the session key is rotated and the dashboard restarted."
|
||||
maintainer: xielevi
|
||||
tier: community
|
||||
category: general
|
||||
title: Feishu / Lark Dashboard Login
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/xielevi/hermes-dashboard-auth-feishu#readme
|
||||
version: "1.0.0"
|
||||
version: "1.0.1"
|
||||
known_issues:
|
||||
- "Lark endpoints are implemented but not live-tested; Feishu is tested end to end."
|
||||
capabilities:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: deepgram-voice
|
||||
repo: https://github.com/jaysonsantos/hermes-deepgram-voice-plugin
|
||||
sha: c1d8ef24ffe3606976ba52d635e684f5566ed04c
|
||||
sha: 2d31b885ec873ed6693edd7ad1a80f7023439ac0
|
||||
description: Deepgram Aura text-to-speech and Nova speech-to-text providers for Hermes Agent.
|
||||
maintainer: jaysonsantos
|
||||
tier: community
|
||||
@@ -11,4 +11,4 @@ capabilities:
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env:
|
||||
- name
|
||||
- DEEPGRAM_API_KEY
|
||||
@@ -1,18 +1,18 @@
|
||||
name: discord-tps
|
||||
repo: https://github.com/tuancookiez-hub/hermes-discord-tokenpersec
|
||||
sha: 6f84f9eb5d3d8cae01bf95ea02f628a818ad39ad
|
||||
sha: 056fcd331d9be03c287f293854b715b17b637203
|
||||
subdir: discord-tps
|
||||
version: "1.0.2"
|
||||
requires_hermes: ">=0.21"
|
||||
version: "1.0.3"
|
||||
requires_hermes: ">=0.21.4"
|
||||
description: "Show live tokens-per-second and the active model on your Discord bot's presence. Disclosure — Replaces the Discord bot's public presence with the live tok/s figure and the model name of the most recent API call in this gateway (any platform or chat), visible to anyone who can see the bot; updates at most every interval seconds (minimum 10) over the gateway's existing Discord connection. Reads no credentials and makes no network calls of its own."
|
||||
maintainer: TunaCookie
|
||||
maintainer: tuancookiez-hub
|
||||
tier: community
|
||||
category: platform
|
||||
docs_url: https://github.com/tuancookiez-hub/hermes-discord-tokenpersec/blob/6f84f9eb5d3d8cae01bf95ea02f628a818ad39ad/discord-tps/README.md
|
||||
docs_url: https://github.com/tuancookiez-hub/hermes-discord-tokenpersec/blob/056fcd331d9be03c287f293854b715b17b637203/discord-tps/README.md
|
||||
readme: true
|
||||
image: https://raw.githubusercontent.com/tuancookiez-hub/hermes-discord-tokenpersec/6f84f9eb5d3d8cae01bf95ea02f628a818ad39ad/assets/banner.png
|
||||
image: https://raw.githubusercontent.com/tuancookiez-hub/hermes-discord-tokenpersec/056fcd331d9be03c287f293854b715b17b637203/assets/banner.png
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/tuancookiez-hub/hermes-discord-tokenpersec/6f84f9eb5d3d8cae01bf95ea02f628a818ad39ad/assets/presence-screenshot.png
|
||||
- https://raw.githubusercontent.com/tuancookiez-hub/hermes-discord-tokenpersec/056fcd331d9be03c287f293854b715b17b637203/assets/presence-screenshot.png
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks:
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
name: gods-eye-view
|
||||
repo: https://github.com/cygnostik/Hermes-Plugin-Gods-Eye-View
|
||||
sha: f34cec4417c4b094341ebe8e2f556627944dac53
|
||||
description: "Explore God's Eye View inside Hermes Desktop, with the native 3D globe, scenes, Focus mode and explicit local engine controls. Disclosure — wraps the separate third-party God's Eye View app (installed by the user), starts/stops its local Node server on loopback and on explicit request updates that checkout from GitHub/npm (runs its esbuild install script); on explicit opt-in copies matching Hermes API keys into that app's own key store."
|
||||
maintainer: cygnostik
|
||||
tier: community
|
||||
category: desktop
|
||||
requires_hermes: ">=0.21.5"
|
||||
version: "0.1.3"
|
||||
docs_url: https://github.com/cygnostik/Hermes-Plugin-Gods-Eye-View#readme
|
||||
image: https://raw.githubusercontent.com/cygnostik/Hermes-Plugin-Gods-Eye-View/f34cec4417c4b094341ebe8e2f556627944dac53/docs/media/catalog-banner.jpg
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/cygnostik/Hermes-Plugin-Gods-Eye-View/f34cec4417c4b094341ebe8e2f556627944dac53/docs/media/catalog.png
|
||||
readme: true
|
||||
platforms: [windows, macos]
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,37 @@
|
||||
# Draft for NousResearch/hermes-agent/plugin-catalog/hermes-adaptive-effort.yaml
|
||||
# Pinned to master after the v0.3.1 pre-release (repository-root payload, so no subdir).
|
||||
# The plugin payload is identical to v0.3.1; the pin adds the README screenshots and release tooling.
|
||||
# Update sha and version together for later releases; a bump is a new catalog PR.
|
||||
name: hermes-adaptive-effort
|
||||
repo: https://github.com/atostivint/hermes-adaptive-effort
|
||||
sha: 0998a083d85ea52fbef38427b44a54d27332358a
|
||||
description: "Choose a separate scorer to set the reasoning effort of each Hermes request. Disclosure — off by default; when a mode is enabled (or on an explicit `/hae probe`), sends the latest user message (or the parent-written subagent goal), truncated to 4000 characters, plus any configured guidance to the scorer you select (TypeSafe Jev at api.typesafe.ai by default, or OpenAI Decisions, OpenRouter, Cloudflare Workers AI, or a custom URL) using that service's key; in Hermes Desktop it also sets the focused chat's session reasoning selector through the gateway `config.set` API after each applied change, and it keeps a prompt-free effort-change history (64 changes × 64 conversations) in its plugin data directory."
|
||||
maintainer: atostivint
|
||||
tier: community
|
||||
category: models
|
||||
requires_hermes: ">=0.21.6"
|
||||
docs_url: https://github.com/atostivint/hermes-adaptive-effort/blob/0998a083d85ea52fbef38427b44a54d27332358a/docs/README.md
|
||||
version: "0.3.1"
|
||||
readme: true
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/atostivint/hermes-adaptive-effort/0998a083d85ea52fbef38427b44a54d27332358a/docs/images/desktop-routing-mode.png
|
||||
- https://raw.githubusercontent.com/atostivint/hermes-adaptive-effort/0998a083d85ea52fbef38427b44a54d27332358a/docs/images/desktop-route-and-scorer.png
|
||||
- https://raw.githubusercontent.com/atostivint/hermes-adaptive-effort/0998a083d85ea52fbef38427b44a54d27332358a/docs/images/desktop-settings.png
|
||||
- https://raw.githubusercontent.com/atostivint/hermes-adaptive-effort/0998a083d85ea52fbef38427b44a54d27332358a/docs/images/routing-mode-popover.png
|
||||
platforms: []
|
||||
title: Hermes Adaptive Effort
|
||||
known_issues:
|
||||
- "Pre-release: live effort acceptance is only partially validated; see the v0.3.0 release notes for the per-route evidence."
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks:
|
||||
- on_session_end
|
||||
- on_session_finalize
|
||||
- on_session_reset
|
||||
- subagent_start
|
||||
- subagent_stop
|
||||
provides_middleware:
|
||||
- llm_request
|
||||
# Provider credentials are conditional on the selected scorer, so none is
|
||||
# unconditionally required to load or use the plugin with a local endpoint.
|
||||
requires_env: []
|
||||
@@ -1,8 +1,8 @@
|
||||
name: hermes-live-voice
|
||||
repo: https://github.com/Synero/hermes-live-voice
|
||||
sha: c983ca8c493ee9609a846e6218f6b12705ea04e6
|
||||
version: "0.2.3"
|
||||
description: 'Full-duplex Live Voice for Hermes Desktop — GPT-Live-1 on the ChatGPT/Codex subscription via the local Codex app-server, with live transcript, voice tool calls and task delegation to the user chat.'
|
||||
sha: 88cf7ba465765b2bc40cc855b6f956a91bdf4864
|
||||
version: "0.2.5"
|
||||
description: "Full-duplex Live Voice for Hermes Desktop — GPT-Live-1 on the ChatGPT/Codex subscription via the local Codex app-server, with live transcript, voice tool calls and task delegation to the user chat. Disclosure — uses your ChatGPT/Codex subscription: reads ~/.codex/auth.json (or $CODEX_HOME), refreshes an expiring token under the Codex CLI's client id and writes it back to that file, and runs codex login --device-auth and codex app-server from the dashboard; its in-panel logout backs up then deletes ~/.codex/auth.json (signing the Codex CLI out too); the usage panel calls the ChatGPT/OpenAI usage endpoints with a codex-cli User-Agent; voice audio goes to OpenAI over WebRTC, and spoken tasks are submitted as messages in your focused chat."
|
||||
maintainer: Synero
|
||||
tier: community
|
||||
category: voice
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: hermes-ssh
|
||||
repo: https://github.com/Adolanium/hermes-ssh
|
||||
sha: 02c6b34ee604a30827df25a082cba03fbf6fdf17
|
||||
sha: ea815bd671737a01948a4d77bad1d6b3a935b2bb
|
||||
description: Manage SSH connections and remote workspaces in Hermes Desktop.
|
||||
maintainer: Adolanium
|
||||
tier: community
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
name: hermes-subscription-meter
|
||||
repo: https://github.com/NealZhouPanda/hermes-subscription-meter
|
||||
sha: 80d3a998b9cef99a84083007da1cbf3517799795
|
||||
description: Provider-neutral subscription quota matrix panel — each provider's subscription window as a single time x quota row (elapsed vs remaining at a glance), with UI-side show/hide per provider row.
|
||||
sha: 44f33fcb041096f159ebf72182ac633547926422
|
||||
description: "Provider-neutral subscription quota matrix panel — each provider's subscription window as a single time x quota row (elapsed vs remaining at a glance), with UI-side show/hide per provider row. Disclosure — reads provider keys from the current profile's .env and token presence from its auth.json, and calls each provider's own usage/balance API with that key (Kimi, GLM, MiniMax, Command Code, DeepSeek, xAI management, OpenRouter, Alibaba Cloud BSS, Grok, Codex via Hermes); an optional shared_sources setting reads another profile's .env/auth.json the same way; nothing is written outside this plugin's settings."
|
||||
maintainer: NealZhouPanda
|
||||
tier: community
|
||||
category: desktop
|
||||
docs_url: https://github.com/NealZhouPanda/hermes-subscription-meter#readme
|
||||
version: "0.5.0"
|
||||
image: https://raw.githubusercontent.com/NealZhouPanda/hermes-subscription-meter/80d3a998b9cef99a84083007da1cbf3517799795/docs/banner.png
|
||||
version: "0.6.0"
|
||||
image: https://raw.githubusercontent.com/NealZhouPanda/hermes-subscription-meter/44f33fcb041096f159ebf72182ac633547926422/docs/banner.png
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,17 @@
|
||||
name: hermes-vision-inline
|
||||
repo: https://github.com/BrokeSkill/hermes-vision-inline
|
||||
sha: 281bce608e799834cd1b60929d3f9b44756cb7c2
|
||||
description: "Attach an image inline to the session model when it can see one, instead of letting an explicit auxiliary.vision backend describe it. Core already attaches images natively by default; what this plugin changes is an explicit auxiliary.vision setting, which it overrides so the model reads the pixels directly. The image stays in context, so follow-ups do not reprocess it. The model's modality comes from the local models.dev cache; no network calls and nothing to configure. Disclosure — replaces the vision_analyze handler through tool_execution middleware: when the local models.dev cache says the main model takes images and its provider carries images in tool results, it attaches the image itself even if you configured an auxiliary.vision backend (agent.image_input_mode: text still turns this off); the image stays in the conversation and is resent on later turns; relies on Hermes private vision helpers, so after a Hermes update it may silently fall back to the normal path."
|
||||
maintainer: BrokeSkill
|
||||
tier: community
|
||||
category: tools
|
||||
docs_url: https://github.com/BrokeSkill/hermes-vision-inline#readme
|
||||
version: "1.1.0"
|
||||
image: https://raw.githubusercontent.com/BrokeSkill/hermes-vision-inline/281bce608e799834cd1b60929d3f9b44756cb7c2/docs/banner.png
|
||||
platforms: [windows, macos, linux]
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware:
|
||||
- tool_execution
|
||||
requires_env: []
|
||||
@@ -0,0 +1,20 @@
|
||||
name: hwiki
|
||||
repo: https://github.com/ottosulin/hwiki
|
||||
sha: d1961956eaafbd64fef71cb7135d475efcc8f954
|
||||
version: "1.0.0"
|
||||
description: "Markdown wiki as Hermes long-term memory: plain files you and the agent edit together (Obsidian-compatible, git-versionable), local SQLite FTS5 search with optional qmd hybrid retrieval. Inspired by the Karpathy LLM wiki. Disclosure — reads every Markdown file under the wiki folder you set (wiki_path, outside HERMES_HOME; untrusted_dirs, default raw/, excluded) and injects up to ~1,400 characters of matching excerpts into each turn; the agent appends dated notes only under <wiki>/<memory_dir> (default wiki/memory), and additions to Hermes's built-in memory are mirrored there by default (mirror_builtin_memory), so they land in a folder you may commit or sync; keeps an index at HERMES_HOME/hwiki-index.sqlite and config in HERMES_HOME/hwiki.json, and adds the wiki folder to hermes backup. Cron and subagent runs get read-only recall. No network with the default fts5 backend; the optional qmd backend runs the separately installed qmd CLI (npm @tobilu/qmd, Node ≥22), whose vector modes download local models (~0.3–1.6 GB)."
|
||||
maintainer: ottosulin
|
||||
tier: community
|
||||
category: memory
|
||||
requires_hermes: ">=0.21.4"
|
||||
docs_url: https://github.com/ottosulin/hwiki
|
||||
readme: true
|
||||
capabilities:
|
||||
provides_tools:
|
||||
- wiki_memory_search
|
||||
- wiki_memory_read
|
||||
- wiki_memory_write
|
||||
- wiki_memory_status
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,14 @@
|
||||
name: iowap
|
||||
repo: https://github.com/iowap-org/iowap-hermes-integration
|
||||
sha: 6ca7224380112f233a70b71bba26c9a352eac80f
|
||||
description: "Fleet control for Hermes Desktop: live statusbar chip (N/M IOWAP nodes online), a draggable fleet pane, a full fleet page (status, load, queue depth, capabilities per node, activity) and a tasks page that submits to any task-type capability on the fleet and tracks submitted tasks live (per-stage status dots, result previews, artifacts). Data stays local via the local node-cli against your own IOWAP relay; agent sessions join through an optional iowap-task bridge into a shared track store. Disclosure — the dashboard backend runs the local node-cli (the IOWAP node client from github.com/iowap-org/iowap-node) to read your relay's node list, capabilities and task results, and to submit tasks you start from the Tasks page to nodes on your IOWAP fleet. It reads ~/.relay/iowap-agent.json for the relay address and makes an unauthenticated /health request to that relay. Tracked task ids are stored in your Hermes home under plugin-data/iowap/."
|
||||
maintainer: Kesuek
|
||||
tier: community
|
||||
category: desktop
|
||||
version: "0.3.0"
|
||||
docs_url: https://github.com/iowap-org/iowap-hermes-integration#readme
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,20 @@
|
||||
name: jp-edinet
|
||||
repo: https://github.com/TakeshiTGAL/hermes-plugin-edinet
|
||||
sha: 1be6d03ff36cd1a1759edb2ab762a910ebd4e4c2
|
||||
description: "Japanese filings on the EDINET API v2. Disclosure — talks only to api.edinet-fsa.go.jp over HTTPS, and your EDINET_API_KEY rides in the URL as Subscription-Key. The edinet toolset is on for every platform, including gateways, so anyone a gateway admits can call these tools with your key. With CHATWORK_ALLOWED_USERS unset, everyone in CHATWORK_ROOMS can call them. Turn the toolset off per platform with hermes tools. There is no daily cap. The agent can call the three tools itself. A cron you add stays after uninstall until you remove it in Hermes. This plugin starts no child process and is not a sandbox. Search sends EDINET the filing date, and a download sends the document id and its type. Company name, securities code, EDINET code, and corporate number are filtered on your machine. found: false only means the figure is missing from the consolidated current-year rows the plugin reads, not that the document has no number. A past day's filing list can change. Lists, saved files, and a key fingerprint, not the key, stay at <HERMES_HOME>/plugin-data/jp-edinet per profile after uninstall. Delete that directory to remove them. The plugin relies on Hermes internals for the write check and the data folder. If an update moves those parts, the tools refuse and do not call EDINET. Test files are not loaded at startup."
|
||||
maintainer: TakeshiTGAL
|
||||
tier: community
|
||||
category: tools
|
||||
requires_hermes: ">=0.21.6"
|
||||
docs_url: https://github.com/TakeshiTGAL/hermes-plugin-edinet#readme
|
||||
version: "0.1.0"
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools:
|
||||
- edinet_search_filings
|
||||
- edinet_financials
|
||||
- edinet_save_document
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env:
|
||||
- EDINET_API_KEY
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user