Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1bde77122 |
@@ -3,9 +3,11 @@ name: Contributor Attribution Check
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
paths:
|
||||
# Only run when code files change (not docs-only PRs)
|
||||
- '*.py'
|
||||
- '**/*.py'
|
||||
- '.github/workflows/contributor-check.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -18,21 +20,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check if relevant files changed
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Python files changed, skipping attribution check."
|
||||
fi
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
if: steps.filter.outputs.run == 'true'
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
|
||||
@@ -3,9 +3,15 @@ name: Supply Chain Audit
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
# No paths filter — the jobs must always run so required checks
|
||||
# report a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
paths:
|
||||
- '**/*.py'
|
||||
- '**/*.pth'
|
||||
- '**/setup.py'
|
||||
- '**/setup.cfg'
|
||||
- '**/sitecustomize.py'
|
||||
- '**/usercustomize.py'
|
||||
- '**/__init__.pth'
|
||||
- 'pyproject.toml'
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -21,44 +27,8 @@ permissions:
|
||||
# advisory-only workflow instead.
|
||||
|
||||
jobs:
|
||||
# ── Path filter (shared by both scan and dep-bounds) ───────────────
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
# True when any file the scanner cares about changed in this PR
|
||||
scan: ${{ steps.filter.outputs.scan }}
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'*.py' '**/*.py' '*.pth' '**/*.pth' \
|
||||
'setup.py' 'setup.cfg' \
|
||||
'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
|
||||
'pyproject.toml' || true)
|
||||
if [ -n "$SCAN_FILES" ]; then
|
||||
echo "scan=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "scan=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
|
||||
if [ -n "$DEPS_FILES" ]; then
|
||||
echo "deps=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
scan:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scan == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -177,24 +147,10 @@ jobs:
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when scan was skipped (no relevant files changed).
|
||||
# This ensures the required check always gets a status.
|
||||
scan-gate:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.scan != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No supply-chain-relevant files changed, skipping scan."
|
||||
|
||||
dep-bounds:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
if: needs.changes.outputs.deps == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -255,16 +211,3 @@ jobs:
|
||||
run: |
|
||||
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when dep-bounds was skipped (no pyproject.toml changed).
|
||||
# This ensures the required check always gets a status.
|
||||
dep-bounds-gate:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.deps != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ Bundled skills (in `skills/`) ship with every Hermes install. They should be **b
|
||||
- Document handling, web research, common dev workflows, system administration
|
||||
- Used regularly by a wide range of people
|
||||
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, built-in trust).
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, builtin trust).
|
||||
|
||||
If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a skills registry and share it in the [Nous Research Discord](https://discord.gg/NousResearch). Users can install it with `hermes skills install`.
|
||||
|
||||
|
||||
+17
-16
@@ -4725,23 +4725,24 @@ def _build_call_kwargs(
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
if max_tokens is not None:
|
||||
# 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 ""
|
||||
# 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)
|
||||
)
|
||||
if _is_anthropic_compat_endpoint(provider, _effective_base):
|
||||
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:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
if tools:
|
||||
|
||||
@@ -518,10 +518,6 @@ 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,
|
||||
@@ -619,10 +615,6 @@ 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 ""
|
||||
|
||||
@@ -656,44 +648,6 @@ 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,15 +115,6 @@ 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,18 +575,19 @@ def compress_context(
|
||||
force=True,
|
||||
)
|
||||
|
||||
# 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.
|
||||
# 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).
|
||||
_compressed_est = estimate_request_tokens_rough(
|
||||
compressed,
|
||||
system_prompt=new_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
agent.context_compressor.last_compression_rough_tokens = _compressed_est
|
||||
agent.context_compressor.last_prompt_tokens = -1
|
||||
agent.context_compressor.last_prompt_tokens = _compressed_est
|
||||
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
|
||||
@@ -598,7 +599,7 @@ def compress_context(
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true",
|
||||
"context compression done: session=%s messages=%d->%d tokens=~%s",
|
||||
agent.session_id or "none", _pre_msg_count, len(compressed),
|
||||
f"{_compressed_est:,}",
|
||||
)
|
||||
|
||||
@@ -600,50 +600,18 @@ 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 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):
|
||||
if agent.context_compressor.should_compress(_preflight_tokens):
|
||||
logger.info(
|
||||
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
|
||||
f"{_preflight_tokens:,}",
|
||||
f"{_compressor.threshold_tokens:,}",
|
||||
f"{agent.context_compressor.threshold_tokens:,}",
|
||||
agent.model,
|
||||
f"{_compressor.context_length:,}",
|
||||
f"{agent.context_compressor.context_length:,}",
|
||||
)
|
||||
agent._emit_status(
|
||||
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
|
||||
f">= {_compressor.threshold_tokens:,} threshold. "
|
||||
f">= {agent.context_compressor.threshold_tokens:,} threshold. "
|
||||
"This may take a moment."
|
||||
)
|
||||
# May need multiple passes for very large sessions with small
|
||||
@@ -678,8 +646,8 @@ def run_conversation(
|
||||
system_prompt=active_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
if not _compressor.should_compress(_preflight_tokens):
|
||||
break # Under threshold or anti-thrash guard stopped it
|
||||
if _preflight_tokens < agent.context_compressor.threshold_tokens:
|
||||
break # Under threshold
|
||||
|
||||
# Plugin hook: pre_llm_call
|
||||
# Fired once per turn before the tool-calling loop. Plugins can
|
||||
@@ -3894,11 +3862,6 @@ 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
|
||||
@@ -4480,55 +4443,6 @@ 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
|
||||
|
||||
+1
-1
@@ -331,7 +331,7 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
|
||||
"""Apply all redaction patterns to a block of text.
|
||||
|
||||
Safe to call on any string -- non-matching text passes through unchanged.
|
||||
Enabled by default. Disable via security.redact_secrets: false in config.yaml.
|
||||
Disabled by default — enable via security.redact_secrets: true in config.yaml.
|
||||
Set force=True for safety boundaries that must never return raw secrets
|
||||
regardless of the user's global logging redaction preference.
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ model:
|
||||
# -----------------------------------------------------------------------------
|
||||
# Working directory behavior:
|
||||
# - CLI (`hermes` command): Uses "." (current directory where you run hermes)
|
||||
# - Gateway/messaging/cron: Uses terminal.cwd here; legacy .env cwd values are deprecated
|
||||
# - Messaging (Telegram/Discord): Uses MESSAGING_CWD from .env (default: home)
|
||||
terminal:
|
||||
backend: "local"
|
||||
cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise.
|
||||
|
||||
@@ -3248,12 +3248,6 @@ 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
|
||||
@@ -6699,21 +6693,10 @@ 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()}")
|
||||
@@ -6827,44 +6810,6 @@ 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.
|
||||
|
||||
@@ -8388,14 +8333,7 @@ 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
|
||||
@@ -9947,20 +9885,10 @@ class HermesCLI:
|
||||
def _manual_compress(self, cmd_original: str = ""):
|
||||
"""Manually trigger context compression on the current conversation.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
if not self.conversation_history or len(self.conversation_history) < 4:
|
||||
print("(._.) Not enough conversation to compress (need at least 4 messages).")
|
||||
@@ -9974,21 +9902,12 @@ class HermesCLI:
|
||||
print("(._.) Compression is disabled in config.")
|
||||
return
|
||||
|
||||
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 = ""
|
||||
# Extract optional focus topic from the command (e.g. "/compress database schema")
|
||||
focus_topic = ""
|
||||
if cmd_original:
|
||||
_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 ""
|
||||
parts = cmd_original.strip().split(None, 1)
|
||||
if len(parts) > 1:
|
||||
focus_topic = parts[1].strip()
|
||||
|
||||
original_count = len(self.conversation_history)
|
||||
with self._busy_command("Compressing context..."):
|
||||
@@ -9996,22 +9915,6 @@ 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
|
||||
@@ -10023,11 +9926,7 @@ class HermesCLI:
|
||||
system_prompt=_sys_prompt,
|
||||
tools=_tools,
|
||||
)
|
||||
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:
|
||||
if focus_topic:
|
||||
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens), "
|
||||
f"focus: \"{focus_topic}\"...")
|
||||
else:
|
||||
@@ -10040,21 +9939,12 @@ class HermesCLI:
|
||||
# which already contain the agent identity — resulting in the
|
||||
# identity block appearing twice (issue #15281).
|
||||
compressed, _ = self.agent._compress_context(
|
||||
head,
|
||||
original_history,
|
||||
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
|
||||
@@ -12945,13 +12835,6 @@ 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
|
||||
@@ -12963,13 +12846,6 @@ 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
|
||||
@@ -14103,12 +13979,7 @@ class HermesCLI:
|
||||
reserved_below = 6
|
||||
|
||||
available = max(0, term_rows - reserved_below)
|
||||
# The compact decision must reserve room for at least one question
|
||||
# row on top of the choices, otherwise full chrome (3 blank
|
||||
# separators) gets kept when there is no room for it and the panel
|
||||
# overflows the viewport — HSplit then clips the panel's tail,
|
||||
# silently dropping the choices (the reported bug).
|
||||
mandatory_full = chrome_full + 1 + len(choice_wrapped) + len(other_wrapped)
|
||||
mandatory_full = chrome_full + len(choice_wrapped) + len(other_wrapped)
|
||||
|
||||
use_compact_chrome = mandatory_full > available
|
||||
chrome_rows = chrome_tight if use_compact_chrome else chrome_full
|
||||
@@ -14116,24 +13987,9 @@ class HermesCLI:
|
||||
max_question_rows = max(1, available - chrome_rows - len(choice_wrapped) - len(other_wrapped))
|
||||
max_question_rows = min(max_question_rows, 12) # soft cap on huge terminals
|
||||
|
||||
# When the choices alone (plus compact chrome) already exceed the
|
||||
# viewport, drop the question entirely — the choices are the only
|
||||
# thing the user must see to make a selection. Without this the
|
||||
# question would still claim its 1-row floor above and push the
|
||||
# tail of the choices off-screen (HSplit clips the overflow).
|
||||
choices_overflow = chrome_rows + len(choice_wrapped) + len(other_wrapped) >= available
|
||||
if choices_overflow:
|
||||
max_question_rows = 0
|
||||
|
||||
question_wrapped = _wrap_panel_text(question, inner_text_width)
|
||||
if max_question_rows <= 0:
|
||||
question_wrapped = []
|
||||
elif len(question_wrapped) > max_question_rows:
|
||||
# The truncation marker is itself a row, so it must count
|
||||
# against the budget. With a 1-row budget there is no room for
|
||||
# both a question line and the marker — show the marker alone
|
||||
# so the rendered question never exceeds max_question_rows.
|
||||
keep = max(0, max_question_rows - 1)
|
||||
if len(question_wrapped) > max_question_rows:
|
||||
keep = max(1, max_question_rows - 1)
|
||||
question_wrapped = question_wrapped[:keep] + ["… (question truncated)"]
|
||||
|
||||
lines = []
|
||||
@@ -14667,17 +14523,6 @@ 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,13 +474,6 @@ 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
|
||||
@@ -589,7 +582,6 @@ 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,
|
||||
@@ -658,9 +650,6 @@ 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),
|
||||
@@ -768,11 +757,6 @@ 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,8 +9,6 @@ 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
|
||||
@@ -23,32 +21,6 @@ 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
|
||||
|
||||
@@ -289,18 +261,6 @@ 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,
|
||||
@@ -326,27 +286,6 @@ 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
|
||||
|
||||
@@ -1605,7 +1605,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
)
|
||||
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
|
||||
effective_session_id = result.get("session_id", session_id) if isinstance(result, dict) else session_id
|
||||
turn_messages = self._turn_transcript_messages(history, user_message, result) if isinstance(result, dict) else []
|
||||
await queue.put(_event_payload("assistant.completed", {
|
||||
"session_id": effective_session_id,
|
||||
"message_id": message_id,
|
||||
@@ -1618,7 +1617,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"session_id": effective_session_id,
|
||||
"message_id": message_id,
|
||||
"completed": True,
|
||||
"messages": turn_messages,
|
||||
"usage": usage,
|
||||
}))
|
||||
except Exception as exc:
|
||||
@@ -3331,44 +3329,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return len(prior)
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def _turn_transcript_messages(
|
||||
cls,
|
||||
conversation_history: List[Dict[str, Any]],
|
||||
user_message: Any,
|
||||
result: Dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return this turn's assistant/tool messages in client-safe shape.
|
||||
|
||||
The streaming SSE contract delivers all assistant text as
|
||||
``assistant.delta`` events under one ``message_id`` interleaved with
|
||||
``tool.*`` events, and a single ``assistant.completed`` carrying only
|
||||
the final reply. A client that accumulates deltas into one buffer
|
||||
cannot reconstruct *intermediate* assistant text segments that preceded
|
||||
tool calls — so when the page is re-opened mid/post-stream those
|
||||
segments appear lost, even though state.db persisted them correctly.
|
||||
|
||||
Emitting the authoritative per-turn transcript on ``run.completed`` lets
|
||||
any SSE consumer reconcile its live view against ground truth without a
|
||||
separate ``GET /messages`` round-trip. Purely additive: clients that
|
||||
ignore the field are unaffected. Refs #34703.
|
||||
"""
|
||||
agent_messages = result.get("messages") if isinstance(result, dict) else None
|
||||
if not isinstance(agent_messages, list) or not agent_messages:
|
||||
return []
|
||||
start = cls._response_messages_turn_start_index(
|
||||
conversation_history, user_message, result
|
||||
)
|
||||
turn = agent_messages[start:]
|
||||
out: List[Dict[str, Any]] = []
|
||||
for msg in turn:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") not in {"assistant", "tool"}:
|
||||
continue
|
||||
out.append(cls._message_response(msg))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
|
||||
+23
-80
@@ -1131,75 +1131,6 @@ SUPPORTED_IMAGE_DOCUMENT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Media-delivery extension allowlist — SINGLE SOURCE OF TRUTH
|
||||
#
|
||||
# Both extractors that turn response text into native attachments derive their
|
||||
# extension set from this tuple:
|
||||
# * ``extract_media()`` — explicit ``MEDIA:<path>`` tags
|
||||
# * ``extract_local_files()`` — bare absolute/home paths the agent mentions
|
||||
#
|
||||
# Historically these two carried independently-maintained extension lists.
|
||||
# ``extract_media`` had a narrow list (no .md/.json/.yaml/.xml/.html/...) while
|
||||
# ``extract_local_files`` had a broad one. Combined with the unconditional
|
||||
# ``MEDIA:\\s*\\S+`` cleanup at the dispatch sites, that mismatch created a
|
||||
# silent black hole: a ``MEDIA:/report.md`` tag failed the narrow extract_media
|
||||
# match, got stripped from the body by the loose cleanup regex, and was then
|
||||
# invisible to extract_local_files — the file was never delivered (issue
|
||||
# #34517). Keeping one list eliminates the drift; building the cleanup regexes
|
||||
# from the same set means a tag is only stripped when its extension is one we
|
||||
# can actually deliver, so an unknown-extension path survives in the body
|
||||
# instead of vanishing.
|
||||
#
|
||||
# Covers images (inline), video (inline where supported), audio (voice/audio),
|
||||
# documents/spreadsheets/presentations (send_document), archives, and rendered
|
||||
# web output. The dispatch partition (image vs video vs document) lives in
|
||||
# ``gateway/run.py``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MEDIA_DELIVERY_EXTS: Tuple[str, ...] = (
|
||||
# Images (embed inline)
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg",
|
||||
# Video (embed inline where supported)
|
||||
".mp4", ".mov", ".avi", ".mkv", ".webm",
|
||||
# Audio (delivered as voice/audio where supported)
|
||||
".mp3", ".wav", ".ogg", ".opus", ".m4a", ".flac",
|
||||
# Documents (uploaded as file attachments)
|
||||
".pdf", ".docx", ".doc", ".odt", ".rtf", ".txt", ".md", ".epub",
|
||||
# Spreadsheets / data
|
||||
".xlsx", ".xls", ".ods", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml",
|
||||
# Presentations
|
||||
".pptx", ".ppt", ".odp", ".key",
|
||||
# Archives
|
||||
".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".apk", ".ipa",
|
||||
# Web / rendered output
|
||||
".html", ".htm",
|
||||
)
|
||||
|
||||
# Regex alternation fragment of bare extensions (no leading dot), e.g.
|
||||
# ``png|jpe?g|...``. ``jpe?g`` collapses jpg/jpeg into one branch. Sorted
|
||||
# longest-first so the alternation never matches a shorter ext as a prefix of
|
||||
# a longer one (e.g. ``.tar`` before ``.tar.gz`` components).
|
||||
_MEDIA_EXT_ALTERNATION = "|".join(
|
||||
sorted((e.lstrip(".") for e in MEDIA_DELIVERY_EXTS), key=len, reverse=True)
|
||||
)
|
||||
|
||||
# Anchored ``MEDIA:<path>`` cleanup pattern. Unlike the old loose
|
||||
# ``MEDIA:\\s*\\S+``, this only strips a tag whose path ends in a known
|
||||
# deliverable extension (optionally quoted/backticked). A ``MEDIA:`` tag with
|
||||
# an unknown extension is left in the text so it can still be picked up by the
|
||||
# bare-path detector (extract_local_files) downstream rather than silently
|
||||
# deleted. Shared by the non-streaming dispatch path and the streaming
|
||||
# consumer so both behave identically.
|
||||
MEDIA_TAG_CLEANUP_RE = re.compile(
|
||||
r'''[`"']?MEDIA:\s*'''
|
||||
r'''(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|'''
|
||||
r'''(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
|
||||
r'''(?=[\s`"',;:)\]}]|$)[`"']?''',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def get_document_cache_dir() -> Path:
|
||||
"""Return the document cache directory, creating it if it doesn't exist."""
|
||||
DOCUMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2611,10 +2542,10 @@ class BasePlatformAdapter(ABC):
|
||||
cleaned = cleaned.replace("[[as_document]]", "")
|
||||
|
||||
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
|
||||
# and quoted/backticked paths for LLM-formatted outputs. The extension
|
||||
# set is the shared MEDIA_DELIVERY_EXTS source of truth (built once into
|
||||
# MEDIA_TAG_CLEANUP_RE) so it can never drift from extract_local_files.
|
||||
media_pattern = MEDIA_TAG_CLEANUP_RE
|
||||
# and quoted/backticked paths for LLM-formatted outputs.
|
||||
media_pattern = re.compile(
|
||||
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$))[`"']?'''
|
||||
)
|
||||
for match in media_pattern.finditer(content):
|
||||
path = match.group("path").strip()
|
||||
if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
|
||||
@@ -2660,7 +2591,24 @@ class BasePlatformAdapter(ABC):
|
||||
Tuple of (list of expanded file paths, cleaned text with the
|
||||
raw path strings removed).
|
||||
"""
|
||||
_LOCAL_MEDIA_EXTS = MEDIA_DELIVERY_EXTS
|
||||
_LOCAL_MEDIA_EXTS = (
|
||||
# Images (embed inline)
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.svg',
|
||||
# Video (embed inline where supported)
|
||||
'.mp4', '.mov', '.avi', '.mkv', '.webm',
|
||||
# Audio (delivered as voice/audio where supported)
|
||||
'.mp3', '.wav', '.ogg', '.m4a', '.flac',
|
||||
# Documents (uploaded as file attachments)
|
||||
'.pdf', '.docx', '.doc', '.odt', '.rtf', '.txt', '.md',
|
||||
# Spreadsheets / data
|
||||
'.xlsx', '.xls', '.ods', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml',
|
||||
# Presentations
|
||||
'.pptx', '.ppt', '.odp', '.key',
|
||||
# Archives
|
||||
'.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar',
|
||||
# Web / rendered output
|
||||
'.html', '.htm',
|
||||
)
|
||||
ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS)
|
||||
|
||||
# (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png)
|
||||
@@ -3781,12 +3729,7 @@ class BasePlatformAdapter(ABC):
|
||||
# Strip any remaining internal directives from message body (fixes #1561)
|
||||
text_content = text_content.replace("[[audio_as_voice]]", "").strip()
|
||||
text_content = text_content.replace("[[as_document]]", "").strip()
|
||||
# Strip only MEDIA: tags whose path has a deliverable extension
|
||||
# (shared MEDIA_TAG_CLEANUP_RE). A MEDIA: tag with an unknown
|
||||
# extension is intentionally left in the body so extract_local_files
|
||||
# below can still pick up the bare path — otherwise the file would
|
||||
# be silently dropped (issue #34517).
|
||||
text_content = MEDIA_TAG_CLEANUP_RE.sub("", text_content).strip()
|
||||
text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip()
|
||||
if images:
|
||||
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ user is seen through different apps in the future.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import hashlib
|
||||
import hmac
|
||||
import itertools
|
||||
@@ -1409,8 +1408,6 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
"""Feishu/Lark bot adapter."""
|
||||
|
||||
MAX_MESSAGE_LENGTH = 8000
|
||||
# Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.
|
||||
CHAT_LOCK_MAX_SIZE: int = 1000
|
||||
# Threshold for detecting Feishu client-side message splits.
|
||||
# When a chunk is near the ~4096-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
@@ -1448,7 +1445,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._pending_inbound_lock = threading.Lock()
|
||||
self._pending_drain_scheduled = False
|
||||
self._pending_inbound_max_depth = 1000 # cap queue; drop oldest beyond
|
||||
self._chat_locks: "collections.OrderedDict[str, asyncio.Lock]" = collections.OrderedDict() # chat_id → lock (per-chat serial processing, LRU-bounded)
|
||||
self._chat_locks: Dict[str, asyncio.Lock] = {} # chat_id → lock (per-chat serial processing)
|
||||
self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing)
|
||||
self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat
|
||||
self._chat_info_cache: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -2838,28 +2835,11 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
# =========================================================================
|
||||
|
||||
def _get_chat_lock(self, chat_id: str) -> asyncio.Lock:
|
||||
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing.
|
||||
|
||||
Bounded with LRU eviction so a long-running gateway that sees many
|
||||
distinct chats does not grow ``_chat_locks`` without limit. Locks that
|
||||
are currently held are never evicted; if every entry is locked we fall
|
||||
back to dropping the least-recently-used one.
|
||||
"""
|
||||
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing."""
|
||||
lock = self._chat_locks.get(chat_id)
|
||||
if lock is not None:
|
||||
self._chat_locks.move_to_end(chat_id)
|
||||
return lock
|
||||
if len(self._chat_locks) >= self.CHAT_LOCK_MAX_SIZE:
|
||||
evicted = False
|
||||
for key in list(self._chat_locks):
|
||||
if not self._chat_locks[key].locked():
|
||||
self._chat_locks.pop(key)
|
||||
evicted = True
|
||||
break
|
||||
if not evicted:
|
||||
self._chat_locks.pop(next(iter(self._chat_locks)))
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[chat_id] = lock
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[chat_id] = lock
|
||||
return lock
|
||||
|
||||
async def _handle_message_with_guards(self, event: MessageEvent) -> None:
|
||||
|
||||
+11
-92
@@ -11743,16 +11743,9 @@ class GatewayRunner:
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio
|
||||
|
||||
media_files, cleaned = adapter.extract_media(response)
|
||||
media_files, _ = adapter.extract_media(response)
|
||||
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||
# Chain the cleaned text through each extractor (extract_media →
|
||||
# extract_images → extract_local_files) so MEDIA: tags and image URLs
|
||||
# are removed before the bare-path auto-detect runs. Previously the
|
||||
# cleaned text from extract_media was dropped (``_``) and
|
||||
# extract_local_files scanned text that still contained MEDIA: tags,
|
||||
# producing false-positive bare-path matches with the MEDIA: prefix
|
||||
# glued on. This matches the chain order in gateway/platforms/base.py.
|
||||
_, cleaned = adapter.extract_images(cleaned)
|
||||
_, cleaned = adapter.extract_images(response)
|
||||
local_files, _ = adapter.extract_local_files(cleaned)
|
||||
local_files = BasePlatformAdapter.filter_local_delivery_paths(local_files)
|
||||
|
||||
@@ -12449,12 +12442,6 @@ 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)
|
||||
@@ -12463,15 +12450,8 @@ class GatewayRunner:
|
||||
if not history or len(history) < 4:
|
||||
return t("gateway.compress.not_enough")
|
||||
|
||||
# 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)
|
||||
# Extract optional focus topic from command args
|
||||
focus_topic = (event.get_command_args() or "").strip() or None
|
||||
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
@@ -12492,19 +12472,6 @@ 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,
|
||||
@@ -12528,20 +12495,15 @@ class GatewayRunner:
|
||||
)
|
||||
|
||||
compressor = tmp_agent.context_compressor
|
||||
if not compressor.has_content_to_compress(head):
|
||||
if not compressor.has_content_to_compress(msgs):
|
||||
return t("gateway.compress.nothing_to_do")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
compressed, _ = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: tmp_agent._compress_context(head, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True)
|
||||
lambda: tmp_agent._compress_context(msgs, "", 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
|
||||
@@ -17516,33 +17478,13 @@ class GatewayRunner:
|
||||
# append any that aren't already present in the final response, so the
|
||||
# adapter's extract_media() can find and deliver the files exactly once.
|
||||
#
|
||||
# Scope the scan to THIS turn's tool results only. ``agent_history``
|
||||
# was passed into run_conversation as ``conversation_history``, so the
|
||||
# agent's returned ``messages`` list is ``agent_history`` followed by
|
||||
# the messages produced this turn. Slicing at ``len(agent_history)``
|
||||
# isolates the current turn precisely, so a stale MEDIA: path emitted
|
||||
# by a tool several turns earlier (still present in the full message
|
||||
# list) can never leak onto a later text-only reply. (Fixes #34608)
|
||||
#
|
||||
# Path-based deduplication against _history_media_paths (collected
|
||||
# before run_conversation) is retained as a secondary guard. It is
|
||||
# also the sole guard on the fallback branch taken when mid-run
|
||||
# context compression shrinks the message list below the original
|
||||
# history length, preserving the compression-safe behaviour of #160.
|
||||
# Uses path-based deduplication against _history_media_paths (collected
|
||||
# before run_conversation) instead of index slicing. This is safe even
|
||||
# when context compression shrinks the message list. (Fixes #160)
|
||||
if "MEDIA:" not in final_response:
|
||||
media_tags = []
|
||||
has_voice_directive = False
|
||||
_all_msgs = result.get("messages", [])
|
||||
_history_len = len(agent_history)
|
||||
# Only trust the slice boundary when the message list still
|
||||
# contains the full history prefix. Mid-run compression can
|
||||
# rewrite/shrink the list; in that case fall back to scanning
|
||||
# everything and rely on _history_media_paths for dedup.
|
||||
if _history_len and len(_all_msgs) >= _history_len:
|
||||
_scan_msgs = _all_msgs[_history_len:]
|
||||
else:
|
||||
_scan_msgs = _all_msgs
|
||||
for msg in _scan_msgs:
|
||||
for msg in result.get("messages", []):
|
||||
if msg.get("role") in {"tool", "function"}:
|
||||
content = msg.get("content", "")
|
||||
if "MEDIA:" in content:
|
||||
@@ -18500,10 +18442,7 @@ def _run_planned_stop_watcher(
|
||||
poll_interval: seconds between marker checks. 0.5s gives a
|
||||
responsive shutdown without burning CPU.
|
||||
"""
|
||||
from gateway.status import (
|
||||
_get_planned_stop_marker_path,
|
||||
planned_stop_marker_targets_self,
|
||||
)
|
||||
from gateway.status import _get_planned_stop_marker_path
|
||||
marker_path = _get_planned_stop_marker_path()
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
@@ -18512,26 +18451,6 @@ def _run_planned_stop_watcher(
|
||||
and not getattr(runner, "_draining", False)
|
||||
and getattr(runner, "_running", False)
|
||||
):
|
||||
# A marker existing is NOT sufficient — it may have been
|
||||
# written for a PREVIOUS gateway instance (different PID)
|
||||
# and left behind because that process exited before the
|
||||
# CLI's stop() could clean it up. Firing the handler on a
|
||||
# stale/foreign marker drives the gateway into shutdown,
|
||||
# then consume_planned_stop_marker_for_self() correctly
|
||||
# reports a PID mismatch — but by then we're already
|
||||
# stopping, so it's logged as an unexpected "UNKNOWN" exit
|
||||
# and the watchdog crash-loops the gateway (issue #34597,
|
||||
# a regression from PR #33798 which added this watcher
|
||||
# without the PID check).
|
||||
#
|
||||
# Only fire when the marker actually targets us. The probe
|
||||
# is non-destructive on a match (the handler does the
|
||||
# authoritative consume on the loop thread) and self-heals
|
||||
# by unlinking stale/malformed markers so they cannot wedge
|
||||
# a freshly booted gateway.
|
||||
if not planned_stop_marker_targets_self():
|
||||
stop_event.wait(poll_interval)
|
||||
continue
|
||||
# Drive the same path as a real signal handler.
|
||||
# Pass signal=None — the handler tolerates that and consumes
|
||||
# the marker via consume_planned_stop_marker_for_self,
|
||||
|
||||
+6
-80
@@ -816,24 +816,12 @@ def _consume_pid_marker_for_self(
|
||||
|
||||
our_pid = os.getpid()
|
||||
our_start_time = _get_process_start_time(our_pid)
|
||||
# Start-time is a PID-reuse guard. It is only meaningful when both
|
||||
# sides actually have it: ``_get_process_start_time`` returns None on
|
||||
# platforms without ``/proc`` (macOS, native Windows — the very
|
||||
# platform the planned-stop watcher exists for). Requiring a non-None
|
||||
# match there would make every consume return False, so a legitimate
|
||||
# ``hermes gateway stop`` on Windows would be misclassified as an
|
||||
# unexpected ``UNKNOWN`` exit (exit 1) and revived by the service
|
||||
# manager. So: when both start_times are known they must match; when
|
||||
# either is unknown, fall back to PID equality alone (bounded by the
|
||||
# marker's short TTL). This mirrors ``planned_stop_marker_targets_self``
|
||||
# so the watcher's non-destructive probe and this authoritative
|
||||
# consume agree on every platform (issue #34597).
|
||||
if target_pid != our_pid:
|
||||
matches = False
|
||||
elif target_start_time is not None and our_start_time is not None:
|
||||
matches = target_start_time == our_start_time
|
||||
else:
|
||||
matches = True
|
||||
matches = (
|
||||
target_pid == our_pid
|
||||
and target_start_time is not None
|
||||
and our_start_time is not None
|
||||
and target_start_time == our_start_time
|
||||
)
|
||||
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
@@ -926,68 +914,6 @@ def consume_planned_stop_marker_for_self() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def planned_stop_marker_targets_self() -> bool:
|
||||
"""Return True only when a live planned-stop marker names the current process.
|
||||
|
||||
This is a **non-destructive** probe used by the watcher thread
|
||||
(``gateway/run.py:_run_planned_stop_watcher``) to decide whether to
|
||||
trigger shutdown. Unlike :func:`consume_planned_stop_marker_for_self`,
|
||||
it never unlinks a marker that matches us — the shutdown handler does
|
||||
the authoritative consume on its own thread.
|
||||
|
||||
It *does* clean up markers that can never apply to this process:
|
||||
malformed markers and markers older than the TTL are unlinked so a
|
||||
stale file left behind by a previous gateway instance cannot wedge
|
||||
the new one. Markers naming a different PID/start_time are left in
|
||||
place (they may still be consumed legitimately by the process they
|
||||
name) but report False here.
|
||||
|
||||
Returns False (without raising) on any read/parse error.
|
||||
"""
|
||||
path = _get_planned_stop_marker_path()
|
||||
record = _read_json_file(path)
|
||||
if not record:
|
||||
return False
|
||||
|
||||
try:
|
||||
target_pid = int(record["target_pid"])
|
||||
target_start_time = record.get("target_start_time")
|
||||
written_at = record.get("written_at") or ""
|
||||
except (KeyError, TypeError, ValueError):
|
||||
# Malformed marker can never match anyone — drop it.
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
if _marker_is_stale(written_at, _PLANNED_STOP_MARKER_TTL_S):
|
||||
# A marker this old is past its useful life regardless of target —
|
||||
# clean it up so it cannot crash-loop a freshly booted gateway.
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
our_pid = os.getpid()
|
||||
if target_pid != our_pid:
|
||||
return False
|
||||
|
||||
# Start-time is a PID-reuse guard. It is only meaningful when both
|
||||
# sides actually have it: ``_get_process_start_time`` returns None on
|
||||
# platforms without ``/proc`` (macOS, native Windows — the very
|
||||
# platform this watcher exists for). Requiring a non-None match there
|
||||
# would make the watcher never fire and re-break the #33778 Windows
|
||||
# session-resume path. So: when both start_times are known they must
|
||||
# match; when either is unknown, fall back to PID equality alone
|
||||
# (the marker is short-lived under a 60s TTL, bounding reuse risk).
|
||||
our_start_time = _get_process_start_time(our_pid)
|
||||
if target_start_time is not None and our_start_time is not None:
|
||||
return target_start_time == our_start_time
|
||||
return True
|
||||
|
||||
|
||||
def clear_planned_stop_marker() -> None:
|
||||
"""Remove the planned-stop marker unconditionally."""
|
||||
try:
|
||||
|
||||
@@ -26,7 +26,6 @@ from typing import Any, Callable, Optional
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter
|
||||
from gateway.platforms.base import _custom_unit_to_cp
|
||||
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
|
||||
from gateway.config import (
|
||||
DEFAULT_STREAMING_EDIT_INTERVAL as _DEFAULT_STREAMING_EDIT_INTERVAL,
|
||||
DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD,
|
||||
@@ -646,13 +645,10 @@ class GatewayStreamConsumer:
|
||||
except Exception as e:
|
||||
logger.error("Stream consumer error: %s", e)
|
||||
|
||||
# Strip MEDIA:<path> tags before display. Uses the shared anchored
|
||||
# MEDIA_TAG_CLEANUP_RE from gateway/platforms/base.py — only tags whose
|
||||
# path ends in a deliverable extension are removed, so an unknown-extension
|
||||
# path stays visible instead of being silently dropped (issue #34517).
|
||||
# Streaming and non-streaming paths share the same regex, so a tag is
|
||||
# treated identically whichever path delivered the text.
|
||||
_MEDIA_RE = MEDIA_TAG_CLEANUP_RE
|
||||
# Pattern to strip MEDIA:<path> tags (including optional surrounding quotes).
|
||||
# Matches the simple cleanup regex used by the non-streaming path in
|
||||
# gateway/platforms/base.py for post-processing.
|
||||
_MEDIA_RE = re.compile(r'''[`"']?MEDIA:\s*\S+[`"']?''')
|
||||
|
||||
@staticmethod
|
||||
def _clean_for_display(text: str) -> str:
|
||||
|
||||
@@ -670,105 +670,6 @@ def restore_quick_snapshot(
|
||||
return restored > 0
|
||||
|
||||
|
||||
# Relative path of the cron job database inside HERMES_HOME. Kept in sync with
|
||||
# the entry in ``_QUICK_STATE_FILES`` and with ``cron/jobs.py``'s ``JOBS_FILE``.
|
||||
_CRON_JOBS_REL = "cron/jobs.json"
|
||||
|
||||
|
||||
def _count_cron_jobs(path: Path) -> Optional[int]:
|
||||
"""Return the number of cron jobs stored in ``path``.
|
||||
|
||||
The canonical on-disk shape is ``{"jobs": [...]}`` (see ``cron/jobs.py``).
|
||||
A legacy bare-list shape (``[...]``) is also honoured.
|
||||
|
||||
Returns:
|
||||
The job count for any *valid, readable* JSON document, or ``None`` if
|
||||
the file is missing or cannot be parsed. ``None`` means "unknown" —
|
||||
callers must not treat it as "zero jobs", because acting on an
|
||||
unreadable file could mask a real corruption the user needs to see.
|
||||
"""
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if isinstance(data, dict):
|
||||
jobs = data.get("jobs", [])
|
||||
return len(jobs) if isinstance(jobs, list) else None
|
||||
if isinstance(data, list):
|
||||
return len(data)
|
||||
return None
|
||||
|
||||
|
||||
def restore_cron_jobs_if_emptied(
|
||||
snapshot_id: str,
|
||||
hermes_home: Optional[Path] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Safety net for silent cron-job loss across ``hermes update``.
|
||||
|
||||
Config-version migrations have been observed to leave ``cron/jobs.json``
|
||||
valid-but-empty after an update, silently dropping every scheduled job
|
||||
(issue #34600). The existing malformed-shape guards in ``cron/jobs.py``
|
||||
don't catch this case because ``{"jobs": []}`` is perfectly valid JSON.
|
||||
|
||||
This compares the *current* job count against the pre-update snapshot. If
|
||||
the live file now has **zero** jobs while the snapshot captured **one or
|
||||
more**, the snapshot copy of ``cron/jobs.json`` is restored in place.
|
||||
|
||||
The check is deliberately conservative — it only ever restores when there
|
||||
is unambiguous evidence of loss (snapshot had jobs, live file has none),
|
||||
so a user who genuinely deleted all their jobs during/after the update is
|
||||
never second-guessed, and an unreadable live file (count ``None``) is left
|
||||
untouched so real corruption still surfaces.
|
||||
|
||||
Args:
|
||||
snapshot_id: The pre-update quick-snapshot id (from
|
||||
:func:`create_quick_snapshot`).
|
||||
hermes_home: Override for the Hermes home directory (tests).
|
||||
|
||||
Returns:
|
||||
``None`` when no action was taken (the common, healthy path). On a
|
||||
successful restore, a dict ``{"restored": True, "job_count": N,
|
||||
"snapshot_id": ...}`` so the caller can warn the user.
|
||||
"""
|
||||
if not snapshot_id:
|
||||
return None
|
||||
|
||||
home = hermes_home or get_hermes_home()
|
||||
live_path = home / _CRON_JOBS_REL
|
||||
|
||||
live_count = _count_cron_jobs(live_path)
|
||||
# Only act when the live file is readable AND empty. ``None`` (missing or
|
||||
# unparseable) is intentionally left alone — that's a different failure
|
||||
# mode the user should see rather than have papered over.
|
||||
if live_count is None or live_count > 0:
|
||||
return None
|
||||
|
||||
snap_path = _quick_snapshot_root(home) / snapshot_id / _CRON_JOBS_REL
|
||||
snap_count = _count_cron_jobs(snap_path)
|
||||
if not snap_count: # None or 0 — nothing worth restoring
|
||||
return None
|
||||
|
||||
try:
|
||||
live_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(snap_path, live_path)
|
||||
except (OSError, PermissionError) as exc:
|
||||
logger.error(
|
||||
"Cron jobs were emptied during update but auto-restore failed: %s", exc
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Restored %d cron job(s) from pre-update snapshot %s "
|
||||
"(cron/jobs.json was emptied during migration)",
|
||||
snap_count,
|
||||
snapshot_id,
|
||||
)
|
||||
return {"restored": True, "job_count": snap_count, "snapshot_id": snapshot_id}
|
||||
|
||||
|
||||
def _prune_quick_snapshots(root: Path, keep: int = _QUICK_DEFAULT_KEEP) -> int:
|
||||
"""Remove oldest quick snapshots beyond the keep limit. Returns count deleted."""
|
||||
if not root.exists():
|
||||
|
||||
+2
-24
@@ -221,11 +221,7 @@ 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 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.
|
||||
# Read cache — invalidate if the embedded rev has changed since last check
|
||||
now = time.time()
|
||||
try:
|
||||
if cache_file.exists():
|
||||
@@ -233,7 +229,6 @@ 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:
|
||||
@@ -254,9 +249,7 @@ 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, "ver": VERSION})
|
||||
)
|
||||
cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -698,21 +691,6 @@ 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", "Compress conversation context (add 'here [N]' to keep recent N turns)", "Session",
|
||||
args_hint="[here [N] | focus topic]"),
|
||||
CommandDef("compress", "Manually compress conversation context", "Session",
|
||||
args_hint="[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,13 +1202,6 @@ 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
|
||||
|
||||
+13
-48
@@ -207,11 +207,9 @@ 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. 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.
|
||||
seconds), then exits with code 75. Both systemd (``Restart=always``
|
||||
+ ``RestartForceExitStatus=75``) and launchd (``KeepAlive.SuccessfulExit
|
||||
= false``) relaunch the process after the graceful exit.
|
||||
|
||||
This is the drain-aware alternative to ``systemctl restart`` / ``SIGTERM``,
|
||||
which SIGKILL in-flight agents after a short timeout.
|
||||
@@ -567,7 +565,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"])
|
||||
args.extend(["gateway", "run", "--replace"])
|
||||
return args
|
||||
|
||||
|
||||
@@ -2163,37 +2161,9 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]:
|
||||
return candidates
|
||||
|
||||
|
||||
def _stable_service_working_dir() -> str:
|
||||
"""Return a WorkingDirectory that will not disappear out from under systemd.
|
||||
|
||||
The gateway does NOT need its cwd to be the source checkout — ``ExecStart``
|
||||
uses an absolute python interpreter and ``-m hermes_cli.main``, so module
|
||||
resolution does not depend on cwd. Pinning ``WorkingDirectory`` to
|
||||
``PROJECT_ROOT`` (``Path(__file__).parent.parent``) is actively harmful:
|
||||
when the unit is generated from a transient checkout — a ``.worktrees/``
|
||||
dir, or a clone that ``hermes update`` later relocates/removes — the path
|
||||
rots. systemd then fails the start at the CHDIR step (``status=200/CHDIR``,
|
||||
"Changing to the requested working directory failed") *before* Python
|
||||
loads, so the on-boot ``refresh_systemd_unit_if_needed()`` self-heal never
|
||||
runs and ``Restart=always`` crash-loops forever on a dead directory.
|
||||
|
||||
``HERMES_HOME`` is the stable anchor: it is where config/state/logs live,
|
||||
it never moves, and it is guaranteed to exist whenever the gateway is
|
||||
meaningfully installed. Fall back to ``PROJECT_ROOT`` only if HERMES_HOME
|
||||
cannot be resolved (it always can in practice).
|
||||
"""
|
||||
try:
|
||||
home = get_hermes_home()
|
||||
if home and Path(home).is_dir():
|
||||
return str(Path(home).resolve())
|
||||
except Exception:
|
||||
pass
|
||||
return str(PROJECT_ROOT)
|
||||
|
||||
|
||||
def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = _stable_service_working_dir()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
detected_venv = _detect_venv_dir()
|
||||
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
|
||||
|
||||
@@ -2222,10 +2192,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
|
||||
# (e.g. /root/) to the target user's home so the service can
|
||||
# actually access them.
|
||||
python_path = _remap_path_for_user(python_path, home_dir)
|
||||
# Anchor cwd to the target user's HERMES_HOME (stable, always exists)
|
||||
# rather than a remapped source-checkout path that can rot. See
|
||||
# _stable_service_working_dir() for the full rationale.
|
||||
working_dir = str(hermes_home) if hermes_home else _remap_path_for_user(working_dir, home_dir)
|
||||
working_dir = _remap_path_for_user(working_dir, home_dir)
|
||||
venv_dir = _remap_path_for_user(venv_dir, home_dir)
|
||||
path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries]
|
||||
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
|
||||
@@ -2242,7 +2209,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
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
|
||||
WorkingDirectory={working_dir}
|
||||
Environment="HOME={home_dir}"
|
||||
Environment="USER={username}"
|
||||
@@ -2250,7 +2217,7 @@ Environment="LOGNAME={username}"
|
||||
Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
Restart=on-failure
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
RestartMaxDelaySec=300
|
||||
RestartSteps=5
|
||||
@@ -2280,12 +2247,12 @@ StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
|
||||
WorkingDirectory={working_dir}
|
||||
Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
Restart=on-failure
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
RestartMaxDelaySec=300
|
||||
RestartSteps=5
|
||||
@@ -2837,10 +2804,7 @@ def _launchd_domain() -> str:
|
||||
|
||||
def generate_launchd_plist() -> str:
|
||||
python_path = get_python_path()
|
||||
# Stable cwd anchor — never the volatile source checkout. See
|
||||
# _stable_service_working_dir() for the rationale (same rot risk applies
|
||||
# to launchd's WorkingDirectory as to systemd's).
|
||||
working_dir = _stable_service_working_dir()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
hermes_home = str(get_hermes_home().resolve())
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2877,6 +2841,7 @@ def generate_launchd_plist() -> str:
|
||||
prog_args.extend([
|
||||
"<string>gateway</string>",
|
||||
"<string>run</string>",
|
||||
"<string>--replace</string>",
|
||||
])
|
||||
prog_args_xml = "\n ".join(prog_args)
|
||||
|
||||
@@ -3271,7 +3236,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=on-failure will retry on transient errors
|
||||
# so systemd Restart=always will retry on transient errors
|
||||
verbosity = None if quiet else verbose
|
||||
|
||||
# ── Exit-path diagnostics ────────────────────────────────────────────
|
||||
|
||||
+7
-59
@@ -9125,13 +9125,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# though `git pull` can't touch $HERMES_HOME, this is cheap
|
||||
# belt-and-suspenders insurance and gives the user something to
|
||||
# restore from via `/snapshot list` / `/snapshot restore <id>`.
|
||||
pre_update_snapshot_id = None
|
||||
try:
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
|
||||
pre_update_snapshot_id = create_quick_snapshot(label="pre-update", keep=1)
|
||||
if pre_update_snapshot_id:
|
||||
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
|
||||
snap_id = create_quick_snapshot(label="pre-update", keep=1)
|
||||
if snap_id:
|
||||
print(f" ✓ Pre-update snapshot: {snap_id}")
|
||||
except Exception as exc:
|
||||
# Never let a snapshot failure block an update.
|
||||
logger.debug("Pre-update snapshot failed: %s", exc)
|
||||
@@ -9468,25 +9467,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
else:
|
||||
print(" ✓ Configuration is up to date")
|
||||
|
||||
# Safety net: config-version migrations have been observed to leave
|
||||
# cron/jobs.json valid-but-empty, silently dropping every scheduled
|
||||
# job (issue #34600). If the live file is now empty while the
|
||||
# pre-update snapshot held jobs, restore it and warn loudly.
|
||||
try:
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
|
||||
cron_restore = restore_cron_jobs_if_emptied(pre_update_snapshot_id)
|
||||
if cron_restore:
|
||||
print()
|
||||
print(
|
||||
" ⚠️ cron/jobs.json was emptied during this update — "
|
||||
f"restored {cron_restore['job_count']} job(s) from "
|
||||
f"pre-update snapshot {cron_restore['snapshot_id']}."
|
||||
)
|
||||
except Exception as exc:
|
||||
# Never let the cron safety net break an otherwise-good update.
|
||||
logger.debug("Cron jobs auto-restore check failed: %s", exc)
|
||||
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
|
||||
@@ -10582,10 +10562,11 @@ def cmd_profile(args):
|
||||
if collision:
|
||||
print(f"Error: {collision}")
|
||||
sys.exit(1)
|
||||
wrapper_path = create_wrapper_script(
|
||||
alias_name, target=name if custom_name else None
|
||||
)
|
||||
wrapper_path = create_wrapper_script(alias_name)
|
||||
if wrapper_path:
|
||||
# If custom name, write the profile name into the wrapper
|
||||
if custom_name:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {name} "$@"\n')
|
||||
print(f"✓ Alias created: {wrapper_path}")
|
||||
if not _is_wrapper_dir_in_path():
|
||||
print(f"⚠ {_get_wrapper_dir()} is not in your PATH.")
|
||||
@@ -13409,11 +13390,6 @@ Examples:
|
||||
"--yes", "-y", action="store_true", help="Skip confirmation"
|
||||
)
|
||||
|
||||
sessions_subparsers.add_parser(
|
||||
"optimize",
|
||||
help="Reclaim disk space: merge FTS5 segments + VACUUM (no data change)",
|
||||
)
|
||||
|
||||
sessions_subparsers.add_parser("stats", help="Show session store statistics")
|
||||
|
||||
sessions_rename = sessions_subparsers.add_parser(
|
||||
@@ -13586,34 +13562,6 @@ Examples:
|
||||
relaunch(["--resume", selected_id])
|
||||
return # won't reach here after execvp
|
||||
|
||||
elif action == "optimize":
|
||||
db_path = db.db_path
|
||||
before_mb = (
|
||||
os.path.getsize(db_path) / (1024 * 1024)
|
||||
if db_path.exists()
|
||||
else 0.0
|
||||
)
|
||||
print("Optimizing session store (FTS merge + VACUUM)…")
|
||||
try:
|
||||
# vacuum() merges FTS5 segments (optimize_fts) then VACUUMs,
|
||||
# and returns the number of indexes it merged.
|
||||
n = db.vacuum()
|
||||
except Exception as e:
|
||||
print(f"Error: optimization failed: {e}")
|
||||
db.close()
|
||||
return
|
||||
after_mb = (
|
||||
os.path.getsize(db_path) / (1024 * 1024)
|
||||
if db_path.exists()
|
||||
else 0.0
|
||||
)
|
||||
saved = before_mb - after_mb
|
||||
print(f"Optimized {n} FTS index(es).")
|
||||
print(
|
||||
f"Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB "
|
||||
f"(reclaimed {saved:.1f} MB)"
|
||||
)
|
||||
|
||||
elif action == "stats":
|
||||
total = db.session_count()
|
||||
msgs = db.message_count()
|
||||
|
||||
@@ -205,22 +205,6 @@ def _probe_single_server(
|
||||
return tools_found
|
||||
|
||||
|
||||
def _oauth_tokens_present(name: str) -> bool:
|
||||
"""Return True if an OAuth token file exists on disk for ``name``.
|
||||
|
||||
Used after ``hermes mcp login`` to distinguish a genuine authentication
|
||||
from a probe that succeeded only because the server allowed
|
||||
initialize/tools-list without auth (so no token was ever acquired).
|
||||
"""
|
||||
try:
|
||||
from tools.mcp_oauth import HermesTokenStorage
|
||||
return HermesTokenStorage(name).has_cached_tokens()
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.debug("Could not check OAuth tokens for '%s': %s", name, exc)
|
||||
# Be permissive on unexpected errors: don't block a real success.
|
||||
return True
|
||||
|
||||
|
||||
def _unwrap_exception_group(exc: BaseException) -> Exception:
|
||||
"""Extract the root-cause exception from anyio TaskGroup wrappers.
|
||||
|
||||
@@ -647,36 +631,6 @@ def cmd_mcp_login(args):
|
||||
# Probe triggers the OAuth flow (browser redirect + callback capture).
|
||||
try:
|
||||
tools = _probe_single_server(name, server_config)
|
||||
# A clean probe is NOT proof of authentication. Some MCP servers
|
||||
# (notably Google's official Drive server) serve initialize +
|
||||
# tools/list WITHOUT auth, so the probe lists tools even when the
|
||||
# OAuth flow never completed — e.g. dynamic client registration
|
||||
# 400'd because the provider doesn't support RFC 7591. Reporting
|
||||
# "Authenticated — N tools" in that case is a false success: every
|
||||
# real tool call later hangs until timeout because there's no token.
|
||||
# Verify a token actually landed on disk before claiming success.
|
||||
if not _oauth_tokens_present(name):
|
||||
_warning(
|
||||
"Server responded, but no OAuth token was obtained — "
|
||||
"authentication did not complete."
|
||||
)
|
||||
print()
|
||||
_info(
|
||||
"Some providers (e.g. Google Drive, Atlassian) do not support "
|
||||
"automatic client registration. For those you must create an "
|
||||
"OAuth client yourself and add its credentials to config.yaml:"
|
||||
)
|
||||
print()
|
||||
print(color(f" mcp_servers:", Colors.DIM))
|
||||
print(color(f" {name}:", Colors.DIM))
|
||||
print(color(f" url: {url}", Colors.DIM))
|
||||
print(color(f" auth: oauth", Colors.DIM))
|
||||
print(color(f" oauth:", Colors.DIM))
|
||||
print(color(f" client_id: \"<your-oauth-client-id>\"", Colors.DIM))
|
||||
print(color(f" client_secret: \"<your-oauth-client-secret>\"", Colors.DIM))
|
||||
print()
|
||||
_info("Then re-run `hermes mcp login " + name + "`.")
|
||||
return
|
||||
if tools:
|
||||
_success(f"Authenticated — {len(tools)} tool(s) available")
|
||||
else:
|
||||
|
||||
+37
-45
@@ -1556,21 +1556,24 @@ def list_authenticated_providers(
|
||||
|
||||
# --- 4. Saved custom providers from config ---
|
||||
# Each ``custom_providers`` entry represents one model under a named
|
||||
# provider. Entries sharing the same endpoint, credential identity, and
|
||||
# wire protocol are grouped into a single picker row, so e.g. four Ollama
|
||||
# entries pointing at ``http://localhost:11434/v1`` with per-model display
|
||||
# names ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
|
||||
# provider. Entries sharing the same endpoint (``base_url`` + ``api_key``)
|
||||
# are grouped into a single picker row, so e.g. four Ollama entries
|
||||
# pointing at ``http://localhost:11434/v1`` with per-model display names
|
||||
# ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
|
||||
# "Ollama" row with four models inside instead of four near-duplicates
|
||||
# that differ only by suffix. Same-host entries with different ``key_env``
|
||||
# or ``api_mode`` remain distinct providers.
|
||||
# that differ only by suffix. Entries with distinct endpoints still
|
||||
# produce separate rows.
|
||||
#
|
||||
# When the grouped endpoint matches ``current_base_url`` the group's
|
||||
# slug becomes ``current_provider`` so that selecting a model from the
|
||||
# picker flows back through the runtime provider that already holds
|
||||
# valid credentials — no re-resolution needed.
|
||||
if custom_providers and isinstance(custom_providers, list):
|
||||
from collections import OrderedDict
|
||||
|
||||
# Key by endpoint + credential identity + wire protocol instead of
|
||||
# slug: names frequently differ per model ("Ollama — X") while the
|
||||
# endpoint stays the same. Keep same-host providers with distinct
|
||||
# env-backed credentials or API protocols separate so picker selection
|
||||
# cannot route through the wrong credential/mode pair.
|
||||
# Key by (base_url, api_key) instead of slug: names frequently
|
||||
# differ per model ("Ollama — X") while the endpoint stays the
|
||||
# same. Slug-based grouping left them as separate rows.
|
||||
groups: "OrderedDict[tuple, dict]" = OrderedDict()
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
@@ -1585,23 +1588,9 @@ def list_authenticated_providers(
|
||||
).strip().rstrip("/")
|
||||
if not raw_name or not api_url:
|
||||
continue
|
||||
inline_api_key = (entry.get("api_key") or "").strip()
|
||||
key_env = (entry.get("key_env") or "").strip()
|
||||
api_key = inline_api_key or (
|
||||
os.environ.get(key_env, "").strip() if key_env else ""
|
||||
)
|
||||
api_mode = str(
|
||||
entry.get("api_mode")
|
||||
or entry.get("transport")
|
||||
or ""
|
||||
).strip().lower()
|
||||
credential_identity = (
|
||||
inline_api_key
|
||||
if inline_api_key
|
||||
else (f"env:{key_env}" if key_env else "")
|
||||
)
|
||||
api_key = (entry.get("api_key") or "").strip()
|
||||
|
||||
group_key = (api_url, credential_identity, api_mode)
|
||||
group_key = (api_url, api_key)
|
||||
if group_key not in groups:
|
||||
# Strip per-model suffix so "Ollama — GLM 5.1" becomes
|
||||
# "Ollama" for the grouped row. Em dash is the convention
|
||||
@@ -1614,16 +1603,29 @@ def list_authenticated_providers(
|
||||
break
|
||||
if not display_name:
|
||||
display_name = raw_name
|
||||
slug = custom_provider_slug(display_name)
|
||||
# If this endpoint matches the currently active one, use
|
||||
# ``current_provider`` as the slug so picker-driven switches
|
||||
# route through the live credential pipeline.
|
||||
if (
|
||||
current_base_url
|
||||
and api_url == current_base_url.strip().rstrip("/")
|
||||
):
|
||||
# Guard against bare "custom" slug left by a prior
|
||||
# failed switch — always resolve to the canonical
|
||||
# custom:<name> form. (GH #17478)
|
||||
slug = (
|
||||
current_provider
|
||||
if current_provider and current_provider != "custom"
|
||||
else custom_provider_slug(display_name)
|
||||
)
|
||||
else:
|
||||
slug = custom_provider_slug(display_name)
|
||||
groups[group_key] = {
|
||||
"slug": slug,
|
||||
"name": display_name,
|
||||
"api_url": api_url,
|
||||
"api_key": api_key,
|
||||
"models": [],
|
||||
}
|
||||
elif api_key and not groups[group_key].get("api_key"):
|
||||
groups[group_key]["api_key"] = api_key
|
||||
|
||||
# The singular ``model:`` field only holds the currently
|
||||
# active model. Hermes's own writer (main.py::_save_custom_provider)
|
||||
@@ -1645,16 +1647,8 @@ def list_authenticated_providers(
|
||||
groups[group_key]["models"].append(m)
|
||||
|
||||
_section4_emitted_slugs: set = set()
|
||||
_current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower()
|
||||
_current_base_url_group_count = sum(
|
||||
1
|
||||
for _grp in groups.values()
|
||||
if _current_base_url_norm
|
||||
and str(_grp["api_url"]).strip().rstrip("/").lower() == _current_base_url_norm
|
||||
)
|
||||
for grp in groups.values():
|
||||
api_url = grp["api_url"]
|
||||
api_key = grp.get("api_key", "")
|
||||
for grp_key, grp in groups.items():
|
||||
api_url, api_key = grp_key
|
||||
slug = grp["slug"]
|
||||
# If the slug is already claimed by a built-in / overlay /
|
||||
# user-provider row (sections 1-3), skip this custom group
|
||||
@@ -1727,10 +1721,8 @@ def list_authenticated_providers(
|
||||
"slug": slug,
|
||||
"name": grp["name"],
|
||||
"is_current": slug == current_provider or (
|
||||
current_provider == "custom"
|
||||
and bool(_current_base_url_norm)
|
||||
and _grp_url_norm == _current_base_url_norm
|
||||
and _current_base_url_group_count == 1
|
||||
bool(current_base_url)
|
||||
and _grp_url_norm == current_base_url.strip().rstrip("/").lower()
|
||||
),
|
||||
"is_user_defined": True,
|
||||
"models": grp["models"],
|
||||
|
||||
@@ -49,7 +49,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("xiaomi/mimo-v2.5-pro", ""),
|
||||
("tencent/hy3-preview", ""),
|
||||
("google/gemini-3-pro-image-preview", ""),
|
||||
("google/gemini-3.5-flash", ""),
|
||||
("google/gemini-3-flash-preview", ""),
|
||||
("google/gemini-3.1-pro-preview", ""),
|
||||
("google/gemini-3.1-flash-lite-preview", ""),
|
||||
("qwen/qwen3.6-35b-a3b", ""),
|
||||
@@ -156,7 +156,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
"tencent/hy3-preview",
|
||||
"google/gemini-3-pro-preview",
|
||||
"google/gemini-3.5-flash",
|
||||
"google/gemini-3-flash-preview",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"google/gemini-3.1-flash-lite-preview",
|
||||
"qwen/qwen3.6-35b-a3b",
|
||||
|
||||
@@ -71,16 +71,12 @@ class NousSubscriptionFeatures:
|
||||
def browser(self) -> NousFeatureState:
|
||||
return self.features["browser"]
|
||||
|
||||
@property
|
||||
def video_gen(self) -> NousFeatureState:
|
||||
return self.features["video_gen"]
|
||||
|
||||
@property
|
||||
def modal(self) -> NousFeatureState:
|
||||
return self.features["modal"]
|
||||
|
||||
def items(self) -> Iterable[NousFeatureState]:
|
||||
ordered = ("web", "image_gen", "video_gen", "tts", "browser", "modal")
|
||||
ordered = ("web", "image_gen", "tts", "browser", "modal")
|
||||
for key in ordered:
|
||||
yield self.features[key]
|
||||
|
||||
@@ -259,7 +255,6 @@ def get_nous_subscription_features(
|
||||
|
||||
web_tool_enabled = _toolset_enabled(config, "web")
|
||||
image_tool_enabled = _toolset_enabled(config, "image_gen")
|
||||
video_tool_enabled = _toolset_enabled(config, "video_gen")
|
||||
tts_tool_enabled = _toolset_enabled(config, "tts")
|
||||
browser_tool_enabled = _toolset_enabled(config, "browser")
|
||||
modal_tool_enabled = _toolset_enabled(config, "terminal")
|
||||
@@ -294,8 +289,6 @@ def get_nous_subscription_features(
|
||||
browser_use_gateway = _uses_gateway(browser_cfg)
|
||||
image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}
|
||||
image_use_gateway = _uses_gateway(image_gen_cfg)
|
||||
video_gen_cfg = config.get("video_gen") if isinstance(config.get("video_gen"), dict) else {}
|
||||
video_use_gateway = _uses_gateway(video_gen_cfg)
|
||||
|
||||
direct_exa = bool(get_env_value("EXA_API_KEY"))
|
||||
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
|
||||
@@ -303,7 +296,6 @@ def get_nous_subscription_features(
|
||||
direct_tavily = bool(get_env_value("TAVILY_API_KEY"))
|
||||
direct_searxng = bool(get_env_value("SEARXNG_URL"))
|
||||
direct_fal = fal_key_is_configured()
|
||||
direct_fal_video = direct_fal # same FAL_KEY; separate var so use_gateway is independent
|
||||
direct_openai_tts = bool(resolve_openai_audio_api_key())
|
||||
direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY"))
|
||||
direct_camofox = bool(get_env_value("CAMOFOX_URL"))
|
||||
@@ -319,8 +311,6 @@ def get_nous_subscription_features(
|
||||
direct_tavily = False
|
||||
if image_use_gateway:
|
||||
direct_fal = False
|
||||
if video_use_gateway:
|
||||
direct_fal_video = False
|
||||
if tts_use_gateway:
|
||||
direct_openai_tts = False
|
||||
direct_elevenlabs = False
|
||||
@@ -330,8 +320,6 @@ def get_nous_subscription_features(
|
||||
|
||||
managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
|
||||
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
|
||||
# Video gen uses the same fal-queue gateway as image gen.
|
||||
managed_video_available = managed_image_available
|
||||
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
|
||||
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browser-use")
|
||||
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
|
||||
@@ -369,10 +357,6 @@ def get_nous_subscription_features(
|
||||
image_active = bool(image_tool_enabled and (image_managed or direct_fal))
|
||||
image_available = bool(managed_image_available or direct_fal)
|
||||
|
||||
video_managed = video_tool_enabled and managed_video_available and not direct_fal_video
|
||||
video_active = bool(video_tool_enabled and (video_managed or direct_fal_video))
|
||||
video_available = bool(managed_video_available or direct_fal_video)
|
||||
|
||||
tts_current_provider = tts_provider or "edge"
|
||||
tts_managed = (
|
||||
tts_tool_enabled
|
||||
@@ -467,18 +451,6 @@ def get_nous_subscription_features(
|
||||
current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""),
|
||||
explicit_configured=direct_fal,
|
||||
),
|
||||
"video_gen": NousFeatureState(
|
||||
key="video_gen",
|
||||
label="Video generation",
|
||||
included_by_default=False,
|
||||
available=video_available,
|
||||
active=video_active,
|
||||
managed_by_nous=video_managed,
|
||||
direct_override=video_active and not video_managed,
|
||||
toolset_enabled=video_tool_enabled,
|
||||
current_provider="FAL" if direct_fal_video else ("Nous Subscription" if video_managed else ""),
|
||||
explicit_configured=direct_fal_video,
|
||||
),
|
||||
"tts": NousFeatureState(
|
||||
key="tts",
|
||||
label="OpenAI TTS",
|
||||
@@ -589,9 +561,6 @@ def apply_nous_managed_defaults(
|
||||
if "image_gen" in selected_toolsets and not fal_key_is_configured():
|
||||
changed.add("image_gen")
|
||||
|
||||
if "video_gen" in selected_toolsets and not fal_key_is_configured():
|
||||
changed.add("video_gen")
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
@@ -602,7 +571,6 @@ def apply_nous_managed_defaults(
|
||||
_GATEWAY_TOOL_LABELS = {
|
||||
"web": "Web search & extract (Firecrawl)",
|
||||
"image_gen": "Image generation (FAL)",
|
||||
"video_gen": "Video generation (FAL)",
|
||||
"tts": "Text-to-speech (OpenAI TTS)",
|
||||
"browser": "Browser automation (Browser Use)",
|
||||
}
|
||||
@@ -610,7 +578,6 @@ _GATEWAY_TOOL_LABELS = {
|
||||
|
||||
def _get_gateway_direct_credentials() -> Dict[str, bool]:
|
||||
"""Return a dict of tool_key -> has_direct_credentials."""
|
||||
fal_direct = fal_key_is_configured()
|
||||
return {
|
||||
"web": bool(
|
||||
get_env_value("FIRECRAWL_API_KEY")
|
||||
@@ -619,8 +586,7 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
|
||||
or get_env_value("TAVILY_API_KEY")
|
||||
or get_env_value("EXA_API_KEY")
|
||||
),
|
||||
"image_gen": fal_direct,
|
||||
"video_gen": fal_direct,
|
||||
"image_gen": fal_key_is_configured(),
|
||||
"tts": bool(
|
||||
resolve_openai_audio_api_key()
|
||||
or get_env_value("ELEVENLABS_API_KEY")
|
||||
@@ -635,12 +601,11 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
|
||||
_GATEWAY_DIRECT_LABELS = {
|
||||
"web": "Firecrawl/Exa/Parallel/Tavily key",
|
||||
"image_gen": "FAL key",
|
||||
"video_gen": "FAL key",
|
||||
"tts": "OpenAI/ElevenLabs key",
|
||||
"browser": "Browser Use/Browserbase key",
|
||||
}
|
||||
|
||||
_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "browser")
|
||||
_ALL_GATEWAY_KEYS = ("web", "image_gen", "tts", "browser")
|
||||
|
||||
|
||||
def get_gateway_eligible_tools(
|
||||
@@ -681,7 +646,6 @@ def get_gateway_eligible_tools(
|
||||
opted_in = {
|
||||
"web": _uses_gateway(config.get("web")),
|
||||
"image_gen": _uses_gateway(config.get("image_gen")),
|
||||
"video_gen": _uses_gateway(config.get("video_gen")),
|
||||
"tts": _uses_gateway(config.get("tts")),
|
||||
"browser": _uses_gateway(config.get("browser")),
|
||||
}
|
||||
@@ -750,15 +714,6 @@ def apply_gateway_defaults(
|
||||
image_cfg["use_gateway"] = True
|
||||
changed.add("image_gen")
|
||||
|
||||
if "video_gen" in tool_keys:
|
||||
video_cfg = config.get("video_gen")
|
||||
if not isinstance(video_cfg, dict):
|
||||
video_cfg = {}
|
||||
config["video_gen"] = video_cfg
|
||||
video_cfg["provider"] = "fal"
|
||||
video_cfg["use_gateway"] = True
|
||||
changed.add("video_gen")
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
"""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
|
||||
+23
-51
@@ -329,19 +329,16 @@ def check_alias_collision(name: str) -> Optional[str]:
|
||||
|
||||
# Check existing commands in PATH
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
is_windows = sys.platform == "win32"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["where" if is_windows else "which", canon],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
["which", canon], capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
existing_path = result.stdout.strip().splitlines()[0]
|
||||
existing_path = result.stdout.strip()
|
||||
# Allow overwriting our own wrappers
|
||||
expected = wrapper_dir / (f"{canon}.bat" if is_windows else canon)
|
||||
if existing_path == str(expected):
|
||||
if existing_path == str(wrapper_dir / canon):
|
||||
try:
|
||||
content = expected.read_text()
|
||||
content = (wrapper_dir / canon).read_text()
|
||||
if "hermes -p" in content:
|
||||
return None # it's our wrapper, safe to overwrite
|
||||
except Exception:
|
||||
@@ -359,18 +356,12 @@ def _is_wrapper_dir_in_path() -> bool:
|
||||
return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
|
||||
|
||||
|
||||
def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
|
||||
def create_wrapper_script(name: str) -> Optional[Path]:
|
||||
"""Create a shell wrapper script at ~/.local/bin/<name>.
|
||||
|
||||
The wrapper file is named after ``name`` (the alias). The profile it
|
||||
activates is ``target`` if given, otherwise ``name`` — this lets a custom
|
||||
alias name point at a differently-named profile without a post-hoc rewrite.
|
||||
|
||||
On Windows, creates a ``.bat`` file instead of a POSIX shell script.
|
||||
Returns the path to the created wrapper, or None if creation failed.
|
||||
"""
|
||||
canon = normalize_profile_name(name)
|
||||
profile = normalize_profile_name(target) if target else canon
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
try:
|
||||
wrapper_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -378,47 +369,28 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
|
||||
print(f"⚠ Could not create {wrapper_dir}: {e}")
|
||||
return None
|
||||
|
||||
is_windows = sys.platform == "win32"
|
||||
if is_windows:
|
||||
wrapper_path = wrapper_dir / f"{canon}.bat"
|
||||
try:
|
||||
wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n")
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
else:
|
||||
wrapper_path = wrapper_dir / canon
|
||||
try:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {profile} "$@"\n')
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
wrapper_path = wrapper_dir / canon
|
||||
try:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {canon} "$@"\n')
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def remove_wrapper_script(name: str) -> bool:
|
||||
"""Remove the wrapper script for a profile. Returns True if removed."""
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
canon = normalize_profile_name(name)
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
# Check both the extensionless path (POSIX) and .bat (Windows)
|
||||
candidates = [wrapper_dir / canon]
|
||||
if is_windows:
|
||||
candidates.insert(0, wrapper_dir / f"{canon}.bat")
|
||||
|
||||
for wrapper_path in candidates:
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
wrapper_path = _get_wrapper_dir() / normalize_profile_name(name)
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -104,9 +104,7 @@ ADVISORIES: tuple[Advisory, ...] = (
|
||||
"them to a hardcoded webhook. If you ran any Python process that "
|
||||
"imported mistralai 2.4.6 — including hermes when configured "
|
||||
"with provider=mistral for TTS or STT — assume those credentials "
|
||||
"are exposed. PyPI has since removed 2.4.6 and the project ships "
|
||||
"clean releases again (2.4.7, 2.4.8); this advisory only fires if "
|
||||
"the compromised 2.4.6 is still installed."
|
||||
"are exposed."
|
||||
),
|
||||
url="https://socket.dev/blog/mini-shai-hulud-worm-pypi",
|
||||
compromised=(
|
||||
|
||||
+16
-19
@@ -454,25 +454,22 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
# Video generation — opt-in via `hermes tools` → Video Generation.
|
||||
# Only show the row when a plugin reports available so we don't badger
|
||||
# users who don't care about video gen with a "missing" status line.
|
||||
if subscription_features.video_gen.managed_by_nous:
|
||||
tool_status.append(("Video Generation (FAL via Nous subscription)", True, None))
|
||||
else:
|
||||
try:
|
||||
from agent.video_gen_registry import list_providers as _list_video_providers
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins
|
||||
_ensure_plugins()
|
||||
_video_backend = None
|
||||
for _vp in _list_video_providers():
|
||||
try:
|
||||
if _vp.is_available():
|
||||
_video_backend = _vp.display_name
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
_video_backend = None
|
||||
if _video_backend:
|
||||
tool_status.append((f"Video Generation ({_video_backend})", True, None))
|
||||
try:
|
||||
from agent.video_gen_registry import list_providers as _list_video_providers
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins
|
||||
_ensure_plugins()
|
||||
_video_backend = None
|
||||
for _vp in _list_video_providers():
|
||||
try:
|
||||
if _vp.is_available():
|
||||
_video_backend = _vp.display_name
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
_video_backend = None
|
||||
if _video_backend:
|
||||
tool_status.append((f"Video Generation ({_video_backend})", True, None))
|
||||
|
||||
# TTS — show configured provider
|
||||
tts_provider = cfg_get(config, "tts", "provider", default="edge")
|
||||
|
||||
+16
-47
@@ -244,16 +244,9 @@ TOOL_CATEGORIES = {
|
||||
],
|
||||
"tts_provider": "elevenlabs",
|
||||
},
|
||||
# Mistral Voxtral TTS — `mistralai` SDK lazy-installs on first use.
|
||||
{
|
||||
"name": "Mistral (Voxtral TTS)",
|
||||
"badge": "paid",
|
||||
"tag": "Multilingual, native Opus",
|
||||
"env_vars": [
|
||||
{"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"},
|
||||
],
|
||||
"tts_provider": "mistral",
|
||||
},
|
||||
# Mistral (Voxtral TTS) temporarily hidden — `mistralai` PyPI
|
||||
# package is currently quarantined (malicious 2.4.6 release on
|
||||
# 2026-05-12). Restore this entry once PyPI un-quarantines.
|
||||
{
|
||||
"name": "Google Gemini TTS",
|
||||
"badge": "preview",
|
||||
@@ -346,26 +339,11 @@ TOOL_CATEGORIES = {
|
||||
"video_gen": {
|
||||
"name": "Video Generation",
|
||||
"icon": "🎬",
|
||||
# "Nous Subscription" row mirrors the image_gen pattern — managed
|
||||
# FAL video generation billed via the Nous Portal. Plugin-backed
|
||||
# provider rows (FAL BYOK, xAI, …) are injected at runtime by
|
||||
# ``_plugin_video_gen_providers()`` in ``_visible_providers``.
|
||||
"providers": [
|
||||
{
|
||||
"name": "Nous Subscription",
|
||||
"badge": "subscription",
|
||||
"tag": "Managed FAL video generation billed to your subscription",
|
||||
"env_vars": [],
|
||||
"requires_nous_auth": True,
|
||||
"managed_nous_feature": "video_gen",
|
||||
"override_env_vars": ["FAL_KEY"],
|
||||
# The underlying plugin backend — when the user picks
|
||||
# "Nous Subscription" we set video_gen.provider = "fal"
|
||||
# and video_gen.use_gateway = True so the FAL plugin
|
||||
# routes through the managed queue gateway.
|
||||
"video_gen_plugin_name": "fal",
|
||||
},
|
||||
],
|
||||
# Providers list is intentionally empty — every video gen backend
|
||||
# is a plugin, surfaced by ``_plugin_video_gen_providers()`` and
|
||||
# injected by ``_visible_providers``. Mirrors the design we'll
|
||||
# converge image_gen toward.
|
||||
"providers": [],
|
||||
},
|
||||
"x_search": {
|
||||
"name": "X (Twitter) Search",
|
||||
@@ -1460,7 +1438,7 @@ def _toolset_has_keys(
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if ts_key in {"web", "image_gen", "video_gen", "tts", "browser"}:
|
||||
if ts_key in {"web", "image_gen", "tts", "browser"}:
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
feature = features.features.get(ts_key)
|
||||
if feature and (feature.available or feature.managed_by_nous):
|
||||
@@ -2175,7 +2153,7 @@ def _is_provider_active(
|
||||
return isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name
|
||||
|
||||
video_plugin_name = provider.get("video_gen_plugin_name")
|
||||
if video_plugin_name and not provider.get("managed_nous_feature"):
|
||||
if video_plugin_name:
|
||||
video_cfg = config.get("video_gen", {})
|
||||
return isinstance(video_cfg, dict) and video_cfg.get("provider") == video_plugin_name
|
||||
|
||||
@@ -2194,15 +2172,6 @@ def _is_provider_active(
|
||||
if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False):
|
||||
return False
|
||||
return feature.managed_by_nous
|
||||
if managed_feature == "video_gen":
|
||||
video_cfg = config.get("video_gen", {})
|
||||
if isinstance(video_cfg, dict):
|
||||
configured_provider = video_cfg.get("provider")
|
||||
if configured_provider not in {None, "", "fal"}:
|
||||
return False
|
||||
if video_cfg.get("use_gateway") is not None and not is_truthy_value(video_cfg.get("use_gateway"), default=False):
|
||||
return False
|
||||
return feature.managed_by_nous
|
||||
if provider.get("tts_provider"):
|
||||
return (
|
||||
feature.managed_by_nous
|
||||
@@ -2536,14 +2505,14 @@ def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None
|
||||
_print_success(f" Model set to: {chosen}")
|
||||
|
||||
|
||||
def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None:
|
||||
def _select_plugin_video_gen_provider(plugin_name: str, config: dict) -> None:
|
||||
"""Persist a plugin-backed video generation provider selection."""
|
||||
vid_cfg = config.setdefault("video_gen", {})
|
||||
if not isinstance(vid_cfg, dict):
|
||||
vid_cfg = {}
|
||||
config["video_gen"] = vid_cfg
|
||||
vid_cfg["provider"] = plugin_name
|
||||
vid_cfg["use_gateway"] = use_gateway
|
||||
vid_cfg["use_gateway"] = False
|
||||
_print_success(f" video_gen.provider set to: {plugin_name}")
|
||||
_configure_videogen_model_for_plugin(plugin_name, config)
|
||||
|
||||
@@ -2628,7 +2597,7 @@ def _configure_provider(
|
||||
# registry.
|
||||
video_plugin = provider.get("video_gen_plugin_name")
|
||||
if video_plugin:
|
||||
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
||||
_select_plugin_video_gen_provider(video_plugin, config)
|
||||
return
|
||||
# Imagegen backends prompt for model selection after backend pick.
|
||||
backend = provider.get("imagegen_backend")
|
||||
@@ -2707,7 +2676,7 @@ def _configure_provider(
|
||||
return
|
||||
video_plugin = provider.get("video_gen_plugin_name")
|
||||
if video_plugin:
|
||||
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
||||
_select_plugin_video_gen_provider(video_plugin, config)
|
||||
return
|
||||
# Imagegen backends prompt for model selection after env vars are in.
|
||||
backend = provider.get("imagegen_backend")
|
||||
@@ -2988,7 +2957,7 @@ def _reconfigure_provider(
|
||||
# Plugin-registered video_gen provider — same flow, different registry.
|
||||
video_plugin = provider.get("video_gen_plugin_name")
|
||||
if video_plugin:
|
||||
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
||||
_select_plugin_video_gen_provider(video_plugin, config)
|
||||
return
|
||||
# Imagegen backends prompt for model selection on reconfig too.
|
||||
backend = provider.get("imagegen_backend")
|
||||
@@ -3028,7 +2997,7 @@ def _reconfigure_provider(
|
||||
# Plugin-registered video_gen provider — same flow, different registry.
|
||||
video_plugin = provider.get("video_gen_plugin_name")
|
||||
if video_plugin:
|
||||
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
||||
_select_plugin_video_gen_provider(video_plugin, config)
|
||||
return
|
||||
|
||||
backend = provider.get("imagegen_backend")
|
||||
|
||||
@@ -117,49 +117,6 @@ 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.
|
||||
@@ -637,17 +594,6 @@ 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...")
|
||||
|
||||
@@ -320,7 +320,9 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"stt.provider": {
|
||||
"type": "select",
|
||||
"description": "Speech-to-text provider",
|
||||
"options": ["local", "openai", "mistral"],
|
||||
# "mistral" temporarily removed — mistralai PyPI package quarantined
|
||||
# (malicious 2.4.6 release on 2026-05-12). Restore once available.
|
||||
"options": ["local", "openai"],
|
||||
},
|
||||
"display.skin": {
|
||||
"type": "select",
|
||||
|
||||
+1
-68
@@ -3251,59 +3251,7 @@ class SessionDB:
|
||||
|
||||
# ── Space reclamation ──
|
||||
|
||||
# FTS5 virtual tables whose b-tree segments we merge on optimize. The
|
||||
# trigram table is created lazily / may be disabled, so we probe before
|
||||
# touching it (see optimize_fts).
|
||||
_FTS_TABLES = ("messages_fts", "messages_fts_trigram")
|
||||
|
||||
def _fts_table_exists(self, name: str) -> bool:
|
||||
"""True if an FTS5 virtual table is queryable in this DB."""
|
||||
try:
|
||||
self._conn.execute(f"SELECT 1 FROM {name} LIMIT 0")
|
||||
return True
|
||||
except sqlite3.OperationalError:
|
||||
return False
|
||||
|
||||
def optimize_fts(self) -> int:
|
||||
"""Merge fragmented FTS5 b-tree segments into one per index.
|
||||
|
||||
FTS5 indexes grow as a series of incremental segments — one per
|
||||
``INSERT`` batch driven by the message triggers. Over tens of
|
||||
thousands of messages these segments accumulate, which both bloats
|
||||
the ``*_data`` shadow tables and slows ``MATCH`` queries that must
|
||||
scan every segment. The special ``'optimize'`` command rewrites each
|
||||
index as a single merged segment.
|
||||
|
||||
This is purely a maintenance operation — it changes neither search
|
||||
results nor ``snippet()`` output, only on-disk layout and query
|
||||
speed. It is complementary to VACUUM: ``optimize`` compacts the FTS
|
||||
index internally, then VACUUM returns the freed pages to the OS.
|
||||
|
||||
Skips any FTS table that does not exist (e.g. the trigram index when
|
||||
disabled via ``HERMES_DISABLE_FTS_TRIGRAM`` or not yet created), so
|
||||
it is safe to call unconditionally.
|
||||
|
||||
Returns the number of FTS indexes that were optimized.
|
||||
"""
|
||||
optimized = 0
|
||||
with self._lock:
|
||||
for tbl in self._FTS_TABLES:
|
||||
if not self._fts_table_exists(tbl):
|
||||
continue
|
||||
try:
|
||||
# The column name in the INSERT must match the table name
|
||||
# for FTS5 special commands.
|
||||
self._conn.execute(
|
||||
f"INSERT INTO {tbl}({tbl}) VALUES('optimize')"
|
||||
)
|
||||
optimized += 1
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.warning(
|
||||
"FTS optimize failed for %s: %s", tbl, exc
|
||||
)
|
||||
return optimized
|
||||
|
||||
def vacuum(self) -> int:
|
||||
def vacuum(self) -> None:
|
||||
"""Run VACUUM to reclaim disk space after large deletes.
|
||||
|
||||
SQLite does not shrink the database file when rows are deleted —
|
||||
@@ -3316,21 +3264,7 @@ class SessionDB:
|
||||
exclusive lock, so callers must ensure no other writers are
|
||||
active. Safe to call at startup before the gateway/CLI starts
|
||||
serving traffic.
|
||||
|
||||
FTS5 segments are merged first via :meth:`optimize_fts` so the
|
||||
subsequent VACUUM reclaims the pages freed by the merge. This is a
|
||||
layout-only optimization — search results are unchanged.
|
||||
|
||||
Returns the number of FTS indexes that were optimized (0 if the
|
||||
merge step failed or no FTS tables exist).
|
||||
"""
|
||||
# Merge FTS5 segments before VACUUM so the freed pages are returned
|
||||
# to the OS in the same pass. optimize_fts() manages its own lock.
|
||||
optimized = 0
|
||||
try:
|
||||
optimized = self.optimize_fts()
|
||||
except Exception as exc:
|
||||
logger.warning("FTS optimize before VACUUM failed: %s", exc)
|
||||
# VACUUM cannot be executed inside a transaction.
|
||||
with self._lock:
|
||||
# Best-effort WAL checkpoint first, then VACUUM.
|
||||
@@ -3339,7 +3273,6 @@ class SessionDB:
|
||||
except Exception:
|
||||
pass
|
||||
self._conn.execute("VACUUM")
|
||||
return optimized
|
||||
|
||||
def maybe_auto_prune_and_vacuum(
|
||||
self,
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ gateway:
|
||||
title: "**Titel:** {title}"
|
||||
created: "**Geskep:** {timestamp}"
|
||||
last_activity: "**Laaste aktiwiteit:** {timestamp}"
|
||||
tokens: "**Kumulatiewe API-tokens (elke oproep weer gestuur):** {tokens}"
|
||||
tokens: "**Tokens:** {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: "**Kumulierte API-Tokens (bei jedem Aufruf erneut gesendet):** {tokens}"
|
||||
tokens: "**Tokens:** {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: "**Cumulative API tokens (re-sent each call):** {tokens}"
|
||||
tokens: "**Tokens:** {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 de API acumulados (reenviados en cada llamada):** {tokens}"
|
||||
tokens: "**Tokens:** {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 de API cumulativos (reenviados a cada chamada):** {tokens}"
|
||||
tokens: "**Tokens:** {tokens}"
|
||||
agent_running: "**Agente em execução:** {state}"
|
||||
state_yes: "Sim ⚡"
|
||||
state_no: "Não"
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
type = types.str;
|
||||
default = "${cfg.stateDir}/workspace";
|
||||
defaultText = literalExpression ''"''${cfg.stateDir}/workspace"'';
|
||||
description = "Working directory for the agent.";
|
||||
description = "Working directory for the agent (MESSAGING_CWD).";
|
||||
};
|
||||
|
||||
# ── Declarative config ───────────────────────────────────────────────
|
||||
@@ -535,7 +535,7 @@
|
||||
|
||||
restart = mkOption {
|
||||
type = types.str;
|
||||
default = "on-failure";
|
||||
default = "always";
|
||||
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 ${lib.concatStringsSep " " cfg.extraArgs}
|
||||
${containerDataDir}/current-package/bin/hermes gateway run --replace ${lib.concatStringsSep " " cfg.extraArgs}
|
||||
|
||||
echo "${containerIdentity}" > ${identityFile}
|
||||
fi
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"modal"
|
||||
"parallel-web"
|
||||
"tts-premium"
|
||||
"vercel"
|
||||
"voice"
|
||||
] ++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
|
||||
};
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
name: antigravity-cli
|
||||
description: "Operate the Antigravity CLI (agy): plugins, auth, sandbox."
|
||||
version: 0.1.0
|
||||
author: Tony Simons (asimons81), Hermes Agent
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Coding-Agent, Antigravity, CLI, Auth, Plugins, Sandbox]
|
||||
related_skills: [grok, codex, claude-code, hermes-agent]
|
||||
---
|
||||
|
||||
# Antigravity CLI (`agy`)
|
||||
|
||||
Operator guide for the Antigravity CLI, invoked as `agy`. Run all `agy`
|
||||
commands through the Hermes `terminal` tool; inspect its config and logs with
|
||||
`read_file`. This skill is reference + procedure — it does not wrap a network
|
||||
API, so there is nothing to authenticate from Hermes itself.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Installing, updating, or smoke-testing the `agy` binary
|
||||
- Driving non-interactive `agy --print` / `agy -p` one-shots
|
||||
- Debugging Antigravity auth, sandbox, permissions, or plugin state
|
||||
- Reading Antigravity settings, keybindings, conversations, or logs
|
||||
|
||||
## Mental model
|
||||
|
||||
Antigravity has two layers — keep them distinct or the guidance will be wrong:
|
||||
|
||||
1. **Shell wrapper commands** — `agy help`, `agy install`, `agy plugin`,
|
||||
`agy update`, `agy changelog`. Run these through the `terminal` tool.
|
||||
2. **Interactive in-session slash commands** — `/config`, `/permissions`,
|
||||
`/skills`, `/agents`, etc. These only exist inside a running `agy` TUI
|
||||
session, not on the shell wrapper.
|
||||
|
||||
`agy help` shows the shell wrapper surface, NOT the in-session slash commands.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The `agy` binary on PATH. Verify through the `terminal` tool:
|
||||
`command -v agy && agy --version`.
|
||||
- No env vars or API keys required by this skill — Antigravity manages its own
|
||||
auth via the OS keyring / browser sign-in (see Authentication below).
|
||||
|
||||
## How to Run
|
||||
|
||||
Invoke every `agy` command through the `terminal` tool. Examples:
|
||||
|
||||
```
|
||||
terminal(command="agy --version")
|
||||
terminal(command="agy help")
|
||||
terminal(command="agy plugin list")
|
||||
terminal(command="agy --print 'Summarize the repo in 3 bullets'", workdir="/path/to/project")
|
||||
```
|
||||
|
||||
For an interactive multi-turn TUI session, launch `agy` with `pty=true` (and
|
||||
tmux for capture/monitoring), the same pattern the `codex` / `claude-code`
|
||||
skills use. For one-shot smoke tests and scripted prompts, prefer
|
||||
`agy --print` (non-interactive).
|
||||
|
||||
To inspect Antigravity's own files, use `read_file` on the paths under Core
|
||||
paths below — do not `cat` them through the terminal.
|
||||
|
||||
## Core paths
|
||||
|
||||
- Binary / entrypoint: `agy`
|
||||
- App data dir: `~/.gemini/antigravity-cli/`
|
||||
- Settings file: `~/.gemini/antigravity-cli/settings.json`
|
||||
- Keybindings file: `~/.gemini/antigravity-cli/keybindings.json`
|
||||
- Logs: `~/.gemini/antigravity-cli/log/cli-*.log`
|
||||
- Conversations: `~/.gemini/antigravity-cli/conversations/`
|
||||
- Brain artifacts: `~/.gemini/antigravity-cli/brain/`
|
||||
- History: `~/.gemini/antigravity-cli/history.jsonl`
|
||||
- Plugin staging: `~/.gemini/antigravity-cli/plugins/<plugin_name>/`
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Wrapper commands
|
||||
- `agy changelog`
|
||||
- `agy help`
|
||||
- `agy install`
|
||||
- `agy plugin` / `agy plugins`
|
||||
- `agy update`
|
||||
|
||||
### Useful flags
|
||||
- `--add-dir`
|
||||
- `--continue` / `-c`
|
||||
- `--conversation`
|
||||
- `--dangerously-skip-permissions`
|
||||
- `--print` / `-p`
|
||||
- `--print-timeout`
|
||||
- `--prompt`
|
||||
- `--prompt-interactive` / `-i`
|
||||
- `--sandbox`
|
||||
- `--log-file`
|
||||
- `--version`
|
||||
|
||||
### Plugin subcommands (`agy plugin --help`)
|
||||
- `list`, `import [source]`, `install <target>`, `uninstall <name>`,
|
||||
`enable <name>`, `disable <name>`, `validate [path]`, `link <mp> <target>`,
|
||||
`help`
|
||||
|
||||
### Install flags (`agy install --help`)
|
||||
- `--dir`, `--skip-aliases`, `--skip-path`
|
||||
|
||||
### In-session slash commands
|
||||
- **Conversation control:** `/resume` (`/switch`), `/rewind` (`/undo`),
|
||||
`/rename <name>`, `/clear`, `/fork`, `/reset`, `/new`
|
||||
- **Settings & tools:** `/config`, `/settings`, `/permissions`, `/model`,
|
||||
`/keybindings`, `/statusline`, `/tasks`, `/skills`, `/mcp`, `/open <path>`,
|
||||
`/usage`, `/logout`, `/agents`
|
||||
- **Prompt helpers:** `@` path autocomplete, `esc esc` clears the prompt (when
|
||||
not streaming), `!` runs a terminal command directly, `?` opens help
|
||||
|
||||
## Settings and permissions
|
||||
|
||||
### Common settings keys (`settings.json`)
|
||||
- `allowNonWorkspaceAccess`
|
||||
- `colorScheme`
|
||||
- `permissions.allow`
|
||||
- `trustedWorkspaces`
|
||||
|
||||
### Permission modes
|
||||
`request-review`, `always-proceed`, `strict`, `proceed-in-sandbox`.
|
||||
|
||||
### Sandbox behavior
|
||||
- `enableTerminalSandbox` is a boolean in `settings.json`; default `false`.
|
||||
- Launch-time overrides (`--sandbox`, `--dangerously-skip-permissions`) can
|
||||
supersede persistent settings for the current session.
|
||||
|
||||
## Authentication behavior
|
||||
|
||||
- The CLI tries the OS secure keyring first.
|
||||
- With no saved session, it falls back to browser-based Google sign-in.
|
||||
- Locally it opens the default browser; over SSH it prints an authorization URL
|
||||
and expects the auth code pasted back.
|
||||
- `/logout` removes saved credentials.
|
||||
|
||||
## Plugins
|
||||
|
||||
- Plugins stage under `~/.gemini/antigravity-cli/plugins/<plugin_name>/`.
|
||||
- They can bundle skills, agents, rules, MCP servers, and hooks.
|
||||
- `agy plugin list` returning no imported plugins is a valid empty state.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- `agy help` shows wrapper commands, not interactive slash commands.
|
||||
- `agy --version` is the safe non-interactive version check; `agy version` is
|
||||
interactive and can fail without a real TTY.
|
||||
- First place to look for failures: `~/.gemini/antigravity-cli/log/cli-*.log`
|
||||
(read with `read_file`).
|
||||
- Don't confuse persistent JSON settings with launch-time overrides.
|
||||
- `~/.gemini/antigravity-cli/bin/agentapi` is a thin wrapper to `agy agentapi`.
|
||||
- On WSL, token storage is file-based, so auth issues are usually local-file /
|
||||
session-state problems, not browser-only problems.
|
||||
- Workspace identity can depend on launch directory and the `.antigravitycli`
|
||||
project marker.
|
||||
|
||||
## Verification
|
||||
|
||||
Confirm the install is real and usable, all through the `terminal` tool (read
|
||||
files with `read_file`):
|
||||
|
||||
1. `terminal(command="command -v agy")`
|
||||
2. `terminal(command="agy --version")`
|
||||
3. `terminal(command="agy help")`
|
||||
4. `terminal(command="agy plugin list")`
|
||||
5. `read_file` on `~/.gemini/antigravity-cli/settings.json`
|
||||
6. `read_file` on the latest `~/.gemini/antigravity-cli/log/cli-*.log`
|
||||
7. If needed, `read_file` on `~/.gemini/antigravity-cli/keybindings.json`
|
||||
|
||||
## Support files
|
||||
|
||||
- `references/cli-docs.md` — condensed notes from the getting-started, usage,
|
||||
and features docs.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Antigravity CLI docs, condensed
|
||||
|
||||
Source pages reviewed:
|
||||
- `/docs/cli-getting-started`
|
||||
- `/docs/cli-using`
|
||||
- `/docs/cli-features`
|
||||
|
||||
## Install
|
||||
- macOS/Linux: `curl -fsSL https://antigravity.google/cli/install.sh | bash`
|
||||
- Windows PowerShell: `irm https://antigravity.google/cli/install.ps1 | iex`
|
||||
- Windows CMD: `curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd`
|
||||
|
||||
## Authentication
|
||||
- Tries secure keyring first.
|
||||
- If no saved session exists, falls back to browser-based Google sign-in.
|
||||
- Local machine: opens the default browser.
|
||||
- SSH/remote: prints a secure authorization URL, then expects the auth code to be pasted back.
|
||||
- `/logout` removes saved credentials.
|
||||
|
||||
## Config and files
|
||||
- Settings: `~/.gemini/antigravity-cli/settings.json`
|
||||
- Keybindings: `~/.gemini/antigravity-cli/keybindings.json`
|
||||
- Plugins: `~/.gemini/antigravity-cli/plugins/<plugin_name>/`
|
||||
|
||||
## Useful slash commands
|
||||
- `/config`, `/settings`
|
||||
- `/permissions`
|
||||
- `/resume` / `/switch`
|
||||
- `/rewind` / `/undo`
|
||||
- `/rename <name>`
|
||||
- `/model`
|
||||
- `/keybindings`
|
||||
- `/statusline`
|
||||
- `/tasks`
|
||||
- `/skills`
|
||||
- `/mcp`
|
||||
- `/open <path>`
|
||||
- `/usage`
|
||||
- `/logout`
|
||||
- `/agents`
|
||||
|
||||
## Prompt helpers
|
||||
- `@` path autocomplete
|
||||
- `esc esc` clears prompt when not streaming
|
||||
- `!` runs a terminal command
|
||||
- `?` opens help / slash command list
|
||||
|
||||
## Permissions and sandbox
|
||||
- Permission modes: `request-review`, `always-proceed`, `strict`, `proceed-in-sandbox`
|
||||
- Launch overrides: `--sandbox`, `--dangerously-skip-permissions`
|
||||
- Sandbox setting: `enableTerminalSandbox` in `settings.json` (default `false`)
|
||||
|
||||
## Plugins
|
||||
- Plugins can bundle skills, agents, rules, MCP servers, and hooks.
|
||||
- They are staged locally and auto-discovered once installed.
|
||||
|
||||
## Subagents
|
||||
- `/agents` opens the panel for active/completed subagents.
|
||||
- Subagents can run in parallel and request approvals.
|
||||
|
||||
## Keybindings
|
||||
- `~/.gemini/antigravity-cli/keybindings.json`
|
||||
- Malformed JSON falls back to defaults for broken actions.
|
||||
- Docs list default bindings for clear, submit, cancel, exit, suspend, editor, approval yes/no, navigation, clipboard, undo/redo, and newline insertion.
|
||||
@@ -1,301 +0,0 @@
|
||||
---
|
||||
name: grok
|
||||
description: "Delegate coding to xAI Grok Build CLI (features, PRs)."
|
||||
version: 0.1.0
|
||||
author: Matt Maximo (MattMaximo), Hermes Agent
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Coding-Agent, Grok, xAI, Code-Review, Refactoring, Automation]
|
||||
related_skills: [codex, claude-code, hermes-agent]
|
||||
---
|
||||
|
||||
# Grok Build CLI — Hermes Orchestration Guide
|
||||
|
||||
Delegate coding tasks to [Grok Build](https://docs.x.ai/build/overview) (xAI's
|
||||
autonomous coding agent CLI, the `grok` command) via the Hermes terminal. Grok
|
||||
can read files, write code, run shell commands, spawn subagents, and manage git
|
||||
workflows. It runs three ways: an interactive TUI, **headless** (`-p`), and as
|
||||
an **ACP agent** over JSON-RPC.
|
||||
|
||||
This is the third sibling to `codex` and `claude-code`. The orchestration
|
||||
pattern is nearly identical — **prefer headless `-p` for one-shots**, use a PTY
|
||||
for interactive sessions.
|
||||
|
||||
## When to use
|
||||
|
||||
- Building features
|
||||
- Refactoring
|
||||
- PR reviews
|
||||
- Batch issue fixing
|
||||
- Any task where you'd otherwise reach for Codex / Claude Code but want Grok
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Install (preferred):** `npm install -g @xai-official/grok`
|
||||
- The official installer `curl -fsSL https://x.ai/cli/install.sh | bash` also
|
||||
works, but the `x.ai` host is Cloudflare-walled in some environments. The
|
||||
npm path avoids that dependency entirely.
|
||||
- **Auth — SuperGrok / X Premium+ subscription (primary path):**
|
||||
- Run `grok login` once → opens a browser for OAuth → token cached in
|
||||
`~/.grok/auth.json`. This uses your **SuperGrok or X Premium+** subscription
|
||||
(no per-token API billing).
|
||||
- Check sign-in state by looking for `~/.grok/auth.json`, or run a cheap
|
||||
headless smoke test: `grok --no-auto-update -p "Say ok."`
|
||||
- In the TUI, `/logout` signs out and `/login` (or relaunching) signs back in.
|
||||
- **No git repo required** — unlike Codex, Grok runs fine outside a git
|
||||
directory (good for scratch/throwaway tasks).
|
||||
- **Claude Code / AGENTS.md compatible with zero config** — Grok auto-reads
|
||||
`CLAUDE.md`, `.claude/` (skills, agents, MCPs, hooks, rules), and the
|
||||
`AGENTS.md` family. Existing project context just works.
|
||||
|
||||
> **API-key fallback (not the default for this user):** Grok also supports
|
||||
> setting the `XAI_API_KEY` environment variable for pay-as-you-go billing
|
||||
> via `api.x.ai`. Only use
|
||||
> this if `grok login` / SuperGrok auth is unavailable. The subscription path
|
||||
> (`grok login`) is the intended setup here.
|
||||
|
||||
## Two Orchestration Modes
|
||||
|
||||
### Mode 1: Headless (`-p`) — Non-Interactive (PREFERRED)
|
||||
|
||||
Runs a one-shot task, prints the result, and exits. No PTY, no interactive
|
||||
dialogs to navigate. This is the cleanest integration path — the analog of
|
||||
`claude -p` and `codex exec`.
|
||||
|
||||
```
|
||||
terminal(command="grok --no-auto-update -p 'Add a dark mode toggle to settings'", workdir="/path/to/project", timeout=180)
|
||||
```
|
||||
|
||||
Always pass `--no-auto-update` in automation to skip background update checks.
|
||||
|
||||
**When to use headless:**
|
||||
- One-shot coding tasks (fix a bug, add a feature, refactor)
|
||||
- CI/CD automation and scripting
|
||||
- Structured output parsing with `--output-format json`
|
||||
- Any task that doesn't need multi-turn conversation
|
||||
|
||||
### Mode 2: Interactive PTY — Multi-Turn TUI Sessions
|
||||
|
||||
The TUI is a fullscreen, mouse-interactive app. Drive it with `pty=true`. For
|
||||
robust monitoring/input use tmux (same pattern as the `claude-code` skill).
|
||||
|
||||
```
|
||||
# Launch in a tmux session for capture-pane monitoring
|
||||
terminal(command="tmux new-session -d -s grok-work -x 140 -y 40")
|
||||
terminal(command="tmux send-keys -t grok-work 'cd /path/to/project && grok' Enter")
|
||||
|
||||
# Wait for startup, then send a task
|
||||
terminal(command="sleep 5 && tmux send-keys -t grok-work 'Refactor the auth module to use JWT' Enter")
|
||||
|
||||
# Monitor progress
|
||||
terminal(command="sleep 15 && tmux capture-pane -t grok-work -p -S -50")
|
||||
|
||||
# Exit when done
|
||||
terminal(command="tmux send-keys -t grok-work '/quit' Enter && sleep 1 && tmux kill-session -t grok-work")
|
||||
```
|
||||
|
||||
**Tip for headless-but-inline output:** if you want TUI-style output without the
|
||||
fullscreen alt-screen takeover (e.g. for cleaner logs), add `--no-alt-screen`.
|
||||
For pure automation, headless `-p` is still cleaner than the TUI.
|
||||
|
||||
## Headless Deep Dive
|
||||
|
||||
### Common Flags
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `-p, --single <PROMPT>` | Send one prompt, run headless, exit |
|
||||
| `-m, --model <MODEL>` | Choose a model |
|
||||
| `-s, --session-id <ID>` | Create or resume a named headless session |
|
||||
| `-r, --resume <ID>` | Resume an existing session |
|
||||
| `-c, --continue` | Continue the most recent session in the current directory |
|
||||
| `--cwd <PATH>` | Set the working directory |
|
||||
| `--output-format <FMT>` | `plain` (default), `json`, or `streaming-json` |
|
||||
| `--always-approve` | Auto-approve all tool executions (the `--full-auto` / `--yolo` equivalent) |
|
||||
| `--no-alt-screen` | Run inline, no fullscreen TUI takeover |
|
||||
| `--no-auto-update` | Skip background update checks (use in all automation) |
|
||||
|
||||
### Output Formats
|
||||
|
||||
- `plain` — human-readable text (default)
|
||||
- `json` — one JSON object at the end of the run (parse the result cleanly)
|
||||
- `streaming-json` — newline-delimited JSON events as they arrive
|
||||
|
||||
```
|
||||
# Structured result for parsing
|
||||
terminal(command="grok --no-auto-update -p 'List all TODO comments in src/' --output-format json", workdir="/project", timeout=120)
|
||||
|
||||
# Auto-approve for autonomous building
|
||||
terminal(command="grok --no-auto-update --always-approve -p 'Refactor the database layer and run the tests'", workdir="/project", timeout=300)
|
||||
```
|
||||
|
||||
### Background Mode (Long Tasks)
|
||||
|
||||
```
|
||||
# Start headless in background
|
||||
terminal(command="grok --no-auto-update --always-approve -p 'Refactor the auth module'", workdir="/project", background=true, notify_on_complete=true)
|
||||
# Returns session_id
|
||||
|
||||
# Monitor
|
||||
process(action="poll", session_id="<id>")
|
||||
process(action="log", session_id="<id>")
|
||||
|
||||
# Kill if needed
|
||||
process(action="kill", session_id="<id>")
|
||||
```
|
||||
|
||||
For an interactive (TUI) background session, use `pty=true` + tmux and monitor
|
||||
with `tmux capture-pane`, exactly like the `claude-code` / `codex` skills.
|
||||
|
||||
### Session Continuation
|
||||
|
||||
```
|
||||
# Start a named session
|
||||
terminal(command="grok --no-auto-update -s refactor-db -p 'Start refactoring the database layer' --always-approve", workdir="/project", timeout=240)
|
||||
|
||||
# Resume it later
|
||||
terminal(command="grok --no-auto-update -r refactor-db -p 'Now add connection pooling' --always-approve", workdir="/project", timeout=180)
|
||||
|
||||
# Or continue the most recent session in this directory
|
||||
terminal(command="grok --no-auto-update -c -p 'What did you change last time?'", workdir="/project", timeout=60)
|
||||
```
|
||||
|
||||
## Read-Only Audit → Markdown Note Pattern
|
||||
|
||||
To have Grok review local artifacts and return a clean markdown note (for
|
||||
Obsidian or a repo) without mutating anything:
|
||||
|
||||
1. Prepare stable input files first with Hermes tools (`read_file`,
|
||||
`write_file`). Snapshot only the relevant context into a temp file rather
|
||||
than dumping raw paths.
|
||||
2. Run Grok headless **without** `--always-approve` so it cannot auto-write, and
|
||||
demand `markdown only, no preamble`.
|
||||
3. Save Grok's stdout straight into the destination note with `write_file()`.
|
||||
|
||||
```
|
||||
grok --no-auto-update -p "Read /tmp/current.md and /tmp/inventory.md. Produce markdown only, no preamble. Output a clean note titled 'Cleanup Review'." --output-format plain
|
||||
```
|
||||
|
||||
**Pitfall (same as Claude Code):** for document rewrites, a loose "rewrite this"
|
||||
prompt may return a change summary instead of the full file. Instead: pipe the
|
||||
file in, and demand `Return ONLY the full revised markdown document. No intro,
|
||||
no explanation, no code fences. Start immediately with '# Title'.` Verify the
|
||||
first lines with `read_file()` before overwriting the destination.
|
||||
|
||||
## PR Review Patterns
|
||||
|
||||
### Quick Review (Headless)
|
||||
|
||||
```
|
||||
terminal(command="cd /path/to/repo && git diff main...feature-branch | grok --no-auto-update -p 'Review this diff for bugs, security issues, and style problems. Be thorough.'", timeout=120)
|
||||
```
|
||||
|
||||
### Clone-to-temp Review (safe, no repo mutation)
|
||||
|
||||
```
|
||||
terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && gh pr checkout 42 && grok --no-auto-update -p 'Review the changes vs origin/main. Check bugs, security, race conditions, missing tests.'", pty=true, timeout=300)
|
||||
```
|
||||
|
||||
### Post the review
|
||||
|
||||
```
|
||||
terminal(command="gh pr comment 42 --body '<review text>'", workdir="/path/to/repo")
|
||||
```
|
||||
|
||||
## Parallel Issue Fixing with Worktrees
|
||||
|
||||
```
|
||||
# Create worktrees
|
||||
terminal(command="git worktree add -b fix/issue-78 /tmp/issue-78 main", workdir="~/project")
|
||||
terminal(command="git worktree add -b fix/issue-99 /tmp/issue-99 main", workdir="~/project")
|
||||
|
||||
# Launch Grok headless in each (background)
|
||||
terminal(command="grok --no-auto-update --always-approve -p 'Fix issue #78: <description>. Commit when done.'", workdir="/tmp/issue-78", background=true, notify_on_complete=true)
|
||||
terminal(command="grok --no-auto-update --always-approve -p 'Fix issue #99: <description>. Commit when done.'", workdir="/tmp/issue-99", background=true, notify_on_complete=true)
|
||||
|
||||
# Monitor
|
||||
process(action="list")
|
||||
|
||||
# After completion: push and open PRs
|
||||
terminal(command="cd /tmp/issue-78 && git push -u origin fix/issue-78")
|
||||
terminal(command="gh pr create --repo user/repo --head fix/issue-78 --title 'fix: ...' --body '...'")
|
||||
|
||||
# Cleanup
|
||||
terminal(command="git worktree remove /tmp/issue-78", workdir="~/project")
|
||||
```
|
||||
|
||||
## Useful Subcommands & TUI Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `grok` | Start the interactive TUI |
|
||||
| `grok -p "query"` | Headless one-shot |
|
||||
| `grok login` / `grok logout` | Sign in / out (SuperGrok / X Premium+ OAuth) |
|
||||
| `grok inspect` | Show what Grok discovered in cwd: config sources, instructions, skills, plugins, hooks, MCP servers |
|
||||
| `grok agent stdio` | Run as an ACP agent over JSON-RPC (for IDE/tool integration) |
|
||||
| `grok update` | Update the CLI (needs the `x.ai` host; skip in automation) |
|
||||
|
||||
TUI slash commands (interactive only): `/model <name>`, `/always-approve`,
|
||||
`/plan`, `/context`, `/compact`, `/resume`, `/sessions`, `/fork`, `/usage`,
|
||||
`/quit`. `Shift+Tab` cycles session modes (including Plan mode, which blocks
|
||||
write tools except the session plan file).
|
||||
|
||||
## Config (`~/.grok/config.toml`)
|
||||
|
||||
```toml
|
||||
[cli]
|
||||
auto_update = false # skip background update checks persistently
|
||||
|
||||
[ui]
|
||||
permission_mode = "ask" # or "always-approve" to skip tool prompts by default
|
||||
|
||||
[models]
|
||||
default = "grok-build-0.1"
|
||||
```
|
||||
|
||||
Put global preferences in `~/.grok/config.toml` (not project-scoped
|
||||
`.grok/config.toml`). `permission_mode` supersedes the legacy `approval_mode` /
|
||||
`yolo = true` keys.
|
||||
|
||||
## Pitfalls & Gotchas
|
||||
|
||||
1. **Auth is subscription-gated.** `grok login` requires a SuperGrok or X
|
||||
Premium+ subscription. If login fails or there's no `~/.grok/auth.json`,
|
||||
confirm the subscription is active before falling back to `XAI_API_KEY`.
|
||||
2. **Don't conflate Hermes' xAI auth with the `grok` CLI's auth.** Hermes'
|
||||
`x_search` runs on its own xAI OAuth; the standalone `grok` CLI has a
|
||||
separate token in `~/.grok/auth.json`. A working `x_search` does NOT mean
|
||||
`grok` is logged in.
|
||||
3. **Always pass `--no-auto-update` in automation** — otherwise Grok phones home
|
||||
for update checks (and `x.ai`/`storage.googleapis.com` may be unreachable).
|
||||
4. **Prefer npm install over the curl installer** — `npm install -g
|
||||
@xai-official/grok` avoids the Cloudflare-walled `x.ai` host.
|
||||
5. **`--always-approve` is the autonomous-build switch.** Without it, headless
|
||||
runs may stall waiting on tool-approval prompts. Omit it deliberately for
|
||||
read-only review/audit work so Grok can't mutate files.
|
||||
6. **Headless `-p` skips TUI dialogs**; the TUI needs `pty=true` (+ tmux for
|
||||
monitoring), just like Claude Code.
|
||||
7. **Use `--no-alt-screen`** if you run the TUI inline and the fullscreen
|
||||
alt-screen takeover garbles captured output.
|
||||
8. **No git repo needed**, but for PR/commit workflows you still want one — use
|
||||
`mktemp -d && git init` for scratch commit tasks.
|
||||
9. **Clean up tmux sessions** with `tmux kill-session -t <name>` when done.
|
||||
|
||||
## Rules for Hermes Agents
|
||||
|
||||
1. **Prefer headless `-p`** for single tasks — cleanest integration, structured
|
||||
output via `--output-format json`.
|
||||
2. **Always set `workdir`** (or `--cwd`) so Grok targets the right project.
|
||||
3. **Pass `--no-auto-update`** in every automated invocation.
|
||||
4. **Use `--always-approve` only when Grok should write autonomously**; omit it
|
||||
for read-only reviews and audits.
|
||||
5. **Background long tasks** with `background=true, notify_on_complete=true` and
|
||||
monitor via the `process` tool.
|
||||
6. **Use tmux for multi-turn interactive work** and monitor with
|
||||
`tmux capture-pane -t <session> -p -S -50`.
|
||||
7. **Verify auth before relying on it** — check `~/.grok/auth.json` or run a
|
||||
cheap `grok -p "Say ok."` smoke test; don't assume Hermes' xAI auth carries
|
||||
over.
|
||||
8. **Report results to the user** — summarize what Grok changed and what's left.
|
||||
@@ -38,7 +38,7 @@ It uses `scripts/openclaw_to_hermes.py` to:
|
||||
- import `SOUL.md` into the Hermes home directory as `SOUL.md`
|
||||
- transform OpenClaw `MEMORY.md` and `USER.md` into Hermes memory entries
|
||||
- merge OpenClaw command approval patterns into Hermes `command_allowlist`
|
||||
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS`, and map OpenClaw workspace settings to Hermes working-directory configuration
|
||||
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS` and `MESSAGING_CWD`
|
||||
- copy OpenClaw skills into `~/.hermes/skills/openclaw-imports/`
|
||||
- optionally copy the OpenClaw workspace instructions file into a chosen Hermes workspace
|
||||
- mirror compatible workspace assets such as `workspace/tts/` into `~/.hermes/tts/`
|
||||
|
||||
@@ -26,7 +26,7 @@ Optional feature knobs::
|
||||
BROWSERBASE_PROXIES=true # default true
|
||||
BROWSERBASE_ADVANCED_STEALTH=false
|
||||
BROWSERBASE_KEEP_ALIVE=true # default true
|
||||
BROWSERBASE_SESSION_TIMEOUT=... (seconds, integer, max 21600 = 6h)
|
||||
BROWSERBASE_SESSION_TIMEOUT=... (ms, integer)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -481,14 +481,7 @@ def guess_category(path: Path) -> Optional[str]:
|
||||
}:
|
||||
return None
|
||||
if top == "cron" or top == "cronjobs":
|
||||
# Only files under the disposable ``output/`` subtree are
|
||||
# cleanup candidates. Top-level cron control-plane state
|
||||
# (e.g. ``jobs.json``, ``.tick.lock``) must never be
|
||||
# auto-tracked — deleting it wipes the live scheduler
|
||||
# registry. See issue #32164.
|
||||
if len(rel.parts) >= 2 and rel.parts[1] == "output":
|
||||
return "cron-output"
|
||||
return None
|
||||
return "cron-output"
|
||||
if top == "cache":
|
||||
return "temp"
|
||||
except ValueError:
|
||||
|
||||
@@ -81,7 +81,6 @@ DEDUP_WINDOW_SECONDS = 300
|
||||
DEDUP_MAX_SIZE = 1000
|
||||
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
|
||||
STREAM_TIMEOUT_SECONDS = 90 # ntfy keepalive default is 55s; give margin
|
||||
_ECHO_TAG = "hermes-agent" # tag added to outgoing messages for echo-loop prevention
|
||||
|
||||
|
||||
def _build_auth_header(token: str) -> Dict[str, str]:
|
||||
@@ -312,12 +311,6 @@ class NtfyAdapter(BasePlatformAdapter):
|
||||
logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id)
|
||||
return
|
||||
|
||||
# Echo-loop prevention: skip messages tagged by this adapter.
|
||||
tags = event.get("tags") or []
|
||||
if _ECHO_TAG in tags:
|
||||
logger.debug("[%s] Skipping own message (echo tag)", self.name)
|
||||
return
|
||||
|
||||
text = (event.get("message") or "").strip()
|
||||
if not text:
|
||||
logger.debug("[%s] Empty message body, skipping", self.name)
|
||||
@@ -394,11 +387,7 @@ class NtfyAdapter(BasePlatformAdapter):
|
||||
|
||||
url = f"{self._server}/{publish_topic}"
|
||||
markdown_enabled = (self.config.extra or {}).get("markdown", False)
|
||||
headers = {
|
||||
**self._auth_headers(),
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"X-Tags": _ECHO_TAG,
|
||||
}
|
||||
headers = {**self._auth_headers(), "Content-Type": "text/plain; charset=utf-8"}
|
||||
if markdown_enabled:
|
||||
headers["X-Markdown"] = "true"
|
||||
|
||||
@@ -530,7 +519,7 @@ async def _standalone_send(
|
||||
markdown_env = os.getenv("NTFY_MARKDOWN", "").strip().lower()
|
||||
markdown_enabled = bool(extra.get("markdown")) or markdown_env in ("1", "true", "yes")
|
||||
|
||||
headers = {"Content-Type": "text/plain; charset=utf-8", "X-Tags": _ECHO_TAG, **_build_auth_header(token)}
|
||||
headers = {"Content-Type": "text/plain; charset=utf-8", **_build_auth_header(token)}
|
||||
if markdown_enabled:
|
||||
headers["X-Markdown"] = "true"
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Model families (each with t2v + i2v endpoints):
|
||||
veo3.1 fal-ai/veo3.1 / fal-ai/veo3.1/image-to-video
|
||||
seedance-2.0 bytedance/seedance-2.0/text-to-video / bytedance/seedance-2.0/image-to-video
|
||||
kling-v3-4k fal-ai/kling-video/v3/4k/text-to-video / fal-ai/kling-video/v3/4k/image-to-video
|
||||
happy-horse alibaba/happy-horse/text-to-video / alibaba/happy-horse/image-to-video
|
||||
happy-horse fal-ai/happy-horse/text-to-video / fal-ai/happy-horse/image-to-video
|
||||
|
||||
Selection precedence for the active family:
|
||||
1. ``model=`` arg from the tool call
|
||||
@@ -26,16 +26,14 @@ Selection precedence for the active family:
|
||||
4. ``video_gen.model`` in ``config.yaml`` (when it's one of our family IDs)
|
||||
5. ``DEFAULT_MODEL``
|
||||
|
||||
Authentication via ``FAL_KEY`` or the managed Nous gateway. Output is an
|
||||
HTTPS URL from FAL's CDN; the gateway downloads and delivers it.
|
||||
Authentication via ``FAL_KEY``. Output is an HTTPS URL from FAL's CDN; the
|
||||
gateway downloads and delivers it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.video_gen_provider import (
|
||||
@@ -106,9 +104,8 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
|
||||
"text_endpoint": "fal-ai/veo3.1",
|
||||
"image_endpoint": "fal-ai/veo3.1/image-to-video",
|
||||
"aspect_ratios": ("16:9", "9:16"),
|
||||
"resolutions": ("720p", "1080p", "4k"),
|
||||
"resolutions": ("720p", "1080p"),
|
||||
"durations": (4, 6, 8),
|
||||
"duration_suffix": "s", # FAL veo3.1 wants "4s" not "4"
|
||||
"audio": True,
|
||||
"negative": True,
|
||||
},
|
||||
@@ -151,8 +148,8 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
|
||||
"price": "premium",
|
||||
"strengths": "Alibaba. New model, sparse public docs — conservative defaults.",
|
||||
"tier": "premium",
|
||||
"text_endpoint": "alibaba/happy-horse/text-to-video",
|
||||
"image_endpoint": "alibaba/happy-horse/image-to-video",
|
||||
"text_endpoint": "fal-ai/happy-horse/text-to-video",
|
||||
"image_endpoint": "fal-ai/happy-horse/image-to-video",
|
||||
# Docs don't expose duration/aspect/resolution — let the endpoint
|
||||
# apply its own defaults.
|
||||
"aspect_ratios": None,
|
||||
@@ -273,9 +270,7 @@ def _build_payload(
|
||||
clamped = _clamp_duration(family, duration)
|
||||
if clamped is not None and family.get("durations"):
|
||||
# FAL exposes duration as a string in the queue API ("8" not 8).
|
||||
# Some families (e.g. veo3.1) require a unit suffix ("4s" not "4").
|
||||
suffix = family.get("duration_suffix", "")
|
||||
payload["duration"] = f"{clamped}{suffix}"
|
||||
payload["duration"] = str(clamped)
|
||||
|
||||
if family.get("audio") and audio is not None:
|
||||
payload["generate_audio"] = bool(audio)
|
||||
@@ -307,92 +302,6 @@ def _load_fal_client() -> Any:
|
||||
return _fal_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Managed FAL gateway (Nous Subscription)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_managed_fal_video_client: Any = None
|
||||
_managed_fal_video_client_config: Any = None
|
||||
_managed_fal_video_client_lock = threading.Lock()
|
||||
|
||||
|
||||
def _resolve_managed_fal_video_gateway():
|
||||
"""Return managed fal-queue gateway config when the user prefers the gateway
|
||||
or direct FAL credentials are absent."""
|
||||
from tools.tool_backend_helpers import fal_key_is_configured, prefers_gateway
|
||||
|
||||
if fal_key_is_configured() and not prefers_gateway("video_gen"):
|
||||
return None
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
|
||||
return resolve_managed_tool_gateway("fal-queue")
|
||||
|
||||
|
||||
def _get_managed_fal_video_client(managed_gateway):
|
||||
"""Reuse the managed FAL client so its internal httpx.Client is not leaked per call."""
|
||||
global _managed_fal_video_client, _managed_fal_video_client_config
|
||||
from tools.fal_common import _ManagedFalSyncClient
|
||||
|
||||
client_config = (
|
||||
managed_gateway.gateway_origin.rstrip("/"),
|
||||
managed_gateway.nous_user_token,
|
||||
)
|
||||
with _managed_fal_video_client_lock:
|
||||
if _managed_fal_video_client is not None and _managed_fal_video_client_config == client_config:
|
||||
return _managed_fal_video_client
|
||||
|
||||
_load_fal_client()
|
||||
_managed_fal_video_client = _ManagedFalSyncClient(
|
||||
_fal_client,
|
||||
key=managed_gateway.nous_user_token,
|
||||
queue_run_origin=managed_gateway.gateway_origin,
|
||||
)
|
||||
_managed_fal_video_client_config = client_config
|
||||
return _managed_fal_video_client
|
||||
|
||||
|
||||
def _submit_fal_video_request(endpoint: str, arguments: Dict[str, Any]):
|
||||
"""Submit a FAL video request using direct credentials or the managed queue gateway.
|
||||
|
||||
Returns a request handle whose ``.get()`` blocks until the result is ready.
|
||||
"""
|
||||
_load_fal_client()
|
||||
request_headers = {"x-idempotency-key": str(uuid.uuid4())}
|
||||
managed_gateway = _resolve_managed_fal_video_gateway()
|
||||
if managed_gateway is None:
|
||||
return _fal_client.submit(endpoint, arguments=arguments, headers=request_headers)
|
||||
|
||||
managed_client = _get_managed_fal_video_client(managed_gateway)
|
||||
try:
|
||||
return managed_client.submit(
|
||||
endpoint,
|
||||
arguments=arguments,
|
||||
headers=request_headers,
|
||||
)
|
||||
except Exception as exc:
|
||||
from tools.fal_common import _extract_http_status
|
||||
|
||||
status = _extract_http_status(exc)
|
||||
if status is not None and 400 <= status < 500:
|
||||
raise ValueError(
|
||||
f"Nous Subscription gateway rejected endpoint '{endpoint}' "
|
||||
f"(HTTP {status}). This model may not yet be enabled on "
|
||||
f"the Nous Portal's FAL proxy. Either:\n"
|
||||
f" • Set FAL_KEY in your environment to use FAL.ai directly, or\n"
|
||||
f" • Pick a different model via `hermes tools` → Video Generation."
|
||||
) from exc
|
||||
raise
|
||||
|
||||
|
||||
def _check_fal_video_available() -> bool:
|
||||
"""True if the FAL.ai video backend is reachable (direct key or managed gateway)."""
|
||||
from tools.tool_backend_helpers import fal_key_is_configured
|
||||
|
||||
if fal_key_is_configured():
|
||||
return True
|
||||
return _resolve_managed_fal_video_gateway() is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -414,10 +323,13 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
return "FAL"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
try:
|
||||
return _check_fal_video_available()
|
||||
except Exception: # noqa: BLE001 — never break the picker
|
||||
if not os.environ.get("FAL_KEY", "").strip():
|
||||
return False
|
||||
try:
|
||||
import fal_client # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
@@ -482,12 +394,11 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
seed: Optional[int] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
if not _check_fal_video_available():
|
||||
if not os.environ.get("FAL_KEY", "").strip():
|
||||
return error_response(
|
||||
error=(
|
||||
"No FAL backend available. Either set FAL_KEY "
|
||||
"(run `hermes tools` → Video Generation → FAL to configure) "
|
||||
"or sign in to Nous (`hermes setup`) for managed gateway access."
|
||||
"FAL_KEY not set. Run `hermes tools` → Video Generation "
|
||||
"→ FAL to configure."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="fal",
|
||||
@@ -495,7 +406,7 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
)
|
||||
|
||||
try:
|
||||
_load_fal_client()
|
||||
fal_client = _load_fal_client()
|
||||
except ImportError:
|
||||
return error_response(
|
||||
error="fal_client Python package not installed (pip install fal-client)",
|
||||
@@ -556,8 +467,11 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
)
|
||||
|
||||
try:
|
||||
handle = _submit_fal_video_request(endpoint, payload)
|
||||
result = handle.get()
|
||||
result = fal_client.subscribe(
|
||||
endpoint,
|
||||
arguments=payload,
|
||||
with_logs=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"FAL video gen failed (family=%s, endpoint=%s): %s",
|
||||
@@ -597,7 +511,7 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
prompt=prompt,
|
||||
modality=modality_used,
|
||||
aspect_ratio=aspect_ratio if "aspect_ratio" in payload else "",
|
||||
duration=int("".join(c for c in payload["duration"] if c.isdigit()) or "0") if "duration" in payload else 0,
|
||||
duration=int(payload["duration"]) if "duration" in payload else 0,
|
||||
provider="fal",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
+18
-11
@@ -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==82.0.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"]
|
||||
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"]
|
||||
@@ -117,15 +117,22 @@ sms = ["aiohttp==3.13.3"]
|
||||
# to it, which is already provided by the `mcp` extra.
|
||||
computer-use = ["mcp==1.26.0"]
|
||||
acp = ["agent-client-protocol==0.9.0"]
|
||||
# mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version.
|
||||
# The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious
|
||||
# 2.4.6 release (Mini Shai-Hulud worm); 2.4.6 was removed from PyPI and the
|
||||
# project is serving clean releases again (2.4.7 2026-05-25, 2.4.8 2026-05-28).
|
||||
# Like other opt-in TTS/STT backends, this is lazy-installed via
|
||||
# tools/lazy_deps.py (stt.mistral / tts.mistral) at first use — deliberately
|
||||
# NOT re-added to [all] so a future quarantined release can't break fresh
|
||||
# installs (see [all] policy comment below).
|
||||
mistral = ["mistralai==2.4.8"]
|
||||
# mistral: extra REMOVED 2026-05-12 — `mistralai` PyPI project quarantined
|
||||
# after malicious 2.4.6 release (Mini Shai-Hulud worm). Every version of
|
||||
# `mistralai` returns 404 on PyPI right now, so any pin we'd write is
|
||||
# unresolvable, which breaks `uv lock --check` in CI.
|
||||
#
|
||||
# To restore once PyPI un-quarantines:
|
||||
# 1. Verify the new release is clean (read the changelog, check Socket
|
||||
# advisory page, confirm no malicious code review findings).
|
||||
# 2. Add back: mistral = ["mistralai==<verified-version>"]
|
||||
# 3. Re-enable Mistral in:
|
||||
# - tools/lazy_deps.py (LAZY_DEPS["tts.mistral"], LAZY_DEPS["stt.mistral"])
|
||||
# - hermes_cli/tools_config.py (un-hide from provider picker)
|
||||
# - hermes_cli/web_server.py (re-add to dashboard STT options)
|
||||
# - tools/transcription_tools.py / tools/tts_tool.py (drop disabled stubs)
|
||||
# 4. Run `uv lock` to regenerate transitives.
|
||||
# 5. Optionally re-add to [all] only after a few days of clean operation.
|
||||
bedrock = ["boto3==1.42.89"]
|
||||
azure-identity = ["azure-identity==1.25.3"]
|
||||
termux = [
|
||||
@@ -230,7 +237,7 @@ plugins = [
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
-120
@@ -2138,126 +2138,6 @@ 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
|
||||
|
||||
@@ -45,14 +45,6 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"metalclaudbot@gmail.com": "HashClawAI",
|
||||
"tonybear55665566@gmail.com": "TonyPepeBear",
|
||||
"kaspersniels@gmail.com": "nielskaspers",
|
||||
"kurobaryo@gmail.com": "kurobaryo",
|
||||
"155192176+alelpoan@users.noreply.github.com": "alelpoan",
|
||||
"aman@abacus.ai": "Aman113114-IITD",
|
||||
"octavio.turra@gmail.com": "octavioturra",
|
||||
"524706+Twanislas@users.noreply.github.com": "Twanislas",
|
||||
"9592417+adam91holt@users.noreply.github.com": "adam91holt",
|
||||
"kchuang1015@users.noreply.github.com": "kchuang1015",
|
||||
"45688690+fujinice@users.noreply.github.com": "fujinice",
|
||||
@@ -92,7 +84,6 @@ 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",
|
||||
@@ -121,7 +112,6 @@ AUTHOR_MAP = {
|
||||
"david@memorilabs.ai": "devwdave",
|
||||
"dave@devwdave.com": "devwdave",
|
||||
"1920071390@campus.ouj.ac.jp": "zapabob",
|
||||
"zapabob@users.noreply.github.com": "zapabob",
|
||||
"gaia@gaia.local": "jfuenmayor",
|
||||
"jiahuigu@users.noreply.github.com": "Jiahui-Gu",
|
||||
"openhands@all-hands.dev": "YLChen-007",
|
||||
@@ -130,8 +120,6 @@ AUTHOR_MAP = {
|
||||
"32711803+waefrebeorn@users.noreply.github.com": "waefrebeorn",
|
||||
"32869278+dusterbloom@users.noreply.github.com": "dusterbloom",
|
||||
"liuhao1024@users.noreply.github.com": "liuhao1024",
|
||||
"annguyenNous@users.noreply.github.com": "annguyenNous",
|
||||
"285874597+annguyenNous@users.noreply.github.com": "annguyenNous",
|
||||
"kylekahraman@users.noreply.github.com": "kylekahraman",
|
||||
"130975919+kylekahraman@users.noreply.github.com": "kylekahraman",
|
||||
"seppe@fushia.be": "seppegadeyne",
|
||||
@@ -526,8 +514,6 @@ AUTHOR_MAP = {
|
||||
"barnacleboy.jezzahehn@agentmail.to": "JezzaHehn",
|
||||
"254021826+dodo-reach@users.noreply.github.com": "dodo-reach",
|
||||
"259807879+Bartok9@users.noreply.github.com": "Bartok9",
|
||||
"123342691+banditburai@users.noreply.github.com": "banditburai",
|
||||
"9063726+Kyzcreig@users.noreply.github.com": "Kyzcreig",
|
||||
"270082434+crayfish-ai@users.noreply.github.com": "crayfish-ai",
|
||||
"241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter",
|
||||
"268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1",
|
||||
@@ -650,7 +636,6 @@ AUTHOR_MAP = {
|
||||
"pub_forgreatagent@antgroup.com": "AntAISecurityLab",
|
||||
"252620095+briandevans@users.noreply.github.com": "briandevans",
|
||||
"danielrpike9@gmail.com": "Bartok9",
|
||||
"96944678+ymylive@users.noreply.github.com": "sweetcornna",
|
||||
"skozyuk@cruxexperts.com": "CruxExperts",
|
||||
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||
"12250313+Kailigithub@users.noreply.github.com": "Kailigithub",
|
||||
|
||||
@@ -446,15 +446,15 @@ Common "why is Hermes doing X to my output / tool calls / commands?" toggles —
|
||||
|
||||
### Secret redaction in tool output
|
||||
|
||||
Secret redaction is **on by default** — tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) is scanned for strings that look like API keys, tokens, and secrets before it enters the conversation context and logs. Leave it enabled for normal use:
|
||||
Secret redaction is **off by default** — tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) passes through unmodified. If the user wants Hermes to auto-mask strings that look like API keys, tokens, and secrets before they enter the conversation context and logs:
|
||||
|
||||
```bash
|
||||
hermes config set security.redact_secrets true # keep enabled globally
|
||||
hermes config set security.redact_secrets true # enable globally
|
||||
```
|
||||
|
||||
**Restart required.** `security.redact_secrets` is snapshotted at import time — toggling it mid-session (e.g. via `export HERMES_REDACT_SECRETS=false` from a tool call) will NOT take effect for the running process. Tell the user to change it in config from a terminal, then start a new session. This is deliberate — it prevents an LLM from flipping the toggle on itself mid-task.
|
||||
**Restart required.** `security.redact_secrets` is snapshotted at import time — toggling it mid-session (e.g. via `export HERMES_REDACT_SECRETS=true` from a tool call) will NOT take effect for the running process. Tell the user to run `hermes config set security.redact_secrets true` in a terminal, then start a new session. This is deliberate — it prevents an LLM from flipping the toggle on itself mid-task.
|
||||
|
||||
Disable only when you deliberately need raw credential-like strings for debugging or redactor development:
|
||||
Disable again with:
|
||||
```bash
|
||||
hermes config set security.redact_secrets false
|
||||
```
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
% \author{Author 1 \\ Address line \\ ... \\ Address line
|
||||
% \And ... \And
|
||||
% Author n \\ Address line \\ ... \\ Address line}
|
||||
% To start a separate ``row'' of authors use \AND, as in
|
||||
% To start a seperate ``row'' of authors use \AND, as in
|
||||
% \author{Author 1 \\ Address line \\ ... \\ Address line
|
||||
% \AND
|
||||
% Author 2 \\ Address line \\ ... \\ Address line \And
|
||||
|
||||
@@ -88,62 +88,6 @@ 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,8 +41,6 @@ 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,
|
||||
@@ -50,39 +48,12 @@ 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):
|
||||
|
||||
@@ -440,7 +440,6 @@ class TestBuildNousSubscriptionPrompt:
|
||||
features={
|
||||
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
|
||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
|
||||
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
|
||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
|
||||
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
|
||||
@@ -465,7 +464,6 @@ class TestBuildNousSubscriptionPrompt:
|
||||
features={
|
||||
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
|
||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
|
||||
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
|
||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
|
||||
"browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, ""),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, ""),
|
||||
|
||||
@@ -112,13 +112,8 @@ 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
|
||||
# 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"]
|
||||
# other kwargs preserved
|
||||
assert retry_kwargs["max_tokens"] == 500
|
||||
|
||||
def test_non_temperature_400_does_not_retry_as_temperature(self):
|
||||
"""Unrelated 400s (e.g. bad tool role) must not silently drop temp."""
|
||||
@@ -212,11 +207,7 @@ 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
|
||||
# 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"]
|
||||
assert retry_kwargs["max_tokens"] == 500
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_non_temperature_400_does_not_retry(self):
|
||||
|
||||
@@ -11,7 +11,6 @@ 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.
|
||||
@@ -117,107 +116,3 @@ 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
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,198 +0,0 @@
|
||||
"""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
|
||||
@@ -1,116 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,202 +0,0 @@
|
||||
"""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
|
||||
@@ -4883,62 +4883,3 @@ class TestFeishuMentionEndToEnd(unittest.TestCase):
|
||||
# Body: leading @Hermes stripped, Alice preserved, trailing text intact.
|
||||
self.assertIn("@Alice review the spec with Alice", event.text)
|
||||
self.assertNotIn("@Hermes @Alice", event.text)
|
||||
|
||||
|
||||
class TestChatLockEviction(unittest.TestCase):
|
||||
"""_get_chat_lock is LRU-bounded so _chat_locks cannot grow unbounded."""
|
||||
|
||||
def _make_adapter(self, max_size=5):
|
||||
import collections as _collections
|
||||
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = object.__new__(FeishuAdapter)
|
||||
adapter._chat_locks = _collections.OrderedDict()
|
||||
adapter.CHAT_LOCK_MAX_SIZE = max_size
|
||||
return adapter
|
||||
|
||||
def test_chat_locks_is_ordered_dict(self):
|
||||
import collections as _collections
|
||||
|
||||
adapter = self._make_adapter()
|
||||
self.assertIsInstance(adapter._chat_locks, _collections.OrderedDict)
|
||||
|
||||
def test_same_id_returns_same_lock_and_stays_bounded(self):
|
||||
adapter = self._make_adapter(max_size=5)
|
||||
locks = [adapter._get_chat_lock(f"c{i}") for i in range(5)]
|
||||
self.assertEqual(len(adapter._chat_locks), 5)
|
||||
# Re-requesting an existing id returns the identical lock, no growth.
|
||||
self.assertIs(adapter._get_chat_lock("c2"), locks[2])
|
||||
self.assertEqual(len(adapter._chat_locks), 5)
|
||||
|
||||
def test_lru_eviction_respects_recent_access(self):
|
||||
adapter = self._make_adapter(max_size=5)
|
||||
for i in range(5):
|
||||
adapter._get_chat_lock(f"c{i}")
|
||||
# Touch c0 so it is no longer the LRU entry, then add a new chat.
|
||||
adapter._get_chat_lock("c0")
|
||||
adapter._get_chat_lock("c_new")
|
||||
self.assertEqual(len(adapter._chat_locks), 5)
|
||||
self.assertNotIn("c1", adapter._chat_locks) # c1 was the true LRU
|
||||
self.assertIn("c0", adapter._chat_locks)
|
||||
self.assertIn("c_new", adapter._chat_locks)
|
||||
|
||||
def test_eviction_skips_held_locks(self):
|
||||
adapter = self._make_adapter(max_size=3)
|
||||
|
||||
async def _run():
|
||||
held = adapter._get_chat_lock("held")
|
||||
await held.acquire()
|
||||
try:
|
||||
adapter._get_chat_lock("x")
|
||||
adapter._get_chat_lock("y")
|
||||
# At capacity; "held" is LRU but locked, so "x" should go instead.
|
||||
adapter._get_chat_lock("z")
|
||||
self.assertIn("held", adapter._chat_locks)
|
||||
self.assertNotIn("x", adapter._chat_locks)
|
||||
self.assertEqual(len(adapter._chat_locks), 3)
|
||||
finally:
|
||||
held.release()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
@@ -5,10 +5,6 @@ Verifies that MEDIA tags (e.g., from TTS tool) are only extracted from
|
||||
messages in the CURRENT turn, not from the full conversation history.
|
||||
This prevents voice messages from accumulating and being sent multiple
|
||||
times per reply. (Regression test for #160)
|
||||
|
||||
Also covers #34608: a stale MEDIA: path emitted by an execute_code /
|
||||
make_image tool several turns earlier must not leak onto a later
|
||||
text-only reply, even when the path-based dedup set fails to capture it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -47,37 +43,6 @@ def extract_media_tags_fixed(result_messages, history_len):
|
||||
return media_tags, has_voice_directive
|
||||
|
||||
|
||||
def extract_media_tags_production(result_messages, history_len, history_media_paths):
|
||||
"""Mirror of the production scan in gateway/run.py after the #34608 fix.
|
||||
|
||||
Primary guard: scope the scan to the current turn via ``history_len``
|
||||
slicing (matching how ``agent_history`` is passed as
|
||||
``conversation_history`` into ``run_conversation``). Secondary guard:
|
||||
path-based dedup against ``history_media_paths`` (the #160 compression-safe
|
||||
fallback, also used when compression shrinks the list below history_len).
|
||||
"""
|
||||
media_tags = []
|
||||
has_voice_directive = False
|
||||
|
||||
if len(result_messages) >= history_len and history_len:
|
||||
scan_msgs = result_messages[history_len:]
|
||||
else:
|
||||
scan_msgs = result_messages
|
||||
|
||||
for msg in scan_msgs:
|
||||
if msg.get("role") == "tool" or msg.get("role") == "function":
|
||||
content = msg.get("content", "")
|
||||
if "MEDIA:" in content:
|
||||
for match in re.finditer(r'MEDIA:(\S+)', content):
|
||||
path = match.group(1).strip().rstrip('",}')
|
||||
if path and path not in history_media_paths:
|
||||
media_tags.append(f"MEDIA:{path}")
|
||||
if "[[audio_as_voice]]" in content:
|
||||
has_voice_directive = True
|
||||
|
||||
return media_tags, has_voice_directive
|
||||
|
||||
|
||||
def extract_media_tags_broken(result_messages):
|
||||
"""
|
||||
The BROKEN behavior: extract MEDIA tags from ALL messages including history.
|
||||
@@ -215,104 +180,5 @@ class TestMediaExtraction:
|
||||
assert len(unique) == 2 # After dedup: same.ogg and different.ogg
|
||||
|
||||
|
||||
class TestStaleToolMediaLeak:
|
||||
"""Regression tests for #34608.
|
||||
|
||||
A MEDIA: path emitted by an execute_code / make_image tool several turns
|
||||
earlier remains in the full conversation message list. A later text-only
|
||||
reply (zero MEDIA directives) must NOT attach that stale image.
|
||||
|
||||
The production code previously relied solely on path-based dedup against
|
||||
paths reconstructed from the replayable transcript. When that
|
||||
reconstruction does not byte-match the in-memory tool content (timestamp
|
||||
stripping, observed-context withholding, compression rewrites), the stale
|
||||
path is absent from the dedup set and leaks. Turn-scoped slicing closes
|
||||
this class of bug deterministically.
|
||||
"""
|
||||
|
||||
def test_stale_execute_code_media_not_attached_to_text_only_reply(self):
|
||||
"""The exact #34608 scenario: make_image cover from an earlier turn."""
|
||||
# Prior turn generated an image via execute_code stdout.
|
||||
history = [
|
||||
{"role": "user", "content": "Make a cover image"},
|
||||
{"role": "assistant", "content": None,
|
||||
"tool_calls": [{"id": "1", "function": {"name": "execute_code"}}]},
|
||||
{"role": "tool", "tool_call_id": "1",
|
||||
"content": "Generating cover...\nMEDIA:/tmp/seosmi_cover.png\nDone."},
|
||||
{"role": "assistant", "content": "Here is your cover."},
|
||||
]
|
||||
# Current turn: plain text status update, zero MEDIA directives.
|
||||
new_messages = [
|
||||
{"role": "user", "content": "What skill version am I on?"},
|
||||
{"role": "assistant", "content": "You're on v0.15.1."},
|
||||
]
|
||||
all_messages = history + new_messages
|
||||
history_len = len(history)
|
||||
|
||||
# Simulate the dedup set FAILING to capture the stale path (the real
|
||||
# #34608 condition: replayable-history reconstruction diverged from
|
||||
# the in-memory tool content, so the path is not in the set).
|
||||
history_media_paths = set()
|
||||
|
||||
tags, voice = extract_media_tags_production(
|
||||
all_messages, history_len, history_media_paths
|
||||
)
|
||||
assert tags == [], (
|
||||
"Stale tool MEDIA from a prior turn must not leak onto a "
|
||||
f"later text-only reply, got {tags}"
|
||||
)
|
||||
assert voice is False
|
||||
|
||||
# The pre-fix production behaviour (scan everything, dedup only) would
|
||||
# have leaked the stale path when the dedup set missed it.
|
||||
broken_tags, _ = extract_media_tags_broken(all_messages)
|
||||
assert any("seosmi_cover.png" in t for t in broken_tags), (
|
||||
"Sanity: the unscoped scan does surface the stale path"
|
||||
)
|
||||
|
||||
def test_current_turn_media_still_attached_when_dedup_set_empty(self):
|
||||
"""Turn-scoping must not suppress genuinely new media."""
|
||||
history = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
]
|
||||
new_messages = [
|
||||
{"role": "user", "content": "Make me a cover image"},
|
||||
{"role": "assistant", "content": None,
|
||||
"tool_calls": [{"id": "9", "function": {"name": "execute_code"}}]},
|
||||
{"role": "tool", "tool_call_id": "9",
|
||||
"content": "MEDIA:/tmp/fresh_cover.png"},
|
||||
{"role": "assistant", "content": "Here it is."},
|
||||
]
|
||||
all_messages = history + new_messages
|
||||
tags, _ = extract_media_tags_production(
|
||||
all_messages, len(history), set()
|
||||
)
|
||||
assert len(tags) == 1 and "fresh_cover.png" in tags[0]
|
||||
|
||||
def test_compression_shrink_falls_back_to_path_dedup(self):
|
||||
"""When the list is shorter than history_len (mid-run compression),
|
||||
fall back to scanning everything with path-based dedup so the #160
|
||||
compression-safe guarantee is preserved."""
|
||||
# Post-compression list is shorter than the original history length.
|
||||
compressed_messages = [
|
||||
{"role": "user", "content": "summary so far..."},
|
||||
{"role": "tool", "tool_call_id": "7",
|
||||
"content": "MEDIA:/tmp/old_from_history.png"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
original_history_len = 12 # larger than the compressed list
|
||||
# The old path IS captured in the dedup set here (history scan ran
|
||||
# before compression), so it must still be excluded.
|
||||
history_media_paths = {"/tmp/old_from_history.png"}
|
||||
tags, _ = extract_media_tags_production(
|
||||
compressed_messages, original_history_len, history_media_paths
|
||||
)
|
||||
assert tags == [], (
|
||||
"On the compression fallback path, path-dedup must still exclude "
|
||||
f"known-old media, got {tags}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -486,22 +486,6 @@ class TestSend:
|
||||
call_headers = mock_client.post.call_args[1]["headers"]
|
||||
assert "X-Markdown" not in call_headers
|
||||
|
||||
def test_send_emits_echo_tag_header(self):
|
||||
"""Outgoing messages carry the echo-prevention tag so the adapter
|
||||
can recognise and skip its own replies when subscribe topic ==
|
||||
publish topic (the default config that causes the loop)."""
|
||||
adapter = self._make_adapter(topic="hermes-in")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"id": "abc123"}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
adapter._http_client = mock_client
|
||||
|
||||
_run(adapter.send("hermes-in", "Hello!"))
|
||||
call_headers = mock_client.post.call_args[1]["headers"]
|
||||
assert call_headers.get("X-Tags") == _ntfy._ECHO_TAG
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Inbound message processing (identity invariant — security-critical)
|
||||
@@ -559,47 +543,6 @@ class TestOnMessage:
|
||||
_run(adapter._on_message(event))
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_own_tagged_message_skipped(self):
|
||||
"""An incoming event carrying the adapter's echo tag is the agent's
|
||||
own reply echoed back by ntfy — it must not be dispatched, otherwise
|
||||
the agent replies to itself forever (issue #34447)."""
|
||||
adapter = self._make_adapter()
|
||||
calls = []
|
||||
|
||||
async def handler(event):
|
||||
calls.append(event)
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
_run(adapter._on_message({
|
||||
"id": "echo-1",
|
||||
"event": "message",
|
||||
"topic": "hermes-in",
|
||||
"message": "my own reply",
|
||||
"tags": [_ntfy._ECHO_TAG],
|
||||
"time": None,
|
||||
}))
|
||||
assert calls == []
|
||||
|
||||
def test_message_with_other_tags_still_dispatched(self):
|
||||
"""Tags unrelated to the echo sentinel must not suppress genuine
|
||||
user messages."""
|
||||
adapter = self._make_adapter()
|
||||
calls = []
|
||||
|
||||
async def handler(event):
|
||||
calls.append(event)
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
_run(adapter._on_message({
|
||||
"id": "user-1",
|
||||
"event": "message",
|
||||
"topic": "hermes-in",
|
||||
"message": "hello",
|
||||
"tags": ["warning", "skull"],
|
||||
"time": None,
|
||||
}))
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_timestamp_parsed_from_event(self):
|
||||
from datetime import timezone
|
||||
adapter = self._make_adapter()
|
||||
@@ -799,28 +742,6 @@ class TestStandaloneSend:
|
||||
posted_url = mock_client.post.call_args[0][0]
|
||||
assert posted_url == "https://ntfy.example.com/hermes-in"
|
||||
|
||||
def test_emits_echo_tag_header(self, monkeypatch):
|
||||
"""Out-of-process cron / send_message deliveries also carry the echo
|
||||
tag, so a gateway subscribed to the same topic skips them too."""
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
pconfig = MagicMock()
|
||||
pconfig.extra = {"topic": "hermes-in"}
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"id": "id-99"}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch.object(_ntfy, "httpx") as mock_httpx:
|
||||
mock_httpx.AsyncClient.return_value = mock_client
|
||||
_run(_standalone_send(pconfig, "hermes-in", "hi"))
|
||||
|
||||
headers = mock_client.post.call_args[1]["headers"]
|
||||
assert headers.get("X-Tags") == _ntfy._ECHO_TAG
|
||||
|
||||
def test_emits_bearer_token_when_configured(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
pconfig = MagicMock()
|
||||
|
||||
@@ -12,33 +12,12 @@ See issue #33778 for the original Windows session-loss bug report.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
from gateway.run import _run_planned_stop_watcher
|
||||
from gateway import status as status_mod
|
||||
|
||||
|
||||
def _write_self_marker(marker, *, stale: bool = False):
|
||||
"""Write a planned-stop marker that targets the CURRENT process.
|
||||
|
||||
The watcher only fires for markers naming our PID + start_time (the
|
||||
fix for issue #34597), so tests that expect a fire must write a
|
||||
self-targeting marker. Pass ``stale=True`` to backdate ``written_at``
|
||||
past the TTL.
|
||||
"""
|
||||
written_at = "2000-01-01T00:00:00+00:00" if stale else status_mod._utc_now_iso()
|
||||
record = {
|
||||
"target_pid": os.getpid(),
|
||||
"target_start_time": status_mod._get_process_start_time(os.getpid()),
|
||||
"stopper_pid": os.getpid(),
|
||||
"written_at": written_at,
|
||||
}
|
||||
marker.write_text(json.dumps(record), encoding="utf-8")
|
||||
|
||||
|
||||
class _FakeRunner:
|
||||
@@ -62,10 +41,11 @@ def _make_loop_capturing_calls():
|
||||
|
||||
|
||||
def test_watcher_fires_shutdown_when_marker_appears(tmp_path, monkeypatch):
|
||||
"""When a marker targeting THIS process exists, fire the shutdown handler."""
|
||||
"""When the marker file exists, the watcher must call the shutdown handler."""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
|
||||
# Patch the marker-path resolver so the watcher polls our temp location.
|
||||
from gateway import status as status_mod
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
runner = _FakeRunner(running=True, draining=False)
|
||||
@@ -73,8 +53,8 @@ def test_watcher_fires_shutdown_when_marker_appears(tmp_path, monkeypatch):
|
||||
shutdown_handler = MagicMock(name="shutdown_signal_handler")
|
||||
stop_event = threading.Event()
|
||||
|
||||
# Drop a self-targeting marker before the thread starts.
|
||||
_write_self_marker(marker)
|
||||
# Drop the marker before the thread starts.
|
||||
marker.write_text('{"target_pid": 1234}', encoding="utf-8")
|
||||
|
||||
watcher = threading.Thread(
|
||||
target=_run_planned_stop_watcher,
|
||||
@@ -134,8 +114,9 @@ def test_watcher_skips_when_runner_already_draining(tmp_path, monkeypatch):
|
||||
so the watcher backs off once any shutdown is in flight.
|
||||
"""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
_write_self_marker(marker)
|
||||
marker.write_text('{"target_pid": 1234}', encoding="utf-8")
|
||||
|
||||
from gateway import status as status_mod
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
# Already draining — watcher should be a no-op.
|
||||
@@ -223,8 +204,9 @@ def test_watcher_fires_only_once_when_marker_persists(tmp_path, monkeypatch):
|
||||
times before the gateway actually shuts down.
|
||||
"""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
_write_self_marker(marker)
|
||||
marker.write_text('{"target_pid": 1234}', encoding="utf-8")
|
||||
|
||||
from gateway import status as status_mod
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
runner = _FakeRunner(running=True, draining=False)
|
||||
@@ -281,113 +263,3 @@ def test_watcher_tolerates_marker_path_resolution_errors(tmp_path, monkeypatch,
|
||||
assert not watcher.is_alive(), "Watcher should still honour stop_event after errors"
|
||||
# No shutdown fired because the marker never reported existence.
|
||||
assert loop._captured == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression coverage for issue #34597:
|
||||
# A marker left behind by a PREVIOUS gateway instance (different PID, or
|
||||
# past its TTL) must NOT crash the freshly booted gateway. The watcher
|
||||
# only fires when the marker targets the current process, and self-heals
|
||||
# by cleaning up stale/malformed markers.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_watcher_does_not_fire_for_foreign_pid_marker(tmp_path, monkeypatch):
|
||||
"""A marker naming a DIFFERENT process must not trigger our shutdown.
|
||||
|
||||
This is the core #34597 regression: a stale marker from a prior
|
||||
gateway instance was firing the handler, driving the new gateway into
|
||||
a false "Received UNKNOWN" shutdown and a watchdog crash loop.
|
||||
"""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
# Foreign PID + a start_time that cannot match ours, freshly written
|
||||
# so the TTL does NOT remove it — the watcher must still decline.
|
||||
record = {
|
||||
"target_pid": os.getpid() + 1,
|
||||
"target_start_time": -1,
|
||||
"stopper_pid": os.getpid() + 1,
|
||||
"written_at": status_mod._utc_now_iso(),
|
||||
}
|
||||
marker.write_text(json.dumps(record), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
runner = _FakeRunner(running=True, draining=False)
|
||||
loop = _make_loop_capturing_calls()
|
||||
shutdown_handler = MagicMock(name="shutdown_signal_handler")
|
||||
stop_event = threading.Event()
|
||||
|
||||
watcher = threading.Thread(
|
||||
target=_run_planned_stop_watcher,
|
||||
args=(stop_event, runner, loop, shutdown_handler),
|
||||
kwargs={"poll_interval": 0.05},
|
||||
daemon=True,
|
||||
)
|
||||
watcher.start()
|
||||
time.sleep(0.3) # several poll cycles
|
||||
stop_event.set()
|
||||
watcher.join(timeout=2.0)
|
||||
|
||||
assert not watcher.is_alive()
|
||||
assert loop._captured == [], (
|
||||
f"Watcher fired on a foreign-PID marker (#34597 regression): {loop._captured}"
|
||||
)
|
||||
shutdown_handler.assert_not_called()
|
||||
# Foreign (but live) marker is left in place — it may still belong to
|
||||
# the process it names.
|
||||
assert marker.exists()
|
||||
|
||||
|
||||
def test_watcher_cleans_up_stale_marker_and_keeps_running(tmp_path, monkeypatch):
|
||||
"""A marker older than the TTL is unlinked and never fires shutdown."""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
# Self-targeting but backdated past the TTL: must be treated as dead.
|
||||
_write_self_marker(marker, stale=True)
|
||||
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
runner = _FakeRunner(running=True, draining=False)
|
||||
loop = _make_loop_capturing_calls()
|
||||
shutdown_handler = MagicMock(name="shutdown_signal_handler")
|
||||
stop_event = threading.Event()
|
||||
|
||||
watcher = threading.Thread(
|
||||
target=_run_planned_stop_watcher,
|
||||
args=(stop_event, runner, loop, shutdown_handler),
|
||||
kwargs={"poll_interval": 0.05},
|
||||
daemon=True,
|
||||
)
|
||||
watcher.start()
|
||||
time.sleep(0.3)
|
||||
stop_event.set()
|
||||
watcher.join(timeout=2.0)
|
||||
|
||||
assert not watcher.is_alive()
|
||||
assert loop._captured == [], "Stale marker must not fire shutdown"
|
||||
shutdown_handler.assert_not_called()
|
||||
assert not marker.exists(), "Stale marker should have been cleaned up"
|
||||
|
||||
|
||||
def test_planned_stop_marker_targets_self_probe_is_non_destructive(tmp_path, monkeypatch):
|
||||
"""The probe returns True for a self-marker WITHOUT unlinking it.
|
||||
|
||||
The shutdown handler performs the authoritative consume on its own
|
||||
thread, so the watcher's probe must leave a matching marker intact.
|
||||
"""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
_write_self_marker(marker)
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
assert status_mod.planned_stop_marker_targets_self() is True
|
||||
assert marker.exists(), "Probe must not consume a matching marker"
|
||||
# Idempotent: still True on a second call.
|
||||
assert status_mod.planned_stop_marker_targets_self() is True
|
||||
|
||||
|
||||
def test_planned_stop_marker_targets_self_drops_malformed(tmp_path, monkeypatch):
|
||||
"""A malformed marker reports False and is cleaned up."""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
marker.write_text("{not valid json", encoding="utf-8")
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
assert status_mod.planned_stop_marker_targets_self() is False
|
||||
|
||||
@@ -362,54 +362,6 @@ class TestExtractMedia:
|
||||
assert "[[as_document]]" not in cleaned
|
||||
|
||||
|
||||
class TestMediaExtensionAllowlistParity:
|
||||
"""Regression coverage for issue #34517 — the MEDIA: extension black hole.
|
||||
|
||||
extract_media used to carry a narrow extension allowlist that omitted
|
||||
.md/.json/.yaml/.xml/.html etc., while extract_local_files had a broad one.
|
||||
Combined with an unconditional ``MEDIA:\\s*\\S+`` strip at the dispatch
|
||||
sites, an unmatched MEDIA: tag for one of those extensions was deleted from
|
||||
the body before extract_local_files could pick up the bare path — the file
|
||||
was silently dropped. Both extractors now derive from the single
|
||||
MEDIA_DELIVERY_EXTS source of truth, and the strip is anchored to that set.
|
||||
"""
|
||||
|
||||
DROPPED_BEFORE = ["md", "json", "yaml", "yml", "xml", "html", "htm",
|
||||
"tsv", "svg"]
|
||||
|
||||
def test_previously_dropped_extensions_now_extract(self):
|
||||
for ext in self.DROPPED_BEFORE:
|
||||
path = f"/tmp/report.{ext}"
|
||||
media, _ = BasePlatformAdapter.extract_media(f"Here: MEDIA:{path}")
|
||||
assert media == [(path, False)], f".{ext} should extract via MEDIA:"
|
||||
|
||||
def test_extract_media_and_local_files_share_one_extension_set(self):
|
||||
from gateway.platforms.base import MEDIA_DELIVERY_EXTS
|
||||
# Both functions reference MEDIA_DELIVERY_EXTS; assert the documents
|
||||
# that motivated the bug are present in the shared set.
|
||||
for ext in (".md", ".json", ".yaml", ".yml", ".xml", ".html", ".htm"):
|
||||
assert ext in MEDIA_DELIVERY_EXTS
|
||||
|
||||
def test_unknown_extension_not_black_holed_by_cleanup(self):
|
||||
"""A MEDIA: tag with an unknown extension is NOT stripped from the
|
||||
body — it survives so extract_local_files can still see the bare path,
|
||||
rather than vanishing entirely (the core of issue #34517)."""
|
||||
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
|
||||
text = "Saved to MEDIA:/tmp/data.weirdext done"
|
||||
media, _ = BasePlatformAdapter.extract_media(text)
|
||||
assert media == [] # unknown extension is not a deliverable MEDIA tag
|
||||
stripped = MEDIA_TAG_CLEANUP_RE.sub("", text)
|
||||
assert "/tmp/data.weirdext" in stripped # path preserved, not dropped
|
||||
|
||||
def test_known_extension_tag_is_stripped_from_body(self):
|
||||
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
|
||||
text = "Here is your report: MEDIA:/tmp/report.md"
|
||||
stripped = MEDIA_TAG_CLEANUP_RE.sub("", text).strip()
|
||||
assert "MEDIA:" not in stripped
|
||||
assert "/tmp/report.md" not in stripped
|
||||
assert "Here is your report:" in stripped
|
||||
|
||||
|
||||
class TestMediaDeliveryPathValidation:
|
||||
def _patch_roots(self, monkeypatch, *roots):
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -268,75 +268,6 @@ async def test_session_chat_stream_emits_lifecycle_events_and_keepalive_safe_sha
|
||||
assert "event: done" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_chat_stream_run_completed_carries_turn_transcript(adapter, session_db):
|
||||
"""run.completed must include the full interleaved turn transcript so a
|
||||
client that lost intermediate (pre-tool-call) assistant text from the live
|
||||
delta stream can reconcile without a separate /messages fetch. Refs #34703.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
session_id = session_db.create_session("transcript-session", "api_server")
|
||||
|
||||
async def fake_run(**kwargs):
|
||||
# Stream the intermediate planning text the way a real turn would.
|
||||
kwargs["stream_delta_callback"]("Let me search for that:")
|
||||
kwargs["stream_delta_callback"]("Here is the summary.")
|
||||
result = {
|
||||
"final_response": "Here is the summary.",
|
||||
"session_id": session_id,
|
||||
"messages": [
|
||||
{"role": "user", "content": "search then summarize"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Let me search for that:",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "web_search", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "content": "results", "tool_call_id": "call_1", "tool_name": "web_search"},
|
||||
{"role": "assistant", "content": "Here is the summary."},
|
||||
],
|
||||
}
|
||||
return result, {"total_tokens": 6}
|
||||
|
||||
app = _create_session_app(adapter)
|
||||
with patch.object(adapter, "_run_agent", side_effect=fake_run):
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.post(
|
||||
f"/api/sessions/{session_id}/chat/stream",
|
||||
json={"message": "search then summarize"},
|
||||
)
|
||||
assert resp.status == 200
|
||||
body = await resp.text()
|
||||
|
||||
# Pull the run.completed event payload out of the SSE body.
|
||||
run_completed_payload = None
|
||||
for block in body.split("\n\n"):
|
||||
if "event: run.completed" in block:
|
||||
for line in block.splitlines():
|
||||
if line.startswith("data: "):
|
||||
run_completed_payload = _json.loads(line[len("data: "):])
|
||||
break
|
||||
assert run_completed_payload is not None, body
|
||||
messages = run_completed_payload.get("messages")
|
||||
assert isinstance(messages, list) and messages, run_completed_payload
|
||||
|
||||
# The colon-ended intermediate text that preceded the tool call must be present.
|
||||
contents = [m.get("content") for m in messages]
|
||||
assert "Let me search for that:" in contents
|
||||
assert "Here is the summary." in contents
|
||||
# No prior-turn user message should leak into the per-turn slice.
|
||||
assert all(m.get("role") in ("assistant", "tool") for m in messages)
|
||||
# The tool call is preserved alongside the intermediate text.
|
||||
assert any(m.get("tool_calls") for m in messages)
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_endpoints_require_auth_when_key_configured(auth_adapter):
|
||||
app = _create_session_app(auth_adapter)
|
||||
|
||||
@@ -707,33 +707,6 @@ class TestTakeoverMarker:
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_consume_returns_true_on_windows_when_start_time_unavailable(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Takeover consume must also recognise a self-marker on platforms
|
||||
without ``/proc`` (macOS / native Windows).
|
||||
|
||||
``consume_takeover_marker_for_self`` shares ``_consume_pid_marker_for_self``
|
||||
with the planned-stop path, so the same start_time fallback applies:
|
||||
a ``--replace`` SIGTERM on Windows (where start_time is None on both
|
||||
sides) must be recognised as a planned takeover and exit 0, not be
|
||||
misclassified as an unexpected UNKNOWN exit. With start_time
|
||||
unavailable we fall back to PID equality alone, bounded by the TTL.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# Simulate Windows: no start_time available for any PID.
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
|
||||
|
||||
ok = status.write_takeover_marker(target_pid=os.getpid())
|
||||
assert ok is True
|
||||
payload = json.loads((tmp_path / ".gateway-takeover.json").read_text())
|
||||
assert payload["target_start_time"] is None
|
||||
|
||||
result = status.consume_takeover_marker_for_self()
|
||||
|
||||
assert result is True
|
||||
assert not (tmp_path / ".gateway-takeover.json").exists()
|
||||
|
||||
def test_consume_returns_false_when_marker_missing(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
@@ -926,74 +899,6 @@ class TestPlannedStopMarker:
|
||||
|
||||
assert ok is False
|
||||
|
||||
def test_consume_returns_true_on_windows_when_start_time_unavailable(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Regression for #34597: a legitimate stop must be recognised on
|
||||
platforms without ``/proc``.
|
||||
|
||||
``_get_process_start_time`` returns None on macOS / native Windows
|
||||
(no ``/proc/<pid>/stat``). The planned-stop watcher only runs there,
|
||||
so if the authoritative consume required a non-None start_time match
|
||||
it would always return False — and ``hermes gateway stop`` would be
|
||||
misclassified as an unexpected ``UNKNOWN`` exit, exit 1, and revived
|
||||
by the service manager (the very crash loop #34597 set out to fix).
|
||||
With start_time unavailable on BOTH sides we fall back to PID
|
||||
equality alone, bounded by the marker TTL.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# Simulate Windows: no start_time available for any PID.
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
|
||||
|
||||
ok = status.write_planned_stop_marker(target_pid=os.getpid())
|
||||
assert ok is True
|
||||
# Marker carries a null start_time, exactly as written on Windows.
|
||||
payload = json.loads((tmp_path / ".gateway-planned-stop.json").read_text())
|
||||
assert payload["target_start_time"] is None
|
||||
|
||||
result = status.consume_planned_stop_marker_for_self()
|
||||
|
||||
assert result is True
|
||||
assert not (tmp_path / ".gateway-planned-stop.json").exists()
|
||||
|
||||
def test_consume_still_rejects_foreign_pid_when_start_time_unavailable(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""The PID-only fallback must NOT match a marker naming another PID.
|
||||
|
||||
Falling back to PID equality when start_time is unknown must remain
|
||||
a PID check — a marker for a different process is never ours.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
|
||||
|
||||
ok = status.write_planned_stop_marker(target_pid=os.getpid() + 9999)
|
||||
assert ok is True
|
||||
|
||||
result = status.consume_planned_stop_marker_for_self()
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_consume_still_rejects_start_time_mismatch_when_both_known(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""PID-reuse defence is preserved when BOTH start_times are present.
|
||||
|
||||
The Windows fallback only relaxes matching when a start_time is
|
||||
unavailable. When both sides report one (Linux), a mismatch must
|
||||
still reject — otherwise PID reuse could resurrect a stale marker.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 100)
|
||||
status.write_planned_stop_marker(target_pid=os.getpid())
|
||||
|
||||
# Simulate PID reuse: same PID, different start_time.
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 9999)
|
||||
|
||||
result = status.consume_planned_stop_marker_for_self()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestReadProcessCmdlinePsFallback:
|
||||
"""Tests for _read_process_cmdline falling back to ps on non-Linux."""
|
||||
|
||||
@@ -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 "**Cumulative API tokens (re-sent each call):** 321" in result
|
||||
assert "**Tokens:** 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 "**Cumulative API tokens (re-sent each call):** 1,900" in result
|
||||
assert "**Tokens:** 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 "**Cumulative API tokens (re-sent each call):** 0" in result
|
||||
assert "**Tokens:** 0" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1679,105 +1679,3 @@ class TestPreMigrationBackup:
|
||||
_t.sleep(1.05)
|
||||
# Update backup must still be there
|
||||
assert update_backup.exists(), "pre-migration rotation wrongly pruned the pre-update backup"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cron jobs auto-restore after silent migration loss (issue #34600)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRestoreCronJobsIfEmptied:
|
||||
"""`hermes update` config migration can leave cron/jobs.json valid-but-empty,
|
||||
silently dropping every scheduled job. `restore_cron_jobs_if_emptied` is the
|
||||
post-migration safety net that restores from the pre-update snapshot."""
|
||||
|
||||
@staticmethod
|
||||
def _seed_jobs(path: Path, jobs):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"jobs": jobs}))
|
||||
|
||||
def _make_snapshot(self, hermes_home: Path, label="pre-update"):
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
return create_quick_snapshot(label=label, hermes_home=hermes_home, keep=5)
|
||||
|
||||
def test_restores_when_emptied_after_migration(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
# Pre-update: 3 real jobs.
|
||||
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}, {"id": "c"}])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
assert snap_id
|
||||
|
||||
# Migration silently empties the file (valid JSON, zero jobs).
|
||||
jobs_path.write_text(json.dumps({"jobs": []}))
|
||||
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is not None
|
||||
assert result["restored"] is True
|
||||
assert result["job_count"] == 3
|
||||
assert result["snapshot_id"] == snap_id
|
||||
|
||||
# The live file now has the jobs back.
|
||||
restored = json.loads(jobs_path.read_text())
|
||||
assert len(restored["jobs"]) == 3
|
||||
|
||||
def test_noop_when_live_file_still_has_jobs(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
|
||||
# Healthy path: file unchanged after update.
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is None
|
||||
|
||||
def test_noop_when_snapshot_had_no_jobs(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
# Pre-update genuinely had zero jobs; current is also empty.
|
||||
self._seed_jobs(jobs_path, [])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
jobs_path.write_text(json.dumps({"jobs": []}))
|
||||
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is None
|
||||
|
||||
def test_noop_when_live_file_unreadable(self, tmp_path):
|
||||
"""An unparseable live file is left alone — that's a different failure
|
||||
mode the user should see, not silently overwrite."""
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
self._seed_jobs(jobs_path, [{"id": "a"}])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
jobs_path.write_text("{ this is not valid json")
|
||||
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is None
|
||||
# File left untouched.
|
||||
assert jobs_path.read_text() == "{ this is not valid json"
|
||||
|
||||
def test_noop_when_snapshot_id_missing(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
self._seed_jobs(jobs_path, [])
|
||||
assert restore_cron_jobs_if_emptied(None, hermes_home=hermes_home) is None
|
||||
assert restore_cron_jobs_if_emptied("", hermes_home=hermes_home) is None
|
||||
|
||||
def test_restores_legacy_bare_list_snapshot_shape(self, tmp_path):
|
||||
"""A legacy snapshot storing a bare JSON list (not {"jobs": [...]}) is
|
||||
still counted and restored."""
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
jobs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
jobs_path.write_text(json.dumps([{"id": "a"}, {"id": "b"}]))
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
|
||||
jobs_path.write_text(json.dumps({"jobs": []}))
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is not None
|
||||
assert result["job_count"] == 2
|
||||
|
||||
@@ -326,8 +326,6 @@ 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
|
||||
@@ -389,8 +387,6 @@ 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
|
||||
@@ -497,10 +493,7 @@ class TestLaunchdServiceRecovery:
|
||||
|
||||
label = gateway_cli.get_launchd_label()
|
||||
domain = gateway_cli._launchd_domain()
|
||||
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 "--replace" in plist_path.read_text(encoding="utf-8")
|
||||
assert calls[:2] == [
|
||||
["launchctl", "bootout", f"{domain}/{label}"],
|
||||
["launchctl", "bootstrap", domain, str(plist_path)],
|
||||
@@ -1623,8 +1616,7 @@ 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" in unit
|
||||
assert "--replace" not in unit
|
||||
assert "gateway run --replace" in unit
|
||||
|
||||
def test_launchd_plist_includes_profile(self, tmp_path, monkeypatch):
|
||||
"""generate_launchd_plist should include --profile in ProgramArguments for named profiles."""
|
||||
@@ -1636,24 +1628,6 @@ 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"
|
||||
@@ -1730,12 +1704,7 @@ class TestSystemUnitPathRemapping:
|
||||
assert str(root_home) not in unit
|
||||
# Target user paths should be present
|
||||
assert "/home/alice" in unit
|
||||
# WorkingDirectory is anchored at the target user's HERMES_HOME (stable,
|
||||
# always exists) — NOT the source checkout under it. Pinning cwd to the
|
||||
# checkout is the rot bug fixed alongside this: a relocated/removed
|
||||
# checkout would crash-loop the unit on CHDIR (status=200).
|
||||
assert "WorkingDirectory=/home/alice/.hermes" in unit
|
||||
assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" not in unit
|
||||
assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" in unit
|
||||
|
||||
|
||||
class TestDockerAwareGateway:
|
||||
@@ -2562,46 +2531,3 @@ class TestGatewayCommandCatchesSystemScopeError:
|
||||
# Renders the message, NOT the ``('msg', 'action')`` tuple repr
|
||||
assert "System gateway start requires root. Re-run with sudo." in out
|
||||
assert "('" not in out # no tuple repr leaking through
|
||||
|
||||
|
||||
class TestServiceWorkingDirIsStable:
|
||||
"""The gateway service must anchor WorkingDirectory at a stable path
|
||||
(HERMES_HOME), never the source checkout / worktree, so a relocated or
|
||||
deleted checkout can't crash-loop the unit on CHDIR (status=200).
|
||||
"""
|
||||
|
||||
def test_stable_working_dir_uses_hermes_home(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
|
||||
assert Path(gateway_cli._stable_service_working_dir()) == home.resolve()
|
||||
|
||||
def test_stable_working_dir_falls_back_to_project_root(self, tmp_path, monkeypatch):
|
||||
# HERMES_HOME points somewhere that does not exist -> fall back.
|
||||
missing = tmp_path / "does-not-exist" / ".hermes"
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: missing)
|
||||
assert gateway_cli._stable_service_working_dir() == str(gateway_cli.PROJECT_ROOT)
|
||||
|
||||
def test_user_unit_workingdirectory_is_hermes_home_not_checkout(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
|
||||
unit = gateway_cli.generate_systemd_unit(system=False)
|
||||
wd = [l for l in unit.splitlines() if l.startswith("WorkingDirectory=")]
|
||||
assert wd, "unit has no WorkingDirectory line"
|
||||
value = wd[0].split("=", 1)[1]
|
||||
assert Path(value).resolve() == home.resolve()
|
||||
# The bug class: never pin cwd inside a transient worktree checkout.
|
||||
assert "/.worktrees/" not in value
|
||||
|
||||
def test_launchd_workingdirectory_is_hermes_home(self, tmp_path, monkeypatch):
|
||||
import re
|
||||
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
m = re.search(r"<key>WorkingDirectory</key>\s*<string>(.*?)</string>", plist)
|
||||
assert m, "plist has no WorkingDirectory entry"
|
||||
assert Path(m.group(1)).resolve() == home.resolve()
|
||||
assert "/.worktrees/" not in m.group(1)
|
||||
|
||||
@@ -595,58 +595,3 @@ class TestMcpLogin:
|
||||
out = capsys.readouterr().out
|
||||
assert "no URL" in out or "not an OAuth" in out
|
||||
|
||||
def test_login_false_success_no_token(self, tmp_path, capsys, monkeypatch):
|
||||
"""Probe lists tools without auth (Google Drive), but no token landed.
|
||||
|
||||
The server allows tools/list without auth (DCR 400'd), so the probe
|
||||
succeeds yet no OAuth token exists. Login must NOT claim success — it
|
||||
should warn and point the user at pre-registered client_id config.
|
||||
"""
|
||||
_seed_config(tmp_path, {
|
||||
"googledrive": {
|
||||
"url": "https://drivemcp.googleapis.com/mcp/v1",
|
||||
"auth": "oauth",
|
||||
},
|
||||
})
|
||||
# Probe returns tools even though auth never completed.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.mcp_config._probe_single_server",
|
||||
lambda name, cfg: [("search_files", "d"), ("read_file_content", "d")],
|
||||
)
|
||||
# No token file is created → _oauth_tokens_present() returns False.
|
||||
from hermes_cli.mcp_config import cmd_mcp_login
|
||||
|
||||
cmd_mcp_login(_make_args(name="googledrive"))
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "no OAuth token was obtained" in out
|
||||
assert "Authenticated" not in out
|
||||
assert "client_id" in out
|
||||
|
||||
def test_login_genuine_success_with_token(self, tmp_path, capsys, monkeypatch):
|
||||
"""Probe lists tools AND a token exists → report real success."""
|
||||
_seed_config(tmp_path, {
|
||||
"realserver": {"url": "https://mcp.example.com/mcp", "auth": "oauth"},
|
||||
})
|
||||
token_dir = tmp_path / "mcp-tokens"
|
||||
|
||||
# cmd_mcp_login wipes tokens before probing, then the real OAuth flow
|
||||
# writes a fresh token during the probe. Simulate that: the mocked
|
||||
# probe drops a token file, mirroring a successful authorization.
|
||||
def mock_probe(name, cfg):
|
||||
token_dir.mkdir(exist_ok=True)
|
||||
(token_dir / "realserver.json").write_text('{"access_token": "x"}')
|
||||
return [("a", "d"), ("b", "d"), ("c", "d")]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.mcp_config._probe_single_server", mock_probe
|
||||
)
|
||||
|
||||
from hermes_cli.mcp_config import cmd_mcp_login
|
||||
|
||||
cmd_mcp_login(_make_args(name="realserver"))
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "Authenticated — 3 tool(s) available" in out
|
||||
assert "no OAuth token" not in out
|
||||
|
||||
|
||||
@@ -403,44 +403,6 @@ def test_list_authenticated_providers_same_url_different_keys_disambiguated(monk
|
||||
assert models["custom:openai-2"] == ["gpt-4.6"]
|
||||
|
||||
|
||||
def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch):
|
||||
"""Same gateway host but different key_env/api_mode entries are distinct providers."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="custom:gpt",
|
||||
current_base_url="https://gateway.example.com",
|
||||
user_providers={},
|
||||
custom_providers=[
|
||||
{
|
||||
"name": "gpt",
|
||||
"base_url": "https://gateway.example.com",
|
||||
"key_env": "GPT_KEY",
|
||||
"api_mode": "codex_responses",
|
||||
"model": "gpt-5.5",
|
||||
},
|
||||
{
|
||||
"name": "claude",
|
||||
"base_url": "https://gateway.example.com",
|
||||
"key_env": "CLAUDE_KEY",
|
||||
"api_mode": "anthropic_messages",
|
||||
"model": "claude-opus-4-8",
|
||||
},
|
||||
],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
custom = [p for p in providers if p.get("is_user_defined")]
|
||||
by_slug = {p["slug"]: p for p in custom}
|
||||
|
||||
assert set(by_slug) == {"custom:gpt", "custom:claude"}
|
||||
assert by_slug["custom:gpt"]["models"] == ["gpt-5.5"]
|
||||
assert by_slug["custom:claude"]["models"] == ["claude-opus-4-8"]
|
||||
assert by_slug["custom:gpt"]["is_current"] is True
|
||||
assert by_slug["custom:claude"]["is_current"] is False
|
||||
|
||||
|
||||
def test_list_authenticated_providers_total_models_reflects_grouped_count(monkeypatch):
|
||||
"""After grouping six entries into one row, total_models must reflect
|
||||
the full count, and every grouped model appears in the list."""
|
||||
|
||||
@@ -218,7 +218,7 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"_get_gateway_direct_credentials",
|
||||
lambda: {"web": True, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
|
||||
lambda: {"web": True, "image_gen": False, "tts": False, "browser": False},
|
||||
)
|
||||
|
||||
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
|
||||
@@ -230,4 +230,4 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
|
||||
|
||||
assert "web" in has_direct
|
||||
assert "web" not in already_managed
|
||||
assert set(unconfigured) == {"image_gen", "video_gen", "tts", "browser"}
|
||||
assert set(unconfigured) == {"image_gen", "tts", "browser"}
|
||||
|
||||
@@ -59,53 +59,3 @@ 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
|
||||
|
||||
@@ -600,114 +600,6 @@ class TestAliasCollision:
|
||||
assert result is not None
|
||||
assert "reserved" in result.lower()
|
||||
|
||||
def test_uses_where_on_windows(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
||||
check_alias_collision("mybot")
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert call_args[0] == "where"
|
||||
|
||||
def test_uses_which_on_posix(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
||||
check_alias_collision("mybot")
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert call_args[0] == "which"
|
||||
|
||||
def test_windows_checks_bat_extension(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
wrapper_dir = profile_env / ".local" / "bin"
|
||||
wrapper_dir.mkdir(parents=True, exist_ok=True)
|
||||
bat_path = wrapper_dir / "mybot.bat"
|
||||
bat_path.write_text("@echo off\r\nhermes -p mybot %*\r\n")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0, stdout=str(bat_path),
|
||||
)
|
||||
result = check_alias_collision("mybot")
|
||||
assert result is None # our own wrapper, safe to overwrite
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestWrapperScript
|
||||
# ===================================================================
|
||||
|
||||
class TestWrapperScript:
|
||||
"""Tests for create_wrapper_script() and remove_wrapper_script()."""
|
||||
|
||||
def test_creates_sh_on_posix(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
from hermes_cli.profiles import create_wrapper_script
|
||||
wrapper = create_wrapper_script("mybot")
|
||||
assert wrapper is not None
|
||||
assert wrapper.name == "mybot"
|
||||
content = wrapper.read_text()
|
||||
assert content.startswith("#!/bin/sh")
|
||||
assert "hermes -p mybot" in content
|
||||
|
||||
def test_creates_bat_on_windows(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
from hermes_cli.profiles import create_wrapper_script
|
||||
wrapper = create_wrapper_script("mybot")
|
||||
assert wrapper is not None
|
||||
assert wrapper.name == "mybot.bat"
|
||||
content = wrapper.read_text()
|
||||
assert "@echo off" in content
|
||||
assert "hermes -p mybot" in content
|
||||
assert "%*" in content
|
||||
|
||||
def test_remove_finds_bat_on_windows(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
from hermes_cli.profiles import create_wrapper_script, remove_wrapper_script
|
||||
wrapper = create_wrapper_script("mybot")
|
||||
assert wrapper is not None
|
||||
assert wrapper.exists()
|
||||
removed = remove_wrapper_script("mybot")
|
||||
assert removed is True
|
||||
assert not wrapper.exists()
|
||||
|
||||
def test_remove_finds_sh_on_posix(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
from hermes_cli.profiles import create_wrapper_script, remove_wrapper_script
|
||||
wrapper = create_wrapper_script("mybot")
|
||||
assert wrapper is not None
|
||||
assert wrapper.exists()
|
||||
removed = remove_wrapper_script("mybot")
|
||||
assert removed is True
|
||||
assert not wrapper.exists()
|
||||
|
||||
def test_remove_returns_false_when_absent(self, profile_env):
|
||||
from hermes_cli.profiles import remove_wrapper_script
|
||||
assert remove_wrapper_script("nonexistent") is False
|
||||
|
||||
def test_custom_alias_target_on_posix(self, profile_env, monkeypatch):
|
||||
# Custom alias name pointing at a differently-named profile: the file
|
||||
# is named after the alias, the -p content references the profile.
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
from hermes_cli.profiles import create_wrapper_script
|
||||
wrapper = create_wrapper_script("rq", target="redqueen")
|
||||
assert wrapper is not None
|
||||
assert wrapper.name == "rq"
|
||||
content = wrapper.read_text()
|
||||
assert content.startswith("#!/bin/sh")
|
||||
assert "hermes -p redqueen" in content
|
||||
|
||||
def test_custom_alias_target_on_windows(self, profile_env, monkeypatch):
|
||||
# Regression: custom-name aliases must still produce an executable
|
||||
# .bat (not a clobbered #!/bin/sh) on Windows.
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
from hermes_cli.profiles import create_wrapper_script
|
||||
wrapper = create_wrapper_script("rq", target="redqueen")
|
||||
assert wrapper is not None
|
||||
assert wrapper.name == "rq.bat"
|
||||
content = wrapper.read_text()
|
||||
assert "@echo off" in content
|
||||
assert "hermes -p redqueen" in content
|
||||
assert "%*" in content
|
||||
assert "#!/bin/sh" not in content
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestRenameProfile
|
||||
|
||||
@@ -793,54 +793,6 @@ def test_named_custom_provider_uses_key_env_from_providers_dict(monkeypatch):
|
||||
assert resolved["model"] == "acme-large"
|
||||
|
||||
|
||||
def test_named_custom_provider_same_url_uses_matching_key_env_and_api_mode(monkeypatch):
|
||||
"""Named custom providers on one gateway must keep their own credentials and protocol."""
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.setenv("GPT_KEY", "gpt-secret")
|
||||
monkeypatch.setenv("CLAUDE_KEY", "claude-secret")
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "gpt",
|
||||
"base_url": "https://gateway.example.com",
|
||||
"key_env": "GPT_KEY",
|
||||
"api_mode": "codex_responses",
|
||||
"model": "gpt-5.5",
|
||||
},
|
||||
{
|
||||
"name": "claude",
|
||||
"base_url": "https://gateway.example.com",
|
||||
"key_env": "CLAUDE_KEY",
|
||||
"api_mode": "anthropic_messages",
|
||||
"model": "claude-opus-4-8",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"resolve_provider",
|
||||
lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError(
|
||||
"resolve_provider should not be called for named custom providers"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="custom:claude")
|
||||
|
||||
assert resolved["provider"] == "custom"
|
||||
assert resolved["base_url"] == "https://gateway.example.com"
|
||||
assert resolved["api_key"] == "claude-secret"
|
||||
assert resolved["api_mode"] == "anthropic_messages"
|
||||
assert resolved["requested_provider"] == "custom:claude"
|
||||
assert resolved["model"] == "claude-opus-4-8"
|
||||
|
||||
|
||||
def test_named_custom_provider_falls_back_to_openai_api_key(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-key")
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
||||
@@ -498,7 +498,6 @@ def test_setup_summary_shows_camofox_when_browser_feature_is_camofox(tmp_path, m
|
||||
features={
|
||||
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
|
||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
|
||||
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
|
||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
|
||||
"browser": NousFeatureState("browser", "Browser automation", True, True, True, False, True, True, "Camofox"),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"),
|
||||
@@ -526,7 +525,6 @@ def test_setup_summary_does_not_mark_incomplete_browserbase_as_available(tmp_pat
|
||||
features={
|
||||
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
|
||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
|
||||
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
|
||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
|
||||
"browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, "Browserbase"),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"),
|
||||
|
||||
@@ -88,7 +88,6 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path
|
||||
features={
|
||||
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
|
||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
|
||||
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
|
||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
|
||||
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
"""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,7 +19,6 @@ 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"
|
||||
@@ -27,7 +26,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, "ver": __version__}))
|
||||
cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3}))
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
||||
@@ -37,43 +36,6 @@ 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
|
||||
|
||||
@@ -129,40 +129,12 @@ class TestGuessCategory:
|
||||
|
||||
def test_cron_subtree_categorised(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
# Only files under ``cron/output/`` are disposable run artifacts.
|
||||
output_dir = _isolate_env / "cron" / "output" / "job_123"
|
||||
output_dir.mkdir(parents=True)
|
||||
p = output_dir / "run.md"
|
||||
cron_dir = _isolate_env / "cron"
|
||||
cron_dir.mkdir()
|
||||
p = cron_dir / "job_output.md"
|
||||
p.write_text("x")
|
||||
assert dg.guess_category(p) == "cron-output"
|
||||
|
||||
def test_cron_jobs_json_not_tracked(self, _isolate_env):
|
||||
"""Regression for #32164: the cron registry must never be tracked."""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cron"
|
||||
cron_dir.mkdir()
|
||||
p = cron_dir / "jobs.json"
|
||||
p.write_text("[]")
|
||||
assert dg.guess_category(p) is None
|
||||
|
||||
def test_cron_tick_lock_not_tracked(self, _isolate_env):
|
||||
"""Regression for #32164: cron tick-lock is control-plane state."""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cron"
|
||||
cron_dir.mkdir()
|
||||
p = cron_dir / ".tick.lock"
|
||||
p.write_text("")
|
||||
assert dg.guess_category(p) is None
|
||||
|
||||
def test_cronjobs_top_level_not_tracked(self, _isolate_env):
|
||||
"""The legacy ``cronjobs`` alias is also control-plane at the top."""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cronjobs"
|
||||
cron_dir.mkdir()
|
||||
p = cron_dir / "jobs.json"
|
||||
p.write_text("[]")
|
||||
assert dg.guess_category(p) is None
|
||||
|
||||
def test_ordinary_file_returns_none(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
p = _isolate_env / "notes.md"
|
||||
|
||||
@@ -85,72 +85,44 @@ def test_fal_list_models_advertises_both_modalities():
|
||||
|
||||
def test_fal_unavailable_without_key(monkeypatch):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
from plugins.video_gen import fal as fal_plugin
|
||||
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
# Also ensure managed gateway is unavailable
|
||||
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
|
||||
assert FALVideoGenProvider().is_available() is False
|
||||
|
||||
|
||||
def test_fal_generate_requires_fal_key(monkeypatch):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
from plugins.video_gen import fal as fal_plugin
|
||||
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
# Also ensure managed gateway is unavailable
|
||||
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
|
||||
result = FALVideoGenProvider().generate("a happy dog")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "auth_required"
|
||||
|
||||
|
||||
def test_fal_available_via_gateway(monkeypatch):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
from plugins.video_gen import fal as fal_plugin
|
||||
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
fal_plugin,
|
||||
"_resolve_managed_fal_video_gateway",
|
||||
lambda: object(), # truthy sentinel — gateway is available
|
||||
)
|
||||
assert FALVideoGenProvider().is_available() is True
|
||||
|
||||
|
||||
class TestFamilyRouting:
|
||||
"""The headline behavior: image_url presence picks the endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def with_fake_fal(self, monkeypatch):
|
||||
"""Stub fal_client.submit to capture which endpoint we hit."""
|
||||
"""Stub fal_client.subscribe to capture which endpoint we hit."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
captured = {"endpoint": None, "arguments": None}
|
||||
|
||||
class FakeHandle:
|
||||
def get(self):
|
||||
return {"video": {"url": "https://fake/out.mp4"}}
|
||||
|
||||
fake = types.ModuleType("fal_client")
|
||||
def _submit(endpoint, arguments=None, headers=None):
|
||||
def _subscribe(endpoint, arguments=None, with_logs=False):
|
||||
captured["endpoint"] = endpoint
|
||||
captured["arguments"] = arguments
|
||||
return FakeHandle()
|
||||
fake.submit = _submit # type: ignore
|
||||
return {"video": {"url": "https://fake/out.mp4"}}
|
||||
fake.subscribe = _subscribe # type: ignore
|
||||
monkeypatch.setitem(sys.modules, "fal_client", fake)
|
||||
|
||||
# Reset the lazy global so it picks up our stub
|
||||
from plugins.video_gen import fal as fal_plugin
|
||||
fal_plugin._fal_client = None
|
||||
# Also reset the managed client cache
|
||||
fal_plugin._managed_fal_video_client = None
|
||||
fal_plugin._managed_fal_video_client_config = None
|
||||
|
||||
monkeypatch.setenv("FAL_KEY", "test")
|
||||
# Force direct mode — no managed gateway
|
||||
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
|
||||
return captured
|
||||
|
||||
def test_text_to_video_routes_to_text_endpoint(self, with_fake_fal):
|
||||
@@ -257,7 +229,7 @@ class TestPayloadBuilder:
|
||||
seed=42,
|
||||
)
|
||||
assert p["prompt"] == "x"
|
||||
assert p["duration"] == "8s" # veo3.1 uses "Ns" format per FAL API
|
||||
assert p["duration"] == "8" # FAL queue API uses strings
|
||||
assert p["aspect_ratio"] == "16:9"
|
||||
assert p["resolution"] == "720p"
|
||||
assert p["generate_audio"] is True
|
||||
|
||||
@@ -491,96 +491,6 @@ 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
|
||||
@@ -665,74 +575,6 @@ 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,9 +70,4 @@ def test_tool_call_validation_accepts_dict_arguments(monkeypatch):
|
||||
|
||||
result = agent.run_conversation("read the file")
|
||||
|
||||
# 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")
|
||||
assert result["final_response"] == "done"
|
||||
|
||||
@@ -3046,11 +3046,7 @@ class TestRunConversation:
|
||||
|
||||
mock_compress.assert_not_called() # no compression triggered
|
||||
assert result["completed"] is True
|
||||
# #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["final_response"] == "(empty)"
|
||||
assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries
|
||||
|
||||
def test_reasoning_only_response_prefill_then_empty(self, agent):
|
||||
@@ -3070,9 +3066,7 @@ class TestRunConversation:
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
assert result["completed"] is True
|
||||
# #34452: explanation replaces the bare "(empty)" sentinel.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
assert result["final_response"] == "(empty)"
|
||||
assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries
|
||||
|
||||
def test_reasoning_only_prefill_succeeds_on_continuation(self, agent):
|
||||
@@ -3119,9 +3113,7 @@ class TestRunConversation:
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
assert result["completed"] is True
|
||||
# #34452: explanation replaces the bare "(empty)" sentinel.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
assert result["final_response"] == "(empty)"
|
||||
assert result["api_calls"] == 4 # 1 original + 3 retries
|
||||
|
||||
def test_truly_empty_response_succeeds_on_nudge(self, agent):
|
||||
@@ -3217,9 +3209,7 @@ class TestRunConversation:
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
assert result["completed"] is True
|
||||
# #34452: explanation replaces the bare "(empty)" sentinel.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
assert result["final_response"] == "(empty)"
|
||||
|
||||
def test_empty_response_emits_status_for_gateway(self, agent):
|
||||
"""_emit_status is called during empty retries so gateway users see feedback."""
|
||||
@@ -3245,10 +3235,7 @@ class TestRunConversation:
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
|
||||
# #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"]
|
||||
assert result["final_response"] == "(empty)"
|
||||
# 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}"
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"""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"]
|
||||
@@ -2676,64 +2676,6 @@ class TestVacuum:
|
||||
db.vacuum()
|
||||
|
||||
|
||||
class TestOptimizeFts:
|
||||
def test_optimize_returns_index_count(self, db):
|
||||
"""A fresh DB has both FTS indexes; optimize merges both."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message(session_id="s1", role="user", content="hello world")
|
||||
assert db.optimize_fts() == 2
|
||||
|
||||
def test_optimize_preserves_search_and_snippet(self, db):
|
||||
"""Optimize is layout-only: MATCH results + snippets are unchanged."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
for i in range(50):
|
||||
db.append_message(
|
||||
session_id="s1",
|
||||
role="user",
|
||||
content=f"needle alpha bravo charlie message {i}",
|
||||
)
|
||||
before = db.search_messages("needle")
|
||||
n = db.optimize_fts()
|
||||
assert n == 2
|
||||
after = db.search_messages("needle")
|
||||
assert len(after) == len(before)
|
||||
assert len(after) > 0
|
||||
# Snippet must still be populated (would be empty/None if the FTS
|
||||
# content shadow were lost during optimize).
|
||||
assert all(row.get("snippet") for row in after)
|
||||
# IDs and snippets are identical before/after — pure layout change.
|
||||
assert [r["id"] for r in after] == [r["id"] for r in before]
|
||||
assert [r["snippet"] for r in after] == [r["snippet"] for r in before]
|
||||
|
||||
def test_optimize_skips_missing_trigram_table(self, db):
|
||||
"""When the trigram index is absent, optimize handles only the porter
|
||||
index and does not raise."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message(session_id="s1", role="user", content="hello")
|
||||
# Drop the trigram table + triggers to simulate a disabled/absent index.
|
||||
with db._lock:
|
||||
for trig in (
|
||||
"messages_fts_trigram_insert",
|
||||
"messages_fts_trigram_delete",
|
||||
"messages_fts_trigram_update",
|
||||
):
|
||||
db._conn.execute(f"DROP TRIGGER IF EXISTS {trig}")
|
||||
db._conn.execute("DROP TABLE IF EXISTS messages_fts_trigram")
|
||||
assert db._fts_table_exists("messages_fts_trigram") is False
|
||||
assert db._fts_table_exists("messages_fts") is True
|
||||
# Only the porter index remains -> 1 optimized, no error.
|
||||
assert db.optimize_fts() == 1
|
||||
|
||||
def test_optimize_idempotent(self, db):
|
||||
"""Running optimize twice is safe (second pass is a no-op merge)."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message(session_id="s1", role="user", content="repeat me")
|
||||
assert db.optimize_fts() == 2
|
||||
assert db.optimize_fts() == 2
|
||||
# Search still works after repeated optimization.
|
||||
assert len(db.search_messages("repeat")) == 1
|
||||
|
||||
|
||||
class TestAutoMaintenance:
|
||||
def _make_old_ended(self, db, sid: str, days_old: int = 100):
|
||||
"""Create a session that is ended and was started `days_old` days ago."""
|
||||
|
||||
@@ -1,66 +1,10 @@
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
|
||||
# setuptools is declared in the [dev] extra and is the build backend, but
|
||||
# guard the import so a runner without it skips these packaging checks
|
||||
# instead of erroring out collection for the whole shard (it used to be
|
||||
# picked up ambiently from the CI image; newer ubuntu-latest images don't
|
||||
# ship it in the test venv).
|
||||
find_packages = pytest.importorskip("setuptools", exc_type=ImportError).find_packages
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _packages_find_include():
|
||||
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
return data["tool"]["setuptools"]["packages"]["find"]["include"]
|
||||
|
||||
|
||||
def test_every_on_disk_subpackage_is_covered_by_packages_find():
|
||||
"""Regression test for #34701 (and the bug class behind #34034 / #28149).
|
||||
|
||||
``[tool.setuptools.packages.find]`` ``include`` is hand-maintained. Every
|
||||
top-level package is listed twice — bare (``hermes_cli``) for the package
|
||||
itself and ``hermes_cli.*`` for its subpackages — EXCEPT when someone
|
||||
forgets the wildcard. v0.15.x listed ``hermes_cli`` without ``hermes_cli.*``,
|
||||
so the wheel shipped ``hermes_cli/*.py`` but dropped the ``dashboard_auth``
|
||||
and ``proxy`` subpackages. The dashboard then died on every install with
|
||||
``ModuleNotFoundError: No module named 'hermes_cli.dashboard_auth'``.
|
||||
|
||||
This drives setuptools' own discovery against the live tree: every package
|
||||
that exists on disk and would be found by a permissive ``<name>.*`` scan
|
||||
must also be found by the actual ``include`` list. A subpackage added under
|
||||
any listed package without the matching wildcard fails here instead of in a
|
||||
user's container.
|
||||
"""
|
||||
include = _packages_find_include()
|
||||
|
||||
# What the real include list actually selects.
|
||||
selected = set(find_packages(where=str(REPO_ROOT), include=include))
|
||||
|
||||
# Top-level packages we ship (bare names in the include list, no wildcard).
|
||||
top_level = sorted({name for name in include if "." not in name})
|
||||
|
||||
# For each shipped top-level package, every on-disk subpackage must be
|
||||
# covered by the include list.
|
||||
expected = set(
|
||||
find_packages(
|
||||
where=str(REPO_ROOT),
|
||||
include=[pattern for name in top_level for pattern in (name, f"{name}.*")],
|
||||
)
|
||||
)
|
||||
|
||||
missing = sorted(expected - selected)
|
||||
assert not missing, (
|
||||
"These packages exist on disk but are dropped from the wheel because "
|
||||
"[tool.setuptools.packages.find] include is missing a wildcard. Add the "
|
||||
f"matching '<name>.*' entry in pyproject.toml: {missing}"
|
||||
)
|
||||
|
||||
|
||||
def test_faster_whisper_is_not_a_base_dependency():
|
||||
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
deps = data["project"]["dependencies"]
|
||||
|
||||
@@ -73,7 +73,6 @@ def test_lazy_installable_extras_excluded_from_all():
|
||||
"modal", "daytona",
|
||||
"messaging", "slack", "matrix", "dingtalk", "feishu",
|
||||
"honcho", "hindsight",
|
||||
"mistral", # mistralai — Voxtral STT/TTS, lazy-installed (stt.mistral / tts.mistral)
|
||||
}
|
||||
all_extra_specs = optional_dependencies["all"]
|
||||
for extra in lazy_covered_extras:
|
||||
|
||||
@@ -5114,8 +5114,6 @@ 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 = []
|
||||
@@ -5124,13 +5122,8 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
server._sessions["sid_busy"] = sess
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
|
||||
|
||||
# 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)
|
||||
while not process_registry.completion_queue.empty():
|
||||
process_registry.completion_queue.get_nowait()
|
||||
process_registry._completion_consumed.discard("proc_busy_test")
|
||||
|
||||
evt = {
|
||||
@@ -5140,7 +5133,7 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
"exit_code": 0,
|
||||
"output": "ok",
|
||||
}
|
||||
isolated_queue.put(evt)
|
||||
process_registry.completion_queue.put(evt)
|
||||
|
||||
stop = threading.Event()
|
||||
stop.set()
|
||||
@@ -5153,8 +5146,10 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
assert len(status_calls) == 1
|
||||
|
||||
# Event was requeued (agent was busy, no turn triggered)
|
||||
assert not isolated_queue.empty()
|
||||
requeued = isolated_queue.get_nowait()
|
||||
assert not process_registry.completion_queue.empty()
|
||||
requeued = process_registry.completion_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()
|
||||
|
||||
@@ -305,214 +305,3 @@ def test_transcription_uses_model_specific_response_formats(monkeypatch, tmp_pat
|
||||
assert json_result["transcript"] == "hello from gpt-4o"
|
||||
assert json_capture["transcription_kwargs"]["response_format"] == "json"
|
||||
assert json_capture["close_calls"] == 1
|
||||
|
||||
|
||||
PLUGINS_DIR = Path(__file__).resolve().parents[2] / "plugins"
|
||||
|
||||
|
||||
def _load_video_gen_plugin(monkeypatch):
|
||||
"""Load the FAL video gen plugin in isolation."""
|
||||
_install_fake_tools_package()
|
||||
|
||||
# Also need the agent.video_gen_provider ABC
|
||||
agent_dir = Path(__file__).resolve().parents[2] / "agent"
|
||||
spec = spec_from_file_location(
|
||||
"agent.video_gen_provider",
|
||||
agent_dir / "video_gen_provider.py",
|
||||
)
|
||||
assert spec and spec.loader
|
||||
mod = module_from_spec(spec)
|
||||
sys.modules["agent.video_gen_provider"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
# Load the plugin
|
||||
plugin_init = PLUGINS_DIR / "video_gen" / "fal" / "__init__.py"
|
||||
spec = spec_from_file_location("plugins.video_gen.fal", plugin_init)
|
||||
assert spec and spec.loader
|
||||
plugin_mod = module_from_spec(spec)
|
||||
sys.modules["plugins.video_gen.fal"] = plugin_mod
|
||||
spec.loader.exec_module(plugin_mod)
|
||||
return plugin_mod
|
||||
|
||||
|
||||
def test_video_gen_managed_fal_submit_uses_gateway(monkeypatch):
|
||||
"""Video gen routes through the managed gateway when FAL_KEY is absent."""
|
||||
captured = {}
|
||||
fake_fal = _install_fake_fal_client(captured)
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
|
||||
|
||||
plugin = _load_video_gen_plugin(monkeypatch)
|
||||
|
||||
# Patch uuid for deterministic idempotency key
|
||||
monkeypatch.setattr(plugin.uuid, "uuid4", lambda: "video-submit-456")
|
||||
|
||||
plugin._submit_fal_video_request(
|
||||
"fal-ai/pixverse/v6/text-to-video",
|
||||
{"prompt": "a cat riding a bicycle", "duration": "5"},
|
||||
)
|
||||
|
||||
assert captured["submit_via"] == "managed_client"
|
||||
assert captured["client_key"] == "nous-video-token"
|
||||
assert captured["submit_url"] == "http://127.0.0.1:3009/fal-ai/pixverse/v6/text-to-video"
|
||||
assert captured["method"] == "POST"
|
||||
assert captured["arguments"] == {"prompt": "a cat riding a bicycle", "duration": "5"}
|
||||
assert captured["headers"] == {"x-idempotency-key": "video-submit-456"}
|
||||
assert captured["sync_client_inits"] == 1
|
||||
|
||||
|
||||
def test_video_gen_managed_client_reused_across_calls(monkeypatch):
|
||||
"""The managed video client is cached and reused across requests."""
|
||||
captured = {}
|
||||
_install_fake_fal_client(captured)
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
|
||||
|
||||
plugin = _load_video_gen_plugin(monkeypatch)
|
||||
|
||||
plugin._submit_fal_video_request("fal-ai/pixverse/v6/text-to-video", {"prompt": "first"})
|
||||
first_client = captured["http_client"]
|
||||
plugin._submit_fal_video_request("fal-ai/pixverse/v6/text-to-video", {"prompt": "second"})
|
||||
|
||||
assert captured["sync_client_inits"] == 1
|
||||
assert captured["http_client"] is first_client
|
||||
|
||||
|
||||
def test_video_gen_direct_mode_when_fal_key_set(monkeypatch):
|
||||
"""When FAL_KEY is set and gateway not preferred, uses direct fal_client.submit."""
|
||||
captured = {}
|
||||
_install_fake_fal_client(captured)
|
||||
monkeypatch.setenv("FAL_KEY", "direct-fal-key-123")
|
||||
monkeypatch.delenv("FAL_QUEUE_GATEWAY_URL", raising=False)
|
||||
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
||||
|
||||
plugin = _load_video_gen_plugin(monkeypatch)
|
||||
monkeypatch.setattr(plugin.uuid, "uuid4", lambda: "direct-456")
|
||||
|
||||
# Trigger the lazy load so _fal_client is populated from our fake
|
||||
plugin._load_fal_client()
|
||||
|
||||
# In direct mode, fal_client.submit is the module-level function.
|
||||
# Our fake raises AssertionError from the managed path, so we need
|
||||
# to patch it to actually capture the call.
|
||||
direct_captured = {}
|
||||
|
||||
def direct_submit(endpoint, arguments=None, headers=None):
|
||||
direct_captured["endpoint"] = endpoint
|
||||
direct_captured["arguments"] = arguments
|
||||
direct_captured["headers"] = headers
|
||||
# Return a mock handle
|
||||
class FakeHandle:
|
||||
def get(self):
|
||||
return {"video": {"url": "https://fal.media/result.mp4"}}
|
||||
return FakeHandle()
|
||||
|
||||
plugin._fal_client.submit = direct_submit
|
||||
|
||||
plugin._submit_fal_video_request(
|
||||
"fal-ai/pixverse/v6/text-to-video",
|
||||
{"prompt": "test direct"},
|
||||
)
|
||||
|
||||
assert direct_captured["endpoint"] == "fal-ai/pixverse/v6/text-to-video"
|
||||
assert direct_captured["arguments"] == {"prompt": "test direct"}
|
||||
assert direct_captured["headers"] == {"x-idempotency-key": "direct-456"}
|
||||
# Managed client should NOT have been initialized
|
||||
assert "submit_via" not in captured
|
||||
|
||||
|
||||
def test_video_gen_gateway_4xx_raises_actionable_valueerror(monkeypatch):
|
||||
"""A 4xx from the managed gateway surfaces a clear ValueError with remediation hints."""
|
||||
captured = {}
|
||||
_install_fake_fal_client(captured)
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
|
||||
|
||||
plugin = _load_video_gen_plugin(monkeypatch)
|
||||
|
||||
# Make _maybe_retry_request raise an exception with a 403 status
|
||||
class FakeResponse:
|
||||
status_code = 403
|
||||
|
||||
class GatewayRejectError(Exception):
|
||||
def __init__(self):
|
||||
super().__init__("forbidden")
|
||||
self.response = FakeResponse()
|
||||
|
||||
original_retry = sys.modules["fal_client"].client._maybe_retry_request
|
||||
|
||||
def raising_retry(client, method, url, json=None, timeout=None, headers=None):
|
||||
raise GatewayRejectError()
|
||||
|
||||
sys.modules["fal_client"].client._maybe_retry_request = raising_retry
|
||||
|
||||
with pytest.raises(ValueError, match=r"gateway rejected endpoint.*HTTP 403"):
|
||||
plugin._submit_fal_video_request(
|
||||
"fal-ai/pixverse/v6/text-to-video",
|
||||
{"prompt": "test 4xx"},
|
||||
)
|
||||
|
||||
|
||||
def test_video_gen_is_available_true_via_gateway(monkeypatch):
|
||||
"""is_available() returns True when FAL_KEY is absent but managed gateway is configured."""
|
||||
_install_fake_fal_client({})
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
|
||||
|
||||
plugin = _load_video_gen_plugin(monkeypatch)
|
||||
provider = plugin.FALVideoGenProvider()
|
||||
assert provider.is_available() is True
|
||||
|
||||
|
||||
def test_video_gen_prefers_gateway_overrides_direct_key(monkeypatch):
|
||||
"""When FAL_KEY is set but prefers_gateway('video_gen') is True, routes through gateway."""
|
||||
captured = {}
|
||||
_install_fake_fal_client(captured)
|
||||
monkeypatch.setenv("FAL_KEY", "direct-key-present")
|
||||
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
|
||||
|
||||
plugin = _load_video_gen_plugin(monkeypatch)
|
||||
|
||||
# Patch prefers_gateway to return True for video_gen
|
||||
tb_helpers = sys.modules["tools.tool_backend_helpers"]
|
||||
original_pg = tb_helpers.prefers_gateway
|
||||
monkeypatch.setattr(tb_helpers, "prefers_gateway", lambda section: section == "video_gen")
|
||||
|
||||
plugin._submit_fal_video_request(
|
||||
"fal-ai/pixverse/v6/text-to-video",
|
||||
{"prompt": "gateway preferred"},
|
||||
)
|
||||
|
||||
assert captured["submit_via"] == "managed_client"
|
||||
assert captured["client_key"] == "nous-video-token"
|
||||
|
||||
|
||||
def test_video_gen_happy_horse_uses_alibaba_namespace():
|
||||
"""Verify the happy-horse family uses alibaba/ not fal-ai/ endpoints."""
|
||||
_install_fake_tools_package()
|
||||
|
||||
# Load just the plugin module to check the catalog
|
||||
plugin_init = PLUGINS_DIR / "video_gen" / "fal" / "__init__.py"
|
||||
|
||||
agent_dir = Path(__file__).resolve().parents[2] / "agent"
|
||||
spec = spec_from_file_location(
|
||||
"agent.video_gen_provider",
|
||||
agent_dir / "video_gen_provider.py",
|
||||
)
|
||||
mod = module_from_spec(spec)
|
||||
sys.modules["agent.video_gen_provider"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
spec = spec_from_file_location("plugins.video_gen.fal", plugin_init)
|
||||
plugin_mod = module_from_spec(spec)
|
||||
sys.modules["plugins.video_gen.fal"] = plugin_mod
|
||||
spec.loader.exec_module(plugin_mod)
|
||||
|
||||
hh = plugin_mod.FAL_FAMILIES["happy-horse"]
|
||||
assert hh["text_endpoint"] == "alibaba/happy-horse/text-to-video"
|
||||
assert hh["image_endpoint"] == "alibaba/happy-horse/image-to-video"
|
||||
|
||||
@@ -50,14 +50,6 @@ class TestResolveTrustLevel:
|
||||
assert _resolve_trust_level("anthropics/skills") == "trusted"
|
||||
assert _resolve_trust_level("openai/skills/some-skill") == "trusted"
|
||||
|
||||
def test_nvidia_skills_is_trusted(self):
|
||||
# NVIDIA/skills ships NVIDIA-verified skills with detached OMS
|
||||
# signatures and governance skill cards. It's wired through the
|
||||
# same trust path as the OpenAI / Anthropic / HuggingFace taps.
|
||||
assert _resolve_trust_level("NVIDIA/skills") == "trusted"
|
||||
assert _resolve_trust_level("NVIDIA/skills/aiq-deploy") == "trusted"
|
||||
assert _resolve_trust_level("skills-sh/NVIDIA/skills/cuopt") == "trusted"
|
||||
|
||||
def test_trusted_repo_sibling_prefixes_are_not_trusted(self):
|
||||
assert _resolve_trust_level("openai/skills-evil") == "community"
|
||||
assert _resolve_trust_level("anthropics/skills-foo/frontend-design") == "community"
|
||||
|
||||
@@ -70,143 +70,6 @@ class TestParseFrontmatterQuick:
|
||||
assert fm == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHubSource skills.sh.json grouping sidecar (category support)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkillsShGroupings:
|
||||
"""Parsing + stamping of the skills.sh.json grouping sidecar.
|
||||
|
||||
A tap can ship a repo-root ``skills.sh.json`` declaring category
|
||||
groupings; we flatten it to {skill_name: title} and stamp the title onto
|
||||
each SkillMeta's ``extra["category"]``. This is the generic cross-ecosystem
|
||||
mechanism behind NVIDIA-style categorization — not NVIDIA-specific.
|
||||
"""
|
||||
|
||||
def test_parse_basic_groupings(self):
|
||||
content = json.dumps({
|
||||
"$schema": "https://skills.sh/schemas/skills.sh.schema.json",
|
||||
"groupings": [
|
||||
{"title": "Inference AI", "skills": ["dynamo-router", "dynamo-recipe"]},
|
||||
{"title": "Decision Optimization", "skills": ["cuopt-developer"]},
|
||||
],
|
||||
})
|
||||
mapping = GitHubSource._parse_skillsh_groupings(content)
|
||||
assert mapping == {
|
||||
"dynamo-router": "Inference AI",
|
||||
"dynamo-recipe": "Inference AI",
|
||||
"cuopt-developer": "Decision Optimization",
|
||||
}
|
||||
|
||||
def test_parse_invalid_json_returns_none(self):
|
||||
assert GitHubSource._parse_skillsh_groupings("not json{{") is None
|
||||
|
||||
def test_parse_non_dict_returns_none(self):
|
||||
assert GitHubSource._parse_skillsh_groupings("[1, 2, 3]") is None
|
||||
|
||||
def test_parse_missing_groupings_returns_none(self):
|
||||
assert GitHubSource._parse_skillsh_groupings('{"foo": 1}') is None
|
||||
|
||||
def test_parse_empty_groupings_returns_empty_map(self):
|
||||
assert GitHubSource._parse_skillsh_groupings('{"groupings": []}') == {}
|
||||
|
||||
def test_parse_tolerates_malformed_group(self):
|
||||
# A group missing its skills list is skipped; the valid one survives.
|
||||
content = json.dumps({"groupings": [
|
||||
{"title": "X"}, # no skills -> skipped
|
||||
{"skills": ["a"]}, # no title -> skipped
|
||||
{"title": "Y", "skills": ["b", 5, None]}, # only valid string members kept
|
||||
]})
|
||||
assert GitHubSource._parse_skillsh_groupings(content) == {"b": "Y"}
|
||||
|
||||
def test_parse_first_grouping_wins_on_duplicate(self):
|
||||
content = json.dumps({"groupings": [
|
||||
{"title": "First", "skills": ["dup"]},
|
||||
{"title": "Second", "skills": ["dup"]},
|
||||
]})
|
||||
assert GitHubSource._parse_skillsh_groupings(content) == {"dup": "First"}
|
||||
|
||||
def test_get_groupings_caches_per_repo(self):
|
||||
auth = MagicMock()
|
||||
src = GitHubSource(auth=auth)
|
||||
content = json.dumps({"groupings": [{"title": "T", "skills": ["s"]}]})
|
||||
with patch.object(src, "_fetch_file_content", return_value=content) as mock_fetch:
|
||||
first = src._get_skillsh_groupings("acme/skills")
|
||||
second = src._get_skillsh_groupings("acme/skills")
|
||||
assert first == {"s": "T"}
|
||||
assert second == {"s": "T"}
|
||||
# Second call must hit the per-repo cache, not GitHub again.
|
||||
mock_fetch.assert_called_once_with("acme/skills", "skills.sh.json")
|
||||
|
||||
def test_get_groupings_no_sidecar_returns_none_and_caches(self):
|
||||
auth = MagicMock()
|
||||
src = GitHubSource(auth=auth)
|
||||
with patch.object(src, "_fetch_file_content", return_value=None) as mock_fetch:
|
||||
assert src._get_skillsh_groupings("acme/skills") is None
|
||||
assert src._get_skillsh_groupings("acme/skills") is None
|
||||
mock_fetch.assert_called_once()
|
||||
|
||||
def test_list_skills_stamps_category_from_sidecar(self):
|
||||
auth = MagicMock()
|
||||
src = GitHubSource(auth=auth)
|
||||
|
||||
meta = SkillMeta(
|
||||
name="cuopt-developer", description="d", source="github",
|
||||
identifier="NVIDIA/skills/skills/cuopt-developer", trust_level="trusted",
|
||||
)
|
||||
contents = [{"type": "dir", "name": "cuopt-developer"}]
|
||||
groupings = {"cuopt-developer": "Decision Optimization"}
|
||||
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = contents
|
||||
|
||||
with patch.object(src, "_read_cache", return_value=None), \
|
||||
patch.object(src, "_write_cache"), \
|
||||
patch.object(src, "_get_skillsh_groupings", return_value=groupings), \
|
||||
patch.object(src, "inspect", return_value=meta), \
|
||||
patch("tools.skills_hub.httpx.get", return_value=resp):
|
||||
skills = src._list_skills_in_repo("NVIDIA/skills", "skills/")
|
||||
|
||||
assert len(skills) == 1
|
||||
assert skills[0].extra["category"] == "Decision Optimization"
|
||||
|
||||
def test_list_skills_no_sidecar_leaves_extra_empty(self):
|
||||
auth = MagicMock()
|
||||
src = GitHubSource(auth=auth)
|
||||
|
||||
meta = SkillMeta(
|
||||
name="foo", description="d", source="github",
|
||||
identifier="acme/skills/skills/foo", trust_level="community",
|
||||
)
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = [{"type": "dir", "name": "foo"}]
|
||||
|
||||
with patch.object(src, "_read_cache", return_value=None), \
|
||||
patch.object(src, "_write_cache"), \
|
||||
patch.object(src, "_get_skillsh_groupings", return_value=None), \
|
||||
patch.object(src, "inspect", return_value=meta), \
|
||||
patch("tools.skills_hub.httpx.get", return_value=resp):
|
||||
skills = src._list_skills_in_repo("acme/skills", "skills/")
|
||||
|
||||
assert len(skills) == 1
|
||||
assert "category" not in skills[0].extra
|
||||
|
||||
def test_meta_to_dict_roundtrip_preserves_extra(self):
|
||||
meta = SkillMeta(
|
||||
name="x", description="d", source="github",
|
||||
identifier="acme/skills/x", trust_level="trusted",
|
||||
extra={"category": "Inference AI"},
|
||||
)
|
||||
d = GitHubSource._meta_to_dict(meta)
|
||||
assert d["extra"] == {"category": "Inference AI"}
|
||||
# Round-trips back through the cache deserialization path.
|
||||
restored = SkillMeta(**d)
|
||||
assert restored.extra == {"category": "Inference AI"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHubSource.trust_level_for
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -239,36 +102,6 @@ class TestTrustLevelFor:
|
||||
# No path part — still resolves repo correctly
|
||||
assert result in {"trusted", "community"}
|
||||
|
||||
def test_nvidia_skills_tap_is_registered_and_trusted(self):
|
||||
# Invariant: every trusted repo in TRUSTED_REPOS that we want
|
||||
# browseable/searchable through `hermes skills browse` must also
|
||||
# appear as a default tap on GitHubSource. Without the tap, the
|
||||
# repo's skills don't show up in search results or the docs-site
|
||||
# Skills Hub page even though the trust level is correct.
|
||||
from tools.skills_guard import TRUSTED_REPOS
|
||||
|
||||
assert "NVIDIA/skills" in TRUSTED_REPOS
|
||||
tap_repos = {tap["repo"] for tap in GitHubSource.DEFAULT_TAPS}
|
||||
assert "NVIDIA/skills" in tap_repos
|
||||
|
||||
src = self._source()
|
||||
assert src.trust_level_for("NVIDIA/skills/aiq-deploy") == "trusted"
|
||||
|
||||
def test_browseable_trusted_repos_have_taps(self):
|
||||
# General invariant covering all current and future trusted repos
|
||||
# that publish under a single `skills/`-style path. openai/skills
|
||||
# is the deliberate exception — it has two taps (`.curated/` and
|
||||
# `.system/`) — so we just assert membership not path equality.
|
||||
from tools.skills_guard import TRUSTED_REPOS
|
||||
|
||||
tap_repos = {tap["repo"] for tap in GitHubSource.DEFAULT_TAPS}
|
||||
for repo in TRUSTED_REPOS:
|
||||
assert repo in tap_repos, (
|
||||
f"Trusted repo {repo!r} is in TRUSTED_REPOS but missing "
|
||||
"from GitHubSource.DEFAULT_TAPS — its skills will not be "
|
||||
"browsable via `hermes skills browse`."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SkillsShSource
|
||||
|
||||
@@ -99,6 +99,12 @@ class TestProviderSelectionGate:
|
||||
assert tt._get_provider({"enabled": True, "provider": "groq"}) == "groq"
|
||||
|
||||
def test_explicit_mistral_sees_dotenv(self):
|
||||
"""Mistral STT is intentionally disabled (PyPI quarantine 2026-05-12).
|
||||
|
||||
Even with the dotenv key visible, explicit `provider: mistral` must
|
||||
return "none" with a warning. Restore the previous behavior once
|
||||
`mistralai` is un-quarantined on PyPI.
|
||||
"""
|
||||
from tools import transcription_tools as tt
|
||||
|
||||
with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
|
||||
@@ -106,7 +112,7 @@ class TestProviderSelectionGate:
|
||||
patch.object(tt, "_has_local_command", return_value=False), \
|
||||
patch("hermes_cli.config.load_env",
|
||||
return_value={"MISTRAL_API_KEY": "dotenv-secret"}):
|
||||
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "mistral"
|
||||
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "none"
|
||||
|
||||
def test_explicit_xai_sees_dotenv(self):
|
||||
from tools import transcription_tools as tt
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user