Instructions for AI coding assistants and developers working on the hermes-agent codebase.
Never give up on the right solution.
Hermes is a personal AI agent that runs the same agent core across a CLI, a messaging gateway (Telegram, Discord, Slack, and ~20 other platforms), a TUI, and an Electron desktop app. It learns across sessions (memory + skills), delegates to subagents, runs scheduled jobs, and drives a real terminal and browser. It is extended primarily through plugins and skills, not by growing the core.
Two properties shape almost every design decision and are the lens for reviewing any change:
This is the project's intent layer. Use it two ways:
implemented_on_main,
cannot_reproduce, incoherent) and, just as important, when NOT to
close one. Taste-based "we don't want this / out of scope" closes are NOT
an automated decision — those stay with a human maintainer. The sweeper's
job here is to recognize design intent and avoid wrongly closing a
legitimate contribution, not to make the won't-implement call itself.Read the balance right: Hermes ships a lot — most merges are bug fixes to real reported behavior, and the product surface (platforms, channels, providers, models, desktop/TUI features) expands aggressively and on purpose. The restraint below is aimed squarely at the core agent + the model tool schema, the one place where every addition is paid for on every API call. "Smallest footprint" governs how a capability is wired into the core, NOT whether the product is allowed to grow. We are expansive at the edges and conservative at the waist.
fix(...) against an
actual reported symptom. A good fix reproduces the symptom on current
main, points to the exact line where it manifests, and fixes the whole bug
class — sibling call paths included — not just the one site the reporter hit.hermes tools, hermes setup, auto-install) rather than bolting on a raw
env var.cli.py / run_agent.py / gateway/run.py into a focused
mixin or module is wanted work, even when the diff is huge and mechanical
(large +N/-N refactors merge regularly). The "every line traces to the
request" test applies to feature PRs; a declared refactor's request IS the
extraction.check_fn) → plugin → MCP server
in the catalog → new core tool (last resort). See "The Footprint Ladder."HERMES_HOME. Mocks hide integration bugs.HERMES_* env vars for non-secret config. .env is for secrets
only (API keys, tokens, passwords). All behavioral settings — timeouts,
thresholds, feature flags, display prefs — go in config.yaml. Bridge to an
internal env var if the mechanism needs one, but user-facing docs point to
config.yaml. Reject PRs that tell users to "set X in your .env" unless X
is a credential.offset/limit
pagination on tools that load content the agent must read fully (skills,
prompts, playbooks). Models will read page 1 and skip the rest.git log -p -S) before restricting behavior; find a fix that preserves the
feature.hermes tools
toggle) exists. Park behind a label, do not merge.plugins/ in
this repo. They place an ongoing maintenance burden on us to keep them working
against a fast-moving core, for a backend we don't own. Ship them as a
standalone plugin repo users install into ~/.hermes/plugins/ (or via a
pip entry point), and promote them in the Nous Research Discord
(#plugins-skills-and-skins). This is a coupling-and-maintenance decision, not
a quality bar — the plugin can be excellent and still be a close. PRs that add
such a directory to the tree are closed with a pointer to publish it as its own
repo.The most common reason a well-written PR gets closed is not code quality — it
is that the change is built on a wrong premise, or it treats an
intentional design as a gap. These patterns cut both ways: they tell a
human reviewer what to scrutinize, and they tell the automated sweeper when a
PR is NOT safe to close as implemented_on_main / cannot_reproduce (when in
doubt, leave it open for a human). They are distilled from real closes.
--clone path already covers the
legitimate "start from my default" case). Read the original commit's intent
(git log -p -S "<symbol>") before assuming something is unfinished.__init__.py files made a test tree importable
as a dotted package that shadowed the real plugin, deleting its register()
at import time. The absence was load-bearing.The throughline: verify the claim AND the intent against the codebase before
writing or merging a fix. A confirmed reproduction on current main plus a
line-level account of where the fix acts beats a plausible-sounding rationale
every time. When in doubt about intent, it is cheaper to ask than to ship a
fix that fights the design.
Each rung adds more permanent surface than the one above. Choose the highest (least-footprint) rung that correctly solves the problem:
hermes <subcommand> guided by a skill. Zero
model-tool footprint. Default choice for subscriptions, scheduled tasks,
service setup. Examples: hermes webhook, hermes cron, hermes tools.check_fn) — needs structured params/returns AND
only appears when a prerequisite is configured. Zero footprint otherwise.
Examples: Home Assistant tools (gated on token), memory-provider tools.~/.hermes/plugins/ or a pip package, discovered at runtime.When 3+ open PRs try to integrate the same category of thing (memory backends, providers, notifiers), don't merge them one at a time — design an ABC + orchestrator, wrap the existing built-in as the first provider, and turn the competing PRs into plugins against that interface.
A tool that only works because of who is on the other end of the connection — the desktop app's panes, the in-app browser, message reactions, Projects — must resolve its availability from the session's own source, not from an env var on the backend process.
The client and the backend are separate machines on separate clocks. The
desktop app can be driving a backend Electron spawned locally, one over SSH,
one behind a plain URL + token, or Hermes Cloud. Only the first two are spawned
by us and carry HERMES_DESKTOP=1. Every env-keyed GUI gate is therefore a
silent no-op on the other half of the topologies, and the failure is invisible:
the tool is stripped from the schema before the model ever sees it, on the same
backend whose platform hint is telling the model it's "chatting inside the
Hermes desktop app."
The pattern that works:
_HERMES_CORE_TOOLS
(nobody else should pay their schema) and put them in a named toolset —
desktop_ui, project. The GUI gateway's _load_enabled_toolsets(platform)
folds that toolset in when the session's platform says GUI. One resolver,
every topology.check_fn answers reachability or user opt-in, not surface. "Is the
renderer bridge wired?", "did the user enable reactions?" — fine. "Was I
spawned by Electron?" — not fine. check_fn results are also TTL-cached
process-wide (tools/registry.py), so a per-session answer does not belong
there at all: one process serves many sessions.HERMES_DESKTOP=1 legitimately
marks "this backend process was spawned by the app" — it gates the cron
ticker and web-dist handling correctly. It does NOT mean "a GUI is watching",
and the embedded terminal pane (hermes --tui against that same backend) is
the standing counterexample.Same test both ways: if the capability would still make sense with the client on another machine, it is session-scoped. Cover it with a test that asserts the GUI session gets the tool with the env var absent — that's the assertion the original gate could never have passed.
# Prefer .venv; fall back to venv if that's what your checkout has.
source .venv/bin/activate # or: source venv/bin/activate
scripts/run_tests.sh probes .venv first, then venv, then
$HOME/.hermes/hermes-agent/venv (for worktrees that share a venv with the
main checkout).
File counts shift constantly — don't treat the tree below as exhaustive. The canonical source is the filesystem. The notes call out the load-bearing entry points you'll actually edit.
hermes-agent/
├── run_agent.py # AIAgent class — core conversation loop (~12k LOC)
├── model_tools.py # Tool orchestration, discover_builtin_tools(), handle_function_call()
├── toolsets.py # Toolset definitions, _HERMES_CORE_TOOLS list
├── cli.py # HermesCLI class — interactive CLI orchestrator (~11k LOC)
├── hermes_state.py # SessionDB — SQLite session store (FTS5 search)
├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths
├── hermes_logging.py # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
├── batch_runner.py # Parallel batch processing
├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
├── tools/ # Tool implementations — auto-discovered via tools/registry.py
│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
├── gateway/ # Messaging gateway — run.py + session.py + platforms/
│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
│ │ # homeassistant, signal, matrix, mattermost, email, sms,
│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
│ │ # 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
│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...)
│ ├── 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, 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`
│ └── src/ # entry.tsx, app.tsx, gatewayClient.ts + app/components/hooks/lib
├── tui_gateway/ # Python JSON-RPC backend for the TUI
├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
├── cron/ # Scheduler — jobs.py, scheduler.py
├── scripts/ # run_tests.sh, release.py, auxiliary scripts
├── website/ # Docusaurus docs site
└── tests/ # Pytest suite (~17k tests across ~900 files as of May 2026)
User config: ~/.hermes/config.yaml (settings), ~/.hermes/.env (API keys only).
Logs: ~/.hermes/logs/ — agent.log (INFO+), errors.log (WARNING+),
gateway.log when running the gateway. Profile-aware via get_hermes_home().
Browse with hermes logs [--follow] [--level ...] [--session ...].
Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
src/store.useStore. Non-rendering actions should read with $atom.get().onState={st => void setGatewayState(st)}.onClick={() => void save()}.type X = { ... } for object props.React.ComponentProps<'button'>, React.ComponentProps<typeof Dialog>, Omit<...>, Pick<...>.src/app owns routes, pages, and page-specific components.src/store owns shared atoms.src/lib owns shared pure helpers.tools/registry.py (no deps — imported by all tool files)
↑
tools/*.py (each calls registry.register() at import time)
↑
model_tools.py (imports tools/registry + triggers tool discovery)
↑
run_agent.py, cli.py, batch_runner.py, environments/
The real AIAgent.__init__ takes ~60 parameters (credentials, routing, callbacks,
session context, budget, credential pool, etc.). The signature below is the
minimum subset you'll usually touch — read run_agent.py for the full list.
class AIAgent:
def __init__(self,
base_url: str = None,
api_key: str = None,
provider: str = None,
api_mode: str = None, # "chat_completions" | "codex_responses" | ...
model: str = "", # empty → resolved from config/provider later
max_iterations: int = 500, # tool-calling iterations (shared with subagents)
enabled_toolsets: list = None,
disabled_toolsets: list = None,
quiet_mode: bool = False,
save_trajectories: bool = False,
platform: str = None, # "cli", "telegram", etc.
session_id: str = None,
skip_context_files: bool = False,
skip_memory: bool = False,
credential_pool=None,
# ... plus callbacks, thread/user/chat IDs, iteration_budget, fallback_model,
# checkpoints config, prefill_messages, service_tier, reasoning_config, etc.
): ...
def chat(self, message: str) -> str:
"""Simple interface — returns final response string."""
def run_conversation(self, user_message: str, system_message: str = None,
conversation_history: list = None, task_id: str = None) -> dict:
"""Full interface — returns dict with final_response + messages."""
The core loop is inside run_conversation() — entirely synchronous, with
interrupt checks, budget tracking, and a one-turn grace call:
while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \
or self._budget_grace_call:
if self._interrupt_requested: break
response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
if response.tool_calls:
for tool_call in response.tool_calls:
result = handle_function_call(tool_call.name, tool_call.args, task_id)
messages.append(tool_result_message(result))
api_call_count += 1
else:
return response.content
Messages follow OpenAI format: {"role": "system/user/assistant/tool", ...}.
Reasoning content is stored in assistant_msg["reasoning"].
agent/display.py) — animated faces during API calls, ┊ activity feed for tool resultsload_cli_config() in cli.py merges hardcoded defaults + user config YAMLhermes_cli/skin_engine.py) — data-driven CLI theming; initialized from display.skin config key at startup; skins customize banner colors, spinner faces/verbs/wings, tool prefix, response box, branding textprocess_command() is a method on HermesCLI — dispatches on canonical command name resolved via resolve_command() from the central registryagent/skill_commands.py scans ~/.hermes/skills/, injects as user message (not system prompt) to preserve prompt cachinghermes_cli/commands.py)All slash commands are defined in a central COMMAND_REGISTRY list of CommandDef objects. Every downstream consumer derives from this registry automatically:
process_command() resolves aliases via resolve_command(), dispatches on canonical nameGATEWAY_KNOWN_COMMANDS frozenset for hook emission, resolve_command() for dispatchgateway_help_lines() generates /help outputtelegram_bot_commands() generates the BotCommand menuslack_subcommand_map() generates /hermes subcommand routingCOMMANDS flat dict feeds SlashCommandCompleterCOMMANDS_BY_CATEGORY dict feeds show_help()CommandDef entry to COMMAND_REGISTRY in hermes_cli/commands.py:CommandDef("mycommand", "Description of what it does", "Session",
aliases=("mc",), args_hint="[arg]"),
HermesCLI.process_command() in cli.py:elif canonical == "mycommand":
self._handle_mycommand(cmd_original)
gateway/run.py:if canonical == "mycommand":
return await self._handle_mycommand(event)
save_config_value() in cli.pyCommandDef fields:
- name — canonical name without slash (e.g. "background")
- description — human-readable description
- category — one of "Session", "Configuration", "Tools & Skills", "Info", "Exit"
- aliases — tuple of alternative names (e.g. ("bg",))
- args_hint — argument placeholder shown in help (e.g. "<prompt>", "[name]")
- cli_only — only available in the interactive CLI
- gateway_only — only available in messaging platforms
- gateway_config_gate — config dotpath (e.g. "display.tool_progress_command"); when set on a cli_only command, the command becomes available in the gateway if the config value is truthy. GATEWAY_KNOWN_COMMANDS always includes config-gated commands so the gateway can dispatch them; help/menus only show them when the gate is open.
Adding an alias requires only adding it to the aliases tuple on the existing CommandDef. No other file changes needed — dispatch, help text, Telegram menu, Slack mapping, and autocomplete all update automatically.
The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via hermes --tui or HERMES_TUI=1.
hermes --tui
└─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway)
│ └─ AIAgent + tools + sessions
└─ renders transcript, composer, prompts, activity
TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic.
Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See tui_gateway/server.py for the full method/event catalog.
| Surface | Ink component | Gateway method |
|---|---|---|
| Chat streaming | app.tsx + messageLine.tsx |
prompt.submit → message.delta/complete |
| Tool activity | thinking.tsx |
tool.start/progress/complete |
| Approvals | prompts.tsx |
approval.respond ← approval.request |
| Clarify/sudo/secret | prompts.tsx, maskedPrompt.tsx |
clarify/sudo/secret.respond |
| Session picker | sessionPicker.tsx |
session.list/resume |
| Slash commands | Local handler + fallthrough | slash.exec → _SlashWorker, command.dispatch |
| Completions | useCompletion hook |
complete.slash, complete.path |
| Theming | theme.ts + branding.tsx |
gateway.ready with skin data |
/help, /quit, /clear, /resume, /copy, /paste, etc.) handled locally in app.tsxslash.exec (runs in persistent _SlashWorker subprocess) → command.dispatch fallbackcd ui-tui
npm install # first time
npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
npm start # production
npm run build # full build (hermes-ink + tsc)
npm run typecheck # typecheck only (tsc --noEmit)
npm run lint # eslint
npm run fmt # prettier
npm test # vitest
hermes dashboard → /chat)The dashboard embeds the real hermes --tui — not a rewrite. See hermes_cli/pty_bridge.py + the @app.websocket("/api/pty") endpoint in hermes_cli/web_server.py.
web/src/pages/ChatPage.tsx, which mounts xterm.js's Terminal with the WebGL renderer, @xterm/addon-fit for container-driven resize, and @xterm/addon-unicode11 for modern wide-character widths./api/pty?token=… upgrades to a WebSocket; auth uses the same ephemeral _SESSION_TOKEN as REST, via query param (browsers can't set Authorization on WS upgrade).hermes --tui would spawn, through ptyprocess (POSIX PTY — WSL works, native Windows does not).\x1b[RESIZE:<cols>;<rows>] intercepted on the server and applied with TIOCSWINSZ.Do not re-implement the primary chat experience in React. The main transcript, composer/input flow (including slash-command behavior), and PTY-backed terminal belong to the embedded hermes --tui — anything new you add to Ink shows up in the dashboard automatically. If you find yourself rebuilding the transcript or composer for the dashboard, stop and extend Ink instead.
Structured React UI around the TUI is allowed when it is not a second chat surface. Sidebar widgets, inspectors, summaries, status panels, and similar supporting views (e.g. ChatSidebar, ModelPickerDialog, ToolCall) are fine when they complement the embedded TUI rather than replacing the transcript / composer / terminal. Keep their state independent of the PTY child's session and surface their failures non-destructively so the terminal pane keeps working unimpaired.
apps/desktop/)A separate chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (@assistant-ui/react) that talks to a tui_gateway backend over JSON-RPC (requestGateway(method, params)). The WebSocket/JSON-RPC transport lives in the framework-agnostic apps/shared package (@hermes/shared — JsonRpcGatewayClient + WS URL helpers), which the web dashboard (web/) also consumes; desktop has no build/runtime dependency on the dashboard frontend — it spawns a headless hermes serve backend server (the same gateway dashboard serves, minus the browser UI entirely: serve sets headless_backend=True, so cmd_dashboard skips _build_web_ui AND exports HERMES_SERVE_HEADLESS=1 so mount_spa() disables the SPA even if a stray web_dist/ exists — only the JSON-RPC/WS/API surface is reachable). dashboard and serve share cmd_dashboard/start_server but are independent surfaces — neither launches the other. The one exception is a backward-compat fallback: serve is newer, so the desktop spawn (electron/backend-command.ts + backendSupportsServe() in electron/main.ts) detects whether the resolved runtime registers serve and, only when it does not (an older managed install / PATH hermes the app hasn't updated yet), rewrites the argv to the legacy dashboard --no-open. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed hermes --tui — it has its own composer, transcript, and slash-command pipeline. For scoped Desktop architecture, state, resolver, transport, and testing rules, read apps/desktop/AGENTS.md.
Slash commands in the desktop app are curated client-side, then dispatched to the backend. The pipeline:
tui_gateway/server.py commands.catalog (empty-query list) and complete.slash (typed-query completions) both include built-in commands, user quick_commands, AND skill-derived commands (scan_skill_commands() / get_skill_commands()). The desktop app does not need a new RPC to see skills.apps/desktop/src/lib/desktop-slash-commands.ts. This is the load-bearing file. It holds DESKTOP_COMMAND_SPECS (the built-ins and their Desktop surfaces) plus NO_DESKTOP_SURFACE block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.isDesktopSlashCommand(name) — gates execution. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.isDesktopSlashSuggestion(name) — gates discovery/completion. Used by BOTH completion paths in app/chat/composer/hooks/use-slash-completions.ts (empty-query catalog filter + typed-query complete.slash filter) and by filterDesktopCommandsCatalog.isDesktopSlashExtensionCommand(name) — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.)app/session/hooks/use-prompt-actions/slash.ts (runSlash): built-ins that the desktop owns (/skin, /help, /new, …) are handled locally or via commands.catalog; everything else goes to slash.exec, falling back to command.dispatch (which the gateway resolves into skill / alias / exec directives). A skill command resolves to {type: "skill", message} and is submitted as a normal prompt.Rule: the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and quick_commands are extensions the backend surfaces — they belong in completions. If you tighten desktop-slash-commands.ts, keep isDesktopSlashExtensionCommand flowing into both the suggestion and catalog-filter paths. Tests: from apps/desktop, run npx vitest run src/lib/desktop-slash-commands.test.ts (workspace dependencies are installed at the repo root).
Before adding any tool, settle the footprint question first (see "The
Footprint Ladder" in the Contribution Rubric): most capabilities should NOT
be core tools. For custom or local-only tools, do not edit Hermes core.
Use the plugin route instead: create ~/.hermes/plugins/<name>/plugin.yaml
and ~/.hermes/plugins/<name>/__init__.py, then register tools with
ctx.register_tool(...). Plugin toolsets are discovered automatically and can be
enabled or disabled without touching tools/ or toolsets.py.
Use the built-in route below only when the user is explicitly contributing a new core Hermes tool that should ship in the base system.
Built-in/core tools require changes in 2 files:
1. Create tools/your_tool.py:
import json, os
from tools.registry import registry
def check_requirements() -> bool:
return bool(os.getenv("EXAMPLE_API_KEY"))
def example_tool(param: str, task_id: str = None) -> str:
return json.dumps({"success": True, "data": "..."})
registry.register(
name="example_tool",
toolset="example",
schema={"name": "example_tool", "description": "...", "parameters": {...}},
handler=lambda args, **kw: example_tool(param=args.get("param", ""), task_id=kw.get("task_id")),
check_fn=check_requirements,
requires_env=["EXAMPLE_API_KEY"],
)
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. 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.
Path references in tool schemas: If the schema description mentions file paths (e.g. default output directories), use display_hermes_home() to make them profile-aware. The schema is generated at import time, which is after _apply_profile_override() sets HERMES_HOME.
State files: If a tool stores persistent state (caches, logs, checkpoints), use get_hermes_home() for the base directory — never Path.home() / ".hermes". This ensures each profile gets its own state.
Agent-level tools (todo, memory): intercepted by run_agent.py before handle_function_call(). See tools/todo_tool.py for the pattern.
All dependencies must have upper bounds to limit supply-chain attack surface. This policy was established after the litellm compromise (PR #2796, #2810) and reinforced after the Mini Shai-Hulud worm campaign (May 2026).
| Source type | Treatment | Example |
|---|---|---|
| PyPI package | >=floor,<next_major |
"httpx>=0.28.1,<1" |
| Git URL | Commit SHA | git+https://...@<40-char-sha> |
| GitHub Actions | Commit SHA + comment | uses: actions/checkout@<sha> # v4 |
| CI-only pip | ==exact |
pyyaml==6.0.2 |
When adding a new dependency to pyproject.toml:
1. Pin to >=current_version,<next_major for post-1.0 (e.g. >=1.5.0,<2).
2. For pre-1.0 packages, use <0.(current_minor + 2) (e.g. >=0.29,<0.32).
3. Never commit a bare >=X.Y.Z without a ceiling — CI and reviewers will reject it.
4. Run uv lock to regenerate uv.lock with hashes.
Reference: #2810 (bounds pass), #9801 (SHA pinning + audit CI).
DEFAULT_CONFIG in hermes_cli/config.py_config_version (check the current value at the top of DEFAULT_CONFIG)
ONLY if you need to actively migrate/transform existing user config
(renaming keys, changing structure). Adding a new key to an existing
section is handled automatically by the deep-merge and does NOT require
a version bump.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).
OPTIONAL_ENV_VARS in hermes_cli/config.py with metadata:"NEW_API_KEY": {
"description": "What it's for",
"prompt": "Display name",
"url": "https://...",
"password": True,
"category": "tool", # provider, tool, messaging, setting
},
Non-secret settings (timeouts, thresholds, feature flags, paths, display
preferences) belong in config.yaml, not .env. If internal code needs an
env var mirror for backward compatibility, bridge it from config.yaml to
the env var in code (see gateway_timeout, terminal.cwd → TERMINAL_CWD).
| Loader | Used by | Location |
|---|---|---|
load_cli_config() |
CLI mode | cli.py — merges CLI-specific defaults + user YAML |
load_config() |
hermes tools, hermes setup, most CLI subcommands |
hermes_cli/config.py — merges DEFAULT_CONFIG + user YAML |
| Direct YAML load | Gateway runtime | gateway/run.py + gateway/config.py — reads user YAML raw |
If you add a new key and the CLI sees it but the gateway doesn't (or vice
versa), you're on the wrong loader. Check DEFAULT_CONFIG coverage.
os.getcwd()).terminal.cwd from config.yaml. The gateway bridges this
to the TERMINAL_CWD env var for child tools. MESSAGING_CWD has been
removed — the config loader prints a deprecation warning if it's set in
.env. Same for TERMINAL_CWD in .env; the canonical setting is
terminal.cwd in config.yaml.The skin engine (hermes_cli/skin_engine.py) provides data-driven CLI visual customization. Skins are pure data — no code changes needed to add a new skin.
hermes_cli/skin_engine.py # SkinConfig dataclass, built-in skins, YAML loader
~/.hermes/skins/*.yaml # User-installed custom skins (drop-in)
init_skin_from_config() — called at CLI startup, reads display.skin from configget_active_skin() — returns cached SkinConfig for the current skinset_active_skin(name) — switches skin at runtime (used by /skin command)load_skin(name) — loads from user skins first, then built-ins, then falls back to defaultdefault skin automatically| Element | Skin Key | Used By |
|---|---|---|
| Banner panel border | colors.banner_border |
banner.py |
| Banner panel title | colors.banner_title |
banner.py |
| Banner section headers | colors.banner_accent |
banner.py |
| Banner dim text | colors.banner_dim |
banner.py |
| Banner body text | colors.banner_text |
banner.py |
| Response box border | colors.response_border |
cli.py |
| Spinner faces (waiting) | spinner.waiting_faces |
display.py |
| Spinner faces (thinking) | spinner.thinking_faces |
display.py |
| Spinner verbs | spinner.thinking_verbs |
display.py |
| Spinner wings (optional) | spinner.wings |
display.py |
| Tool output prefix | tool_prefix |
display.py |
| Per-tool emojis | tool_emojis |
display.py → get_tool_emoji() |
| Agent name | branding.agent_name |
banner.py, cli.py |
| Welcome message | branding.welcome |
cli.py |
| Response box label | branding.response_label |
cli.py |
| Prompt symbol | branding.prompt_symbol |
cli.py |
default — Classic Hermes gold/kawaii (the current look)ares — Crimson/bronze war-god theme with custom spinner wingsmono — Clean grayscale monochromeslate — Cool blue developer-focused themeAdd to _BUILTIN_SKINS dict in hermes_cli/skin_engine.py:
"mytheme": {
"name": "mytheme",
"description": "Short description",
"colors": { ... },
"spinner": { ... },
"branding": { ... },
"tool_prefix": "┊",
},
Users create ~/.hermes/skins/<name>.yaml:
name: cyberpunk
description: Neon-soaked terminal theme
colors:
banner_border: "#FF00FF"
banner_title: "#00FFFF"
banner_accent: "#FF1493"
spinner:
thinking_verbs: ["jacking in", "decrypting", "uploading"]
wings:
- ["⟨⚡", "⚡⟩"]
branding:
agent_name: "Cyber Agent"
response_label: " ⚡ Cyber "
tool_prefix: "▏"
Activate with /skin cyberpunk or display.skin: cyberpunk in config.yaml.
Hermes has two plugin surfaces. Both live under plugins/ in the repo so
repo-shipped plugins can be discovered alongside user-installed ones in
~/.hermes/plugins/ and pip-installed entry points.
hermes_cli/plugins.py + plugins/<name>/)PluginManager discovers plugins from ~/.hermes/plugins/, ./.hermes/plugins/,
and pip entry points. Each plugin exposes a register(ctx) function that
can:
pre_tool_call, post_tool_call, pre_llm_call, post_llm_call,
on_session_start, on_session_endctx.register_tool(...)ctx.register_cli_command(...) — the
plugin's argparse tree is wired into hermes at startup so
hermes <pluginname> <subcmd> works with no change to main.pyHooks are invoked from model_tools.py (pre/post tool) and run_agent.py
(lifecycle). Discovery timing pitfall: discover_plugins() only runs
as a side effect of importing model_tools.py. Code paths that read plugin
state without importing model_tools.py first must call discover_plugins()
explicitly (it's idempotent).
plugins/memory/<name>/)Separate discovery system for pluggable memory backends. Current built-in providers include honcho, mem0, supermemory, byterover, hindsight, holographic, openviking, retaindb.
Each provider implements the MemoryProvider ABC (see agent/memory_provider.py)
and is orchestrated by agent/memory_manager.py. Lifecycle hooks include
sync_turn(turn_messages), prefetch(query), shutdown(), and optional
post_setup(hermes_home, config) for setup-wizard integration.
CLI commands via plugins/memory/<name>/cli.py: if a memory plugin
defines register_cli(subparser), discover_plugin_cli_commands() finds
it at argparse setup time and wires it into hermes <plugin>. The
framework only exposes CLI commands for the currently active memory
provider (read from memory.provider in config.yaml), so disabled
providers don't clutter hermes --help.
Rule (Teknium, May 2026): plugins MUST NOT modify core files
(run_agent.py, cli.py, gateway/run.py, hermes_cli/main.py, etc.).
If a plugin needs a capability the framework doesn't expose, expand the
generic plugin surface (new hook, new ctx method) — never hardcode
plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
honcho argparse from main.py for exactly this reason.
No new in-tree memory providers (policy, May 2026): the set of
built-in memory providers under plugins/memory/ is closed. New memory
backends must ship as standalone plugin repos that users install
into ~/.hermes/plugins/ (or via pip entry points) — they implement
the same MemoryProvider ABC, register through the same discovery
path, and integrate via hermes memory setup / post_setup() without
landing in this tree. PRs that add a new directory under
plugins/memory/ will be closed with a pointer to publish the
provider as its own repo. Existing in-tree providers stay; bug fixes
to them are welcome.
No new third-party-product plugins in-tree (policy, June 2026): the
same rule applies beyond memory providers. Plugins that integrate
someone else's product or project — observability/metrics backends,
vendor SaaS connectors, analytics dashboards, paid-service tie-ins —
must ship as standalone plugin repos that users install into
~/.hermes/plugins/ (or via pip entry points). They register through
the existing plugin discovery path and use the ABCs/hooks/ctx surface
we expose; nothing special is needed in core. The reason is
maintenance load: every product we absorb into the tree becomes our
burden to keep working against a fast-moving core, for a backend we
don't own. Promote standalone plugins in the Nous Research Discord
(#plugins-skills-and-skins). PRs that add such a directory under
plugins/ are closed with a pointer to publish it as its own repo —
this is a coupling decision, not a quality judgment. (The
observability/, kanban/, disk-cleanup/, etc. directories already
in the tree are existing precedent, not an invitation to add more
third-party-product plugins alongside them.)
plugins/model-providers/<name>/)Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …)
ships as a plugin here. Each plugin's __init__.py calls
providers.register_provider(ProviderProfile(...)) at module load.
providers/__init__.py._discover_providers() is a lazy, separate
discovery system — scanned on first get_provider_profile() or
list_providers() call, NOT by the general PluginManager.
Scan order:
1. Bundled: <repo>/plugins/model-providers/<name>/
2. User: $HERMES_HOME/plugins/model-providers/<name>/
3. Legacy: <repo>/providers/<name>.py (back-compat)
User plugins of the same name override bundled ones — register_provider()
is last-writer-wins. This lets third parties swap out any built-in
profile without a repo patch.
The general PluginManager records kind: model-provider manifests but does
NOT import them (would double-instantiate ProviderProfile). Plugins
without an explicit kind: get auto-coerced via a source-text heuristic
(register_provider + ProviderProfile in __init__.py).
Full authoring guide: website/docs/developer-guide/model-provider-plugin.md.
plugins/context_engine/, plugins/image_gen/, etc. follow the same
pattern (ABC + orchestrator + per-plugin directory). Context engines
plug into agent/context_engine.py; image-gen providers into
agent/image_gen_provider.py. Reference / docs-companion plugins
(example-dashboard, strike-freedom-cockpit, plugin-llm-example,
plugin-llm-async-example) live in the
hermes-example-plugins
companion repo, not in this tree.
Two parallel surfaces:
skills/ — built-in skills shipped and loadable by default.
Organized by category directories (e.g. skills/github/, skills/mlops/).optional-skills/ — heavier or niche skills shipped with the repo but
NOT active by default. Installed explicitly via
hermes skills install official/<category>/<skill>. Adapter lives in
tools/skills_hub.py (OptionalSkillSource). Categories include
autonomous-ai-agents, blockchain, communication, creative,
devops, email, health, mcp, migration, mlops, productivity,
research, security, web-development.When reviewing skill PRs, check which directory they target — heavy-dep or
niche skills belong in optional-skills/.
Standard fields: name, description, version, author, license,
platforms (OS-gating list: [macos], [linux, macos], ...),
metadata.hermes.tags, metadata.hermes.category,
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.
Every new or modernized skill — bundled, optional, or contributed — must meet these standards before merge. Reviewers reject PRs that violate them.
description ≤ 60 characters, one sentence, ends with a period.
Long descriptions bloat skill listings and dilute the model's
attention when many skills are loaded. State the capability, not
the implementation. No marketing words ("powerful",
"comprehensive", "seamless", "advanced"). Don't repeat the skill
name. Verify with:
python
import re, pathlib
m = re.search(r'^description: (.*)$',
pathlib.Path('skills/<cat>/<name>/SKILL.md').read_text(),
re.MULTILINE)
assert len(m.group(1)) <= 60, len(m.group(1))
Tools referenced in SKILL.md prose must be native Hermes tools or
MCP servers the skill explicitly expects. When the skill needs a
capability, point at the proper tool by name in backticks
(`terminal`, `web_extract`, `read_file`,
`patch`, `search_files`, `vision_analyze`,
`browser_navigate`, `delegate_task`, etc.). Do NOT
name shell utilities the agent already has wrapped — grep →
search_files, cat/head/tail → read_file, sed/awk →
patch, find/ls → search_files target='files'. If the skill
depends on an MCP server, name the MCP server and document the
expected setup in ## Prerequisites. Anything else (third-party
CLIs, shell pipelines, etc.) is fair game inside script files but
should not be the headline interaction surface in the prose.
platforms: gating audited against actual script imports.
Skills that use POSIX-only primitives (fcntl, termios,
os.setsid, os.kill(pid, 0) for liveness, /proc, /tmp
hardcoded, signal.SIGKILL, bash heredocs, osascript, apt,
systemctl) must declare their supported platforms. Default
posture: try to fix it cross-platform first — tempfile.gettempdir,
pathlib.Path, psutil.pid_exists, Python-level filtering instead
of grep. Gate to a narrower set only when the dependency is
genuinely platform-bound.
author credits the human contributor first. For external
contributions, the contributor's real name + GitHub handle goes
first; "Hermes Agent" is the secondary collaborator. If the
contributor's commit shows "Hermes Agent" as author (because they
used Hermes to draft the skill), replace it with their actual name
— credit the human, not the tool.
SKILL.md body uses the modern section order. # <Skill> Skill
title, 2-3 sentence intro stating what it does and doesn't do,
## When to Use, ## Prerequisites, ## How to Run,
## Quick Reference, ## Procedure, ## Pitfalls,
## Verification. Target ~200 lines for a complex skill,
~100 lines for a simple one. Cut redundant intro fluff, marketing
prose, and re-explanations of env vars already in
## Prerequisites.
Scripts go in scripts/, references in references/,
templates in templates/. Don't expect the model to inline-write
parsers, XML walkers, or non-trivial logic every call — ship a
helper script. Reference it from SKILL.md by path relative to the
skill directory.
Tests live at tests/skills/test_<skill>_skill.py and use only
stdlib + pytest + unittest.mock. No live network calls. Run via
scripts/run_tests.sh tests/skills/test_<skill>_skill.py -q.
.env.example additions are isolated to a clearly delimited
block. Don't touch the surrounding file — contributor-supplied
.env.example versions are usually stale and edits outside the
skill's own block must be dropped during salvage.
The full salvage / modernization checklist for external skill PRs
lives in the hermes-agent-dev skill at
references/new-skill-pr-salvage.md — load it before polishing
contributor skill PRs.
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.
delegate_task)tools/delegate_tool.py spawns a subagent with an isolated
context + terminal session. By default the parent waits for the
child's summary before continuing its own loop. With background=true,
Hermes returns a delegation id immediately and the result re-enters the
conversation later through the async-delegation completion queue.
Two shapes:
goal (+ optional context, toolsets).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, cronjob. Retains execute_code
(programmatic tool calling).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.
Durability rule: background delegate_task is detached from the current
turn but still process-local. For work that must survive process restart, use
cronjob or terminal(background=True, notify_on_complete=True) instead.
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.
agent/curator.py (review loop, auto-transitions, LLM review
prompt) + agent/curator_backup.py (pre-run tar.gz snapshots).hermes_cli/curator.py wires hermes curator <verb> where
verbs are: status, run, pause, resume, pin, unpin,
archive, restore, prune, backup, rollback.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/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 120s–2h.
- 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.
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.
hermes_cli/kanban.py wires hermes kanban with verbs
init, create, list (alias ls), show, assign, link,
unlink, comment, attach, attachments, attach-rm, complete,
block, unblock, archive, tail, plus less-commonly-used watch,
stats, runs, log, assignees, heartbeat, notify-*,
dispatch, daemon, gc.tools/kanban_tools.py exposes
kanban_show, kanban_complete, kanban_block, kanban_heartbeat,
kanban_comment, kanban_create, kanban_link, kanban_attach,
kanban_attach_url, kanban_attachments; profiles that explicitly
enable the kanban toolset outside a dispatcher-spawned task also get
kanban_list and kanban_unblock for board routing.kanban.dispatch_in_gateway: true.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 kanban.failure_limit consecutive non-success attempts on the
same task (default: 2), the dispatcher auto-blocks it to prevent spin
loops.
Full user-facing docs: website/docs/user-guide/features/kanban.md.
Hermes-Agent ensures caching remains valid throughout a conversation. Do NOT implement changes that would: - Alter past context mid-conversation - Change toolsets mid-conversation - Reload memories or rebuild system prompts mid-conversation
Cache-breaking forces dramatically higher costs. The ONLY time we alter context is during context compression.
Slash commands that mutate system-prompt state (skills, tools, memory, etc.)
must be cache-aware: default to deferred invalidation (change takes
effect next session), with an opt-in --now flag for immediate
invalidation. See /skills install --now for the canonical pattern.
When terminal(background=true, notify_on_complete=true) is used, the gateway runs a watcher that
detects process completion and triggers a new agent turn. Control verbosity of background process
messages with display.background_process_notifications
in config.yaml (or HERMES_BACKGROUND_NOTIFICATIONS env var):
all — running-output updates + final message (default)result — only the final completion messageerror — only the final message when exit code != 0off — no watcher messages at allHermes supports profiles — multiple fully isolated instances, each with its own
HERMES_HOME directory (config, API keys, memory, sessions, skills, gateway, etc.).
The core mechanism: _apply_profile_override() in hermes_cli/main.py sets
HERMES_HOME before any module imports. All get_hermes_home() references
automatically scope to the active profile.
get_hermes_home() for all HERMES_HOME paths. Import from hermes_constants.
NEVER hardcode ~/.hermes or Path.home() / ".hermes" in code that reads/writes state.
```python
# GOOD
from hermes_constants import get_hermes_home
config_path = get_hermes_home() / "config.yaml"# BAD — breaks profiles config_path = Path.home() / ".hermes" / "config.yaml" ```
display_hermes_home() for user-facing messages. Import from hermes_constants.
This returns ~/.hermes for default or ~/.hermes/profiles/<name> for profiles.
```python
# GOOD
from hermes_constants import display_hermes_home
print(f"Config saved to {display_hermes_home()}/config.yaml")# BAD — shows wrong path for profiles print("Config saved to ~/.hermes/config.yaml") ```
Module-level constants are fine — they cache get_hermes_home() at import time,
which is AFTER _apply_profile_override() sets the env var. Just use get_hermes_home(),
not Path.home() / ".hermes".
Tests that mock Path.home() must also set HERMES_HOME — since code now uses
get_hermes_home() (reads env var), not Path.home() / ".hermes":
python
with patch.object(Path, "home", return_value=tmp_path), \
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
...
Gateway platform adapters should use token locks — if the adapter connects with
a unique credential (bot token, API key), call acquire_scoped_lock() from
gateway.status in the connect()/start() method and release_scoped_lock() in
disconnect()/stop(). This prevents two profiles from using the same credential.
See plugins/platforms/irc/adapter.py for the canonical pattern.
Profile operations are HOME-anchored, not HERMES_HOME-anchored — _get_profiles_root()
returns Path.home() / ".hermes" / "profiles", NOT get_hermes_home() / "profiles".
This is intentional — it lets hermes -p coder profile list see all profiles regardless
of which one is active.
~/.hermes pathsUse get_hermes_home() from hermes_constants for code paths. Use display_hermes_home()
for user-facing print/log messages. Hardcoding ~/.hermes breaks profiles — each profile
has its own HERMES_HOME directory. This was the source of 5 bugs fixed in PR #3575.
simple_term_menu usageExisting call sites in hermes_cli/main.py remain for legacy fallback only;
the preferred UI is curses (stdlib) because simple_term_menu has
ghost-duplication rendering bugs in tmux/iTerm2 with arrow keys. New
interactive menus must use hermes_cli/curses_ui.py — see
hermes_cli/tools_config.py for the canonical pattern.
\033[K (ANSI erase-to-EOL) in spinner/display codeLeaks as literal ?[K text under prompt_toolkit's patch_stdout. Use space-padding: f"\r{line}{' ' * pad}".
_last_resolved_tool_names is a process-global in model_tools.py_run_single_child() in delegate_tool.py saves and restores this global around subagent execution. If you add new code that reads this global, be aware it may be temporarily stale during child agent runs.
Tool schema descriptions must not mention tools from other toolsets by name (e.g., browser_navigate saying "prefer web_search"). Those tools may be unavailable (missing API keys, disabled toolset), causing the model to hallucinate calls to non-existent tools. If a cross-reference is needed, add it dynamically in get_tool_definitions() in model_tools.py — see the browser_navigate / execute_code post-processing blocks for the pattern.
When an agent is running, messages pass through two sequential guards:
(1) base adapter (gateway/platforms/base.py) queues messages in
_pending_messages when session_key in self._active_sessions, and
(2) gateway runner (gateway/run.py) intercepts /stop, /new,
/queue, /status, /approve, /deny before they reach
running_agent.interrupt(). Any new command that must reach the runner
while the agent is blocked (e.g. approval prompts) MUST bypass BOTH
guards and be dispatched inline, not via _process_message_background()
(which races session lifecycle).
Before squash-merging a PR, ensure the branch is up to date with main
(git fetch origin main && git reset --hard origin/main in the worktree,
then re-apply the PR's commits). A stale branch's version of an unrelated
file will silently overwrite recent fixes on main when squashed. Verify
with git diff HEAD~1..HEAD after merging — unexpected deletions are a
red flag.
Unused code that was never shipped was dead for a reason. Before wiring an
unused module into a live code path, E2E test the real resolution chain
with actual imports (not mocks) against a temp HERMES_HOME.
~/.hermes/The _isolate_hermes_home autouse fixture in tests/conftest.py redirects HERMES_HOME to a temp dir. Never hardcode ~/.hermes/ paths in tests.
Profile tests: When testing profile features, also mock Path.home() so that
_get_profiles_root() and _get_default_hermes_home() resolve within the temp dir.
Use the pattern from tests/hermes_cli/test_profiles.py:
@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))
return home
ALWAYS use scripts/run_tests.sh — do not call pytest directly. The script enforces
hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
per-file subprocess isolation via scripts/run_tests_parallel.py — no xdist,
worker count auto-scaled from CPU count). Direct pytest
on a 16+ core developer machine with API keys set diverges from CI in ways
that have caused multiple "works locally, fails in CI" incidents (and the reverse).
scripts/run_tests.sh # full suite, CI-parity
scripts/run_tests.sh tests/gateway/ # one directory
scripts/run_tests.sh tests/agent/test_foo.py -k test_x # one test (file + -k; the runner is file-granular)
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
Flake policy: the runner auto-retries a failing test FILE once in a fresh
subprocess (--file-retries, default 1; HERMES_TEST_FILE_RETRIES=0 to
disable). Pass-on-retry counts as green but is printed in a ⚠ FLAKY summary
section with both attempts' output. A FLAKY report is a bug to fix, not noise
to ignore — timing-sensitive tests must not assume a quiet runner (loose
wall-clock bounds ≥ 2s, event-based sync, no assert not _wait_until(...)
negative-timing races).
Every test file runs in a freshly-spawned Python subprocess via run_tests_parallel.py. This means module-level dicts/sets and
ContextVars from one test file cannot leak into the next.
| Without wrapper | With wrapper | |
|---|---|---|
| Provider API keys | Whatever is in your env (auto-detects pool) | All env vars except a specific few unset. |
HOME / ~/.hermes/ |
Your real config+auth.json | Temp dir per test |
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
The CI change classifier (scripts/ci/classify_changes.py) runs specific jobs based on what files changed. A Python test that asserts
about the contents of package.json, package-lock.json, .ts/.tsx
source, or any other JS-side artifact will not run on a PR that only touches
those files. This means a regression can go green on a PR and red on main (where the
classifier fails open and runs everything).
Any test that reads or asserts about package.json,
package-lock.json, tsconfig.json, .ts/.tsx/.js/.mjs/.cjs
source files configuration belongs in the JS (vitest) test suite, not in tests/*.py.
A test is a change-detector if it fails whenever data that is expected to change gets updated — model catalogs, config version numbers, enumeration counts, hardcoded lists of provider models. These tests add no behavioral coverage; they just guarantee that routine source updates break CI and cost engineering time to "fix."
Do not write:
# catalog snapshot — breaks every model release
assert "gemini-2.5-pro" in _PROVIDER_MODELS["gemini"]
assert "MiniMax-M2.7" in models
# config version literal — breaks every schema bump
assert DEFAULT_CONFIG["_config_version"] == 21
# enumeration count — breaks every time a skill/provider is added
assert len(_PROVIDER_MODELS["huggingface"]) == 8
Do write:
# behavior: does the catalog plumbing work at all?
assert "gemini" in _PROVIDER_MODELS
assert len(_PROVIDER_MODELS["gemini"]) >= 1
# behavior: does migration bump the user's version to current latest?
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
# invariant: no plan-only model leaks into the legacy list
assert not (set(moonshot_models) & coding_plan_only_models)
# invariant: every model in the catalog has a context-length entry
for m in _PROVIDER_MODELS["huggingface"]:
assert m.lower() in DEFAULT_CONTEXT_LENGTHS_LOWER
The rule: if the test reads like a snapshot of current data, delete it. If it reads like a contract about how two pieces of data must relate, keep it. When a PR adds a new provider/model and you want a test, make the test assert the relationship (e.g. "catalog entries all have context lengths"), not the specific names.
Reviewers should reject new change-detector tests; authors should convert them into invariants before re-requesting review.
A test that reads a source file's text is testing the shape of the source code, not its behavior. This is a hard antipattern, banned outright. Any test that reads a .py, .ts, .tsx, etc., file is suspect.
Why it's actively harmful, not just weak:
Do not write:
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
test('backend spawn hides the Windows console', () => {
assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/)
})
Do write — extract the logic into a small pure/DI-testable function and call it for real:
// backend-spawn.ts
export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') {
if (!isWindows || 'windowsHide' in options) return options
return { ...options, windowsHide: true }
}
// backend-spawn.test.ts
test('windowsHide defaults to true on Windows, is left alone elsewhere', () => {
assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true)
assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined)
assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false)
})
If the logic lives inline in a god-file (main.ts, cli.py,
gateway/run.py) and extracting it feels disruptive: that's the actual
signal to do the extraction, not to regex around it.