Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8825ad20c1 | ||
|
|
152207c0cc | ||
|
|
e643c79c2c | ||
|
|
fb0ab27649 | ||
|
|
de6d6023d7 | ||
|
|
59b0ea98c8 | ||
|
|
897f9533ed | ||
|
|
9d4c81130a | ||
|
|
2259c15e4d | ||
|
|
45bc65abbe | ||
|
|
9dbc3722ae | ||
|
|
e38b0b55d1 | ||
|
|
04de307d62 | ||
|
|
bcc8301000 | ||
|
|
54aa4db1de | ||
|
|
2062a84000 | ||
|
|
f9daa4a41d | ||
|
|
689ef5e233 | ||
|
|
bb50825716 | ||
|
|
9f5afc7636 | ||
|
|
4fd8521e44 | ||
|
|
edfdc77664 |
+16
-17
@@ -4725,24 +4725,23 @@ def _build_call_kwargs(
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
if max_tokens is not None:
|
||||
# Codex adapter handles max_tokens internally; OpenRouter/Nous use max_tokens.
|
||||
# Direct OpenAI api.openai.com with newer models needs max_completion_tokens.
|
||||
# ZAI vision models (glm-4v-flash, glm-4v-plus, etc.) reject max_tokens with
|
||||
# error code 1210 ("API 调用参数有误") on multimodal requests — skip it.
|
||||
_model_lower = (model or "").lower()
|
||||
_skip_max_tokens = (
|
||||
provider == "zai"
|
||||
and ("4v" in _model_lower or "5v" in _model_lower or "-v" in _model_lower)
|
||||
# We do NOT cap output by default. Most chat-completions providers treat
|
||||
# an omitted max_tokens as "use the model's max output", which is what we
|
||||
# want for auxiliary tasks (compression summaries, titles, vision, etc.) —
|
||||
# an explicit cap only risks truncating a summary or 400-ing on providers
|
||||
# that reject the parameter outright (e.g. GitHub Copilot / newer OpenAI
|
||||
# GPT-5 models require max_completion_tokens, not max_tokens; ZAI vision
|
||||
# models reject it entirely with error 1210). Omitting it sidesteps all of
|
||||
# those wire-format quirks at once.
|
||||
#
|
||||
# The one exception is the Anthropic Messages wire (MiniMax and any
|
||||
# ``/anthropic`` endpoint reached through the OpenAI SDK wrapper), where
|
||||
# max_tokens is a MANDATORY field — omitting it is a hard 400. Keep it only
|
||||
# there.
|
||||
_effective_base = base_url or (
|
||||
_current_custom_base_url() if provider == "custom" else ""
|
||||
)
|
||||
if _skip_max_tokens:
|
||||
pass # ZAI vision models do not accept max_tokens
|
||||
elif provider == "custom":
|
||||
custom_base = base_url or _current_custom_base_url()
|
||||
if base_url_hostname(custom_base) == "api.openai.com":
|
||||
kwargs["max_completion_tokens"] = max_tokens
|
||||
else:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
else:
|
||||
if _is_anthropic_compat_endpoint(provider, _effective_base):
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
if tools:
|
||||
|
||||
@@ -518,6 +518,10 @@ class ContextCompressor(ContextEngine):
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
|
||||
self.last_real_prompt_tokens = 0
|
||||
self.last_compression_rough_tokens = 0
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
def update_model(
|
||||
self,
|
||||
@@ -615,6 +619,10 @@ class ContextCompressor(ContextEngine):
|
||||
|
||||
self.last_prompt_tokens = 0
|
||||
self.last_completion_tokens = 0
|
||||
self.last_real_prompt_tokens = 0
|
||||
self.last_compression_rough_tokens = 0
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
self.summary_model = summary_model_override or ""
|
||||
|
||||
@@ -648,6 +656,44 @@ class ContextCompressor(ContextEngine):
|
||||
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
|
||||
self.last_completion_tokens = usage.get("completion_tokens", 0)
|
||||
self.last_total_tokens = usage.get("total_tokens", self.last_prompt_tokens + self.last_completion_tokens)
|
||||
if self.last_prompt_tokens > 0:
|
||||
self.last_real_prompt_tokens = self.last_prompt_tokens
|
||||
if self.last_prompt_tokens < self.threshold_tokens:
|
||||
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
|
||||
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
|
||||
else:
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
|
||||
"""Return True when a high rough preflight estimate is known-noisy.
|
||||
|
||||
``estimate_request_tokens_rough(..., tools=...)`` intentionally
|
||||
overestimates schema-heavy requests so Hermes compresses before a
|
||||
provider rejects the payload. After a successful compressed API call,
|
||||
though, provider ``prompt_tokens`` are a better signal than repeating
|
||||
compaction from the same rough schema overhead. Defer only while the
|
||||
rough estimate has grown modestly since a request the provider proved
|
||||
fit under the threshold.
|
||||
"""
|
||||
if rough_tokens < self.threshold_tokens:
|
||||
return False
|
||||
if self.last_real_prompt_tokens <= 0:
|
||||
return False
|
||||
if self.last_real_prompt_tokens >= self.threshold_tokens:
|
||||
return False
|
||||
|
||||
baseline = self.last_rough_tokens_when_real_prompt_fit or self.last_compression_rough_tokens
|
||||
if baseline <= 0:
|
||||
return False
|
||||
|
||||
growth = max(0, rough_tokens - baseline)
|
||||
tolerated_growth = max(4096, int(self.threshold_tokens * 0.05))
|
||||
if growth > tolerated_growth:
|
||||
return False
|
||||
|
||||
self.last_rough_tokens_when_real_prompt_fit = max(baseline, rough_tokens)
|
||||
return True
|
||||
|
||||
def should_compress(self, prompt_tokens: int = None) -> bool:
|
||||
"""Check if context exceeds the compression threshold.
|
||||
|
||||
@@ -115,6 +115,15 @@ class ContextEngine(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
|
||||
"""Return True when preflight should trust recent real usage instead.
|
||||
|
||||
Built-in compression uses this to avoid re-compacting from known-noisy
|
||||
rough estimates after a compressed request has already fit. Third-party
|
||||
engines can ignore it safely.
|
||||
"""
|
||||
return False
|
||||
|
||||
# -- Optional: manual /compress preflight ------------------------------
|
||||
|
||||
def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
|
||||
@@ -575,19 +575,18 @@ def compress_context(
|
||||
force=True,
|
||||
)
|
||||
|
||||
# Update token estimate after compaction so pressure calculations
|
||||
# use the post-compression count, not the stale pre-compression one.
|
||||
# Use estimate_request_tokens_rough() so tool schemas are included —
|
||||
# with 50+ tools enabled, schemas alone can add 20-30K tokens, and
|
||||
# omitting them delays the next compression cycle far past the
|
||||
# configured threshold (issue #14695).
|
||||
# Keep the post-compression rough estimate for diagnostics, but do not
|
||||
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
|
||||
# can remain above threshold even after the next real API request fits.
|
||||
_compressed_est = estimate_request_tokens_rough(
|
||||
compressed,
|
||||
system_prompt=new_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
agent.context_compressor.last_prompt_tokens = _compressed_est
|
||||
agent.context_compressor.last_compression_rough_tokens = _compressed_est
|
||||
agent.context_compressor.last_prompt_tokens = -1
|
||||
agent.context_compressor.last_completion_tokens = 0
|
||||
agent.context_compressor.awaiting_real_usage_after_compression = True
|
||||
|
||||
# Clear the file-read dedup cache. After compression the original
|
||||
# read content is summarised away — if the model re-reads the same
|
||||
@@ -599,7 +598,7 @@ def compress_context(
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"context compression done: session=%s messages=%d->%d tokens=~%s",
|
||||
"context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true",
|
||||
agent.session_id or "none", _pre_msg_count, len(compressed),
|
||||
f"{_compressed_est:,}",
|
||||
)
|
||||
|
||||
@@ -600,18 +600,50 @@ def run_conversation(
|
||||
system_prompt=active_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
_compressor = agent.context_compressor
|
||||
_defer_preflight = getattr(
|
||||
_compressor,
|
||||
"should_defer_preflight_to_real_usage",
|
||||
lambda _tokens: False,
|
||||
)
|
||||
_preflight_deferred = _defer_preflight(_preflight_tokens)
|
||||
|
||||
if agent.context_compressor.should_compress(_preflight_tokens):
|
||||
if not _preflight_deferred:
|
||||
# Keep the CLI/ACP context display in sync with what preflight
|
||||
# actually measured. The status bar reads
|
||||
# ``compressor.last_prompt_tokens``, which otherwise only updates
|
||||
# from a *successful* API response. When the conversation has grown
|
||||
# since the last successful call — or when compression then fails
|
||||
# (e.g. the auxiliary summary model times out) and no fresh usage
|
||||
# arrives — the bar stays stuck at the old, smaller value while
|
||||
# preflight reports a much larger number, looking out of sync.
|
||||
# Seed it with the fresh estimate (only ever revising upward; a real
|
||||
# ``update_from_response`` will correct it after the next API call).
|
||||
# Skipped when deferring — a deferred estimate is known to over-count
|
||||
# vs the last real provider prompt, so trusting it for the display
|
||||
# would re-introduce the very desync we're avoiding.
|
||||
if _preflight_tokens > (_compressor.last_prompt_tokens or 0):
|
||||
_compressor.last_prompt_tokens = _preflight_tokens
|
||||
|
||||
if _preflight_deferred:
|
||||
logger.info(
|
||||
"Skipping preflight compression: rough estimate ~%s >= %s, "
|
||||
"but last real provider prompt was %s after compression",
|
||||
f"{_preflight_tokens:,}",
|
||||
f"{_compressor.threshold_tokens:,}",
|
||||
f"{_compressor.last_real_prompt_tokens:,}",
|
||||
)
|
||||
elif _compressor.should_compress(_preflight_tokens):
|
||||
logger.info(
|
||||
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
|
||||
f"{_preflight_tokens:,}",
|
||||
f"{agent.context_compressor.threshold_tokens:,}",
|
||||
f"{_compressor.threshold_tokens:,}",
|
||||
agent.model,
|
||||
f"{agent.context_compressor.context_length:,}",
|
||||
f"{_compressor.context_length:,}",
|
||||
)
|
||||
agent._emit_status(
|
||||
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
|
||||
f">= {agent.context_compressor.threshold_tokens:,} threshold. "
|
||||
f">= {_compressor.threshold_tokens:,} threshold. "
|
||||
"This may take a moment."
|
||||
)
|
||||
# May need multiple passes for very large sessions with small
|
||||
@@ -646,8 +678,8 @@ def run_conversation(
|
||||
system_prompt=active_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
if _preflight_tokens < agent.context_compressor.threshold_tokens:
|
||||
break # Under threshold
|
||||
if not _compressor.should_compress(_preflight_tokens):
|
||||
break # Under threshold or anti-thrash guard stopped it
|
||||
|
||||
# Plugin hook: pre_llm_call
|
||||
# Fired once per turn before the tool-calling loop. Plugins can
|
||||
@@ -3862,6 +3894,11 @@ def run_conversation(
|
||||
# inflate completion_tokens with reasoning,
|
||||
# causing premature compression. (#12026)
|
||||
_real_tokens = _compressor.last_prompt_tokens
|
||||
elif _compressor.last_prompt_tokens == -1:
|
||||
# Compression just ran and no API-reported prompt count
|
||||
# has arrived yet. Avoid treating a schema-heavy rough
|
||||
# post-compression estimate as real context pressure.
|
||||
_real_tokens = 0
|
||||
else:
|
||||
# Include tool schemas — with 50+ tools enabled
|
||||
# these add 20-30K tokens the messages-only
|
||||
@@ -4443,6 +4480,55 @@ def run_conversation(
|
||||
except Exception as _ver_err:
|
||||
logger.debug("file-mutation verifier footer failed: %s", _ver_err)
|
||||
|
||||
# Turn-completion explainer.
|
||||
# When a turn ends abnormally after substantive work — empty content
|
||||
# after retries, a partial/truncated stream, a still-pending tool
|
||||
# result, or an iteration/budget limit — the user otherwise gets a
|
||||
# blank or fragmentary response box with no consolidated reason why
|
||||
# the agent stopped (#34452). Surface a single user-visible
|
||||
# explanation derived from ``_turn_exit_reason``, mirroring the
|
||||
# file-mutation verifier footer pattern above.
|
||||
#
|
||||
# Gate carefully so healthy turns stay quiet:
|
||||
# - ``text_response(...)`` exits never produce an explanation
|
||||
# (handled inside the formatter), so a terse ``Done.`` is silent.
|
||||
# - We only ACT when there is no genuinely usable reply this turn:
|
||||
# an empty response, the "(empty)" terminal sentinel, or a
|
||||
# suspiciously short partial fragment with no terminating
|
||||
# punctuation (e.g. "The"). A real short answer keeps its text.
|
||||
if not interrupted:
|
||||
try:
|
||||
if agent._turn_completion_explainer_enabled():
|
||||
_stripped = (final_response or "").strip()
|
||||
_is_empty_terminal = _stripped == "" or _stripped == "(empty)"
|
||||
# A short fragment that is not a normal text_response exit
|
||||
# and lacks sentence-ending punctuation is treated as a
|
||||
# truncated partial (the "The" case from #34452).
|
||||
_is_partial_fragment = (
|
||||
not _is_empty_terminal
|
||||
and not str(_turn_exit_reason).startswith("text_response")
|
||||
and len(_stripped) <= 24
|
||||
and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"}
|
||||
)
|
||||
if _is_empty_terminal or _is_partial_fragment:
|
||||
_explanation = agent._format_turn_completion_explanation(
|
||||
_turn_exit_reason
|
||||
)
|
||||
if _explanation:
|
||||
if _is_empty_terminal:
|
||||
# Replace the bare "(empty)"/blank sentinel with
|
||||
# the actionable explanation.
|
||||
final_response = _explanation
|
||||
else:
|
||||
# Keep the partial fragment, append the reason so
|
||||
# the user sees both what arrived and why it
|
||||
# stopped.
|
||||
final_response = (
|
||||
_stripped + "\n\n" + _explanation
|
||||
)
|
||||
except Exception as _exp_err:
|
||||
logger.debug("turn-completion explainer failed: %s", _exp_err)
|
||||
|
||||
_response_transformed = False
|
||||
|
||||
# Plugin hook: transform_llm_output
|
||||
|
||||
@@ -3248,6 +3248,12 @@ class HermesCLI:
|
||||
self._slash_confirm_state = None
|
||||
self._slash_confirm_deadline = 0
|
||||
self._model_picker_state = None
|
||||
# Armed when a bare `/resume` prints the recent-sessions list so the
|
||||
# very next bare numeric input (e.g. `3`) resolves to that session.
|
||||
# Holds the exact list used for index resolution; one-shot (cleared on
|
||||
# the next submitted input, whether it's the selection or anything
|
||||
# else). See #34584.
|
||||
self._pending_resume_sessions = None
|
||||
self._secret_state = None
|
||||
self._secret_deadline = 0
|
||||
self._spinner_text: str = "" # thinking spinner text for TUI
|
||||
@@ -6693,10 +6699,21 @@ class HermesCLI:
|
||||
if not target:
|
||||
_cprint(" Usage: /resume <number|session_id_or_title>")
|
||||
if self._show_recent_sessions(reason="resume"):
|
||||
# Arm a one-shot pending-resume selection so the user can type
|
||||
# just the number (`3`) on the next line instead of having to
|
||||
# retype `/resume 3`. The list here must match the one shown by
|
||||
# _show_recent_sessions and used for index resolution below —
|
||||
# all three go through _list_recent_sessions(limit=10). See
|
||||
# #34584.
|
||||
self._pending_resume_sessions = self._list_recent_sessions(limit=10)
|
||||
return
|
||||
_cprint(" Tip: Use /history or `hermes sessions list` to find sessions.")
|
||||
return
|
||||
|
||||
# Any explicit /resume <target> supersedes a previously-armed bare
|
||||
# numbered prompt.
|
||||
self._pending_resume_sessions = None
|
||||
|
||||
if not self._session_db:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
_cprint(f" {format_session_db_unavailable()}")
|
||||
@@ -6810,6 +6827,44 @@ class HermesCLI:
|
||||
else:
|
||||
_cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.")
|
||||
|
||||
def _consume_pending_resume_selection(self, text: str) -> bool:
|
||||
"""Resolve a bare numeric reply that follows a bare ``/resume`` prompt.
|
||||
|
||||
After ``/resume`` (no args) prints the recent-sessions list it arms
|
||||
``self._pending_resume_sessions``. The next submitted input is given
|
||||
one chance to be a bare session number (``3``); if so we resume that
|
||||
session here. Anything else (another command, free text, blank) simply
|
||||
disarms the prompt and is handled normally by the caller.
|
||||
|
||||
Returns True if the input was consumed as a resume selection (caller
|
||||
must not treat it as chat); False otherwise. The pending state is
|
||||
always one-shot: it is cleared on the first submitted input regardless
|
||||
of outcome. See #34584.
|
||||
"""
|
||||
pending = self._pending_resume_sessions
|
||||
if not pending:
|
||||
return False
|
||||
# One-shot: disarm now so a non-matching input can't leave the prompt
|
||||
# armed and hijack a later number the user meant as chat.
|
||||
self._pending_resume_sessions = None
|
||||
|
||||
if not isinstance(text, str):
|
||||
return False
|
||||
stripped = text.strip()
|
||||
# Only a pure number selects; let "/resume 3", titles, or any other
|
||||
# text fall through to normal handling.
|
||||
if not stripped.isdigit():
|
||||
return False
|
||||
|
||||
index = int(stripped)
|
||||
if index < 1 or index > len(pending):
|
||||
_cprint(f" Resume index {index} is out of range.")
|
||||
_cprint(" Use /resume with no arguments to see available sessions.")
|
||||
return True
|
||||
|
||||
self._handle_resume_command(f"/resume {index}")
|
||||
return True
|
||||
|
||||
def _handle_sessions_command(self, cmd_original: str) -> None:
|
||||
"""Handle /sessions [list|<id_or_title>] — browse or resume previous sessions.
|
||||
|
||||
@@ -8333,7 +8388,14 @@ class HermesCLI:
|
||||
_base_word = cmd_lower.split()[0].lstrip("/")
|
||||
_cmd_def = _resolve_cmd(_base_word)
|
||||
canonical = _cmd_def.name if _cmd_def else _base_word
|
||||
|
||||
|
||||
# A bare `/resume` prompt is one-shot: any command other than the
|
||||
# resume/sessions handlers (which manage the pending state themselves)
|
||||
# disarms it so a later number isn't swallowed as a stale selection.
|
||||
# See #34584.
|
||||
if canonical not in {"resume", "sessions"}:
|
||||
self._pending_resume_sessions = None
|
||||
|
||||
if canonical in {"quit", "exit"}:
|
||||
# Parse --delete flag: /exit --delete also removes the current
|
||||
# session's transcripts + SQLite history. Ported from
|
||||
@@ -9885,10 +9947,20 @@ class HermesCLI:
|
||||
def _manual_compress(self, cmd_original: str = ""):
|
||||
"""Manually trigger context compression on the current conversation.
|
||||
|
||||
Accepts an optional focus topic: ``/compress <focus>`` guides the
|
||||
summariser to preserve information related to *focus* while being
|
||||
more aggressive about discarding everything else. Inspired by
|
||||
Claude Code's ``/compact <focus>`` feature.
|
||||
Two modes:
|
||||
|
||||
* ``/compress [<focus>]`` — compress the *whole* history. An
|
||||
optional focus topic guides the summariser to preserve
|
||||
information related to *focus* while being more aggressive
|
||||
about discarding everything else. Inspired by Claude Code's
|
||||
``/compact <focus>`` feature.
|
||||
* ``/compress here [N]`` — boundary-aware compression. Summarize
|
||||
everything *except* the most recent ``N`` exchanges (default
|
||||
2), which are preserved verbatim. Inspired by Claude Code's
|
||||
Rewind "Summarize up to here" action (v2.1.139, May 2026,
|
||||
https://code.claude.com/docs/en/whats-new/2026-w20). Lets the
|
||||
user pick the compression boundary instead of leaving it to
|
||||
the automatic token-budget heuristic.
|
||||
"""
|
||||
if not self.conversation_history or len(self.conversation_history) < 4:
|
||||
print("(._.) Not enough conversation to compress (need at least 4 messages).")
|
||||
@@ -9902,12 +9974,21 @@ class HermesCLI:
|
||||
print("(._.) Compression is disabled in config.")
|
||||
return
|
||||
|
||||
# Extract optional focus topic from the command (e.g. "/compress database schema")
|
||||
focus_topic = ""
|
||||
from hermes_cli.partial_compress import (
|
||||
parse_partial_compress_args,
|
||||
rejoin_compressed_head_and_tail,
|
||||
split_history_for_partial_compress,
|
||||
)
|
||||
|
||||
# Args after the command word (e.g. "/compress here 3" -> "here 3").
|
||||
raw_args = ""
|
||||
if cmd_original:
|
||||
parts = cmd_original.strip().split(None, 1)
|
||||
if len(parts) > 1:
|
||||
focus_topic = parts[1].strip()
|
||||
_parts = cmd_original.strip().split(None, 1)
|
||||
if len(_parts) > 1:
|
||||
raw_args = _parts[1].strip()
|
||||
|
||||
partial, keep_last, focus_topic = parse_partial_compress_args(raw_args)
|
||||
focus_topic = focus_topic or ""
|
||||
|
||||
original_count = len(self.conversation_history)
|
||||
with self._busy_command("Compressing context..."):
|
||||
@@ -9915,6 +9996,22 @@ class HermesCLI:
|
||||
from agent.model_metadata import estimate_request_tokens_rough
|
||||
from agent.manual_compression_feedback import summarize_manual_compression
|
||||
original_history = list(self.conversation_history)
|
||||
|
||||
# Boundary-aware split: only the head is summarized; the
|
||||
# most recent `keep_last` exchanges ride along verbatim.
|
||||
tail: list = []
|
||||
head = original_history
|
||||
if partial:
|
||||
head, tail = split_history_for_partial_compress(
|
||||
original_history, keep_last
|
||||
)
|
||||
if not tail:
|
||||
# Split degenerated (everything would be kept, or
|
||||
# no head left to compress). Fall back to full
|
||||
# compression so the user still gets an action.
|
||||
partial = False
|
||||
head = original_history
|
||||
|
||||
# Include system prompt + tool schemas in the estimate —
|
||||
# a transcript-only number understates real request pressure
|
||||
# and can even appear to grow after compression because a
|
||||
@@ -9926,7 +10023,11 @@ class HermesCLI:
|
||||
system_prompt=_sys_prompt,
|
||||
tools=_tools,
|
||||
)
|
||||
if focus_topic:
|
||||
if partial:
|
||||
print(f"🗜️ Summarizing up to here: compressing {len(head)} of "
|
||||
f"{original_count} messages (~{approx_tokens:,} tokens), "
|
||||
f"keeping last {keep_last} exchange(s) verbatim...")
|
||||
elif focus_topic:
|
||||
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens), "
|
||||
f"focus: \"{focus_topic}\"...")
|
||||
else:
|
||||
@@ -9939,12 +10040,21 @@ class HermesCLI:
|
||||
# which already contain the agent identity — resulting in the
|
||||
# identity block appearing twice (issue #15281).
|
||||
compressed, _ = self.agent._compress_context(
|
||||
original_history,
|
||||
head,
|
||||
None,
|
||||
approx_tokens=approx_tokens,
|
||||
focus_topic=focus_topic or None,
|
||||
force=True,
|
||||
)
|
||||
# Re-append the verbatim tail after the compressed head.
|
||||
# The split guarantees `tail` begins on a user turn, so the
|
||||
# compressed-head -> tail boundary is normally valid
|
||||
# (the head's compressed output ends on assistant/tool).
|
||||
# rejoin_compressed_head_and_tail() additionally guards the
|
||||
# seam against any illegal user->user / assistant->assistant
|
||||
# adjacency, defending provider role-alternation rules.
|
||||
if partial and tail:
|
||||
compressed = rejoin_compressed_head_and_tail(compressed, tail)
|
||||
self.conversation_history = compressed
|
||||
# _compress_context ends the old session and creates a new child
|
||||
# session on the agent (run_agent.py::_compress_context). Sync the
|
||||
@@ -12835,6 +12945,13 @@ class HermesCLI:
|
||||
if event.app.is_running:
|
||||
event.app.exit()
|
||||
event.app.current_buffer.reset(append_to_history=True)
|
||||
# Force a repaint: process_command() prints through
|
||||
# patch_stdout (scrolls output above the prompt) and never
|
||||
# invalidates the app, so the just-cleared input area can
|
||||
# keep showing the submitted text until some unrelated
|
||||
# redraw fires. Every other early-return branch in this
|
||||
# handler invalidates after reset — match them.
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Handle /steer while the agent is running immediately on the
|
||||
@@ -12846,6 +12963,13 @@ class HermesCLI:
|
||||
if self._should_handle_steer_command_inline(text, has_images=has_images):
|
||||
self.process_command(text)
|
||||
event.app.current_buffer.reset(append_to_history=True)
|
||||
# Force a repaint after clearing the buffer. /steer is
|
||||
# dispatched mid-run while the agent streams output through
|
||||
# patch_stdout; process_command() never invalidates the
|
||||
# app, so without this the submitted "/steer <text>" can
|
||||
# linger in the input area (looking unsent) and invite an
|
||||
# accidental re-submit. See issue #34569.
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Snapshot and clear attached images
|
||||
@@ -14543,6 +14667,17 @@ class HermesCLI:
|
||||
+ (f"\n{_remainder}" if _remainder else "")
|
||||
)
|
||||
|
||||
# A bare number right after a bare `/resume` prompt selects
|
||||
# that session (see #34584). Checked before chat routing so
|
||||
# the digit isn't sent to the agent as a message.
|
||||
if (
|
||||
not _file_drop
|
||||
and self._pending_resume_sessions
|
||||
and isinstance(user_input, str)
|
||||
and self._consume_pending_resume_selection(user_input)
|
||||
):
|
||||
continue
|
||||
|
||||
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
|
||||
_cprint(f"\n⚙️ {user_input}")
|
||||
try:
|
||||
|
||||
@@ -474,6 +474,13 @@ class GatewayConfig:
|
||||
|
||||
# Delivery settings
|
||||
always_log_local: bool = True # Always save cron outputs to local files
|
||||
# Drop outbound "silence narration" messages (e.g. *(silent)*, 🔇, a bare
|
||||
# ".") pre-send. These are model hallucinations emitted when a persona has
|
||||
# nothing actionable to say; in bot-to-bot channels they mirror back and
|
||||
# forth, burning tokens and crashing models. Substrate-level guard that
|
||||
# survives SOUL.md/prompt drift across providers. Opt out with False for
|
||||
# raw passthrough.
|
||||
filter_silence_narration: bool = True
|
||||
|
||||
# STT settings
|
||||
stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages
|
||||
@@ -582,6 +589,7 @@ class GatewayConfig:
|
||||
"quick_commands": self.quick_commands,
|
||||
"sessions_dir": str(self.sessions_dir),
|
||||
"always_log_local": self.always_log_local,
|
||||
"filter_silence_narration": self.filter_silence_narration,
|
||||
"stt_enabled": self.stt_enabled,
|
||||
"group_sessions_per_user": self.group_sessions_per_user,
|
||||
"thread_sessions_per_user": self.thread_sessions_per_user,
|
||||
@@ -650,6 +658,9 @@ class GatewayConfig:
|
||||
quick_commands=quick_commands,
|
||||
sessions_dir=sessions_dir,
|
||||
always_log_local=_coerce_bool(data.get("always_log_local"), True),
|
||||
filter_silence_narration=_coerce_bool(
|
||||
data.get("filter_silence_narration"), True
|
||||
),
|
||||
stt_enabled=_coerce_bool(stt_enabled, True),
|
||||
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
|
||||
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
|
||||
@@ -757,6 +768,11 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if "always_log_local" in yaml_cfg:
|
||||
gw_data["always_log_local"] = yaml_cfg["always_log_local"]
|
||||
|
||||
if "filter_silence_narration" in yaml_cfg:
|
||||
gw_data["filter_silence_narration"] = yaml_cfg[
|
||||
"filter_silence_narration"
|
||||
]
|
||||
|
||||
if "unauthorized_dm_behavior" in yaml_cfg:
|
||||
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
|
||||
yaml_cfg.get("unauthorized_dm_behavior"),
|
||||
|
||||
@@ -9,6 +9,8 @@ Routes messages to the appropriate destination based on:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
@@ -21,6 +23,32 @@ logger = logging.getLogger(__name__)
|
||||
MAX_PLATFORM_OUTPUT = 4000
|
||||
TRUNCATED_VISIBLE = 3800
|
||||
|
||||
# Matches strings that are *only* a "silence" narration with optional markdown
|
||||
# wrappers. Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent,
|
||||
# 🔇, a bare ".", "…", and the whitespace/marker-padded variants seen in the
|
||||
# wild. Anchored to start/end so substantive messages that merely *contain* the
|
||||
# word "silent" are never matched.
|
||||
_SILENCE_NARRATION = re.compile(
|
||||
r'^[\s*_~`]*\(?\s*(silent|silence|no\s+response|no\s+reply)\s*\.?\)?[\s*_~`]*$'
|
||||
r'|^[\s*_~`]*[\U0001F507\.\u2026]+[\s*_~`]*$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_silence_narration(content: Optional[str]) -> bool:
|
||||
"""Return True when ``content`` is *only* a silence-narration token.
|
||||
|
||||
Length-guarded (real messages are longer) and anchored to the whole string
|
||||
so legitimate prose like "The deployment ran silently" or "Silence is
|
||||
golden — here is the plan..." is never flagged.
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
stripped = content.strip()
|
||||
if not stripped or len(stripped) > 64: # length guard
|
||||
return False
|
||||
return bool(_SILENCE_NARRATION.match(stripped))
|
||||
|
||||
from .config import Platform, GatewayConfig
|
||||
from .session import SessionSource
|
||||
|
||||
@@ -261,6 +289,18 @@ class DeliveryRouter:
|
||||
path.write_text(content)
|
||||
return path
|
||||
|
||||
def _filter_silence_narration_enabled(self) -> bool:
|
||||
"""Whether the outbound silence-narration filter is active.
|
||||
|
||||
``HERMES_FILTER_SILENCE_NARRATION`` env var overrides config when set;
|
||||
otherwise the ``gateway.filter_silence_narration`` config flag wins
|
||||
(default True).
|
||||
"""
|
||||
env = os.getenv("HERMES_FILTER_SILENCE_NARRATION")
|
||||
if env is not None:
|
||||
return env.strip().lower() in ("1", "true", "yes", "on")
|
||||
return bool(getattr(self.config, "filter_silence_narration", True))
|
||||
|
||||
async def _deliver_to_platform(
|
||||
self,
|
||||
target: DeliveryTarget,
|
||||
@@ -286,6 +326,27 @@ class DeliveryRouter:
|
||||
+ f"\n\n... [truncated, full output saved to {saved_path}]"
|
||||
)
|
||||
|
||||
# Substrate-level anti-loop guard: drop hallucinated "silence narration"
|
||||
# (*(silent)*, 🔇, a bare ".", etc.) before it ever reaches the adapter.
|
||||
# In bot-to-bot channels these tokens mirror back and forth until a
|
||||
# model crashes with "no content after all retries". Behavioral prompt
|
||||
# rules drift across providers; this single chokepoint covers every
|
||||
# platform adapter regardless of which persona's prompt failed.
|
||||
# Local/file delivery (_deliver_local) is a separate path and is never
|
||||
# filtered — saved silence has no loop risk.
|
||||
if self._filter_silence_narration_enabled() and _is_silence_narration(content):
|
||||
logger.warning(
|
||||
"Dropped silence-narration outbound to %s (chat=%s): %r",
|
||||
target.platform.value,
|
||||
target.chat_id,
|
||||
content[:40],
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"filtered": "silence_narration",
|
||||
"delivered": False,
|
||||
}
|
||||
|
||||
send_metadata = dict(metadata or {})
|
||||
is_named_telegram_private_topic = False
|
||||
named_telegram_private_topic_name: Optional[str] = None
|
||||
|
||||
+35
-4
@@ -12449,6 +12449,12 @@ class GatewayRunner:
|
||||
Accepts an optional focus topic: ``/compress <focus>`` guides the
|
||||
summariser to preserve information related to *focus* while being
|
||||
more aggressive about discarding everything else.
|
||||
|
||||
Also accepts the boundary-aware form ``/compress here [N]``:
|
||||
summarize everything except the most recent ``N`` exchanges
|
||||
(default 2), kept verbatim. Inspired by Claude Code's Rewind
|
||||
"Summarize up to here" action (v2.1.139, May 2026,
|
||||
https://code.claude.com/docs/en/whats-new/2026-w20).
|
||||
"""
|
||||
source = event.source
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
@@ -12457,8 +12463,15 @@ class GatewayRunner:
|
||||
if not history or len(history) < 4:
|
||||
return t("gateway.compress.not_enough")
|
||||
|
||||
# Extract optional focus topic from command args
|
||||
focus_topic = (event.get_command_args() or "").strip() or None
|
||||
# Parse args: either a focus topic (full compress) or the
|
||||
# boundary-aware "here [N]" form (partial compress).
|
||||
from hermes_cli.partial_compress import (
|
||||
parse_partial_compress_args,
|
||||
rejoin_compressed_head_and_tail,
|
||||
split_history_for_partial_compress,
|
||||
)
|
||||
_raw_args = (event.get_command_args() or "").strip()
|
||||
partial, keep_last, focus_topic = parse_partial_compress_args(_raw_args)
|
||||
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
@@ -12479,6 +12492,19 @@ class GatewayRunner:
|
||||
if m.get("role") in {"user", "assistant"} and m.get("content")
|
||||
]
|
||||
|
||||
# Boundary-aware split: only the head is summarized; the most
|
||||
# recent `keep_last` exchanges are preserved verbatim. The
|
||||
# split snaps the tail to a user-turn start so the rejoined
|
||||
# transcript keeps role alternation valid.
|
||||
tail: list = []
|
||||
head = msgs
|
||||
if partial:
|
||||
head, tail = split_history_for_partial_compress(msgs, keep_last)
|
||||
if not tail:
|
||||
# Degenerate split — fall back to full compression.
|
||||
partial = False
|
||||
head = msgs
|
||||
|
||||
tmp_agent = AIAgent(
|
||||
**runtime_kwargs,
|
||||
model=model,
|
||||
@@ -12502,15 +12528,20 @@ class GatewayRunner:
|
||||
)
|
||||
|
||||
compressor = tmp_agent.context_compressor
|
||||
if not compressor.has_content_to_compress(msgs):
|
||||
if not compressor.has_content_to_compress(head):
|
||||
return t("gateway.compress.nothing_to_do")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
compressed, _ = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True)
|
||||
lambda: tmp_agent._compress_context(head, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True)
|
||||
)
|
||||
|
||||
# Re-append the verbatim tail after the compressed head,
|
||||
# guarding the seam against illegal role adjacency.
|
||||
if partial and tail:
|
||||
compressed = rejoin_compressed_head_and_tail(compressed, tail)
|
||||
|
||||
# _compress_context already calls end_session() on the old session
|
||||
# (preserving its full transcript in SQLite) and creates a new
|
||||
# session_id for the continuation. Write the compressed messages
|
||||
|
||||
+24
-2
@@ -221,7 +221,11 @@ def check_for_updates() -> Optional[int]:
|
||||
cache_file = hermes_home / ".update_check"
|
||||
embedded_rev = os.environ.get("HERMES_REVISION") or None
|
||||
|
||||
# Read cache — invalidate if the embedded rev has changed since last check
|
||||
# Read cache — invalidate if the embedded rev OR installed version has
|
||||
# changed since the last check. The version guard matters for pip installs:
|
||||
# `check_via_pypi()` compares against VERSION, so a `pip install --upgrade`
|
||||
# changes VERSION but leaves rev unchanged (both None), and without this
|
||||
# the stale "behind" count would survive the upgrade for up to 6h. See #34491.
|
||||
now = time.time()
|
||||
try:
|
||||
if cache_file.exists():
|
||||
@@ -229,6 +233,7 @@ def check_for_updates() -> Optional[int]:
|
||||
if (
|
||||
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
|
||||
and cached.get("rev") == embedded_rev
|
||||
and cached.get("ver") == VERSION
|
||||
):
|
||||
return cached.get("behind")
|
||||
except Exception:
|
||||
@@ -249,7 +254,9 @@ def check_for_updates() -> Optional[int]:
|
||||
behind = _check_via_local_git(repo_dir)
|
||||
|
||||
try:
|
||||
cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))
|
||||
cache_file.write_text(
|
||||
json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION})
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -691,6 +698,21 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
except Exception:
|
||||
pass # Never break the banner over an update check
|
||||
|
||||
# Pip-install warning — `pip install hermes-agent` is not the supported
|
||||
# install path (it exists on PyPI for internal/CI reasons, not end users).
|
||||
# Such installs miss the git checkout + installer-managed deps, so updates,
|
||||
# self-update, and issue triage don't behave correctly. Warn, don't block.
|
||||
try:
|
||||
from hermes_cli.config import detect_install_method
|
||||
if detect_install_method() == "pip":
|
||||
right_lines.append(
|
||||
"[bold yellow]⚠ pip install not officially supported[/]"
|
||||
"[dim yellow] — exists for reasons other than user install; "
|
||||
"expect instability and an inability to support issues[/]"
|
||||
)
|
||||
except Exception:
|
||||
pass # Never break the banner over the install-method check
|
||||
|
||||
right_content = "\n".join(right_lines)
|
||||
layout_table.add_row(left_content, right_content)
|
||||
|
||||
|
||||
@@ -85,8 +85,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
args_hint="<platform>", cli_only=True),
|
||||
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
|
||||
aliases=("fork",), args_hint="[name]"),
|
||||
CommandDef("compress", "Manually compress conversation context", "Session",
|
||||
args_hint="[focus topic]"),
|
||||
CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns)", "Session",
|
||||
args_hint="[here [N] | focus topic]"),
|
||||
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
|
||||
args_hint="[number]"),
|
||||
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
|
||||
|
||||
@@ -1202,6 +1202,13 @@ DEFAULT_CONFIG = {
|
||||
# class of over-claim that otherwise forces users to run
|
||||
# `git status` to verify edits landed. Set false to suppress.
|
||||
"file_mutation_verifier": True,
|
||||
# Turn-completion explainer. When true (default), the agent appends a
|
||||
# one-line explanation to its final response whenever a turn ends
|
||||
# abnormally with no usable reply — empty content after retries, a
|
||||
# partial/truncated stream, a still-pending tool result, or an
|
||||
# iteration/budget limit. Replaces the bare "(empty)" sentinel so the
|
||||
# failure isn't silent from the UI's perspective. Set false to suppress.
|
||||
"turn_completion_explainer": True,
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
# UI language for static user-facing messages (approval prompts, a
|
||||
|
||||
+11
-10
@@ -207,9 +207,11 @@ def _graceful_restart_via_sigusr1(pid: int, drain_timeout: float) -> bool:
|
||||
|
||||
SIGUSR1 is wired in gateway/run.py to ``request_restart(via_service=True)``
|
||||
which drains in-flight agent runs (up to ``agent.restart_drain_timeout``
|
||||
seconds), then exits with code 75. Both systemd (``Restart=always``
|
||||
+ ``RestartForceExitStatus=75``) and launchd (``KeepAlive.SuccessfulExit
|
||||
= false``) relaunch the process after the graceful exit.
|
||||
seconds), then exits with code 75. Systemd units generated by Hermes use
|
||||
``Restart=on-failure`` together with ``RestartForceExitStatus=75`` so the
|
||||
service is relaunched after the graceful exit without reviving clean
|
||||
``--replace`` takeovers. launchd still uses ``KeepAlive.SuccessfulExit =
|
||||
false`` for the same relaunch behavior.
|
||||
|
||||
This is the drain-aware alternative to ``systemctl restart`` / ``SIGTERM``,
|
||||
which SIGKILL in-flight agents after a short timeout.
|
||||
@@ -565,7 +567,7 @@ def _gateway_run_args_for_profile(profile: str) -> list[str]:
|
||||
args = [get_python_path(), "-m", "hermes_cli.main"]
|
||||
if profile != "default":
|
||||
args.extend(["--profile", profile])
|
||||
args.extend(["gateway", "run", "--replace"])
|
||||
args.extend(["gateway", "run"])
|
||||
return args
|
||||
|
||||
|
||||
@@ -2240,7 +2242,7 @@ StartLimitIntervalSec=0
|
||||
Type=simple
|
||||
User={username}
|
||||
Group={group_name}
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run
|
||||
WorkingDirectory={working_dir}
|
||||
Environment="HOME={home_dir}"
|
||||
Environment="USER={username}"
|
||||
@@ -2248,7 +2250,7 @@ Environment="LOGNAME={username}"
|
||||
Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
Restart=always
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
RestartMaxDelaySec=300
|
||||
RestartSteps=5
|
||||
@@ -2278,12 +2280,12 @@ StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run
|
||||
WorkingDirectory={working_dir}
|
||||
Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
Restart=always
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
RestartMaxDelaySec=300
|
||||
RestartSteps=5
|
||||
@@ -2875,7 +2877,6 @@ def generate_launchd_plist() -> str:
|
||||
prog_args.extend([
|
||||
"<string>gateway</string>",
|
||||
"<string>run</string>",
|
||||
"<string>--replace</string>",
|
||||
])
|
||||
prog_args_xml = "\n ".join(prog_args)
|
||||
|
||||
@@ -3270,7 +3271,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
print()
|
||||
|
||||
# Exit with code 1 if gateway fails to connect any platform,
|
||||
# so systemd Restart=always will retry on transient errors
|
||||
# so systemd Restart=on-failure will retry on transient errors
|
||||
verbosity = None if quiet else verbose
|
||||
|
||||
# ── Exit-path diagnostics ────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Boundary-aware partial compression — "summarize up to here".
|
||||
|
||||
Inspired by Claude Code's Rewind menu "Summarize up to here" action
|
||||
(v2.1.139–v2.1.142, Week 20, May 2026):
|
||||
https://code.claude.com/docs/en/whats-new/2026-w20
|
||||
|
||||
Hermes already has ``/compress`` (full-history compaction) and an
|
||||
automatic token-budget tail-protection heuristic inside
|
||||
``ContextCompressor``. What was missing is *user-chosen* boundary
|
||||
control: "fold everything before this point into a summary, but keep
|
||||
my most recent N exchanges exactly as they are." That is the value of
|
||||
the Claude Code feature — the user decides the compression boundary
|
||||
instead of leaving it to the token-budget heuristic.
|
||||
|
||||
This module owns the pure, side-effect-free split logic so both the
|
||||
CLI (``cli.py::_manual_compress``) and the gateway
|
||||
(``gateway/run.py::_handle_compress_command``) share one
|
||||
implementation. The slash-command surfaces handle compression of the
|
||||
*head* via the existing ``_compress_context`` pipeline (preserving all
|
||||
the session-rotation / lock / memory-notify machinery) and then
|
||||
re-append the verbatim *tail* returned here.
|
||||
|
||||
Design notes / invariants honored:
|
||||
|
||||
* **Role alternation.** The compressed head ends with summary/handoff
|
||||
content (assistant- or user-role, possibly a trailing todo snapshot).
|
||||
The verbatim tail must begin with a ``user`` message so the rejoined
|
||||
history keeps the user↔assistant alternation that providers validate.
|
||||
:func:`split_history_for_partial_compress` snaps the tail boundary
|
||||
backwards to the nearest ``user`` turn so the rejoin is always legal.
|
||||
|
||||
* **No silent context mutation.** This is a manual, user-invoked
|
||||
action. It rotates the session exactly like ``/compress`` does (via
|
||||
the caller), so the prompt-cache reset is explicit and expected, not
|
||||
silent.
|
||||
|
||||
* **Conservative defaults.** ``keep_last`` counts *exchanges* (a user
|
||||
turn plus its following assistant/tool turns), defaulting to 2. The
|
||||
split never compresses if doing so would leave nothing in the head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
#: Default number of recent exchanges to preserve verbatim when the user
|
||||
#: runs ``/compress here`` without an explicit count.
|
||||
DEFAULT_KEEP_LAST = 2
|
||||
|
||||
#: Hard ceiling so a fat-fingered ``/compress here 9999`` doesn't turn
|
||||
#: into a no-op surprise — clamp instead.
|
||||
MAX_KEEP_LAST = 100
|
||||
|
||||
|
||||
def parse_partial_compress_args(
|
||||
raw_args: str,
|
||||
) -> Tuple[bool, int, Optional[str]]:
|
||||
"""Parse the argument string after ``/compress``.
|
||||
|
||||
Recognizes the boundary-aware forms:
|
||||
|
||||
* ``here`` → partial compress, keep ``DEFAULT_KEEP_LAST``
|
||||
* ``here 4`` → partial compress, keep 4 exchanges
|
||||
* ``--keep 4`` → partial compress, keep 4 exchanges
|
||||
* ``up to here`` → alias for ``here`` (matches Claude Code's
|
||||
menu label "Summarize up to here")
|
||||
|
||||
Anything else is treated as a focus topic for the existing full
|
||||
``/compress <focus>`` behavior.
|
||||
|
||||
Returns ``(partial, keep_last, focus_topic)``:
|
||||
|
||||
* ``partial`` — True when a boundary-aware form was requested.
|
||||
* ``keep_last`` — exchanges to preserve verbatim (only meaningful
|
||||
when ``partial`` is True).
|
||||
* ``focus_topic`` — focus string for full compression, or None.
|
||||
Always None when ``partial`` is True (the two modes are exclusive;
|
||||
a focused partial compress is not a documented Claude Code
|
||||
behavior and would muddy the UX).
|
||||
"""
|
||||
text = (raw_args or "").strip()
|
||||
if not text:
|
||||
return False, DEFAULT_KEEP_LAST, None
|
||||
|
||||
lowered = text.lower()
|
||||
|
||||
# Normalize the "up to here" alias to "here".
|
||||
if lowered.startswith("up to here"):
|
||||
lowered = lowered[len("up to ") :]
|
||||
text = text[len("up to ") :]
|
||||
|
||||
tokens = lowered.split()
|
||||
|
||||
# Form: here [N]
|
||||
if tokens and tokens[0] == "here":
|
||||
keep = DEFAULT_KEEP_LAST
|
||||
if len(tokens) >= 2:
|
||||
keep = _coerce_keep(tokens[1])
|
||||
return True, keep, None
|
||||
|
||||
# Form: --keep N (or --keep=N)
|
||||
if tokens and tokens[0] in ("--keep", "-k") and len(tokens) >= 2:
|
||||
return True, _coerce_keep(tokens[1]), None
|
||||
if tokens and tokens[0].startswith("--keep="):
|
||||
return True, _coerce_keep(tokens[0].split("=", 1)[1]), None
|
||||
|
||||
# Otherwise: full compression with this as the focus topic.
|
||||
return False, DEFAULT_KEEP_LAST, text or None
|
||||
|
||||
|
||||
def _coerce_keep(value: str) -> int:
|
||||
"""Parse a keep-count token, clamping to [1, MAX_KEEP_LAST]."""
|
||||
try:
|
||||
n = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_KEEP_LAST
|
||||
if n < 1:
|
||||
return 1
|
||||
if n > MAX_KEEP_LAST:
|
||||
return MAX_KEEP_LAST
|
||||
return n
|
||||
|
||||
|
||||
def split_history_for_partial_compress(
|
||||
history: List[Dict[str, Any]],
|
||||
keep_last: int,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""Split ``history`` into ``(head, tail)`` for partial compression.
|
||||
|
||||
``head`` is the earlier portion that will be summarized; ``tail`` is
|
||||
the most recent ``keep_last`` exchanges, preserved verbatim.
|
||||
|
||||
An *exchange* is counted by ``user``-role messages: keeping N
|
||||
exchanges means keeping everything from the Nth-most-recent ``user``
|
||||
message onward. This guarantees the tail starts on a ``user`` turn,
|
||||
so when the caller rejoins ``compressed_head + tail`` the
|
||||
user↔assistant alternation stays valid (the compressed head's
|
||||
trailing content is followed by a fresh user turn).
|
||||
|
||||
Returns ``(head, tail)``. If the split would leave the head empty
|
||||
(not enough history to compress meaningfully), returns
|
||||
``(history, [])`` — signaling the caller to fall back to full
|
||||
compression or report "nothing to do".
|
||||
"""
|
||||
if keep_last < 1:
|
||||
keep_last = 1
|
||||
|
||||
n = len(history)
|
||||
if n == 0:
|
||||
return [], []
|
||||
|
||||
# Walk backwards collecting the indices of the most recent `keep_last`
|
||||
# user-message starts. The tail begins at the earliest such index.
|
||||
user_starts: List[int] = []
|
||||
for idx in range(n - 1, -1, -1):
|
||||
if history[idx].get("role") == "user":
|
||||
user_starts.append(idx)
|
||||
if len(user_starts) >= keep_last:
|
||||
break
|
||||
|
||||
if not user_starts:
|
||||
# No user turns at all (degenerate) — nothing sensible to keep
|
||||
# as a "recent exchange"; treat as full compression.
|
||||
return list(history), []
|
||||
|
||||
boundary = user_starts[-1] # earliest of the kept user starts
|
||||
|
||||
head = history[:boundary]
|
||||
tail = history[boundary:]
|
||||
|
||||
# If everything is in the tail (nothing left to compress), signal the
|
||||
# caller to fall back to full compression rather than producing a
|
||||
# no-op that rotates the session for no benefit.
|
||||
if not head:
|
||||
return list(history), []
|
||||
|
||||
return head, tail
|
||||
|
||||
|
||||
def rejoin_compressed_head_and_tail(
|
||||
compressed_head: List[Dict[str, Any]],
|
||||
tail: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Concatenate a compressed head with the verbatim tail, defending
|
||||
the seam against an illegal user→user / assistant→assistant adjacency.
|
||||
|
||||
In normal operation the compressed head ends with the head's own
|
||||
protected verbatim tail (the ``ContextCompressor`` always preserves a
|
||||
recent window), which terminates on an ``assistant``/``tool`` turn —
|
||||
so ``assistant → user`` at the seam is already valid. But the head
|
||||
compressor's exact output shape is not contractually guaranteed (a
|
||||
plugin context engine could return something that ends on a ``user``
|
||||
turn, or a degenerate single-summary message). Rather than trust the
|
||||
seam, this helper inspects the boundary and, if the last head message
|
||||
and the first tail message share a ``user``/``assistant`` role, folds
|
||||
the tail's first message content onto the head's last message so the
|
||||
rejoined list never violates provider role-alternation rules.
|
||||
|
||||
``tool`` messages are left alone — consecutive ``tool`` entries are
|
||||
the one legal repetition (parallel tool results).
|
||||
"""
|
||||
if not tail:
|
||||
return list(compressed_head)
|
||||
if not compressed_head:
|
||||
return list(tail)
|
||||
|
||||
head = list(compressed_head)
|
||||
rest = list(tail)
|
||||
|
||||
last = head[-1]
|
||||
first = rest[0]
|
||||
last_role = last.get("role")
|
||||
first_role = first.get("role")
|
||||
|
||||
if last_role == first_role and last_role in ("user", "assistant"):
|
||||
# Illegal adjacency. Merge the tail's first message text into the
|
||||
# head's last message so alternation is preserved. Only string
|
||||
# contents are merged inline; structured/multimodal contents fall
|
||||
# back to dropping the redundant standalone (the content is
|
||||
# preserved by concatenation when both are strings).
|
||||
last_content = last.get("content")
|
||||
first_content = first.get("content")
|
||||
if isinstance(last_content, str) and isinstance(first_content, str):
|
||||
merged = dict(last)
|
||||
merged["content"] = f"{last_content}\n\n{first_content}"
|
||||
head[-1] = merged
|
||||
rest = rest[1:]
|
||||
else:
|
||||
# Can't safely string-merge multimodal content. Insert a
|
||||
# minimal bridging turn so the seam alternates rather than
|
||||
# losing data.
|
||||
bridge_role = "assistant" if first_role == "user" else "user"
|
||||
head.append({"role": bridge_role, "content": ""})
|
||||
|
||||
return head + rest
|
||||
@@ -117,6 +117,49 @@ def remove_wrapper_script():
|
||||
return removed
|
||||
|
||||
|
||||
def remove_node_symlinks(hermes_home: Path) -> list:
|
||||
"""Remove the node/npm/npx symlinks the installer drops in ~/.local/bin.
|
||||
|
||||
The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
|
||||
creates::
|
||||
|
||||
~/.local/bin/node -> $HERMES_HOME/node/bin/node
|
||||
~/.local/bin/npm -> $HERMES_HOME/node/bin/npm
|
||||
~/.local/bin/npx -> $HERMES_HOME/node/bin/npx
|
||||
|
||||
and prepends ``~/.local/bin`` to PATH, so these shadow an existing Node
|
||||
manager such as nvm. Symmetrically remove them on uninstall, but *only*
|
||||
when the link still resolves into this Hermes home's ``node`` directory.
|
||||
A link the user has since repointed at nvm (or anything else outside
|
||||
Hermes) is left untouched so we never break unrelated tooling.
|
||||
"""
|
||||
node_dir = (hermes_home / "node").resolve()
|
||||
removed = []
|
||||
|
||||
for name in ("node", "npm", "npx"):
|
||||
link = Path.home() / ".local" / "bin" / name
|
||||
try:
|
||||
# Only act on symlinks — never delete a real binary the user put here.
|
||||
if not link.is_symlink():
|
||||
continue
|
||||
|
||||
# Resolve the link target and confirm it points into our node dir.
|
||||
# os.readlink + manual join handles broken (dangling) links too;
|
||||
# Path.resolve() on a dangling link still returns the target path.
|
||||
target = Path(os.readlink(link))
|
||||
if not target.is_absolute():
|
||||
target = (link.parent / target)
|
||||
target = target.resolve()
|
||||
|
||||
if target == node_dir or node_dir in target.parents:
|
||||
link.unlink()
|
||||
removed.append(link)
|
||||
except Exception as e:
|
||||
log_warn(f"Could not remove {link}: {e}")
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
def uninstall_gateway_service():
|
||||
"""Stop and uninstall the gateway service (systemd, launchd, Windows
|
||||
Scheduled Task / Startup folder) and kill any standalone gateway processes.
|
||||
@@ -594,6 +637,17 @@ def run_uninstall(args):
|
||||
log_success(f"Removed {wrapper}")
|
||||
else:
|
||||
log_info("No wrapper script found")
|
||||
|
||||
# 3b. Remove node/npm/npx symlinks the installer left in ~/.local/bin
|
||||
# (only when they still point into this Hermes home's node dir, so we
|
||||
# never clobber an existing nvm / user-managed Node).
|
||||
log_info("Removing Hermes-managed node/npm/npx symlinks...")
|
||||
removed_node_links = remove_node_symlinks(hermes_home)
|
||||
if removed_node_links:
|
||||
for link in removed_node_links:
|
||||
log_success(f"Removed {link}")
|
||||
else:
|
||||
log_info("No Hermes-managed node/npm/npx symlinks found")
|
||||
|
||||
# 4. Remove installation directory (code)
|
||||
log_info("Removing installation directory...")
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ gateway:
|
||||
title: "**Titel:** {title}"
|
||||
created: "**Geskep:** {timestamp}"
|
||||
last_activity: "**Laaste aktiwiteit:** {timestamp}"
|
||||
tokens: "**Tokens:** {tokens}"
|
||||
tokens: "**Kumulatiewe API-tokens (elke oproep weer gestuur):** {tokens}"
|
||||
agent_running: "**Agent loop:** {state}"
|
||||
state_yes: "Ja ⚡"
|
||||
state_no: "Nee"
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ gateway:
|
||||
title: "**Titel:** {title}"
|
||||
created: "**Erstellt:** {timestamp}"
|
||||
last_activity: "**Letzte Aktivität:** {timestamp}"
|
||||
tokens: "**Tokens:** {tokens}"
|
||||
tokens: "**Kumulierte API-Tokens (bei jedem Aufruf erneut gesendet):** {tokens}"
|
||||
agent_running: "**Agent läuft:** {state}"
|
||||
state_yes: "Ja ⚡"
|
||||
state_no: "Nein"
|
||||
|
||||
+1
-1
@@ -270,7 +270,7 @@ gateway:
|
||||
title: "**Title:** {title}"
|
||||
created: "**Created:** {timestamp}"
|
||||
last_activity: "**Last Activity:** {timestamp}"
|
||||
tokens: "**Tokens:** {tokens}"
|
||||
tokens: "**Cumulative API tokens (re-sent each call):** {tokens}"
|
||||
agent_running: "**Agent Running:** {state}"
|
||||
state_yes: "Yes ⚡"
|
||||
state_no: "No"
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ gateway:
|
||||
title: "**Título:** {title}"
|
||||
created: "**Creado:** {timestamp}"
|
||||
last_activity: "**Última actividad:** {timestamp}"
|
||||
tokens: "**Tokens:** {tokens}"
|
||||
tokens: "**Tokens de API acumulados (reenviados en cada llamada):** {tokens}"
|
||||
agent_running: "**Agente activo:** {state}"
|
||||
state_yes: "Sí ⚡"
|
||||
state_no: "No"
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ gateway:
|
||||
title: "**Título:** {title}"
|
||||
created: "**Criada:** {timestamp}"
|
||||
last_activity: "**Última atividade:** {timestamp}"
|
||||
tokens: "**Tokens:** {tokens}"
|
||||
tokens: "**Tokens de API cumulativos (reenviados a cada chamada):** {tokens}"
|
||||
agent_running: "**Agente em execução:** {state}"
|
||||
state_yes: "Sim ⚡"
|
||||
state_no: "Não"
|
||||
|
||||
@@ -535,7 +535,7 @@
|
||||
|
||||
restart = mkOption {
|
||||
type = types.str;
|
||||
default = "always";
|
||||
default = "on-failure";
|
||||
description = "systemd Restart= policy.";
|
||||
};
|
||||
|
||||
@@ -974,7 +974,7 @@
|
||||
--env MESSAGING_CWD=${containerWorkDir} \
|
||||
${lib.concatStringsSep " " cfg.container.extraOptions} \
|
||||
${cfg.container.image} \
|
||||
${containerDataDir}/current-package/bin/hermes gateway run --replace ${lib.concatStringsSep " " cfg.extraArgs}
|
||||
${containerDataDir}/current-package/bin/hermes gateway run ${lib.concatStringsSep " " cfg.extraArgs}
|
||||
|
||||
echo "${containerIdentity}" > ${identityFile}
|
||||
fi
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ edge-tts = ["edge-tts==7.2.7"]
|
||||
modal = ["modal==1.3.4"]
|
||||
daytona = ["daytona==0.155.0"]
|
||||
hindsight = ["hindsight-client==0.6.1"]
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10", "setuptools>=61.0,<83"]
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"]
|
||||
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
|
||||
cron = [] # croniter is now a core dependency; this extra kept for back-compat
|
||||
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"]
|
||||
|
||||
+120
@@ -2138,6 +2138,126 @@ class AIAgent:
|
||||
lines.append(f" • … and {remaining} more")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _turn_completion_explainer_enabled(self) -> bool:
|
||||
"""Check whether the end-of-turn completion explainer footer is on.
|
||||
|
||||
Config path: ``display.turn_completion_explainer`` (bool, default
|
||||
True). ``HERMES_TURN_COMPLETION_EXPLAINER`` env var overrides
|
||||
config. Exposed as a method so tests can patch a single seam,
|
||||
mirroring ``_file_mutation_verifier_enabled``.
|
||||
"""
|
||||
try:
|
||||
import os as _os
|
||||
env = _os.environ.get("HERMES_TURN_COMPLETION_EXPLAINER")
|
||||
if env is not None:
|
||||
return env.strip().lower() not in {"0", "false", "no", "off"}
|
||||
# Read from the persisted config.yaml so gateway and CLI share
|
||||
# the same setting. Import lazily to avoid a startup-time cycle.
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_config
|
||||
_cfg = _load_config() or {}
|
||||
except Exception:
|
||||
_cfg = {}
|
||||
_display = _cfg.get("display") if isinstance(_cfg, dict) else None
|
||||
if isinstance(_display, dict) and "turn_completion_explainer" in _display:
|
||||
return bool(_display.get("turn_completion_explainer"))
|
||||
except Exception:
|
||||
pass
|
||||
return True # safe default: explainer on
|
||||
|
||||
@staticmethod
|
||||
def _format_turn_completion_explanation(turn_exit_reason: str) -> str:
|
||||
"""Render a user-facing explanation for an abnormal turn ending.
|
||||
|
||||
Maps the internal ``turn_exit_reason`` to a short, actionable
|
||||
message so a turn that produced no usable assistant reply (empty
|
||||
content after retries, a partial/truncated stream, a still-pending
|
||||
tool result, or an iteration/budget limit) is never silent from
|
||||
the UI's perspective — the symptom users report in #34452.
|
||||
|
||||
Returns an empty string for reasons that are NOT abnormal (e.g.
|
||||
a normal ``text_response(...)`` exit), so callers can concatenate
|
||||
or substitute unconditionally without warning on healthy turns
|
||||
like a terse ``Done.``.
|
||||
"""
|
||||
if not turn_exit_reason:
|
||||
return ""
|
||||
reason = str(turn_exit_reason)
|
||||
|
||||
# Normal completion — stay quiet. ``text_response(...)`` is the
|
||||
# healthy terminal; anything that produced a real reply is fine.
|
||||
if reason.startswith("text_response"):
|
||||
return ""
|
||||
|
||||
prefix = "⚠️ No reply: "
|
||||
if reason == "empty_response_exhausted":
|
||||
return (
|
||||
prefix
|
||||
+ "the model returned empty content after retries and any "
|
||||
"fallback providers. Try `continue`, switch model/provider, "
|
||||
"or inspect the tool output above."
|
||||
)
|
||||
if reason == "all_retries_exhausted_no_response":
|
||||
return (
|
||||
prefix
|
||||
+ "all API retries were exhausted before a response was "
|
||||
"produced (provider errors / rate limits). Try `continue` "
|
||||
"or switch provider."
|
||||
)
|
||||
if reason == "partial_stream_recovery":
|
||||
return (
|
||||
prefix
|
||||
+ "streaming stopped early and only a partial response was "
|
||||
"recovered. Send `continue` to resume from where it stopped."
|
||||
)
|
||||
if reason == "fallback_prior_turn_content":
|
||||
return (
|
||||
prefix
|
||||
+ "no new content was produced this turn; showing recovered "
|
||||
"prior context. Send `continue` to retry."
|
||||
)
|
||||
if reason == "interrupted_during_api_call":
|
||||
return (
|
||||
prefix
|
||||
+ "the request was interrupted mid-call before a reply was "
|
||||
"received. Send `continue` to retry."
|
||||
)
|
||||
if reason == "budget_exhausted":
|
||||
return (
|
||||
prefix
|
||||
+ "the per-turn iteration/cost budget was exhausted before a "
|
||||
"final answer. Send `continue` to keep going."
|
||||
)
|
||||
if reason == "ollama_runtime_context_too_small":
|
||||
return (
|
||||
prefix
|
||||
+ "the local model's context window was too small to finish. "
|
||||
"Increase the context size or use a larger model."
|
||||
)
|
||||
if reason.startswith("max_iterations_reached"):
|
||||
return (
|
||||
prefix
|
||||
+ "the maximum tool-iteration limit was reached before a "
|
||||
"final answer. Send `continue` to keep going, or raise "
|
||||
"`max_iterations`."
|
||||
)
|
||||
if reason.startswith("error_near_max_iterations"):
|
||||
return (
|
||||
prefix
|
||||
+ "an error occurred near the iteration limit before a final "
|
||||
"answer. Check the tool output above, then send `continue`."
|
||||
)
|
||||
if reason == "pending_tool_result":
|
||||
return (
|
||||
prefix
|
||||
+ "the turn stopped while a tool result was still pending and "
|
||||
"the model produced no follow-up text. Send `continue` to "
|
||||
"let it summarize."
|
||||
)
|
||||
# Unknown/diagnostic-only reasons (e.g. "unknown", guardrail_halt
|
||||
# which already surfaces its own message) — don't second-guess.
|
||||
return ""
|
||||
|
||||
def _apply_pending_steer_to_tool_results(self, messages: list, num_tool_msgs: int) -> None:
|
||||
"""Forwarder — see ``agent.agent_runtime_helpers.apply_pending_steer_to_tool_results``."""
|
||||
from agent.agent_runtime_helpers import apply_pending_steer_to_tool_results
|
||||
|
||||
@@ -92,6 +92,7 @@ AUTHOR_MAP = {
|
||||
"steve@steveonjava.com": "steveonjava",
|
||||
"steveonjava@gmail.com": "steveonjava",
|
||||
"squiddy@2rook.ai": "MoonRay305",
|
||||
"annguyenNous@users.noreply.github.com": "annguyenNous",
|
||||
"32201324+simpolism@users.noreply.github.com": "simpolism",
|
||||
"simpolism@gmail.com": "simpolism",
|
||||
"jake@nousresearch.com": "simpolism",
|
||||
|
||||
@@ -88,6 +88,62 @@ class TestAuxiliaryMaxTokensParam:
|
||||
assert auxiliary_max_tokens_param(2048) == {"max_completion_tokens": 2048}
|
||||
|
||||
|
||||
class TestBuildCallKwargsMaxTokens:
|
||||
"""_build_call_kwargs should not cap output by default (#34530).
|
||||
|
||||
Most chat-completions providers treat an omitted max_tokens as "use the
|
||||
model max", which is what we want for auxiliary tasks. An explicit cap only
|
||||
risks truncation or a wire-format 400 (GitHub Copilot / GPT-5 reject
|
||||
max_tokens; ZAI vision rejects it entirely). The Anthropic Messages wire is
|
||||
the one exception — max_tokens is a mandatory field there.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider,model,base_url",
|
||||
[
|
||||
("copilot", "gpt-5.4", "https://api.githubcopilot.com"),
|
||||
("copilot", "gpt-5.5", "https://api.githubcopilot.com"),
|
||||
("custom", "gpt-5", "https://api.openai.com/v1"),
|
||||
("openrouter", "anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1"),
|
||||
("nous", "hermes-4", "https://inference-api.nousresearch.com/v1"),
|
||||
("custom", "qwen", "http://localhost:8080/v1"),
|
||||
("zai", "glm-4v-flash", "https://open.bigmodel.cn/api/paas/v4"),
|
||||
],
|
||||
)
|
||||
def test_omits_max_tokens_for_openai_compatible(self, provider, model, base_url):
|
||||
from agent.auxiliary_client import _build_call_kwargs
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
provider=provider,
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_tokens=1234,
|
||||
base_url=base_url,
|
||||
)
|
||||
assert "max_tokens" not in kwargs
|
||||
assert "max_completion_tokens" not in kwargs
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider,model,base_url",
|
||||
[
|
||||
("minimax", "minimax-m2", "https://api.minimax.io/v1"),
|
||||
("custom", "claude", "https://proxy.example.com/anthropic/v1"),
|
||||
],
|
||||
)
|
||||
def test_keeps_max_tokens_on_anthropic_wire(self, provider, model, base_url):
|
||||
from agent.auxiliary_client import _build_call_kwargs
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
provider=provider,
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_tokens=1234,
|
||||
base_url=base_url,
|
||||
)
|
||||
assert kwargs["max_tokens"] == 1234
|
||||
assert "max_completion_tokens" not in kwargs
|
||||
|
||||
|
||||
class TestNormalizeAuxProvider:
|
||||
def test_maps_github_copilot_aliases(self):
|
||||
assert _normalize_aux_provider("github") == "copilot"
|
||||
|
||||
@@ -41,6 +41,8 @@ class TestShouldCompress:
|
||||
|
||||
class TestUpdateFromResponse:
|
||||
def test_updates_fields(self, compressor):
|
||||
compressor.awaiting_real_usage_after_compression = True
|
||||
compressor.last_compression_rough_tokens = 90_000
|
||||
compressor.update_from_response({
|
||||
"prompt_tokens": 5000,
|
||||
"completion_tokens": 1000,
|
||||
@@ -48,12 +50,39 @@ class TestUpdateFromResponse:
|
||||
})
|
||||
assert compressor.last_prompt_tokens == 5000
|
||||
assert compressor.last_completion_tokens == 1000
|
||||
assert compressor.last_real_prompt_tokens == 5000
|
||||
assert compressor.last_rough_tokens_when_real_prompt_fit == 90_000
|
||||
assert compressor.awaiting_real_usage_after_compression is False
|
||||
|
||||
def test_missing_fields_default_zero(self, compressor):
|
||||
compressor.update_from_response({})
|
||||
assert compressor.last_prompt_tokens == 0
|
||||
|
||||
|
||||
class TestPreflightDeferral:
|
||||
def test_defers_when_recent_real_usage_fit_and_rough_growth_is_small(self, compressor):
|
||||
compressor.threshold_tokens = 85_000
|
||||
compressor.last_real_prompt_tokens = 50_000
|
||||
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
|
||||
|
||||
assert compressor.should_defer_preflight_to_real_usage(93_000) is True
|
||||
assert compressor.last_rough_tokens_when_real_prompt_fit == 93_000
|
||||
|
||||
def test_does_not_defer_when_rough_growth_is_large(self, compressor):
|
||||
compressor.threshold_tokens = 85_000
|
||||
compressor.last_real_prompt_tokens = 50_000
|
||||
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
|
||||
|
||||
assert compressor.should_defer_preflight_to_real_usage(100_000) is False
|
||||
|
||||
def test_does_not_defer_without_recent_real_usage(self, compressor):
|
||||
compressor.threshold_tokens = 85_000
|
||||
compressor.last_real_prompt_tokens = 0
|
||||
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
|
||||
|
||||
assert compressor.should_defer_preflight_to_real_usage(93_000) is False
|
||||
|
||||
|
||||
|
||||
class TestCompress:
|
||||
def _make_messages(self, n):
|
||||
|
||||
@@ -112,8 +112,13 @@ class TestCallLlmUnsupportedTemperatureRetry:
|
||||
retry_kwargs = client.chat.completions.create.call_args_list[1].kwargs
|
||||
assert first_kwargs["temperature"] == 0.3
|
||||
assert "temperature" not in retry_kwargs
|
||||
# other kwargs preserved
|
||||
assert retry_kwargs["max_tokens"] == 500
|
||||
# max_tokens is intentionally omitted on OpenAI-compatible endpoints
|
||||
# (#34530) — auxiliary calls let the model max out its own output — so
|
||||
# it must be absent in BOTH the first and retry kwargs. Use a kwarg that
|
||||
# actually survives (model) to prove the retry preserves the rest.
|
||||
assert "max_tokens" not in first_kwargs
|
||||
assert "max_tokens" not in retry_kwargs
|
||||
assert retry_kwargs["model"] == first_kwargs["model"]
|
||||
|
||||
def test_non_temperature_400_does_not_retry_as_temperature(self):
|
||||
"""Unrelated 400s (e.g. bad tool role) must not silently drop temp."""
|
||||
@@ -207,7 +212,11 @@ class TestAsyncCallLlmUnsupportedTemperatureRetry:
|
||||
retry_kwargs = client.chat.completions.create.call_args_list[1].kwargs
|
||||
assert first_kwargs["temperature"] == 0.3
|
||||
assert "temperature" not in retry_kwargs
|
||||
assert retry_kwargs["max_tokens"] == 500
|
||||
# max_tokens is intentionally omitted on OpenAI-compatible endpoints
|
||||
# (#34530); assert it's absent and that model survives the retry.
|
||||
assert "max_tokens" not in first_kwargs
|
||||
assert "max_tokens" not in retry_kwargs
|
||||
assert retry_kwargs["model"] == first_kwargs["model"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_non_temperature_400_does_not_retry(self):
|
||||
|
||||
@@ -11,6 +11,7 @@ def _make_cli():
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.agent = None
|
||||
cli_obj._session_db = MagicMock()
|
||||
cli_obj._pending_resume_sessions = None
|
||||
# _handle_resume_command now triggers _display_resumed_history (#31695),
|
||||
# which reads self.resume_display. "minimal" short-circuits the recap so
|
||||
# the test only exercises session-switch behavior.
|
||||
@@ -116,3 +117,107 @@ class TestCliResumeCommand:
|
||||
|
||||
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
|
||||
assert "<half" in printed
|
||||
|
||||
|
||||
class TestPendingResumeNumberedSelection:
|
||||
"""Bare `/resume` arms a one-shot prompt so the next bare number resumes.
|
||||
|
||||
Regression coverage for #34584: previously, running `/resume` (no args)
|
||||
printed the recent-sessions list but left no selection state armed, so
|
||||
typing just `3` on the next line was sent to the agent as chat instead of
|
||||
resuming session #3.
|
||||
"""
|
||||
|
||||
def test_bare_resume_arms_pending_selection(self):
|
||||
cli_obj = _make_cli()
|
||||
sessions = [
|
||||
{"id": "sess_002", "title": "Coding"},
|
||||
{"id": "sess_001", "title": "Research"},
|
||||
]
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
|
||||
cli_obj._show_recent_sessions = MagicMock(return_value=True)
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_resume_command("/resume")
|
||||
|
||||
assert cli_obj._pending_resume_sessions == sessions
|
||||
|
||||
def test_bare_resume_no_sessions_does_not_arm(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._show_recent_sessions = MagicMock(return_value=False)
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=[])
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_resume_command("/resume")
|
||||
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_pending_number_resumes_selected_session(self):
|
||||
cli_obj = _make_cli()
|
||||
sessions = [
|
||||
{"id": "sess_002", "title": "Coding"},
|
||||
{"id": "sess_001", "title": "Research"},
|
||||
]
|
||||
cli_obj._pending_resume_sessions = sessions
|
||||
# _handle_resume_command("/resume 2") re-resolves the index via
|
||||
# _list_recent_sessions, so it must return the same list.
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
|
||||
cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
|
||||
cli_obj._session_db.get_messages_as_conversation.return_value = [
|
||||
{"role": "user", "content": "hello"},
|
||||
]
|
||||
cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001"
|
||||
|
||||
with (
|
||||
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
|
||||
patch("cli._cprint"),
|
||||
):
|
||||
consumed = cli_obj._consume_pending_resume_selection("2")
|
||||
|
||||
assert consumed is True
|
||||
assert cli_obj.session_id == "sess_001"
|
||||
# One-shot: prompt is disarmed after consuming.
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_pending_out_of_range_consumed_with_message(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
|
||||
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
consumed = cli_obj._consume_pending_resume_selection("9")
|
||||
|
||||
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
|
||||
# An out-of-range number is still consumed (not sent to the agent),
|
||||
# and the prompt is disarmed.
|
||||
assert consumed is True
|
||||
assert "out of range" in printed.lower()
|
||||
assert cli_obj.session_id == "current_session"
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_pending_non_numeric_falls_through_and_disarms(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
|
||||
|
||||
with patch("cli._cprint"):
|
||||
consumed = cli_obj._consume_pending_resume_selection("hello there")
|
||||
|
||||
# Free text is NOT consumed (caller treats it as chat), but the
|
||||
# one-shot prompt is disarmed so a later number isn't hijacked.
|
||||
assert consumed is False
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_no_pending_returns_false(self):
|
||||
cli_obj = _make_cli()
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
assert cli_obj._consume_pending_resume_selection("3") is False
|
||||
|
||||
def test_pending_disarmed_by_other_command(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
|
||||
# Stub out the help handler so process_command("/help") is cheap.
|
||||
cli_obj.show_help = MagicMock()
|
||||
|
||||
cli_obj.process_command("/help")
|
||||
|
||||
# A non-resume command disarms the one-shot prompt (#34584).
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for /compress here [N] — boundary-aware partial compression.
|
||||
|
||||
Verifies the CLI handler (_manual_compress) splits the history, compresses
|
||||
only the head, and re-appends the verbatim tail. Inspired by Claude Code's
|
||||
Rewind "Summarize up to here" action (v2.1.139, May 2026).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.cli.test_cli_init import _make_cli
|
||||
|
||||
|
||||
def _make_history() -> list[dict[str, str]]:
|
||||
# 8 messages = 4 exchanges.
|
||||
h: list[dict[str, str]] = []
|
||||
for i in range(4):
|
||||
h.append({"role": "user", "content": f"u{i}"})
|
||||
h.append({"role": "assistant", "content": f"a{i}"})
|
||||
return h
|
||||
|
||||
|
||||
def _wire_agent(shell, compressed_head):
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = None
|
||||
shell.agent.tools = None
|
||||
shell.agent._compress_context.return_value = (compressed_head, "")
|
||||
|
||||
|
||||
def test_compress_here_compresses_head_only(capsys):
|
||||
"""/compress here 2 passes only the head to _compress_context."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
# Pretend compression collapses the head into a single summary message.
|
||||
summary = [{"role": "user", "content": "[summary of earlier turns]"}]
|
||||
_wire_agent(shell, summary)
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress here 2")
|
||||
|
||||
# _compress_context should have been called with the HEAD only
|
||||
# (everything before the last 2 user-starts = first 4 messages).
|
||||
shell.agent._compress_context.assert_called_once()
|
||||
call = shell.agent._compress_context.call_args
|
||||
passed_head = call.args[0]
|
||||
assert passed_head == history[:4]
|
||||
# focus_topic must be None in partial mode (modes are exclusive).
|
||||
assert call.kwargs.get("focus_topic") is None
|
||||
|
||||
|
||||
def test_compress_here_reappends_verbatim_tail(capsys):
|
||||
"""The most recent exchanges are preserved verbatim after the summary."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
# Head compresses to an assistant-role summary so the seam
|
||||
# (assistant -> user tail) is already valid — tail rides along whole.
|
||||
summary = [{"role": "assistant", "content": "[summary]"}]
|
||||
_wire_agent(shell, summary)
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress here 2")
|
||||
|
||||
# Result = compressed head + verbatim tail (last 2 exchanges).
|
||||
assert shell.conversation_history == summary + history[4:]
|
||||
# Tail boundary keeps role alternation valid (tail starts on user).
|
||||
assert history[4]["role"] == "user"
|
||||
# No consecutive same-role user/assistant messages anywhere.
|
||||
roles = [m["role"] for m in shell.conversation_history
|
||||
if m["role"] in ("user", "assistant")]
|
||||
assert all(roles[i] != roles[i + 1] for i in range(len(roles) - 1))
|
||||
|
||||
|
||||
def test_compress_here_banner_mentions_summarizing_up_to_here(capsys):
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
_wire_agent(shell, [{"role": "user", "content": "[summary]"}])
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress here")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Summarizing up to here" in out
|
||||
assert "verbatim" in out
|
||||
|
||||
|
||||
def test_bare_compress_still_full(capsys):
|
||||
"""/compress with no args compresses the whole history (full mode)."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
_wire_agent(shell, list(history))
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress")
|
||||
|
||||
call = shell.agent._compress_context.call_args
|
||||
# Full mode passes the entire history as the head.
|
||||
assert call.args[0] == history
|
||||
out = capsys.readouterr().out
|
||||
assert "Summarizing up to here" not in out
|
||||
|
||||
|
||||
def test_focus_still_works(capsys):
|
||||
"""/compress <focus> keeps the existing focus behavior."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
_wire_agent(shell, list(history))
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress database schema")
|
||||
|
||||
call = shell.agent._compress_context.call_args
|
||||
assert call.args[0] == history
|
||||
assert call.kwargs.get("focus_topic") == "database schema"
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Tests for hermes_cli.partial_compress — the pure split/parse helpers
|
||||
behind ``/compress here [N]`` (boundary-aware "summarize up to here").
|
||||
|
||||
Inspired by Claude Code's Rewind "Summarize up to here" action.
|
||||
"""
|
||||
|
||||
from hermes_cli.partial_compress import (
|
||||
DEFAULT_KEEP_LAST,
|
||||
MAX_KEEP_LAST,
|
||||
parse_partial_compress_args,
|
||||
rejoin_compressed_head_and_tail,
|
||||
split_history_for_partial_compress,
|
||||
)
|
||||
|
||||
|
||||
def _history(n_pairs: int) -> list[dict[str, str]]:
|
||||
"""Build n_pairs of (user, assistant) exchanges."""
|
||||
h: list[dict[str, str]] = []
|
||||
for i in range(n_pairs):
|
||||
h.append({"role": "user", "content": f"u{i}"})
|
||||
h.append({"role": "assistant", "content": f"a{i}"})
|
||||
return h
|
||||
|
||||
|
||||
# ── parse_partial_compress_args ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_args_is_full_compress():
|
||||
partial, keep, focus = parse_partial_compress_args("")
|
||||
assert partial is False
|
||||
assert keep == DEFAULT_KEEP_LAST
|
||||
assert focus is None
|
||||
|
||||
|
||||
def test_here_defaults_keep_last():
|
||||
partial, keep, focus = parse_partial_compress_args("here")
|
||||
assert partial is True
|
||||
assert keep == DEFAULT_KEEP_LAST
|
||||
assert focus is None
|
||||
|
||||
|
||||
def test_here_with_count():
|
||||
partial, keep, focus = parse_partial_compress_args("here 4")
|
||||
assert partial is True
|
||||
assert keep == 4
|
||||
assert focus is None
|
||||
|
||||
|
||||
def test_up_to_here_alias():
|
||||
partial, keep, focus = parse_partial_compress_args("up to here 3")
|
||||
assert partial is True
|
||||
assert keep == 3
|
||||
assert focus is None
|
||||
|
||||
|
||||
def test_keep_flag_forms():
|
||||
for arg in ("--keep 5", "-k 5", "--keep=5"):
|
||||
partial, keep, focus = parse_partial_compress_args(arg)
|
||||
assert partial is True, arg
|
||||
assert keep == 5, arg
|
||||
assert focus is None, arg
|
||||
|
||||
|
||||
def test_focus_topic_when_not_boundary_form():
|
||||
partial, keep, focus = parse_partial_compress_args("database schema")
|
||||
assert partial is False
|
||||
assert focus == "database schema"
|
||||
|
||||
|
||||
def test_here_count_clamped_low_and_high():
|
||||
_, keep_low, _ = parse_partial_compress_args("here 0")
|
||||
assert keep_low == 1
|
||||
_, keep_high, _ = parse_partial_compress_args(f"here {MAX_KEEP_LAST + 50}")
|
||||
assert keep_high == MAX_KEEP_LAST
|
||||
|
||||
|
||||
def test_here_garbage_count_falls_back_to_default():
|
||||
partial, keep, focus = parse_partial_compress_args("here lots")
|
||||
assert partial is True
|
||||
assert keep == DEFAULT_KEEP_LAST
|
||||
|
||||
|
||||
# ── split_history_for_partial_compress ───────────────────────────────
|
||||
|
||||
|
||||
def test_split_keeps_last_n_exchanges():
|
||||
h = _history(5) # 10 messages: u0 a0 u1 a1 u2 a2 u3 a3 u4 a4
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=2)
|
||||
# Keep last 2 user-starts → tail begins at u3 (index 6).
|
||||
assert tail == h[6:]
|
||||
assert head == h[:6]
|
||||
# Tail must begin on a user turn (role-alternation safety).
|
||||
assert tail[0]["role"] == "user"
|
||||
|
||||
|
||||
def test_split_default_keep():
|
||||
h = _history(4) # 8 messages
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=DEFAULT_KEEP_LAST)
|
||||
assert tail[0]["role"] == "user"
|
||||
assert head + tail == h
|
||||
assert len(head) > 0
|
||||
|
||||
|
||||
def test_split_tail_always_starts_on_user():
|
||||
# Tool messages interleaved — tail must still snap to a user turn.
|
||||
h = [
|
||||
{"role": "user", "content": "u0"},
|
||||
{"role": "assistant", "content": "a0"},
|
||||
{"role": "user", "content": "u1"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "tool", "content": "t1"},
|
||||
{"role": "assistant", "content": "a1b"},
|
||||
{"role": "user", "content": "u2"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
]
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=1)
|
||||
assert tail[0]["role"] == "user"
|
||||
assert tail[0]["content"] == "u2"
|
||||
assert head + tail == h
|
||||
|
||||
|
||||
def test_split_degenerate_returns_no_tail():
|
||||
# keep_last larger than the number of exchanges → nothing to compress.
|
||||
h = _history(2) # 4 messages, 2 user turns
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=5)
|
||||
# Boundary lands at the first user turn → head empty → signal full.
|
||||
assert tail == []
|
||||
assert head == h
|
||||
|
||||
|
||||
def test_split_empty_history():
|
||||
head, tail = split_history_for_partial_compress([], keep_last=2)
|
||||
assert head == []
|
||||
assert tail == []
|
||||
|
||||
|
||||
def test_split_rejoin_preserves_all_messages():
|
||||
h = _history(6)
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=3)
|
||||
assert head + tail == h
|
||||
|
||||
|
||||
# ── rejoin_compressed_head_and_tail (seam-alternation guard) ─────────
|
||||
|
||||
|
||||
def _roles(msgs):
|
||||
return [m["role"] for m in msgs if m["role"] in ("user", "assistant")]
|
||||
|
||||
|
||||
def _no_consecutive_dupes(msgs):
|
||||
r = _roles(msgs)
|
||||
return all(r[i] != r[i + 1] for i in range(len(r) - 1))
|
||||
|
||||
|
||||
def test_rejoin_valid_seam_assistant_then_user():
|
||||
# Normal case: head ends on assistant, tail starts on user → valid.
|
||||
head = [{"role": "user", "content": "[summary]"},
|
||||
{"role": "assistant", "content": "ack"}]
|
||||
tail = [{"role": "user", "content": "next"},
|
||||
{"role": "assistant", "content": "reply"}]
|
||||
out = rejoin_compressed_head_and_tail(head, tail)
|
||||
assert out == head + tail
|
||||
assert _no_consecutive_dupes(out)
|
||||
|
||||
|
||||
def test_rejoin_user_user_seam_merges():
|
||||
# Degenerate head ending on a user summary; tail starts on user.
|
||||
head = [{"role": "user", "content": "[summary of head]"}]
|
||||
tail = [{"role": "user", "content": "latest question"},
|
||||
{"role": "assistant", "content": "answer"}]
|
||||
out = rejoin_compressed_head_and_tail(head, tail)
|
||||
assert _no_consecutive_dupes(out), out
|
||||
# The two user messages were merged into one.
|
||||
assert out[0]["content"] == "[summary of head]\n\nlatest question"
|
||||
assert out[1] == {"role": "assistant", "content": "answer"}
|
||||
|
||||
|
||||
def test_rejoin_assistant_assistant_seam_merges():
|
||||
head = [{"role": "user", "content": "q"},
|
||||
{"role": "assistant", "content": "head end"}]
|
||||
tail = [{"role": "assistant", "content": "tail start"},
|
||||
{"role": "user", "content": "u"}]
|
||||
out = rejoin_compressed_head_and_tail(head, tail)
|
||||
assert _no_consecutive_dupes(out), out
|
||||
assert out[-2]["content"] == "head end\n\ntail start"
|
||||
|
||||
|
||||
def test_rejoin_empty_tail_returns_head():
|
||||
head = [{"role": "user", "content": "x"}]
|
||||
assert rejoin_compressed_head_and_tail(head, []) == head
|
||||
|
||||
|
||||
def test_rejoin_tool_seam_left_alone():
|
||||
# tool->tool is the one legal repetition; don't merge.
|
||||
head = [{"role": "user", "content": "q"}, {"role": "tool", "content": "t1"}]
|
||||
tail = [{"role": "user", "content": "u"}]
|
||||
out = rejoin_compressed_head_and_tail(head, tail)
|
||||
assert out == head + tail
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Regression guard for issue #34569 — inline /steer (and /model) submit
|
||||
must repaint the input area after clearing the buffer.
|
||||
|
||||
Mechanism of the bug
|
||||
--------------------
|
||||
``handle_enter`` dispatches ``/steer`` (and ``/model``) inline on the UI
|
||||
thread while the agent is running. Those branches called
|
||||
``buffer.reset(append_to_history=True)`` but — unlike every *other*
|
||||
early-return branch in the handler — did NOT call ``event.app.invalidate()``.
|
||||
Because ``process_command()`` prints through ``patch_stdout`` (which scrolls
|
||||
output above the prompt and never triggers a prompt_toolkit redraw), the
|
||||
just-cleared input area could keep showing the submitted ``/steer <text>``
|
||||
until some unrelated redraw fired. The user saw their submitted text as if
|
||||
it were unsent and could accidentally re-submit it.
|
||||
|
||||
This test pins the contract structurally: inside ``handle_enter``, any
|
||||
inline-command early-return that resets the buffer must be followed by an
|
||||
``event.app.invalidate()`` before its ``return``. It is an *invariant*
|
||||
(every reset-then-return repaints), not a snapshot of current source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_handle_enter_node() -> ast.FunctionDef:
|
||||
"""Extract the ``handle_enter`` nested function node from cli.py."""
|
||||
cli_path = Path(__file__).resolve().parents[2] / "cli.py"
|
||||
tree = ast.parse(cli_path.read_text(encoding="utf-8"))
|
||||
|
||||
target = None
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "handle_enter":
|
||||
target = node
|
||||
break
|
||||
assert target is not None, "handle_enter closure not found in cli.py"
|
||||
return target
|
||||
|
||||
|
||||
def _is_buffer_reset(node: ast.stmt) -> bool:
|
||||
"""True if the statement is ``...current_buffer.reset(...)``."""
|
||||
if not isinstance(node, ast.Expr):
|
||||
return False
|
||||
call = node.value
|
||||
if not isinstance(call, ast.Call):
|
||||
return False
|
||||
func = call.func
|
||||
return isinstance(func, ast.Attribute) and func.attr == "reset"
|
||||
|
||||
|
||||
def _is_invalidate(node: ast.stmt) -> bool:
|
||||
"""True if the statement is ``event.app.invalidate()``."""
|
||||
if not isinstance(node, ast.Expr):
|
||||
return False
|
||||
call = node.value
|
||||
if not isinstance(call, ast.Call):
|
||||
return False
|
||||
func = call.func
|
||||
return isinstance(func, ast.Attribute) and func.attr == "invalidate"
|
||||
|
||||
|
||||
def _collect_reset_blocks(func: ast.FunctionDef) -> list[list[ast.stmt]]:
|
||||
"""Find every statement sequence (a block body/orelse/finalbody) within
|
||||
``handle_enter`` that contains a ``buffer.reset()`` call."""
|
||||
blocks: list[list[ast.stmt]] = []
|
||||
for node in ast.walk(func):
|
||||
for attr in ("body", "orelse", "finalbody"):
|
||||
seq = getattr(node, attr, None)
|
||||
if not isinstance(seq, list):
|
||||
continue
|
||||
if any(isinstance(s, ast.stmt) and _is_buffer_reset(s) for s in seq):
|
||||
blocks.append(seq)
|
||||
return blocks
|
||||
|
||||
|
||||
def test_inline_command_reset_branches_invalidate():
|
||||
"""Every handle_enter branch that resets the buffer and then returns must
|
||||
invalidate the app first (issue #34569)."""
|
||||
func = _load_handle_enter_node()
|
||||
reset_blocks = _collect_reset_blocks(func)
|
||||
|
||||
assert reset_blocks, "expected to find buffer.reset() calls in handle_enter"
|
||||
|
||||
offenders = []
|
||||
for seq in reset_blocks:
|
||||
for i, stmt in enumerate(seq):
|
||||
if not _is_buffer_reset(stmt):
|
||||
continue
|
||||
# Find the next return after this reset in the same block.
|
||||
ret_idx = None
|
||||
for j in range(i + 1, len(seq)):
|
||||
if isinstance(seq[j], ast.Return):
|
||||
ret_idx = j
|
||||
break
|
||||
if ret_idx is None:
|
||||
# reset not directly followed by a return in this block
|
||||
# (e.g. the fall-through reset at the end of the handler) —
|
||||
# the next user input naturally repaints, so skip.
|
||||
continue
|
||||
between = seq[i + 1 : ret_idx]
|
||||
if not any(_is_invalidate(s) for s in between):
|
||||
offenders.append(ast.dump(stmt))
|
||||
|
||||
assert not offenders, (
|
||||
"handle_enter has reset-then-return branch(es) that never call "
|
||||
"event.app.invalidate() — the input area can keep showing the "
|
||||
"submitted text (issue #34569). Offending reset stmts:\n"
|
||||
+ "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
test_inline_command_reset_branches_invalidate()
|
||||
print("ok")
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Tests for the outbound silence-narration filter (anti-loop control).
|
||||
|
||||
See the gateway delivery path: hallucinated "silence" tokens like ``*(silent)*``
|
||||
are dropped pre-send so bot-to-bot channels can't mirror them into a token-burning
|
||||
loop that crashes a model with "no content after all retries".
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform
|
||||
from gateway.delivery import (
|
||||
DeliveryRouter,
|
||||
DeliveryTarget,
|
||||
_is_silence_narration,
|
||||
)
|
||||
|
||||
|
||||
# --- Truth table -----------------------------------------------------------
|
||||
|
||||
POSITIVE_CASES = [
|
||||
"*(silent)*",
|
||||
"*Silence.*",
|
||||
"🔇",
|
||||
".",
|
||||
"…",
|
||||
"...",
|
||||
"(silent)",
|
||||
"_silent_",
|
||||
"silent",
|
||||
" *(silent)* ",
|
||||
"`silent`",
|
||||
"~silent~",
|
||||
"Silence",
|
||||
"no response",
|
||||
"No Reply.",
|
||||
]
|
||||
|
||||
NEGATIVE_CASES = [
|
||||
"Silence is golden — here is the plan...",
|
||||
"Silent install completed",
|
||||
"The deployment ran silently in the background",
|
||||
"ok",
|
||||
"👍",
|
||||
"Here is the result:\n\n- item one\n- item two",
|
||||
"I have nothing to add, but here is why: the build is green.",
|
||||
"silently", # word boundary — trailing letters mean it isn't a bare token
|
||||
"no responses were collected from the survey",
|
||||
# A 64+ char string that opens with a silence token must not be dropped.
|
||||
"silent " + "x" * 70,
|
||||
"",
|
||||
" ",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", POSITIVE_CASES)
|
||||
def test_is_silence_narration_positive(content):
|
||||
assert _is_silence_narration(content) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", NEGATIVE_CASES)
|
||||
def test_is_silence_narration_negative(content):
|
||||
assert _is_silence_narration(content) is False
|
||||
|
||||
|
||||
def test_is_silence_narration_none_safe():
|
||||
assert _is_silence_narration(None) is False
|
||||
|
||||
|
||||
def test_length_guard_rejects_long_strings():
|
||||
# Exactly 65 chars of dots — over the 64-char guard, so not treated as narration.
|
||||
assert _is_silence_narration("." * 65) is False
|
||||
assert _is_silence_narration("." * 64) is True
|
||||
|
||||
|
||||
# --- Integration through DeliveryRouter ------------------------------------
|
||||
|
||||
class RecordingAdapter:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def send(self, chat_id, content, metadata=None):
|
||||
self.calls.append({"chat_id": chat_id, "content": content, "metadata": metadata})
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_silence_narration_dropped_pre_send(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
|
||||
adapter = RecordingAdapter()
|
||||
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
|
||||
target = DeliveryTarget.parse("discord:99887766")
|
||||
|
||||
result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
|
||||
|
||||
assert adapter.calls == [] # adapter.send never invoked
|
||||
assert result == {
|
||||
"success": True,
|
||||
"filtered": "silence_narration",
|
||||
"delivered": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_message_is_delivered(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
|
||||
adapter = RecordingAdapter()
|
||||
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
|
||||
target = DeliveryTarget.parse("discord:99887766")
|
||||
|
||||
result = await router._deliver_to_platform(
|
||||
target, "Silence is golden — here is the plan...", metadata=None
|
||||
)
|
||||
|
||||
assert len(adapter.calls) == 1
|
||||
assert adapter.calls[0]["content"] == "Silence is golden — here is the plan..."
|
||||
assert result == {"success": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_opt_out_lets_silence_through(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
|
||||
adapter = RecordingAdapter()
|
||||
config = GatewayConfig(filter_silence_narration=False)
|
||||
router = DeliveryRouter(config, adapters={Platform.DISCORD: adapter})
|
||||
target = DeliveryTarget.parse("discord:99887766")
|
||||
|
||||
result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
|
||||
|
||||
assert len(adapter.calls) == 1
|
||||
assert adapter.calls[0]["content"] == "*(silent)*"
|
||||
assert result == {"success": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_override_disables_filter(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_FILTER_SILENCE_NARRATION", "0")
|
||||
adapter = RecordingAdapter()
|
||||
# Config default is True, but env override wins.
|
||||
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
|
||||
target = DeliveryTarget.parse("discord:99887766")
|
||||
|
||||
result = await router._deliver_to_platform(target, "🔇", metadata=None)
|
||||
|
||||
assert len(adapter.calls) == 1
|
||||
assert result == {"success": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_override_enables_filter_over_config(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_FILTER_SILENCE_NARRATION", "1")
|
||||
adapter = RecordingAdapter()
|
||||
# Config says off, env override forces on.
|
||||
config = GatewayConfig(filter_silence_narration=False)
|
||||
router = DeliveryRouter(config, adapters={Platform.DISCORD: adapter})
|
||||
target = DeliveryTarget.parse("discord:99887766")
|
||||
|
||||
result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
|
||||
|
||||
assert adapter.calls == []
|
||||
assert result["filtered"] == "silence_narration"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_delivery_not_filtered(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
|
||||
router = DeliveryRouter(GatewayConfig(), adapters={})
|
||||
|
||||
results = await router.deliver(
|
||||
content="*(silent)*",
|
||||
targets=[DeliveryTarget.parse("local")],
|
||||
job_id="silence-job",
|
||||
)
|
||||
|
||||
# Local path saved the file (no loop risk) and was not filtered.
|
||||
local_result = results["local"]
|
||||
assert local_result["success"] is True
|
||||
saved_path = local_result["result"]["path"]
|
||||
assert saved_path.endswith(".md")
|
||||
|
||||
|
||||
# --- Config round-trip ------------------------------------------------------
|
||||
|
||||
def test_config_flag_defaults_true():
|
||||
assert GatewayConfig().filter_silence_narration is True
|
||||
|
||||
|
||||
def test_config_from_dict_parses_flag():
|
||||
cfg = GatewayConfig.from_dict({"filter_silence_narration": False})
|
||||
assert cfg.filter_silence_narration is False
|
||||
|
||||
|
||||
def test_config_to_dict_roundtrip():
|
||||
cfg = GatewayConfig(filter_silence_narration=False)
|
||||
assert cfg.to_dict()["filter_silence_narration"] is False
|
||||
restored = GatewayConfig.from_dict(cfg.to_dict())
|
||||
assert restored.filter_silence_narration is False
|
||||
@@ -97,7 +97,7 @@ async def test_status_command_reports_running_agent_without_interrupt(monkeypatc
|
||||
result = await runner._handle_message(_make_event("/status"))
|
||||
|
||||
assert "**Session ID:** `sess-1`" in result
|
||||
assert "**Tokens:** 321" in result
|
||||
assert "**Cumulative API tokens (re-sent each call):** 321" in result
|
||||
assert "**Agent Running:** Yes ⚡" in result
|
||||
assert "**Title:**" not in result
|
||||
running_agent.interrupt.assert_not_called()
|
||||
@@ -150,7 +150,7 @@ async def test_status_command_reads_token_totals_from_session_db():
|
||||
result = await runner._handle_message(_make_event("/status"))
|
||||
|
||||
# 1000 + 250 + 500 + 100 + 50 = 1,900
|
||||
assert "**Tokens:** 1,900" in result
|
||||
assert "**Cumulative API tokens (re-sent each call):** 1,900" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -171,7 +171,7 @@ async def test_status_command_tokens_zero_when_session_db_row_missing():
|
||||
|
||||
result = await runner._handle_message(_make_event("/status"))
|
||||
|
||||
assert "**Tokens:** 0" in result
|
||||
assert "**Cumulative API tokens (re-sent each call):** 0" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -326,6 +326,8 @@ class TestGeneratedSystemdUnits:
|
||||
assert "ExecStart=" in unit
|
||||
assert "ExecStop=" not in unit
|
||||
assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
|
||||
assert "Restart=on-failure" in unit
|
||||
assert "Restart=always" not in unit
|
||||
assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
|
||||
# TimeoutStopSec must exceed the default drain_timeout (60s) so
|
||||
# systemd doesn't SIGKILL the cgroup before post-interrupt cleanup
|
||||
@@ -387,6 +389,8 @@ class TestGeneratedSystemdUnits:
|
||||
assert "ExecStart=" in unit
|
||||
assert "ExecStop=" not in unit
|
||||
assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
|
||||
assert "Restart=on-failure" in unit
|
||||
assert "Restart=always" not in unit
|
||||
assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
|
||||
# TimeoutStopSec must exceed the default drain_timeout (60s) so
|
||||
# systemd doesn't SIGKILL the cgroup before post-interrupt cleanup
|
||||
@@ -493,7 +497,10 @@ class TestLaunchdServiceRecovery:
|
||||
|
||||
label = gateway_cli.get_launchd_label()
|
||||
domain = gateway_cli._launchd_domain()
|
||||
assert "--replace" in plist_path.read_text(encoding="utf-8")
|
||||
plist_text = plist_path.read_text(encoding="utf-8")
|
||||
assert "<string>gateway</string>" in plist_text
|
||||
assert "<string>run</string>" in plist_text
|
||||
assert "--replace" not in plist_text
|
||||
assert calls[:2] == [
|
||||
["launchctl", "bootout", f"{domain}/{label}"],
|
||||
["launchctl", "bootstrap", domain, str(plist_path)],
|
||||
@@ -1616,7 +1623,8 @@ class TestProfileArg:
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir)
|
||||
unit = gateway_cli.generate_systemd_unit(system=False)
|
||||
assert "--profile mybot" in unit
|
||||
assert "gateway run --replace" in unit
|
||||
assert "gateway run" in unit
|
||||
assert "--replace" not in unit
|
||||
|
||||
def test_launchd_plist_includes_profile(self, tmp_path, monkeypatch):
|
||||
"""generate_launchd_plist should include --profile in ProgramArguments for named profiles."""
|
||||
@@ -1628,6 +1636,24 @@ class TestProfileArg:
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
assert "<string>--profile</string>" in plist
|
||||
assert "<string>mybot</string>" in plist
|
||||
assert "<string>--replace</string>" not in plist
|
||||
|
||||
def test_gateway_run_args_for_profile_omit_replace(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "get_python_path", lambda: "/venv/bin/python")
|
||||
|
||||
default_args = gateway_cli._gateway_run_args_for_profile("default")
|
||||
named_args = gateway_cli._gateway_run_args_for_profile("mybot")
|
||||
|
||||
assert default_args == ["/venv/bin/python", "-m", "hermes_cli.main", "gateway", "run"]
|
||||
assert named_args == [
|
||||
"/venv/bin/python",
|
||||
"-m",
|
||||
"hermes_cli.main",
|
||||
"--profile",
|
||||
"mybot",
|
||||
"gateway",
|
||||
"run",
|
||||
]
|
||||
|
||||
def test_launchd_plist_path_uses_real_user_home_not_profile_home(self, tmp_path, monkeypatch):
|
||||
profile_dir = tmp_path / ".hermes" / "profiles" / "orcha"
|
||||
|
||||
@@ -59,3 +59,53 @@ def test_docker_detected_via_dockerenv(tmp_path):
|
||||
def test_recommended_update_command_docker():
|
||||
from hermes_cli.config import recommended_update_command_for_method
|
||||
assert "docker pull" in recommended_update_command_for_method("docker")
|
||||
|
||||
|
||||
def test_banner_warns_on_pip_install(tmp_path):
|
||||
"""The welcome banner surfaces a warning when the install method is pip."""
|
||||
import io
|
||||
from rich.console import Console
|
||||
from hermes_cli import banner
|
||||
|
||||
hh = tmp_path / ".hermes"
|
||||
hh.mkdir()
|
||||
(hh / ".install_method").write_text("pip\n")
|
||||
|
||||
with patch("hermes_cli.config.get_hermes_home", return_value=hh), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=hh):
|
||||
buf = io.StringIO()
|
||||
# Wide console so the warning isn't wrapped across lines in the panel.
|
||||
console = Console(file=buf, width=400, force_terminal=False, color_system=None)
|
||||
banner.build_welcome_banner(
|
||||
console, model="m", cwd="/tmp",
|
||||
tools=[{"function": {"name": "terminal"}}],
|
||||
enabled_toolsets=["terminal"],
|
||||
)
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "officially" in out
|
||||
assert "instability" in out
|
||||
|
||||
|
||||
def test_banner_no_pip_warning_on_git_install(tmp_path):
|
||||
"""Git installs must not show the pip-install warning."""
|
||||
import io
|
||||
from rich.console import Console
|
||||
from hermes_cli import banner
|
||||
|
||||
hh = tmp_path / ".hermes"
|
||||
hh.mkdir()
|
||||
(hh / ".install_method").write_text("git\n")
|
||||
|
||||
with patch("hermes_cli.config.get_hermes_home", return_value=hh), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=hh):
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, width=400, force_terminal=False, color_system=None)
|
||||
banner.build_welcome_banner(
|
||||
console, model="m", cwd="/tmp",
|
||||
tools=[{"function": {"name": "terminal"}}],
|
||||
enabled_toolsets=["terminal"],
|
||||
)
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "officially" not in out
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Tests for hermes_cli.uninstall.remove_node_symlinks.
|
||||
|
||||
Regression for #34536: the POSIX installer drops node/npm/npx symlinks in
|
||||
~/.local/bin pointing into $HERMES_HOME/node and prepends ~/.local/bin to
|
||||
PATH, shadowing an existing nvm. Uninstall must remove those symlinks, but
|
||||
only when they still resolve into the Hermes-managed node dir.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.uninstall as uninstall
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_home(tmp_path, monkeypatch):
|
||||
"""Redirect Path.home() at the home both the installer-symlink target and
|
||||
the ~/.local/bin links live under the same temp dir."""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
|
||||
(home / ".local" / "bin").mkdir(parents=True)
|
||||
return home
|
||||
|
||||
|
||||
def _make_hermes_node(hermes_home: Path) -> Path:
|
||||
"""Create a fake $HERMES_HOME/node/bin/{node,npm,npx} tree."""
|
||||
node_bin = hermes_home / "node" / "bin"
|
||||
node_bin.mkdir(parents=True)
|
||||
for name in ("node", "npm", "npx"):
|
||||
(node_bin / name).write_text("#!/bin/sh\n")
|
||||
(node_bin / name).chmod(0o755)
|
||||
return node_bin
|
||||
|
||||
|
||||
def test_removes_symlinks_pointing_into_hermes_node(fake_home):
|
||||
hermes_home = fake_home / ".hermes"
|
||||
node_bin = _make_hermes_node(hermes_home)
|
||||
local_bin = fake_home / ".local" / "bin"
|
||||
|
||||
for name in ("node", "npm", "npx"):
|
||||
(local_bin / name).symlink_to(node_bin / name)
|
||||
|
||||
removed = uninstall.remove_node_symlinks(hermes_home)
|
||||
|
||||
assert sorted(p.name for p in removed) == ["node", "npm", "npx"]
|
||||
for name in ("node", "npm", "npx"):
|
||||
assert not (local_bin / name).exists()
|
||||
assert not (local_bin / name).is_symlink()
|
||||
|
||||
|
||||
def test_leaves_unrelated_symlinks_untouched(fake_home):
|
||||
"""A node symlink the user repointed at nvm must survive uninstall."""
|
||||
hermes_home = fake_home / ".hermes"
|
||||
_make_hermes_node(hermes_home)
|
||||
local_bin = fake_home / ".local" / "bin"
|
||||
|
||||
# Simulate nvm's node living elsewhere; user's ~/.local/bin/node -> nvm.
|
||||
nvm_bin = fake_home / ".nvm" / "versions" / "node" / "v20.0.0" / "bin"
|
||||
nvm_bin.mkdir(parents=True)
|
||||
(nvm_bin / "node").write_text("#!/bin/sh\n")
|
||||
(local_bin / "node").symlink_to(nvm_bin / "node")
|
||||
|
||||
removed = uninstall.remove_node_symlinks(hermes_home)
|
||||
|
||||
assert removed == []
|
||||
assert (local_bin / "node").is_symlink()
|
||||
assert (local_bin / "node").resolve() == (nvm_bin / "node").resolve()
|
||||
|
||||
|
||||
def test_leaves_real_binaries_untouched(fake_home):
|
||||
"""A real (non-symlink) binary in ~/.local/bin is never deleted."""
|
||||
hermes_home = fake_home / ".hermes"
|
||||
_make_hermes_node(hermes_home)
|
||||
local_bin = fake_home / ".local" / "bin"
|
||||
|
||||
real_node = local_bin / "node"
|
||||
real_node.write_text("#!/bin/sh\necho real\n")
|
||||
real_node.chmod(0o755)
|
||||
|
||||
removed = uninstall.remove_node_symlinks(hermes_home)
|
||||
|
||||
assert removed == []
|
||||
assert real_node.exists()
|
||||
assert not real_node.is_symlink()
|
||||
|
||||
|
||||
def test_handles_missing_local_bin(fake_home):
|
||||
"""No symlinks present -> no-op, no error."""
|
||||
hermes_home = fake_home / ".hermes"
|
||||
_make_hermes_node(hermes_home)
|
||||
|
||||
assert uninstall.remove_node_symlinks(hermes_home) == []
|
||||
|
||||
|
||||
def test_removes_dangling_symlink_into_hermes_node(fake_home):
|
||||
"""A link into the Hermes node dir is removed even if the target file is
|
||||
already gone (dangling) \u2014 the link still shadows PATH."""
|
||||
hermes_home = fake_home / ".hermes"
|
||||
node_bin = hermes_home / "node" / "bin"
|
||||
node_bin.mkdir(parents=True)
|
||||
local_bin = fake_home / ".local" / "bin"
|
||||
|
||||
# Create the symlink, then delete the target so it dangles.
|
||||
(local_bin / "node").symlink_to(node_bin / "node")
|
||||
assert (local_bin / "node").is_symlink()
|
||||
|
||||
removed = uninstall.remove_node_symlinks(hermes_home)
|
||||
|
||||
assert [p.name for p in removed] == ["node"]
|
||||
assert not (local_bin / "node").is_symlink()
|
||||
|
||||
|
||||
def test_only_some_links_present(fake_home):
|
||||
"""Removes the Hermes links that exist; ignores the ones that don't."""
|
||||
hermes_home = fake_home / ".hermes"
|
||||
node_bin = _make_hermes_node(hermes_home)
|
||||
local_bin = fake_home / ".local" / "bin"
|
||||
|
||||
# Only npm and npx are Hermes-managed; node is a real user binary.
|
||||
(local_bin / "npm").symlink_to(node_bin / "npm")
|
||||
(local_bin / "npx").symlink_to(node_bin / "npx")
|
||||
(local_bin / "node").write_text("#!/bin/sh\n")
|
||||
|
||||
removed = uninstall.remove_node_symlinks(hermes_home)
|
||||
|
||||
assert sorted(p.name for p in removed) == ["npm", "npx"]
|
||||
assert (local_bin / "node").exists()
|
||||
assert not (local_bin / "npm").is_symlink()
|
||||
assert not (local_bin / "npx").is_symlink()
|
||||
@@ -19,6 +19,7 @@ def test_version_string_no_v_prefix():
|
||||
def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
|
||||
"""When cache is fresh, check_for_updates should return cached value without calling git."""
|
||||
from hermes_cli.banner import check_for_updates
|
||||
from hermes_cli import __version__
|
||||
|
||||
# Create a fake git repo and fresh cache
|
||||
repo_dir = tmp_path / "hermes-agent"
|
||||
@@ -26,7 +27,7 @@ def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
|
||||
(repo_dir / ".git").mkdir()
|
||||
|
||||
cache_file = tmp_path / ".update_check"
|
||||
cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3}))
|
||||
cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3, "ver": __version__}))
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
||||
@@ -36,6 +37,43 @@ def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_check_for_updates_invalidates_on_version_change(tmp_path, monkeypatch):
|
||||
"""A fresh cache from a different installed version must be re-checked, not reused.
|
||||
|
||||
Regression for #34491: after `pip install --upgrade`, VERSION changes but the
|
||||
cache's 6h TTL hadn't expired and rev was unchanged (both None), so the stale
|
||||
'behind' count survived the upgrade. The version guard forces a recheck.
|
||||
"""
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
# No local git checkout -> the PyPI path is exercised (pip-install class).
|
||||
fake_banner = tmp_path / "hermes_cli" / "banner.py"
|
||||
fake_banner.parent.mkdir(parents=True, exist_ok=True)
|
||||
fake_banner.touch()
|
||||
monkeypatch.setattr(banner, "__file__", str(fake_banner))
|
||||
|
||||
# Fresh (within TTL) cache that says "behind", but stamped with an OLD version.
|
||||
cache_file = tmp_path / ".update_check"
|
||||
cache_file.write_text(
|
||||
json.dumps({"ts": time.time(), "behind": 1, "rev": None, "ver": "0.0.1-old"})
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("HERMES_REVISION", raising=False)
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run, \
|
||||
patch("hermes_cli.banner.check_via_pypi", return_value=0) as mock_pypi:
|
||||
result = banner.check_for_updates()
|
||||
|
||||
# Stale-version cache rejected -> fresh check ran -> up-to-date result.
|
||||
assert result == 0
|
||||
mock_pypi.assert_called_once()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
# Cache rewritten with the current installed version.
|
||||
written = json.loads(cache_file.read_text())
|
||||
assert written["ver"] == banner.VERSION
|
||||
|
||||
|
||||
def test_check_for_updates_expired_cache(tmp_path, monkeypatch):
|
||||
"""When cache is expired, check_for_updates should call git fetch."""
|
||||
from hermes_cli.banner import check_for_updates
|
||||
|
||||
@@ -491,6 +491,96 @@ class TestPreflightCompression:
|
||||
for ev, msg in status_messages
|
||||
)
|
||||
|
||||
def test_preflight_defers_when_recent_real_usage_fit(self, agent):
|
||||
"""A noisy rough estimate should not re-compact a recently fitting request."""
|
||||
agent.compression_enabled = True
|
||||
agent.context_compressor.context_length = 200_000
|
||||
agent.context_compressor.threshold_tokens = 100_000
|
||||
agent.context_compressor.last_prompt_tokens = 58_000
|
||||
agent.context_compressor.last_real_prompt_tokens = 58_000
|
||||
agent.context_compressor.last_rough_tokens_when_real_prompt_fit = 113_000
|
||||
|
||||
big_history = []
|
||||
for i in range(20):
|
||||
big_history.append({"role": "user", "content": f"Message {i} padded"})
|
||||
big_history.append({"role": "assistant", "content": f"Response {i} padded"})
|
||||
|
||||
ok_resp = _mock_response(
|
||||
content="Used real fit",
|
||||
finish_reason="stop",
|
||||
usage={"prompt_tokens": 59_000, "completion_tokens": 100, "total_tokens": 59_100},
|
||||
)
|
||||
agent.client.chat.completions.create.side_effect = [ok_resp]
|
||||
status_messages = []
|
||||
agent.status_callback = lambda ev, msg: status_messages.append((ev, msg))
|
||||
|
||||
with (
|
||||
patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=114_000),
|
||||
patch.object(agent, "_compress_context") as mock_compress,
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation("hello", conversation_history=big_history)
|
||||
|
||||
mock_compress.assert_not_called()
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "Used real fit"
|
||||
assert not any(
|
||||
ev == "lifecycle" and "Preflight compression" in msg
|
||||
for ev, msg in status_messages
|
||||
)
|
||||
|
||||
def test_preflight_compresses_when_rough_growth_after_fit_is_large(self, agent):
|
||||
"""Large rough growth after a fitting request still triggers preflight."""
|
||||
agent.compression_enabled = True
|
||||
agent.context_compressor.context_length = 200_000
|
||||
agent.context_compressor.threshold_tokens = 100_000
|
||||
agent.context_compressor.last_prompt_tokens = 58_000
|
||||
agent.context_compressor.last_real_prompt_tokens = 58_000
|
||||
agent.context_compressor.last_rough_tokens_when_real_prompt_fit = 113_000
|
||||
|
||||
big_history = []
|
||||
for i in range(20):
|
||||
big_history.append({"role": "user", "content": f"Message {i} padded"})
|
||||
big_history.append({"role": "assistant", "content": f"Response {i} padded"})
|
||||
|
||||
ok_resp = _mock_response(
|
||||
content="Compressed after growth",
|
||||
finish_reason="stop",
|
||||
usage={"prompt_tokens": 50_000, "completion_tokens": 100, "total_tokens": 50_100},
|
||||
)
|
||||
agent.client.chat.completions.create.side_effect = [ok_resp]
|
||||
|
||||
# First rough estimate must clear the threshold so preflight fires
|
||||
# (rough growth since the last fitting request is large, so the
|
||||
# deferral path is NOT taken). Every estimate after compaction is
|
||||
# sub-threshold. Use a callable side_effect rather than a fixed list
|
||||
# so we don't have to predict how many times the loop re-estimates —
|
||||
# the post-response real-token estimate is an extra call that a
|
||||
# 2-element list would exhaust (StopIteration).
|
||||
_rough_calls = {"n": 0}
|
||||
|
||||
def _rough_estimate(*_args, **_kwargs):
|
||||
_rough_calls["n"] += 1
|
||||
return 125_000 if _rough_calls["n"] == 1 else 40_000
|
||||
|
||||
with (
|
||||
patch("agent.conversation_loop.estimate_request_tokens_rough", side_effect=_rough_estimate),
|
||||
patch.object(agent, "_compress_context") as mock_compress,
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
mock_compress.return_value = (
|
||||
[{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}],
|
||||
"new system prompt",
|
||||
)
|
||||
result = agent.run_conversation("hello", conversation_history=big_history)
|
||||
|
||||
mock_compress.assert_called_once()
|
||||
assert result["completed"] is True
|
||||
|
||||
def test_no_preflight_when_under_threshold(self, agent):
|
||||
"""When history fits within context, no preflight compression needed."""
|
||||
agent.compression_enabled = True
|
||||
@@ -575,6 +665,74 @@ class TestPreflightCompression:
|
||||
mock_compress.assert_not_called()
|
||||
assert result["completed"] is True
|
||||
|
||||
def test_preflight_seeds_display_tokens_when_compression_aborts(self, agent):
|
||||
"""Display must reflect the real context size even when compression no-ops.
|
||||
|
||||
Regression: the CLI status bar reads ``last_prompt_tokens``, which only
|
||||
updated from a *successful* API response. When the loaded history was
|
||||
oversized but compression failed to reduce it (e.g. the auxiliary
|
||||
summary model timed out), the bar stayed stuck at the old, smaller
|
||||
value while the preflight estimate reported a much larger number —
|
||||
looking permanently out of sync.
|
||||
"""
|
||||
agent.compression_enabled = True
|
||||
agent.context_compressor.context_length = 200_000
|
||||
agent.context_compressor.threshold_tokens = 130_000
|
||||
# Simulate a stale display value from an earlier, smaller turn.
|
||||
agent.context_compressor.last_prompt_tokens = 74_400
|
||||
|
||||
big_history = []
|
||||
for i in range(20):
|
||||
big_history.append({"role": "user", "content": f"Message {i} padded text"})
|
||||
big_history.append({"role": "assistant", "content": f"Response {i} padded text"})
|
||||
|
||||
ok_resp = _mock_response(content="After preflight", finish_reason="stop")
|
||||
agent.client.chat.completions.create.side_effect = [ok_resp]
|
||||
|
||||
with (
|
||||
patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=144_669),
|
||||
# Compression no-ops (returns input unchanged) — mirrors an aux
|
||||
# summary-model timeout where the messages can't be reduced.
|
||||
patch.object(agent, "_compress_context", side_effect=lambda msgs, *a, **k: (msgs, agent._cached_system_prompt)),
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation("hello", conversation_history=big_history)
|
||||
|
||||
assert result["completed"] is True
|
||||
# The display token count was revised up to the fresh preflight estimate,
|
||||
# not left at the stale 74_400.
|
||||
assert agent.context_compressor.last_prompt_tokens == 144_669
|
||||
|
||||
def test_preflight_seed_only_revises_upward(self, agent):
|
||||
"""A larger tracked value must not be clobbered by a smaller estimate."""
|
||||
agent.compression_enabled = True
|
||||
agent.context_compressor.context_length = 200_000
|
||||
agent.context_compressor.threshold_tokens = 130_000
|
||||
# A real, larger usage figure is already tracked.
|
||||
agent.context_compressor.last_prompt_tokens = 160_000
|
||||
|
||||
big_history = []
|
||||
for i in range(20):
|
||||
big_history.append({"role": "user", "content": f"Message {i} padded text"})
|
||||
big_history.append({"role": "assistant", "content": f"Response {i} padded text"})
|
||||
|
||||
ok_resp = _mock_response(content="After preflight", finish_reason="stop")
|
||||
agent.client.chat.completions.create.side_effect = [ok_resp]
|
||||
|
||||
with (
|
||||
patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=144_669),
|
||||
patch.object(agent, "_compress_context", side_effect=lambda msgs, *a, **k: (msgs, agent._cached_system_prompt)),
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
agent.run_conversation("hello", conversation_history=big_history)
|
||||
|
||||
# Smaller estimate must not overwrite the larger tracked value.
|
||||
assert agent.context_compressor.last_prompt_tokens == 160_000
|
||||
|
||||
|
||||
class TestToolResultPreflightCompression:
|
||||
"""Compression should trigger when tool results push context past the threshold."""
|
||||
|
||||
@@ -70,4 +70,9 @@ def test_tool_call_validation_accepts_dict_arguments(monkeypatch):
|
||||
|
||||
result = agent.run_conversation("read the file")
|
||||
|
||||
assert result["final_response"] == "done"
|
||||
# The conversation hits max_iterations=3 (3 tool turns then forced summary).
|
||||
# PR #34470 adds an explainer suffix to abnormal turn endings so users
|
||||
# understand why the response is short instead of seeing a blank reply.
|
||||
# The exact suffix wording is owned by conversation_loop; this test only
|
||||
# cares that the model's actual text ('done') survives at the start.
|
||||
assert result["final_response"].startswith("done")
|
||||
|
||||
@@ -3046,7 +3046,11 @@ class TestRunConversation:
|
||||
|
||||
mock_compress.assert_not_called() # no compression triggered
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "(empty)"
|
||||
# #34452: the bare "(empty)" sentinel is now replaced by a
|
||||
# user-visible end-of-turn explanation so the failure isn't silent.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
assert result["turn_exit_reason"] == "empty_response_exhausted"
|
||||
assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries
|
||||
|
||||
def test_reasoning_only_response_prefill_then_empty(self, agent):
|
||||
@@ -3066,7 +3070,9 @@ class TestRunConversation:
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "(empty)"
|
||||
# #34452: explanation replaces the bare "(empty)" sentinel.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries
|
||||
|
||||
def test_reasoning_only_prefill_succeeds_on_continuation(self, agent):
|
||||
@@ -3113,7 +3119,9 @@ class TestRunConversation:
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "(empty)"
|
||||
# #34452: explanation replaces the bare "(empty)" sentinel.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
assert result["api_calls"] == 4 # 1 original + 3 retries
|
||||
|
||||
def test_truly_empty_response_succeeds_on_nudge(self, agent):
|
||||
@@ -3209,7 +3217,9 @@ class TestRunConversation:
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "(empty)"
|
||||
# #34452: explanation replaces the bare "(empty)" sentinel.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
|
||||
def test_empty_response_emits_status_for_gateway(self, agent):
|
||||
"""_emit_status is called during empty retries so gateway users see feedback."""
|
||||
@@ -3235,7 +3245,10 @@ class TestRunConversation:
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
|
||||
assert result["final_response"] == "(empty)"
|
||||
# #34452: explanation replaces the bare "(empty)" sentinel, but the
|
||||
# status emissions during retries are unchanged.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
# Should have emitted retry statuses (3 retries) + final failure
|
||||
retry_msgs = [m for m in status_messages if "retrying" in m.lower()]
|
||||
assert len(retry_msgs) == 3, f"Expected 3 retry status messages, got {len(retry_msgs)}: {status_messages}"
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for the end-of-turn completion explainer (#34452).
|
||||
|
||||
When a turn ends abnormally after tools (empty content after retries, a
|
||||
partial/truncated stream, exhausted retries, or an iteration/budget limit)
|
||||
the user should get a single user-visible explanation of why the reply
|
||||
stopped instead of a blank or fragmentary response box. Normal short
|
||||
replies (e.g. ``Done.``) must stay quiet.
|
||||
|
||||
These tests exercise:
|
||||
1. ``_format_turn_completion_explanation`` — the pure reason→message map.
|
||||
2. ``_turn_completion_explainer_enabled`` — the env/config seam.
|
||||
3. An end-to-end ``run_conversation`` turn that exhausts empty-response
|
||||
retries and verifies the explanation reaches ``final_response``.
|
||||
|
||||
All assertions work under the mocked OpenAI SDK used elsewhere in this
|
||||
suite (we patch ``run_agent.OpenAI`` and drive ``agent.client``), so they
|
||||
pass identically in CI and locally.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from run_agent import AIAgent
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fixtures (mirrors tests/run_agent/test_tool_call_guardrail_runtime.py)
|
||||
# --------------------------------------------------------------------------
|
||||
def _mock_response(content="Hello", finish_reason="stop", tool_calls=None):
|
||||
msg = SimpleNamespace(content=content, tool_calls=tool_calls)
|
||||
choice = SimpleNamespace(message=msg, finish_reason=finish_reason)
|
||||
return SimpleNamespace(choices=[choice], model="test/model", usage=None)
|
||||
|
||||
|
||||
def _make_agent(max_iterations: int = 10, config: dict | None = None) -> AIAgent:
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=[]),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("hermes_cli.config.load_config", return_value=config or {}),
|
||||
patch("run_agent.OpenAI"),
|
||||
):
|
||||
agent = AIAgent(
|
||||
api_key="test-key-1234567890",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
max_iterations=max_iterations,
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent.client = MagicMock()
|
||||
agent._cached_system_prompt = "You are helpful."
|
||||
agent._use_prompt_caching = False
|
||||
agent.tool_delay = 0
|
||||
agent.compression_enabled = False
|
||||
agent.save_trajectories = False
|
||||
# No fallback chain so empty responses exhaust deterministically.
|
||||
agent._fallback_chain = []
|
||||
return agent
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. Pure formatter
|
||||
# --------------------------------------------------------------------------
|
||||
def test_explanation_quiet_for_normal_text_response():
|
||||
"""A healthy text_response exit must NOT produce any explanation."""
|
||||
out = AIAgent._format_turn_completion_explanation(
|
||||
"text_response(finish_reason=stop)"
|
||||
)
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_explanation_quiet_for_empty_reason():
|
||||
assert AIAgent._format_turn_completion_explanation("") == ""
|
||||
assert AIAgent._format_turn_completion_explanation("unknown") == ""
|
||||
# guardrail_halt surfaces its own message; explainer stays out of the way.
|
||||
assert AIAgent._format_turn_completion_explanation("guardrail_halt") == ""
|
||||
|
||||
|
||||
def test_explanation_for_empty_response_exhausted():
|
||||
out = AIAgent._format_turn_completion_explanation("empty_response_exhausted")
|
||||
assert out # non-empty
|
||||
assert "empty content" in out
|
||||
assert "continue" in out.lower()
|
||||
|
||||
|
||||
def test_explanation_for_partial_stream_recovery():
|
||||
out = AIAgent._format_turn_completion_explanation("partial_stream_recovery")
|
||||
assert "partial" in out.lower()
|
||||
assert "continue" in out.lower()
|
||||
|
||||
|
||||
def test_explanation_for_max_iterations_reached_prefix_match():
|
||||
"""``max_iterations_reached(...)`` carries a parenthetical suffix."""
|
||||
out = AIAgent._format_turn_completion_explanation(
|
||||
"max_iterations_reached(10/10)"
|
||||
)
|
||||
assert "iteration" in out.lower()
|
||||
|
||||
|
||||
def test_explanation_for_all_retries_exhausted():
|
||||
out = AIAgent._format_turn_completion_explanation(
|
||||
"all_retries_exhausted_no_response"
|
||||
)
|
||||
assert "retries" in out.lower()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. Enable/disable seam
|
||||
# --------------------------------------------------------------------------
|
||||
def test_explainer_enabled_by_default():
|
||||
agent = _make_agent()
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HERMES_TURN_COMPLETION_EXPLAINER", None)
|
||||
with patch("hermes_cli.config.load_config", return_value={}):
|
||||
assert agent._turn_completion_explainer_enabled() is True
|
||||
|
||||
|
||||
def test_explainer_disabled_via_env():
|
||||
agent = _make_agent()
|
||||
with patch.dict(
|
||||
os.environ, {"HERMES_TURN_COMPLETION_EXPLAINER": "0"}, clear=False
|
||||
):
|
||||
assert agent._turn_completion_explainer_enabled() is False
|
||||
|
||||
|
||||
def test_explainer_disabled_via_config():
|
||||
agent = _make_agent()
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HERMES_TURN_COMPLETION_EXPLAINER", None)
|
||||
with patch(
|
||||
"hermes_cli.config.load_config",
|
||||
return_value={"display": {"turn_completion_explainer": False}},
|
||||
):
|
||||
assert agent._turn_completion_explainer_enabled() is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. End-to-end: empty-response exhaustion surfaces the explanation
|
||||
# --------------------------------------------------------------------------
|
||||
def test_run_conversation_empty_exhausted_surfaces_explanation():
|
||||
"""Four empty responses in a row should exhaust retries and the final
|
||||
response should be the actionable explanation, not a bare '(empty)'."""
|
||||
agent = _make_agent(max_iterations=10)
|
||||
# 4 empty responses: retries 1..3 then the terminal on the 4th.
|
||||
agent.client.chat.completions.create.side_effect = [
|
||||
_mock_response(content="", finish_reason="stop") for _ in range(8)
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation("do something")
|
||||
|
||||
assert result["turn_exit_reason"] == "empty_response_exhausted"
|
||||
# The user must NOT be left with a bare sentinel; the explanation wins.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert result["final_response"].strip() != ""
|
||||
assert "No reply:" in result["final_response"]
|
||||
|
||||
|
||||
def test_run_conversation_normal_reply_stays_quiet():
|
||||
"""A normal short reply like 'Done.' must NOT get an explainer footer."""
|
||||
agent = _make_agent(max_iterations=10)
|
||||
agent.client.chat.completions.create.side_effect = [
|
||||
_mock_response(content="Done.", finish_reason="stop"),
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation("do something")
|
||||
|
||||
assert result["turn_exit_reason"].startswith("text_response")
|
||||
assert result["final_response"] == "Done."
|
||||
assert "No reply:" not in result["final_response"]
|
||||
@@ -5114,6 +5114,8 @@ def test_notification_poller_skips_consumed(monkeypatch):
|
||||
|
||||
def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
"""When the agent is busy, the poller requeues the event."""
|
||||
import queue as _queue_mod
|
||||
|
||||
from tools.process_registry import process_registry
|
||||
|
||||
emitted = []
|
||||
@@ -5122,8 +5124,13 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
server._sessions["sid_busy"] = sess
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
|
||||
|
||||
while not process_registry.completion_queue.empty():
|
||||
process_registry.completion_queue.get_nowait()
|
||||
# Isolate the completion queue for the duration of this test. The poller
|
||||
# reads process_registry.completion_queue by attribute at runtime, so a
|
||||
# fresh Queue here means no concurrently-running test in the same xdist
|
||||
# worker can put/get on the shared singleton mid-run and drain the event
|
||||
# we expect to be requeued. monkeypatch restores the original on teardown.
|
||||
isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
|
||||
monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
|
||||
process_registry._completion_consumed.discard("proc_busy_test")
|
||||
|
||||
evt = {
|
||||
@@ -5133,7 +5140,7 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
"exit_code": 0,
|
||||
"output": "ok",
|
||||
}
|
||||
process_registry.completion_queue.put(evt)
|
||||
isolated_queue.put(evt)
|
||||
|
||||
stop = threading.Event()
|
||||
stop.set()
|
||||
@@ -5146,10 +5153,8 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
assert len(status_calls) == 1
|
||||
|
||||
# Event was requeued (agent was busy, no turn triggered)
|
||||
assert not process_registry.completion_queue.empty()
|
||||
requeued = process_registry.completion_queue.get_nowait()
|
||||
assert not isolated_queue.empty()
|
||||
requeued = isolated_queue.get_nowait()
|
||||
assert requeued["session_id"] == "proc_busy_test"
|
||||
finally:
|
||||
server._sessions.pop("sid_busy", None)
|
||||
while not process_registry.completion_queue.empty():
|
||||
process_registry.completion_queue.get_nowait()
|
||||
|
||||
+1
-1
@@ -3366,7 +3366,7 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
||||
return_exceptions=True,
|
||||
)
|
||||
for name, result in zip(server_names, results):
|
||||
if isinstance(result, Exception):
|
||||
if isinstance(result, BaseException):
|
||||
command = new_servers.get(name, {}).get("command")
|
||||
logger.warning(
|
||||
"Failed to connect to MCP server '%s'%s: %s",
|
||||
|
||||
@@ -1879,7 +1879,7 @@ requires-dist = [
|
||||
{ name = "rich", specifier = "==14.3.3" },
|
||||
{ name = "ruamel-yaml", specifier = "==0.18.17" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" },
|
||||
{ name = "setuptools", marker = "extra == 'dev'", specifier = ">=61.0,<83" },
|
||||
{ name = "setuptools", marker = "extra == 'dev'", specifier = "==82.0.1" },
|
||||
{ name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" },
|
||||
{ name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" },
|
||||
{ name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" },
|
||||
|
||||
@@ -43,7 +43,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
||||
| `/retry` | Retry the last message (resend to agent) |
|
||||
| `/undo` | Remove the last user/assistant exchange |
|
||||
| `/title` | Set a title for the current session (usage: /title My Session Name) |
|
||||
| `/compress [focus topic]` | Manually compress conversation context (flush memories + summarize). Optional focus topic narrows what the summary preserves. |
|
||||
| `/compress [here [N] \| focus topic]` | Manually compress conversation context (flush memories + summarize). `/compress here [N]` summarizes everything except the most recent N exchanges (default 2), kept verbatim — pick your own compression boundary. A focus topic narrows what a full summary preserves. |
|
||||
| `/rollback` | List or restore filesystem checkpoints (usage: /rollback [number]) |
|
||||
| `/snapshot [create\|restore <id>\|prune]` (alias: `/snap`) | Create or restore state snapshots of Hermes config/state. `create [label]` saves a snapshot, `restore <id>` reverts to it, `prune [N]` removes old snapshots, or list all with no args. |
|
||||
| `/stop` | Kill all running background processes |
|
||||
@@ -206,7 +206,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
||||
| `/retry` | Retry the last message. |
|
||||
| `/undo` | Remove the last exchange. |
|
||||
| `/sethome` (alias: `/set-home`) | Mark the current chat as the platform home channel for deliveries. |
|
||||
| `/compress [focus topic]` | Manually compress conversation context. Optional focus topic narrows what the summary preserves. |
|
||||
| `/compress [here [N] \| focus topic]` | Manually compress conversation context. `/compress here [N]` keeps the most recent N exchanges (default 2) verbatim and summarizes the rest. A focus topic narrows what a full summary preserves. |
|
||||
| `/topic [off\|help\|session-id]` | **Telegram DM only.** Manage user-managed multi-session topic mode. `/topic` enables it or shows status; `/topic off` disables it and clears bindings; `/topic help` shows usage; `/topic <session-id>` inside a topic restores a previous session. See [Multi-session DM mode](/user-guide/messaging/telegram#multi-session-dm-mode-topic). |
|
||||
| `/title [name]` | Set or show the session title. |
|
||||
| `/resume [name]` | Resume a previously named session. |
|
||||
|
||||
Reference in New Issue
Block a user