Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

This commit is contained in:
Brooklyn Nicholson 2026-05-05 13:21:04 -05:00
commit dda3894523
108 changed files with 7729 additions and 1207 deletions

204
AGENTS.md
View file

@ -39,12 +39,17 @@ hermes-agent/
│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
│ │ # homeassistant, signal, matrix, mattermost, email, sms,
│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
│ │ # webhook, api_server, ...). See ADDING_A_PLATFORM.md.
│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
├── plugins/ # Plugin system (see "Plugins" section below)
│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
│ ├── context_engine/ # Context-engine plugins
│ └── <others>/ # Dashboard, image-gen, disk-cleanup, examples, ...
│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
│ ├── hermes-achievements/ # Gamified achievement tracking
│ ├── observability/ # Metrics / traces / logs plugin
│ ├── image_gen/ # Image-generation providers
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
│ # spotify, strike-freedom-cockpit, ...
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
├── skills/ # Built-in skills bundled with the repo
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
@ -55,7 +60,7 @@ hermes-agent/
├── environments/ # RL training environments (Atropos)
├── scripts/ # run_tests.sh, release.py, auxiliary scripts
├── website/ # Docusaurus docs site
└── tests/ # Pytest suite (~15k tests across ~700 files as of Apr 2026)
└── tests/ # Pytest suite (~17k tests across ~900 files as of May 2026)
```
**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
@ -314,9 +319,9 @@ registry.register(
)
```
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset.
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only *exposed to an agent* if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from.
Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain.
Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. Wiring into a toolset is still a deliberate, manual step.
The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
@ -338,6 +343,22 @@ The registry handles schema collection, dispatch, availability checking, and err
section is handled automatically by the deep-merge and does NOT require
a version bump.
### Top-level `config.yaml` sections (non-exhaustive):
`model`, `agent`, `terminal`, `compression`, `display`, `stt`, `tts`,
`memory`, `security`, `delegation`, `smart_model_routing`, `checkpoints`,
`auxiliary`, `curator`, `skills`, `gateway`, `logging`, `cron`, `profiles`,
`plugins`, `honcho`.
`auxiliary` holds per-task overrides for side-LLM work (curator, vision,
embedding, title generation, session_search, etc.) — each task can pin
its own provider/model/base_url/max_tokens/reasoning_effort. See
`agent/auxiliary_client.py::_resolve_auto` for resolution order.
`curator` holds the background skill-maintenance config —
`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
`archive_after_days`, `backup` (nested).
### .env variables (SECRETS ONLY — API keys, tokens, passwords):
1. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
```python
@ -544,11 +565,176 @@ niche skills belong in `optional-skills/`.
### SKILL.md frontmatter
Standard fields: `name`, `description`, `version`, `platforms`
(OS-gating list: `[macos]`, `[linux, macos]`, ...),
Standard fields: `name`, `description`, `version`, `author`, `license`,
`platforms` (OS-gating list: `[macos]`, `[linux, macos]`, ...),
`metadata.hermes.tags`, `metadata.hermes.category`,
`metadata.hermes.config` (config.yaml settings the skill needs — stored
under `skills.config.<key>`, prompted during setup, injected at load time).
`metadata.hermes.related_skills`, `metadata.hermes.config` (config.yaml
settings the skill needs — stored under `skills.config.<key>`, prompted
during setup, injected at load time).
Top-level `tags:` and `category:` are also accepted and mirrored from
`metadata.hermes.*` by the loader.
---
## Toolsets
All toolsets are defined in `toolsets.py` as a single `TOOLSETS` dict.
Each platform's adapter picks a base toolset (e.g. Telegram uses
`"messaging"`); `_HERMES_CORE_TOOLS` is the default bundle most
platforms inherit from.
Current toolset keys: `browser`, `clarify`, `code_execution`, `cronjob`,
`debugging`, `delegation`, `discord`, `discord_admin`, `feishu_doc`,
`feishu_drive`, `file`, `homeassistant`, `image_gen`, `kanban`, `memory`,
`messaging`, `moa`, `rl`, `safe`, `search`, `session_search`, `skills`,
`spotify`, `terminal`, `todo`, `tts`, `video`, `vision`, `web`, `yuanbao`.
Enable/disable per platform via `hermes tools` (the curses UI) or the
`tools.<platform>.enabled` / `tools.<platform>.disabled` lists in
`config.yaml`.
---
## Delegation (`delegate_task`)
`tools/delegate_tool.py` spawns a subagent with an isolated
context + terminal session. Synchronous: the parent waits for the
child's summary before continuing its own loop — if the parent is
interrupted, the child is cancelled.
Two shapes:
- **Single:** pass `goal` (+ optional `context`, `toolsets`).
- **Batch (parallel):** pass `tasks: [...]` — each gets its own subagent
running concurrently. Concurrency is capped by
`delegation.max_concurrent_children` (default 3).
Roles:
- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
`clarify`, `memory`, `send_message`, `execute_code`.
- `role="orchestrator"` — retains `delegate_task` so it can spawn its
own workers. Gated by `delegation.orchestrator_enabled` (default true)
and bounded by `delegation.max_spawn_depth` (default 2).
Key config knobs (under `delegation:` in `config.yaml`):
`max_concurrent_children`, `max_spawn_depth`, `child_timeout_seconds`,
`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`,
`max_iterations`.
Synchronicity rule: delegate_task is **not** durable. For long-running
work that must outlive the current turn, use `cronjob` or
`terminal(background=True, notify_on_complete=True)` instead.
---
## Curator (skill lifecycle)
Background skill-maintenance system that tracks usage on agent-created
skills and auto-archives stale ones. Users never lose skills; archives
go to `~/.hermes/skills/.archive/` and are restorable.
- **Core:** `agent/curator.py` (review loop, auto-transitions, LLM review
prompt) + `agent/curator_backup.py` (pre-run tar.gz snapshots).
- **CLI:** `hermes_cli/curator.py` wires `hermes curator <verb>` where
verbs are: `status`, `run`, `pause`, `resume`, `pin`, `unpin`,
`archive`, `restore`, `prune`, `backup`, `rollback`.
- **Telemetry:** `tools/skill_usage.py` owns the sidecar
`~/.hermes/skills/.usage.json` — per-skill `use_count`, `view_count`,
`patch_count`, `last_activity_at`, `state` (active / stale /
archived), `pinned`.
Invariants:
- Curator only touches skills with `created_by: "agent"` provenance —
bundled + hub-installed skills are off-limits.
- Never deletes; max destructive action is archive.
- Pinned skills are exempt from every auto-transition and from the
LLM review pass.
- `skill_manage(action="delete")` refuses pinned skills; patch/edit/
write_file/remove_file go through so the agent can keep improving
pinned skills.
Config section (`curator:` in `config.yaml`):
`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
`archive_after_days`, `backup.*`.
Full user-facing docs: `website/docs/user-guide/features/curator.md`.
---
## Cron (scheduled jobs)
`cron/jobs.py` (job store) + `cron/scheduler.py` (tick loop). Agents
schedule jobs via the `cronjob` tool; users via `hermes cron <verb>`
(`list`, `add`, `edit`, `pause`, `resume`, `run`, `remove`) or the
`/cron` slash command.
Supported schedule formats:
- Duration: `"30m"`, `"2h"`, `"1d"`
- "every" phrase: `"every 2h"`, `"every monday 9am"`
- 5-field cron expression: `"0 9 * * *"`
- ISO timestamp (one-shot): `"2026-06-01T09:00:00Z"`
Per-job fields include `skills` (load specific skills), `model` /
`provider` overrides, `script` (pre-run data-collection script whose
stdout is injected into the prompt; `no_agent=True` turns the script
into the entire job), `context_from` (chain job A's last output into
job B's prompt), `workdir` (run in a specific directory with its
`AGENTS.md`/`CLAUDE.md` loaded), and multi-platform delivery.
Hardening invariants:
- **3-minute hard interrupt** on cron sessions — runaway agent loops
cannot monopolize the scheduler.
- Catchup window: half the job's period, clamped to 120s2h.
- Grace window: 120s for one-shot jobs whose fire time was missed.
- File lock at `~/.hermes/cron/.tick.lock` prevents duplicate ticks
across processes.
- Cron sessions pass `skip_memory=True` by default; memory providers
intentionally do not run during cron.
Cron deliveries are **not** mirrored into the target gateway session —
they land in their own cron session with a header/footer frame so the
main conversation's message-role alternation stays intact.
---
## Kanban (multi-agent work queue)
Durable SQLite-backed board that lets multiple profiles / workers
collaborate on shared tasks. Users drive it via `hermes kanban <verb>`;
workers spawned by the dispatcher drive it via a dedicated `kanban_*`
toolset so their schema footprint is zero when they're not inside a
kanban task.
- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
`init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
`unlink`, `comment`, `complete`, `block`, `unblock`, `archive`,
`tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`,
`assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`.
- **Worker toolset:** `tools/kanban_tools.py` exposes `kanban_show`,
`kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`,
`kanban_create`, `kanban_link` — gated by `HERMES_KANBAN_TASK` so
the schema only appears for processes actually running as a worker.
- **Dispatcher:** long-lived loop that (default every 60s) reclaims
stale claims, promotes ready tasks, atomically claims, and spawns
assigned profiles. Runs **inside the gateway** by default via
`kanban.dispatch_in_gateway: true`.
- **Plugin assets:** `plugins/kanban/dashboard/` (web UI) +
`plugins/kanban/systemd/` (`hermes-kanban-dispatcher.service` for
standalone dispatcher deployment).
Isolation model:
- **Board** is the hard boundary — workers are spawned with
`HERMES_KANBAN_BOARD` pinned in their env so they can't see other
boards.
- **Tenant** is a soft namespace *within* a board — one specialist
fleet can serve multiple businesses with workspace-path + memory-key
isolation.
- After ~5 consecutive spawn failures on the same task the dispatcher
auto-blocks it to prevent spin loops.
Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
---

View file

@ -466,17 +466,10 @@ class SessionManager:
except Exception:
logger.debug("Failed to update ACP session metadata", exc_info=True)
# Replace stored messages with current history.
db.clear_messages(state.session_id)
for msg in state.history:
db.append_message(
session_id=state.session_id,
role=msg.get("role", "user"),
content=msg.get("content"),
tool_name=msg.get("tool_name") or msg.get("name"),
tool_calls=msg.get("tool_calls"),
tool_call_id=msg.get("tool_call_id"),
)
# Replace stored messages with current history atomically so a
# mid-rewrite failure rolls back and the previously persisted
# conversation is preserved (salvaged from #13675).
db.replace_messages(state.session_id, state.history)
except Exception:
logger.warning("Failed to persist ACP session %s", state.session_id, exc_info=True)

View file

@ -259,10 +259,12 @@ _PROVIDERS_WITHOUT_VISION: frozenset = frozenset({
"kimi-coding-cn",
})
# OpenRouter app attribution headers (base — always sent)
# OpenRouter app attribution headers (base — always sent).
# `X-Title` is the canonical attribution header OpenRouter's dashboard
# reads; the previous `X-OpenRouter-Title` label was not recognized there.
_OR_HEADERS_BASE = {
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
"X-OpenRouter-Title": "Hermes Agent",
"X-Title": "Hermes Agent",
"X-OpenRouter-Categories": "productivity,cli-agent",
}
@ -567,7 +569,12 @@ class _CodexCompletionsAdapter:
# API allows it.
pass
else:
effort = reasoning_cfg.get("effort", "medium")
# Truthy-only check mirrors agent/transports/codex.py
# build_kwargs(): falsy values (None, "", 0) fall back
# to the default rather than being forwarded to the
# Codex backend, which rejects e.g. {"effort": null}
# with a 400.
effort = reasoning_cfg.get("effort") or "medium"
# Codex backend rejects "minimal"; clamp to "low" to
# match the main-agent Codex transport behavior.
if effort == "minimal":
@ -1643,6 +1650,39 @@ def _is_payment_error(exc: Exception) -> bool:
return False
def _is_rate_limit_error(exc: Exception) -> bool:
"""Detect rate-limit errors that warrant provider fallback.
Returns True for HTTP 429 errors whose message indicates rate limiting
(as opposed to billing/quota exhaustion, which _is_payment_error handles).
Also catches OpenAI SDK RateLimitError instances that may not set
.status_code on the exception object.
"""
status = getattr(exc, "status_code", None)
err_lower = str(exc).lower()
# OpenAI SDK's RateLimitError sometimes omits .status_code —
# detect by class name so we don't miss these. (PR #8023 pattern)
if type(exc).__name__ == "RateLimitError":
return True
if status == 429:
# Distinguish rate-limit from billing: billing keywords are handled
# by _is_payment_error, everything else on 429 is a rate limit.
if any(kw in err_lower for kw in (
"rate limit", "rate_limit", "too many requests",
"try again", "retry after", "resets in",
)):
return True
# Generic 429 without billing keywords = likely a rate limit
if not any(kw in err_lower for kw in (
"credits", "insufficient funds", "billing",
"payment required", "can only afford",
)):
return True
return False
def _is_connection_error(exc: Exception) -> bool:
"""Detect connection/network errors that warrant provider fallback.
@ -3127,8 +3167,14 @@ def _resolve_task_provider_model(
if task:
# Config.yaml is the primary source for per-task overrides.
if cfg_base_url:
if cfg_base_url and cfg_api_key:
# Both base_url and api_key explicitly set → custom endpoint.
return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode
if cfg_base_url and cfg_provider and cfg_provider != "auto":
# base_url set without api_key but with a known provider — use
# the provider so it can resolve credentials from env vars
# (e.g. OPENROUTER_API_KEY) instead of locking into "custom".
return cfg_provider, resolved_model, cfg_base_url, None, resolved_api_mode
if cfg_provider and cfg_provider != "auto":
return cfg_provider, resolved_model, None, None, resolved_api_mode
@ -3529,7 +3575,7 @@ def call_llm(
except Exception as retry_err:
# If the max_tokens retry also hits a payment or connection
# error, fall through to the fallback chain below.
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err) or _is_rate_limit_error(retry_err)):
raise
first_err = retry_err
@ -3612,13 +3658,27 @@ def call_llm(
# Codex/OAuth tokens that authenticate but whose endpoint is down,
# and providers the user never configured that got picked up by
# the auto-detection chain.
should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)
#
# ── Rate-limit fallback (#13579) ─────────────────────────────
# When the provider returns a 429 rate-limit (not billing), fall
# back to an alternative provider instead of exhausting retries
# against the same rate-limited endpoint.
should_fallback = (
_is_payment_error(first_err)
or _is_connection_error(first_err)
or _is_rate_limit_error(first_err)
)
# Only try alternative providers when the user didn't explicitly
# configure this task's provider. Explicit provider = hard constraint;
# auto (the default) = best-effort fallback chain. (#7559)
is_auto = resolved_provider in ("auto", "", None)
if should_fallback and is_auto:
reason = "payment error" if _is_payment_error(first_err) else "connection error"
if _is_payment_error(first_err):
reason = "payment error"
elif _is_rate_limit_error(first_err):
reason = "rate limit"
else:
reason = "connection error"
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
task or "call", reason, resolved_provider, first_err)
fb_client, fb_model, fb_label = _try_payment_fallback(
@ -3821,7 +3881,7 @@ async def async_call_llm(
except Exception as retry_err:
# If the max_tokens retry also hits a payment or connection
# error, fall through to the fallback chain below.
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err) or _is_rate_limit_error(retry_err)):
raise
first_err = retry_err
@ -3890,11 +3950,20 @@ async def async_call_llm(
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task)
# ── Payment / connection fallback (mirrors sync call_llm) ─────
should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)
# ── Payment / connection / rate-limit fallback (mirrors sync call_llm) ──
should_fallback = (
_is_payment_error(first_err)
or _is_connection_error(first_err)
or _is_rate_limit_error(first_err)
)
is_auto = resolved_provider in ("auto", "", None)
if should_fallback and is_auto:
reason = "payment error" if _is_payment_error(first_err) else "connection error"
if _is_payment_error(first_err):
reason = "payment error"
elif _is_rate_limit_error(first_err):
reason = "rate limit"
else:
reason = "connection error"
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
task or "call", reason, resolved_provider, first_err)
fb_client, fb_model, fb_label = _try_payment_fallback(

View file

@ -993,15 +993,39 @@ The user has requested that this compaction PRIORITISE preserving all informatio
return None
@staticmethod
def _with_summary_prefix(summary: str) -> str:
"""Normalize summary text to the current compaction handoff format."""
def _strip_summary_prefix(summary: str) -> str:
"""Return summary body without the current or legacy handoff prefix."""
text = (summary or "").strip()
for prefix in (LEGACY_SUMMARY_PREFIX, SUMMARY_PREFIX):
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX):
if text.startswith(prefix):
text = text[len(prefix):].lstrip()
break
return text[len(prefix):].lstrip()
return text
@classmethod
def _with_summary_prefix(cls, summary: str) -> str:
"""Normalize summary text to the current compaction handoff format."""
text = cls._strip_summary_prefix(summary)
return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX
@staticmethod
def _is_context_summary_content(content: Any) -> bool:
text = _content_text_for_contains(content).lstrip()
return text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX)
@classmethod
def _find_latest_context_summary(
cls,
messages: List[Dict[str, Any]],
start: int,
end: int,
) -> tuple[Optional[int], str]:
"""Find the newest handoff summary inside a compression window."""
for idx in range(end - 1, start - 1, -1):
content = messages[idx].get("content")
if cls._is_context_summary_content(content):
return idx, cls._strip_summary_prefix(_content_text_for_contains(content))
return None, ""
# ------------------------------------------------------------------
# Tool-call / tool-result pair integrity helpers
# ------------------------------------------------------------------
@ -1308,6 +1332,15 @@ The user has requested that this compaction PRIORITISE preserving all informatio
return messages
turns_to_summarize = messages[compress_start:compress_end]
summary_idx, summary_body = self._find_latest_context_summary(
messages,
compress_start,
compress_end,
)
if summary_idx is not None:
if summary_body and not self._previous_summary:
self._previous_summary = summary_body
turns_to_summarize = messages[summary_idx + 1:compress_end]
if not self.quiet_mode:
logger.info(
@ -1385,6 +1418,19 @@ The user has requested that this compaction PRIORITISE preserving all informatio
# Merge the summary into the first tail message instead
# of inserting a standalone message that breaks alternation.
_merge_summary_into_tail = True
# When the summary lands as a standalone role="user" message,
# weak models read the verbatim "## Active Task" quote of a past
# user request as fresh input (#11475, #14521). Append the explicit
# end marker — the same one used in the merge-into-tail path — so
# the model has a clear "summary above, not new input" signal.
if not _merge_summary_into_tail and summary_role == "user":
summary = (
summary
+ "\n\n--- END OF CONTEXT SUMMARY — "
"respond to the message below, not the summary above ---"
)
if not _merge_summary_into_tail:
compressed.append({"role": summary_role, "content": summary})

View file

@ -55,6 +55,7 @@ class FailoverReason(enum.Enum):
thinking_signature = "thinking_signature" # Anthropic thinking block sig invalid
long_context_tier = "long_context_tier" # Anthropic "extra usage" tier gate
oauth_long_context_beta_forbidden = "oauth_long_context_beta_forbidden" # Anthropic OAuth subscription rejects 1M context beta — disable beta and retry
llama_cpp_grammar_pattern = "llama_cpp_grammar_pattern" # llama.cpp json-schema-to-grammar rejects regex escapes in `pattern` / `format` — strip from tools and retry
# Catch-all
unknown = "unknown" # Unclassifiable — retry with backoff
@ -470,6 +471,31 @@ def classify_api_error(
should_compress=False,
)
# llama.cpp's ``json-schema-to-grammar`` converter (used by its OAI
# server to build GBNF tool-call parsers) rejects regex escape classes
# like ``\d``/``\w``/``\s`` and most ``format`` values. MCP servers
# routinely emit ``"pattern": "\\d{4}-\\d{2}-\\d{2}"`` for date/phone/
# email params. llama.cpp surfaces this as HTTP 400 with one of a few
# recognizable phrases; on match we strip ``pattern``/``format`` from
# ``self.tools`` in the retry loop and retry once. Cloud providers are
# unaffected — they accept these keywords and we never hit this branch.
if (
status_code == 400
and (
"error parsing grammar" in error_msg
or "json-schema-to-grammar" in error_msg
or (
"unable to generate parser" in error_msg
and "template" in error_msg
)
)
):
return _result(
FailoverReason.llama_cpp_grammar_pattern,
retryable=True,
should_compress=False,
)
# ── 2. HTTP status code classification ──────────────────────────
if status_code is not None:

230
agent/i18n.py Normal file
View file

@ -0,0 +1,230 @@
"""Lightweight internationalization (i18n) for Hermes static user-facing messages.
Scope (thin slice, by design): only the highest-impact static strings shown
to the user by Hermes itself -- approval prompts, a handful of gateway slash
command replies, restart-drain notices. Agent-generated output, log lines,
error tracebacks, tool outputs, and slash-command descriptions all stay in
English.
Catalog files live under ``locales/<lang>.yaml`` at the repo root. Each
catalog is a flat dict keyed by dotted paths (e.g. ``approval.choose`` or
``gateway.approval_expired``). Missing keys fall back to English; if English
is missing too, the key path itself is returned so a broken catalog never
crashes the agent.
Usage::
from agent.i18n import t
print(t("approval.choose_long")) # current lang
print(t("gateway.draining", count=3)) # {count} formatted
print(t("approval.choose_long", lang="zh")) # explicit override
Language resolution order:
1. Explicit ``lang=`` argument passed to :func:`t`
2. ``HERMES_LANGUAGE`` environment variable (for tests / quick override)
3. ``display.language`` from config.yaml
4. ``"en"`` (baseline)
Supported languages: en, zh, ja, de, es. Unknown values fall back to en.
"""
from __future__ import annotations
import logging
import os
import threading
from functools import lru_cache
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es")
DEFAULT_LANGUAGE = "en"
# Accept a few natural aliases so users who type "chinese" / "zh-CN" / "jp"
# get the right catalog instead of silently falling back to English.
_LANGUAGE_ALIASES: dict[str, str] = {
"english": "en", "en-us": "en", "en-gb": "en",
"chinese": "zh", "mandarin": "zh", "zh-cn": "zh", "zh-tw": "zh", "zh-hans": "zh", "zh-hant": "zh",
"japanese": "ja", "jp": "ja", "ja-jp": "ja",
"german": "de", "deutsch": "de", "de-de": "de",
"spanish": "es", "español": "es", "espanol": "es", "es-es": "es", "es-mx": "es",
}
_catalog_cache: dict[str, dict[str, str]] = {}
_catalog_lock = threading.Lock()
def _locales_dir() -> Path:
"""Return the directory containing locale YAML files.
Lives next to the repo root so both the bundled install and editable
checkouts find it without PYTHONPATH gymnastics.
"""
# agent/i18n.py -> agent/ -> repo root
return Path(__file__).resolve().parent.parent / "locales"
def _normalize_lang(value: Any) -> str:
"""Normalize a user-supplied language value to a supported code.
Accepts supported codes directly, common aliases (``chinese`` -> ``zh``),
and case-insensitive regional tags (``zh-CN`` -> ``zh``). Returns the
default language for unknown values.
"""
if not isinstance(value, str):
return DEFAULT_LANGUAGE
key = value.strip().lower()
if not key:
return DEFAULT_LANGUAGE
if key in SUPPORTED_LANGUAGES:
return key
if key in _LANGUAGE_ALIASES:
return _LANGUAGE_ALIASES[key]
# Try stripping a region suffix (e.g. "pt-br" -> "pt" won't be supported,
# but "zh-CN" -> "zh" will).
base = key.split("-", 1)[0]
if base in SUPPORTED_LANGUAGES:
return base
return DEFAULT_LANGUAGE
def _load_catalog(lang: str) -> dict[str, str]:
"""Load and flatten one locale YAML file into a dotted-key dict.
YAML files can be nested for human readability; this produces the flat
key space :func:`t` expects. Cached per-language for the process.
"""
with _catalog_lock:
cached = _catalog_cache.get(lang)
if cached is not None:
return cached
path = _locales_dir() / f"{lang}.yaml"
if not path.is_file():
logger.debug("i18n catalog missing for %s at %s", lang, path)
with _catalog_lock:
_catalog_cache[lang] = {}
return {}
try:
import yaml # PyYAML is already a hermes dependency
with path.open("r", encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
except Exception as exc:
logger.warning("Failed to load i18n catalog %s: %s", path, exc)
with _catalog_lock:
_catalog_cache[lang] = {}
return {}
flat: dict[str, str] = {}
_flatten_into(raw, "", flat)
with _catalog_lock:
_catalog_cache[lang] = flat
return flat
def _flatten_into(node: Any, prefix: str, out: dict[str, str]) -> None:
if isinstance(node, dict):
for key, value in node.items():
child_key = f"{prefix}.{key}" if prefix else str(key)
_flatten_into(value, child_key, out)
elif isinstance(node, str):
out[prefix] = node
# Non-string, non-dict leaves are ignored -- catalogs are text-only.
@lru_cache(maxsize=1)
def _config_language_cached() -> str | None:
"""Read ``display.language`` from config.yaml once per process.
Cached because ``t()`` is called in hot paths (every approval prompt,
every gateway reply) and re-reading YAML each call would be wasteful.
``reset_language_cache()`` clears this when config changes at runtime
(e.g. after the setup wizard).
"""
try:
from hermes_cli.config import load_config
cfg = load_config()
lang = (cfg.get("display") or {}).get("language")
if lang:
return _normalize_lang(lang)
except Exception as exc:
logger.debug("Could not read display.language from config: %s", exc)
return None
def reset_language_cache() -> None:
"""Invalidate cached language resolution and catalogs.
Call after :func:`hermes_cli.config.save_config` if a running process
needs to pick up a changed ``display.language`` without restart.
"""
_config_language_cached.cache_clear()
with _catalog_lock:
_catalog_cache.clear()
def get_language() -> str:
"""Resolve the active language using env > config > default order."""
env_lang = os.environ.get("HERMES_LANGUAGE")
if env_lang:
return _normalize_lang(env_lang)
cfg_lang = _config_language_cached()
if cfg_lang:
return cfg_lang
return DEFAULT_LANGUAGE
def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str:
"""Translate a dotted key to the active language.
Parameters
----------
key
Dotted path into the catalog, e.g. ``"approval.choose_long"``.
lang
Explicit language override. Takes precedence over env + config.
**format_kwargs
``str.format`` substitution arguments (``t("gateway.drain", count=3)``
expects a catalog entry with a ``{count}`` placeholder).
Returns
-------
The translated string, or the English fallback if the key is missing in
the target language, or the bare key if English is also missing.
"""
target = _normalize_lang(lang) if lang else get_language()
catalog = _load_catalog(target)
value = catalog.get(key)
if value is None and target != DEFAULT_LANGUAGE:
# Fall through to English rather than showing a key path to the user.
value = _load_catalog(DEFAULT_LANGUAGE).get(key)
if value is None:
# Last-ditch: return the key itself. A broken catalog should not
# crash anything; it just looks ugly until someone fixes it.
logger.debug("i18n miss: key=%r lang=%r", key, target)
value = key
if format_kwargs:
try:
return value.format(**format_kwargs)
except (KeyError, IndexError, ValueError) as exc:
logger.warning(
"i18n format failed for key=%r lang=%r kwargs=%r: %s",
key, target, format_kwargs, exc,
)
return value
return value
__all__ = [
"SUPPORTED_LANGUAGES",
"DEFAULT_LANGUAGE",
"t",
"get_language",
"reset_language_cache",
]

View file

@ -513,6 +513,12 @@ PLATFORM_HINTS = {
"image and is the WRONG path. Bare Unicode emoji in text is also not a substitute "
"— when a sticker is the right response, use yb_send_sticker."
),
"api_server": (
"You're responding through an API server. The rendering layer is unknown — "
"assume plain text. No markdown formatting (no asterisks, bullets, headers, "
"code fences). Treat this like a conversation, not a document. Keep responses "
"brief and natural."
),
}
# ---------------------------------------------------------------------------

386
agent/think_scrubber.py Normal file
View file

@ -0,0 +1,386 @@
"""Stateful scrubber for reasoning/thinking blocks in streamed assistant text.
``run_agent._strip_think_blocks`` is regex-based and correct for a complete
string, but when it runs *per-delta* in ``_fire_stream_delta`` it destroys
the state that downstream consumers (CLI ``_stream_delta``, gateway
``GatewayStreamConsumer._filter_and_accumulate``) rely on.
Concretely, when MiniMax-M2.7 streams
delta1 = "<think>"
delta2 = "Let me check their config"
delta3 = "</think>"
the per-delta regex erases delta1 entirely (case 2: unterminated-open at
boundary matches ``^<think>...``), so the downstream state machine never
sees the open tag, treats delta2 as regular content, and leaks reasoning
to the user. Consumers that don't run their own state machine (ACP,
api_server, TTS) never had any defence at all they just emitted
whatever survived the upstream regex.
This module centralises the tag-suppression state machine at the
upstream layer so every stream_delta_callback sees text that has
already had reasoning blocks removed. Partial tags at delta
boundaries are held back until the next delta resolves them, and
end-of-stream flushing surfaces any held-back prose that turned out
not to be a real tag.
Usage::
scrubber = StreamingThinkScrubber()
for delta in stream:
visible = scrubber.feed(delta)
if visible:
emit(visible)
tail = scrubber.flush() # at end of stream
if tail:
emit(tail)
The scrubber is re-entrant per agent instance. Call ``reset()`` at
the top of each new turn so a hung block from an interrupted prior
stream cannot taint the next turn's output.
Tag variants handled (case-insensitive):
``<think>``, ``<thinking>``, ``<reasoning>``, ``<thought>``,
``<REASONING_SCRATCHPAD>``.
Block-boundary rule for opens: an opening tag is only treated as a
reasoning-block opener when it appears at the start of the stream,
after a newline (optionally followed by whitespace), or when only
whitespace has been emitted on the current line. This prevents prose
that *mentions* the tag name (e.g. ``"use <think> tags here"``) from
being incorrectly suppressed. Closed pairs (``<think>X</think>``) are
always suppressed regardless of boundary; a closed pair is an
intentional, bounded construct.
"""
from __future__ import annotations
from typing import Tuple
__all__ = ["StreamingThinkScrubber"]
class StreamingThinkScrubber:
"""Stateful scrubber for streaming reasoning/thinking blocks.
State machine:
- ``_in_block``: True while inside an opened block, waiting for
a close tag. All text inside is discarded.
- ``_buf``: held-back partial-tag tail. Emitted / discarded on
the next ``feed()`` call or by ``flush()``.
- ``_last_emitted_ended_newline``: True iff the most recent
emission to the consumer ended with ``\\n``, or nothing has
been emitted yet (start-of-stream counts as a boundary). Used
to decide whether an open tag at buffer position 0 is at a
block boundary.
"""
_OPEN_TAG_NAMES: Tuple[str, ...] = (
"think",
"thinking",
"reasoning",
"thought",
"REASONING_SCRATCHPAD",
)
# Materialise literal tag strings so the hot path does string
# operations, not regex compilation per feed().
_OPEN_TAGS: Tuple[str, ...] = tuple(f"<{name}>" for name in _OPEN_TAG_NAMES)
_CLOSE_TAGS: Tuple[str, ...] = tuple(f"</{name}>" for name in _OPEN_TAG_NAMES)
# Pre-compute the longest tag (for partial-tag hold-back bound).
_MAX_TAG_LEN: int = max(len(tag) for tag in _OPEN_TAGS + _CLOSE_TAGS)
def __init__(self) -> None:
self._in_block: bool = False
self._buf: str = ""
self._last_emitted_ended_newline: bool = True
def reset(self) -> None:
"""Reset all state. Call at the top of every new turn."""
self._in_block = False
self._buf = ""
self._last_emitted_ended_newline = True
def feed(self, text: str) -> str:
"""Feed one delta; return the scrubbed visible portion.
May return an empty string when the entire delta is reasoning
content or is being held back pending resolution of a partial
tag at the boundary.
"""
if not text:
return ""
buf = self._buf + text
self._buf = ""
out: list[str] = []
while buf:
if self._in_block:
# Hunt for the earliest close tag.
close_idx, close_len = self._find_first_tag(
buf, self._CLOSE_TAGS,
)
if close_idx == -1:
# No close yet — hold back a potential partial
# close-tag prefix; discard everything else.
held = self._max_partial_suffix(buf, self._CLOSE_TAGS)
self._buf = buf[-held:] if held else ""
return "".join(out)
# Found close: discard block content + tag, continue.
buf = buf[close_idx + close_len:]
self._in_block = False
else:
# Priority 1 — closed <tag>X</tag> pair anywhere in
# buf. Closed pairs are always an intentional,
# bounded construct (even mid-line prose containing
# an open/close pair is almost certainly a model
# leaking reasoning inline), so no boundary gating.
pair = self._find_earliest_closed_pair(buf)
# Priority 2 — unterminated open tag at a block
# boundary. Boundary-gated so prose that mentions
# '<think>' isn't over-stripped.
open_idx, open_len = self._find_open_at_boundary(
buf, out,
)
# Pick whichever match comes earliest in the buffer.
if pair is not None and (
open_idx == -1 or pair[0] <= open_idx
):
start_idx, end_idx = pair
preceding = buf[:start_idx]
if preceding:
preceding = self._strip_orphan_close_tags(preceding)
if preceding:
out.append(preceding)
self._last_emitted_ended_newline = (
preceding.endswith("\n")
)
buf = buf[end_idx:]
continue
if open_idx != -1:
# Unterminated open at boundary — emit preceding,
# enter block, continue loop with remainder.
preceding = buf[:open_idx]
if preceding:
preceding = self._strip_orphan_close_tags(preceding)
if preceding:
out.append(preceding)
self._last_emitted_ended_newline = (
preceding.endswith("\n")
)
self._in_block = True
buf = buf[open_idx + open_len:]
continue
# No resolvable tag structure in buf. Hold back any
# partial-tag prefix at the tail so a split tag
# across deltas isn't missed, then emit the rest.
held = self._max_partial_suffix(buf, self._OPEN_TAGS)
held_close = self._max_partial_suffix(
buf, self._CLOSE_TAGS,
)
held = max(held, held_close)
if held:
emit_text = buf[:-held]
self._buf = buf[-held:]
else:
emit_text = buf
self._buf = ""
if emit_text:
emit_text = self._strip_orphan_close_tags(emit_text)
if emit_text:
out.append(emit_text)
self._last_emitted_ended_newline = (
emit_text.endswith("\n")
)
return "".join(out)
return "".join(out)
def flush(self) -> str:
"""End-of-stream flush.
If still inside an unterminated block, held-back content is
discarded leaking partial reasoning is worse than a
truncated answer. Otherwise the held-back partial-tag tail is
emitted verbatim (it turned out not to be a real tag prefix).
"""
if self._in_block:
self._buf = ""
self._in_block = False
return ""
tail = self._buf
self._buf = ""
if not tail:
return ""
tail = self._strip_orphan_close_tags(tail)
if tail:
self._last_emitted_ended_newline = tail.endswith("\n")
return tail
# ── internal helpers ───────────────────────────────────────────────
@staticmethod
def _find_first_tag(
buf: str, tags: Tuple[str, ...],
) -> Tuple[int, int]:
"""Return (earliest_index, tag_length) over *tags*, or (-1, 0).
Case-insensitive match.
"""
buf_lower = buf.lower()
best_idx = -1
best_len = 0
for tag in tags:
idx = buf_lower.find(tag.lower())
if idx != -1 and (best_idx == -1 or idx < best_idx):
best_idx = idx
best_len = len(tag)
return best_idx, best_len
def _find_earliest_closed_pair(self, buf: str):
"""Return (start_idx, end_idx) of the earliest closed pair, else None.
A closed pair is ``<tag>...</tag>`` of any variant. Matches are
case-insensitive and non-greedy (the closest close tag after
an open tag wins), matching the regex ``<tag>.*?</tag>``
semantics of ``_strip_think_blocks`` case 1. When two tag
variants could both match, the one whose open tag appears
earlier wins.
"""
buf_lower = buf.lower()
best: "tuple[int, int] | None" = None
for open_tag, close_tag in zip(self._OPEN_TAGS, self._CLOSE_TAGS):
open_lower = open_tag.lower()
close_lower = close_tag.lower()
open_idx = buf_lower.find(open_lower)
if open_idx == -1:
continue
close_idx = buf_lower.find(
close_lower, open_idx + len(open_lower),
)
if close_idx == -1:
continue
end_idx = close_idx + len(close_lower)
if best is None or open_idx < best[0]:
best = (open_idx, end_idx)
return best
def _find_open_at_boundary(
self, buf: str, already_emitted: list[str],
) -> Tuple[int, int]:
"""Return the earliest block-boundary open-tag (idx, len).
Returns (-1, 0) if no boundary-legal opener is present.
"""
buf_lower = buf.lower()
best_idx = -1
best_len = 0
for tag in self._OPEN_TAGS:
tag_lower = tag.lower()
search_start = 0
while True:
idx = buf_lower.find(tag_lower, search_start)
if idx == -1:
break
if self._is_block_boundary(buf, idx, already_emitted):
if best_idx == -1 or idx < best_idx:
best_idx = idx
best_len = len(tag)
break # first boundary hit for this tag is enough
search_start = idx + 1
return best_idx, best_len
def _is_block_boundary(
self, buf: str, idx: int, already_emitted: list[str],
) -> bool:
"""True iff position *idx* in *buf* is a block boundary.
A block boundary is:
- buf position 0 AND the most recent emission ended with
a newline (or nothing has been emitted yet)
- any position whose preceding text on the current line
(since the last newline in buf) is whitespace-only, AND
if there is no newline in the preceding buf portion, the
most recent prior emission ended with a newline
"""
if idx == 0:
# Check whether the last already-emitted chunk in THIS
# feed() call ended with a newline, otherwise fall back
# to the cross-feed flag.
if already_emitted:
return already_emitted[-1].endswith("\n")
return self._last_emitted_ended_newline
preceding = buf[:idx]
last_nl = preceding.rfind("\n")
if last_nl == -1:
# No newline in buf before the tag — boundary only if the
# prior emission ended with a newline AND everything since
# is whitespace.
if already_emitted:
prior_newline = already_emitted[-1].endswith("\n")
else:
prior_newline = self._last_emitted_ended_newline
return prior_newline and preceding.strip() == ""
# Newline present — text between it and the tag must be
# whitespace-only.
return preceding[last_nl + 1:].strip() == ""
@classmethod
def _max_partial_suffix(
cls, buf: str, tags: Tuple[str, ...],
) -> int:
"""Return the longest buf-suffix that is a prefix of any tag.
Only prefixes strictly shorter than the tag itself count
(full-length suffixes are the tag and are handled as matches,
not held-back partials). Case-insensitive.
"""
if not buf:
return 0
buf_lower = buf.lower()
max_check = min(len(buf_lower), cls._MAX_TAG_LEN - 1)
for i in range(max_check, 0, -1):
suffix = buf_lower[-i:]
for tag in tags:
tag_lower = tag.lower()
if len(tag_lower) > i and tag_lower.startswith(suffix):
return i
return 0
@classmethod
def _strip_orphan_close_tags(cls, text: str) -> str:
"""Remove any close tags from *text* (orphan-close handling).
An orphan close tag has no matching open in the current
scrubber state; it's always noise, stripped with any trailing
whitespace so the surrounding prose flows naturally.
"""
if "</" not in text:
return text
text_lower = text.lower()
out: list[str] = []
i = 0
while i < len(text):
matched = False
if text_lower[i:i + 2] == "</":
for tag in cls._CLOSE_TAGS:
tag_lower = tag.lower()
tag_len = len(tag_lower)
if text_lower[i:i + tag_len] == tag_lower:
# Skip the tag and any trailing whitespace,
# matching _strip_think_blocks case 3.
j = i + tag_len
while j < len(text) and text[j] in " \t\n\r":
j += 1
i = j
matched = True
break
if not matched:
out.append(text[i])
i += 1
return "".join(out)

13
cli.py
View file

@ -1890,8 +1890,8 @@ _skill_commands = scan_skill_commands()
def _get_plugin_cmd_handler_names() -> set:
"""Return plugin command names (without slash prefix) for dispatch matching."""
try:
from hermes_cli.plugins import get_plugin_manager
return set(get_plugin_manager()._plugin_commands.keys())
from hermes_cli.plugins import get_plugin_commands
return set(get_plugin_commands().keys())
except Exception:
return set()
@ -2145,7 +2145,10 @@ class HermesCLI:
elif CLI_CONFIG.get("max_turns"): # Backwards compat: root-level max_turns
self.max_turns = CLI_CONFIG["max_turns"]
elif os.getenv("HERMES_MAX_ITERATIONS"):
self.max_turns = int(os.getenv("HERMES_MAX_ITERATIONS"))
try:
self.max_turns = int(os.getenv("HERMES_MAX_ITERATIONS", ""))
except (TypeError, ValueError):
self.max_turns = 90
else:
self.max_turns = 90
@ -7659,6 +7662,10 @@ class HermesCLI:
):
self.session_id = self.agent.session_id
self._pending_title = None
# Manual /compress replaces conversation_history with a new
# compressed handoff for the child session. Persist it from
# offset 0 so resume can recover the continuation after exit.
self.agent._flush_messages_to_session_db(self.conversation_history, None)
new_tokens = estimate_request_tokens_rough(
self.conversation_history,
system_prompt=_sys_prompt,

View file

@ -114,12 +114,20 @@ from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_
# locally for audit.
SILENT_MARKER = "[SILENT]"
# Resolve Hermes home directory (respects HERMES_HOME override)
_hermes_home = get_hermes_home()
# Backward-compatible module override used by tests and emergency monkeypatches.
_hermes_home: Path | None = None
# File-based lock prevents concurrent ticks from gateway + daemon + systemd timer
_LOCK_DIR = _hermes_home / "cron"
_LOCK_FILE = _LOCK_DIR / ".tick.lock"
def _get_hermes_home() -> Path:
"""Resolve Hermes home dynamically while preserving test monkeypatch hooks."""
return _hermes_home or get_hermes_home()
def _get_lock_paths() -> tuple[Path, Path]:
"""Resolve cron lock paths at call time so profile/env changes are honored."""
hermes_home = _get_hermes_home()
lock_dir = hermes_home / "cron"
return lock_dir, lock_dir / ".tick.lock"
def _resolve_origin(job: dict) -> Optional[dict]:
@ -597,7 +605,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
"""
from hermes_constants import get_hermes_home
scripts_dir = get_hermes_home() / "scripts"
scripts_dir = _get_hermes_home() / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
scripts_dir_resolved = scripts_dir.resolve()
@ -1058,9 +1066,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
# changes take effect without a gateway restart.
from dotenv import load_dotenv
try:
load_dotenv(str(_hermes_home / ".env"), override=True, encoding="utf-8")
load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="utf-8")
except UnicodeDecodeError:
load_dotenv(str(_hermes_home / ".env"), override=True, encoding="latin-1")
load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="latin-1")
delivery_target = _resolve_delivery_target(job)
if delivery_target:
@ -1078,7 +1086,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
_cfg = {}
try:
import yaml
_cfg_path = str(_hermes_home / "config.yaml")
_cfg_path = str(_get_hermes_home() / "config.yaml")
if os.path.exists(_cfg_path):
with open(_cfg_path) as _f:
_cfg = yaml.safe_load(_f) or {}
@ -1112,7 +1120,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
if prefill_file:
pfpath = Path(prefill_file).expanduser()
if not pfpath.is_absolute():
pfpath = _hermes_home / pfpath
pfpath = _get_hermes_home() / pfpath
if pfpath.exists():
try:
with open(pfpath, "r", encoding="utf-8") as _pf:
@ -1436,12 +1444,13 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
Returns:
Number of jobs executed (0 if another tick is already running)
"""
_LOCK_DIR.mkdir(parents=True, exist_ok=True)
lock_dir, lock_file = _get_lock_paths()
lock_dir.mkdir(parents=True, exist_ok=True)
# Cross-platform file locking: fcntl on Unix, msvcrt on Windows
lock_fd = None
try:
lock_fd = open(_LOCK_FILE, "w")
lock_fd = open(lock_file, "w")
if fcntl:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
elif msvcrt:

View file

@ -845,6 +845,16 @@ def load_gateway_config() -> GatewayConfig:
):
if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()
# reply_to_mode: top-level preferred, falls back to extra.reply_to_mode
# YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
_discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {}
_discord_rtm = (
discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg
else _discord_extra.get("reply_to_mode")
)
if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"):
_rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower()
os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str
# Bridge top-level require_mention to Telegram when the telegram: section
# does not already provide one. Users often write "require_mention: true"
@ -881,6 +891,16 @@ def load_gateway_config() -> GatewayConfig:
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"):
os.environ["TELEGRAM_PROXY"] = str(telegram_cfg["proxy_url"]).strip()
# reply_to_mode: top-level preferred, falls back to extra.reply_to_mode
# YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
_telegram_extra = telegram_cfg.get("extra") if isinstance(telegram_cfg.get("extra"), dict) else {}
_telegram_rtm = (
telegram_cfg["reply_to_mode"] if "reply_to_mode" in telegram_cfg
else _telegram_extra.get("reply_to_mode")
)
if _telegram_rtm is not None and not os.getenv("TELEGRAM_REPLY_TO_MODE"):
_rtm_str = "off" if _telegram_rtm is False else str(_telegram_rtm).lower()
os.environ["TELEGRAM_REPLY_TO_MODE"] = _rtm_str
allowed_users = telegram_cfg.get("allow_from")
if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"):
if isinstance(allowed_users, list):

View file

@ -2,8 +2,8 @@
OpenAI-compatible API server platform adapter.
Exposes an HTTP server with endpoints:
- POST /v1/chat/completions OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header)
- POST /v1/responses OpenAI Responses API format (stateful via previous_response_id)
- POST /v1/chat/completions OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header; opt-in long-term memory scoping via X-Hermes-Session-Key header)
- POST /v1/responses OpenAI Responses API format (stateful via previous_response_id; X-Hermes-Session-Key supported)
- GET /v1/responses/{response_id} Retrieve a stored response
- DELETE /v1/responses/{response_id} Delete a stored response
- GET /v1/models lists hermes-agent as an available model
@ -698,6 +698,71 @@ class APIServerAdapter(BasePlatformAdapter):
status=401,
)
# ------------------------------------------------------------------
# Session header helpers
# ------------------------------------------------------------------
# Soft length cap for session identifiers. Headers are bounded in
# aggregate by aiohttp (``client_max_size`` / default 8 KiB per
# header), but we impose a tighter limit on the session headers so a
# caller can't burn memory by passing a multi-kilobyte "session key".
# 256 chars is well above any realistic stable channel identifier
# (e.g. ``agent:main:webui:dm:user-42``) while staying small enough
# that the sanitized form is safe to pass into Honcho / state.db.
_MAX_SESSION_HEADER_LEN = 256
def _parse_session_key_header(
self, request: "web.Request"
) -> tuple[Optional[str], Optional["web.Response"]]:
"""Extract and validate the ``X-Hermes-Session-Key`` header.
The session key is a stable per-channel identifier that scopes
long-term memory (e.g. Honcho sessions) across transcripts. It
is independent of ``X-Hermes-Session-Id``: callers may send
either, both, or neither.
Returns ``(session_key, None)`` on success (with an empty/absent
header yielding ``None`` for the key), or ``(None, error_response)``
on validation failure.
Security: like session continuation, accepting a caller-supplied
memory scope requires API-key authentication so that an
unauthenticated client on a local-only server can't inject itself
into another user's long-term memory scope by guessing a key.
"""
raw = request.headers.get("X-Hermes-Session-Key", "").strip()
if not raw:
return None, None
if not self._api_key:
logger.warning(
"X-Hermes-Session-Key rejected: no API key configured. "
"Set API_SERVER_KEY to enable long-term memory scoping."
)
return None, web.json_response(
_openai_error(
"X-Hermes-Session-Key requires API key authentication. "
"Configure API_SERVER_KEY to enable this feature."
),
status=403,
)
# Reject control characters that could enable header injection on
# the echo path.
if re.search(r'[\r\n\x00]', raw):
return None, web.json_response(
{"error": {"message": "Invalid session key", "type": "invalid_request_error"}},
status=400,
)
if len(raw) > self._MAX_SESSION_HEADER_LEN:
return None, web.json_response(
{"error": {"message": "Session key too long", "type": "invalid_request_error"}},
status=400,
)
return raw, None
# ------------------------------------------------------------------
# Session DB helper
# ------------------------------------------------------------------
@ -728,6 +793,7 @@ class APIServerAdapter(BasePlatformAdapter):
tool_progress_callback=None,
tool_start_callback=None,
tool_complete_callback=None,
gateway_session_key: Optional[str] = None,
) -> Any:
"""
Create an AIAgent instance using the gateway's runtime config.
@ -736,6 +802,13 @@ class APIServerAdapter(BasePlatformAdapter):
base_url, etc. from config.yaml / env vars. Toolsets are resolved
from config.yaml platform_toolsets.api_server (same as all other
gateway platforms), falling back to the hermes-api-server default.
``gateway_session_key`` is a stable per-channel identifier supplied
by the client (via ``X-Hermes-Session-Key``). Unlike ``session_id``
which scopes the short-term transcript and rotates on /new, this
key is meant to persist across transcripts so long-term memory
providers (e.g. Honcho) can scope their per-chat state correctly
matching the semantics of the native gateway's ``session_key``.
"""
from run_agent import AIAgent
from gateway.run import _resolve_runtime_agent_kwargs, _resolve_gateway_model, _load_gateway_config, GatewayRunner
@ -771,6 +844,7 @@ class APIServerAdapter(BasePlatformAdapter):
session_db=self._ensure_session_db(),
fallback_model=fallback_model,
reasoning_config=reasoning_config,
gateway_session_key=gateway_session_key,
)
return agent
@ -854,6 +928,7 @@ class APIServerAdapter(BasePlatformAdapter):
"run_stop": True,
"tool_progress_events": True,
"session_continuity_header": "X-Hermes-Session-Id",
"session_key_header": "X-Hermes-Session-Key",
"cors": bool(self._cors_origins),
},
"endpoints": {
@ -925,6 +1000,15 @@ class APIServerAdapter(BasePlatformAdapter):
status=400,
)
# Allow caller to scope long-term memory (e.g. Honcho) with a
# stable per-channel identifier via X-Hermes-Session-Key. This
# is independent of X-Hermes-Session-Id: the key persists across
# transcripts while the id rotates when the caller starts a new
# transcript (i.e. /new semantics). See _parse_session_key_header.
gateway_session_key, key_err = self._parse_session_key_header(request)
if key_err is not None:
return key_err
# Allow caller to continue an existing session by passing X-Hermes-Session-Id.
# When provided, history is loaded from state.db instead of from the request body.
#
@ -1059,11 +1143,13 @@ class APIServerAdapter(BasePlatformAdapter):
tool_start_callback=_on_tool_start,
tool_complete_callback=_on_tool_complete,
agent_ref=agent_ref,
gateway_session_key=gateway_session_key,
))
return await self._write_sse_chat_completion(
request, completion_id, model_name, created, _stream_q,
agent_task, agent_ref, session_id=session_id,
gateway_session_key=gateway_session_key,
)
# Non-streaming: run the agent (with optional Idempotency-Key)
@ -1073,6 +1159,7 @@ class APIServerAdapter(BasePlatformAdapter):
conversation_history=history,
ephemeral_system_prompt=system_prompt,
session_id=session_id,
gateway_session_key=gateway_session_key,
)
idempotency_key = request.headers.get("Idempotency-Key")
@ -1122,11 +1209,17 @@ class APIServerAdapter(BasePlatformAdapter):
},
}
return web.json_response(response_data, headers={"X-Hermes-Session-Id": session_id})
response_headers = {
"X-Hermes-Session-Id": result.get("session_id", session_id),
}
if gateway_session_key:
response_headers["X-Hermes-Session-Key"] = gateway_session_key
return web.json_response(response_data, headers=response_headers)
async def _write_sse_chat_completion(
self, request: "web.Request", completion_id: str, model: str,
created: int, stream_q, agent_task, agent_ref=None, session_id: str = None,
gateway_session_key: str = None,
) -> "web.StreamResponse":
"""Write real streaming SSE from agent's stream_delta_callback queue.
@ -1149,6 +1242,8 @@ class APIServerAdapter(BasePlatformAdapter):
sse_headers.update(cors)
if session_id:
sse_headers["X-Hermes-Session-Id"] = session_id
if gateway_session_key:
sse_headers["X-Hermes-Session-Key"] = gateway_session_key
response = web.StreamResponse(status=200, headers=sse_headers)
await response.prepare(request)
@ -1272,6 +1367,7 @@ class APIServerAdapter(BasePlatformAdapter):
conversation: Optional[str],
store: bool,
session_id: str,
gateway_session_key: Optional[str] = None,
) -> "web.StreamResponse":
"""Write an SSE stream for POST /v1/responses (OpenAI Responses API).
@ -1314,6 +1410,8 @@ class APIServerAdapter(BasePlatformAdapter):
sse_headers.update(cors)
if session_id:
sse_headers["X-Hermes-Session-Id"] = session_id
if gateway_session_key:
sse_headers["X-Hermes-Session-Key"] = gateway_session_key
response = web.StreamResponse(status=200, headers=sse_headers)
await response.prepare(request)
@ -1763,6 +1861,11 @@ class APIServerAdapter(BasePlatformAdapter):
if auth_err:
return auth_err
# Long-term memory scope header (see chat_completions for details).
gateway_session_key, key_err = self._parse_session_key_header(request)
if key_err is not None:
return key_err
# Parse request body
try:
body = await request.json()
@ -1914,6 +2017,7 @@ class APIServerAdapter(BasePlatformAdapter):
tool_start_callback=_on_tool_start,
tool_complete_callback=_on_tool_complete,
agent_ref=agent_ref,
gateway_session_key=gateway_session_key,
))
response_id = f"resp_{uuid.uuid4().hex[:28]}"
@ -1934,6 +2038,7 @@ class APIServerAdapter(BasePlatformAdapter):
conversation=conversation,
store=store,
session_id=session_id,
gateway_session_key=gateway_session_key,
)
async def _compute_response():
@ -1942,6 +2047,7 @@ class APIServerAdapter(BasePlatformAdapter):
conversation_history=conversation_history,
ephemeral_system_prompt=instructions,
session_id=session_id,
gateway_session_key=gateway_session_key,
)
idempotency_key = request.headers.get("Idempotency-Key")
@ -2016,7 +2122,10 @@ class APIServerAdapter(BasePlatformAdapter):
if conversation:
self._response_store.set_conversation(conversation, response_id)
return web.json_response(response_data)
response_headers = {"X-Hermes-Session-Id": session_id}
if gateway_session_key:
response_headers["X-Hermes-Session-Key"] = gateway_session_key
return web.json_response(response_data, headers=response_headers)
# ------------------------------------------------------------------
# GET / DELETE response endpoints
@ -2338,6 +2447,7 @@ class APIServerAdapter(BasePlatformAdapter):
tool_start_callback=None,
tool_complete_callback=None,
agent_ref: Optional[list] = None,
gateway_session_key: Optional[str] = None,
) -> tuple:
"""
Create an agent and run a conversation in a thread executor.
@ -2360,6 +2470,7 @@ class APIServerAdapter(BasePlatformAdapter):
tool_progress_callback=tool_progress_callback,
tool_start_callback=tool_start_callback,
tool_complete_callback=tool_complete_callback,
gateway_session_key=gateway_session_key,
)
if agent_ref is not None:
agent_ref[0] = agent
@ -2374,6 +2485,12 @@ class APIServerAdapter(BasePlatformAdapter):
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
}
# Include the effective session ID in the result so callers
# (e.g. X-Hermes-Session-Id header) can track compression-
# triggered session rotations. (#16938)
_eff_sid = getattr(agent, "session_id", session_id)
if isinstance(_eff_sid, str) and _eff_sid:
result["session_id"] = _eff_sid
return result, usage
return await loop.run_in_executor(None, _run)
@ -2453,6 +2570,11 @@ class APIServerAdapter(BasePlatformAdapter):
if auth_err:
return auth_err
# Long-term memory scope header (see chat_completions for details).
gateway_session_key, key_err = self._parse_session_key_header(request)
if key_err is not None:
return key_err
# Enforce concurrency limit
if len(self._run_streams) >= self._MAX_CONCURRENT_RUNS:
return web.json_response(
@ -2561,6 +2683,7 @@ class APIServerAdapter(BasePlatformAdapter):
session_id=session_id,
stream_delta_callback=_text_cb,
tool_progress_callback=event_cb,
gateway_session_key=gateway_session_key,
)
self._active_run_agents[run_id] = agent
def _run_sync():
@ -2661,7 +2784,14 @@ class APIServerAdapter(BasePlatformAdapter):
if hasattr(task, "add_done_callback"):
task.add_done_callback(self._background_tasks.discard)
return web.json_response({"run_id": run_id, "status": "started"}, status=202)
response_headers = (
{"X-Hermes-Session-Key": gateway_session_key} if gateway_session_key else {}
)
return web.json_response(
{"run_id": run_id, "status": "started"},
status=202,
headers=response_headers,
)
async def _handle_get_run(self, request: "web.Request") -> "web.Response":
"""GET /v1/runs/{run_id} — return pollable run status for external UIs."""

View file

@ -2675,10 +2675,18 @@ class BasePlatformAdapter(ABC):
mode = os.getenv("HERMES_HUMAN_DELAY_MODE", "off").lower()
if mode == "off":
return 0.0
min_ms = int(os.getenv("HERMES_HUMAN_DELAY_MIN_MS", "800"))
max_ms = int(os.getenv("HERMES_HUMAN_DELAY_MAX_MS", "2500"))
if mode == "natural":
min_ms, max_ms = 800, 2500
return random.uniform(min_ms / 1000.0, max_ms / 1000.0)
# custom mode — tolerate malformed env vars instead of crashing.
try:
min_ms = int(os.getenv("HERMES_HUMAN_DELAY_MIN_MS", "800"))
except (TypeError, ValueError):
min_ms = 800
try:
max_ms = int(os.getenv("HERMES_HUMAN_DELAY_MAX_MS", "2500"))
except (TypeError, ValueError):
max_ms = 2500
return random.uniform(min_ms / 1000.0, max_ms / 1000.0)
async def _process_message_background(self, event: MessageEvent, session_key: str) -> None:

View file

@ -153,6 +153,9 @@ _MARKDOWN_HINT_RE = re.compile(
r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(<u>.+?</u>)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)",
re.MULTILINE,
)
# Detect markdown tables: a line starting with | followed by a separator line.
# Feishu post-type 'md' elements do not render tables, so we force text mode.
_MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", re.MULTILINE)
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$")
_MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^```\s*$")
@ -3862,47 +3865,50 @@ class FeishuAdapter(BasePlatformAdapter):
and self-sent bot event filtering.
Populates ``_bot_open_id`` and ``_bot_name`` from /open-apis/bot/v3/info
(no extra scopes required beyond the tenant access token). Falls back to
the application info endpoint for ``_bot_name`` only when the first probe
doesn't return it. Each field is hydrated independently — a value already
supplied via env vars (FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID /
FEISHU_BOT_NAME) is preserved and skips its probe.
(no extra scopes required beyond the tenant access token). The probe
always runs when a client is available so stale env vars from app/bot
migrations do not break group @mention gating. Falls back to the
application info endpoint for ``_bot_name`` only when the first probe
doesn't return it. If the probe fails, env-provided values are preserved.
"""
if not self._client:
return
if self._bot_open_id and self._bot_name:
# Everything the self-send filter and precise mention gate need is
# already in place; nothing to probe.
return
# Primary probe: /open-apis/bot/v3/info — returns bot_name + open_id, no
# extra scopes required. This is the same endpoint the onboarding wizard
# uses via probe_bot().
if not self._bot_open_id or not self._bot_name:
try:
req = (
BaseRequest.builder()
.http_method(HttpMethod.GET)
.uri("/open-apis/bot/v3/info")
.token_types({AccessTokenType.TENANT})
.build()
)
resp = await asyncio.to_thread(self._client.request, req)
content = getattr(getattr(resp, "raw", None), "content", None)
if content:
payload = json.loads(content)
parsed = _parse_bot_response(payload) or {}
open_id = (parsed.get("bot_open_id") or "").strip()
bot_name = (parsed.get("bot_name") or "").strip()
if open_id and not self._bot_open_id:
self._bot_open_id = open_id
if bot_name and not self._bot_name:
self._bot_name = bot_name
except Exception:
logger.debug(
"[Feishu] /bot/v3/info probe failed during hydration",
exc_info=True,
)
try:
req = (
BaseRequest.builder()
.http_method(HttpMethod.GET)
.uri("/open-apis/bot/v3/info")
.token_types({AccessTokenType.TENANT})
.build()
)
resp = await asyncio.to_thread(self._client.request, req)
content = getattr(getattr(resp, "raw", None), "content", None)
if content:
payload = json.loads(content)
parsed = _parse_bot_response(payload) or {}
open_id = (parsed.get("bot_open_id") or "").strip()
bot_name = (parsed.get("bot_name") or "").strip()
if open_id:
if self._bot_open_id and self._bot_open_id != open_id:
logger.warning(
"[Feishu] FEISHU_BOT_OPEN_ID is stale; using /bot/v3/info open_id for group @mention gating."
)
self._bot_open_id = open_id
if bot_name:
if self._bot_name and self._bot_name != bot_name:
logger.info(
"[Feishu] FEISHU_BOT_NAME differs from /bot/v3/info; using hydrated bot name for group @mention gating."
)
self._bot_name = bot_name
except Exception:
logger.debug(
"[Feishu] /bot/v3/info probe failed during hydration",
exc_info=True,
)
# Fallback probe for _bot_name only: application info endpoint. Needs
# admin:app.info:readonly or application:application:self_manage scope,
@ -3947,7 +3953,14 @@ class FeishuAdapter(BasePlatformAdapter):
if isinstance(seen_data, list):
entries: Dict[str, float] = {str(item).strip(): 0.0 for item in seen_data if str(item).strip()}
elif isinstance(seen_data, dict):
entries = {k: float(v) for k, v in seen_data.items() if isinstance(k, str) and k.strip()}
entries = {}
for key, value in seen_data.items():
if not isinstance(key, str) or not key.strip():
continue
try:
entries[key] = float(value)
except (TypeError, ValueError):
continue
else:
return
# Filter out TTL-expired entries (entries saved with ts=0.0 are treated as immortal
@ -3992,6 +4005,12 @@ class FeishuAdapter(BasePlatformAdapter):
# =========================================================================
def _build_outbound_payload(self, content: str) -> tuple[str, str]:
# Feishu post-type 'md' elements do not render markdown tables; sending
# table content as post causes the message to appear blank on the client.
# Force plain text for anything that looks like a markdown table.
if _MARKDOWN_TABLE_RE.search(content):
text_payload = {"text": content}
return "text", json.dumps(text_payload, ensure_ascii=False)
if _MARKDOWN_HINT_RE.search(content):
return "post", _build_markdown_post_payload(content)
text_payload = {"text": content}
@ -4087,7 +4106,15 @@ class FeishuAdapter(BasePlatformAdapter):
content=payload,
uuid_value=str(uuid.uuid4()),
)
request = self._build_create_message_request("chat_id", body)
# Detect whether chat_id is a user open_id (DM) or a chat_id (group).
# Feishu API expects receive_id_type="open_id" for user DMs (ou_ prefix)
# and receive_id_type="chat_id" for group chats (oc_ prefix, which IS
# the chat_id format — see https://open.feishu.cn/document/).
if chat_id.startswith("ou_"):
receive_id_type = "open_id"
else:
receive_id_type = "chat_id"
request = self._build_create_message_request(receive_id_type, body)
return await asyncio.to_thread(self._client.im.v1.message.create, request)
@staticmethod

View file

@ -222,33 +222,37 @@ class ThreadParticipationTracker:
def __init__(self, platform_name: str, max_tracked: int = 500):
self._platform = platform_name
self._max_tracked = max_tracked
self._threads: set = self._load()
self._threads: dict[str, None] = {
str(thread_id): None for thread_id in self._load()
}
def _state_path(self) -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / f"{self._platform}_threads.json"
def _load(self) -> set:
def _load(self) -> list[str]:
path = self._state_path()
if path.exists():
try:
return set(json.loads(path.read_text(encoding="utf-8")))
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, list):
return [str(thread_id) for thread_id in data]
except Exception:
pass
return set()
return []
def _save(self) -> None:
path = self._state_path()
thread_list = list(self._threads)
if len(thread_list) > self._max_tracked:
thread_list = thread_list[-self._max_tracked:]
self._threads = set(thread_list)
self._threads = {thread_id: None for thread_id in thread_list}
atomic_json_write(path, thread_list, indent=None)
def mark(self, thread_id: str) -> None:
"""Mark *thread_id* as participated and persist."""
if thread_id not in self._threads:
self._threads.add(thread_id)
self._threads[thread_id] = None
self._save()
def __contains__(self, thread_id: str) -> bool:

View file

@ -185,10 +185,13 @@ async def _query_doh_provider(
async def discover_fallback_ips() -> list[str]:
"""Auto-discover Telegram API IPs via DNS-over-HTTPS.
Resolves api.telegram.org through Google and Cloudflare DoH, collects all
unique IPs, and excludes the system-DNS-resolved IP (which is presumably
unreachable on this network). Falls back to a hardcoded seed list when DoH
is also unavailable.
Resolves api.telegram.org through Google and Cloudflare DoH and returns all
unique A records. IPs that match the local system resolver are kept rather
than excluded: in many networks the system-DNS IP is the most reliable path
to api.telegram.org and a transient primary-path failure should be retried
against the same address via the IP-rewrite path before the seed list is
consulted (#14520). Falls back to a hardcoded seed list only when DoH
yields no usable answers.
"""
async with httpx.AsyncClient(timeout=httpx.Timeout(_DOH_TIMEOUT)) as client:
doh_tasks = [_query_doh_provider(client, p) for p in _DOH_PROVIDERS]
@ -203,11 +206,11 @@ async def discover_fallback_ips() -> list[str]:
if isinstance(r, list):
doh_ips.extend(r)
# Deduplicate preserving order, exclude system-DNS IPs
# Deduplicate preserving order
seen: set[str] = set()
candidates: list[str] = []
for ip in doh_ips:
if ip not in seen and ip not in system_ips:
if ip not in seen:
seen.add(ip)
candidates.append(ip)
@ -219,7 +222,7 @@ async def discover_fallback_ips() -> list[str]:
return validated
logger.info(
"DoH discovery yielded no new IPs (system DNS: %s); using seed fallback IPs %s",
"DoH discovery yielded no usable IPs (system DNS: %s); using seed fallback IPs %s",
", ".join(system_ips) or "unknown",
", ".join(_SEED_FALLBACK_IPS),
)

View file

@ -1015,6 +1015,8 @@ class WeComAdapter(BasePlatformAdapter):
if not aes_key:
raise ValueError("aes_key is required")
# WeCom doesn't pad base64 keys; add padding if needed
aes_key = aes_key + '=' * ((4 - len(aes_key) % 4) % 4)
key = base64.b64decode(aes_key)
if len(key) != 32:
raise ValueError(f"Invalid WeCom AES key length: expected 32 bytes, got {len(key)}")

View file

@ -39,6 +39,7 @@ from typing import Dict, Optional, Any, List, Union
# gateway is a long-running daemon, so its boot cost matters less than
# preserving the established test-patch surface.
from agent.account_usage import fetch_account_usage, render_account_usage_lines
from agent.i18n import t
from hermes_cli.config import cfg_get
# --- Agent cache tuning ---------------------------------------------------
@ -93,153 +94,6 @@ def _telegramize_command_mentions(text: str, platform: Any) -> str:
_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT = 60 * 60
# --- Stale-code self-check ------------------------------------------------
# Long-running gateway processes that survive an ``hermes update`` keep the
# old ``hermes_cli.config`` (and friends) cached in ``sys.modules``. When
# the updated tool files on disk then try to ``from hermes_cli.config
# import cfg_get`` (added in PR #17304), the import resolves against the
# already-loaded stale module object and raises ``ImportError`` — see
# Issue #17648. Rather than papering over the import failure site-by-site
# in every tool file, detect the stale state centrally and auto-restart
# so the gateway reloads with fresh code.
#
# The signal we use is ``git rev-parse HEAD`` — the only thing ``hermes
# update`` moves that is NOT moved by agent-driven file edits. Earlier
# revisions of this check compared file mtimes across a sentinel set
# (run_agent.py, gateway/run.py, ...), but that produced false positives
# whenever the agent edited its own source files during a session:
# mtime jumps, stale-check fires, gateway restarts, user must retype.
# See the conversation at PR #<this> for the motivating incident.
#
# The legacy mtime sentinels are kept ONLY as a last-resort fallback for
# non-git installs (pip install from wheel, sparse clones with no .git
# dir). In those environments ``hermes update`` is not a supported path,
# so the check effectively no-ops — which is the safe behavior: better
# to ship one broken import than to restart on every agent-edit.
_STALE_CODE_SENTINELS: tuple[str, ...] = (
"hermes_cli/config.py",
"hermes_cli/__init__.py",
"run_agent.py",
"gateway/run.py",
"pyproject.toml",
)
# Cache git HEAD reads across consecutive messages so a chat burst doesn't
# spawn one subprocess per message. 5s is long enough to collapse a burst
# and short enough that the real post-update detection still fires within
# the user's perceived "next message" window.
_GIT_SHA_CACHE_TTL_SECS = 5.0
def _read_git_head_sha(repo_root: Path) -> Optional[str]:
"""Return the git HEAD SHA for ``repo_root``, or None if unavailable.
Reads ``.git/HEAD`` directly (and follows one level of ref) instead
of shelling out to ``git`` cheaper, no subprocess tax, works on
gateway hosts that don't have a ``git`` binary on PATH. Returns
None for non-git installs (no ``.git`` dir) or any I/O error; callers
treat None as "can't tell" and skip the check.
Supports the three layouts we care about:
1. Main checkout: ``<repo>/.git/`` is a directory.
2. Git worktree: ``<repo>/.git`` is a file ``gitdir: <path>`` that
points at ``<main>/.git/worktrees/<name>/``. The worktree's
gitdir has HEAD + index but NOT refs/heads/ those live in
the main checkout, and ``<worktree-gitdir>/commondir`` points
at the main ``.git``. We search both locations for refs.
3. Packed refs: ``refs/heads/<branch>`` is absent on disk but
listed in ``<main-git-dir>/packed-refs``.
"""
try:
git_dir = repo_root / ".git"
# Worktrees store ``.git`` as a file pointing at gitdir: <path>
if git_dir.is_file():
try:
content = git_dir.read_text().strip()
if content.startswith("gitdir:"):
git_dir = Path(content.split(":", 1)[1].strip())
if not git_dir.is_absolute():
git_dir = (repo_root / git_dir).resolve()
except OSError:
return None
if not git_dir.is_dir():
return None
# Figure out the "common" git dir — the one that owns shared refs.
# For a worktree, commondir points at it (relative path, resolve
# against git_dir). For a main checkout, common_dir == git_dir.
common_dir = git_dir
commondir_file = git_dir / "commondir"
if commondir_file.is_file():
try:
rel = commondir_file.read_text().strip()
candidate = (git_dir / rel).resolve() if rel else git_dir
if candidate.is_dir():
common_dir = candidate
except OSError:
pass
head_path = git_dir / "HEAD"
if not head_path.is_file():
return None
head_content = head_path.read_text().strip()
if head_content.startswith("ref:"):
# Symbolic ref — follow one level (e.g. ref: refs/heads/main).
# Worktree-local refs (bisect, rebase-merge state) live under
# git_dir; shared refs (refs/heads/*, refs/tags/*) live under
# common_dir. Try git_dir first, then common_dir.
ref_rel = head_content.split(":", 1)[1].strip()
for base in (git_dir, common_dir) if git_dir != common_dir else (git_dir,):
ref_path = base / ref_rel
if ref_path.is_file():
try:
sha = ref_path.read_text().strip()
except OSError:
continue
if sha:
return sha
# Packed refs fallback — always stored in the common dir.
packed = common_dir / "packed-refs"
if packed.is_file():
try:
for line in packed.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or line.startswith("^"):
continue
parts = line.split(None, 1)
if len(parts) == 2 and parts[1] == ref_rel:
return parts[0] or None
except OSError:
return None
return None
# Detached HEAD — content is the SHA directly.
return head_content or None
except Exception:
return None
def _compute_repo_mtime(repo_root: Path) -> float:
"""Return the newest mtime across the stale-code sentinel files.
Legacy fallback used only for non-git installs (``.git`` missing).
Missing files are ignored (they may not exist on older checkouts).
Returns 0.0 if no sentinel file is readable treat that as "can't
tell", which downstream callers interpret as "not stale" to avoid
false-positive restart loops.
"""
newest = 0.0
for rel in _STALE_CODE_SENTINELS:
try:
st = (repo_root / rel).stat()
except (OSError, FileNotFoundError):
continue
if st.st_mtime > newest:
newest = st.st_mtime
return newest
def _coerce_gateway_timestamp(value: Any) -> Optional[float]:
"""Best-effort conversion of stored gateway timestamps to epoch seconds.
@ -1107,13 +961,6 @@ class GatewayRunner:
_stop_task: Optional[asyncio.Task] = None
_session_model_overrides: Dict[str, Dict[str, str]] = {}
_session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
# Stale-code self-check defaults (see _detect_stale_code()). Class-level
# so tests that construct GatewayRunner via ``object.__new__`` without
# running __init__ don't crash when _handle_message reads these.
_boot_wall_time: float = 0.0
_boot_repo_mtime: float = 0.0
_boot_git_sha: Optional[str] = None
_stale_code_restart_triggered: bool = False
def __init__(self, config: Optional[GatewayConfig] = None):
global _gateway_runner_ref
@ -1122,30 +969,6 @@ class GatewayRunner:
self._warn_if_docker_media_delivery_is_risky()
_gateway_runner_ref = _weakref.ref(self)
# Boot-time snapshot used by the stale-code self-check. Captured
# before any work happens so post-update file writes are guaranteed
# to have newer mtimes. See _detect_stale_code() / Issue #17648.
try:
self._boot_wall_time: float = time.time()
self._repo_root_for_staleness: Path = Path(__file__).resolve().parent.parent
self._boot_git_sha: Optional[str] = _read_git_head_sha(
self._repo_root_for_staleness,
)
self._boot_repo_mtime: float = _compute_repo_mtime(
self._repo_root_for_staleness,
)
except Exception:
self._boot_wall_time = 0.0
self._repo_root_for_staleness = Path(".")
self._boot_git_sha = None
self._boot_repo_mtime = 0.0
self._stale_code_notified: set[str] = set()
self._stale_code_restart_triggered: bool = False
# Cached current-SHA read, refreshed at most every
# _GIT_SHA_CACHE_TTL_SECS so bursty chats don't hammer the filesystem.
self._cached_current_sha: Optional[str] = self._boot_git_sha
self._cached_current_sha_at: float = self._boot_wall_time
# Load ephemeral config from config.yaml / env vars.
# Both are injected at API-call time only and never persisted.
self._prefill_messages = self._load_prefill_messages()
@ -2853,101 +2676,6 @@ class GatewayRunner:
task.add_done_callback(self._background_tasks.discard)
return True
def _current_git_sha_cached(self) -> Optional[str]:
"""Return the current HEAD SHA, cached for _GIT_SHA_CACHE_TTL_SECS.
A bursty chat (user mashes "hello?" three times) would otherwise
re-read ``.git/HEAD`` on every message. Caching collapses that
into a single read and still re-checks within the user's
perceived "next message" window.
"""
now = time.time()
if (
self._cached_current_sha is not None
and (now - self._cached_current_sha_at) < _GIT_SHA_CACHE_TTL_SECS
):
return self._cached_current_sha
try:
sha = _read_git_head_sha(self._repo_root_for_staleness)
except Exception:
sha = None
self._cached_current_sha = sha
self._cached_current_sha_at = now
return sha
def _detect_stale_code(self) -> bool:
"""Return True if the git HEAD moved since this process booted.
A gateway that survives ``hermes update`` (manual SIGTERM never
escalated, systemd restart race, detached-process respawn failed,
etc.) keeps pre-update modules cached in ``sys.modules``. Later
imports of names added post-update e.g. ``cfg_get`` from PR
#17304 — raise ImportError against the stale module object (see
Issue #17648).
We compare the git HEAD SHA at boot to the current SHA on disk.
``hermes update`` always moves HEAD forward via ``git pull``;
agent file edits (the agent patching ``run_agent.py`` or
``gateway/run.py`` during a self-dev session) never move HEAD.
That makes SHA comparison free of the false-positive class that
the old mtime check suffered from the agent can edit any file
without triggering a phantom restart.
Returns False when:
- the boot SHA is unavailable (non-git install, first call
during partial init, etc.); we can't tell and refuse to loop
- the current SHA matches the boot SHA
- reading the current SHA fails for any reason
"""
if not self._boot_wall_time:
return False
if not self._boot_git_sha:
# Non-git install. ``hermes update`` is git-based, so a
# non-git install can't experience the stale-modules class
# this check exists to catch. Return False — no check, no
# false positives. (If we ever ship a pip-install update
# path, we'd add a persistent update marker here and compare
# its timestamp to self._boot_wall_time.)
return False
try:
current = self._current_git_sha_cached()
except Exception:
return False
if not current:
return False
return current != self._boot_git_sha
def _trigger_stale_code_restart(self) -> None:
"""Idempotently kick off a graceful restart after stale-code detection.
Runs at most once per process. The restart request goes through
the normal drain path so in-flight agent turns finish before the
process exits; the service manager (systemd / launchd / detached
profile watcher) then respawns with fresh code. On manual
``hermes gateway run`` installs without a supervisor, the
process exits and the user must restart by hand but they get a
user-visible message telling them so.
"""
if self._stale_code_restart_triggered:
return
self._stale_code_restart_triggered = True
current_sha = None
try:
current_sha = self._current_git_sha_cached()
except Exception:
pass
logger.warning(
"Stale-code self-check: git HEAD moved since gateway boot "
"(boot=%s, current=%s) — requesting graceful restart. "
"See Issue #17648.",
(self._boot_git_sha or "?")[:12],
(current_sha or "?")[:12],
)
try:
self.request_restart(detached=False, via_service=True)
except Exception as exc:
logger.error("Stale-code restart request failed: %s", exc)
async def start(self) -> bool:
"""
Start the gateway and all configured platform adapters.
@ -3906,7 +3634,17 @@ class GatewayRunner:
return out
def _ready_nonempty() -> bool:
"""Cheap probe: is there a ready+assigned+unclaimed task on ANY board?"""
"""Cheap probe: is there at least one ready+assigned+unclaimed
task on ANY board whose assignee maps to a real Hermes profile
(i.e. one the dispatcher would actually spawn for)?
Tasks assigned to control-plane lanes (e.g. ``orion-cc``,
``orion-research``) are pulled by terminals via
``claim_task`` directly and never spawnable, so a queue full
of those is "correctly idle", not "stuck". Filtering them out
here keeps the stuck-warn fire only on real failures (broken
PATH, missing venv, credential loss for a real Hermes profile).
"""
try:
boards = _kb.list_boards(include_archived=False)
except Exception:
@ -3916,12 +3654,7 @@ class GatewayRunner:
conn = None
try:
conn = _kb.connect(board=slug)
row = conn.execute(
"SELECT 1 FROM tasks "
"WHERE status = 'ready' AND assignee IS NOT NULL "
" AND claim_lock IS NULL LIMIT 1"
).fetchone()
if row is not None:
if _kb.has_spawnable_ready(conn):
return True
except Exception:
continue
@ -4873,27 +4606,6 @@ class GatewayRunner:
"""
source = event.source
# Stale-code self-check (Issue #17648). A gateway that survives
# ``hermes update`` keeps old modules cached in sys.modules; the
# first inbound message is our earliest safe chance to detect
# this and restart gracefully before we dispatch to the agent
# and hit ImportError on freshly-added names (e.g. cfg_get).
# Idempotent — runs the real check at most once per message, and
# request_restart() no-ops after the first call.
try:
if self._detect_stale_code():
self._trigger_stale_code_restart()
# Acknowledge to the user so they don't see a silent
# drop; the gateway will be back up in a moment via the
# service manager / profile-watcher respawn.
return (
"⟳ Gateway code was updated in the background — "
"restarting this gateway so your next message runs "
"on the new code. Please retry in a moment."
)
except Exception as _stale_exc:
logger.debug("Stale-code self-check failed: %s", _stale_exc)
# Internal events (e.g. background-process completion notifications)
# are system-generated and must skip user authorization.
is_internal = bool(getattr(event, "internal", False))
@ -5020,10 +4732,12 @@ class GatewayRunner:
response_text = raw
if response_text:
response_path = _hermes_home / ".update_response"
prompt_path = _hermes_home / ".update_prompt.json"
try:
tmp = response_path.with_suffix(".tmp")
tmp.write_text(response_text)
tmp.replace(response_path)
prompt_path.unlink(missing_ok=True)
except OSError as e:
logger.warning("Failed to write update response: %s", e)
return f"✗ Failed to send response to update process: {e}"
@ -5038,10 +4752,12 @@ class GatewayRunner:
# The slash command then falls through to normal dispatch.
if _recognized_cmd:
response_path = _hermes_home / ".update_response"
prompt_path = _hermes_home / ".update_prompt.json"
try:
tmp = response_path.with_suffix(".tmp")
tmp.write_text("")
tmp.replace(response_path)
prompt_path.unlink(missing_ok=True)
logger.info(
"Recognized /%s during pending update prompt for %s; "
"cancelled prompt with default and dispatching command",
@ -7662,7 +7378,7 @@ class GatewayRunner:
if self._restart_requested or self._draining:
count = self._running_agent_count()
if count:
return f"⏳ Draining {count} active agent(s) before restart..."
return t("gateway.draining", count=count)
return EphemeralReply("⏳ Gateway restart already in progress...")
# Save the requester's routing info so the new gateway process can
@ -7714,7 +7430,7 @@ class GatewayRunner:
else:
self.request_restart(detached=True, via_service=False)
if active_agents:
return f"⏳ Draining {active_agents} active agent(s) before restart..."
return t("gateway.draining", count=active_agents)
return EphemeralReply("♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`.")
def _is_stale_restart_redelivery(self, event: MessageEvent) -> bool:
@ -7862,6 +7578,7 @@ class GatewayRunner:
from hermes_cli.model_switch import (
switch_model as _switch_model, parse_model_flags,
list_authenticated_providers,
list_picker_providers,
)
from hermes_cli.providers import get_label
@ -7916,7 +7633,7 @@ class GatewayRunner:
if has_picker:
try:
providers = list_authenticated_providers(
providers = list_picker_providers(
current_provider=current_provider,
current_base_url=current_base_url,
current_model=current_model,
@ -8384,7 +8101,7 @@ class GatewayRunner:
if lower in ("clear", "stop", "done"):
had = mgr.has_goal()
mgr.clear()
return "✓ Goal cleared." if had else "No active goal."
return t("gateway.goal_cleared") if had else t("gateway.no_active_goal")
# Otherwise — treat the remaining text as the new goal.
try:
@ -9602,7 +9319,7 @@ class GatewayRunner:
try:
user_config: dict = _load_gateway_config()
except Exception as e:
return f"⚠️ Could not read config.yaml: {e}"
return t("gateway.config_read_failed", error=e)
effective = resolve_footer_config(user_config, platform_key)
@ -9635,7 +9352,7 @@ class GatewayRunner:
atomic_yaml_write(config_path, user_config)
except Exception as e:
logger.warning("Failed to save runtime_footer.enabled: %s", e)
return f"⚠️ Could not save config: {e}"
return t("gateway.config_save_failed", error=e)
state = "ON" if new_state else "OFF"
example = ""
@ -11073,7 +10790,7 @@ class GatewayRunner:
if not has_blocking_approval(session_key):
if session_key in self._pending_approvals:
self._pending_approvals.pop(session_key)
return "⚠️ Approval expired (agent is no longer waiting). Ask the agent to try again."
return t("gateway.approval_expired")
return "No pending command to approve."
# Parse args: support "all", "all session", "all always", "session", "always"
@ -11488,12 +11205,13 @@ class GatewayRunner:
f"or type your answer directly.",
metadata=metadata,
)
# Keep the prompt marker on disk until the user
# answers. If the gateway restarts mid-prompt, the
# next watcher can recover by re-forwarding it from
# disk. Duplicate sends in the same process are
# still suppressed by _update_prompt_pending.
self._update_prompt_pending[session_key] = True
# Remove the prompt file so it isn't re-read on the
# next poll cycle. The update process only needs
# .update_response to continue — it doesn't re-check
# .update_prompt.json while waiting.
prompt_path.unlink(missing_ok=True)
logger.info("Forwarded update prompt to %s: %s", session_key, prompt_text[:80])
except (json.JSONDecodeError, OSError) as e:
logger.debug("Failed to read update prompt: %s", e)

View file

@ -1276,8 +1276,9 @@ class SessionStore:
# Also write legacy JSONL (keeps existing tooling working during transition)
transcript_path = self.get_transcript_path(session_id)
with open(transcript_path, "a", encoding="utf-8") as f:
f.write(json.dumps(message, ensure_ascii=False) + "\n")
with self._lock:
with open(transcript_path, "a", encoding="utf-8") as f:
f.write(json.dumps(message, ensure_ascii=False) + "\n")
def rewrite_transcript(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
"""Replace the entire transcript for a session with new messages.

View file

@ -235,6 +235,9 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
"""
findings: list[tuple[Path, str]] = []
if not source_dir.exists():
return findings
# Direct state files in the root
for name in ("todo.json", "sessions", "logs"):
candidate = source_dir / name
@ -243,7 +246,12 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
findings.append((candidate, f"Root {kind}: {name}"))
# State files inside workspace directories
for child in sorted(source_dir.iterdir()):
try:
children = sorted(source_dir.iterdir())
except OSError:
return findings
for child in children:
if not child.is_dir() or child.name.startswith("."):
continue
# Check for workspace-like subdirectories

View file

@ -781,6 +781,11 @@ DEFAULT_CONFIG = {
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
"show_cost": False, # Show $ cost in the status bar (off by default)
"skin": "default",
# UI language for static user-facing messages (approval prompts, a
# handful of gateway slash-command replies). Does NOT affect agent
# responses, log lines, tool outputs, or slash-command descriptions.
# Supported: en, zh, ja, de, es. Unknown values fall back to en.
"language": "en",
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
# spinner), or ascii. Live-swappable via `/indicator <style>`.
"tui_status_indicator": "kaomoji",

View file

@ -245,6 +245,111 @@ def _cmd_restore(args) -> int:
return 0 if ok else 1
def _cmd_archive(args) -> int:
"""Manually archive an agent-created skill. Refuses if pinned.
The auto-curator archives stale skills on its own schedule; this verb is
for the user who wants to archive *now* without waiting for a run.
"""
from tools import skill_usage
if skill_usage.get_record(args.skill).get("pinned"):
print(
f"curator: '{args.skill}' is pinned — unpin first with "
f"`hermes curator unpin {args.skill}`"
)
return 1
ok, msg = skill_usage.archive_skill(args.skill)
print(f"curator: {msg}")
return 0 if ok else 1
def _idle_days(record: dict) -> Optional[int]:
"""Days since the skill's last activity (view / use / patch).
Falls back to ``created_at`` so a skill that was authored but never used
can still be pruned otherwise never-touched skills would be immortal.
Returns None only when both fields are missing or unparseable.
"""
ts = record.get("last_activity_at") or record.get("created_at")
if not ts:
return None
try:
dt = datetime.fromisoformat(str(ts))
except (TypeError, ValueError):
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return max(0, (datetime.now(timezone.utc) - dt).days)
def _cmd_prune(args) -> int:
"""Bulk-archive agent-created skills idle for >= N days.
Pinned skills are exempt. Already-archived skills are skipped. Default
``--days 90`` matches a conservative read of the curator's own archive
threshold; adjust with ``--days``. Use ``--dry-run`` to preview.
"""
from tools import skill_usage
days = getattr(args, "days", 90)
if days < 1:
print(f"curator: --days must be >= 1 (got {days})", file=sys.stderr)
return 2
dry_run = bool(getattr(args, "dry_run", False))
skip_confirm = bool(getattr(args, "yes", False))
candidates = []
for r in skill_usage.agent_created_report():
if r.get("pinned"):
continue
if r.get("state") == skill_usage.STATE_ARCHIVED:
continue
idle = _idle_days(r)
if idle is None or idle < days:
continue
candidates.append((r["name"], idle))
if not candidates:
print(f"curator: nothing to prune (no unpinned skills idle >= {days}d)")
return 0
candidates.sort(key=lambda c: -c[1])
print(f"curator: {len(candidates)} skill(s) idle >= {days}d:")
for name, idle in candidates:
print(f" {name:40s} idle {idle}d")
if dry_run:
print("\n(dry run — no changes made)")
return 0
if not skip_confirm:
try:
reply = input(f"\nArchive {len(candidates)} skill(s)? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("\ncurator: aborted")
return 1
if reply not in ("y", "yes"):
print("curator: aborted")
return 1
archived = 0
failures = []
for name, _ in candidates:
ok, msg = skill_usage.archive_skill(name)
if ok:
archived += 1
else:
failures.append((name, msg))
print(f"\ncurator: archived {archived}/{len(candidates)}")
if failures:
print("failures:")
for name, msg in failures:
print(f" {name}: {msg}")
return 1
return 0
def _cmd_backup(args) -> int:
"""Take a manual snapshot of the skills tree. Same mechanism as the
automatic pre-run snapshot, just user-initiated."""
@ -383,6 +488,31 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
p_restore.add_argument("skill", help="Skill name")
p_restore.set_defaults(func=_cmd_restore)
p_archive = subs.add_parser(
"archive",
help="Manually archive a skill (move to .archive/, excluded from prompt)",
)
p_archive.add_argument("skill", help="Skill name")
p_archive.set_defaults(func=_cmd_archive)
p_prune = subs.add_parser(
"prune",
help="Bulk-archive agent-created skills idle for >= N days (default 90)",
)
p_prune.add_argument(
"--days", type=int, default=90,
help="Archive skills idle for at least N days (default: 90)",
)
p_prune.add_argument(
"-y", "--yes", action="store_true",
help="Skip the confirmation prompt",
)
p_prune.add_argument(
"--dry-run", dest="dry_run", action="store_true",
help="Show what would be archived without doing it",
)
p_prune.set_defaults(func=_cmd_prune)
p_backup = subs.add_parser(
"backup",
help="Take a manual tar.gz snapshot of ~/.hermes/skills/ "

View file

@ -12,6 +12,7 @@ import importlib.util
from pathlib import Path
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
from hermes_cli.env_loader import load_hermes_dotenv
from hermes_constants import display_hermes_home
PROJECT_ROOT = get_project_root()
@ -19,15 +20,8 @@ HERMES_HOME = get_hermes_home()
_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)
# Load environment variables from ~/.hermes/.env so API key checks work
from dotenv import load_dotenv
_env_path = get_env_path()
if _env_path.exists():
try:
load_dotenv(_env_path, encoding="utf-8")
except UnicodeDecodeError:
load_dotenv(_env_path, encoding="latin-1")
# Also try project .env as dev fallback
load_dotenv(PROJECT_ROOT / ".env", override=False, encoding="utf-8")
load_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / ".env")
from hermes_cli.colors import Colors, color
from hermes_cli.models import _HERMES_USER_AGENT

View file

@ -14,6 +14,7 @@ import sys
from pathlib import Path
from hermes_cli.config import get_hermes_home, get_env_path, get_project_root, load_config
from hermes_cli.env_loader import load_hermes_dotenv
from hermes_constants import display_hermes_home
@ -195,15 +196,11 @@ def run_dump(args):
show_keys = getattr(args, "show_keys", False)
# Load env from .env file so key checks work
from dotenv import load_dotenv
env_path = get_env_path()
if env_path.exists():
try:
load_dotenv(env_path, encoding="utf-8")
except UnicodeDecodeError:
load_dotenv(env_path, encoding="latin-1")
# Also try project .env as dev fallback
load_dotenv(get_project_root() / ".env", override=False, encoding="utf-8")
load_hermes_dotenv(
hermes_home=env_path.parent,
project_env=get_project_root() / ".env",
)
project_root = get_project_root()
hermes_home = get_hermes_home()

View file

@ -308,6 +308,35 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
p_assign.add_argument("task_id")
p_assign.add_argument("profile", help="Profile name (or 'none' to unassign)")
# --- reclaim / reassign (recovery) ---
p_reclaim = sub.add_parser(
"reclaim",
help="Release an active worker claim on a running task",
)
p_reclaim.add_argument("task_id")
p_reclaim.add_argument(
"--reason", default=None,
help="Human-readable reason (recorded on the reclaimed event)",
)
p_reassign = sub.add_parser(
"reassign",
help="Reassign a task to a different profile, optionally reclaiming first",
)
p_reassign.add_argument("task_id")
p_reassign.add_argument(
"profile",
help="New profile name (or 'none' to unassign)",
)
p_reassign.add_argument(
"--reclaim", action="store_true",
help="Release any active claim before reassigning (required if task is running)",
)
p_reassign.add_argument(
"--reason", default=None,
help="Human-readable reason (recorded on the reclaimed event)",
)
# --- link / unlink ---
p_link = sub.add_parser("link", help="Add a parent->child dependency")
p_link.add_argument("parent_id")
@ -343,6 +372,27 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
help='JSON dict of structured facts (e.g. \'{"changed_files": [...], '
'"tests_run": 12}\'). Stored on the closing run.')
p_edit = sub.add_parser(
"edit",
help="Edit recovery fields on an already-completed task",
)
p_edit.add_argument("task_id")
p_edit.add_argument(
"--result",
required=True,
help="Backfilled task result text for a done task",
)
p_edit.add_argument(
"--summary",
default=None,
help="Structured handoff summary. Falls back to --result if omitted.",
)
p_edit.add_argument(
"--metadata",
default=None,
help="JSON dict of structured facts to store on the latest completed run.",
)
p_block = sub.add_parser("block", help="Mark one or more tasks blocked")
p_block.add_argument("task_id")
p_block.add_argument("reason", nargs="*", help="Reason (also appended as a comment)")
@ -576,11 +626,14 @@ def kanban_command(args: argparse.Namespace) -> int:
"ls": _cmd_list,
"show": _cmd_show,
"assign": _cmd_assign,
"reclaim": _cmd_reclaim,
"reassign": _cmd_reassign,
"link": _cmd_link,
"unlink": _cmd_unlink,
"claim": _cmd_claim,
"comment": _cmd_comment,
"complete": _cmd_complete,
"edit": _cmd_edit,
"block": _cmd_block,
"unblock": _cmd_unblock,
"archive": _cmd_archive,
@ -1095,6 +1148,45 @@ def _cmd_assign(args: argparse.Namespace) -> int:
return 0
def _cmd_reclaim(args: argparse.Namespace) -> int:
with kb.connect() as conn:
ok = kb.reclaim_task(
conn, args.task_id,
reason=getattr(args, "reason", None),
)
if not ok:
print(
f"cannot reclaim {args.task_id} (not running or unknown id)",
file=sys.stderr,
)
return 1
print(f"Reclaimed {args.task_id}")
return 0
def _cmd_reassign(args: argparse.Namespace) -> int:
profile = None if args.profile.lower() in ("none", "-", "null") else args.profile
with kb.connect() as conn:
ok = kb.reassign_task(
conn, args.task_id, profile,
reclaim_first=bool(getattr(args, "reclaim", False)),
reason=getattr(args, "reason", None),
)
if not ok:
print(
f"cannot reassign {args.task_id} "
f"(unknown id, or still running — pass --reclaim to release first)",
file=sys.stderr,
)
return 1
print(
f"Reassigned {args.task_id} to "
f"{profile or '(unassigned)'}"
+ (" (claim reclaimed)" if getattr(args, "reclaim", False) else "")
)
return 0
def _cmd_link(args: argparse.Namespace) -> int:
with kb.connect() as conn:
kb.link_tasks(conn, args.parent_id, args.child_id)
@ -1187,6 +1279,34 @@ def _cmd_complete(args: argparse.Namespace) -> int:
return 0 if not failed else 1
def _cmd_edit(args: argparse.Namespace) -> int:
raw_meta = getattr(args, "metadata", None)
metadata = None
if raw_meta:
try:
metadata = json.loads(raw_meta)
if not isinstance(metadata, dict):
raise ValueError("must be a JSON object")
except (ValueError, json.JSONDecodeError) as exc:
print(f"kanban: --metadata: {exc}", file=sys.stderr)
return 2
with kb.connect() as conn:
if not kb.edit_completed_task_result(
conn,
args.task_id,
result=args.result,
summary=getattr(args, "summary", None),
metadata=metadata,
):
print(
f"cannot edit {args.task_id} (unknown id or task is not done)",
file=sys.stderr,
)
return 1
print(f"Edited {args.task_id}")
return 0
def _cmd_block(args: argparse.Namespace) -> int:
reason = " ".join(args.reason).strip() if args.reason else None
author = _profile_author()
@ -1274,6 +1394,7 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
for (tid, who, ws) in res.spawned
],
"skipped_unassigned": res.skipped_unassigned,
"skipped_nonspawnable": res.skipped_nonspawnable,
}, indent=2))
return 0
print(f"Reclaimed: {res.reclaimed}")
@ -1293,6 +1414,11 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
print(f" - {tid} -> {who} @ {ws or '-'}{tag}")
if res.skipped_unassigned:
print(f"Skipped (unassigned): {', '.join(res.skipped_unassigned)}")
if res.skipped_nonspawnable:
print(
f"Skipped (non-spawnable assignee — terminal lane, OK): "
f"{', '.join(res.skipped_nonspawnable)}"
)
return 0
@ -1404,16 +1530,18 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
)
def _ready_queue_nonempty() -> bool:
"""Cheap SELECT — just asks whether there's at least one ready
task with an assignee that the dispatcher could have picked up."""
"""Cheap probe — is there at least one ready+assigned+unclaimed
task whose assignee maps to a real Hermes profile (i.e. one the
dispatcher would actually try to spawn for)?
Filters out tasks assigned to control-plane lanes
(e.g. ``orion-cc``, ``orion-research``) that are pulled by
terminals via ``claim_task`` directly those are correctly idle
from the dispatcher's perspective, not stuck.
"""
try:
with kb.connect() as conn:
row = conn.execute(
"SELECT 1 FROM tasks "
"WHERE status = 'ready' AND assignee IS NOT NULL "
" AND claim_lock IS NULL LIMIT 1"
).fetchone()
return row is not None
return kb.has_spawnable_ready(conn)
except Exception:
return False

View file

@ -76,6 +76,7 @@ import os
import re
import secrets
import sqlite3
import subprocess
import sys
import time
from dataclasses import dataclass, field
@ -190,12 +191,12 @@ def get_current_board() -> str:
1. ``HERMES_KANBAN_BOARD`` env var (set by the dispatcher on worker
spawn, or manually for ad-hoc overrides).
2. ``<root>/kanban/current`` on disk (set by ``hermes kanban boards
switch``).
switch``), but only when that board still exists.
3. ``DEFAULT_BOARD`` (``"default"``).
A malformed slug at any step falls through to the next layer with a
best-effort warning the dispatcher must never crash because a user
hand-edited a file.
A malformed or stale slug at any step falls through to the next layer
with a best-effort warning the dispatcher must never crash because a
user hand-edited a file or removed a board directory.
"""
env = os.environ.get("HERMES_KANBAN_BOARD", "").strip()
if env:
@ -212,7 +213,7 @@ def get_current_board() -> str:
if val:
try:
normed = _normalize_board_slug(val)
if normed:
if normed and board_exists(normed):
return normed
except ValueError:
pass
@ -1841,6 +1842,212 @@ def release_stale_claims(conn: sqlite3.Connection) -> int:
return reclaimed
def reclaim_task(
conn: sqlite3.Connection,
task_id: str,
*,
reason: Optional[str] = None,
) -> bool:
"""Operator-driven reclaim: release the claim and reset to ``ready``.
Unlike :func:`release_stale_claims` which only acts on tasks whose
``claim_expires`` has passed, this function reclaims immediately
regardless of TTL. Intended for the dashboard/CLI recovery flow
when an operator wants to abort a running worker without waiting
for the TTL to expire (e.g. after seeing a hallucination warning).
Returns True if a reclaim happened, False if the task isn't in a
reclaimable state (not running, or doesn't exist).
"""
with write_txn(conn):
row = conn.execute(
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
if not row:
return False
if row["status"] != "running" and row["claim_lock"] is None:
# Nothing to reclaim — already ready / blocked / done.
return False
prev_lock = row["claim_lock"]
prev_pid = row["worker_pid"]
conn.execute(
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
"claim_expires = NULL, worker_pid = NULL "
"WHERE id = ? AND status IN ('running', 'ready', 'blocked')",
(task_id,),
)
run_id = _end_run(
conn, task_id,
outcome="reclaimed", status="reclaimed",
error=(
f"manual_reclaim: {reason}" if reason
else f"manual_reclaim lock={prev_lock}"
),
)
_append_event(
conn, task_id, "reclaimed",
{
"manual": True,
"reason": reason,
"prev_lock": prev_lock,
"prev_pid": prev_pid,
},
run_id=run_id,
)
return True
def reassign_task(
conn: sqlite3.Connection,
task_id: str,
profile: Optional[str],
*,
reclaim_first: bool = False,
reason: Optional[str] = None,
) -> bool:
"""Reassign a task, optionally reclaiming a stuck running worker first.
This is the recovery path for "this profile's model is broken, try
a different one". If ``reclaim_first`` is True, any active claim is
released (via :func:`reclaim_task`) before the reassign happens;
otherwise the function refuses to reassign a currently-running task
and returns False (caller can retry with ``reclaim_first=True``).
Returns True if the reassign landed. ``profile`` may be ``None`` to
unassign entirely.
"""
if reclaim_first:
# Safe to call even if nothing to reclaim.
reclaim_task(conn, task_id, reason=reason or "reassign")
# assign_task handles its own txn + the still-running guard.
try:
return assign_task(conn, task_id, profile)
except RuntimeError:
# Task is still running and reclaim_first was False; caller
# needs to decide whether to retry with reclaim.
return False
def _verify_created_cards(
conn: sqlite3.Connection,
completing_task_id: str,
claimed_ids: Iterable[str],
) -> tuple[list[str], list[str]]:
"""Partition ``claimed_ids`` into (verified, phantom).
A card is "verified" iff a row exists in ``tasks`` with the given id
AND ``created_by`` matches the completing task's ``assignee`` (or
the completing task itself workers that create children of their
own task also qualify).
``phantom`` returns ids that either don't exist at all or exist but
were not created by the completing worker. The caller decides what
to do with each bucket; this helper never mutates.
"""
claimed = [str(x).strip() for x in (claimed_ids or []) if str(x).strip()]
if not claimed:
return [], []
# Dedupe while preserving order.
seen: set[str] = set()
ordered: list[str] = []
for cid in claimed:
if cid not in seen:
seen.add(cid)
ordered.append(cid)
row = conn.execute(
"SELECT assignee FROM tasks WHERE id = ?", (completing_task_id,),
).fetchone()
if row is None:
# Completing task not found — nothing resolves.
return [], ordered
completing_assignee = row["assignee"]
# Batch-fetch existence + created_by in one query.
placeholders = ",".join(["?"] * len(ordered))
rows = conn.execute(
f"SELECT id, created_by FROM tasks WHERE id IN ({placeholders})",
tuple(ordered),
).fetchall()
found = {r["id"]: r["created_by"] for r in rows}
verified: list[str] = []
phantom: list[str] = []
for cid in ordered:
created_by = found.get(cid)
if created_by is None:
phantom.append(cid)
continue
# Accept if created_by matches the completing task's assignee
# profile, OR the task itself (workers whose created_by happens
# to match their task id are unusual but harmless to accept).
if completing_assignee and created_by == completing_assignee:
verified.append(cid)
elif created_by == completing_task_id:
verified.append(cid)
else:
phantom.append(cid)
return verified, phantom
# Task-id pattern used both by ``kanban_create`` (``t_<12 hex>``) and
# ``_new_task_id`` below. Kept permissive on length for forward compat:
# accept 8+ hex chars after the ``t_`` prefix.
_TASK_ID_PROSE_RE = re.compile(r"\bt_[a-f0-9]{8,}\b")
def _scan_prose_for_phantom_ids(
conn: sqlite3.Connection,
text: str,
) -> list[str]:
"""Regex-scan free-form text for ``t_<hex>`` references; return the
ones that don't exist in ``tasks``.
Used as a non-blocking advisory check on completion summaries. An
empty return means "no suspicious references found" either the
text had no IDs at all, or every ID it mentioned resolves to a real
task. Duplicates are deduped.
"""
if not text:
return []
matches = _TASK_ID_PROSE_RE.findall(text)
if not matches:
return []
# Dedupe preserving order.
seen: set[str] = set()
unique: list[str] = []
for m in matches:
if m not in seen:
seen.add(m)
unique.append(m)
placeholders = ",".join(["?"] * len(unique))
rows = conn.execute(
f"SELECT id FROM tasks WHERE id IN ({placeholders})",
tuple(unique),
).fetchall()
existing = {r["id"] for r in rows}
return [m for m in unique if m not in existing]
class HallucinatedCardsError(ValueError):
"""Raised by ``complete_task`` when ``created_cards`` contains ids
that don't exist or weren't created by the completing worker.
The phantom list is attached as ``.phantom`` for callers that want
structured access. Kept as ``ValueError`` subclass so existing
tool-error handlers treat it as a recoverable user error.
"""
def __init__(self, phantom: list[str], completing_task_id: str):
self.phantom = list(phantom)
self.completing_task_id = completing_task_id
super().__init__(
f"completion blocked: claimed created_cards that do not exist "
f"or were not created by this worker: {', '.join(phantom)}"
)
def complete_task(
conn: sqlite3.Connection,
task_id: str,
@ -1848,21 +2055,65 @@ def complete_task(
result: Optional[str] = None,
summary: Optional[str] = None,
metadata: Optional[dict] = None,
created_cards: Optional[Iterable[str]] = None,
) -> bool:
"""Transition ``running|ready -> done`` and record ``result``.
Accepts a task that's merely ``ready`` too, so a manual CLI
Accepts a task that is merely ``ready`` too, so a manual CLI
completion (``hermes kanban complete <id>``) works without requiring
a claim/start/complete sequence.
``summary`` and ``metadata`` are stored on the closing run (if any)
and surfaced to downstream children via :func:`build_worker_context`.
When ``summary`` is omitted we fall back to ``result`` so single-run
callers don't have to pass both. ``metadata`` is a free-form dict
callers do not have to pass both. ``metadata`` is a free-form dict
(e.g. ``{"changed_files": [...], "tests_run": [...]}``) workers
are encouraged to use it for structured handoff facts.
``created_cards`` is an optional list of task ids the completing
worker claims to have created. Each id is verified against
``tasks.created_by``. If any id is phantom (does not exist or was
not created by this worker's assignee profile), completion is blocked
with a ``HallucinatedCardsError`` and a
``completion_blocked_hallucination`` event is emitted so the rejected
attempt is auditable. When all ids verify, they are recorded on the
``completed`` event payload.
After a successful completion, ``summary`` and ``result`` are scanned
for prose references like ``t_deadbeefcafe`` that do not resolve.
Any suspected phantom references are recorded as a
``suspected_hallucinated_references`` event. This pass is advisory
and never blocks.
"""
now = int(time.time())
# Gate: verify created_cards BEFORE the main write txn. A rejected
# completion still needs an auditable event, so we emit it in a
# tiny dedicated txn, then raise. The caller is responsible for
# surfacing HallucinatedCardsError to the worker; this function
# never mutates task state on a phantom-card rejection.
if created_cards:
verified_cards, phantom_cards = _verify_created_cards(
conn, task_id, created_cards
)
if phantom_cards:
with write_txn(conn):
_append_event(
conn, task_id, "completion_blocked_hallucination",
{
"phantom_cards": phantom_cards,
"verified_cards": verified_cards,
"summary_preview": (
(summary or result or "").strip().splitlines()[0][:200]
if (summary or result)
else None
),
},
)
raise HallucinatedCardsError(phantom_cards, task_id)
else:
verified_cards = []
with write_txn(conn):
cur = conn.execute(
"""
@ -1903,16 +2154,107 @@ def complete_task(
# full summary stays on the run row.
ev_summary = (summary if summary is not None else result) or ""
ev_summary = ev_summary.strip().splitlines()[0][:400] if ev_summary else ""
completed_payload: dict = {
"result_len": len(result) if result else 0,
"summary": ev_summary or None,
}
if verified_cards:
completed_payload["verified_cards"] = verified_cards
_append_event(
conn, task_id, "completed",
completed_payload,
run_id=run_id,
)
# Prose-scan the summary + result for t_<hex> references that do
# not resolve. Advisory — does not block the completion. Runs in
# its own txn so the completion itself is already durable by the
# time we emit the warning.
scan_text = " ".join(filter(None, [summary, result]))
if scan_text:
phantom_refs = _scan_prose_for_phantom_ids(conn, scan_text)
# Drop any phantom refs that were already flagged as verified
# above (shouldn't happen — verified means they exist — but
# belt-and-suspenders).
phantom_refs = [p for p in phantom_refs if p not in set(verified_cards)]
if phantom_refs:
with write_txn(conn):
_append_event(
conn, task_id, "suspected_hallucinated_references",
{
"phantom_refs": phantom_refs,
"source": "completion_summary",
},
run_id=run_id,
)
# Recompute ready status for dependents (separate txn so children see done).
recompute_ready(conn)
return True
def edit_completed_task_result(
conn: sqlite3.Connection,
task_id: str,
*,
result: str,
summary: Optional[str] = None,
metadata: Optional[dict] = None,
) -> bool:
"""Backfill the user-visible result for an already completed task."""
handoff_summary = summary if summary is not None else result
with write_txn(conn):
row = conn.execute(
"SELECT status FROM tasks WHERE id = ?", (task_id,),
).fetchone()
if not row or row["status"] != "done":
return False
conn.execute(
"UPDATE tasks SET result = ? WHERE id = ?",
(result, task_id),
)
run = conn.execute(
"""
SELECT id FROM task_runs
WHERE task_id = ?
AND outcome = 'completed'
ORDER BY COALESCE(ended_at, started_at, 0) DESC, id DESC
LIMIT 1
""",
(task_id,),
).fetchone()
run_id = int(run["id"]) if run else None
if run_id is None:
run_id = _synthesize_ended_run(
conn, task_id,
outcome="completed",
summary=handoff_summary,
metadata=metadata,
)
else:
conn.execute(
"UPDATE task_runs SET summary = ? WHERE id = ?",
(handoff_summary, run_id),
)
if metadata is not None:
conn.execute(
"UPDATE task_runs SET metadata = ? WHERE id = ?",
(json.dumps(metadata, ensure_ascii=False), run_id),
)
ev_summary = (
handoff_summary.strip().splitlines()[0][:400]
if handoff_summary else ""
)
_append_event(
conn, task_id, "edited",
{
"fields": (
["result", "summary"]
+ (["metadata"] if metadata is not None else [])
),
"result_len": len(result) if result else 0,
"summary": ev_summary or None,
},
run_id=run_id,
)
# Recompute ready status for dependents (separate txn so children see done).
recompute_ready(conn)
return True
@ -2118,6 +2460,15 @@ class DispatchResult:
spawned: list[tuple[str, str, str]] = field(default_factory=list)
"""List of ``(task_id, assignee, workspace_path)`` triples."""
skipped_unassigned: list[str] = field(default_factory=list)
"""Ready task ids skipped because they have no assignee at all.
Operator-actionable usually a misfiled task waiting for routing."""
skipped_nonspawnable: list[str] = field(default_factory=list)
"""Ready task ids skipped because their assignee names a control-plane
lane (a Claude Code terminal like ``orion-cc``) rather than a Hermes
profile. Expected steady-state on multi-lane setups; NOT an
operator-actionable failure. Tracked separately so health telemetry
can distinguish "real stuck" (nothing spawned but spawnable work
available) from "correctly idle" (nothing spawnable in the queue)."""
crashed: list[str] = field(default_factory=list)
"""Task ids reclaimed because their worker PID disappeared."""
auto_blocked: list[str] = field(default_factory=list)
@ -2132,16 +2483,16 @@ def _pid_alive(pid: Optional[int]) -> bool:
Cross-platform: uses ``os.kill(pid, 0)`` on POSIX and ``OpenProcess``
on Windows. Returns False for falsy PIDs or on any OS error.
**Zombie handling (Linux):** ``os.kill(pid, 0)`` succeeds against
**Zombie handling:** ``os.kill(pid, 0)`` succeeds against
zombie processes (post-exit, pre-reap) because the process table
entry still exists. A worker that exits without being reaped by its
parent would stay "alive" to the dispatcher forever. Dispatcher
workers are started via ``start_new_session=True`` + intentional
Popen handle abandonment, so init reaps them quickly but during
the window between exit and reap, we'd otherwise see stale "alive"
signals. On Linux we additionally peek at ``/proc/<pid>/status``
and treat ``State: Z`` as dead. On other POSIX or on Windows the
zombie check is a no-op.
signals. On Linux we peek at ``/proc/<pid>/status`` and treat
``State: Z`` as dead. On macOS we ask ``ps`` for the BSD ``stat``
field and treat values containing ``Z`` as dead.
"""
if not pid or pid <= 0:
return False
@ -2155,7 +2506,8 @@ def _pid_alive(pid: Optional[int]) -> bool:
return True
except OSError:
return False
# Still here → kill(0) succeeded. Check for zombie on Linux.
# Still here → kill(0) succeeded. Check for zombie on platforms
# where we have a cheap, deterministic process-state probe.
if sys.platform == "linux":
try:
with open(f"/proc/{int(pid)}/status", "r") as f:
@ -2170,6 +2522,23 @@ def _pid_alive(pid: Optional[int]) -> bool:
# PermissionError shouldn't happen for our own children but
# be defensive.
pass
elif sys.platform == "darwin":
try:
proc = subprocess.run(
["ps", "-o", "stat=", "-p", str(int(pid))],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=1,
check=False,
)
if proc.returncode != 0:
return False
if "Z" in (proc.stdout or "").strip():
return False
except (OSError, subprocess.SubprocessError, TimeoutError):
# If the secondary probe fails, keep the kill(0) answer.
pass
return True
@ -2459,6 +2828,38 @@ def _clear_spawn_failures(conn: sqlite3.Connection, task_id: str) -> None:
)
def has_spawnable_ready(conn: sqlite3.Connection) -> bool:
"""Return True iff there is at least one ready+assigned+unclaimed task
whose assignee maps to a real Hermes profile.
Used by the gateway- and CLI-embedded dispatchers' health telemetry to
decide whether ``0 spawned`` is a "stuck" condition (real spawnable
work waiting) or a "correctly idle" condition (only control-plane
lanes like ``orion-cc`` / ``orion-research`` waiting on terminals
that pull tasks via ``claim_task`` directly).
Falls back to "any ready+assigned" if ``profile_exists`` is not
importable (e.g. partial install) preserves the old behavior so
the warning still fires in degraded environments.
"""
rows = conn.execute(
"SELECT DISTINCT assignee FROM tasks "
"WHERE status = 'ready' AND assignee IS NOT NULL "
" AND claim_lock IS NULL"
).fetchall()
if not rows:
return False
try:
from hermes_cli.profiles import profile_exists # local import: avoids cycle
except Exception:
# Can't introspect — assume spawnable, preserve legacy behavior.
return True
for row in rows:
if profile_exists(row["assignee"]):
return True
return False
def dispatch_once(
conn: sqlite3.Connection,
*,
@ -2506,6 +2907,29 @@ def dispatch_once(
if not row["assignee"]:
result.skipped_unassigned.append(row["id"])
continue
# Skip ready tasks whose assignee is not a real Hermes profile.
# `_default_spawn` invokes ``hermes -p <assignee>`` which fails
# with "Profile 'X' does not exist" when the assignee names a
# control-plane lane (e.g. an interactive Claude Code terminal
# like ``orion-cc`` / ``orion-research``) rather than a Hermes
# profile. Those task lanes are pulled by terminals via
# ``claim_task`` directly and should NEVER auto-spawn — the
# subprocess would crash on startup, get reaped as a zombie,
# the task would loop back to ``ready`` on next tick, and we'd
# burn CPU forever (#kanban-dispatcher-crash-loop 2026-05-05).
try:
from hermes_cli.profiles import profile_exists # local import: avoids cycle
except Exception:
profile_exists = None # type: ignore[assignment]
if profile_exists is not None and not profile_exists(row["assignee"]):
# Bucket separately from skipped_unassigned: the operator
# cannot fix this by assigning a profile (the assignee IS the
# intended owner — a terminal lane). Health telemetry uses
# this distinction to suppress spurious "stuck" warnings on
# multi-lane setups where the ready queue is steadily full
# of human-pulled work.
result.skipped_nonspawnable.append(row["id"])
continue
if dry_run:
result.spawned.append((row["id"], row["assignee"], ""))
continue
@ -3213,30 +3637,38 @@ def read_worker_log(
# ---------------------------------------------------------------------------
def list_profiles_on_disk() -> list[str]:
"""Return the set of named profiles discovered on disk.
"""Return the set of assignee/profile names discovered on disk.
Reads ``~/.hermes/profiles/`` directly so this module has no import
dependency on ``hermes_cli.profiles`` (which pulls in a large chunk
of the CLI startup path). Only returns directories that contain a
``config.yaml`` a bare dir without config isn't a real profile.
Includes:
- named profiles under ``<default-root>/profiles/<name>/config.yaml``
- the implicit ``default`` profile when the default Hermes root exists
Reads profile paths directly so this module has no import dependency on
``hermes_cli.profiles`` (which pulls in a large chunk of the CLI startup
path).
"""
try:
from hermes_constants import get_default_hermes_root
home = get_default_hermes_root() / "profiles"
default_root = get_default_hermes_root()
profiles_dir = default_root / "profiles"
except Exception:
return []
if not home.is_dir():
return []
names: list[str] = []
try:
for entry in sorted(home.iterdir()):
if not entry.is_dir():
continue
if (entry / "config.yaml").is_file():
names.append(entry.name)
except OSError:
return names
return names
names: set[str] = set()
if default_root.exists():
names.add("default")
if profiles_dir.is_dir():
try:
for entry in sorted(profiles_dir.iterdir()):
if not entry.is_dir():
continue
if (entry / "config.yaml").is_file():
names.add(entry.name)
except OSError:
pass
return sorted(names)
def known_assignees(conn: sqlite3.Connection) -> list[dict]:

View file

@ -1216,6 +1216,26 @@ def _launch_tui(
sys.exit(code)
def _pin_kanban_board_env() -> None:
"""Pin the active kanban board into ``HERMES_KANBAN_BOARD`` for the chat session.
Without this, in-process tools (``kanban_*``) and shelled-out CLI calls
(``hermes kanban ``) resolve the board on different paths: the env-pin if
set, otherwise the global ``<root>/kanban/current`` file. A concurrent
``hermes kanban boards switch`` from another session can flip the file
mid-turn, so the same chat sees its tool calls hit board A while its shell
calls hit board B (#20074). Pinning at chat boot mirrors what the
dispatcher already does for spawned workers.
"""
if os.environ.get("HERMES_KANBAN_BOARD"):
return
try:
from hermes_cli.kanban_db import get_current_board
os.environ["HERMES_KANBAN_BOARD"] = get_current_board()
except Exception:
pass
def cmd_chat(args):
"""Run interactive chat CLI."""
use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"
@ -1324,6 +1344,8 @@ def cmd_chat(args):
if getattr(args, "source", None):
os.environ["HERMES_SESSION_SOURCE"] = args.source
_pin_kanban_board_env()
if use_tui:
_launch_tui(
getattr(args, "resume", None),
@ -3974,6 +3996,85 @@ def _model_flow_copilot_acp(config, current_model=""):
print(f"Default model set to: {selected} (via {pconfig.name})")
def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "") -> tuple:
"""Shared API-key entry point for ``hermes setup`` / ``hermes model``.
Handles both first-time entry and the already-configured case. When a key
is already present, offers [K]eep / [R]eplace / [C]lear so the user can
recover from a malformed paste without editing ``~/.hermes/.env`` by hand.
Returns ``(resolved_key, abort)``. ``abort=True`` means the caller should
``return`` immediately the user cancelled entry, declined to replace, or
cleared the key and is now unconfigured.
"""
import getpass
from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER
from hermes_cli.config import save_env_value
key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else ""
def _prompt_new_key(*, allow_lmstudio_default: bool) -> str:
if provider_id == "lmstudio" and allow_lmstudio_default:
prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): "
else:
prompt = f"{key_env} (or Enter to cancel): "
try:
entered = getpass.getpass(prompt).strip()
except (KeyboardInterrupt, EOFError):
print()
return ""
if not entered and provider_id == "lmstudio" and allow_lmstudio_default:
return LMSTUDIO_NOAUTH_PLACEHOLDER
return entered
# First-time entry ────────────────────────────────────────────────────
if not existing_key:
print(f"No {pconfig.name} API key configured.")
if not key_env:
return "", True
new_key = _prompt_new_key(allow_lmstudio_default=True)
if not new_key:
print("Cancelled.")
return "", True
save_env_value(key_env, new_key)
print("API key saved.")
print()
return new_key, False
# Already configured — offer K / R / C ────────────────────────────────
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
if not key_env:
# Nothing we can rewrite; just acknowledge and move on.
print()
return existing_key, False
try:
choice = input(" [K]eep / [R]eplace / [C]lear (default K): ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
choice = "k"
if choice.startswith("r"):
new_key = _prompt_new_key(allow_lmstudio_default=False)
if not new_key:
print(" No change.")
print()
return existing_key, False
save_env_value(key_env, new_key)
print(" API key updated.")
print()
return new_key, False
if choice.startswith("c"):
save_env_value(key_env, "")
print(f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again.")
return "", True
# Keep (default, or any other input)
print()
return existing_key, False
def _model_flow_kimi(config, current_model=""):
"""Kimi / Moonshot model selection with automatic endpoint routing.
@ -4008,26 +4109,9 @@ def _model_flow_kimi(config, current_model=""):
if existing_key:
break
if not existing_key:
print(f"No {pconfig.name} API key configured.")
if key_env:
try:
import getpass
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return
if not new_key:
print("Cancelled.")
return
save_env_value(key_env, new_key)
existing_key = new_key
print("API key saved.")
print()
else:
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
print()
existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
if abort:
return
# Step 2: Auto-detect endpoint from key prefix
is_coding_plan = existing_key.startswith("sk-kimi-")
@ -4128,25 +4212,9 @@ def _model_flow_stepfun(config, current_model=""):
if existing_key:
break
if not existing_key:
print(f"No {pconfig.name} API key configured.")
if key_env:
try:
import getpass
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return
if not new_key:
print("Cancelled.")
return
save_env_value(key_env, new_key)
existing_key = new_key
print("API key saved.")
print()
else:
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
print()
existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
if abort:
return
current_base = ""
if base_url_env:
@ -4522,33 +4590,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
if existing_key:
break
if not existing_key:
print(f"No {pconfig.name} API key configured.")
if key_env:
try:
import getpass
if provider_id == "lmstudio":
prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): "
else:
prompt = f"{key_env} (or Enter to cancel): "
new_key = getpass.getpass(prompt).strip()
except (KeyboardInterrupt, EOFError):
print()
return
if not new_key:
if provider_id == "lmstudio":
new_key = LMSTUDIO_NOAUTH_PLACEHOLDER
else:
print("Cancelled.")
return
save_env_value(key_env, new_key)
existing_key = new_key
print("API key saved.")
print()
else:
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
print()
existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
if abort:
return
# Gemini free-tier gate: free-tier daily quotas (<= 250 RPD for Flash)
# are exhausted in a handful of agent turns, so refuse to wire up the

View file

@ -190,11 +190,18 @@ def _load_direct_aliases() -> dict[str, DirectAlias]:
model: "minimax-m2.7"
provider: custom
base_url: "https://ollama.com/v1"
Also reads ``model.aliases`` (set by ``hermes config set model.aliases.xxx``)
and converts simple string entries (``ds-flash: deepseek/deepseek-v4-flash``)
into DirectAlias objects. The provider is parsed from the ``provider/``
prefix in the value; if no slash, the current provider is used.
"""
merged = dict(_BUILTIN_DIRECT_ALIASES)
try:
from hermes_cli.config import load_config
cfg = load_config()
# --- model_aliases (dict-based format) ---
user_aliases = cfg.get("model_aliases")
if isinstance(user_aliases, dict):
for name, entry in user_aliases.items():
@ -207,6 +214,30 @@ def _load_direct_aliases() -> dict[str, DirectAlias]:
merged[name.strip().lower()] = DirectAlias(
model=model, provider=provider, base_url=base_url,
)
# --- model.aliases (string-based format, from config set) ---
model_section = cfg.get("model", {})
if isinstance(model_section, dict):
simple_aliases = model_section.get("aliases")
if isinstance(simple_aliases, dict):
current_provider = model_section.get("provider", "")
for name, value in simple_aliases.items():
if not isinstance(value, str) or not value.strip():
continue
key = name.strip().lower()
if key in merged:
continue # don't override explicit model_aliases entries
val = value.strip()
if "/" in val:
provider, model = val.split("/", 1)
else:
provider = current_provider
model = val
merged[key] = DirectAlias(
model=model.strip(),
provider=provider.strip() or current_provider,
base_url="",
)
except Exception:
pass
return merged
@ -1652,3 +1683,59 @@ def list_authenticated_providers(
results.sort(key=lambda r: (not r["is_current"], -r["total_models"]))
return results
def list_picker_providers(
current_provider: str = "",
user_providers: dict = None,
custom_providers: list | None = None,
max_models: int = 8,
) -> List[dict]:
"""Interactive-picker variant of :func:`list_authenticated_providers`.
Post-processes the base list so the ``/model`` picker (Telegram/Discord
inline keyboards) only surfaces models that are actually callable in the
current install:
- OpenRouter's model list is replaced with the output of
:func:`hermes_cli.models.fetch_openrouter_models`, which filters the
curated ``OPENROUTER_MODELS`` snapshot against the live OpenRouter
catalog. IDs the live catalog no longer carries drop out, so the
picker never offers a model the user can't call.
- Provider rows whose model list ends up empty are dropped, except
custom endpoints (``is_user_defined=True`` with an ``api_url``) where
the user may supply their own model set through config.
All other providers and metadata fields are passed through unchanged.
The typed ``/model <name>`` path is unaffected -- only the interactive
picker payload is narrowed.
"""
from hermes_cli.models import fetch_openrouter_models
providers = list_authenticated_providers(
current_provider=current_provider,
user_providers=user_providers,
custom_providers=custom_providers,
max_models=max_models,
)
filtered: List[dict] = []
for p in providers:
slug = str(p.get("slug", "")).lower()
if slug == "openrouter":
try:
live = fetch_openrouter_models()
live_ids = [mid for mid, _ in live]
except Exception:
live_ids = list(p.get("models", []))
p = dict(p)
p["models"] = live_ids[:max_models]
p["total_models"] = len(live_ids)
has_models = bool(p.get("models"))
is_custom_endpoint = bool(p.get("is_user_defined")) and bool(p.get("api_url"))
if not has_models and not is_custom_endpoint:
continue
filtered.append(p)
return filtered

View file

@ -15,6 +15,7 @@ import importlib.util
import json
import logging
import os
import re
import shutil
import sys
import copy
@ -208,12 +209,23 @@ def prompt(question: str, default: str = None, password: bool = False) -> str:
else:
value = input(color(display, Colors.YELLOW))
return value.strip() or default or ""
cleaned = _sanitize_pasted_input(value)
return cleaned.strip() or default or ""
except (KeyboardInterrupt, EOFError):
print()
sys.exit(1)
_BRACKETED_PASTE_PATTERN = re.compile(r"\x1b\[\s*200~|\x1b\[\s*201~")
def _sanitize_pasted_input(value: str) -> str:
"""Strip terminal bracketed-paste control markers from pasted text."""
if not isinstance(value, str) or not value:
return value
return _BRACKETED_PASTE_PATTERN.sub("", value)
def _curses_prompt_choice(question: str, choices: list, default: int = 0, description: str | None = None) -> int:
"""Single-select menu using curses. Delegates to curses_radiolist."""
from hermes_cli.curses_ui import curses_radiolist

View file

@ -334,6 +334,144 @@ TIPS = [
"MCP ${ENV_VAR} placeholders in config are resolved at server spawn — including vars from ~/.hermes/.env.",
"Skills from trusted repos (NousResearch) get a 'trusted' security level; community skills get extra scanning.",
"The skills quarantine at ~/.hermes/skills/.hub/quarantine/ holds skills pending security review.",
# --- Advanced Slash Commands ---
'/steer <prompt> injects a note after the next tool call — nudge direction mid-task without interrupting.',
'/goal <text> sets a standing Ralph-loop objective — Hermes auto-continues turn after turn until a judge says done.',
'/snapshot create [label] saves a full state snapshot of Hermes config; /snapshot restore <id> reverts later.',
'/copy [N] copies the last assistant response to your clipboard, or the Nth-from-last with a number.',
'/redraw forces a full UI repaint, fixing terminal drift after tmux resize or mouse selection artifacts.',
'/agents (alias /tasks) shows active agents and running background tasks across the current session.',
'/footer toggles the gateway footer on final replies showing model, tool counts, and turn timing.',
'/busy queue|steer|interrupt controls what pressing Enter does while Hermes is working.',
'/topic in Telegram DMs enables user-managed multi-session topic mode — /topic <id> restores past sessions inline.',
'/approve session|always runs a pending dangerous command with your chosen trust scope; /deny rejects it.',
'/restart gracefully restarts the gateway after draining active runs, then pings the requester when back up.',
'/kanban boards switch <slug> changes the active multi-project Kanban board from inside chat.',
'/reload reloads ~/.hermes/.env into the running session — pick up new API keys without restarting.',
# --- Cron (no-agent & scripts) ---
'cronjob with no_agent=True runs a script on schedule and sends its stdout directly — zero tokens, zero LLM.',
'An empty cron script stdout means silent tick — nothing is delivered, perfect for threshold watchdogs.',
"HERMES_CRON_MAX_PARALLEL (default 4) caps how many cron jobs run per tick so bursts don't saturate your keys.",
# --- Gateway Hooks ---
'Gateway hooks live under ~/.hermes/hooks/<name>/ with HOOK.yaml + handler.py — handler must be named `handle`.',
'Hook events include gateway:startup, session:start, agent:step, and command:* wildcard subscriptions.',
'Drop a ~/.hermes/BOOT.md checklist and a gateway:startup hook runs it as a one-shot agent every boot.',
# --- Curator ---
'hermes curator run --dry-run previews what the curator would archive or consolidate without mutating anything.',
"hermes curator pin <skill> hard-fences a skill against both auto-archival and the agent's skill_manage tool.",
'hermes curator rollback restores skills from a pre-run snapshot — backups live under skills/.curator_backups/.',
# --- Credential Pools & Routing ---
'hermes auth reset <provider> clears all cooldowns and exhaustion flags on a credential pool.',
'credential_pool_strategies.<provider>: round_robin cycles keys evenly instead of the fill_first default.',
'use_gateway: true per-tool routes web, image, tts, or browser through your Nous subscription — no extra keys.',
'provider_routing.data_collection: deny excludes data-storing providers on OpenRouter.',
'provider_routing.require_parameters: true only routes to providers that support every param in your request.',
# --- TUI & Dashboard ---
'HERMES_TUI_RESUME=1 auto-re-attaches to the most recent TUI session on launch — handy after SSH drops.',
"HERMES_TUI_THEME=light|dark|<hex> forces the TUI theme on terminals that don't set COLORFGBG.",
'Ctrl+G or Ctrl+X Ctrl+E in the TUI opens the input buffer in $EDITOR for long multi-line prompts.',
'The TUI renders LaTeX inline — $E=mc^2$ becomes Unicode math instead of raw TeX.',
'hermes dashboard launches a local web UI at 127.0.0.1:9119 — zero data leaves localhost.',
'hermes dashboard --tui embeds the full Hermes TUI in your browser via xterm.js and a WebSocket PTY.',
'Drop a YAML in ~/.hermes/dashboard-themes/ with two palette colors to reskin the entire dashboard.',
'Dashboard plugins are drop-in: manifest.json + JS bundle in ~/.hermes/dashboard-plugins/ — no npm build required.',
'layoutVariant: cockpit in a dashboard theme adds a 260px left rail that plugins can populate via the sidebar slot.',
# --- Env Vars & Config Gates ---
"display.tool_progress_command: true exposes /verbose on messaging platforms; it's CLI-only by default.",
'HERMES_BACKGROUND_NOTIFICATIONS=result only pings when background tasks finish (vs all/error/off).',
'HERMES_WRITE_SAFE_ROOT restricts write_file and patch to a directory prefix; writes outside require approval.',
'HERMES_IGNORE_RULES skips auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills.',
'HERMES_ACCEPT_HOOKS auto-approves unseen shell hooks declared in config.yaml without a TTY prompt.',
'auxiliary.goal_judge.model routes the /goal judge to a cheap fast model to keep loop cost near zero.',
'Checkpoints skip directories with more than 50,000 files to avoid slow git operations on massive monorepos.',
# --- TTS ---
'tts.provider: piper runs 44-language local TTS on CPU — voices auto-download to ~/.hermes/cache/piper-voices/.',
'tts.providers.<name>.type: command wires any CLI TTS engine with {input_path} and {output_path} placeholders.',
# --- API Server & Proxy ---
'API_SERVER_ENABLED=true runs an OpenAI-compatible endpoint alongside the gateway for Open WebUI and LibreChat.',
'GATEWAY_PROXY_URL runs a split setup: platform I/O locally, agent work delegated to a remote API server.',
# --- Platform-specific ---
'MATRIX_DEVICE_ID pins a stable device ID for E2EE — without it, keys rotate every start and historic decrypt breaks.',
'TELEGRAM_WEBHOOK_SECRET is required whenever TELEGRAM_WEBHOOK_URL is set — generate with openssl rand -hex 32.',
# --- Batch ---
"batch_runner.py --resume content-matches completed prompts by text so dataset reorders don't re-run finished work.",
# --- Less-Known Slash Commands ---
'/new starts a fresh session in place (alias /reset) — fresh session ID, clean history, CLI stays open.',
'/clear wipes the terminal screen AND starts a new session — one shortcut for a visual reset.',
'/history prints the current conversation in-line without leaving the CLI — useful for a quick re-read.',
'/save writes the current conversation to disk without ending the session.',
'/status shows session info at a glance: ID, title, model, token usage, and elapsed time.',
'/image <path> attaches a local image file for your next prompt without pasting or drag-and-drop.',
'/platforms shows gateway and messaging-platform connection status right from inside chat.',
'/commands paginates the full slash-command + installed-skill list — useful on platforms without tab completion.',
'/toolsets lists every available toolset so you know what -t/--toolsets accepts.',
'/gquota shows Google Gemini Code Assist quota usage with progress bars when that provider is active.',
'/voice tts toggles TTS-only mode — agent replies out loud but you still type your prompts.',
'/reload-skills re-scans ~/.hermes/skills/ so drop-in skills appear without restarting the session.',
'/indicator kaomoji|emoji|unicode|ascii picks the TUI busy-indicator style shown during agent runs.',
'/debug uploads a support bundle (system info + logs) and returns shareable links — works in chat too.',
# --- CLI Subcommands & Flags ---
'hermes -z "<prompt>" is the purest one-shot: final answer on stdout, nothing else — ideal for piping in scripts.',
'hermes chat --pass-session-id injects the session ID into the system prompt so the agent can self-reference it.',
'hermes chat --image path/to/pic.png attaches a local image to a single -q query without a separate upload step.',
'hermes chat --ignore-user-config skips ~/.hermes/config.yaml — reproducible bug reports and CI runs.',
"hermes chat --source tool tags programmatic chats so they don't clutter hermes sessions list.",
'hermes dump --show-keys includes redacted API key fingerprints for deeper support debugging.',
'hermes sessions rename <ID> "new title" renames any past session; hermes sessions delete <ID> removes one.',
'hermes import restores a session export or profile archive produced by sessions export or profile export.',
'hermes fallback manages the fallback_model chain interactively — no hand-editing config.yaml.',
'hermes pairing rotates the DM pairing token — the first messager after rotation claims access to the bot.',
'hermes setup walks first-time users through provider, keys, and platform wiring in one interactive flow.',
'hermes status --deep runs the full health sweep across every component; plain hermes status is the quick view.',
# --- Agent Behavior Env Vars ---
'HERMES_AGENT_TIMEOUT=0 disables the gateway inactivity kill for a running agent — use for long research runs.',
'HERMES_ENABLE_PROJECT_PLUGINS=1 auto-loads repo-local plugins from ./.hermes/plugins/ — trust-gated by design.',
"HERMES_DISABLE_FILE_STATE_GUARD=1 turns off the 'file changed since you read it' guard on patch and write_file.",
'HERMES_ALLOW_PRIVATE_URLS=true lets web tools hit localhost and private networks — off by default in gateway mode.',
'HERMES_OPTIONAL_SKILLS=name1,name2 auto-installs extra optional-catalog skills on first run per profile.',
'HERMES_BUNDLED_SKILLS points at a custom bundled-skill tree — used by Homebrew and Nix packaging.',
'HERMES_DUMP_REQUEST_STDOUT=1 dumps every API request payload to stdout instead of log files.',
'HERMES_OAUTH_TRACE=1 logs redacted OAuth token exchange and refresh attempts for debugging provider auth.',
'HERMES_STREAM_RETRIES (default 3) controls mid-stream reconnect attempts on transient network errors.',
# --- Gateway Behavior Env Vars ---
'HERMES_GATEWAY_BUSY_ACK_ENABLED=false silences the ⚡/⏳/⏩ ack messages when a user messages a busy agent.',
'HERMES_AGENT_NOTIFY_INTERVAL (default 180s) sets how often the gateway pings with progress on long turns.',
'HERMES_RESTART_DRAIN_TIMEOUT (default 900s) caps how long /restart waits for in-flight runs before forcing.',
'HERMES_CHECKPOINT_TIMEOUT (default 30s) caps filesystem checkpoint creation — raise it on huge monorepos.',
# --- Auxiliary Tasks & Image Generation ---
'image_gen.model in config.yaml picks the FAL model: flux-2/klein, gpt-image-2, nano-banana-pro, and more.',
'image_gen.provider routes image generation through a plugin (OpenAI Images, Codex, FAL) instead of the default.',
'AUXILIARY_VISION_BASE_URL + AUXILIARY_VISION_API_KEY point vision analysis at any OpenAI-compatible endpoint.',
'auxiliary.session_search.max_concurrency bounds how many matched sessions are summarized in parallel (default 3).',
'auxiliary.session_search.extra_body forwards provider-specific OpenAI-compatible fields on summarization calls.',
# --- Security ---
'security.tirith_fail_open: false makes Hermes block commands when the tirith scanner itself errors out.',
'TIRITH_FAIL_OPEN env var overrides the tirith_fail_open config — a quick toggle without editing config.yaml.',
# --- Sessions & Source Tags ---
'--source tool chats are excluded from hermes sessions list by default — set --source explicitly to see them.',
'Session IDs are timestamp-prefixed (20250305_091523_abcd) so sorting works naturally in ls and jq.',
# --- Misc ---
'API_SERVER_MODEL_NAME customizes the model name on /v1/models — essential for multi-profile Open WebUI setups.',
'Dashboard plugins are served from /dashboard-plugins/<name>/ — drop files into ~/.hermes/dashboard-plugins/.',
]

24
locales/de.yaml Normal file
View file

@ -0,0 +1,24 @@
# Hermes-Katalog für statische Meldungen -- Deutsch
# See locales/en.yaml for the source of truth; keep keys in sync.
approval:
dangerous_header: "⚠️ GEFÄHRLICHER BEFEHL: {description}"
choose_long: " [o]einmal | [s]sitzung | [a]immer | [d]ablehnen"
choose_short: " [o]einmal | [s]sitzung | [d]ablehnen"
prompt_long: " Auswahl [o/s/a/D]: "
prompt_short: " Auswahl [o/s/D]: "
timeout: " ⏱ Zeitüberschreitung Befehl wird abgelehnt"
allowed_once: " ✓ Einmalig erlaubt"
allowed_session: " ✓ Für diese Sitzung erlaubt"
allowed_always: " ✓ Zur dauerhaften Erlaubnisliste hinzugefügt"
denied: " ✗ Abgelehnt"
cancelled: " ✗ Abgebrochen"
blocklist_message: "Dieser Befehl steht auf der unbedingten Sperrliste und kann nicht genehmigt werden."
gateway:
approval_expired: "⚠️ Genehmigung abgelaufen (Agent wartet nicht mehr). Bitten Sie den Agenten, es erneut zu versuchen."
draining: "⏳ Warte auf {count} aktive(n) Agent(en) vor dem Neustart..."
goal_cleared: "✓ Ziel gelöscht."
no_active_goal: "Kein aktives Ziel."
config_read_failed: "⚠️ config.yaml konnte nicht gelesen werden: {error}"
config_save_failed: "⚠️ Konfiguration konnte nicht gespeichert werden: {error}"

35
locales/en.yaml Normal file
View file

@ -0,0 +1,35 @@
# Hermes static-message catalog -- English (baseline / source of truth)
#
# Only user-facing static messages from the CLI approval prompt and a handful
# of gateway slash-command replies live here. Agent-generated output, log
# lines, error tracebacks, tool outputs, and slash-command descriptions stay
# in English and are NOT translated -- see agent/i18n.py for scope rationale.
#
# Keys are dotted paths; nesting below is purely for readability. Values may
# contain {placeholder} tokens for str.format substitution. When adding a
# new key, add it to EVERY locale file (en/zh/ja/de/es) in the same commit --
# tests/agent/test_i18n.py asserts catalog parity.
approval:
# CLI approval prompt -- shown when a dangerous command needs user review.
dangerous_header: "⚠️ DANGEROUS COMMAND: {description}"
choose_long: " [o]nce | [s]ession | [a]lways | [d]eny"
choose_short: " [o]nce | [s]ession | [d]eny"
prompt_long: " Choice [o/s/a/D]: "
prompt_short: " Choice [o/s/D]: "
timeout: " ⏱ Timeout - denying command"
allowed_once: " ✓ Allowed once"
allowed_session: " ✓ Allowed for this session"
allowed_always: " ✓ Added to permanent allowlist"
denied: " ✗ Denied"
cancelled: " ✗ Cancelled"
blocklist_message: "This command is on the unconditional blocklist and cannot be approved."
gateway:
# Messenger replies to slash commands and implicit state changes.
approval_expired: "⚠️ Approval expired (agent is no longer waiting). Ask the agent to try again."
draining: "⏳ Draining {count} active agent(s) before restart..."
goal_cleared: "✓ Goal cleared."
no_active_goal: "No active goal."
config_read_failed: "⚠️ Could not read config.yaml: {error}"
config_save_failed: "⚠️ Could not save config: {error}"

24
locales/es.yaml Normal file
View file

@ -0,0 +1,24 @@
# Catálogo de mensajes estáticos de Hermes -- Español
# See locales/en.yaml for the source of truth; keep keys in sync.
approval:
dangerous_header: "⚠️ COMANDO PELIGROSO: {description}"
choose_long: " [o]una vez | [s]sesión | [a]siempre | [d]denegar"
choose_short: " [o]una vez | [s]sesión | [d]denegar"
prompt_long: " Opción [o/s/a/D]: "
prompt_short: " Opción [o/s/D]: "
timeout: " ⏱ Tiempo agotado — comando denegado"
allowed_once: " ✓ Permitido una vez"
allowed_session: " ✓ Permitido en esta sesión"
allowed_always: " ✓ Añadido a la lista de permitidos permanente"
denied: " ✗ Denegado"
cancelled: " ✗ Cancelado"
blocklist_message: "Este comando está en la lista de bloqueo incondicional y no se puede aprobar."
gateway:
approval_expired: "⚠️ La aprobación ha caducado (el agente ya no está esperando). Pida al agente que lo intente de nuevo."
draining: "⏳ Esperando a que terminen {count} agente(s) activo(s) antes de reiniciar..."
goal_cleared: "✓ Objetivo eliminado."
no_active_goal: "No hay objetivo activo."
config_read_failed: "⚠️ No se pudo leer config.yaml: {error}"
config_save_failed: "⚠️ No se pudo guardar la configuración: {error}"

24
locales/ja.yaml Normal file
View file

@ -0,0 +1,24 @@
# Hermes 静的メッセージカタログ -- 日本語
# See locales/en.yaml for the source of truth; keep keys in sync.
approval:
dangerous_header: "⚠️ 危険なコマンド: {description}"
choose_long: " [o]今回のみ | [s]セッション中 | [a]常に許可 | [d]拒否"
choose_short: " [o]今回のみ | [s]セッション中 | [d]拒否"
prompt_long: " 選択 [o/s/a/D]: "
prompt_short: " 選択 [o/s/D]: "
timeout: " ⏱ タイムアウト — コマンドを拒否しました"
allowed_once: " ✓ 今回のみ許可"
allowed_session: " ✓ このセッション中は許可"
allowed_always: " ✓ 永続的な許可リストに追加"
denied: " ✗ 拒否しました"
cancelled: " ✗ キャンセルしました"
blocklist_message: "このコマンドは無条件ブロックリストに含まれており、承認できません。"
gateway:
approval_expired: "⚠️ 承認の有効期限が切れました(エージェントはもう待機していません)。エージェントに再試行を依頼してください。"
draining: "⏳ 再起動前に {count} 個のアクティブエージェントの終了を待っています..."
goal_cleared: "✓ 目標をクリアしました。"
no_active_goal: "アクティブな目標はありません。"
config_read_failed: "⚠️ config.yaml を読み込めませんでした: {error}"
config_save_failed: "⚠️ 設定を保存できませんでした: {error}"

24
locales/zh.yaml Normal file
View file

@ -0,0 +1,24 @@
# Hermes 静态消息目录 -- 中文(简体)
# See locales/en.yaml for the source of truth; keep keys in sync.
approval:
dangerous_header: "⚠️ 危险命令: {description}"
choose_long: " [o]仅此一次 | [s]本次会话 | [a]永久允许 | [d]拒绝"
choose_short: " [o]仅此一次 | [s]本次会话 | [d]拒绝"
prompt_long: " 选择 [o/s/a/D]: "
prompt_short: " 选择 [o/s/D]: "
timeout: " ⏱ 超时 — 已拒绝命令"
allowed_once: " ✓ 本次允许"
allowed_session: " ✓ 本次会话内允许"
allowed_always: " ✓ 已加入永久允许列表"
denied: " ✗ 已拒绝"
cancelled: " ✗ 已取消"
blocklist_message: "此命令位于无条件拦截列表中,无法被批准。"
gateway:
approval_expired: "⚠️ 批准已过期(代理不再等待)。请让代理重试。"
draining: "⏳ 正在等待 {count} 个活跃代理结束后重启..."
goal_cleared: "✓ 目标已清除。"
no_active_goal: "当前没有活跃的目标。"
config_read_failed: "⚠️ 无法读取 config.yaml{error}"
config_save_failed: "⚠️ 无法保存配置:{error}"

View file

@ -163,35 +163,42 @@
for entry in "''${ENTRIES[@]}"; do
IFS=":" read -r ATTR FOLDER NIX_FILE <<< "$entry"
echo "==> .#$ATTR ($FOLDER -> $NIX_FILE)"
OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1)
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
# Compute the actual hash from the lockfile directly using
# prefetch-npm-deps. This avoids false "ok" from nix build when
# an old derivation is cached in a substituter (cachix/cache.nixos.org).
LOCK_FILE="$FOLDER/package-lock.json"
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
if [ -z "$NEW_HASH" ]; then
echo " prefetch-npm-deps failed, falling back to nix build" >&2
OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1)
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo " ok (via nix build)"
continue
fi
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -z "$NEW_HASH" ]; then
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo " skipped (transient cache failure see primary nix build for real status)" >&2
echo "$OUTPUT" | tail -8 >&2
continue
fi
echo " build failed with no hash mismatch:" >&2
echo "$OUTPUT" | tail -40 >&2
exit 1
fi
fi
OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \
| sed -E 's/hash = "(.*)"/\1/')
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
echo " ok"
continue
fi
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -z "$NEW_HASH" ]; then
# Magic-Nix-Cache occasionally returns HTTP 418 / cache-throttled
# mid-run; nix then prints "outputs … not valid, so checking is
# not possible" without a `got:` line. That's an infrastructure
# blip, not a stale lockfile — warn + skip rather than failing
# the lint. A real hash mismatch would still surface in the
# primary `.#$ATTR` build, which is a separate CI job.
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo " skipped (transient cache failure see primary nix build for real status)" >&2
echo "$OUTPUT" | tail -8 >&2
continue
fi
echo " build failed with no hash mismatch:" >&2
echo "$OUTPUT" | tail -40 >&2
exit 1
fi
HASH_LINE=$(grep -n 'hash = "sha256-' "$NIX_FILE" | head -1 | cut -d: -f1)
OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \
| sed -E 's/hash = "(.*)"/\1/')
LOCK_FILE="$FOLDER/package-lock.json"
echo " stale: $NIX_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
STALE=1

View file

@ -4,7 +4,7 @@ let
src = ../ui-tui;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-a/HGI9OgVcTnZrMXA7xFMGnFoVxyHe95fulVz+WNYB0=";
hash = "sha256-MLcLhjTF6dgdvNBtJWzo8Nh19eNh/ZitD2b07nm61Tc=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };

View file

@ -60,6 +60,51 @@
blocked: "Mark this task as blocked? The worker's claim is released.",
};
// Event kinds that indicate a hallucinated/phantom task-id reference
// in a completion. ``completion_blocked_hallucination`` is emitted when
// the kernel's ``created_cards`` gate rejects a completion; the task is
// left in its prior state and the worker can retry. ``suspected_
// hallucinated_references`` is the advisory prose-scan result — the
// completion succeeded but the summary text references task ids that
// do not resolve.
const HALLUCINATION_EVENT_KINDS = [
"completion_blocked_hallucination",
"suspected_hallucinated_references",
];
const HALLUCINATION_EVENT_LABELS = {
completion_blocked_hallucination: "Completion blocked — phantom card ids",
suspected_hallucinated_references: "Prose referenced phantom card ids",
};
function isHallucinationEvent(kind) {
return HALLUCINATION_EVENT_KINDS.indexOf(kind) !== -1;
}
function phantomIdsFromEvent(ev) {
// Payload shapes:
// completion_blocked_hallucination: {phantom_cards, verified_cards, summary_preview}
// suspected_hallucinated_references: {phantom_refs, source}
if (!ev || !ev.payload) return [];
const p = ev.payload;
return p.phantom_cards || p.phantom_refs || [];
}
function withCompletionSummary(patch, count) {
if (!patch || patch.status !== "done") return patch;
const label = count && count > 1 ? `${count} selected task(s)` : "this task";
const value = window.prompt(
`Completion summary for ${label}. This is stored as the task result.`,
"",
);
if (value === null) return null;
const summary = value.trim();
if (!summary) {
window.alert("Completion summary is required before marking a task done.");
return null;
}
return Object.assign({}, patch, { result: summary, summary });
}
const API = "/api/plugins/kanban";
const MIME_TASK = "text/x-hermes-task";
@ -480,6 +525,8 @@
const moveTask = useCallback(function (taskId, newStatus) {
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
if (confirmMsg && !window.confirm(confirmMsg)) return;
const patch = withCompletionSummary({ status: newStatus }, 1);
if (!patch) return;
setBoardData(function (b) {
if (!b) return b;
let moved = null;
@ -499,7 +546,7 @@
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
body: JSON.stringify(patch),
}).catch(function (err) {
setError(`Move failed: ${err.message || err}`);
loadBoard();
@ -538,7 +585,9 @@
const applyBulk = useCallback(function (patch, confirmMsg) {
if (selectedIds.size === 0) return;
if (confirmMsg && !window.confirm(confirmMsg)) return;
const body = Object.assign({ ids: Array.from(selectedIds) }, patch);
const finalPatch = withCompletionSummary(patch, selectedIds.size);
if (!finalPatch) return;
const body = Object.assign({ ids: Array.from(selectedIds) }, finalPatch);
SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -626,6 +675,10 @@
return createNewBoard(payload).then(function () { setShowNewBoard(false); });
},
}) : null,
h(AttentionStrip, {
boardData,
onOpen: setSelectedTaskId,
}),
h(BoardToolbar, {
board: boardData,
tenantFilter, setTenantFilter,
@ -664,12 +717,303 @@
onRefresh: loadBoard,
renderMarkdown: renderMd,
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
assignees: (boardData && boardData.assignees) || [],
eventTick: taskEventTick[selectedTaskId] || 0,
}) : null,
),
);
}
// -------------------------------------------------------------------------
// Attention strip — surfaces tasks with active hallucination warnings.
// Renders a collapsed bar just below the board switcher; clicking expands
// a list of affected tasks with an "Open" button each. Dismissible per
// session via state flag; tasks re-appear on page reload if they still
// have warnings.
// -------------------------------------------------------------------------
function collectWarningTasks(boardData) {
if (!boardData || !boardData.columns) return [];
const out = [];
for (const col of boardData.columns) {
for (const t of col.tasks || []) {
if (t.warnings && t.warnings.count > 0) out.push(t);
}
}
// Sort: most recent warning first.
out.sort(function (a, b) {
return (b.warnings.latest_at || 0) - (a.warnings.latest_at || 0);
});
return out;
}
function AttentionStrip(props) {
const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState(false);
const warnTasks = useMemo(
function () { return collectWarningTasks(props.boardData); },
[props.boardData]
);
if (dismissed || warnTasks.length === 0) return null;
return h("div", { className: "hermes-kanban-attention" },
h("div", { className: "hermes-kanban-attention-bar" },
h("span", { className: "hermes-kanban-attention-icon" }, "⚠"),
h("span", { className: "hermes-kanban-attention-text" },
warnTasks.length === 1
? "1 task with hallucination warnings"
: `${warnTasks.length} tasks with hallucination warnings`,
),
h("button", {
className: "hermes-kanban-attention-toggle",
onClick: function () { setExpanded(function (x) { return !x; }); },
type: "button",
}, expanded ? "Hide" : "Show"),
h("button", {
className: "hermes-kanban-attention-dismiss",
onClick: function () { setDismissed(true); },
title: "Hide until next page reload",
type: "button",
}, "✕"),
),
expanded
? h("div", { className: "hermes-kanban-attention-list" },
warnTasks.map(function (t) {
return h("div", { key: t.id, className: "hermes-kanban-attention-row" },
h("span", { className: "hermes-kanban-attention-row-id" }, t.id),
h("span", { className: "hermes-kanban-attention-row-title" },
t.title || "(untitled)"),
h("span", { className: "hermes-kanban-attention-row-meta" },
t.assignee ? "@" + t.assignee : "unassigned",
" · ",
`${t.warnings.count} event${t.warnings.count === 1 ? "" : "s"}`,
),
h("button", {
className: "hermes-kanban-attention-row-btn",
onClick: function () { props.onOpen(t.id); },
type: "button",
}, "Open"),
);
}),
)
: null,
);
}
// -------------------------------------------------------------------------
// Recovery popover — operator actions for a task flagged with
// hallucination warnings. Three primary actions:
// 1. Reclaim — release a running worker's claim; task back to ready.
// 2. Reassign — switch the task to a different profile (with optional
// reclaim-first toggle for currently-running tasks).
// 3. Edit profile — copy the CLI hint for `hermes -p <name> model`
// (the dashboard can't edit profile config from the
// browser; it lives on the filesystem).
// Rendered from inside TaskDetail via a toggle button.
// -------------------------------------------------------------------------
function RecoveryPopover(props) {
const t = props.task;
const board = props.boardSlug;
const assignees = props.assignees || [];
const [reason, setReason] = useState("");
const [newProfile, setNewProfile] = useState(t.assignee || "");
const [reclaimFirst, setReclaimFirst] = useState(t.status === "running");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState(null);
const [copied, setCopied] = useState(false);
const act = function (kind) {
if (busy) return;
setBusy(true);
setMsg(null);
const urlBase = `${API}/tasks/${encodeURIComponent(t.id)}`;
const url = kind === "reclaim"
? withBoard(`${urlBase}/reclaim`, board)
: withBoard(`${urlBase}/reassign`, board);
const body = kind === "reclaim"
? { reason: reason || null }
: {
profile: newProfile || null,
reclaim_first: !!reclaimFirst,
reason: reason || null,
};
SDK.fetchJSON(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(function () {
setMsg({ ok: true, text:
kind === "reclaim"
? `Reclaimed ${t.id}. Task back to ready.`
: `Reassigned ${t.id} to ${newProfile || "(unassigned)"}.`
});
if (props.onActionComplete) props.onActionComplete(kind);
}).catch(function (err) {
setMsg({ ok: false, text: `Failed: ${err.message || err}` });
}).then(function () {
setBusy(false);
});
};
const profileCmd = `hermes -p ${t.assignee || "<profile>"} model`;
const copyCmd = function () {
try {
navigator.clipboard.writeText(profileCmd).then(function () {
setCopied(true);
setTimeout(function () { setCopied(false); }, 2000);
});
} catch (_) {
window.prompt("Copy this command:", profileCmd);
}
};
return h("div", { className: "hermes-kanban-recovery" },
h("div", { className: "hermes-kanban-recovery-title" },
"Recovery actions"),
h("div", { className: "hermes-kanban-recovery-hint" },
"Use these when a worker is stuck (crash loop, repeated hallucination, ",
"broken model). Events in this task's history are preserved as audit trail."),
// Reason input (shared across actions)
h("div", { className: "hermes-kanban-recovery-section" },
h("label", { className: "hermes-kanban-recovery-label" },
"Reason (optional, logged on event)"),
h("input", {
type: "text",
className: "hermes-kanban-recovery-input",
value: reason,
onChange: function (e) { setReason(e.target.value); },
placeholder: "e.g. model hallucinating, switching to larger",
}),
),
// Action 1: Reclaim
h("div", { className: "hermes-kanban-recovery-section" },
h("div", { className: "hermes-kanban-recovery-action-row" },
h("div", { className: "hermes-kanban-recovery-action-label" },
"1. Reclaim"),
h("div", { className: "hermes-kanban-recovery-action-desc" },
t.status === "running"
? "Abort the running worker and reset to ready."
: "Task is not running — nothing to reclaim."),
h("button", {
className: "hermes-kanban-recovery-btn",
disabled: busy || t.status !== "running",
onClick: function () { act("reclaim"); },
type: "button",
}, "Reclaim"),
),
),
// Action 2: Reassign
h("div", { className: "hermes-kanban-recovery-section" },
h("div", { className: "hermes-kanban-recovery-action-row" },
h("div", { className: "hermes-kanban-recovery-action-label" },
"2. Reassign"),
h("div", { className: "hermes-kanban-recovery-action-desc" },
"Switch to a different worker profile and retry."),
),
h("div", { className: "hermes-kanban-recovery-reassign-row" },
h("select", {
className: "hermes-kanban-recovery-select",
value: newProfile,
onChange: function (e) { setNewProfile(e.target.value); },
},
h("option", { value: "" }, "(unassigned)"),
assignees.map(function (a) {
return h("option", { key: a, value: a }, a);
}),
),
h("label", { className: "hermes-kanban-recovery-checkbox" },
h("input", {
type: "checkbox",
checked: reclaimFirst,
onChange: function (e) { setReclaimFirst(e.target.checked); },
}),
" Reclaim first",
),
h("button", {
className: "hermes-kanban-recovery-btn",
disabled: busy,
onClick: function () { act("reassign"); },
type: "button",
}, "Reassign"),
),
),
// Action 3: Edit profile model (CLI hint)
h("div", { className: "hermes-kanban-recovery-section" },
h("div", { className: "hermes-kanban-recovery-action-row" },
h("div", { className: "hermes-kanban-recovery-action-label" },
"3. Change profile model"),
h("div", { className: "hermes-kanban-recovery-action-desc" },
"Profile config lives on disk — change it from a terminal, ",
"then use Reclaim above to retry with the new model."),
),
h("div", { className: "hermes-kanban-recovery-cmd-row" },
h("code", { className: "hermes-kanban-recovery-cmd" }, profileCmd),
h("button", {
className: "hermes-kanban-recovery-btn",
onClick: copyCmd,
type: "button",
}, copied ? "Copied" : "Copy"),
),
),
msg
? h("div", {
className: cn(
"hermes-kanban-recovery-msg",
msg.ok ? "hermes-kanban-recovery-msg--ok" : "hermes-kanban-recovery-msg--err",
),
}, msg.text)
: null,
);
}
// Thin wrapper that toggles the RecoveryPopover visibility inside a
// task drawer. Auto-opens when the task has active hallucination
// warnings; operators can still collapse it. Always available via a
// header button for tasks without warnings, so reclaim/reassign is
// accessible for other stuck-worker scenarios too.
function RecoverySection(props) {
const [open, setOpen] = useState(!!props.hasWarnings);
// Re-open automatically if warnings appear while the drawer is open.
useEffect(function () {
if (props.hasWarnings) setOpen(true);
}, [props.hasWarnings]);
return h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head-row" },
h("span", { className: "hermes-kanban-section-head" },
props.hasWarnings
? h("span", { className: "hermes-kanban-section-head-warning" },
"⚠ Recovery")
: "Recovery",
),
h("button", {
className: "hermes-kanban-section-toggle",
onClick: function () { setOpen(function (x) { return !x; }); },
type: "button",
}, open ? "Hide" : "Show"),
),
open
? h(RecoveryPopover, {
// Keyed by task id so React tears the popover down and
// remounts it when the drawer swaps to a different task —
// otherwise reason / newProfile / success toast from the
// previous task leak into the new one.
key: props.task.id,
task: props.task,
boardSlug: props.boardSlug,
assignees: props.assignees,
onActionComplete: function () {
if (props.onRefresh) props.onRefresh();
},
})
: null,
);
}
// -------------------------------------------------------------------------
// Board switcher (multi-project)
// -------------------------------------------------------------------------
@ -1199,6 +1543,14 @@
title: "Select for bulk actions",
}),
h("span", { className: "hermes-kanban-card-id" }, t.id),
t.warnings && t.warnings.count > 0
? h("span", {
className: "hermes-kanban-warning-badge",
title: `${t.warnings.count} hallucination ` +
`event(s) since last clean completion. ` +
`Click to open for details.`,
}, "⚠")
: null,
t.priority > 0
? h(Badge, { className: "hermes-kanban-priority" }, `P${t.priority}`)
: null,
@ -1426,10 +1778,12 @@
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
return Promise.resolve();
}
const finalPatch = withCompletionSummary(patch, 1);
if (!finalPatch) return Promise.resolve();
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
body: JSON.stringify(finalPatch),
}).then(function () { load(); props.onRefresh(); });
};
@ -1519,6 +1873,7 @@
data, editing, setEditing,
renderMarkdown: props.renderMarkdown,
allTasks: props.allTasks,
assignees: props.assignees || [],
boardSlug: boardSlug,
onPatch: doPatch,
onAddParent: addLink,
@ -1528,6 +1883,7 @@
homeChannels: homeChannels,
homeBusy: homeBusy,
onToggleHomeSub: toggleHomeSubscription,
onRefresh: props.onRefresh,
}) : null,
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
h(Input, {
@ -1589,6 +1945,13 @@
t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null,
),
h(StatusActions, { task: t, onPatch: props.onPatch }),
h(RecoverySection, {
task: t,
boardSlug: props.boardSlug,
assignees: props.assignees,
hasWarnings: t.warnings && t.warnings.count > 0,
onRefresh: props.onRefresh,
}),
h(HomeSubsSection, {
homeChannels: props.homeChannels || [],
homeBusy: props.homeBusy || {},
@ -1629,11 +1992,41 @@
h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head" }, `Events (${events.length})`),
events.slice().reverse().slice(0, 20).map(function (e) {
return h("div", { key: e.id, className: "hermes-kanban-event" },
h("span", { className: "hermes-kanban-event-kind" }, e.kind),
h("span", { className: "hermes-kanban-event-ago" },
timeAgo ? timeAgo(e.created_at) : ""),
e.payload
const isHall = isHallucinationEvent(e.kind);
const phantoms = isHall ? phantomIdsFromEvent(e) : [];
return h("div", {
key: e.id,
className: cn(
"hermes-kanban-event",
isHall ? "hermes-kanban-event--hallucination" : "",
),
},
isHall
? h("div", { className: "hermes-kanban-event-header" },
h("span", { className: "hermes-kanban-event-warning-icon" }, "⚠"),
h("span", { className: "hermes-kanban-event-warning-label" },
HALLUCINATION_EVENT_LABELS[e.kind] || e.kind),
h("span", { className: "hermes-kanban-event-ago" },
timeAgo ? timeAgo(e.created_at) : ""),
)
: h("div", { className: "hermes-kanban-event-header-plain" },
h("span", { className: "hermes-kanban-event-kind" }, e.kind),
h("span", { className: "hermes-kanban-event-ago" },
timeAgo ? timeAgo(e.created_at) : ""),
),
isHall && phantoms.length > 0
? h("div", { className: "hermes-kanban-event-phantom-row" },
h("span", { className: "hermes-kanban-event-phantom-label" },
"Phantom ids:"),
phantoms.map(function (pid) {
return h("code", {
key: pid,
className: "hermes-kanban-event-phantom-chip",
}, pid);
}),
)
: null,
e.payload && !isHall
? h("code", { className: "hermes-kanban-event-payload" },
JSON.stringify(e.payload))
: null,

View file

@ -847,3 +847,256 @@
gap: 0.5rem;
margin-top: 1rem;
}
/* ---------------------------------------------------------------------- */
/* Hallucination warnings: per-card badge, events callout, attention */
/* strip, recovery popover. Orange/red palette but muted so the board */
/* doesn't scream on every render. */
/* ---------------------------------------------------------------------- */
.hermes-kanban-warning-badge {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: #ff9e3b;
margin-left: 0.25rem;
cursor: help;
}
/* Attention strip — collapsed state is a thin bar. */
.hermes-kanban-attention {
border: 1px solid rgba(255, 158, 59, 0.35);
background: rgba(255, 158, 59, 0.06);
border-radius: 0.5rem;
overflow: hidden;
}
.hermes-kanban-attention-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
font-size: 0.8125rem;
}
.hermes-kanban-attention-icon { color: #ff9e3b; font-size: 1rem; }
.hermes-kanban-attention-text { flex: 1; }
.hermes-kanban-attention-toggle,
.hermes-kanban-attention-dismiss,
.hermes-kanban-attention-row-btn {
background: transparent;
border: 1px solid rgba(120, 120, 140, 0.3);
border-radius: 0.3rem;
padding: 0.15rem 0.55rem;
font-size: 0.75rem;
color: inherit;
cursor: pointer;
}
.hermes-kanban-attention-toggle:hover,
.hermes-kanban-attention-dismiss:hover,
.hermes-kanban-attention-row-btn:hover {
background: rgba(255, 158, 59, 0.12);
}
.hermes-kanban-attention-list {
border-top: 1px solid rgba(255, 158, 59, 0.2);
padding: 0.25rem 0;
}
.hermes-kanban-attention-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.75rem;
font-size: 0.8125rem;
}
.hermes-kanban-attention-row:hover {
background: rgba(255, 158, 59, 0.08);
}
.hermes-kanban-attention-row-id {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.75rem;
color: var(--color-muted-foreground, #888);
min-width: 7rem;
}
.hermes-kanban-attention-row-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hermes-kanban-attention-row-meta {
font-size: 0.75rem;
color: var(--color-muted-foreground, #888);
}
/* Events tab — callout style for hallucination events. */
.hermes-kanban-event--hallucination {
border-left: 3px solid #ff6b6b;
background: rgba(255, 107, 107, 0.08);
padding: 0.5rem 0.65rem;
border-radius: 0.35rem;
margin: 0.25rem 0;
}
.hermes-kanban-event-header,
.hermes-kanban-event-header-plain {
display: flex;
align-items: center;
gap: 0.5rem;
}
.hermes-kanban-event-warning-icon { color: #ff6b6b; font-size: 1rem; }
.hermes-kanban-event-warning-label {
color: #ff6b6b;
font-weight: 600;
font-size: 0.8125rem;
}
.hermes-kanban-event-phantom-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.3rem;
padding-left: 1.35rem;
}
.hermes-kanban-event-phantom-label {
font-size: 0.75rem;
color: var(--color-muted-foreground, #999);
}
.hermes-kanban-event-phantom-chip {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
background: rgba(255, 107, 107, 0.15);
border: 1px solid rgba(255, 107, 107, 0.3);
border-radius: 0.3rem;
}
/* Recovery section header — amber accent when the task has warnings. */
.hermes-kanban-section-head-warning { color: #ff9e3b; }
.hermes-kanban-section-head-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.hermes-kanban-section-toggle {
background: transparent;
border: 1px solid rgba(120, 120, 140, 0.3);
border-radius: 0.3rem;
padding: 0.15rem 0.55rem;
font-size: 0.75rem;
color: inherit;
cursor: pointer;
}
/* Recovery popover body. */
.hermes-kanban-recovery {
border: 1px solid rgba(120, 120, 140, 0.25);
background: rgba(255, 158, 59, 0.04);
border-radius: 0.5rem;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.hermes-kanban-recovery-title {
font-weight: 600;
font-size: 0.8125rem;
}
.hermes-kanban-recovery-hint {
font-size: 0.75rem;
color: var(--color-muted-foreground, #888);
line-height: 1.35;
}
.hermes-kanban-recovery-section {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.hermes-kanban-recovery-label {
font-size: 0.75rem;
color: var(--color-muted-foreground, #888);
}
.hermes-kanban-recovery-input,
.hermes-kanban-recovery-select {
padding: 0.25rem 0.4rem;
font-size: 0.8125rem;
background: rgba(0, 0, 0, 0.15);
border: 1px solid rgba(120, 120, 140, 0.3);
border-radius: 0.3rem;
color: inherit;
outline: none;
}
.hermes-kanban-recovery-action-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.hermes-kanban-recovery-action-label {
font-size: 0.8125rem;
font-weight: 600;
min-width: 8rem;
}
.hermes-kanban-recovery-action-desc {
flex: 1;
font-size: 0.75rem;
color: var(--color-muted-foreground, #888);
}
.hermes-kanban-recovery-btn {
padding: 0.25rem 0.7rem;
font-size: 0.75rem;
background: rgba(255, 158, 59, 0.15);
border: 1px solid rgba(255, 158, 59, 0.4);
border-radius: 0.3rem;
color: inherit;
cursor: pointer;
}
.hermes-kanban-recovery-btn:hover:not(:disabled) {
background: rgba(255, 158, 59, 0.25);
}
.hermes-kanban-recovery-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.hermes-kanban-recovery-reassign-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.hermes-kanban-recovery-checkbox {
font-size: 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.hermes-kanban-recovery-cmd-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.hermes-kanban-recovery-cmd {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(120, 120, 140, 0.3);
border-radius: 0.3rem;
flex: 1;
min-width: 10rem;
overflow-x: auto;
white-space: nowrap;
}
.hermes-kanban-recovery-msg {
font-size: 0.75rem;
padding: 0.35rem 0.5rem;
border-radius: 0.3rem;
}
.hermes-kanban-recovery-msg--ok {
background: rgba(120, 200, 120, 0.12);
color: #6bc46b;
border: 1px solid rgba(120, 200, 120, 0.3);
}
.hermes-kanban-recovery-msg--err {
background: rgba(255, 107, 107, 0.12);
color: #ff8b8b;
border: 1px solid rgba(255, 107, 107, 0.3);
}

View file

@ -176,6 +176,74 @@ def _run_dict(r: kanban_db.Run) -> dict[str, Any]:
}
# Hallucination-warning event kinds — see complete_task() in kanban_db.py.
# completion_blocked_hallucination: kernel rejected created_cards with
# phantom ids; task stays in prior state.
# suspected_hallucinated_references: prose scan found t_<hex> in summary
# that doesn't resolve; completion succeeded, advisory only.
_WARNING_EVENT_KINDS = (
"completion_blocked_hallucination",
"suspected_hallucinated_references",
)
def _compute_warnings_for_tasks(
conn: sqlite3.Connection,
task_ids: Optional[list[str]] = None,
) -> dict[str, dict]:
"""Return {task_id: {count, kinds, latest_at}} for tasks with
hallucination warnings that occurred AFTER the most recent clean
completion event (completed / edited). An empty dict means no tasks
on the board have active warnings.
``task_ids`` narrows the query; pass ``None`` to scan the whole DB
(matches board-level rollup). Used by both the /board aggregate and
per-task /tasks/:id endpoints.
"""
params: tuple = ()
if task_ids is not None:
if not task_ids:
return {}
placeholders = ",".join(["?"] * len(task_ids))
sql = (
"SELECT task_id, kind, created_at FROM task_events "
f"WHERE task_id IN ({placeholders}) AND kind IN "
"('completion_blocked_hallucination', "
" 'suspected_hallucinated_references', "
" 'completed', 'edited') "
"ORDER BY task_id, id"
)
params = tuple(task_ids)
else:
sql = (
"SELECT task_id, kind, created_at FROM task_events "
"WHERE kind IN "
"('completion_blocked_hallucination', "
" 'suspected_hallucinated_references', "
" 'completed', 'edited') "
"ORDER BY task_id, id"
)
out: dict[str, dict] = {}
for row in conn.execute(sql, params).fetchall():
tid = row["task_id"]
kind = row["kind"]
created_at = row["created_at"]
if kind in ("completed", "edited"):
# Clean event wipes prior warning counters; only events after
# this timestamp count.
out.pop(tid, None)
continue
bucket = out.setdefault(
tid, {"count": 0, "kinds": {}, "latest_at": 0}
)
bucket["count"] += 1
bucket["kinds"][kind] = bucket["kinds"].get(kind, 0) + 1
if created_at > bucket["latest_at"]:
bucket["latest_at"] = created_at
return out
def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]:
"""Return {'parents': [...], 'children': [...]} for a task."""
parents = [
@ -253,6 +321,11 @@ def get_board(
if row["cstatus"] == "done":
p["done"] += 1
# Hallucination-warning rollup for this board (all tasks).
# Delegated to _compute_warnings_for_tasks so the per-task
# /tasks/:id endpoint can reuse the same rule.
warnings_per_task = _compute_warnings_for_tasks(conn, task_ids=None)
latest_event_id = conn.execute(
"SELECT COALESCE(MAX(id), 0) AS m FROM task_events"
).fetchone()["m"]
@ -266,6 +339,9 @@ def get_board(
d["link_counts"] = link_counts.get(t.id, {"parents": 0, "children": 0})
d["comment_count"] = comment_counts.get(t.id, 0)
d["progress"] = progress.get(t.id) # None when the task has no children
w = warnings_per_task.get(t.id)
if w:
d["warnings"] = w
col = t.status if t.status in columns else "todo"
columns[col].append(d)
@ -313,8 +389,14 @@ def get_task(task_id: str, board: Optional[str] = Query(None)):
task = kanban_db.get_task(conn, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
task_d = _task_dict(task)
# Attach warnings metadata so the drawer's Recovery section can
# auto-open when a hallucination is unresolved.
warnings = _compute_warnings_for_tasks(conn, task_ids=[task_id])
if warnings.get(task_id):
task_d["warnings"] = warnings[task_id]
return {
"task": _task_dict(task),
"task": task_d,
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
"events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)],
"links": _links_for(conn, task_id),
@ -630,6 +712,9 @@ class BulkTaskBody(BaseModel):
assignee: Optional[str] = None # "" or None = unassign
priority: Optional[int] = None
archive: bool = False
result: Optional[str] = None
summary: Optional[str] = None
metadata: Optional[dict] = None
@router.post("/tasks/bulk")
@ -660,7 +745,12 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
if payload.status is not None and not payload.archive:
s = payload.status
if s == "done":
ok = kanban_db.complete_task(conn, tid)
ok = kanban_db.complete_task(
conn, tid,
result=payload.result,
summary=payload.summary,
metadata=payload.metadata,
)
elif s == "blocked":
ok = kanban_db.block_task(conn, tid)
elif s == "ready":
@ -705,6 +795,85 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
conn.close()
# ---------------------------------------------------------------------------
# Recovery actions — reclaim a running claim, reassign to a new profile
# ---------------------------------------------------------------------------
class ReclaimBody(BaseModel):
reason: Optional[str] = None
@router.post("/tasks/{task_id}/reclaim")
def reclaim_task_endpoint(
task_id: str,
payload: ReclaimBody,
board: Optional[str] = Query(None),
):
"""Release an active worker claim on a running task.
Used by the dashboard recovery popover when an operator wants to
abort a stuck worker (e.g. one that keeps hallucinating card ids)
without waiting for the claim TTL. Maps 1:1 to
``hermes kanban reclaim <task_id> --reason ...``.
"""
board = _resolve_board(board)
conn = _conn(board=board)
try:
ok = kanban_db.reclaim_task(conn, task_id, reason=payload.reason)
if not ok:
raise HTTPException(
status_code=409,
detail=(
f"cannot reclaim {task_id}: not in a claimable state "
"(not running, or unknown id)"
),
)
return {"ok": True, "task_id": task_id}
finally:
conn.close()
class ReassignBody(BaseModel):
profile: Optional[str] = None # "" or None = unassign
reclaim_first: bool = False
reason: Optional[str] = None
@router.post("/tasks/{task_id}/reassign")
def reassign_task_endpoint(
task_id: str,
payload: ReassignBody,
board: Optional[str] = Query(None),
):
"""Reassign a task to a different profile, optionally reclaiming first.
Used by the dashboard recovery popover when an operator wants to
retry a task with a different worker profile (e.g. switch to a
smarter model after the assigned profile keeps hallucinating).
Maps 1:1 to ``hermes kanban reassign <task_id> <profile> [--reclaim]``.
"""
board = _resolve_board(board)
conn = _conn(board=board)
try:
ok = kanban_db.reassign_task(
conn, task_id,
payload.profile or None,
reclaim_first=bool(payload.reclaim_first),
reason=payload.reason,
)
if not ok:
raise HTTPException(
status_code=409,
detail=(
f"cannot reassign {task_id}: unknown id, or still "
"running (pass reclaim_first=true to release the claim first)"
),
)
return {"ok": True, "task_id": task_id, "assignee": payload.profile or None}
finally:
conn.close()
# ---------------------------------------------------------------------------
# Plugin config (read dashboard.kanban.* defaults from config.yaml)
# ---------------------------------------------------------------------------

View file

@ -626,14 +626,15 @@ class HonchoSessionManager:
Pre-fetch user and AI peer context from Honcho.
Fetches peer_representation and peer_card for both peers, plus the
session summary when available. search_query is intentionally omitted
it would only affect additional excerpts that this code does not
consume, and passing the raw message exposes conversation content in
server access logs.
session summary when available. When user_message is provided, it is
passed as search_query to the peer context call so Honcho returns
conclusions relevant to the session topic rather than the full
observation dump.
Args:
session_key: The session key to get context for.
user_message: Unused; kept for call-site compatibility.
user_message: Optional first user message used as search_query for
topic-relevant context retrieval.
Returns:
Dictionary with 'representation', 'card', 'ai_representation',
@ -659,7 +660,7 @@ class HonchoSessionManager:
logger.debug("Failed to fetch session summary from Honcho: %s", e)
try:
user_ctx = self._fetch_peer_context(session.user_peer_id, target=session.user_peer_id)
user_ctx = self._fetch_peer_context(session.user_peer_id, search_query=user_message or None, target=session.user_peer_id)
result["representation"] = user_ctx["representation"]
result["card"] = "\n".join(user_ctx["card"])
except Exception as e:

View file

@ -128,6 +128,7 @@ from tools.browser_tool import cleanup_browser
# Agent internals extracted to agent/ package for modularity
from agent.memory_manager import StreamingContextScrubber, build_memory_context_block, sanitize_context
from agent.think_scrubber import StreamingThinkScrubber
from agent.retry_utils import jittered_backoff
from agent.error_classifier import classify_api_error, FailoverReason
from agent.prompt_builder import (
@ -833,7 +834,9 @@ def _routermint_headers() -> dict:
}
def _pool_may_recover_from_rate_limit(pool) -> bool:
def _pool_may_recover_from_rate_limit(
pool, *, provider: str | None = None, base_url: str | None = None
) -> bool:
"""Decide whether to wait for credential-pool rotation instead of falling back.
The existing pool-rotation path requires the pool to (1) exist and (2) have
@ -846,15 +849,23 @@ def _pool_may_recover_from_rate_limit(pool) -> bool:
cooldown to expire means retrying against the same exhausted quota the
daily-quota 429 will recur immediately, and the retry budget is burned.
In that case we must fall back to the configured ``fallback_model``
Additionally, Google CloudCode / Gemini CLI rate limits are ACCOUNT-level
throttles even a multi-entry pool shares the same quota window, so
rotation won't recover. Skip straight to the fallback for those (#13636).
In those cases we must fall back to the configured ``fallback_model``
instead. Returns True only when rotation has somewhere to go.
See issue #11314.
See issues #11314 and #13636.
"""
if pool is None:
return False
if not pool.has_available():
return False
# CloudCode / Gemini CLI quotas are account-wide — all pool entries share
# the same throttle window, so rotation can't recover. Prefer fallback.
if provider == "google-gemini-cli" or str(base_url or "").startswith("cloudcode-pa://"):
return False
return len(pool.entries()) > 1
@ -1297,6 +1308,13 @@ class AIAgent:
# deltas (#5719). sanitize_context() alone can't survive chunk
# boundaries because the block regex needs both tags in one string.
self._stream_context_scrubber = StreamingContextScrubber()
# Stateful scrubber for reasoning/thinking tags in streamed deltas
# (#17924). Replaces the per-delta _strip_think_blocks regex that
# destroyed downstream state (e.g. MiniMax-M2.7 streaming
# '<think>' as delta1 and 'Let me check' as delta2 — the regex
# erased delta1, so downstream state machines never learned a
# block was open and leaked delta2 as content).
self._stream_think_scrubber = StreamingThinkScrubber()
# Visible assistant text already delivered through live token callbacks
# during the current model response. Used to avoid re-sending the same
# commentary when the provider later returns it as a completed interim
@ -2659,7 +2677,10 @@ class AIAgent:
base_url=aux_base_url,
api_key=aux_api_key,
config_context_length=getattr(self, "_aux_compression_context_length_config", None),
provider=getattr(self, "provider", ""),
# Each model must be resolved with its own provider so that
# provider-specific paths (e.g. Bedrock static table, OpenRouter API)
# are invoked for the correct client, not inherited from the main model.
provider=(_aux_cfg_provider if _aux_cfg_provider and _aux_cfg_provider != "auto" else getattr(self, "provider", "")),
)
# Hard floor: the auxiliary compression model must have at least
@ -6356,6 +6377,21 @@ class AIAgent:
return False, has_retried_429
def _credential_pool_may_recover_rate_limit(self) -> bool:
"""Whether a rate-limit retry should wait for same-provider credentials."""
pool = self._credential_pool
if pool is None:
return False
if (
self.provider == "google-gemini-cli"
or str(getattr(self, "base_url", "")).startswith("cloudcode-pa://")
):
# CloudCode/Gemini quota windows are usually account-level throttles.
# Prefer the configured fallback immediately instead of waiting out
# Retry-After while a pooled OAuth credential may still appear usable.
return False
return pool.has_available()
def _anthropic_messages_create(self, api_kwargs: dict):
if self.api_mode == "anthropic_messages":
self._try_refresh_anthropic_client_credentials()
@ -6543,6 +6579,29 @@ class AIAgent:
def _reset_stream_delivery_tracking(self) -> None:
"""Reset tracking for text delivered during the current model response."""
# Flush any benign partial-tag tail held by the think scrubber
# first (#17924): an innocent '<' at the end of the stream that
# turned out not to be a tag prefix should reach the UI. Then
# flush the context scrubber. Order matters — the think
# scrubber's output feeds into the context scrubber's state.
think_scrubber = getattr(self, "_stream_think_scrubber", None)
if think_scrubber is not None:
think_tail = think_scrubber.flush()
if think_tail:
# Route the tail through the context scrubber too so a
# memory-context span straddling the final boundary is
# still caught.
ctx_scrubber = getattr(self, "_stream_context_scrubber", None)
if ctx_scrubber is not None:
think_tail = ctx_scrubber.feed(think_tail)
if think_tail:
callbacks = [cb for cb in (self.stream_delta_callback, self._stream_callback) if cb is not None]
for cb in callbacks:
try:
cb(think_tail)
except Exception:
pass
self._record_streamed_assistant_text(think_tail)
# Flush any benign partial-tag tail held by the context scrubber so it
# reaches the UI before we clear state for the next model call. If
# the scrubber is mid-span, flush() drops the orphaned content.
@ -6611,11 +6670,22 @@ class AIAgent:
else:
prepended_break = False
if isinstance(text, str):
# Strip <think> blocks first (per-delta is safe for closed pairs; the
# unterminated-tag path is handled downstream by stream_consumer).
# Suppress reasoning/thinking blocks via the stateful
# scrubber (#17924). Earlier versions ran _strip_think_blocks
# per-delta here, which destroyed downstream state machines
# when a tag was split across deltas (e.g. MiniMax-M2.7
# sends '<think>' and its content as separate deltas —
# regex case 2 erased the first delta, so the CLI/gateway
# state machine never saw the open tag and leaked the
# reasoning content as regular response text).
think_scrubber = getattr(self, "_stream_think_scrubber", None)
if think_scrubber is not None:
text = think_scrubber.feed(text or "")
else:
# Defensive: legacy callers without the scrubber attribute.
text = self._strip_think_blocks(text or "")
# Then feed through the stateful context scrubber so memory-context
# spans split across chunks cannot leak to the UI (#5719).
text = self._strip_think_blocks(text or "")
scrubber = getattr(self, "_stream_context_scrubber", None)
if scrubber is not None:
text = scrubber.feed(text)
@ -8540,6 +8610,7 @@ class AIAgent:
"google/gemini-2",
"qwen/qwen3",
"tencent/hy3-preview",
"xiaomi/",
)
return any(model.startswith(prefix) for prefix in reasoning_model_prefixes)
@ -10576,6 +10647,11 @@ class AIAgent:
scrubber = getattr(self, "_stream_context_scrubber", None)
if scrubber is not None:
scrubber.reset()
# Reset the think scrubber for the same reason — an interrupted
# prior stream may have left us inside an unterminated block.
think_scrubber = getattr(self, "_stream_think_scrubber", None)
if think_scrubber is not None:
think_scrubber.reset()
# Preserve the original user message (no nudge injection).
original_user_message = persist_user_message if persist_user_message is not None else user_message
@ -11116,6 +11192,7 @@ class AIAgent:
thinking_sig_retry_attempted = False
image_shrink_retry_attempted = False
oauth_1m_beta_retry_attempted = False
llama_cpp_grammar_retry_attempted = False
has_retried_429 = False
restart_with_compressed_messages = False
restart_with_length_continuation = False
@ -12206,6 +12283,49 @@ class AIAgent:
)
continue
# ── llama.cpp grammar-parse recovery ──────────────────
# llama.cpp's ``json-schema-to-grammar`` converter rejects
# regex escape classes (``\d``, ``\w``, ``\s``) and most
# ``format`` values in tool schemas. MCP servers emit
# these routinely for date/phone/email params. Recovery:
# strip ``pattern``/``format`` from ``self.tools`` and
# retry once. We keep the keywords by default so cloud
# providers get the full prompting hints; this branch
# fires only for users on llama.cpp's OAI server.
if (
classified.reason == FailoverReason.llama_cpp_grammar_pattern
and not llama_cpp_grammar_retry_attempted
):
llama_cpp_grammar_retry_attempted = True
try:
from tools.schema_sanitizer import strip_pattern_and_format
_, _stripped = strip_pattern_and_format(self.tools)
except Exception as _strip_exc: # pragma: no cover — defensive
logging.warning(
"%sllama.cpp grammar recovery: strip helper failed: %s",
self.log_prefix, _strip_exc,
)
_stripped = 0
if _stripped:
self._vprint(
f"{self.log_prefix}⚠️ llama.cpp rejected tool schema grammar — "
f"stripped {_stripped} pattern/format keyword(s), retrying...",
force=True,
)
logging.warning(
"%sllama.cpp grammar recovery: stripped %d "
"pattern/format keyword(s) from tool schemas",
self.log_prefix, _stripped,
)
continue
# No keywords found to strip — fall through to normal
# retry path rather than loop forever on the same error.
logging.warning(
"%sllama.cpp grammar error but no pattern/format "
"keywords to strip — falling through to normal retry",
self.log_prefix,
)
retry_count += 1
elapsed_time = time.time() - api_start_time
self._touch_activity(
@ -12352,9 +12472,12 @@ class AIAgent:
if is_rate_limited and self._fallback_index < len(self._fallback_chain):
# Don't eagerly fallback if credential pool rotation may
# still recover. See _pool_may_recover_from_rate_limit
# for the single-credential-pool exception. Fixes #11314.
# for the single-credential-pool and CloudCode-quota
# exceptions. Fixes #11314 and #13636.
pool_may_recover = _pool_may_recover_from_rate_limit(
self._credential_pool
self._credential_pool,
provider=self.provider,
base_url=getattr(self, "base_url", None),
)
if not pool_may_recover:
self._emit_status("⚠️ Rate limited — switching to fallback provider...")
@ -13861,9 +13984,19 @@ class AIAgent:
except Exception as exc:
logger.warning("post_llm_call hook failed: %s", exc)
# Extract reasoning from the last assistant message (if any)
# Extract reasoning from the CURRENT turn only. Walk backwards
# but stop at the user message that started this turn — anything
# earlier is from a prior turn and must not leak into the reasoning
# box (confusing stale display; #17055). Within the current turn
# we still want the *most recent* non-empty reasoning: many
# providers (Claude thinking, DeepSeek v4, Codex Responses) emit
# reasoning on the tool-call step and leave the final-answer step
# with reasoning=None, so picking only the last assistant would
# silently drop legitimate same-turn reasoning.
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break # turn boundary — don't cross into prior turns
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break

View file

@ -176,9 +176,12 @@ def check_env_vars():
# Load .env
try:
from dotenv import load_dotenv
if ENV_FILE.exists():
load_dotenv(ENV_FILE)
from hermes_cli.env_loader import load_hermes_dotenv
load_hermes_dotenv(
hermes_home=ENV_FILE.parent,
project_env=PROJECT_ROOT / ".env",
)
except ImportError:
pass

View file

@ -55,7 +55,36 @@ AUTHOR_MAP = {
"14046872+tmimmanuel@users.noreply.github.com": "tmimmanuel",
"657290301@qq.com": "IMHaoyan",
"revar@users.noreply.github.com": "revaraver",
"dengtaoyuan@dengtaoyuandeMac-mini.local": "dengtaoyuan450-a11y",
"ysfalweshcan@gmail.com": "Junass1",
"bartokmagic@proton.me": "Bartok9",
"25840394+Bongulielmi@users.noreply.github.com": "Bongulielmi",
"jonathan.troyer@overmatch.com": "JTroyerOvermatch",
"harryykyle1@gmail.com": "hharry11",
"wysie@users.noreply.github.com": "wysie",
"jkausel@gmail.com": "jkausel-ai",
"e.silacandmr@gmail.com": "Es1la",
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
"zjtan1@gmail.com": "zeejaytan",
"asslaenn5@gmail.com": "Aslaaen",
"trae.anderson17@icloud.com": "Tkander1715",
"beardthelion@users.noreply.github.com": "beardthelion",
"tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc",
"leon@agentlinker.ai": "agentlinker",
"santoshhumagain1887@gmail.com": "npmisantosh",
"novax635@gmail.com": "novax635",
"krionex1@gmail.com": "Krionex",
"rxdxxxx@users.noreply.github.com": "rxdxxxx",
"ma.haohao2@xydigit.com": "MaHaoHao-ch",
"29756950+revaraver@users.noreply.github.com": "revaraver",
"nexus@eptic.me": "TheEpTic",
"74554762+wmagev@users.noreply.github.com": "wmagev",
"ashermorse@icloud.com": "ashermorse",
"happy5318@users.noreply.github.com": "happy5318",
"chengoak@users.noreply.github.com": "chengoak",
"mrhanoi@outlook.com": "qxxaa",
"emelyanenko.kirill@gmail.com": "EmelyanenkoK",
"lazycat.manatee@gmail.com": "manateelazycat",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
@ -81,6 +110,7 @@ AUTHOR_MAP = {
# Curator fixes (Apr 30 2026)
"yuxiangl490@gmail.com": "y0shua1ee",
"manmit0x@gmail.com": "0xDevNinja",
"stevekelly622@gmail.com": "steezkelly",
"aamirjawaid@microsoft.com": "heyitsaamir",
"johnnncenaaa77@gmail.com": "johnncenae",
"thomasjhon6666@gmail.com": "ThomassJonax",
@ -260,6 +290,7 @@ AUTHOR_MAP = {
"hakanerten02@hotmail.com": "teyrebaz33",
"linux2010@users.noreply.github.com": "Linux2010",
"elmatadorgh@users.noreply.github.com": "elmatadorgh",
"coktinbaran5@gmail.com": "elmatadorgh",
"alexazzjjtt@163.com": "alexzhu0",
"1180176+Swift42@users.noreply.github.com": "Swift42",
"ruzzgarcn@gmail.com": "Ruzzgar",
@ -533,6 +564,7 @@ AUTHOR_MAP = {
"memosr_email@gmail.com": "memosr",
"jperlow@gmail.com": "perlowja",
"jasonpette1783@gmail.com": "web-dev0521",
"bjianhang@gmail.com": "bjianhang",
"tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc",
"harryplusplus@gmail.com": "harryplusplus",
"anthhub@163.com": "anthhub",

View file

@ -23,8 +23,10 @@ import express from 'express';
import { Boom } from '@hapi/boom';
import pino from 'pino';
import path from 'path';
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
import { randomBytes } from 'crypto';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import qrcode from 'qrcode-terminal';
import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js';
@ -505,8 +507,31 @@ app.post('/send-media', async (req, res) => {
msgPayload = { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' };
break;
case 'audio': {
const audioMime = (ext === 'ogg' || ext === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
msgPayload = { audio: buffer, mimetype: audioMime, ptt: ext === 'ogg' || ext === 'opus' };
// WhatsApp only renders a native voice bubble (ptt) when the file is ogg/opus.
// If the caller passes mp3, wav, m4a etc. (e.g. from Edge TTS / NeuTTS),
// silently convert to ogg/opus via ffmpeg so ptt is always honoured.
let audioBuffer = buffer;
let audioExt = ext;
const needsConversion = !['ogg', 'opus'].includes(ext);
let tmpPath = null;
if (needsConversion) {
tmpPath = path.join(tmpdir(), `hermes_voice_${randomBytes(6).toString('hex')}.ogg`);
try {
execSync(
`ffmpeg -y -i ${JSON.stringify(filePath)} -ar 48000 -ac 1 -c:a libopus ${JSON.stringify(tmpPath)}`,
{ timeout: 30000, stdio: 'pipe' }
);
audioBuffer = readFileSync(tmpPath);
audioExt = 'ogg';
} catch (convErr) {
// ffmpeg not available or conversion failed — fall back to original format
console.warn('[bridge] ffmpeg conversion failed, sending as file attachment:', convErr.message);
} finally {
try { if (tmpPath && existsSync(tmpPath)) unlinkSync(tmpPath); } catch (_) {}
}
}
const audioMime = (audioExt === 'ogg' || audioExt === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
msgPayload = { audio: audioBuffer, mimetype: audioMime, ptt: audioExt === 'ogg' || audioExt === 'opus' };
break;
}
case 'document':

View file

@ -150,3 +150,13 @@ Tell them what you created in plain prose:
**Don't pre-create the whole graph if the shape depends on intermediate findings.** If T3's structure depends on what T1 and T2 find, let T3 exist as a "synthesize findings" task whose own first step is to read parent handoffs and plan the rest. Orchestrators can spawn orchestrators.
**Tenant inheritance.** If `HERMES_TENANT` is set in your env, pass `tenant=os.environ.get("HERMES_TENANT")` on every `kanban_create` call so child tasks stay in the same namespace.
## Recovering stuck workers
When a worker profile keeps crashing, hallucinating, or getting blocked by its own mistakes (usually: wrong model, missing skill, broken credential), the kanban dashboard flags the task with a ⚠ badge and opens a **Recovery** section in the drawer. Three primary actions:
1. **Reclaim** (or `hermes kanban reclaim <task_id>`) — abort the running worker immediately and reset the task to `ready`. The existing claim TTL is ~15 min; this is the fast path out.
2. **Reassign** (or `hermes kanban reassign <task_id> <new-profile> --reclaim`) — switch the task to a different profile and let the dispatcher pick it up with a fresh worker.
3. **Change profile model** — the dashboard prints a copy-paste hint for `hermes -p <profile> model` since profile config lives on disk; edit it in a terminal, then Reclaim to retry with the new model.
Hallucination warnings appear on tasks where a worker's `kanban_complete(created_cards=[...])` claim included card ids that don't exist or weren't created by the worker's profile (the gate blocks the completion), or where the free-form summary references `t_<hex>` ids that don't resolve (advisory prose scan, non-blocking). Both produce audit events that persist even after recovery actions — the trail stays for debugging.

View file

@ -75,6 +75,32 @@ kanban_complete(
Shape `metadata` so downstream parsers (reviewers, aggregators, schedulers) can use it without re-reading your prose.
## Claiming cards you actually created
If your run produced new kanban tasks (via `kanban_create`), pass the ids in `created_cards` on `kanban_complete`. The kernel verifies each id exists and was created by your profile; any phantom id blocks the completion with an error listing what went wrong, and the rejected attempt is permanently recorded on the task's event log. **Only list ids you captured from a successful `kanban_create` return value — never invent ids from prose, never paste ids from earlier runs, never claim cards another worker created.**
```python
# GOOD — capture return values, then claim them.
c1 = kanban_create(title="remediate SQL injection", assignee="security-worker")
c2 = kanban_create(title="fix CSRF middleware", assignee="web-worker")
kanban_complete(
summary="Review done; spawned remediations for both findings.",
metadata={"pr_number": 123, "approved": False},
created_cards=[c1["task_id"], c2["task_id"]],
)
```
```python
# BAD — claiming ids you don't have captured return values for.
kanban_complete(
summary="Created remediation cards t_a1b2c3d4, t_deadbeef", # hallucinated
created_cards=["t_a1b2c3d4", "t_deadbeef"], # → gate rejects
)
```
If a `kanban_create` call fails (exception, tool_error), the card was NOT created — do not include a phantom id for it. Retry the create, or omit the id and mention the failure in your summary. The prose-scan pass also catches `t_<hex>` references in your free-form summary that don't resolve; these don't block the completion but show up as advisory warnings on the task in the dashboard.
## Block reasons that get answered fast
Bad: `"stuck"` — the human has no context.

View file

@ -188,6 +188,31 @@ class TestListAndCleanup:
manager.create_session(cwd="/empty")
assert manager.list_sessions() == []
def test_save_session_preserves_existing_messages_on_encode_failure(self, manager):
"""Regression for #13675: a bad message in state.history must not
clobber the previously-persisted transcript. replace_messages()
wraps DELETE + INSERT in a single rolled-back-on-exception txn.
"""
state = manager.create_session()
state.history.append({"role": "user", "content": "original"})
manager.save_session(state.session_id)
# Now swap history with a message whose tool_calls is non-JSON-serializable.
# _execute_write rolls back; the previously persisted "original" stays.
state.history = [
{"role": "user", "content": "replacement"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"bad": object()}],
},
]
manager.save_session(state.session_id)
db = manager._get_db()
messages = db.get_messages_as_conversation(state.session_id)
assert messages == [{"role": "user", "content": "original"}]
def test_cleanup_clears_all(self, manager):
s1 = manager.create_session()
s2 = manager.create_session()
@ -455,6 +480,39 @@ class TestPersistence:
assert restored.history[0].get("tool_calls") is not None
assert restored.history[1].get("tool_call_id") == "tc_1"
def test_assistant_reasoning_fields_persisted(self, manager):
"""ACP session restore should preserve assistant reasoning context."""
state = manager.create_session()
state.history.append({
"role": "assistant",
"content": "hello",
"reasoning": "step-by-step",
"reasoning_details": [
{"type": "thinking", "thinking": "first thought"},
],
"codex_reasoning_items": [
{"type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob"},
],
})
manager.save_session(state.session_id)
with manager._lock:
del manager._sessions[state.session_id]
restored = manager.get_session(state.session_id)
assert restored is not None
assert restored.history == [{
"role": "assistant",
"content": "hello",
"reasoning": "step-by-step",
"reasoning_details": [
{"type": "thinking", "thinking": "first thought"},
],
"codex_reasoning_items": [
{"type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob"},
],
}]
def test_restore_preserves_persisted_provider_snapshot(self, tmp_path, monkeypatch):
"""Restored ACP sessions should keep their original runtime provider."""
runtime_choice = {"provider": "anthropic"}

View file

@ -20,6 +20,7 @@ from agent.auxiliary_client import (
_read_codex_access_token,
_get_provider_chain,
_is_payment_error,
_is_rate_limit_error,
_normalize_aux_provider,
_try_payment_fallback,
_resolve_auto,
@ -789,6 +790,65 @@ class TestIsPaymentError:
assert _is_payment_error(exc) is False
class TestIsRateLimitError:
"""_is_rate_limit_error detects 429 rate-limit errors warranting fallback."""
def test_429_with_rate_limit_message(self):
exc = Exception("Rate limit exceeded, try again in 2 seconds")
exc.status_code = 429
assert _is_rate_limit_error(exc) is True
def test_429_with_resets_in_message(self):
"""Nous-style 429: 'resets in 3508s'."""
exc = Exception("Hold up for a bit, you've exceeded the rate limit on your API key")
exc.status_code = 429
assert _is_rate_limit_error(exc) is True
def test_429_with_too_many_requests(self):
exc = Exception("Too many requests")
exc.status_code = 429
assert _is_rate_limit_error(exc) is True
def test_429_without_billing_keywords_is_rate_limit(self):
"""Generic 429 without billing keywords = likely a rate limit."""
exc = Exception("Something went wrong")
exc.status_code = 429
assert _is_rate_limit_error(exc) is True
def test_429_with_credits_message_is_not_rate_limit(self):
"""Billing-related 429 should NOT be classified as rate limit."""
exc = Exception("insufficient credits remaining")
exc.status_code = 429
assert _is_rate_limit_error(exc) is False
def test_429_with_billing_message_is_not_rate_limit(self):
exc = Exception("you can only afford 1000 tokens")
exc.status_code = 429
assert _is_rate_limit_error(exc) is False
def test_402_is_not_rate_limit(self):
exc = Exception("Payment Required")
exc.status_code = 402
assert _is_rate_limit_error(exc) is False
def test_500_is_not_rate_limit(self):
exc = Exception("Internal Server Error")
exc.status_code = 500
assert _is_rate_limit_error(exc) is False
def test_openai_ratelimiterror_classname(self):
"""OpenAI SDK RateLimitError may omit .status_code — detect by class name."""
class RateLimitError(Exception):
pass
exc = RateLimitError("rate limit exceeded")
# No status_code set, but class name matches
assert _is_rate_limit_error(exc) is True
def test_no_status_code_no_keywords_is_not_rate_limit(self):
exc = Exception("connection reset")
assert _is_rate_limit_error(exc) is False
class TestGetProviderChain:
"""_get_provider_chain() resolves functions at call time (testable)."""
@ -860,13 +920,18 @@ class TestTryPaymentFallback:
class TestCallLlmPaymentFallback:
"""call_llm() retries with a different provider on 402 / payment errors."""
"""call_llm() retries with a different provider on 402 / payment / rate-limit errors."""
def _make_402_error(self, msg="Payment Required: insufficient credits"):
exc = Exception(msg)
exc.status_code = 402
return exc
def _make_429_rate_limit_error(self, msg="Rate limit exceeded, try again in 60 seconds"):
exc = Exception(msg)
exc.status_code = 429
return exc
def test_non_payment_error_not_caught(self, monkeypatch):
"""Non-payment/non-connection errors (500) should NOT trigger fallback."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
@ -886,6 +951,32 @@ class TestCallLlmPaymentFallback:
messages=[{"role": "user", "content": "hello"}],
)
def test_429_rate_limit_triggers_fallback(self, monkeypatch):
"""429 rate-limit errors should trigger fallback to next provider."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
rate_err = self._make_429_rate_limit_error()
primary_client.chat.completions.create.side_effect = rate_err
fallback_client = MagicMock()
fallback_client.chat.completions.create.return_value = MagicMock(choices=[
MagicMock(message=MagicMock(content="fallback response"))
])
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "xiaomi/mimo-v2-pro")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", "xiaomi/mimo-v2-pro", None, None, None)), \
patch("agent.auxiliary_client._try_payment_fallback",
return_value=(fallback_client, "fallback-model", "openrouter")):
result = call_llm(
task="session_search",
messages=[{"role": "user", "content": "hello"}],
)
# Fallback client should have been used
assert fallback_client.chat.completions.create.called
# ---------------------------------------------------------------------------
# Gate: _resolve_api_key_provider must skip anthropic when not configured
# ---------------------------------------------------------------------------
@ -1650,6 +1741,42 @@ class TestCodexAdapterReasoningTranslation:
)
assert "reasoning" not in captured
def test_reasoning_effort_null_falls_back_to_medium(self):
"""Parity with agent/transports/codex.py::build_kwargs() — falsy
``effort`` (None / empty / 0) keeps the default ``medium`` instead
of being forwarded to Codex. Codex rejects ``{"effort": null}``
with HTTP 400 (Invalid value for parameter `reasoning.effort`)."""
adapter, captured = self._build_adapter()
adapter.create(
messages=[{"role": "user", "content": "hi"}],
extra_body={"reasoning": {"effort": None}},
)
assert captured.get("reasoning") == {"effort": "medium", "summary": "auto"}
assert captured.get("include") == ["reasoning.encrypted_content"]
def test_reasoning_effort_empty_string_falls_back_to_medium(self):
"""Empty-string effort (e.g. ``effort: ""`` in YAML) is falsy in
the main-agent path's truthy check; mirror that here so the same
config produces the same result."""
adapter, captured = self._build_adapter()
adapter.create(
messages=[{"role": "user", "content": "hi"}],
extra_body={"reasoning": {"effort": ""}},
)
assert captured.get("reasoning") == {"effort": "medium", "summary": "auto"}
assert captured.get("include") == ["reasoning.encrypted_content"]
def test_reasoning_effort_zero_falls_back_to_medium(self):
"""Numeric ``0`` is also falsy — the docstring lists it explicitly,
so cover the contract. Codex would reject ``{"effort": 0}`` the
same way it rejects ``null``."""
adapter, captured = self._build_adapter()
adapter.create(
messages=[{"role": "user", "content": "hi"}],
extra_body={"reasoning": {"effort": 0}},
)
assert captured.get("reasoning") == {"effort": "medium", "summary": "auto"}
assert captured.get("include") == ["reasoning.encrypted_content"]
class TestVisionAutoSkipsKimiCoding:

View file

@ -664,6 +664,44 @@ class TestCompressWithClient:
"call_123"
]
def test_user_role_summary_carries_end_marker(self):
"""When the summary lands as standalone role='user' (e.g. head ends
with assistant/tool), the message body must include the explicit
'--- END OF CONTEXT SUMMARY ---' marker. Without it, weak models
read the verbatim past user request quoted in '## Active Task' as
fresh input (#11475, #14521).
"""
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "summary text"
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
# head_last=assistant, tail_first=assistant (same shape as the
# existing consecutive-user test) → role resolves to "user".
msgs = [
{"role": "user", "content": "msg 0"},
{"role": "assistant", "content": "msg 1"},
{"role": "user", "content": "msg 2"},
{"role": "assistant", "content": "msg 3"},
{"role": "user", "content": "msg 4"},
{"role": "assistant", "content": "msg 5"},
{"role": "user", "content": "msg 6"},
{"role": "assistant", "content": "msg 7"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(msgs)
summary_msg = next(
m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX)
)
assert summary_msg["role"] == "user"
assert "END OF CONTEXT SUMMARY" in summary_msg["content"]
assert summary_msg["content"].rstrip().endswith(
"respond to the message below, not the summary above ---"
)
def test_summary_role_avoids_consecutive_user_messages(self):
"""Summary role should alternate with the last head message to avoid consecutive same-role messages."""
mock_client = MagicMock()

View file

@ -0,0 +1,67 @@
"""Regression tests for iterative context-summary continuity."""
from unittest.mock import MagicMock, patch
from agent.context_compressor import ContextCompressor, SUMMARY_PREFIX
def _compressor() -> ContextCompressor:
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
return ContextCompressor(
model="test/model",
threshold_percent=0.85,
protect_first_n=1,
protect_last_n=1,
quiet_mode=True,
)
def _response(content: str):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = content
return mock_response
def _messages_with_handoff(summary_body: str):
return [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": f"{SUMMARY_PREFIX}\n{summary_body}"},
{"role": "user", "content": "new user turn after resume"},
{"role": "assistant", "content": "new assistant work after resume"},
{"role": "user", "content": "more new work after resume"},
{"role": "assistant", "content": "latest tail response"},
]
def test_existing_previous_summary_is_not_serialized_again_as_new_turn():
"""Same-process iterative compression should not feed the old handoff twice."""
compressor = _compressor()
old_summary = "OLD-SUMMARY-BODY unique continuity facts"
compressor._previous_summary = old_summary
with patch("agent.context_compressor.call_llm", return_value=_response("updated summary")) as mock_call:
compressor.compress(_messages_with_handoff(old_summary))
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
assert "PREVIOUS SUMMARY:" in prompt
assert "NEW TURNS TO INCORPORATE:" in prompt
assert prompt.count(old_summary) == 1
assert f"[USER]: {SUMMARY_PREFIX}" not in prompt
def test_resume_rehydrates_previous_summary_from_handoff_message():
"""After restart/resume, the persisted handoff should regain summary identity."""
compressor = _compressor()
old_summary = "RESUMED-SUMMARY-BODY durable continuity facts"
assert compressor._previous_summary is None
with patch("agent.context_compressor.call_llm", return_value=_response("updated summary")) as mock_call:
compressor.compress(_messages_with_handoff(old_summary))
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
assert "PREVIOUS SUMMARY:" in prompt
assert "NEW TURNS TO INCORPORATE:" in prompt
assert "TURNS TO SUMMARIZE:" not in prompt
assert prompt.count(old_summary) == 1
assert f"[USER]: {SUMMARY_PREFIX}" not in prompt

View file

@ -59,6 +59,7 @@ class TestFailoverReason:
"provider_policy_blocked",
"thinking_signature", "long_context_tier",
"oauth_long_context_beta_forbidden",
"llama_cpp_grammar_pattern",
"unknown",
}
actual = {r.value for r in FailoverReason}
@ -475,6 +476,43 @@ class TestClassifyApiError:
# Without "thinking" in the message, it shouldn't be thinking_signature
assert result.reason != FailoverReason.thinking_signature
# ── Provider-specific: llama.cpp grammar-parse ──
def test_llama_cpp_grammar_parse_error(self):
"""llama.cpp rejects regex escapes in JSON Schema `pattern`."""
e = MockAPIError(
"parse: error parsing grammar: unknown escape at \\d",
status_code=400,
)
result = classify_api_error(e, provider="openai-compatible")
assert result.reason == FailoverReason.llama_cpp_grammar_pattern
assert result.retryable is True
assert result.should_compress is False
def test_llama_cpp_unable_to_generate_parser(self):
"""Older llama.cpp builds surface the error as 'unable to generate parser'."""
e = MockAPIError(
"Unable to generate parser for this template",
status_code=400,
)
result = classify_api_error(e, provider="openai-compatible")
assert result.reason == FailoverReason.llama_cpp_grammar_pattern
def test_llama_cpp_json_schema_to_grammar_phrase(self):
"""Some builds mention the module name explicitly."""
e = MockAPIError(
"json-schema-to-grammar failed to convert schema",
status_code=400,
)
result = classify_api_error(e, provider="openai-compatible")
assert result.reason == FailoverReason.llama_cpp_grammar_pattern
def test_llama_cpp_grammar_requires_400(self):
"""A 500 with the same phrase isn't the llama.cpp grammar case."""
e = MockAPIError("error parsing grammar", status_code=500)
result = classify_api_error(e, provider="openai-compatible")
assert result.reason != FailoverReason.llama_cpp_grammar_pattern
# ── Provider-specific: Anthropic long-context tier ──
def test_anthropic_long_context_tier(self):

View file

@ -0,0 +1,62 @@
"""Regression tests for #13636 — CloudCode / Gemini CLI rate-limit fallback.
_pool_may_recover_from_rate_limit() is the hinge between credential-pool
rotation and fallback-provider activation. For CloudCode (Gemini CLI /
Gemini OAuth) the 429 is an account-wide throttle, so waiting for pool
rotation is pointless prefer fallback immediately.
"""
from unittest.mock import MagicMock
from run_agent import _pool_may_recover_from_rate_limit
def _pool(entries: int = 2):
p = MagicMock()
p.has_available.return_value = True
p.entries.return_value = list(range(entries))
return p
def test_cloudcode_provider_skips_pool_rotation():
assert _pool_may_recover_from_rate_limit(
_pool(entries=3),
provider="google-gemini-cli",
base_url="cloudcode-pa://google",
) is False
def test_cloudcode_base_url_skips_pool_rotation_even_on_alias_provider():
# Even if the provider label is something else, a cloudcode-pa:// URL
# signals the account-wide quota regime.
assert _pool_may_recover_from_rate_limit(
_pool(entries=3),
provider="custom-provider",
base_url="cloudcode-pa://google",
) is False
def test_non_cloudcode_multi_entry_pool_still_recovers():
assert _pool_may_recover_from_rate_limit(
_pool(entries=3),
provider="openrouter",
base_url="https://openrouter.ai/api/v1",
) is True
def test_single_entry_pool_skips_rotation_regardless_of_provider():
# Pre-existing single-entry-pool exception (#11314) still holds.
assert _pool_may_recover_from_rate_limit(
_pool(entries=1),
provider="openrouter",
base_url="https://openrouter.ai/api/v1",
) is False
def test_exhausted_pool_skips_rotation():
p = MagicMock()
p.has_available.return_value = False
assert _pool_may_recover_from_rate_limit(p) is False
def test_no_pool_skips_rotation():
assert _pool_may_recover_from_rate_limit(None) is False

156
tests/agent/test_i18n.py Normal file
View file

@ -0,0 +1,156 @@
"""Tests for agent.i18n -- catalog parity, fallback, language resolution."""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from agent import i18n
LOCALES_DIR = Path(__file__).resolve().parents[2] / "locales"
def _load_raw(lang: str) -> dict:
with (LOCALES_DIR / f"{lang}.yaml").open("r", encoding="utf-8") as f:
return yaml.safe_load(f)
def _flatten(d, prefix="") -> dict:
flat = {}
for k, v in (d or {}).items():
key = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
flat.update(_flatten(v, key))
else:
flat[key] = v
return flat
# ---------------------------------------------------------------------------
# Catalog completeness -- this is the key invariant test. If someone adds a
# new key to en.yaml they MUST add it to every other locale, else runtime
# falls back to English for those users and defeats the feature.
# ---------------------------------------------------------------------------
def test_all_locales_exist():
"""Every supported language must have a catalog file on disk."""
for lang in i18n.SUPPORTED_LANGUAGES:
assert (LOCALES_DIR / f"{lang}.yaml").is_file(), f"missing locales/{lang}.yaml"
@pytest.mark.parametrize("lang", [l for l in i18n.SUPPORTED_LANGUAGES if l != "en"])
def test_catalog_keys_match_english(lang: str):
"""Every non-English catalog must have exactly the same key set as English."""
en_keys = set(_flatten(_load_raw("en")).keys())
lang_keys = set(_flatten(_load_raw(lang)).keys())
missing = en_keys - lang_keys
extra = lang_keys - en_keys
assert not missing, f"{lang}.yaml missing keys: {sorted(missing)}"
assert not extra, f"{lang}.yaml has keys not in en.yaml: {sorted(extra)}"
@pytest.mark.parametrize("lang", list(i18n.SUPPORTED_LANGUAGES))
def test_catalog_placeholders_match_english(lang: str):
"""Every translated value must use the same {placeholder} tokens as English.
A mistranslated placeholder (e.g. ``{description}`` typoed as ``{descricao}``)
would either raise KeyError at runtime or silently drop the interpolated
value. Pin parity at the test layer.
"""
import re
placeholder_re = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
en_flat = _flatten(_load_raw("en"))
lang_flat = _flatten(_load_raw(lang))
for key, en_value in en_flat.items():
en_placeholders = set(placeholder_re.findall(en_value))
lang_value = lang_flat.get(key, "")
lang_placeholders = set(placeholder_re.findall(lang_value))
assert en_placeholders == lang_placeholders, (
f"{lang}.yaml key={key!r}: placeholders {lang_placeholders} "
f"don't match English {en_placeholders}"
)
# ---------------------------------------------------------------------------
# Language resolution
# ---------------------------------------------------------------------------
def test_normalize_lang_accepts_supported():
assert i18n._normalize_lang("zh") == "zh"
assert i18n._normalize_lang("EN") == "en"
def test_normalize_lang_accepts_aliases():
assert i18n._normalize_lang("chinese") == "zh"
assert i18n._normalize_lang("zh-CN") == "zh"
assert i18n._normalize_lang("Deutsch") == "de"
assert i18n._normalize_lang("español") == "es"
assert i18n._normalize_lang("jp") == "ja"
def test_normalize_lang_unknown_falls_back():
assert i18n._normalize_lang("klingon") == "en"
assert i18n._normalize_lang("") == "en"
assert i18n._normalize_lang(None) == "en"
def test_env_var_override(monkeypatch):
"""HERMES_LANGUAGE wins over config."""
i18n.reset_language_cache()
monkeypatch.setenv("HERMES_LANGUAGE", "ja")
assert i18n.get_language() == "ja"
def test_env_var_normalized(monkeypatch):
i18n.reset_language_cache()
monkeypatch.setenv("HERMES_LANGUAGE", "Chinese")
assert i18n.get_language() == "zh"
def test_default_when_nothing_set(monkeypatch):
"""With no env var and no config override, falls back to English."""
monkeypatch.delenv("HERMES_LANGUAGE", raising=False)
# Force config lookup to return None -- patch the cached reader.
i18n.reset_language_cache()
monkeypatch.setattr(i18n, "_config_language_cached", lambda: None)
assert i18n.get_language() == "en"
# ---------------------------------------------------------------------------
# t() semantics
# ---------------------------------------------------------------------------
def test_t_explicit_lang():
assert i18n.t("approval.denied", lang="en").endswith("Denied")
assert i18n.t("approval.denied", lang="zh").endswith("已拒绝")
def test_t_formats_placeholders():
msg = i18n.t("gateway.draining", lang="en", count=3)
assert "3" in msg
def test_t_missing_key_returns_key():
"""A missing key returns its own path -- ugly but never crashes."""
result = i18n.t("nonexistent.key.path", lang="en")
assert result == "nonexistent.key.path"
def test_t_missing_key_in_non_english_falls_back_to_english(tmp_path, monkeypatch):
"""If a key exists in English but not in the target locale, fall back."""
# Stand up a fake incomplete locale under a temp locales dir.
fake_locales = tmp_path / "locales"
fake_locales.mkdir()
(fake_locales / "en.yaml").write_text("foo: English Foo\n", encoding="utf-8")
(fake_locales / "zh.yaml").write_text("# intentionally empty\n", encoding="utf-8")
monkeypatch.setattr(i18n, "_locales_dir", lambda: fake_locales)
i18n.reset_language_cache()
assert i18n.t("foo", lang="zh") == "English Foo"
def test_t_unknown_language_uses_english():
"""Unknown lang codes normalize to English, not to a key-path fallback."""
assert i18n.t("approval.denied", lang="klingon") == i18n.t("approval.denied", lang="en")

View file

@ -19,7 +19,7 @@ class TestBuildOrHeaders:
headers = build_or_headers(or_config={"response_cache": False})
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
assert headers["X-OpenRouter-Title"] == "Hermes Agent"
assert headers["X-Title"] == "Hermes Agent"
assert headers["X-OpenRouter-Categories"] == "productivity,cli-agent"
def test_cache_enabled(self):

View file

@ -788,6 +788,7 @@ class TestPromptBuilderConstants:
assert "discord" in PLATFORM_HINTS
assert "cron" in PLATFORM_HINTS
assert "cli" in PLATFORM_HINTS
assert "api_server" in PLATFORM_HINTS
def test_cli_hint_does_not_suggest_media_tags(self):
# Regression: MEDIA:/path tags are intercepted only by messaging

View file

@ -0,0 +1,229 @@
"""Tests for StreamingThinkScrubber.
These tests lock in the contract the scrubber must satisfy so downstream
consumers (ACP, api_server, TTS, CLI, gateway) never see reasoning
blocks leaking through the stream_delta_callback. The scenarios map
directly to the MiniMax-M2.7 / DeepSeek / Qwen3 streaming patterns that
break the older per-delta regex strip.
"""
from __future__ import annotations
import pytest
from agent.think_scrubber import StreamingThinkScrubber
def _drive(scrubber: StreamingThinkScrubber, deltas: list[str]) -> str:
"""Feed a sequence of deltas and return the concatenated visible output."""
out = [scrubber.feed(d) for d in deltas]
out.append(scrubber.flush())
return "".join(out)
class TestClosedPairs:
"""Closed <tag>...</tag> pairs are always stripped, regardless of boundary."""
def test_closed_pair_single_delta(self) -> None:
s = StreamingThinkScrubber()
assert _drive(s, ["<think>reasoning</think>Hello world"]) == "Hello world"
def test_closed_pair_surrounded_by_content(self) -> None:
s = StreamingThinkScrubber()
assert _drive(s, ["Hello <think>note</think> world"]) == "Hello world"
@pytest.mark.parametrize(
"tag",
["think", "thinking", "reasoning", "thought", "REASONING_SCRATCHPAD"],
)
def test_all_tag_variants(self, tag: str) -> None:
s = StreamingThinkScrubber()
delta = f"<{tag}>x</{tag}>Hello"
assert _drive(s, [delta]) == "Hello"
def test_case_insensitive_pair(self) -> None:
s = StreamingThinkScrubber()
assert _drive(s, ["<THINK>x</Think>Hello"]) == "Hello"
class TestUnterminatedOpen:
"""Unterminated open tag discards all subsequent content to end of stream."""
def test_open_at_stream_start(self) -> None:
s = StreamingThinkScrubber()
assert _drive(s, ["<think>reasoning text with no close"]) == ""
def test_open_after_newline(self) -> None:
s = StreamingThinkScrubber()
# 'Hello\n' is a block boundary for the <think> that follows
assert _drive(s, ["Hello\n<think>reasoning"]) == "Hello\n"
def test_open_after_newline_then_whitespace(self) -> None:
s = StreamingThinkScrubber()
assert _drive(s, ["Hello\n <think>reasoning"]) == "Hello\n "
def test_prose_mentioning_tag_not_stripped(self) -> None:
"""Mid-line '<think>' in prose is preserved (no boundary)."""
s = StreamingThinkScrubber()
text = "Use the <think> element for reasoning"
assert _drive(s, [text]) == text
class TestOrphanClose:
"""Orphan close tags (no prior open) are stripped without boundary check."""
def test_orphan_close_alone(self) -> None:
s = StreamingThinkScrubber()
assert _drive(s, ["Hello</think>world"]) == "Helloworld"
def test_orphan_close_with_trailing_space_consumed(self) -> None:
"""Matches _strip_think_blocks case 3 \\s* behaviour."""
s = StreamingThinkScrubber()
assert _drive(s, ["Hello</think> world"]) == "Helloworld"
def test_multiple_orphan_closes(self) -> None:
s = StreamingThinkScrubber()
assert _drive(s, ["A</think>B</thinking>C"]) == "ABC"
class TestPartialTagsAcrossDeltas:
"""Partial tags at delta boundaries must be held back, not emitted raw."""
def test_split_open_tag_held_back(self) -> None:
"""'<' arrives alone, 'think>' completes it on next delta."""
s = StreamingThinkScrubber()
# At stream start, last_emitted_ended_newline=True, so <think> at 0 is boundary
assert (
_drive(s, ["<", "think>reasoning</think>done"])
== "done"
)
def test_split_open_tag_not_at_boundary(self) -> None:
"""Mid-line split '<' + 'think>X</think>' is a closed pair.
Closed pairs are always stripped (matching
``_strip_think_blocks`` case 1), even without a block
boundary a closed pair is an intentional bounded construct.
"""
s = StreamingThinkScrubber()
out = _drive(s, ["word<", "think>prose</think>more"])
assert out == "wordmore"
def test_split_close_tag_held_back(self) -> None:
"""Close tag split across deltas still closes the block."""
s = StreamingThinkScrubber()
assert (
_drive(s, ["<think>reasoning<", "/think>after"])
== "after"
)
def test_split_close_tag_deep(self) -> None:
"""Close tag can be split anywhere."""
s = StreamingThinkScrubber()
assert (
_drive(s, ["<think>reasoning</th", "ink>after"])
== "after"
)
class TestTheMiniMaxScenario:
"""The exact pattern run_agent per-delta regex strip breaks."""
def test_minimax_split_open(self) -> None:
"""delta1='<think>', delta2='Let me check', delta3='</think>done'."""
s = StreamingThinkScrubber()
out = _drive(s, ["<think>", "Let me check their config", "</think>", "done"])
assert out == "done"
def test_minimax_split_open_with_trailing_content(self) -> None:
"""Reasoning then closes and hands off to final content."""
s = StreamingThinkScrubber()
out = _drive(
s,
[
"<think>",
"The user wants to know if thinking is on",
"</think>",
"\n\nshow_reasoning: false — thinking is OFF.",
],
)
assert out == "\n\nshow_reasoning: false — thinking is OFF."
def test_minimax_unterminated_reasoning_at_end(self) -> None:
"""Unclosed reasoning at stream end is dropped entirely."""
s = StreamingThinkScrubber()
out = _drive(s, ["<think>", "The user wants", " to know something"])
assert out == ""
class TestResetAndReentry:
def test_reset_clears_in_block_state(self) -> None:
s = StreamingThinkScrubber()
s.feed("<think>hanging")
assert s._in_block is True
s.reset()
assert s._in_block is False
# After reset, a new turn works cleanly
assert _drive(s, ["Hello world"]) == "Hello world"
def test_reset_clears_buffered_partial_tag(self) -> None:
s = StreamingThinkScrubber()
s.feed("word<")
assert s._buf == "<"
s.reset()
assert s._buf == ""
assert _drive(s, ["fresh content"]) == "fresh content"
class TestFlushBehaviour:
def test_flush_drops_unterminated_block(self) -> None:
s = StreamingThinkScrubber()
assert s.feed("<think>reasoning with no close") == ""
assert s.flush() == ""
def test_flush_emits_innocent_partial_tag_tail(self) -> None:
"""If held-back tail turned out not to be a real tag, emit it."""
s = StreamingThinkScrubber()
s.feed("word<") # '<' could be a tag prefix
# Stream ends with only '<' held back — emit it as prose.
assert s.flush() == "<"
def test_flush_on_empty_scrubber(self) -> None:
s = StreamingThinkScrubber()
assert s.flush() == ""
class TestRealisticStreaming:
"""Character-by-character streaming must work as well as larger chunks."""
def test_char_by_char_closed_pair(self) -> None:
s = StreamingThinkScrubber()
deltas = list("<think>x</think>Hello world")
assert _drive(s, deltas) == "Hello world"
def test_char_by_char_orphan_close(self) -> None:
s = StreamingThinkScrubber()
deltas = list("Hello</think>world")
assert _drive(s, deltas) == "Helloworld"
def test_reasoning_then_real_response_first_word_preserved(self) -> None:
"""Regression: the first word of the final response must NOT be eaten.
Stefan's screenshot bug — 'Let me check' was being rendered as
' me check'. The scrubber must not consume any character of
post-close content.
"""
s = StreamingThinkScrubber()
deltas = [
"<think>",
"User wants to know things",
"</think>",
"Let me check their config.",
]
assert _drive(s, deltas) == "Let me check their config."
def test_no_tag_passthrough_is_identical(self) -> None:
"""Streams without any reasoning tags pass through byte-for-byte."""
s = StreamingThinkScrubber()
deltas = ["Hello ", "world ", "how ", "are ", "you?"]
assert _drive(s, deltas) == "Hello world how are you?"

View file

@ -75,6 +75,11 @@ class TestMaxTurnsResolution:
cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "42"})
assert cli_obj.max_turns == 42
def test_invalid_env_var_max_turns_falls_back_to_default(self):
"""Invalid env values should not crash CLI init."""
cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "not-a-number"})
assert cli_obj.max_turns == 90
def test_legacy_root_max_turns_is_used_when_agent_key_exists_without_value(self):
cli_obj = _make_cli(config_overrides={"agent": {}, "max_turns": 77})
assert cli_obj.max_turns == 77

View file

@ -111,6 +111,57 @@ def test_manual_compress_syncs_session_id_after_split():
assert shell._pending_title is None
def test_manual_compress_flushes_compressed_history_to_child_session_db():
"""Manual /compress must persist the handoff in the continuation DB.
_compress_context rotates the agent to a new child session and returns a
compressed transcript whose first messages include the handoff summary. The
CLI then replaces its in-memory conversation_history with that transcript.
Because the child DB starts empty, the flush must start from offset 0 rather
than treating the compressed history as already persisted.
"""
shell = _make_cli()
history = _make_history()
old_id = shell.session_id
new_child_id = "20260101_000000_child1"
compressed = [
{"role": "user", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] compacted"},
history[-1],
]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent.session_id = old_id
def _fake_compress(*args, **kwargs):
shell.agent.session_id = new_child_id
return (compressed, "")
shell.agent._compress_context.side_effect = _fake_compress
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress()
shell.agent._flush_messages_to_session_db.assert_called_once_with(compressed, None)
def test_manual_compress_does_not_flush_full_history_when_session_id_unchanged():
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent.session_id = shell.session_id
shell.agent._compress_context.return_value = (list(history), "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress()
shell.agent._flush_messages_to_session_db.assert_not_called()
def test_manual_compress_no_sync_when_session_id_unchanged():
"""If compression is a no-op (agent.session_id didn't change), the CLI
must NOT clear _pending_title or otherwise disturb session state.

View file

@ -178,6 +178,8 @@ class TestLastReasoningInResult(unittest.TestCase):
messages = self._build_messages(reasoning="Let me think...")
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
@ -187,6 +189,8 @@ class TestLastReasoningInResult(unittest.TestCase):
messages = self._build_messages(reasoning=None)
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
@ -201,6 +205,8 @@ class TestLastReasoningInResult(unittest.TestCase):
]
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
@ -210,6 +216,8 @@ class TestLastReasoningInResult(unittest.TestCase):
messages = self._build_messages(reasoning="")
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
@ -584,6 +592,8 @@ class TestEndToEndPipeline(unittest.TestCase):
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break

View file

@ -2055,8 +2055,8 @@ class TestParallelTick:
"""Point the tick file lock at a per-test temp dir to avoid xdist contention."""
lock_dir = tmp_path / "cron"
lock_dir.mkdir()
with patch("cron.scheduler._LOCK_DIR", lock_dir), \
patch("cron.scheduler._LOCK_FILE", lock_dir / ".tick.lock"):
lock_file = lock_dir / ".tick.lock"
with patch("cron.scheduler._get_lock_paths", return_value=(lock_dir, lock_file)):
yield
def test_parallel_jobs_run_concurrently(self):

View file

@ -395,7 +395,12 @@ class TestAgentExecution:
session_id="session-123",
)
assert result == {"final_response": "ok"}
# _run_agent annotates result with the effective agent.session_id
# when it's a real string, so the response-header writer can track
# compression-triggered session rotations (#16938). The mock agent
# here doesn't set an explicit session_id string so the guard skips
# the annotation — header will fall back to the provided session_id.
assert result["final_response"] == "ok"
assert usage == {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}
mock_agent.run_conversation.assert_called_once_with(
user_message="hello",
@ -2563,3 +2568,185 @@ class TestSessionIdHeader:
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs["conversation_history"] == []
assert call_kwargs["session_id"] == "some-session"
# ---------------------------------------------------------------------------
# X-Hermes-Session-Key header (long-term memory scoping)
# ---------------------------------------------------------------------------
class TestSessionKeyHeader:
"""The session key is a stable per-channel identifier that scopes
long-term memory (e.g. Honcho) independently of the transcript-scoped
session_id. A third-party Web UI passes one stable key per assistant
channel and rotates session_id on /new, matching the native
gateway's session_key / session_id split.
"""
@pytest.mark.asyncio
async def test_session_key_passed_to_agent_and_echoed(self, auth_adapter):
"""X-Hermes-Session-Key reaches _run_agent as gateway_session_key and is echoed back."""
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
resp = await cli.post(
"/v1/chat/completions",
headers={
"X-Hermes-Session-Key": "webui:user-42",
"Authorization": "Bearer sk-secret",
},
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
)
assert resp.status == 200
assert resp.headers.get("X-Hermes-Session-Key") == "webui:user-42"
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs["gateway_session_key"] == "webui:user-42"
@pytest.mark.asyncio
async def test_session_key_independent_of_session_id(self, auth_adapter):
"""Both headers coexist: key scopes memory, id scopes transcript."""
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
mock_db = MagicMock()
mock_db.get_messages_as_conversation.return_value = []
auth_adapter._session_db = mock_db
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
resp = await cli.post(
"/v1/chat/completions",
headers={
"X-Hermes-Session-Key": "channel-abc",
"X-Hermes-Session-Id": "transcript-xyz",
"Authorization": "Bearer sk-secret",
},
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
)
assert resp.status == 200
assert resp.headers.get("X-Hermes-Session-Key") == "channel-abc"
assert resp.headers.get("X-Hermes-Session-Id") == "transcript-xyz"
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs["gateway_session_key"] == "channel-abc"
assert call_kwargs["session_id"] == "transcript-xyz"
@pytest.mark.asyncio
async def test_session_key_absent_yields_none(self, auth_adapter):
"""Omitting the header passes gateway_session_key=None and doesn't echo."""
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
resp = await cli.post(
"/v1/chat/completions",
headers={"Authorization": "Bearer sk-secret"},
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
)
assert resp.status == 200
assert "X-Hermes-Session-Key" not in resp.headers
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs["gateway_session_key"] is None
@pytest.mark.asyncio
async def test_session_key_rejected_without_api_key(self, adapter):
"""Without API_SERVER_KEY, accepting a caller-supplied memory scope is unsafe — reject with 403."""
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
"/v1/chat/completions",
headers={"X-Hermes-Session-Key": "whatever"},
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
)
assert resp.status == 403
@pytest.mark.asyncio
async def test_session_key_rejects_control_chars(self, auth_adapter):
"""Header injection via \\r\\n must be rejected by the server-side validator.
Note: aiohttp client refuses to SEND a header containing CR/LF
(that check fires before the request leaves the client), so we
can't reach this code path through TestClient. Test the helper
directly instead with a raw request that bypasses client-side
validation.
"""
mock_request = MagicMock()
mock_request.headers = {"X-Hermes-Session-Key": "bad\rvalue"}
key, err = auth_adapter._parse_session_key_header(mock_request)
assert key is None
assert err is not None
assert err.status == 400
@pytest.mark.asyncio
async def test_session_key_rejects_oversized(self, auth_adapter):
"""Session keys longer than the cap are rejected."""
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
"/v1/chat/completions",
headers={"X-Hermes-Session-Key": "x" * 1000, "Authorization": "Bearer sk-secret"},
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
)
assert resp.status == 400
@pytest.mark.asyncio
async def test_session_key_threads_into_create_agent(self, auth_adapter):
"""End-to-end: verify AIAgent(gateway_session_key=...) receives the key via _create_agent."""
captured_kwargs = {}
def _fake_create_agent(**kwargs):
captured_kwargs.update(kwargs)
mock_agent = MagicMock()
mock_agent.run_conversation.return_value = {"final_response": "ok", "messages": []}
mock_agent.session_prompt_tokens = 0
mock_agent.session_completion_tokens = 0
mock_agent.session_total_tokens = 0
return mock_agent
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(auth_adapter, "_create_agent", side_effect=_fake_create_agent):
resp = await cli.post(
"/v1/chat/completions",
headers={
"X-Hermes-Session-Key": "agent:main:webui:dm:user-7",
"Authorization": "Bearer sk-secret",
},
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
)
assert resp.status == 200
# _create_agent must be called with gateway_session_key threaded through
assert captured_kwargs.get("gateway_session_key") == "agent:main:webui:dm:user-7"
@pytest.mark.asyncio
async def test_responses_endpoint_accepts_session_key(self, auth_adapter):
"""Responses API honors the same X-Hermes-Session-Key contract."""
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
resp = await cli.post(
"/v1/responses",
headers={
"X-Hermes-Session-Key": "webui:chan-1",
"Authorization": "Bearer sk-secret",
},
json={"model": "hermes-agent", "input": "hello", "store": False},
)
assert resp.status == 200
assert resp.headers.get("X-Hermes-Session-Key") == "webui:chan-1"
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs["gateway_session_key"] == "webui:chan-1"
@pytest.mark.asyncio
async def test_capabilities_advertises_session_key_header(self, adapter):
"""GET /v1/capabilities should advertise the new header so clients can feature-detect."""
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/v1/capabilities")
assert resp.status == 200
data = await resp.json()
assert data["features"]["session_key_header"] == "X-Hermes-Session-Key"

View file

@ -15,7 +15,7 @@ from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides
from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides, load_gateway_config
def _ensure_discord_mock():
@ -396,3 +396,67 @@ class TestReplyToText:
event = reply_text_adapter.handle_message.await_args.args[0]
assert event.reply_to_message_id == "555"
assert event.reply_to_text is None
class TestYamlConfigLoading:
"""Tests for reply_to_mode loaded from config.yaml discord section."""
def _write_config(self, tmp_path, content: str):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(content, encoding="utf-8")
return hermes_home
def test_top_level_reply_to_mode_off(self, tmp_path, monkeypatch):
"""YAML 1.1 parses bare 'off' as boolean False — must map back to 'off'."""
hermes_home = self._write_config(tmp_path, "discord:\n reply_to_mode: off\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False)
load_gateway_config()
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "off"
def test_top_level_reply_to_mode_all(self, tmp_path, monkeypatch):
hermes_home = self._write_config(tmp_path, "discord:\n reply_to_mode: all\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False)
load_gateway_config()
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "all"
def test_extra_reply_to_mode_off(self, tmp_path, monkeypatch):
"""discord.extra.reply_to_mode is also honoured."""
hermes_home = self._write_config(
tmp_path, "discord:\n extra:\n reply_to_mode: \"off\"\n"
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False)
load_gateway_config()
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "off"
def test_env_var_takes_precedence_over_yaml(self, tmp_path, monkeypatch):
"""Existing DISCORD_REPLY_TO_MODE env var is not overwritten by YAML."""
hermes_home = self._write_config(tmp_path, "discord:\n reply_to_mode: all\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("DISCORD_REPLY_TO_MODE", "first")
load_gateway_config()
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "first"
def test_top_level_takes_precedence_over_extra(self, tmp_path, monkeypatch):
"""discord.reply_to_mode wins over discord.extra.reply_to_mode."""
hermes_home = self._write_config(
tmp_path,
"discord:\n reply_to_mode: all\n extra:\n reply_to_mode: \"off\"\n",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False)
load_gateway_config()
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "all"

View file

@ -67,6 +67,21 @@ class TestDiscordThreadPersistence:
saved = json.loads((tmp_path / "discord_threads.json").read_text())
assert len(saved) == 5
assert saved == ["5", "6", "7", "8", "9"]
def test_capacity_keeps_newest_thread_when_existing_state_is_full(self, tmp_path):
"""A newly joined thread must not be evicted by unordered set iteration."""
state_file = tmp_path / "discord_threads.json"
state_file.write_text(json.dumps(["0", "1", "2", "3", "4"]), encoding="utf-8")
adapter = self._make_adapter(tmp_path)
adapter._threads._max_tracked = 5
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
adapter._threads.mark("newest")
saved = json.loads(state_file.read_text(encoding="utf-8"))
assert saved == ["1", "2", "3", "4", "newest"]
assert "newest" in adapter._threads
def test_corrupted_state_file_falls_back_to_empty(self, tmp_path):
state_file = tmp_path / "discord_threads.json"

View file

@ -2817,20 +2817,32 @@ class TestHydrateBotIdentity(unittest.TestCase):
},
clear=True,
)
def test_hydration_skipped_when_env_vars_supply_both_fields(self):
def test_hydration_refreshes_env_values_when_bot_info_available(self):
adapter = self._make_adapter()
adapter._client = Mock()
adapter._client.request = Mock()
payload = json.dumps(
{
"code": 0,
"bot": {
"bot_name": "Hydrated Hermes",
"open_id": "ou_hydrated",
},
}
).encode("utf-8")
adapter._client.request = Mock(return_value=SimpleNamespace(raw=SimpleNamespace(content=payload)))
asyncio.run(adapter._hydrate_bot_identity())
adapter._client.request.assert_not_called()
self.assertEqual(adapter._bot_open_id, "ou_env")
self.assertEqual(adapter._bot_name, "Env Hermes")
# PR #16993 semantics: /bot/v3/info probe runs unconditionally
# and hydrated values win over env vars so a stale FEISHU_BOT_*
# from an old app registration doesn't break @mention gating.
adapter._client.request.assert_called_once()
self.assertEqual(adapter._bot_open_id, "ou_hydrated")
self.assertEqual(adapter._bot_name, "Hydrated Hermes")
@patch.dict(os.environ, {"FEISHU_BOT_OPEN_ID": "ou_env"}, clear=True)
def test_hydration_fills_only_missing_fields(self):
"""Env-var open_id must NOT be overwritten by a different probe value."""
def test_hydration_overwrites_stale_env_open_id(self):
"""A stale env open_id should not break group mention gating after app migration."""
adapter = self._make_adapter()
adapter._client = Mock()
payload = json.dumps(
@ -2846,9 +2858,27 @@ class TestHydrateBotIdentity(unittest.TestCase):
asyncio.run(adapter._hydrate_bot_identity())
self.assertEqual(adapter._bot_open_id, "ou_env") # preserved
self.assertEqual(adapter._bot_open_id, "ou_probe_DIFFERENT")
self.assertEqual(adapter._bot_name, "Hermes Bot") # filled in
@patch.dict(
os.environ,
{
"FEISHU_BOT_OPEN_ID": "ou_env",
"FEISHU_BOT_NAME": "Env Hermes",
},
clear=True,
)
def test_hydration_preserves_env_values_when_bot_info_probe_fails(self):
adapter = self._make_adapter()
adapter._client = Mock()
adapter._client.request = Mock(side_effect=RuntimeError("network down"))
asyncio.run(adapter._hydrate_bot_identity())
self.assertEqual(adapter._bot_open_id, "ou_env")
self.assertEqual(adapter._bot_name, "Env Hermes")
@patch.dict(os.environ, {}, clear=True)
def test_hydration_tolerates_probe_failure_and_falls_back_to_app_info(self):
adapter = self._make_adapter()
@ -3167,6 +3197,37 @@ class TestDedupTTL(unittest.TestCase):
with patch.object(adapter, "_persist_seen_message_ids"):
self.assertFalse(adapter._is_duplicate("om_old"))
@patch.dict(os.environ, {}, clear=True)
def test_load_tolerates_malformed_timestamp_values(self):
"""Regression #13632 — a non-numeric timestamp in the persisted
dedup state must not crash adapter startup. The bad key is
skipped; the rest of the state loads.
"""
import tempfile
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
with tempfile.TemporaryDirectory() as temp_home:
with patch.dict(os.environ, {"HERMES_HOME": temp_home}, clear=True):
adapter = FeishuAdapter(PlatformConfig())
adapter._dedup_state_path.parent.mkdir(parents=True, exist_ok=True)
adapter._dedup_state_path.write_text(
json.dumps(
{
"message_ids": {
"om_good": time.time(),
"om_bad_str": "not-a-timestamp",
"om_bad_null": None,
}
}
),
encoding="utf-8",
)
adapter._load_seen_message_ids()
assert "om_good" in adapter._seen_message_ids
assert "om_bad_str" not in adapter._seen_message_ids
assert "om_bad_null" not in adapter._seen_message_ids
@patch.dict(os.environ, {}, clear=True)
def test_persist_saves_timestamps_as_dict(self):
from gateway.config import PlatformConfig

View file

@ -492,6 +492,16 @@ class TestGetHumanDelay:
delay = BasePlatformAdapter._get_human_delay()
assert 0.8 <= delay <= 2.5
def test_natural_mode_ignores_malformed_custom_env_vars(self):
env = {
"HERMES_HUMAN_DELAY_MODE": "natural",
"HERMES_HUMAN_DELAY_MIN_MS": "oops",
"HERMES_HUMAN_DELAY_MAX_MS": "still-bad",
}
with patch.dict(os.environ, env):
delay = BasePlatformAdapter._get_human_delay()
assert 0.8 <= delay <= 2.5
def test_custom_mode_uses_env_vars(self):
env = {
"HERMES_HUMAN_DELAY_MODE": "custom",
@ -502,6 +512,17 @@ class TestGetHumanDelay:
delay = BasePlatformAdapter._get_human_delay()
assert 0.1 <= delay <= 0.2
def test_custom_mode_tolerates_malformed_env_vars(self):
env = {
"HERMES_HUMAN_DELAY_MODE": "custom",
"HERMES_HUMAN_DELAY_MIN_MS": "oops",
"HERMES_HUMAN_DELAY_MAX_MS": "still-bad",
}
with patch.dict(os.environ, env):
# falls back to the custom-mode defaults instead of crashing
delay = BasePlatformAdapter._get_human_delay()
assert 0.8 <= delay <= 2.5
# ---------------------------------------------------------------------------
# utf16_len / _prefix_within_utf16_limit / truncate_message with len_fn

View file

@ -1,412 +0,0 @@
"""Tests for the gateway stale-code self-check (Issue #17648).
A gateway that survives ``hermes update`` keeps pre-update modules cached
in ``sys.modules``. Later imports of names added post-update (e.g.
``cfg_get`` from PR #17304) raise ImportError against the stale module
object.
The self-check compares the git HEAD SHA at boot to the current SHA on
disk. ``hermes update`` always moves HEAD forward via ``git pull``;
agent-driven file edits (Hermes editing ``run_agent.py`` / ``gateway/run.py``
during a self-dev session) never move HEAD so the SHA signal is free of
the false-positive class that the earlier mtime-based check suffered from.
"""
import os
import time
from pathlib import Path
import pytest
from gateway.run import (
GatewayRunner,
_compute_repo_mtime,
_read_git_head_sha,
_STALE_CODE_SENTINELS,
_GIT_SHA_CACHE_TTL_SECS,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_tmp_repo(tmp_path: Path) -> Path:
"""Create a fake repo with all stale-code sentinel files."""
for rel in _STALE_CODE_SENTINELS:
p = tmp_path / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("# test sentinel\n")
return tmp_path
def _make_git_repo(tmp_path: Path, sha: str = "a" * 40, branch: str = "main") -> Path:
"""Stamp a minimal .git directory so _read_git_head_sha can resolve a SHA.
We don't run real git — just lay down the files the reader walks
(.git/HEAD pointing at refs/heads/<branch>, refs/heads/<branch>
containing the SHA).
"""
git_dir = tmp_path / ".git"
git_dir.mkdir(parents=True, exist_ok=True)
(git_dir / "HEAD").write_text(f"ref: refs/heads/{branch}\n")
refs_dir = git_dir / "refs" / "heads"
refs_dir.mkdir(parents=True, exist_ok=True)
(refs_dir / branch).write_text(f"{sha}\n")
return tmp_path
def _set_head_sha(repo_root: Path, sha: str, branch: str = "main") -> None:
"""Rewrite the current branch ref to a new SHA (simulates git pull)."""
(repo_root / ".git" / "refs" / "heads" / branch).write_text(f"{sha}\n")
def _make_runner(
repo_root: Path,
*,
boot_sha: str | None,
boot_wall: float = None,
boot_mtime: float = 0.0,
):
"""Bare GatewayRunner with just the stale-check attributes set."""
if boot_wall is None:
boot_wall = time.time()
runner = object.__new__(GatewayRunner)
runner._repo_root_for_staleness = repo_root
runner._boot_wall_time = boot_wall
runner._boot_git_sha = boot_sha
runner._boot_repo_mtime = boot_mtime
runner._stale_code_notified = set()
runner._stale_code_restart_triggered = False
runner._cached_current_sha = boot_sha
runner._cached_current_sha_at = boot_wall
return runner
# ---------------------------------------------------------------------------
# _read_git_head_sha — raw SHA reader
# ---------------------------------------------------------------------------
def test_read_git_head_sha_branch_ref(tmp_path):
"""Resolves ref: refs/heads/<branch> → SHA from refs/heads/<branch>."""
sha = "b" * 40
_make_git_repo(tmp_path, sha=sha, branch="main")
assert _read_git_head_sha(tmp_path) == sha
def test_read_git_head_sha_detached_head(tmp_path):
"""Detached HEAD: .git/HEAD contains the SHA directly."""
sha = "c" * 40
git_dir = tmp_path / ".git"
git_dir.mkdir()
(git_dir / "HEAD").write_text(f"{sha}\n")
assert _read_git_head_sha(tmp_path) == sha
def test_read_git_head_sha_packed_refs(tmp_path):
"""Falls back to packed-refs when refs/heads/<branch> is missing."""
sha = "d" * 40
git_dir = tmp_path / ".git"
git_dir.mkdir()
(git_dir / "HEAD").write_text("ref: refs/heads/main\n")
# No refs/heads/main file — only packed-refs
(git_dir / "packed-refs").write_text(
f"# pack-refs with: peeled fully-peeled sorted\n"
f"{sha} refs/heads/main\n"
)
assert _read_git_head_sha(tmp_path) == sha
def test_read_git_head_sha_worktree_gitdir_file(tmp_path):
"""Worktree: .git is a file with `gitdir: <path>` pointing to the real git dir.
Real git worktrees store shared refs (refs/heads/*) in the main
checkout's .git/ and write a ``commondir`` pointer into the
worktree-gitdir. The reader must follow commondir to resolve the
branch ref this is the layout Hermes dev sessions actually use.
"""
sha = "e" * 40
# Main repo layout
main_repo = tmp_path / "main-repo"
main_git = main_repo / ".git"
(main_git / "refs" / "heads").mkdir(parents=True)
(main_git / "HEAD").write_text("ref: refs/heads/main\n")
(main_git / "refs" / "heads" / "main").write_text("0" * 40 + "\n")
# Worktree lives in main-repo/.git/worktrees/<name>/
worktree_git_dir = main_git / "worktrees" / "feature"
worktree_git_dir.mkdir(parents=True)
(worktree_git_dir / "HEAD").write_text("ref: refs/heads/feature\n")
# commondir points back at the main .git (relative path, "../..")
(worktree_git_dir / "commondir").write_text("../..\n")
# Feature branch ref lives in the shared refs/heads
(main_git / "refs" / "heads" / "feature").write_text(f"{sha}\n")
# Worktree checkout with .git file pointing at worktree_git_dir
worktree = tmp_path / "wt"
worktree.mkdir()
(worktree / ".git").write_text(f"gitdir: {worktree_git_dir}\n")
assert _read_git_head_sha(worktree) == sha
def test_read_git_head_sha_worktree_packed_refs_in_common(tmp_path):
"""Worktree + packed-refs in common dir: fallback still resolves."""
sha = "f" * 40
main_repo = tmp_path / "main-repo"
main_git = main_repo / ".git"
main_git.mkdir(parents=True)
(main_git / "HEAD").write_text("ref: refs/heads/main\n")
# packed-refs in the common (main) .git
(main_git / "packed-refs").write_text(
f"# pack-refs with: peeled fully-peeled sorted\n"
f"{sha} refs/heads/feature\n"
)
worktree_git_dir = main_git / "worktrees" / "feature"
worktree_git_dir.mkdir(parents=True)
(worktree_git_dir / "HEAD").write_text("ref: refs/heads/feature\n")
(worktree_git_dir / "commondir").write_text("../..\n")
worktree = tmp_path / "wt"
worktree.mkdir()
(worktree / ".git").write_text(f"gitdir: {worktree_git_dir}\n")
assert _read_git_head_sha(worktree) == sha
def test_read_git_head_sha_no_git_returns_none(tmp_path):
"""No .git dir → None (non-git install, safely disables the check)."""
assert _read_git_head_sha(tmp_path) is None
def test_read_git_head_sha_malformed_head_returns_none(tmp_path):
"""Empty HEAD file → None (don't loop on corrupt repos)."""
git_dir = tmp_path / ".git"
git_dir.mkdir()
(git_dir / "HEAD").write_text("")
assert _read_git_head_sha(tmp_path) is None
# ---------------------------------------------------------------------------
# _detect_stale_code — the main regression guard
# ---------------------------------------------------------------------------
def test_detect_stale_code_false_when_sha_unchanged(tmp_path):
"""Boot SHA == current SHA → not stale (no restart)."""
sha = "a" * 40
_make_git_repo(tmp_path, sha=sha)
runner = _make_runner(tmp_path, boot_sha=sha)
# Force fresh read by expiring the cache
runner._cached_current_sha_at = 0.0
assert runner._detect_stale_code() is False
def test_detect_stale_code_true_after_git_pull(tmp_path):
"""Boot SHA != current SHA → stale (hermes update happened)."""
boot_sha = "a" * 40
_make_git_repo(tmp_path, sha=boot_sha)
runner = _make_runner(tmp_path, boot_sha=boot_sha)
# Simulate git pull moving HEAD forward
_set_head_sha(tmp_path, "b" * 40)
runner._cached_current_sha_at = 0.0 # expire cache
assert runner._detect_stale_code() is True
def test_detect_stale_code_ignores_agent_file_edits(tmp_path):
"""THE CORE REGRESSION: agent edits to source files do NOT trigger restart.
This is the motivating incident for the SHA-based check. Under the
previous mtime-based scheme, any ``patch`` / ``write_file`` call
against run_agent.py / gateway/run.py / hermes_cli/config.py would
flip the stale-check to True and force a gateway restart on the
next message even though no update actually happened. SHA
comparison decouples the two: git HEAD only moves on ``git pull``,
never on file writes.
"""
sha = "a" * 40
_make_git_repo(tmp_path, sha=sha)
_make_tmp_repo(tmp_path) # lay down sentinel files too
runner = _make_runner(tmp_path, boot_sha=sha)
# Simulate the agent editing run_agent.py and gateway/run.py with
# mtimes far into the future — exactly the scenario that used to
# false-positive the old mtime check.
future = time.time() + 10_000
for rel in _STALE_CODE_SENTINELS:
p = tmp_path / rel
if p.is_file():
p.write_text("# agent just edited this\n")
os.utime(p, (future, future))
# HEAD SHA has NOT moved — check must stay False.
runner._cached_current_sha_at = 0.0 # expire cache
assert runner._detect_stale_code() is False
def test_detect_stale_code_false_for_non_git_install(tmp_path):
"""Non-git install (no .git dir) → check disabled, never fires."""
# No .git dir at all; runner's boot_sha is None
runner = _make_runner(tmp_path, boot_sha=None)
# Even if we pretended the current SHA differed, the check should
# short-circuit on boot_sha=None and return False.
assert runner._detect_stale_code() is False
def test_detect_stale_code_false_when_no_boot_wall_time(tmp_path):
"""No boot snapshot at all → can't tell → not stale (no restart loop)."""
runner = _make_runner(tmp_path, boot_sha="a" * 40, boot_wall=0.0)
assert runner._detect_stale_code() is False
def test_detect_stale_code_handles_disappearing_git_dir(tmp_path):
""".git vanishes mid-run → current_sha = None → not stale (don't loop)."""
sha = "a" * 40
_make_git_repo(tmp_path, sha=sha)
runner = _make_runner(tmp_path, boot_sha=sha)
# Nuke the git dir after boot
import shutil
shutil.rmtree(tmp_path / ".git")
runner._cached_current_sha_at = 0.0 # expire cache
assert runner._detect_stale_code() is False
# ---------------------------------------------------------------------------
# SHA cache
# ---------------------------------------------------------------------------
def test_current_sha_cache_collapses_bursts(tmp_path, monkeypatch):
"""Consecutive calls inside the TTL window reuse the cached SHA."""
sha = "a" * 40
_make_git_repo(tmp_path, sha=sha)
runner = _make_runner(tmp_path, boot_sha=sha)
read_calls = {"n": 0}
real_reader = _read_git_head_sha
def counting_reader(repo_root):
read_calls["n"] += 1
return real_reader(repo_root)
from gateway import run as run_mod
monkeypatch.setattr(run_mod, "_read_git_head_sha", counting_reader)
# Force cache expiry so the first call definitely reads
runner._cached_current_sha_at = 0.0
runner._current_git_sha_cached()
first_count = read_calls["n"]
# Immediate second/third calls should hit cache (no new read)
runner._current_git_sha_cached()
runner._current_git_sha_cached()
assert read_calls["n"] == first_count
def test_current_sha_cache_expires_after_ttl(tmp_path, monkeypatch):
"""After _GIT_SHA_CACHE_TTL_SECS elapses, a fresh read happens."""
sha = "a" * 40
_make_git_repo(tmp_path, sha=sha)
runner = _make_runner(tmp_path, boot_sha=sha)
read_calls = {"n": 0}
real_reader = _read_git_head_sha
def counting_reader(repo_root):
read_calls["n"] += 1
return real_reader(repo_root)
from gateway import run as run_mod
monkeypatch.setattr(run_mod, "_read_git_head_sha", counting_reader)
runner._cached_current_sha_at = 0.0
runner._current_git_sha_cached()
first = read_calls["n"]
# Age the cache past the TTL
runner._cached_current_sha_at = time.time() - (_GIT_SHA_CACHE_TTL_SECS + 1.0)
runner._current_git_sha_cached()
assert read_calls["n"] == first + 1
# ---------------------------------------------------------------------------
# _trigger_stale_code_restart — idempotency preserved
# ---------------------------------------------------------------------------
def test_trigger_stale_code_restart_is_idempotent(tmp_path):
"""Calling _trigger_stale_code_restart twice only requests restart once."""
sha = "a" * 40
_make_git_repo(tmp_path, sha=sha)
runner = _make_runner(tmp_path, boot_sha=sha)
calls = []
def fake_request_restart(*, detached=False, via_service=False):
calls.append((detached, via_service))
return True
runner.request_restart = fake_request_restart
runner._trigger_stale_code_restart()
runner._trigger_stale_code_restart()
runner._trigger_stale_code_restart()
assert len(calls) == 1
assert runner._stale_code_restart_triggered is True
def test_trigger_stale_code_restart_survives_request_failure(tmp_path):
"""If request_restart raises, we swallow and mark as triggered anyway."""
sha = "a" * 40
_make_git_repo(tmp_path, sha=sha)
runner = _make_runner(tmp_path, boot_sha=sha)
def boom(*, detached=False, via_service=False):
raise RuntimeError("no event loop")
runner.request_restart = boom
# Should not raise
runner._trigger_stale_code_restart()
# Marked triggered so we don't retry on every subsequent message
assert runner._stale_code_restart_triggered is True
# ---------------------------------------------------------------------------
# Class-level defaults — tests that build bare runners via object.__new__
# ---------------------------------------------------------------------------
def test_class_level_defaults_prevent_uninitialized_access():
"""Partial construction via object.__new__ must not crash _detect_stale_code."""
runner = object.__new__(GatewayRunner)
# Don't set any instance attrs — class-level defaults should kick in
runner._repo_root_for_staleness = Path(".")
# _boot_wall_time / _boot_git_sha fall through to class defaults
# (0.0 and None respectively)
assert runner._detect_stale_code() is False
# _stale_code_restart_triggered falls through to class default (False)
assert runner._stale_code_restart_triggered is False
# ---------------------------------------------------------------------------
# Legacy mtime reader kept for compatibility — light sanity check only
# ---------------------------------------------------------------------------
def test_compute_repo_mtime_still_returns_newest(tmp_path):
"""_compute_repo_mtime remains available for any legacy callers."""
repo = _make_tmp_repo(tmp_path)
baseline = time.time() - 100
for rel in _STALE_CODE_SENTINELS:
os.utime(repo / rel, (baseline, baseline))
newer = time.time()
os.utime(repo / "hermes_cli/config.py", (newer, newer))
result = _compute_repo_mtime(repo)
assert abs(result - newer) < 1.0
def test_compute_repo_mtime_missing_files_returns_zero(tmp_path):
"""Legacy sanity: missing sentinels → 0.0."""
assert _compute_repo_mtime(tmp_path) == 0.0

View file

@ -534,15 +534,20 @@ class TestDiscoverFallbackIps:
assert "149.154.167.221" in ips
@pytest.mark.asyncio
async def test_system_dns_ip_excluded(self, monkeypatch):
"""The IP from system DNS is the one that doesn't work — exclude it."""
async def test_system_dns_ip_kept_when_doh_confirms(self, monkeypatch):
"""DoH-confirmed IPs are kept even when they match system DNS (#14520).
The system-DNS IP is often the most reliable path; including it as a
fallback lets the IP-rewrite retry recover from transient primary-path
failures instead of jumping straight to the hardcoded seed list.
"""
self._patch_doh(monkeypatch, {
"https://dns.google": (200, _doh_answer("149.154.166.110", "149.154.167.220")),
"https://cloudflare-dns.com": (200, _doh_answer("149.154.166.110")),
}, system_dns_ips=["149.154.166.110"])
ips = await tnet.discover_fallback_ips()
assert ips == ["149.154.167.220"]
assert ips == ["149.154.166.110", "149.154.167.220"]
@pytest.mark.asyncio
async def test_doh_results_deduplicated(self, monkeypatch):
@ -607,15 +612,21 @@ class TestDiscoverFallbackIps:
assert "149.154.167.220" in ips
@pytest.mark.asyncio
async def test_all_doh_ips_same_as_system_dns_uses_seed(self, monkeypatch):
"""DoH returns only the same blocked IP — seed list is the fallback."""
async def test_all_doh_ips_same_as_system_dns_kept(self, monkeypatch):
"""DoH agrees with system DNS — keep that IP instead of seed list (#14520).
Previous behavior fell through to ``_SEED_FALLBACK_IPS`` here, but the
seed addresses are not routable on every network. When DoH confirms
the system IP, that IP is the best candidate we have and should be
used as the fallback target.
"""
self._patch_doh(monkeypatch, {
"https://dns.google": (200, _doh_answer("149.154.166.110")),
"https://cloudflare-dns.com": (200, _doh_answer("149.154.166.110")),
}, system_dns_ips=["149.154.166.110"])
ips = await tnet.discover_fallback_ips()
assert ips == tnet._SEED_FALLBACK_IPS
assert ips == ["149.154.166.110"]
@pytest.mark.asyncio
async def test_cloudflare_gets_accept_header(self, monkeypatch):

View file

@ -11,7 +11,7 @@ from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides
from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides, load_gateway_config
def _ensure_telegram_mock():
@ -240,3 +240,67 @@ class TestEnvVarOverride:
with patch.dict(os.environ, {"TELEGRAM_REPLY_TO_MODE": ""}, clear=False):
_apply_env_overrides(config)
assert config.platforms[Platform.TELEGRAM].reply_to_mode == "first"
class TestTelegramYamlConfigLoading:
"""Tests for reply_to_mode loaded from config.yaml telegram section."""
def _write_config(self, tmp_path, content: str):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(content, encoding="utf-8")
return hermes_home
def test_top_level_reply_to_mode_off(self, tmp_path, monkeypatch):
"""YAML 1.1 parses bare 'off' as boolean False — must map back to 'off'."""
hermes_home = self._write_config(tmp_path, "telegram:\n reply_to_mode: off\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("TELEGRAM_REPLY_TO_MODE", raising=False)
load_gateway_config()
assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "off"
def test_top_level_reply_to_mode_all(self, tmp_path, monkeypatch):
hermes_home = self._write_config(tmp_path, "telegram:\n reply_to_mode: all\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("TELEGRAM_REPLY_TO_MODE", raising=False)
load_gateway_config()
assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "all"
def test_extra_reply_to_mode_off(self, tmp_path, monkeypatch):
"""telegram.extra.reply_to_mode is also honoured."""
hermes_home = self._write_config(
tmp_path, "telegram:\n extra:\n reply_to_mode: \"off\"\n"
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("TELEGRAM_REPLY_TO_MODE", raising=False)
load_gateway_config()
assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "off"
def test_env_var_takes_precedence_over_yaml(self, tmp_path, monkeypatch):
"""Existing TELEGRAM_REPLY_TO_MODE env var is not overwritten by YAML."""
hermes_home = self._write_config(tmp_path, "telegram:\n reply_to_mode: all\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("TELEGRAM_REPLY_TO_MODE", "first")
load_gateway_config()
assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "first"
def test_top_level_takes_precedence_over_extra(self, tmp_path, monkeypatch):
"""telegram.reply_to_mode wins over telegram.extra.reply_to_mode."""
hermes_home = self._write_config(
tmp_path,
"telegram:\n reply_to_mode: all\n extra:\n reply_to_mode: \"off\"\n",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("TELEGRAM_REPLY_TO_MODE", raising=False)
load_gateway_config()
assert os.environ.get("TELEGRAM_REPLY_TO_MODE") == "all"

View file

@ -459,8 +459,9 @@ class TestWatchUpdateProgress:
async def test_prompt_forwarded_only_once(self, tmp_path):
"""Regression: prompt must not be re-sent on every poll cycle.
Before the fix, the watcher never deleted .update_prompt.json after
forwarding, causing the same prompt to be sent every poll_interval.
The in-memory pending flag should suppress duplicate sends within a
single watcher process even when the prompt marker stays on disk for
restart recovery.
"""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
@ -505,6 +506,75 @@ class TestWatchUpdateProgress:
f"All sends: {all_sent}"
)
@pytest.mark.asyncio
async def test_prompt_is_recovered_after_watcher_restart(self, tmp_path):
"""A forwarded prompt stays on disk until answered so a new watcher can recover it."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {
"platform": "telegram",
"chat_id": "111",
"user_id": "222",
"session_key": "agent:main:telegram:dm:111",
}
prompt = {
"prompt": "Restore local changes? [Y/n]",
"default": "y",
"id": "restart-recover",
}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text("")
(hermes_home / ".update_prompt.json").write_text(json.dumps(prompt))
runner1 = _make_runner()
adapter1 = AsyncMock()
runner1.adapters = {Platform.TELEGRAM: adapter1}
with patch("gateway.run._hermes_home", hermes_home):
watch1 = asyncio.create_task(
runner1._watch_update_progress(
poll_interval=0.05,
stream_interval=0.1,
timeout=10.0,
)
)
for _ in range(40):
if adapter1.send.call_count:
break
await asyncio.sleep(0.05)
assert adapter1.send.call_count == 1
assert (hermes_home / ".update_prompt.json").exists()
watch1.cancel()
with pytest.raises(asyncio.CancelledError):
await watch1
runner2 = _make_runner()
adapter2 = AsyncMock()
runner2.adapters = {Platform.TELEGRAM: adapter2}
async def respond_and_finish():
await asyncio.sleep(0.2)
(hermes_home / ".update_response").write_text("y")
await asyncio.sleep(0.2)
(hermes_home / ".update_exit_code").write_text("0")
finisher = asyncio.create_task(respond_and_finish())
await runner2._watch_update_progress(
poll_interval=0.05,
stream_interval=0.1,
timeout=10.0,
)
await finisher
prompt_sends = [
str(call) for call in adapter2.send.call_args_list
if "Restore local changes" in str(call)
]
assert len(prompt_sends) == 1
# ---------------------------------------------------------------------------
# Message interception for update prompts
@ -525,6 +595,7 @@ class TestUpdatePromptInterception:
# The session key uses the full format from build_session_key
session_key = "agent:main:telegram:dm:67890"
runner._update_prompt_pending[session_key] = True
(hermes_home / ".update_prompt.json").write_text(json.dumps({"prompt": "test"}))
# Mock authorization and _session_key_for_source
runner._is_user_authorized = MagicMock(return_value=True)
@ -538,6 +609,7 @@ class TestUpdatePromptInterception:
response_path = hermes_home / ".update_response"
assert response_path.exists()
assert response_path.read_text() == "y"
assert not (hermes_home / ".update_prompt.json").exists()
# Should clear the pending flag
assert session_key not in runner._update_prompt_pending
@ -560,6 +632,7 @@ class TestUpdatePromptInterception:
runner._is_user_authorized = MagicMock(return_value=True)
runner._session_key_for_source = MagicMock(return_value=session_key)
runner._handle_reset_command = AsyncMock(return_value="reset ok")
(hermes_home / ".update_prompt.json").write_text(json.dumps({"prompt": "test"}))
with patch("gateway.run._hermes_home", hermes_home):
result = await runner._handle_message(event)
@ -572,6 +645,7 @@ class TestUpdatePromptInterception:
response_path = hermes_home / ".update_response"
assert response_path.exists()
assert response_path.read_text() == ""
assert not (hermes_home / ".update_prompt.json").exists()
# Pending flag is cleared so stray future input won't be
# re-intercepted for a prompt that is no longer outstanding.
assert session_key not in runner._update_prompt_pending
@ -588,6 +662,7 @@ class TestUpdatePromptInterception:
runner._update_prompt_pending[session_key] = True
runner._is_user_authorized = MagicMock(return_value=True)
runner._session_key_for_source = MagicMock(return_value=session_key)
(hermes_home / ".update_prompt.json").write_text(json.dumps({"prompt": "test"}))
with patch("gateway.run._hermes_home", hermes_home):
result = await runner._handle_message(event)
@ -595,6 +670,7 @@ class TestUpdatePromptInterception:
response_path = hermes_home / ".update_response"
assert response_path.exists()
assert response_path.read_text() == "/foobarbaz"
assert not (hermes_home / ".update_prompt.json").exists()
assert "Sent" in (result or "")
assert session_key not in runner._update_prompt_pending

View file

@ -0,0 +1,19 @@
"""Fixtures shared across hermes_cli kanban tests."""
from __future__ import annotations
import pytest
@pytest.fixture
def all_assignees_spawnable(monkeypatch):
"""Pretend every assignee maps to a real Hermes profile.
Most dispatcher tests use synthetic assignees ("alice", "bob") that
don't correspond to actual profile directories on disk. Without this
patch, the dispatcher's profile-exists guard (PR #20105) routes
those tasks into ``skipped_nonspawnable`` instead of spawning, which
would break tests that assert spawn behavior.
"""
from hermes_cli import profiles
monkeypatch.setattr(profiles, "profile_exists", lambda name: True)

View file

@ -0,0 +1,269 @@
"""Tests for `hermes curator archive` and `hermes curator prune`.
Covers:
- archive refuses pinned skills with an `unpin` hint
- archive returns 0/1 based on archive_skill() success
- prune filters pinned and already-archived, applies --days threshold
- prune falls back to created_at when last_activity_at is null
- prune --dry-run makes no state changes
- prune --yes skips confirmation
- prune --days validation
"""
from __future__ import annotations
import io
from contextlib import redirect_stdout, redirect_stderr
from types import SimpleNamespace
from unittest.mock import patch
import pytest
def _ns(**kwargs):
return SimpleNamespace(**kwargs)
# ─── archive ────────────────────────────────────────────────────────────────
def test_archive_refuses_pinned(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": True})
called = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: called.append(name) or (True, "should not get here"),
)
rc = curator_cli._cmd_archive(_ns(skill="pinned-skill"))
assert rc == 1
assert called == []
out = capsys.readouterr().out
assert "pinned" in out.lower()
assert "hermes curator unpin" in out
def test_archive_calls_archive_skill(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": False})
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: (True, f"archived to .archive/{name}"),
)
rc = curator_cli._cmd_archive(_ns(skill="my-skill"))
assert rc == 0
assert "archived to .archive/my-skill" in capsys.readouterr().out
def test_archive_reports_failure(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": False})
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: (False, f"skill '{name}' is bundled or hub-installed; never archive"),
)
rc = curator_cli._cmd_archive(_ns(skill="hub-slug"))
assert rc == 1
assert "bundled or hub-installed" in capsys.readouterr().out
# ─── prune ──────────────────────────────────────────────────────────────────
def _mk_record(name, *, idle_days=0, pinned=False, state="active", created_idle_days=None):
import datetime as _dt
now = _dt.datetime.now(_dt.timezone.utc)
last_activity = (now - _dt.timedelta(days=idle_days)).isoformat() if idle_days else None
created_delta = created_idle_days if created_idle_days is not None else idle_days
created = (now - _dt.timedelta(days=created_delta)).isoformat()
return {
"name": name,
"state": state,
"pinned": pinned,
"last_activity_at": last_activity,
"created_at": created,
"activity_count": 0 if idle_days == 0 and last_activity is None else 1,
}
def test_prune_days_validation(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
rc = curator_cli._cmd_prune(_ns(days=0, yes=True, dry_run=False))
assert rc == 2
err = capsys.readouterr().err
assert "--days must be >= 1" in err
def test_prune_nothing_to_do(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: [])
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))
assert rc == 0
assert "nothing to prune" in capsys.readouterr().out
def test_prune_filters_pinned_and_archived(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [
_mk_record("old-pinned", idle_days=200, pinned=True),
_mk_record("old-archived", idle_days=200, state="archived"),
_mk_record("recent", idle_days=10),
_mk_record("old-active", idle_days=200),
]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, f"archived {name}"),
)
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))
assert rc == 0
assert archived == ["old-active"]
out = capsys.readouterr().out
assert "old-active" in out
assert "old-pinned" not in out
assert "old-archived" not in out
assert "recent" not in out
assert "archived 1/1" in out
def test_prune_falls_back_to_created_at_when_never_used(monkeypatch, capsys):
"""Never-used skills must be prunable via created_at — otherwise immortal."""
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [_mk_record("never-used", idle_days=0, created_idle_days=200)]
# Force last_activity_at to None explicitly
rows[0]["last_activity_at"] = None
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, "ok"),
)
rc = curator_cli._cmd_prune(_ns(days=90, yes=True, dry_run=False))
assert rc == 0
assert archived == ["never-used"]
def test_prune_dry_run_makes_no_changes(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [_mk_record("old-skill", idle_days=200)]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, "ok"),
)
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=True))
assert rc == 0
assert archived == []
out = capsys.readouterr().out
assert "old-skill" in out
assert "dry run" in out
def test_prune_prompts_without_yes(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [_mk_record("old-skill", idle_days=200)]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, "ok"),
)
monkeypatch.setattr("builtins.input", lambda _prompt: "n")
rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False))
assert rc == 1
assert archived == []
assert "aborted" in capsys.readouterr().out
def test_prune_confirms_with_y(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [_mk_record("old-skill", idle_days=200)]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, "ok"),
)
monkeypatch.setattr("builtins.input", lambda _prompt: "y")
rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False))
assert rc == 0
assert archived == ["old-skill"]
def test_prune_reports_partial_failure(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [
_mk_record("ok-skill", idle_days=200),
_mk_record("bad-skill", idle_days=200),
]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
def fake_archive(name):
if name == "bad-skill":
return False, "disk full"
return True, "ok"
monkeypatch.setattr(skill_usage, "archive_skill", fake_archive)
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))
assert rc == 1
out = capsys.readouterr().out
assert "archived 1/2" in out
assert "bad-skill: disk full" in out
# ─── argparse wiring ────────────────────────────────────────────────────────
def test_archive_and_prune_registered():
import argparse
import hermes_cli.curator as curator_cli
parser = argparse.ArgumentParser(prog="hermes curator")
curator_cli.register_cli(parser)
args = parser.parse_args(["archive", "my-skill"])
assert args.skill == "my-skill"
assert args.func.__name__ == "_cmd_archive"
args = parser.parse_args(["prune", "--days", "45", "--yes", "--dry-run"])
assert args.days == 45
assert args.yes is True
assert args.dry_run is True
assert args.func.__name__ == "_cmd_prune"
def test_prune_defaults():
import argparse
import hermes_cli.curator as curator_cli
parser = argparse.ArgumentParser(prog="hermes curator")
curator_cli.register_cli(parser)
args = parser.parse_args(["prune"])
assert args.days == 90
assert args.yes is False
assert args.dry_run is False

View file

@ -160,6 +160,15 @@ class TestCurrentBoard:
kb.set_current_board("filepick")
assert kb.get_current_board() == "filepick"
def test_stale_file_pointer_falls_back_to_default(self, fresh_home):
current = fresh_home / "kanban" / "current"
current.parent.mkdir(parents=True, exist_ok=True)
current.write_text("missing-board\n", encoding="utf-8")
assert kb.get_current_board() == "default"
assert not kb.board_exists("missing-board")
assert [b["slug"] for b in kb.list_boards()] == ["default"]
def test_env_beats_file(self, fresh_home, monkeypatch):
kb.create_board("a")
kb.create_board("b")

View file

@ -208,3 +208,81 @@ def test_kanban_not_gateway_only():
cmd = next(c for c in COMMAND_REGISTRY if c.name == "kanban")
assert not cmd.cli_only
assert not cmd.gateway_only
# ---------------------------------------------------------------------------
# reclaim + reassign CLI smoke tests
# ---------------------------------------------------------------------------
def test_run_slash_reclaim_running_task(kanban_home):
import re
import time
import secrets
from hermes_cli import kanban_db as kb
out1 = kc.run_slash("create 'stuck worker task' --assignee broken-model")
m = re.search(r"(t_[a-f0-9]+)", out1)
assert m
tid = m.group(1)
# Simulate a running claim outside TTL.
conn = kb.connect()
try:
lock = secrets.token_hex(4)
conn.execute(
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
"worker_pid=? WHERE id=?",
(lock, int(time.time()) + 3600, 4242, tid),
)
conn.execute(
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
(tid, lock, int(time.time()) + 3600, 4242, int(time.time())),
)
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (rid, tid))
conn.commit()
finally:
conn.close()
out = kc.run_slash(f"reclaim {tid} --reason 'test'")
assert "Reclaimed" in out, out
# Status back to ready.
out2 = kc.run_slash(f"show {tid}")
assert "ready" in out2.lower()
def test_run_slash_reassign_with_reclaim_flag(kanban_home):
import re
import time
import secrets
from hermes_cli import kanban_db as kb
out1 = kc.run_slash("create 'switch model' --assignee orig")
m = re.search(r"(t_[a-f0-9]+)", out1)
tid = m.group(1)
# Simulate a running claim.
conn = kb.connect()
try:
lock = secrets.token_hex(4)
conn.execute(
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
"worker_pid=? WHERE id=?",
(lock, int(time.time()) + 3600, 4242, tid),
)
conn.execute(
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
(tid, lock, int(time.time()) + 3600, 4242, int(time.time())),
)
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (rid, tid))
conn.commit()
finally:
conn.close()
out = kc.run_slash(f"reassign {tid} newbie --reclaim --reason 'switch'")
assert "Reassigned" in out, out
out2 = kc.run_slash(f"show {tid}")
assert "newbie" in out2

View file

@ -13,9 +13,11 @@ from __future__ import annotations
import argparse
import json
import os
import subprocess
import threading
import time
from pathlib import Path
from types import SimpleNamespace
from typing import Optional
import pytest
@ -80,7 +82,7 @@ def test_no_idempotency_key_never_collides(kanban_home):
# Spawn-failure circuit breaker
# ---------------------------------------------------------------------------
def test_spawn_failure_auto_blocks_after_limit(kanban_home):
def test_spawn_failure_auto_blocks_after_limit(kanban_home, all_assignees_spawnable):
"""N consecutive spawn failures on the same task → auto_blocked."""
def _bad_spawn(task, ws):
raise RuntimeError("no PATH")
@ -109,7 +111,7 @@ def test_spawn_failure_auto_blocks_after_limit(kanban_home):
conn.close()
def test_successful_spawn_resets_failure_counter(kanban_home):
def test_successful_spawn_resets_failure_counter(kanban_home, all_assignees_spawnable):
"""A successful spawn clears the counter so past failures don't count
against future retries of the same task."""
calls = [0]
@ -138,7 +140,7 @@ def test_successful_spawn_resets_failure_counter(kanban_home):
conn.close()
def test_workspace_resolution_failure_also_counts(kanban_home):
def test_workspace_resolution_failure_also_counts(kanban_home, all_assignees_spawnable):
"""`dir:` workspace with no path should fail workspace resolution AND
count against the failure budget not just crash the tick."""
conn = kb.connect()
@ -183,6 +185,20 @@ def test_pid_alive_helper():
assert not kb._pid_alive(2 ** 30)
def test_pid_alive_detects_darwin_zombie(monkeypatch):
monkeypatch.setattr(kb.sys, "platform", "darwin")
monkeypatch.setattr(kb.os, "kill", lambda pid, sig: None)
def fake_run(args, **kwargs):
assert args == ["ps", "-o", "stat=", "-p", "123"]
assert kwargs["stdout"] is subprocess.PIPE
return SimpleNamespace(returncode=0, stdout="Z+\n")
monkeypatch.setattr(kb.subprocess, "run", fake_run)
assert kb._pid_alive(123) is False
def test_detect_crashed_workers_reclaims(kanban_home):
"""A running task whose pid vanished gets dropped to ready with a
``crashed`` event, independent of the claim TTL."""
@ -824,7 +840,7 @@ def test_recompute_ready_emits_promoted_not_ready(kanban_home):
conn.close()
def test_spawn_failure_circuit_breaker_emits_gave_up(kanban_home):
def test_spawn_failure_circuit_breaker_emits_gave_up(kanban_home, all_assignees_spawnable):
def _bad(task, ws):
raise RuntimeError("nope")
conn = kb.connect()
@ -840,7 +856,7 @@ def test_spawn_failure_circuit_breaker_emits_gave_up(kanban_home):
conn.close()
def test_spawned_event_emitted_with_pid(kanban_home):
def test_spawned_event_emitted_with_pid(kanban_home, all_assignees_spawnable):
"""Successful spawn must append a ``spawned`` event with the pid in
the payload so humans tailing events see pid tracking."""
def _spawn_returns_pid(task, ws):
@ -899,8 +915,8 @@ def test_migration_renames_legacy_event_kinds(tmp_path, monkeypatch):
# ---------------------------------------------------------------------------
def test_list_profiles_on_disk(tmp_path, monkeypatch):
"""list_profiles_on_disk returns directories under ~/.hermes/profiles/
that contain a config.yaml."""
"""list_profiles_on_disk returns the implicit default profile plus
named profiles under ~/.hermes/profiles/ that contain a config.yaml."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("HERMES_HOME", raising=False)
profiles = tmp_path / ".hermes" / "profiles"
@ -914,7 +930,7 @@ def test_list_profiles_on_disk(tmp_path, monkeypatch):
(profiles / "stray.txt").write_text("noise")
names = kb.list_profiles_on_disk()
assert names == ["researcher", "writer"]
assert names == ["default", "researcher", "writer"]
def test_list_profiles_on_disk_custom_root(tmp_path, monkeypatch):
@ -928,7 +944,7 @@ def test_list_profiles_on_disk_custom_root(tmp_path, monkeypatch):
(d / "config.yaml").write_text("model: {}\n")
names = kb.list_profiles_on_disk()
assert names == ["researcher", "writer"]
assert names == ["default", "researcher", "writer"]
def test_known_assignees_merges_disk_and_board(tmp_path, monkeypatch):
@ -955,6 +971,8 @@ def test_known_assignees_merges_disk_and_board(tmp_path, monkeypatch):
conn.close()
by_name = {d["name"]: d for d in data}
assert by_name["default"]["on_disk"] is True
assert by_name["default"]["counts"] == {}
assert by_name["researcher"]["on_disk"] is True
assert by_name["researcher"]["counts"] == {}
assert by_name["writer"]["on_disk"] is True
@ -1154,7 +1172,7 @@ def test_run_on_block_with_reason(kanban_home):
conn.close()
def test_run_on_spawn_failure_records_failed_runs(kanban_home):
def test_run_on_spawn_failure_records_failed_runs(kanban_home, all_assignees_spawnable):
"""Each spawn_failed event closes a run with outcome='spawn_failed',
and the Nth failure closes a run with outcome='gave_up'."""
def _bad(task, ws):
@ -1371,6 +1389,48 @@ def test_cli_complete_with_summary_and_metadata(kanban_home):
assert r.metadata == {"files": 3}
def test_cli_edit_backfills_result_on_done_task(kanban_home):
conn = kb.connect()
try:
tid = kb.create_task(conn, title="x", assignee="worker")
kb.complete_task(conn, tid)
finally:
conn.close()
meta = '{"source": "dashboard-recovery"}'
out = run_slash(
"edit " + tid
+ " --result \"DECIDED: done\""
+ " --summary \"DECIDED: done\""
+ " --metadata '" + meta + "'"
)
assert "Edited" in out
conn = kb.connect()
try:
task = kb.get_task(conn, tid)
run = kb.latest_run(conn, tid)
events = kb.list_events(conn, tid)
finally:
conn.close()
assert task.result == "DECIDED: done"
assert run.summary == "DECIDED: done"
assert run.metadata == {"source": "dashboard-recovery"}
assert events[-1].kind == "edited"
def test_cli_edit_rejects_non_done_task(kanban_home):
conn = kb.connect()
try:
tid = kb.create_task(conn, title="x", assignee="worker")
finally:
conn.close()
out = run_slash(f"edit {tid} --result nope")
assert "not done" in out
def test_cli_complete_bad_metadata_exits_nonzero(kanban_home):
conn = kb.connect()
try:
@ -2726,3 +2786,269 @@ def test_gateway_dispatcher_watcher_env_truthy_uses_config(monkeypatch):
timeout=3.0,
)
)
# ---------------------------------------------------------------------------
# Hallucination gate (created_cards verify + prose scan)
# ---------------------------------------------------------------------------
def test_complete_with_created_cards_all_verified_records_manifest(kanban_home):
"""A completion with created_cards that all exist + belong to this
worker records them on the ``completed`` event payload."""
conn = kb.connect()
try:
parent = kb.create_task(conn, title="parent", assignee="alice")
c1 = kb.create_task(conn, title="c1", assignee="x", created_by="alice")
c2 = kb.create_task(conn, title="c2", assignee="y", created_by="alice")
ok = kb.complete_task(
conn, parent,
summary="done, created c1+c2",
created_cards=[c1, c2],
)
assert ok is True
evs = list(conn.execute(
"SELECT kind, payload FROM task_events WHERE task_id=? ORDER BY id",
(parent,),
))
completed = [e for e in evs if e["kind"] == "completed"]
assert len(completed) == 1
import json as _json
payload = _json.loads(completed[0]["payload"])
assert payload.get("verified_cards") == [c1, c2]
finally:
conn.close()
def test_complete_with_phantom_created_cards_raises_and_audits(kanban_home):
"""A completion claiming a card id that doesn't exist raises
HallucinatedCardsError, leaves the task in its prior state, and
records a ``completion_blocked_hallucination`` event for auditing."""
conn = kb.connect()
try:
parent = kb.create_task(conn, title="parent", assignee="alice")
real = kb.create_task(conn, title="real", assignee="x", created_by="alice")
phantom_id = "t_deadbeefcafe"
with pytest.raises(kb.HallucinatedCardsError) as excinfo:
kb.complete_task(
conn, parent,
summary="claimed phantom",
created_cards=[real, phantom_id],
)
assert excinfo.value.phantom == [phantom_id]
# Task still in prior state (ready, not done).
row = conn.execute(
"SELECT status FROM tasks WHERE id=?", (parent,),
).fetchone()
assert row["status"] == "ready"
# Audit event landed.
kinds = [
r["kind"] for r in conn.execute(
"SELECT kind FROM task_events WHERE task_id=? ORDER BY id",
(parent,),
)
]
assert "completion_blocked_hallucination" in kinds
assert "completed" not in kinds
finally:
conn.close()
def test_complete_with_cross_worker_card_is_rejected(kanban_home):
"""A card that exists but was created by a different worker profile
is treated as phantom (hallucinated attribution)."""
conn = kb.connect()
try:
parent = kb.create_task(conn, title="parent", assignee="alice")
other = kb.create_task(conn, title="other", assignee="x", created_by="bob")
with pytest.raises(kb.HallucinatedCardsError) as excinfo:
kb.complete_task(
conn, parent,
summary="claiming someone else's card",
created_cards=[other],
)
assert excinfo.value.phantom == [other]
finally:
conn.close()
def test_complete_prose_scan_flags_nonexistent_ids(kanban_home):
"""Successful completion whose summary references a ``t_<hex>`` id
that doesn't resolve emits a ``suspected_hallucinated_references``
event. Does not block the completion."""
conn = kb.connect()
try:
parent = kb.create_task(conn, title="parent", assignee="x")
ok = kb.complete_task(
conn, parent,
summary="also saw t_abcd1234ffff failing in CI",
)
assert ok is True
kinds_and_payloads = list(conn.execute(
"SELECT kind, payload FROM task_events WHERE task_id=? ORDER BY id",
(parent,),
))
kinds = [r["kind"] for r in kinds_and_payloads]
assert "suspected_hallucinated_references" in kinds
import json as _json
susp = [
_json.loads(r["payload"])
for r in kinds_and_payloads
if r["kind"] == "suspected_hallucinated_references"
][0]
assert "t_abcd1234ffff" in susp["phantom_refs"]
finally:
conn.close()
def test_complete_prose_scan_ignores_existing_ids(kanban_home):
"""Summaries referencing real task ids don't emit a warning."""
conn = kb.connect()
try:
other = kb.create_task(conn, title="other", assignee="x")
parent = kb.create_task(conn, title="parent", assignee="x")
ok = kb.complete_task(
conn, parent,
summary=f"depended on {other}, now done",
)
assert ok is True
kinds = [
r["kind"] for r in conn.execute(
"SELECT kind FROM task_events WHERE task_id=? ORDER BY id",
(parent,),
)
]
assert "suspected_hallucinated_references" not in kinds
finally:
conn.close()
# ---------------------------------------------------------------------------
# Recovery helpers (reclaim + reassign)
# ---------------------------------------------------------------------------
def test_reclaim_task_resets_running_to_ready(kanban_home):
"""Manual reclaim releases the claim, resets status, and emits a
``reclaimed`` event even when claim_expires has not passed."""
import time
import secrets
conn = kb.connect()
try:
t = kb.create_task(conn, title="stuck", assignee="broken")
# Simulate a live claim (not expired).
lock = secrets.token_hex(8)
future = int(time.time()) + 3600
conn.execute(
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
"worker_pid=? WHERE id=?",
(lock, future, 12345, t),
)
conn.execute(
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
(t, lock, future, 12345, int(time.time())),
)
run_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (run_id, t))
conn.commit()
# release_stale_claims should NOT reclaim (not expired).
assert kb.release_stale_claims(conn) == 0
# reclaim_task should work immediately.
assert kb.reclaim_task(conn, t, reason="test reason") is True
row = conn.execute(
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id=?",
(t,),
).fetchone()
assert row["status"] == "ready"
assert row["claim_lock"] is None
assert row["worker_pid"] is None
import json as _json
reclaim_evs = [
_json.loads(r["payload"])
for r in conn.execute(
"SELECT payload FROM task_events WHERE task_id=? AND kind='reclaimed'",
(t,),
)
]
assert len(reclaim_evs) == 1
assert reclaim_evs[0].get("manual") is True
assert reclaim_evs[0].get("reason") == "test reason"
finally:
conn.close()
def test_reclaim_task_returns_false_for_already_ready(kanban_home):
"""Reclaiming a task that's not running returns False (no-op)."""
conn = kb.connect()
try:
t = kb.create_task(conn, title="ready task", assignee="x")
assert kb.reclaim_task(conn, t) is False
finally:
conn.close()
def test_reassign_task_refuses_running_without_reclaim_first(kanban_home):
"""Without ``reclaim_first=True``, reassigning a running task is a
no-op returning False (matches assign_task's RuntimeError via
internal catch)."""
conn = kb.connect()
try:
t = kb.create_task(conn, title="running", assignee="orig")
conn.execute(
"UPDATE tasks SET status='running', claim_lock=? WHERE id=?",
("live", t),
)
conn.commit()
assert kb.reassign_task(conn, t, "new") is False
# Assignee unchanged.
row = conn.execute(
"SELECT assignee FROM tasks WHERE id=?", (t,),
).fetchone()
assert row["assignee"] == "orig"
finally:
conn.close()
def test_reassign_task_with_reclaim_first_switches_profile(kanban_home):
"""With ``reclaim_first=True``, a running task is reclaimed and
reassigned in one operation."""
import time
import secrets
conn = kb.connect()
try:
t = kb.create_task(conn, title="switch me", assignee="orig")
lock = secrets.token_hex(8)
future = int(time.time()) + 3600
conn.execute(
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
"worker_pid=? WHERE id=?",
(lock, future, 99999, t),
)
conn.execute(
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
(t, lock, future, 99999, int(time.time())),
)
run_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (run_id, t))
conn.commit()
assert kb.reassign_task(
conn, t, "new-profile",
reclaim_first=True, reason="switch model",
) is True
row = conn.execute(
"SELECT assignee, status FROM tasks WHERE id=?", (t,),
).fetchone()
assert row["assignee"] == "new-profile"
assert row["status"] == "ready"
finally:
conn.close()

View file

@ -327,7 +327,7 @@ def test_worker_context_includes_parent_results_and_comments(kanban_home):
# Dispatcher
# ---------------------------------------------------------------------------
def test_dispatch_dry_run_does_not_claim(kanban_home):
def test_dispatch_dry_run_does_not_claim(kanban_home, all_assignees_spawnable):
with kb.connect() as conn:
t1 = kb.create_task(conn, title="a", assignee="alice")
t2 = kb.create_task(conn, title="b", assignee="bob")
@ -344,10 +344,58 @@ def test_dispatch_skips_unassigned(kanban_home):
t = kb.create_task(conn, title="floater")
res = kb.dispatch_once(conn, dry_run=True)
assert t in res.skipped_unassigned
assert t not in res.skipped_nonspawnable
assert not res.spawned
def test_dispatch_promotes_ready_and_spawns(kanban_home):
def test_dispatch_skips_nonspawnable_into_separate_bucket(kanban_home, monkeypatch):
"""Tasks whose assignee fails profile_exists() must NOT land in
``skipped_unassigned`` (which is operator-actionable) they go in
the dedicated ``skipped_nonspawnable`` bucket so health telemetry
can suppress false-positive "stuck" warnings."""
from hermes_cli import profiles
monkeypatch.setattr(profiles, "profile_exists", lambda name: False)
with kb.connect() as conn:
t = kb.create_task(conn, title="for-terminal", assignee="orion-cc")
res = kb.dispatch_once(conn, dry_run=True)
assert t in res.skipped_nonspawnable
assert t not in res.skipped_unassigned
assert not res.spawned
def test_has_spawnable_ready_false_when_only_terminal_lanes(kanban_home, monkeypatch):
"""``has_spawnable_ready`` returns False when every ready task is
assigned to a control-plane lane used by gateway/CLI dispatchers
to silence the stuck-warn while terminals still have queued work."""
from hermes_cli import profiles
monkeypatch.setattr(profiles, "profile_exists", lambda name: False)
with kb.connect() as conn:
kb.create_task(conn, title="t1", assignee="orion-cc")
kb.create_task(conn, title="t2", assignee="orion-research")
assert kb.has_spawnable_ready(conn) is False
def test_has_spawnable_ready_true_when_real_profile_present(kanban_home, monkeypatch):
"""``has_spawnable_ready`` returns True as soon as ANY ready task
has an assignee that maps to a real Hermes profile preserves the
real "stuck" signal when a daily/agent task is queued."""
from hermes_cli import profiles
monkeypatch.setattr(
profiles, "profile_exists", lambda name: name == "daily"
)
with kb.connect() as conn:
kb.create_task(conn, title="terminal-task", assignee="orion-cc")
kb.create_task(conn, title="hermes-task", assignee="daily")
assert kb.has_spawnable_ready(conn) is True
def test_has_spawnable_ready_false_on_empty_queue(kanban_home):
"""Empty queue is the trivial false case — no ready tasks at all."""
with kb.connect() as conn:
assert kb.has_spawnable_ready(conn) is False
def test_dispatch_promotes_ready_and_spawns(kanban_home, all_assignees_spawnable):
spawns = []
def fake_spawn(task, workspace):
@ -368,7 +416,7 @@ def test_dispatch_promotes_ready_and_spawns(kanban_home):
assert kb.get_task(conn, c).status == "running"
def test_dispatch_spawn_failure_releases_claim(kanban_home):
def test_dispatch_spawn_failure_releases_claim(kanban_home, all_assignees_spawnable):
def boom(task, workspace):
raise RuntimeError("spawn failed")

View file

@ -0,0 +1,216 @@
"""Tests for ``list_picker_providers`` — the /model picker filter.
``list_picker_providers`` wraps ``list_authenticated_providers`` and
post-processes the result for interactive pickers (Telegram, Discord):
- OpenRouter's ``models`` are replaced with the live-filtered output of
``fetch_openrouter_models``, so IDs the live catalog no longer carries
drop out.
- Provider rows with an empty ``models`` list are dropped, except custom
endpoints (``is_user_defined=True`` with an ``api_url``) where the user
may supply their own model set through config.
These tests exercise the filter in isolation by mocking
``list_authenticated_providers`` and ``fetch_openrouter_models`` so no
network or auth state is required.
"""
import pytest
from hermes_cli import model_switch
def _make_provider(slug, name=None, models=None, *, is_current=False,
is_user_defined=False, source="built-in", api_url=None):
"""Build a dict shaped like ``list_authenticated_providers`` output."""
entry = {
"slug": slug,
"name": name or slug.title(),
"is_current": is_current,
"is_user_defined": is_user_defined,
"models": list(models or []),
"total_models": len(models or []),
"source": source,
}
if api_url is not None:
entry["api_url"] = api_url
return entry
def test_openrouter_models_replaced_with_live_catalog(monkeypatch):
"""OpenRouter row's ``models`` should come from fetch_openrouter_models."""
base = [
_make_provider("openrouter", models=["openai/gpt-stale", "old/model"]),
]
live = [("openai/gpt-5.4", "recommended"), ("moonshotai/kimi-k2.6", "")]
monkeypatch.setattr(model_switch, "list_authenticated_providers",
lambda **kw: list(base))
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
lambda *a, **kw: list(live))
result = model_switch.list_picker_providers(max_models=50)
assert len(result) == 1
openrouter = result[0]
assert openrouter["slug"] == "openrouter"
assert openrouter["models"] == ["openai/gpt-5.4", "moonshotai/kimi-k2.6"]
assert openrouter["total_models"] == 2
def test_openrouter_falls_back_to_base_models_on_fetch_failure(monkeypatch):
"""If the live catalog fetch raises, keep whatever base provided."""
fallback_models = ["openai/gpt-5.4", "moonshotai/kimi-k2.6"]
base = [_make_provider("openrouter", models=fallback_models)]
def _raise(*_a, **_kw):
raise RuntimeError("network down")
monkeypatch.setattr(model_switch, "list_authenticated_providers",
lambda **kw: list(base))
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models", _raise)
result = model_switch.list_picker_providers(max_models=50)
assert len(result) == 1
assert result[0]["models"] == fallback_models
def test_openrouter_empty_live_catalog_drops_row(monkeypatch):
"""If the live catalog returns nothing for OpenRouter, drop the row."""
base = [_make_provider("openrouter", models=["something/stale"])]
monkeypatch.setattr(model_switch, "list_authenticated_providers",
lambda **kw: list(base))
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
lambda *a, **kw: [])
result = model_switch.list_picker_providers(max_models=50)
assert result == []
def test_non_openrouter_rows_passed_through_unchanged(monkeypatch):
"""Non-OpenRouter providers keep their curated ``models`` as-is."""
base = [
_make_provider("anthropic", models=["claude-sonnet-4-6", "claude-opus-4-7"]),
_make_provider("gemini", models=["gemini-3-flash-preview"]),
]
monkeypatch.setattr(model_switch, "list_authenticated_providers",
lambda **kw: list(base))
# fetch_openrouter_models must not be consulted when there's no openrouter row
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
lambda *a, **kw: pytest.fail("should not be called"))
result = model_switch.list_picker_providers(max_models=50)
assert [p["slug"] for p in result] == ["anthropic", "gemini"]
assert result[0]["models"] == ["claude-sonnet-4-6", "claude-opus-4-7"]
assert result[1]["models"] == ["gemini-3-flash-preview"]
def test_empty_models_row_dropped(monkeypatch):
"""Built-in provider with an empty ``models`` list is dropped."""
base = [
_make_provider("anthropic", models=[]), # drop
_make_provider("openrouter", models=["anything"]), # replaced by live
]
monkeypatch.setattr(model_switch, "list_authenticated_providers",
lambda **kw: list(base))
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
lambda *a, **kw: [("openai/gpt-5.4", "recommended")])
result = model_switch.list_picker_providers(max_models=50)
assert [p["slug"] for p in result] == ["openrouter"]
def test_custom_endpoint_with_api_url_kept_when_models_empty(monkeypatch):
"""User-defined endpoints with an ``api_url`` survive even if models empty.
Rationale: custom endpoints may accept any model id the user types --
the picker still shows the row so the user can enter one manually.
"""
base = [
_make_provider("local-ollama", is_user_defined=True,
api_url="http://localhost:11434/v1", models=[],
source="user-config"),
]
monkeypatch.setattr(model_switch, "list_authenticated_providers",
lambda **kw: list(base))
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
lambda *a, **kw: [])
result = model_switch.list_picker_providers(max_models=50)
assert len(result) == 1
assert result[0]["slug"] == "local-ollama"
assert result[0]["models"] == []
def test_user_defined_without_api_url_and_empty_models_dropped(monkeypatch):
"""An is_user_defined row WITHOUT api_url and no models is still dropped.
The exemption is specifically for custom endpoints that can accept
arbitrary model ids; without an api_url there's nothing to point at.
"""
base = [
_make_provider("orphan", is_user_defined=True, api_url=None, models=[]),
]
monkeypatch.setattr(model_switch, "list_authenticated_providers",
lambda **kw: list(base))
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
lambda *a, **kw: [])
result = model_switch.list_picker_providers(max_models=50)
assert result == []
def test_max_models_caps_openrouter_live_output(monkeypatch):
"""``max_models`` caps how many OpenRouter IDs land in the row."""
live = [(f"vendor/model-{i}", "") for i in range(20)]
base = [_make_provider("openrouter", models=["placeholder"])]
monkeypatch.setattr(model_switch, "list_authenticated_providers",
lambda **kw: list(base))
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
lambda *a, **kw: list(live))
result = model_switch.list_picker_providers(max_models=5)
assert len(result) == 1
assert len(result[0]["models"]) == 5
assert result[0]["models"] == [mid for mid, _ in live[:5]]
# total_models reflects the full live catalog, not the capped slice.
assert result[0]["total_models"] == 20
def test_passthrough_kwargs_to_base(monkeypatch):
"""All kwargs (current_provider, user_providers, custom_providers, max_models)
must be forwarded to ``list_authenticated_providers`` unchanged.
"""
captured = {}
def _capture(**kwargs):
captured.update(kwargs)
return []
monkeypatch.setattr(model_switch, "list_authenticated_providers", _capture)
monkeypatch.setattr("hermes_cli.models.fetch_openrouter_models",
lambda *a, **kw: [])
model_switch.list_picker_providers(
current_provider="openrouter",
user_providers={"foo": {"api": "http://x"}},
custom_providers=[{"name": "bar", "base_url": "http://y"}],
max_models=12,
)
assert captured["current_provider"] == "openrouter"
assert captured["user_providers"] == {"foo": {"api": "http://x"}}
assert captured["custom_providers"] == [{"name": "bar", "base_url": "http://y"}]
assert captured["max_models"] == 12

View file

@ -0,0 +1,75 @@
"""Tests for `_pin_kanban_board_env` helper invoked by `cmd_chat`.
Regression coverage for #20074: a chat session must export the active kanban
board into `HERMES_KANBAN_BOARD` at boot so subprocess shell-outs (e.g.
`hermes kanban `) inherit the same board the in-process kanban tools resolve.
Without this, a concurrent `hermes kanban boards switch` from another session
can flip the global current-board file mid-turn and silently divert the
shell calls to a different DB.
"""
import importlib
import os
import pytest
@pytest.fixture(autouse=True)
def _isolate_kanban_board_env():
"""Snapshot `HERMES_KANBAN_BOARD` and restore it after the test.
`_pin_kanban_board_env()` writes to ``os.environ`` directly, bypassing
any ``monkeypatch.setenv`` tracking. Without this fixture the mutation
leaks into subsequent tests and breaks anything that resolves a kanban
path from the env (e.g. ``TestSharedBoardPaths`` in test_kanban_db.py).
"""
prev = os.environ.get("HERMES_KANBAN_BOARD")
os.environ.pop("HERMES_KANBAN_BOARD", None)
try:
yield
finally:
if prev is None:
os.environ.pop("HERMES_KANBAN_BOARD", None)
else:
os.environ["HERMES_KANBAN_BOARD"] = prev
def test_pin_writes_resolved_board_when_env_unset(monkeypatch):
main_mod = importlib.import_module("hermes_cli.main")
import hermes_cli.kanban_db as kdb
monkeypatch.setattr(kdb, "get_current_board", lambda: "space")
main_mod._pin_kanban_board_env()
assert main_mod.os.environ.get("HERMES_KANBAN_BOARD") == "space"
def test_pin_does_not_overwrite_existing_env(monkeypatch):
monkeypatch.setenv("HERMES_KANBAN_BOARD", "preset")
main_mod = importlib.import_module("hermes_cli.main")
import hermes_cli.kanban_db as kdb
def _explode():
raise AssertionError("get_current_board must not be called when env is set")
monkeypatch.setattr(kdb, "get_current_board", _explode)
main_mod._pin_kanban_board_env()
assert main_mod.os.environ.get("HERMES_KANBAN_BOARD") == "preset"
def test_pin_swallows_resolution_failures(monkeypatch):
main_mod = importlib.import_module("hermes_cli.main")
import hermes_cli.kanban_db as kdb
def _boom():
raise RuntimeError("disk gone")
monkeypatch.setattr(kdb, "get_current_board", _boom)
main_mod._pin_kanban_board_env()
assert "HERMES_KANBAN_BOARD" not in main_mod.os.environ

View file

@ -0,0 +1,157 @@
"""Tests for ``_prompt_api_key`` — the shared Keep/Replace/Clear menu used by
``hermes setup`` / ``hermes model`` when an API key already exists in ``.env``.
Regression coverage for #16394: the wizard used to silently skip the key prompt
when any value was present (even malformed junk), leaving users stuck.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
@pytest.fixture
def profile_env(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
(home / ".env").write_text("")
return home
def _pconfig(name="deepseek"):
from hermes_cli.auth import PROVIDER_REGISTRY
return PROVIDER_REGISTRY[name]
def _run_prompt(existing_key, choice, new_key="", provider_id="", pconfig_name="deepseek"):
"""Invoke _prompt_api_key with mocked input()/getpass() responses."""
from hermes_cli import main as m
pconfig = _pconfig(pconfig_name)
with patch("builtins.input", return_value=choice), \
patch("getpass.getpass", return_value=new_key):
return m._prompt_api_key(pconfig, existing_key, provider_id=provider_id)
# First-time entry ────────────────────────────────────────────────────────────
def test_first_time_save_new_key(profile_env):
from hermes_cli.config import get_env_value
key, abort = _run_prompt(existing_key="", choice="", new_key="sk-abcdef")
assert key == "sk-abcdef"
assert abort is False
assert get_env_value("DEEPSEEK_API_KEY") == "sk-abcdef"
def test_first_time_cancelled(profile_env):
key, abort = _run_prompt(existing_key="", choice="", new_key="")
assert key == ""
assert abort is True
# Already configured — K / R / C ───────────────────────────────────────────────
def test_keep_default_empty_input(profile_env):
from hermes_cli.config import save_env_value
save_env_value("DEEPSEEK_API_KEY", "sk-existing")
key, abort = _run_prompt(existing_key="sk-existing", choice="")
assert key == "sk-existing"
assert abort is False
def test_keep_letter_k(profile_env):
key, abort = _run_prompt(existing_key="sk-existing", choice="k")
assert key == "sk-existing"
assert abort is False
def test_keep_on_unrecognised_input(profile_env):
"""Garbage input falls through to keep — never destroys the user's key."""
key, abort = _run_prompt(existing_key="sk-existing", choice="xyz")
assert key == "sk-existing"
assert abort is False
def test_replace_saves_new_key(profile_env):
from hermes_cli.config import get_env_value, save_env_value
save_env_value("DEEPSEEK_API_KEY", "sk-malformed-junk")
key, abort = _run_prompt(
existing_key="sk-malformed-junk", choice="r", new_key="sk-fresh"
)
assert key == "sk-fresh"
assert abort is False
assert get_env_value("DEEPSEEK_API_KEY") == "sk-fresh"
def test_replace_cancelled_preserves_key(profile_env):
"""Empty entry to the Replace prompt means cancel — keeps the old key intact."""
from hermes_cli.config import get_env_value, save_env_value
save_env_value("DEEPSEEK_API_KEY", "sk-existing")
key, abort = _run_prompt(
existing_key="sk-existing", choice="r", new_key=""
)
assert key == "sk-existing"
assert abort is False
assert get_env_value("DEEPSEEK_API_KEY") == "sk-existing"
def test_clear_wipes_env_and_aborts(profile_env):
from hermes_cli.config import get_env_value, save_env_value
save_env_value("DEEPSEEK_API_KEY", "sk-existing")
save_env_value("OTHER_VAR", "keep-me")
key, abort = _run_prompt(existing_key="sk-existing", choice="c")
assert key == ""
assert abort is True
# Cleared, but sibling entries untouched.
assert not get_env_value("DEEPSEEK_API_KEY")
assert get_env_value("OTHER_VAR") == "keep-me"
def test_ctrl_c_at_choice_prompt_keeps(profile_env):
from hermes_cli import main as m
pconfig = _pconfig("deepseek")
with patch("builtins.input", side_effect=KeyboardInterrupt):
key, abort = m._prompt_api_key(pconfig, "sk-existing")
assert key == "sk-existing"
assert abort is False
# LM Studio no-auth placeholder ────────────────────────────────────────────────
def test_lmstudio_first_time_empty_uses_placeholder(profile_env):
from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER
from hermes_cli.config import get_env_value
key, abort = _run_prompt(
existing_key="", choice="", new_key="",
provider_id="lmstudio", pconfig_name="lmstudio",
)
assert key == LMSTUDIO_NOAUTH_PLACEHOLDER
assert abort is False
assert get_env_value("LM_API_KEY") == LMSTUDIO_NOAUTH_PLACEHOLDER
def test_lmstudio_replace_empty_does_not_overwrite_with_placeholder(profile_env):
"""On REPLACE with empty input, preserve the user's existing key — do NOT
silently substitute the placeholder. The placeholder path only fires for
first-time configuration where the user has made no explicit choice yet."""
from hermes_cli.config import get_env_value, save_env_value
save_env_value("LM_API_KEY", "my-real-lmstudio-key")
key, abort = _run_prompt(
existing_key="my-real-lmstudio-key", choice="r", new_key="",
provider_id="lmstudio", pconfig_name="lmstudio",
)
assert key == "my-real-lmstudio-key"
assert abort is False
assert get_env_value("LM_API_KEY") == "my-real-lmstudio-key"

View file

@ -1,6 +1,28 @@
from hermes_cli import setup as setup_mod
def test_prompt_strips_bracketed_paste_markers(monkeypatch):
monkeypatch.setattr(
"builtins.input",
lambda _prompt="": "\x1b[200~sk-ant-api-key\x1b[201~",
)
value = setup_mod.prompt("API key")
assert value == "sk-ant-api-key"
def test_password_prompt_strips_bracketed_paste_markers(monkeypatch):
monkeypatch.setattr(
"getpass.getpass",
lambda _prompt="": "\x1b[200~secret-token\x1b[201~",
)
value = setup_mod.prompt("API key", password=True)
assert value == "secret-token"
def test_prompt_choice_uses_curses_helper(monkeypatch):
monkeypatch.setattr(setup_mod, "_curses_prompt_choice", lambda question, choices, default=0, description=None: 1)

View file

@ -561,6 +561,49 @@ def test_bulk_status_ready(client):
assert {a["id"], b["id"], c2["id"]}.issubset(ids)
def test_bulk_status_done_forwards_completion_summary(client):
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
r = client.post(
"/api/plugins/kanban/tasks/bulk",
json={
"ids": [a["id"], b["id"]],
"status": "done",
"result": "DECIDED: ship it",
"summary": "DECIDED: ship it",
"metadata": {"source": "dashboard"},
},
)
assert r.status_code == 200
assert all(r["ok"] for r in r.json()["results"])
conn = kb.connect()
try:
for tid in (a["id"], b["id"]):
task = kb.get_task(conn, tid)
run = kb.latest_run(conn, tid)
assert task.status == "done"
assert task.result == "DECIDED: ship it"
assert run.summary == "DECIDED: ship it"
assert run.metadata == {"source": "dashboard"}
finally:
conn.close()
def test_dashboard_done_actions_prompt_for_completion_summary():
repo_root = Path(__file__).resolve().parents[2]
bundle = (
repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
).read_text()
assert "withCompletionSummary" in bundle
assert "Completion summary" in bundle
assert "result: summary" in bundle
assert "body: JSON.stringify(patch)" in bundle
assert "body: JSON.stringify(finalPatch)" in bundle
def test_bulk_archive(client):
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
@ -1074,3 +1117,221 @@ def test_home_channels_empty_when_no_homes_configured(client, monkeypatch):
r = client.get("/api/plugins/kanban/home-channels")
assert r.status_code == 200
assert r.json()["home_channels"] == []
# ---------------------------------------------------------------------------
# Recovery endpoints (reclaim + reassign) and warnings field
# ---------------------------------------------------------------------------
def test_board_surfaces_warnings_field_for_hallucinated_completions(client):
"""Tasks with a pending completion_blocked_hallucination event surface
a ``warnings`` object on the /board payload so the UI can badge
them without fetching per-task events."""
conn = kb.connect()
try:
parent = kb.create_task(conn, title="parent", assignee="alice")
real = kb.create_task(conn, title="real", assignee="x", created_by="alice")
import pytest as _pytest
with _pytest.raises(kb.HallucinatedCardsError):
kb.complete_task(
conn, parent,
summary="claimed phantom",
created_cards=[real, "t_deadbeefcafe"],
)
finally:
conn.close()
r = client.get("/api/plugins/kanban/board")
assert r.status_code == 200
data = r.json()
tasks = [t for col in data["columns"] for t in col["tasks"]]
parent_dict = next(t for t in tasks if t["title"] == "parent")
assert parent_dict.get("warnings") is not None
w = parent_dict["warnings"]
assert w["count"] >= 1
assert "completion_blocked_hallucination" in w["kinds"]
def test_board_warnings_cleared_after_clean_completion(client):
"""A completed or edited event after a hallucination event clears
the warning badge we don't mark tasks permanently."""
conn = kb.connect()
try:
parent = kb.create_task(conn, title="parent", assignee="alice")
real = kb.create_task(conn, title="real", assignee="x", created_by="alice")
import pytest as _pytest
with _pytest.raises(kb.HallucinatedCardsError):
kb.complete_task(
conn, parent,
summary="first attempt phantom",
created_cards=[real, "t_phantom11"],
)
# Second attempt drops the bad id — succeeds.
ok = kb.complete_task(
conn, parent,
summary="retry without phantom",
created_cards=[real],
)
assert ok is True
finally:
conn.close()
r = client.get("/api/plugins/kanban/board", params={"include_archived": True})
assert r.status_code == 200
data = r.json()
tasks = [t for col in data["columns"] for t in col["tasks"]]
parent_dict = next(t for t in tasks if t["title"] == "parent")
# The clean completion wiped the warning.
assert parent_dict.get("warnings") is None
def test_reclaim_endpoint_releases_running_claim(client):
"""POST /tasks/<id>/reclaim drops the claim, returns ok, and emits
a manual reclaimed event."""
import secrets
conn = kb.connect()
try:
t = kb.create_task(conn, title="running", assignee="x")
lock = secrets.token_hex(8)
future = int(time.time()) + 3600
conn.execute(
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
"worker_pid=? WHERE id=?",
(lock, future, 99999, t),
)
conn.execute(
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
(t, lock, future, 99999, int(time.time())),
)
run_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (run_id, t))
conn.commit()
finally:
conn.close()
r = client.post(
f"/api/plugins/kanban/tasks/{t}/reclaim",
json={"reason": "browser recovery"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["task_id"] == t
# Confirm the task is back to ready.
conn2 = kb.connect()
try:
row = conn2.execute(
"SELECT status, claim_lock FROM tasks WHERE id=?", (t,),
).fetchone()
assert row["status"] == "ready"
assert row["claim_lock"] is None
finally:
conn2.close()
def test_reclaim_endpoint_409_for_non_running_task(client):
"""Reclaiming a task that's already ready returns 409."""
conn = kb.connect()
try:
t = kb.create_task(conn, title="ready", assignee="x")
finally:
conn.close()
r = client.post(
f"/api/plugins/kanban/tasks/{t}/reclaim",
json={},
)
assert r.status_code == 409
def test_reassign_endpoint_switches_profile(client):
"""POST /tasks/<id>/reassign changes the assignee field."""
conn = kb.connect()
try:
t = kb.create_task(conn, title="task", assignee="orig")
finally:
conn.close()
r = client.post(
f"/api/plugins/kanban/tasks/{t}/reassign",
json={"profile": "newbie", "reclaim_first": False},
)
assert r.status_code == 200, r.text
assert r.json()["assignee"] == "newbie"
conn2 = kb.connect()
try:
row = conn2.execute(
"SELECT assignee FROM tasks WHERE id=?", (t,),
).fetchone()
assert row["assignee"] == "newbie"
finally:
conn2.close()
def test_reassign_endpoint_409_on_running_without_reclaim(client):
"""Reassigning a running task without reclaim_first returns 409."""
import secrets
conn = kb.connect()
try:
t = kb.create_task(conn, title="running", assignee="orig")
conn.execute(
"UPDATE tasks SET status='running', claim_lock=? WHERE id=?",
(secrets.token_hex(4), t),
)
conn.commit()
finally:
conn.close()
r = client.post(
f"/api/plugins/kanban/tasks/{t}/reassign",
json={"profile": "new", "reclaim_first": False},
)
assert r.status_code == 409
def test_reassign_endpoint_with_reclaim_first_succeeds_on_running(client):
"""With reclaim_first=true, a running task is reclaimed+reassigned in
one call."""
import secrets
conn = kb.connect()
try:
t = kb.create_task(conn, title="running", assignee="orig")
lock = secrets.token_hex(4)
conn.execute(
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
"worker_pid=? WHERE id=?",
(lock, int(time.time()) + 3600, 1234, t),
)
conn.execute(
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
(t, lock, int(time.time()) + 3600, 1234, int(time.time())),
)
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (rid, t))
conn.commit()
finally:
conn.close()
r = client.post(
f"/api/plugins/kanban/tasks/{t}/reassign",
json={"profile": "new", "reclaim_first": True, "reason": "switch"},
)
assert r.status_code == 200, r.text
assert r.json()["assignee"] == "new"
conn2 = kb.connect()
try:
row = conn2.execute(
"SELECT status, assignee FROM tasks WHERE id=?", (t,),
).fetchone()
assert row["status"] == "ready"
assert row["assignee"] == "new"
finally:
conn2.close()

View file

@ -0,0 +1,107 @@
"""Tests for per-turn reasoning extraction in AIAgent.run_conversation.
Verifies the reasoning field returned to display layers (CLI reasoning box,
gateway reasoning footer, TUI reasoning event) only reflects the CURRENT
turn's reasoning — never leaks from a prior turn — and is picked up
correctly when reasoning is attached to a tool-calling assistant step
rather than the final-answer assistant step.
"""
from __future__ import annotations
def _extract_last_reasoning(messages):
"""Replica of the extraction loop in run_agent.py (~line 13867).
Tests pin the loop's behaviour so that refactors can't silently
regress the per-turn semantic.
"""
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
return last_reasoning
def test_simple_turn_reasoning_present():
messages = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi", "reasoning": "greeting the user"},
]
assert _extract_last_reasoning(messages) == "greeting the user"
def test_simple_turn_no_reasoning():
messages = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi", "reasoning": None},
]
assert _extract_last_reasoning(messages) is None
def test_tool_call_turn_reasoning_on_tool_call_step():
"""When the model reasons on the tool-call step and the final-answer
step has no reasoning (Claude thinking / DeepSeek v4 / Codex Responses
pattern), the box must show the tool-call-step reasoning, not empty.
"""
messages = [
{"role": "user", "content": "search the repo for X"},
{
"role": "assistant",
"content": "",
"reasoning": "I should use search_files",
"tool_calls": [{"id": "c1", "type": "function",
"function": {"name": "search_files", "arguments": "{}"}}],
},
{"role": "tool", "tool_call_id": "c1", "content": "3 matches"},
{"role": "assistant", "content": "Found 3 matches", "reasoning": None},
]
assert _extract_last_reasoning(messages) == "I should use search_files"
def test_no_stale_reasoning_across_turns():
"""The regression the whole change exists for. Prior turn had
reasoning; current turn has none. The reasoning box must NOT show
the prior turn's text.
"""
messages = [
# prior turn
{"role": "user", "content": "explain quantum tunneling"},
{"role": "assistant", "content": "It's when...",
"reasoning": "tunneling happens when particles..."},
# current turn
{"role": "user", "content": "thanks"},
{"role": "assistant", "content": "You're welcome!", "reasoning": None},
]
assert _extract_last_reasoning(messages) is None
def test_tool_call_turn_picks_latest_reasoning_within_turn():
"""If BOTH the tool-call step and the final step have reasoning
(uncommon but possible), the final-step reasoning wins it's the
most recent thought within the current turn.
"""
messages = [
{"role": "user", "content": "search and summarize"},
{
"role": "assistant",
"content": "",
"reasoning": "initial plan",
"tool_calls": [{"id": "c1", "type": "function",
"function": {"name": "search_files", "arguments": "{}"}}],
},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
{"role": "assistant", "content": "Here's the summary",
"reasoning": "synthesized view of results"},
]
assert _extract_last_reasoning(messages) == "synthesized view of results"
def test_empty_string_reasoning_treated_as_missing():
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello", "reasoning": ""},
]
assert _extract_last_reasoning(messages) is None

View file

@ -24,7 +24,7 @@ def test_openrouter_base_url_applies_or_headers(mock_openai):
headers = agent._client_kwargs["default_headers"]
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
assert headers["X-OpenRouter-Title"] == "Hermes Agent"
assert headers["X-Title"] == "Hermes Agent"
@patch("run_agent.OpenAI")

View file

@ -4990,6 +4990,28 @@ class TestDeadRetryCode:
)
class TestSupportsReasoningExtraBody:
def _make_agent(self):
agent = object.__new__(AIAgent)
agent.provider = "openrouter"
agent.base_url = "https://openrouter.ai/api/v1"
agent._base_url_lower = agent.base_url.lower()
agent.model = ""
return agent
def test_xiaomi_models_are_treated_as_reasoning_capable(self):
agent = self._make_agent()
for model in (
"xiaomi/mimo-v2.5-pro",
"xiaomi/mimo-v2.5",
"xiaomi/mimo-v2-omni",
"xiaomi/mimo-v2-pro",
"xiaomi/mimo-v2-flash",
):
agent.model = model
assert agent._supports_reasoning_extra_body() is True, model
class TestMemoryContextSanitization:
"""sanitize_context() helper correctness — used at provider boundaries."""

View file

@ -241,6 +241,23 @@ class TestSkillViewQualifiedName:
assert result["success"] is False
assert "not found" in result["error"].lower()
def test_category_qualified_local_skill_falls_through(self, tmp_path, monkeypatch):
from tools.skills_tool import skill_view
local_skills = tmp_path / "local-skills"
skill_dir = local_skills / "productivity" / "ticktick"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: ticktick\ndescription: local categorized\n---\nTickTick body.\n"
)
monkeypatch.setattr("tools.skills_tool.SKILLS_DIR", local_skills)
result = json.loads(skill_view("productivity:ticktick"))
assert result["success"] is True
assert result["name"] == "ticktick"
assert "TickTick body." in result["content"]
def test_stale_entry_self_heals(self, tmp_path):
from tools.skills_tool import skill_view

View file

@ -32,6 +32,21 @@ class TestGetToolset:
assert ts is not None
assert "web_search" in ts["tools"]
def test_merges_registry_tools_into_builtin_toolset(self, monkeypatch):
reg = ToolRegistry()
reg.register(
name="web_search_plus",
toolset="web",
schema=_make_schema("web_search_plus", "Plugin web search"),
handler=_dummy_handler,
)
monkeypatch.setattr("tools.registry.registry", reg)
ts = get_toolset("web")
assert ts is not None
assert set(ts["tools"]) == {"web_search", "web_extract", "web_search_plus"}
def test_unknown_returns_none(self):
assert get_toolset("nonexistent") is None

View file

@ -82,7 +82,11 @@ class TestIsLikelyBinary:
class TestCheckLintBracePaths:
"""Verify _check_lint handles file paths with curly braces safely."""
"""Verify _check_lint handles file paths with curly braces safely.
Uses ``.js`` to exercise the shell-linter path since ``.py`` now goes
through the in-process ast.parse linter (see TestCheckLintInproc).
"""
@pytest.fixture()
def ops(self):
@ -95,12 +99,12 @@ class TestCheckLintBracePaths:
with patch.object(ops, "_has_command", return_value=True), \
patch.object(ops, "_exec") as mock_exec:
mock_exec.return_value = MagicMock(exit_code=0, stdout="")
result = ops._check_lint("/tmp/test_file.py")
result = ops._check_lint("/tmp/test_file.js")
assert result.success is True
# Verify the command was built correctly
cmd_arg = mock_exec.call_args[0][0]
assert "'/tmp/test_file.py'" in cmd_arg
assert "'/tmp/test_file.js'" in cmd_arg
def test_path_with_curly_braces(self, ops):
"""Path containing ``{`` and ``}`` must not raise KeyError/ValueError."""
@ -108,7 +112,7 @@ class TestCheckLintBracePaths:
patch.object(ops, "_exec") as mock_exec:
mock_exec.return_value = MagicMock(exit_code=0, stdout="")
# This would raise KeyError with .format() but works with .replace()
result = ops._check_lint("/tmp/{test}_file.py")
result = ops._check_lint("/tmp/{test}_file.js")
assert result.success is True
cmd_arg = mock_exec.call_args[0][0]
@ -119,7 +123,7 @@ class TestCheckLintBracePaths:
with patch.object(ops, "_has_command", return_value=True), \
patch.object(ops, "_exec") as mock_exec:
mock_exec.return_value = MagicMock(exit_code=0, stdout="")
result = ops._check_lint("/tmp/{{var}}.py")
result = ops._check_lint("/tmp/{{var}}.js")
assert result.success is True
@ -131,7 +135,7 @@ class TestCheckLintBracePaths:
def test_missing_linter_skipped(self, ops):
"""When the linter binary is not installed, skip gracefully."""
with patch.object(ops, "_has_command", return_value=False):
result = ops._check_lint("/tmp/test.py")
result = ops._check_lint("/tmp/test.js")
assert result.skipped is True
def test_lint_failure_returns_output(self, ops):
@ -142,12 +146,122 @@ class TestCheckLintBracePaths:
exit_code=1,
stdout="SyntaxError: invalid syntax",
)
result = ops._check_lint("/tmp/bad.py")
result = ops._check_lint("/tmp/bad.js")
assert result.success is False
assert "SyntaxError" in result.output
class TestCheckLintInproc:
"""Verify in-process linters (.py via ast.parse, .json, .yaml, .toml).
These bypass the shell linter table entirely and parse content
directly in Python no subprocess, no toolchain dependency.
"""
@pytest.fixture()
def ops(self):
obj = ShellFileOperations.__new__(ShellFileOperations)
obj._command_cache = {}
return obj
def test_python_inproc_clean(self, ops):
"""Valid Python content passes in-process ast.parse."""
result = ops._check_lint("/tmp/ok.py", content="x = 1\n")
assert result.success is True
assert not result.skipped
assert result.output == ""
def test_python_inproc_syntax_error(self, ops):
"""Invalid Python content fails with SyntaxError + line info."""
result = ops._check_lint("/tmp/bad.py", content="def foo(:\n pass\n")
assert result.success is False
assert "SyntaxError" in result.output
assert "line" in result.output.lower()
def test_python_inproc_content_explicit(self, ops):
"""When content is passed explicitly, the file is not re-read."""
with patch.object(ops, "_exec") as mock_exec:
result = ops._check_lint("/tmp/explicit.py", content="y = 2\n")
# _exec must not have been called — content was supplied
mock_exec.assert_not_called()
assert result.success is True
def test_json_inproc_clean(self, ops):
result = ops._check_lint("/tmp/a.json", content='{"a": 1}')
assert result.success is True
def test_json_inproc_error(self, ops):
result = ops._check_lint("/tmp/b.json", content='{"a": 1')
assert result.success is False
assert "JSONDecodeError" in result.output
def test_yaml_inproc_clean(self, ops):
result = ops._check_lint("/tmp/a.yaml", content="a: 1\nb: 2\n")
assert result.success is True
def test_yaml_inproc_error(self, ops):
result = ops._check_lint("/tmp/b.yaml", content='key: "unclosed\n')
assert result.success is False
assert "YAMLError" in result.output
def test_toml_inproc_clean(self, ops):
result = ops._check_lint("/tmp/a.toml", content='[section]\nk = "v"\n')
assert result.success is True
def test_toml_inproc_error(self, ops):
result = ops._check_lint("/tmp/b.toml", content='[section\nk = "v"')
assert result.success is False
assert "TOMLDecodeError" in result.output
class TestCheckLintDelta:
"""Verify _check_lint_delta() filters pre-existing errors from post-edit output."""
@pytest.fixture()
def ops(self):
obj = ShellFileOperations.__new__(ShellFileOperations)
obj._command_cache = {}
return obj
def test_clean_post_no_pre_lint(self, ops):
"""Hot path: post-write is clean, pre-lint should be skipped entirely."""
with patch.object(ops, "_check_lint", wraps=ops._check_lint) as wrapped:
r = ops._check_lint_delta("/tmp/a.py", pre_content="x = 0\n", post_content="x = 1\n")
# Post-lint called exactly once (clean), pre-lint never called.
assert wrapped.call_count == 1
assert r.success is True
def test_new_file_reports_all_errors(self, ops):
"""No pre-content means no delta refinement — all post errors surface."""
r = ops._check_lint_delta("/tmp/new.py", pre_content=None, post_content="def x(:\n")
assert r.success is False
assert "SyntaxError" in r.output
def test_broken_file_becomes_good(self, ops):
"""Post-clean short-circuits without any delta refinement."""
r = ops._check_lint_delta("/tmp/fix.py", pre_content="def x(:\n", post_content="def x():\n pass\n")
assert r.success is True
def test_introduces_new_error_filters_pre(self, ops):
"""Delta filter drops pre-existing errors, surfaces only new ones."""
pre = 'def a(:\n pass\n' # line 1 broken
post = 'def a():\n pass\n\ndef b(:\n pass\n' # line 1 fixed, line 4 broken
r = ops._check_lint_delta("/tmp/d.py", pre_content=pre, post_content=post)
assert r.success is False
assert "New lint errors" in r.output or "line 4" in r.output
def test_pre_existing_remains_flagged_but_not_new(self, ops):
"""Single-error parsers (ast) may miss that post is OK — be cautious."""
# Pre has line-1 error, post keeps it (and doesn't add anything new)
pre = 'def a(:\n pass\n'
post = 'def a(:\n pass\n\nprint(42)\n' # still line 1 broken
r = ops._check_lint_delta("/tmp/d.py", pre_content=pre, post_content=post)
# File is still broken — don't lie and claim success — but flag it as pre-existing
assert r.success is False
assert "pre-existing" in (r.message or "").lower()
# =========================================================================
# Pagination bounds
# =========================================================================

View file

@ -9,7 +9,7 @@ from __future__ import annotations
import copy
from tools.schema_sanitizer import sanitize_tool_schemas
from tools.schema_sanitizer import sanitize_tool_schemas, strip_pattern_and_format
def _tool(name: str, parameters: dict) -> dict:
@ -203,3 +203,102 @@ def test_empty_tools_list_returns_empty():
def test_none_tools_returns_none():
assert sanitize_tool_schemas(None) is None
# ─────────────────────────────────────────────────────────────────────────
# strip_pattern_and_format — reactive recovery when llama.cpp rejects a
# schema with an HTTP 400 grammar-parse error. Must be opt-in (only
# invoked on recovery) and must not damage property names.
# ─────────────────────────────────────────────────────────────────────────
def test_strip_pattern_removes_schema_pattern_keyword():
"""`pattern` as a sibling of `type` → stripped."""
tools = [_tool("t", {
"type": "object",
"properties": {
"date": {"type": "string", "pattern": "\\d{4,4}-\\d{2,2}-\\d{2,2}"},
},
})]
_, stripped = strip_pattern_and_format(tools)
assert stripped == 1
prop = tools[0]["function"]["parameters"]["properties"]["date"]
assert "pattern" not in prop
assert prop["type"] == "string"
def test_strip_format_removes_schema_format_keyword():
"""`format` as a sibling of `type` → stripped."""
tools = [_tool("t", {
"type": "object",
"properties": {
"ts": {"type": "string", "format": "date-time"},
},
})]
_, stripped = strip_pattern_and_format(tools)
assert stripped == 1
assert "format" not in tools[0]["function"]["parameters"]["properties"]["ts"]
def test_strip_preserves_property_named_pattern():
"""Property literally *named* 'pattern' (search_files) must survive."""
tools = [_tool("search_files", {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regex pattern..."},
"limit": {"type": "integer"},
},
"required": ["pattern"],
})]
_, stripped = strip_pattern_and_format(tools)
assert stripped == 0
params = tools[0]["function"]["parameters"]
# Property named "pattern" still exists with its schema intact
assert "pattern" in params["properties"]
assert params["properties"]["pattern"]["type"] == "string"
assert params["required"] == ["pattern"]
def test_strip_recurses_into_anyof_variants():
"""Pattern/format inside anyOf variant schemas are also stripped."""
tools = [_tool("t", {
"type": "object",
"properties": {
"value": {
"anyOf": [
{"type": "string", "pattern": "[A-Z]+", "format": "uuid"},
{"type": "integer"},
],
},
},
})]
_, stripped = strip_pattern_and_format(tools)
assert stripped == 2
variants = tools[0]["function"]["parameters"]["properties"]["value"]["anyOf"]
assert "pattern" not in variants[0]
assert "format" not in variants[0]
assert variants[0]["type"] == "string"
def test_strip_is_idempotent():
"""Second call on already-stripped tools is a no-op."""
tools = [_tool("t", {
"type": "object",
"properties": {"d": {"type": "string", "pattern": "\\d+"}},
})]
_, first = strip_pattern_and_format(tools)
_, second = strip_pattern_and_format(tools)
assert first == 1
assert second == 0
def test_strip_empty_tools_returns_zero():
tools, stripped = strip_pattern_and_format([])
assert tools == []
assert stripped == 0
def test_strip_none_returns_zero():
tools, stripped = strip_pattern_and_format(None)
assert tools is None
assert stripped == 0

View file

@ -838,12 +838,13 @@ class TestExternalSkillMutations:
# ---------------------------------------------------------------------------
# Pinned-skill guard — skill_manage refuses all writes to pinned skills.
# The user unpins via `hermes curator unpin <name>`.
# Pinned-skill guard — skill_manage refuses only `delete` on pinned skills.
# Patches and edits go through so pinned skills can still evolve as pitfalls
# come up. The user unpins via `hermes curator unpin <name>` to delete.
# ---------------------------------------------------------------------------
class TestPinnedGuard:
"""Every mutation action must refuse when the skill is pinned."""
"""Delete is refused on pinned skills; patch/edit/write_file/remove_file are allowed."""
@staticmethod
def _pin(name: str):
@ -852,31 +853,28 @@ class TestPinnedGuard:
return {"pinned": True} if skill_name == _name else {"pinned": False}
return patch("tools.skill_usage.get_record", side_effect=_fake_get_record)
def test_edit_refuses_pinned(self, tmp_path):
def test_edit_allowed_when_pinned(self, tmp_path):
"""Pin does NOT block edit — agent can still improve pinned skills."""
with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT)
with self._pin("my-skill"):
result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2)
assert result["success"] is False
assert "pinned" in result["error"].lower()
assert "hermes curator unpin my-skill" in result["error"]
# Original content preserved
assert result["success"] is True, result
# Content updated
content = (tmp_path / "my-skill" / "SKILL.md").read_text()
assert "A test skill" in content
assert "A test skill" not in content
def test_patch_refuses_pinned(self, tmp_path):
def test_patch_allowed_when_pinned(self, tmp_path):
with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT)
with self._pin("my-skill"):
result = _patch_skill("my-skill", "Do the thing.", "Do the new thing.")
assert result["success"] is False
assert "pinned" in result["error"].lower()
assert "hermes curator unpin my-skill" in result["error"]
assert result["success"] is True, result
content = (tmp_path / "my-skill" / "SKILL.md").read_text()
assert "Do the thing." in content # unchanged
assert "Do the new thing." in content
def test_patch_supporting_file_refuses_pinned(self, tmp_path):
"""Pin covers supporting files too, not just SKILL.md."""
def test_patch_supporting_file_allowed_when_pinned(self, tmp_path):
"""Supporting-file patches also go through on pinned skills."""
with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT)
_write_file("my-skill", "references/api.md", "original")
@ -885,57 +883,56 @@ class TestPinnedGuard:
"my-skill", "original", "modified",
file_path="references/api.md",
)
assert result["success"] is False
assert "pinned" in result["error"].lower()
assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "original"
assert result["success"] is True, result
assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "modified"
def test_delete_refuses_pinned(self, tmp_path):
"""Delete is the one action pin still blocks — it's the irrecoverable one."""
with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT)
with self._pin("my-skill"):
result = _delete_skill("my-skill")
assert result["success"] is False
assert "pinned" in result["error"].lower()
assert "cannot be deleted" in result["error"]
assert "hermes curator unpin my-skill" in result["error"]
# Skill still exists
assert (tmp_path / "my-skill" / "SKILL.md").exists()
def test_write_file_refuses_pinned(self, tmp_path):
def test_write_file_allowed_when_pinned(self, tmp_path):
with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT)
with self._pin("my-skill"):
result = _write_file("my-skill", "references/api.md", "content")
assert result["success"] is False
assert "pinned" in result["error"].lower()
assert not (tmp_path / "my-skill" / "references" / "api.md").exists()
assert result["success"] is True, result
assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "content"
def test_remove_file_refuses_pinned(self, tmp_path):
def test_remove_file_allowed_when_pinned(self, tmp_path):
with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT)
_write_file("my-skill", "references/api.md", "content")
with self._pin("my-skill"):
result = _remove_file("my-skill", "references/api.md")
assert result["success"] is False
assert "pinned" in result["error"].lower()
# File still there
assert (tmp_path / "my-skill" / "references" / "api.md").exists()
assert result["success"] is True, result
assert not (tmp_path / "my-skill" / "references" / "api.md").exists()
def test_unpinned_skills_still_editable(self, tmp_path):
"""Sanity check: the guard doesn't fire for unpinned skills.
"""Sanity check: the guard doesn't fire for unpinned skills on delete.
Only the specifically-pinned skill is refused; a sibling skill must
still be freely editable.
Only the specifically-pinned skill is refused from delete; a sibling
skill must still be freely deletable.
"""
with _skill_dir(tmp_path):
_create_skill("pinned-one", VALID_SKILL_CONTENT)
_create_skill("free-one", VALID_SKILL_CONTENT)
with self._pin("pinned-one"):
blocked = _edit_skill("pinned-one", VALID_SKILL_CONTENT_2)
allowed = _edit_skill("free-one", VALID_SKILL_CONTENT_2)
blocked = _delete_skill("pinned-one")
allowed = _delete_skill("free-one")
assert blocked["success"] is False
assert allowed["success"] is True
def test_broken_sidecar_fails_open(self, tmp_path):
"""If skill_usage.get_record raises, we allow the write through.
"""If skill_usage.get_record raises, we allow delete through.
Rationale: a corrupted telemetry file shouldn't lock the agent out
of skills it would otherwise be allowed to touch.
@ -944,5 +941,5 @@ class TestPinnedGuard:
_create_skill("my-skill", VALID_SKILL_CONTENT)
with patch("tools.skill_usage.get_record",
side_effect=RuntimeError("sidecar broken")):
result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2)
result = _delete_skill("my-skill")
assert result["success"] is True

View file

@ -225,6 +225,52 @@ def test_agent_created_excludes_hub_installed(skills_home):
assert "hub-skill" not in names
def test_agent_created_excludes_hub_installed_frontmatter_name(skills_home):
from tools.skill_usage import (
is_agent_created,
list_agent_created_skill_names,
mark_agent_created,
)
skills_dir = skills_home / "skills"
hub_skill = skills_dir / "productivity" / "getnote"
hub_skill.mkdir(parents=True)
(hub_skill / "SKILL.md").write_text(
"""---
name: Get笔记
description: test skill
---
# body
""",
encoding="utf-8",
)
_write_skill(skills_dir, "my-skill")
mark_agent_created("my-skill")
hub_dir = skills_dir / ".hub"
hub_dir.mkdir()
(hub_dir / "lock.json").write_text(
json.dumps(
{
"version": 1,
"installed": {
"getnote": {
"source": "taps/main",
"install_path": "productivity/getnote",
}
},
}
),
encoding="utf-8",
)
names = list_agent_created_skill_names()
assert "my-skill" in names
assert "Get笔记" not in names
assert is_agent_created("Get笔记") is False
assert is_agent_created("getnote") is False
def test_is_agent_created(skills_home):
from tools.skill_usage import is_agent_created
skills_dir = skills_home / "skills"

View file

@ -628,15 +628,18 @@ def prompt_dangerous_approval(command: str, description: str,
os.environ["HERMES_SPINNER_PAUSE"] = "1"
try:
# Resolve the active UI language once per prompt so we don't re-read
# config/YAML inside the retry loop below.
from agent.i18n import t
while True:
print()
print(f" ⚠️ DANGEROUS COMMAND: {description}")
print(f" {t('approval.dangerous_header', description=description)}")
print(f" {command}")
print()
if allow_permanent:
print(" [o]nce | [s]ession | [a]lways | [d]eny")
print(t("approval.choose_long"))
else:
print(" [o]nce | [s]ession | [d]eny")
print(t("approval.choose_short"))
print()
sys.stdout.flush()
@ -644,7 +647,7 @@ def prompt_dangerous_approval(command: str, description: str,
def get_input():
try:
prompt = " Choice [o/s/a/D]: " if allow_permanent else " Choice [o/s/D]: "
prompt = t("approval.prompt_long") if allow_permanent else t("approval.prompt_short")
result["choice"] = input(prompt).strip().lower()
except (EOFError, OSError):
result["choice"] = ""
@ -654,28 +657,28 @@ def prompt_dangerous_approval(command: str, description: str,
thread.join(timeout=timeout_seconds)
if thread.is_alive():
print("\n ⏱ Timeout - denying command")
print("\n" + t("approval.timeout"))
return "deny"
choice = result["choice"]
if choice in ('o', 'once'):
print(" ✓ Allowed once")
print(t("approval.allowed_once"))
return "once"
elif choice in ('s', 'session'):
print(" ✓ Allowed for this session")
print(t("approval.allowed_session"))
return "session"
elif choice in ('a', 'always'):
if not allow_permanent:
print(" ✓ Allowed for this session")
print(t("approval.allowed_session"))
return "session"
print(" ✓ Added to permanent allowlist")
print(t("approval.allowed_always"))
return "always"
else:
print(" ✗ Denied")
print(t("approval.denied"))
return "deny"
except (EOFError, KeyboardInterrupt):
print("\n ✗ Cancelled")
print("\n" + t("approval.cancelled"))
return "deny"
finally:
if "HERMES_SPINNER_PAUSE" in os.environ:

View file

@ -27,6 +27,10 @@ def _ensure_ssh_available() -> None:
raise RuntimeError(
"SSH is not installed or not in PATH. Install OpenSSH client: apt install openssh-client"
)
if not shutil.which("scp"):
raise RuntimeError(
"SCP is not installed or not in PATH. Install OpenSSH client: apt install openssh-client"
)
class SSHEnvironment(BaseEnvironment):

View file

@ -119,9 +119,10 @@ class WriteResult:
"""Result from writing a file."""
bytes_written: int = 0
dirs_created: bool = False
lint: Optional[Dict[str, Any]] = None
error: Optional[str] = None
warning: Optional[str] = None
def to_dict(self) -> dict:
return {k: v for k, v in self.__dict__.items() if v is not None}
@ -202,10 +203,10 @@ class LintResult:
def to_dict(self) -> dict:
if self.skipped:
return {"status": "skipped", "message": self.message}
return {
"status": "ok" if self.success else "error",
"output": self.output
}
result = {"status": "ok" if self.success else "error", "output": self.output}
if self.message:
result["message"] = self.message
return result
@dataclass
@ -303,7 +304,9 @@ class FileOperations(ABC):
# Image extensions (subset of binary that we can return as base64)
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico'}
# Linters by file extension
# Shell-based linters by file extension. Invoked via _exec() with the
# filesystem path. Cover languages where a compile/type check needs an
# external toolchain (py_compile, node, tsc, go vet, rustfmt).
LINTERS = {
'.py': 'python -m py_compile {file} 2>&1',
'.js': 'node --check {file} 2>&1',
@ -312,6 +315,86 @@ LINTERS = {
'.rs': 'rustfmt --check {file} 2>&1',
}
def _lint_json_inproc(content: str) -> tuple[bool, str]:
"""In-process JSON syntax check. Returns (ok, error_message)."""
import json as _json
try:
_json.loads(content)
return True, ""
except _json.JSONDecodeError as e:
return False, f"JSONDecodeError: {e.msg} (line {e.lineno}, column {e.colno})"
except Exception as e: # noqa: BLE001 — any parse failure is a lint failure
return False, f"{type(e).__name__}: {e}"
def _lint_yaml_inproc(content: str) -> tuple[bool, str]:
"""In-process YAML syntax check. Returns (ok, error_message).
Skipped gracefully if PyYAML isn't installed — YAML parsing is optional.
"""
try:
import yaml as _yaml
except ImportError:
# PyYAML not available — skip silently, caller treats as no linter.
return True, "__SKIP__"
try:
_yaml.safe_load(content)
return True, ""
except _yaml.YAMLError as e:
return False, f"YAMLError: {e}"
except Exception as e: # noqa: BLE001
return False, f"{type(e).__name__}: {e}"
def _lint_toml_inproc(content: str) -> tuple[bool, str]:
"""In-process TOML syntax check (stdlib tomllib, Python 3.11+)."""
try:
import tomllib as _toml
except ImportError:
# Pre-3.11 fallback via tomli, if installed.
try:
import tomli as _toml # type: ignore[no-redef]
except ImportError:
return True, "__SKIP__"
try:
_toml.loads(content)
return True, ""
except Exception as e: # tomllib raises TOMLDecodeError, a ValueError subclass
return False, f"{type(e).__name__}: {e}"
def _lint_python_inproc(content: str) -> tuple[bool, str]:
"""In-process Python syntax check via ast.parse.
Catches SyntaxError, IndentationError, and everything else the
ast module rejects matching py_compile's scope but with no
subprocess overhead and no dependency on a ``python`` in PATH.
"""
import ast as _ast
try:
_ast.parse(content)
return True, ""
except SyntaxError as e:
loc = f" (line {e.lineno}, column {e.offset})" if e.lineno else ""
return False, f"{type(e).__name__}: {e.msg}{loc}"
except Exception as e: # noqa: BLE001
return False, f"{type(e).__name__}: {e}"
# In-process linters by file extension. Preferred over shell linters when
# present — no subprocess overhead, microseconds per call. Each callable
# takes file content (str) and returns (ok: bool, error: str). An error
# string of ``"__SKIP__"`` signals the linter isn't available (missing
# dependency) and should be treated as "no linter".
LINTERS_INPROC = {
'.py': _lint_python_inproc,
'.json': _lint_json_inproc,
'.yaml': _lint_yaml_inproc,
'.yml': _lint_yaml_inproc,
'.toml': _lint_toml_inproc,
}
# Max limits for read operations
MAX_LINES = 2000
MAX_LINE_LENGTH = 2000
@ -745,12 +828,19 @@ class ShellFileOperations(FileOperations):
files. The content never appears in the shell command string
only the file path does.
After the write, runs a post-first / pre-lazy lint check via
``_check_lint_delta()``. If the new content is clean, the lint
call is O(one parse). If the new content has errors, the pre-write
content is linted too and only errors newly introduced by this
write are surfaced pre-existing problems are filtered out so
the agent isn't distracted chasing them.
Args:
path: File path to write
content: Content to write
Returns:
WriteResult with bytes written or error
WriteResult with bytes written, lint summary, or error.
"""
# Expand ~ and other shell paths
path = self._expand_path(path)
@ -759,36 +849,58 @@ class ShellFileOperations(FileOperations):
if _is_write_denied(path):
return WriteResult(error=f"Write denied: '{path}' is a protected system/credential file.")
# Capture pre-write content for lint-delta computation. Only do this
# when an in-process OR shell linter exists for this extension — no
# point paying for the read otherwise. For in-process linters we
# pass the content directly; for shell linters the pre-state isn't
# useful (we'd have to re-write-read to lint the old version, which
# defeats the purpose), so we skip the capture and accept the naive
# "all errors" report.
ext = os.path.splitext(path)[1].lower()
pre_content: Optional[str] = None
if ext in LINTERS_INPROC:
# Best-effort read; failure (file missing, permission) leaves
# pre_content as None which makes the delta step degrade
# gracefully to "report all errors".
read_cmd = f"cat {self._escape_shell_arg(path)} 2>/dev/null"
read_result = self._exec(read_cmd)
if read_result.exit_code == 0 and read_result.stdout:
pre_content = read_result.stdout
# Create parent directories
parent = os.path.dirname(path)
dirs_created = False
if parent:
mkdir_cmd = f"mkdir -p {self._escape_shell_arg(parent)}"
mkdir_result = self._exec(mkdir_cmd)
if mkdir_result.exit_code == 0:
dirs_created = True
# Write via stdin pipe — content bypasses shell arg parsing entirely,
# so there's no ARG_MAX limit regardless of file size.
write_cmd = f"cat > {self._escape_shell_arg(path)}"
write_result = self._exec(write_cmd, stdin_data=content)
if write_result.exit_code != 0:
return WriteResult(error=f"Failed to write file: {write_result.stdout}")
# Get bytes written (wc -c is POSIX, works on Linux + macOS)
stat_cmd = f"wc -c < {self._escape_shell_arg(path)} 2>/dev/null"
stat_result = self._exec(stat_cmd)
try:
bytes_written = int(stat_result.stdout.strip())
except ValueError:
bytes_written = len(content.encode('utf-8'))
# Post-write lint with delta refinement.
lint_result = self._check_lint_delta(path, pre_content=pre_content, post_content=content)
return WriteResult(
bytes_written=bytes_written,
dirs_created=dirs_created
dirs_created=dirs_created,
lint=lint_result.to_dict() if lint_result else None,
)
# =========================================================================
@ -864,10 +976,12 @@ class ShellFileOperations(FileOperations):
# Generate diff
diff = self._unified_diff(content, new_content, path)
# Auto-lint
lint_result = self._check_lint(path)
# Auto-lint with delta refinement: only surface errors introduced
# by this patch, filtering out pre-existing lint failures so the
# agent isn't distracted by problems that were already there.
lint_result = self._check_lint_delta(path, pre_content=content, post_content=new_content)
return PatchResult(
success=True,
diff=diff,
@ -905,37 +1019,143 @@ class ShellFileOperations(FileOperations):
result = apply_v4a_operations(operations, self)
return result
def _check_lint(self, path: str) -> LintResult:
def _check_lint(self, path: str, content: Optional[str] = None) -> LintResult:
"""
Run syntax check on a file after editing.
Prefers the in-process linter for structured formats (JSON, YAML,
TOML) when possible those parse via the Python stdlib in
microseconds and don't require a subprocess. Falls back to the
shell linter table for compiled/type-checked languages
(py_compile, node --check, tsc, go vet, rustfmt).
Args:
path: File path to lint
path: File path (used to select the linter + for shell invocation).
content: Optional file content. If provided AND an in-process
linter matches the extension, we lint the content
directly without re-reading the file from disk. Ignored
for shell linters.
Returns:
LintResult with status and any errors
LintResult with status and any errors.
"""
ext = os.path.splitext(path)[1].lower()
# Prefer in-process linter when available.
inproc = LINTERS_INPROC.get(ext)
if inproc is not None:
# Need content — either passed in or read from disk.
if content is None:
read_cmd = f"cat {self._escape_shell_arg(path)} 2>/dev/null"
read_result = self._exec(read_cmd)
if read_result.exit_code != 0:
return LintResult(skipped=True, message=f"Failed to read {path} for lint")
content = read_result.stdout
ok, err = inproc(content)
if err == "__SKIP__":
return LintResult(skipped=True, message=f"No linter available for {ext} (missing dependency)")
return LintResult(success=ok, output="" if ok else err)
# Fall back to shell linter.
if ext not in LINTERS:
return LintResult(skipped=True, message=f"No linter for {ext} files")
# Check if linter command is available
linter_cmd = LINTERS[ext]
# Extract the base command (first word)
base_cmd = linter_cmd.split()[0]
if not self._has_command(base_cmd):
return LintResult(skipped=True, message=f"{base_cmd} not available")
# Run linter
cmd = linter_cmd.replace("{file}", self._escape_shell_arg(path))
result = self._exec(cmd, timeout=30)
return LintResult(
success=result.exit_code == 0,
output=result.stdout.strip() if result.stdout.strip() else ""
)
def _check_lint_delta(self, path: str, pre_content: Optional[str],
post_content: Optional[str] = None) -> LintResult:
"""
Run post-write lint with pre-write baseline comparison.
Strategy (post-first, pre-lazy):
1. Lint the post-write state. If clean return clean immediately.
This is the hot path and matches _check_lint() in cost.
2. If post-lint found errors AND we have pre-write content, lint
that too. If the pre-write file was already broken, return only
the *new* errors introduced by this edit errors that existed
before aren't the agent's problem to chase right now.
3. If pre_content is None (new file or unavailable), skip the delta
step and return all post-write errors.
This mirrors Cline's and OpenCode's post-edit LSP pattern: surface
only the errors this specific edit introduced, so the agent doesn't
get distracted by pre-existing problems.
Args:
path: File path (for linter selection).
pre_content: File content BEFORE the write. Pass None for new
files or when the pre-state isn't available — the
delta refinement is skipped and all post errors
are returned.
post_content: File content AFTER the write. Optional; if None,
the shell linter reads from disk (same as
_check_lint).
Returns:
LintResult. ``output`` contains either the full post-lint
errors (no pre-state) or just the new-error lines (delta
refinement applied).
"""
post = self._check_lint(path, content=post_content)
# Hot path: clean post-write, no pre-lint needed.
if post.success or post.skipped:
return post
# Post-write has errors. If we have pre-content, run the delta
# refinement to filter out pre-existing errors.
if pre_content is None:
return post
pre = self._check_lint(path, content=pre_content)
if pre.success or pre.skipped or not pre.output:
# Pre-write was clean (or we couldn't lint it) — post errors
# are all new. Return the full post output.
return post
# Both pre- and post-write had errors. Compute the set-difference
# on non-empty stripped lines. Caveat: single-error parsers
# (ast.parse, json.loads) stop at the first error and don't report
# later ones — if the pre-existing error blocks parsing before
# reaching the edit region, we can't prove the edit is clean. So
# if every post error also appeared pre-edit, we report the file
# as still broken but annotate that this edit introduced nothing
# new on top — the agent knows it's inherited state, not fresh
# damage, without silently dropping the error.
pre_lines = {ln.strip() for ln in pre.output.splitlines() if ln.strip()}
post_lines = [ln for ln in post.output.splitlines() if ln.strip() and ln.strip() not in pre_lines]
if not post_lines:
# Every error in post was also in pre — this edit didn't make
# anything obviously worse, but the file remains broken and
# the agent should know.
return LintResult(
success=False,
output=post.output,
message="Pre-existing lint errors — this edit didn't introduce new ones but the file is still broken.",
)
return LintResult(
success=False,
output=(
"New lint errors introduced by this edit "
"(pre-existing errors filtered out):\n" + "\n".join(post_lines)
)
)
# =========================================================================
# SEARCH Implementation

View file

@ -1042,7 +1042,7 @@ READ_FILE_SCHEMA = {
WRITE_FILE_SCHEMA = {
"name": "write_file",
"description": "Write content to a file, completely replacing existing content. Use this instead of echo/cat heredoc in terminal. Creates parent directories automatically. OVERWRITES the entire file — use 'patch' for targeted edits.",
"description": "Write content to a file, completely replacing existing content. Use this instead of echo/cat heredoc in terminal. Creates parent directories automatically. OVERWRITES the entire file — use 'patch' for targeted edits. Auto-runs syntax checks on .py/.json/.yaml/.toml and other linted languages; only NEW errors introduced by this write are surfaced (pre-existing errors are filtered out).",
"parameters": {
"type": "object",
"properties": {

View file

@ -210,6 +210,20 @@ def _handle_complete(args: dict, **kw) -> str:
summary = args.get("summary")
metadata = args.get("metadata")
result = args.get("result")
created_cards = args.get("created_cards")
if created_cards is not None:
if isinstance(created_cards, str):
# Accept a single id as a string for convenience.
created_cards = [created_cards]
if not isinstance(created_cards, (list, tuple)):
return tool_error(
f"created_cards must be a list of task ids, got "
f"{type(created_cards).__name__}"
)
# Normalise: strings only, stripped, non-empty.
created_cards = [
str(c).strip() for c in created_cards if str(c).strip()
]
if not (summary or result):
return tool_error(
"provide at least one of: summary (preferred), result"
@ -221,10 +235,23 @@ def _handle_complete(args: dict, **kw) -> str:
try:
kb, conn = _connect()
try:
ok = kb.complete_task(
conn, tid,
result=result, summary=summary, metadata=metadata,
)
try:
ok = kb.complete_task(
conn, tid,
result=result, summary=summary, metadata=metadata,
created_cards=created_cards,
)
except kb.HallucinatedCardsError as hall_err:
# Structured rejection — surface the phantom ids so the
# worker can retry with a corrected list or drop the
# field. Audit event already landed in the DB.
return tool_error(
f"kanban_complete blocked: the following created_cards "
f"do not exist or were not created by this worker: "
f"{', '.join(hall_err.phantom)}. "
f"Either omit them, use only ids returned from successful "
f"kanban_create calls, or remove the created_cards field."
)
if not ok:
return tool_error(
f"could not complete {tid} (unknown id or already terminal)"
@ -452,7 +479,11 @@ KANBAN_COMPLETE_SCHEMA = {
"human-readable 1-3 sentence description of what you did; put "
"machine-readable facts in ``metadata`` (changed_files, "
"tests_run, decisions, findings, etc). At least one of "
"``summary`` or ``result`` is required."
"``summary`` or ``result`` is required. If you created new "
"tasks via ``kanban_create`` during this run, list their ids "
"in ``created_cards`` — the kernel verifies them so phantom "
"references are caught before they leak into downstream "
"automation."
),
"parameters": {
"type": "object",
@ -487,6 +518,22 @@ KANBAN_COMPLETE_SCHEMA = {
"callers that still set --result on the CLI."
),
},
"created_cards": {
"type": "array",
"items": {"type": "string"},
"description": (
"Optional structured manifest of task ids you "
"created via ``kanban_create`` during this run. "
"The kernel verifies each id exists and was "
"created by this worker's profile; any phantom "
"id blocks the completion with an error listing "
"what went wrong (auditable in the task's events). "
"Only list ids you got back from a successful "
"``kanban_create`` call — do not invent or "
"remember ids from prose. Omit the field if you "
"did not create any cards."
),
},
},
"required": [],
},

View file

@ -1038,14 +1038,43 @@ class MCPServerTask:
with a fresh signal.
Shutdown takes precedence if both events are set simultaneously.
Periodically sends a lightweight keepalive (``list_tools``) to
prevent TCP connections from going stale during long idle
periods (#17003). If the keepalive fails, triggers a reconnect.
"""
# Keepalive interval in seconds. Must be shorter than typical
# LB / NAT idle-timeout (commonly 300-600s).
_KEEPALIVE_INTERVAL = 180 # 3 minutes
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
try:
await asyncio.wait(
{shutdown_task, reconnect_task},
return_when=asyncio.FIRST_COMPLETED,
)
while True:
done, _pending = await asyncio.wait(
{shutdown_task, reconnect_task},
timeout=_KEEPALIVE_INTERVAL,
return_when=asyncio.FIRST_COMPLETED,
)
if done:
break
# Timeout — no lifecycle event fired. Send a keepalive
# to exercise the connection and detect stale sockets.
if self.session:
try:
await asyncio.wait_for(
self.session.list_tools(),
timeout=30.0,
)
except Exception as exc:
logger.warning(
"MCP server '%s' keepalive failed, "
"triggering reconnect: %s",
self.name, exc,
)
self._reconnect_event.set()
break
finally:
for t in (shutdown_task, reconnect_task):
if not t.done():

View file

@ -255,3 +255,75 @@ def _sanitize_node(node: Any, path: str) -> Any:
out["required"] = valid
return out
# =============================================================================
# Reactive strip — only invoked when llama.cpp rejects a schema
# =============================================================================
_STRIP_ON_RECOVERY_KEYS = frozenset({"pattern", "format"})
def strip_pattern_and_format(tools: list[dict]) -> tuple[list[dict], int]:
"""Strip ``pattern`` and ``format`` JSON Schema keywords from tool schemas.
This is a *reactive* sanitizer invoked only when llama.cpp's
``json-schema-to-grammar`` converter has rejected a tool schema with an
HTTP 400 grammar-parse error. llama.cpp's regex engine supports only a
small subset of ECMAScript regex (literals, ``.``, ``[...]``, ``|``,
``*``, ``+``, ``?``, ``{n,m}``) it rejects escape classes like ``\\d``,
``\\w``, ``\\s`` and most ``format`` values. Cloud providers (OpenAI,
Anthropic, OpenRouter, Gemini) accept these keywords fine and rely on
them as prompting hints, so we keep them in the default schema and only
strip on demand.
The strip operates on a sibling of ``type`` (so schema keywords are
removed) a property literally *named* ``pattern`` (e.g. the first arg
of the built-in ``search_files`` tool) is not affected because property
names live in the ``properties`` dict, not as siblings of ``type``.
Args:
tools: OpenAI-format tool list, mutated in place for efficiency.
Callers that need to preserve the original should deep-copy first.
Returns:
``(tools, stripped_count)`` the same list reference plus a count of
how many ``pattern``/``format`` keywords were removed across all tools.
"""
if not tools:
return tools, 0
stripped = 0
def _walk(node: Any) -> None:
nonlocal stripped
if isinstance(node, dict):
# Only strip as a sibling of ``type`` — i.e. when this node is
# itself a schema. This avoids stripping literal property keys
# named "pattern" (search_files.pattern, etc.) because those live
# inside a ``properties`` dict, not as siblings of ``type``.
is_schema_node = "type" in node or "anyOf" in node or "oneOf" in node or "allOf" in node
for key in list(node.keys()):
if is_schema_node and key in _STRIP_ON_RECOVERY_KEYS:
node.pop(key, None)
stripped += 1
continue
_walk(node[key])
elif isinstance(node, list):
for item in node:
_walk(item)
for tool in tools:
fn = tool.get("function") if isinstance(tool, dict) else None
if isinstance(fn, dict):
params = fn.get("parameters")
if isinstance(params, dict):
_walk(params)
if stripped:
logger.info(
"schema_sanitizer: stripped %d pattern/format keyword(s) from "
"tool schemas (llama.cpp grammar-parse recovery)",
stripped,
)
return tools, stripped

View file

@ -137,14 +137,12 @@ def _containing_skills_root(skill_path: Path) -> Path:
def _pinned_guard(name: str) -> Optional[str]:
"""Return a refusal message if *name* is pinned, else None.
Pinned skills are off-limits to the agent's skill_manage tool. The only
way to modify one is for the user to unpin it via
``hermes curator unpin <name>`` (or edit it directly by hand). This
mirrors the curator's own pinned-skip behavior but extends the guard
to tool-driven writes as well, giving users a hard fence against
accidental agent edits.
Pin protects a skill from **deletion** both the curator's auto-archive
passes and the agent's ``skill_manage(action="delete")`` tool call. The
agent can still patch/edit pinned skills; pin only guards against
irrecoverable loss, not against content evolution.
Best-effort: if the sidecar is unreadable we let the write through
Best-effort: if the sidecar is unreadable we let the delete through
rather than block on a broken telemetry file.
"""
try:
@ -152,9 +150,11 @@ def _pinned_guard(name: str) -> Optional[str]:
rec = skill_usage.get_record(name)
if rec.get("pinned"):
return (
f"Skill '{name}' is pinned and cannot be modified by "
f"Skill '{name}' is pinned and cannot be deleted by "
f"skill_manage. Ask the user to run "
f"`hermes curator unpin {name}` if they want the change."
f"`hermes curator unpin {name}` if they want to delete it. "
f"Patches and edits are allowed on pinned skills; only "
f"deletion is blocked."
)
except Exception:
logger.debug("pinned-guard lookup failed for %s", name, exc_info=True)
@ -439,10 +439,6 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:
if not existing:
return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."}
pinned_err = _pinned_guard(name)
if pinned_err:
return {"success": False, "error": pinned_err}
skill_md = existing["path"] / "SKILL.md"
# Back up original content for rollback
original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None
@ -483,10 +479,6 @@ def _patch_skill(
if not existing:
return {"success": False, "error": f"Skill '{name}' not found."}
pinned_err = _pinned_guard(name)
if pinned_err:
return {"success": False, "error": pinned_err}
skill_dir = existing["path"]
if file_path:
@ -645,10 +637,6 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:
if not existing:
return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."}
pinned_err = _pinned_guard(name)
if pinned_err:
return {"success": False, "error": pinned_err}
target, err = _resolve_skill_target(existing["path"], file_path)
if err:
return {"success": False, "error": err}
@ -683,10 +671,6 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
if not existing:
return {"success": False, "error": f"Skill '{name}' not found."}
pinned_err = _pinned_guard(name)
if pinned_err:
return {"success": False, "error": pinned_err}
skill_dir = existing["path"]
target, err = _resolve_skill_target(skill_dir, file_path)
@ -835,9 +819,10 @@ SKILL_MANAGE_SCHEMA = {
"Skip for simple one-offs. Confirm with user before creating/deleting.\n\n"
"Good skills: trigger conditions, numbered steps with exact commands, "
"pitfalls section, verification steps. Use skill_view() to see format examples.\n\n"
"Pinned skills are off-limits — all write actions refuse with a message "
"pointing the user to `hermes curator unpin <name>`. Don't try to route "
"around this by renaming or recreating."
"Pinned skills are protected from deletion only — skill_manage(action='delete') "
"will refuse with a message pointing the user to `hermes curator unpin <name>`. "
"Patches and edits go through on pinned skills so you can still improve them as "
"pitfalls come up; pin only guards against irrecoverable loss."
),
"parameters": {
"type": "object",

View file

@ -143,7 +143,26 @@ def _read_hub_installed_names() -> Set[str]:
if isinstance(data, dict):
installed = data.get("installed") or {}
if isinstance(installed, dict):
return {str(k) for k in installed.keys()}
names = {str(k) for k in installed.keys()}
skills_dir = _skills_dir()
for entry in installed.values():
if not isinstance(entry, dict):
continue
install_path = entry.get("install_path")
if not isinstance(install_path, str) or not install_path.strip():
continue
skill_dir = Path(install_path)
if not skill_dir.is_absolute():
skill_dir = skills_dir / skill_dir
try:
resolved = skill_dir.resolve()
resolved.relative_to(skills_dir.resolve())
except (OSError, ValueError):
continue
skill_md = resolved / "SKILL.md"
if skill_md.exists():
names.add(_read_skill_name(skill_md, fallback=resolved.name))
return names
except (OSError, json.JSONDecodeError) as e:
logger.debug("Failed to read hub lock file: %s", e)
return set()

View file

@ -868,6 +868,7 @@ def skill_view(
JSON string with skill content or error message
"""
try:
local_category_name: str | None = None
# ── Qualified name dispatch (plugin skills) ──────────────────
# Names containing ':' are routed to the plugin skill registry.
# Bare names fall through to the existing flat-tree scan below.
@ -928,8 +929,12 @@ def skill_view(
},
ensure_ascii=False,
)
# Plugin itself not found — fall through to flat-tree scan
# which will return a normal "not found" with suggestions.
# Plugin itself not found — fall through to flat-tree scan.
# Categorized local skills also use `category:skill` in config and
# gateway prompts, so preserve that form and translate it to the
# on-disk `category/skill` path during the local scan below.
if bare:
local_category_name = f"{namespace}/{bare}"
from agent.skill_utils import get_external_skills_dirs
@ -962,6 +967,15 @@ def skill_view(
elif direct_path.with_suffix(".md").exists():
skill_md = direct_path.with_suffix(".md")
break
if local_category_name:
categorized_path = search_dir / local_category_name
if categorized_path.is_dir() and (categorized_path / "SKILL.md").exists():
skill_dir = categorized_path
skill_md = categorized_path / "SKILL.md"
break
elif categorized_path.with_suffix(".md").exists():
skill_md = categorized_path.with_suffix(".md")
break
# Search by directory name across all dirs
if not skill_md:

Some files were not shown because too many files have changed in this diff Show more