Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8825ad20c1 | ||
|
|
152207c0cc | ||
|
|
e643c79c2c | ||
|
|
fb0ab27649 | ||
|
|
de6d6023d7 | ||
|
|
59b0ea98c8 | ||
|
|
897f9533ed | ||
|
|
9d4c81130a | ||
|
|
2259c15e4d | ||
|
|
45bc65abbe | ||
|
|
9dbc3722ae | ||
|
|
e38b0b55d1 | ||
|
|
04de307d62 |
@@ -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
|
||||
|
||||
@@ -12945,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
|
||||
@@ -12956,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
+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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user