diff --git a/AGENTS.md b/AGENTS.md index dd45310ca86dd..b2bb9f262e55f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,1132 +1,59 @@ # Hermes Agent - Development Guide -Instructions for AI coding assistants and developers working on the hermes-agent codebase. +Concise instructions for agents working in this repo. The old long-form guide was moved to `docs/AGENTS.reference.md`; load that file only when you need deep architecture details. Do not inject the whole cookbook into every session. That was token arson with markdown. -## Development Environment +## Environment ```bash -# Prefer .venv; fall back to venv if that's what your checkout has. -source .venv/bin/activate # or: source venv/bin/activate +source .venv/bin/activate # prefer .venv; fall back to venv if needed ``` -`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). +- Current repo: `~/.hermes/hermes-agent`. +- User config: `~/.hermes/config.yaml`; secrets: `~/.hermes/.env`. +- Logs: `~/.hermes/logs/` (`gateway.log`, `agent.log`, `errors.log`). +- Use `get_hermes_home()` from `hermes_constants.py` for Hermes paths; do not hardcode `~/.hermes` in code. -## Project Structure +## Load-bearing files -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. +- `run_agent.py` — `AIAgent`, system prompt assembly, compression, conversation loop. +- `model_tools.py` — tool discovery/dispatch. +- `toolsets.py` — built-in toolset definitions. +- `cli.py` / `hermes_cli/commands.py` — CLI and slash commands. +- `hermes_state.py` — SQLite session store/search. +- `gateway/run.py`, `gateway/session.py`, `gateway/platforms/` — messaging gateway. +- `tools/` — tool implementations registered via `tools.registry`. +- `tests/` — pytest suite. -``` -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 -│ └── / # 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` -│ └── 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 ...]`. - -## File Dependency Chain - -``` -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/ -``` - ---- - -## AIAgent Class (run_agent.py) - -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. - -```python -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 = 90, # 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.""" -``` - -### Agent Loop - -The core loop is inside `run_conversation()` — entirely synchronous, with -interrupt checks, budget tracking, and a one-turn grace call: - -```python -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"]`. - ---- - -## CLI Architecture (cli.py) - -- **Rich** for banner/panels, **prompt_toolkit** for input with autocomplete -- **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results -- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML -- **Skin engine** (`hermes_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 text -- `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry -- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching - -### Slash Command Registry (`hermes_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: - -- **CLI** — `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name -- **Gateway** — `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch -- **Gateway help** — `gateway_help_lines()` generates `/help` output -- **Telegram** — `telegram_bot_commands()` generates the BotCommand menu -- **Slack** — `slack_subcommand_map()` generates `/hermes` subcommand routing -- **Autocomplete** — `COMMANDS` flat dict feeds `SlashCommandCompleter` -- **CLI help** — `COMMANDS_BY_CATEGORY` dict feeds `show_help()` - -### Adding a Slash Command - -1. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`: -```python -CommandDef("mycommand", "Description of what it does", "Session", - aliases=("mc",), args_hint="[arg]"), -``` -2. Add handler in `HermesCLI.process_command()` in `cli.py`: -```python -elif canonical == "mycommand": - self._handle_mycommand(cmd_original) -``` -3. If the command is available in the gateway, add a handler in `gateway/run.py`: -```python -if canonical == "mycommand": - return await self._handle_mycommand(event) -``` -4. For persistent settings, use `save_config_value()` in `cli.py` - -**CommandDef 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. `""`, `"[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. - ---- - -## TUI Architecture (ui-tui + tui_gateway) - -The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via `hermes --tui` or `HERMES_TUI=1`. - -### Process Model - -``` -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. - -### Transport - -Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog. - -### Key Surfaces - -| 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 | - -### Slash Command Flow - -1. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx` -2. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback - -### Dev Commands - -```bash -cd 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 type-check # typecheck only (tsc --noEmit) -npm run lint # eslint -npm run fmt # prettier -npm test # vitest -``` - -### TUI in the Dashboard (`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`. - -- Browser loads `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). -- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not). -- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:;]` 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. - ---- - -## Adding New Tools - -For most custom or local-only tools, do **not** edit Hermes core. Use the plugin -route instead: create `~/.hermes/plugins//plugin.yaml` and -`~/.hermes/plugins//__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`:** -```python -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. - ---- - -## Dependency Pinning Policy - -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,=0.28.1,<1"` | -| Git URL | Commit SHA | `git+https://...@<40-char-sha>` | -| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@ # v4` | -| CI-only pip | `==exact` | `pyyaml==6.0.2` | - -**When adding a new dependency to `pyproject.toml`:** -1. Pin to `>=current_version,=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). - ---- - -## Adding Configuration - -### config.yaml options: -1. Add to `DEFAULT_CONFIG` in `hermes_cli/config.py` -2. Bump `_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. - -### 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 -"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`). - -### Config loaders (three paths — know which one you're in): - -| 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. - -### Working directory: -- **CLI** — uses the process's current directory (`os.getcwd()`). -- **Messaging** — uses `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`. - ---- - -## Skin/Theme System - -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. - -### Architecture - -``` -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 config -- `get_active_skin()` — returns cached `SkinConfig` for the current skin -- `set_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 default -- Missing skin values inherit from the `default` skin automatically - -### What skins customize - -| 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` | - -### Built-in skins - -- `default` — Classic Hermes gold/kawaii (the current look) -- `ares` — Crimson/bronze war-god theme with custom spinner wings -- `mono` — Clean grayscale monochrome -- `slate` — Cool blue developer-focused theme - -### Adding a built-in skin - -Add to `_BUILTIN_SKINS` dict in `hermes_cli/skin_engine.py`: - -```python -"mytheme": { - "name": "mytheme", - "description": "Short description", - "colors": { ... }, - "spinner": { ... }, - "branding": { ... }, - "tool_prefix": "┊", -}, -``` - -### User skins (YAML) - -Users create `~/.hermes/skins/.yaml`: - -```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. - ---- - -## Plugins - -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. - -### General plugins (`hermes_cli/plugins.py` + `plugins//`) - -`PluginManager` discovers plugins from `~/.hermes/plugins/`, `./.hermes/plugins/`, -and pip entry points. Each plugin exposes a `register(ctx)` function that -can: - -- Register Python-callback lifecycle hooks: - `pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`, - `on_session_start`, `on_session_end` -- Register new tools via `ctx.register_tool(...)` -- Register CLI subcommands via `ctx.register_cli_command(...)` — the - plugin's argparse tree is wired into `hermes` at startup so - `hermes ` works with no change to `main.py` - -Hooks 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). - -### Memory-provider plugins (`plugins/memory//`) - -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. +## Coding rules -**CLI commands via `plugins/memory//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 `. 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. - -### Model-provider plugins (`plugins/model-providers//`) - -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: `/plugins/model-providers//` -2. User: `$HERMES_HOME/plugins/model-providers//` -3. Legacy: `/providers/.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`. - -### Dashboard / context-engine / image-gen plugin directories - -`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`](https://github.com/NousResearch/hermes-example-plugins) -companion repo, not in this tree. - ---- - -## Skills - -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//`. 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/`. - -### SKILL.md frontmatter - -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.`, prompted -during setup, injected at load time). - -Top-level `tags:` and `category:` are also accepted and mirrored from -`metadata.hermes.*` by the loader. - -### Skill authoring standards (HARDLINE) - -Every new or modernized skill — bundled, optional, or contributed — -must meet these standards before merge. Reviewers reject PRs that -violate them. - -1. **`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///SKILL.md').read_text(), - re.MULTILINE) - assert len(m.group(1)) <= 60, len(m.group(1)) - ``` - -2. **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. - -3. **`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. - -4. **`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. - -5. **SKILL.md body uses the modern section order.** `# 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`. - -6. **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. - -7. **Tests live at `tests/skills/test__skill.py`** and use only - stdlib + pytest + `unittest.mock`. No live network calls. Run via - `scripts/run_tests.sh tests/skills/test__skill.py -q`. - -8. **`.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. - ---- - -## 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..enabled` / `tools..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 ` 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 ` -(`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. - ---- - -## Kanban (multi-agent work queue) - -Durable SQLite-backed board that lets multiple profiles / workers -collaborate on shared tasks. Users drive it via `hermes kanban `; -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/orchestrator toolset:** `tools/kanban_tools.py` exposes - `kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, - `kanban_comment`, `kanban_create`, `kanban_link`; profiles that - explicitly enable the `kanban` toolset outside a dispatcher-spawned - task also get `kanban_list` and `kanban_unblock` for board routing. -- **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 `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`. - ---- - -## Important Policies - -### Prompt Caching Must Not Break - -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. - -### Background Process Notifications (Gateway) - -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 message -- `error` — only the final message when exit code != 0 -- `off` — no watcher messages at all - ---- - -## Profiles: Multi-Instance Support - -Hermes 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. - -### Rules for profile-safe code - -1. **Use `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" - ``` - -2. **Use `display_hermes_home()` for user-facing messages.** Import from `hermes_constants`. - This returns `~/.hermes` for default or `~/.hermes/profiles/` 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") - ``` - -3. **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"`. - -4. **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")}): - ... - ``` - -5. **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 `gateway/platforms/telegram.py` for the canonical pattern. - -6. **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. - -## Known Pitfalls - -### DO NOT hardcode `~/.hermes` paths -Use `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. - -### DO NOT introduce new `simple_term_menu` usage -Existing 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. - -### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code -Leaks 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. - -### DO NOT hardcode cross-tool references in schema descriptions -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. - -### The gateway has TWO message guards — both must bypass approval/control commands -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). - -### Squash merges from stale branches silently revert recent fixes -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. - -### Don't wire in dead code without E2E validation -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`. - -### Tests must not write to `~/.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`: -```python -@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 -``` - ---- +1. Keep prompt caching stable: do not mutate system prompts, tool schemas, or context files mid-session unless the user explicitly resets/restarts. +2. Preserve OpenAI message validity: avoid duplicate adjacent assistant/user messages and keep tool call/result pairing intact. +3. New tools need: `tools/.py`, import/discovery in `model_tools.py`, and a toolset entry in `toolsets.py`. Handlers return JSON strings and include `check_fn`/env gating when applicable. +4. New slash commands go through `COMMAND_REGISTRY` in `hermes_cli/commands.py`; CLI/gateway help derive from it. +5. Gateway platform code must avoid blocking the event loop; use async adapters and proper shutdown cleanup. +6. Config belongs in `config.yaml`; credentials belong in `.env` or auth stores, never source files. +7. Tests redirect `HERMES_HOME` to temp dirs. Do not write tests that touch real user state. ## Testing -**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, -`-n auto` xdist workers, in-tree subprocess-isolation plugin). 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). - ```bash -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::test_x # one test -scripts/run_tests.sh -v --tb=long # pass-through pytest flags -scripts/run_tests.sh --no-isolate tests/foo/ # disable subprocess isolation (faster, for debugging) +source .venv/bin/activate 2>/dev/null || source venv/bin/activate +python -m pytest tests/ -o 'addopts=' -q +python -m pytest tests/path/to/test_file.py -q ``` -### Subprocess-per-test isolation - -Every test runs in a freshly-spawned Python subprocess via the in-tree plugin -at `tests/_isolate_plugin.py`. This means module-level dicts/sets and -ContextVars from one test cannot leak into the next — the historic -`_reset_module_state` autouse fixture is gone. - -Implementation notes: - -- The plugin uses `multiprocessing.get_context("spawn")`, which works on - Linux, macOS, and Windows alike (POSIX `fork` is not used). -- Per-test overhead is ~0.5–1.0s (Python startup + pytest collection). xdist - parallelism amortizes this across cores; on a 20-core box the full suite - finishes in roughly the same wall time as before, but flake-free. -- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s. - Hangs are killed and surfaced as a failure report. -- Pass `--no-isolate` to disable isolation — useful when debugging a single - test interactively, or when you specifically want to verify state leakage. -- The plugin disables itself in child processes (sentinel envvar - `HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk. - -### Why the wrapper (and why the old "just call pytest" doesn't work) +`scripts/run_tests.sh` probes `.venv`, then `venv`, then `$HOME/.hermes/hermes-agent/venv` for worktrees sharing the main checkout venv. -Five real sources of local-vs-CI drift the script closes: +For gateway/config/toolset changes, add targeted tests before running broad suites. Use foreground commands with sane timeouts; kill anything stuck over ~60 seconds unless it is an expected long test run. -| | Without wrapper | With wrapper | -|---|---|---| -| Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. 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 | -| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) | +## Common workflows -`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest -invocation (including IDE integrations) gets hermetic behavior — but the wrapper -is belt-and-suspenders. - -### Running without the wrapper (only if you must) - -If you can't use the wrapper (e.g. inside an IDE that shells pytest directly), -at minimum activate the venv. The isolation plugin loads automatically from -`addopts` in `pyproject.toml`, so you get the same per-test process isolation -either way. - -```bash -source .venv/bin/activate # or: source venv/bin/activate -python -m pytest tests/ -q -``` - -If you need to bypass isolation for fast feedback while debugging: - -```bash -python -m pytest tests/agent/test_foo.py -q --no-isolate -``` - -Always run the full suite before pushing changes. - -### Don't write change-detector tests - -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:** - -```python -# 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:** - -```python -# 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 -``` +- Gateway status: `hermes gateway status` or `hermes status --all`. +- Gateway logs: `tail -n 120 ~/.hermes/logs/gateway.log`. +- Tool resolution: inspect `hermes_cli/tools_config.py::_get_platform_tools` and `toolsets.py`. +- Session bloat: measure fixed prompt pieces with `AIAgent._build_system_prompt_parts()` and tool schema rough tokens via `agent.model_metadata.estimate_tokens_rough`. -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. +## Reference -Reviewers should reject new change-detector tests; authors should convert -them into invariants before re-requesting review. +Full archived/reference version: `docs/AGENTS.reference.md`. +Load it only for details not covered above: deep architecture, plugin internals, platform edge cases, or historical pitfalls. diff --git a/docs/AGENTS.reference.md b/docs/AGENTS.reference.md new file mode 100644 index 0000000000000..7c324f50332a4 --- /dev/null +++ b/docs/AGENTS.reference.md @@ -0,0 +1,1102 @@ +# Hermes Agent - Development Guide + +Instructions for AI coding assistants and developers working on the hermes-agent codebase. + +## Development Environment + +```bash +# 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). + +## Project Structure + +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 +│ └── / # 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` +│ └── 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 ...]`. + +## File Dependency Chain + +``` +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/ +``` + +--- + +## AIAgent Class (run_agent.py) + +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. + +```python +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 = 90, # 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.""" +``` + +### Agent Loop + +The core loop is inside `run_conversation()` — entirely synchronous, with +interrupt checks, budget tracking, and a one-turn grace call: + +```python +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"]`. + +--- + +## CLI Architecture (cli.py) + +- **Rich** for banner/panels, **prompt_toolkit** for input with autocomplete +- **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results +- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML +- **Skin engine** (`hermes_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 text +- `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry +- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching + +### Slash Command Registry (`hermes_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: + +- **CLI** — `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name +- **Gateway** — `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch +- **Gateway help** — `gateway_help_lines()` generates `/help` output +- **Telegram** — `telegram_bot_commands()` generates the BotCommand menu +- **Slack** — `slack_subcommand_map()` generates `/hermes` subcommand routing +- **Autocomplete** — `COMMANDS` flat dict feeds `SlashCommandCompleter` +- **CLI help** — `COMMANDS_BY_CATEGORY` dict feeds `show_help()` + +### Adding a Slash Command + +1. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`: +```python +CommandDef("mycommand", "Description of what it does", "Session", + aliases=("mc",), args_hint="[arg]"), +``` +2. Add handler in `HermesCLI.process_command()` in `cli.py`: +```python +elif canonical == "mycommand": + self._handle_mycommand(cmd_original) +``` +3. If the command is available in the gateway, add a handler in `gateway/run.py`: +```python +if canonical == "mycommand": + return await self._handle_mycommand(event) +``` +4. For persistent settings, use `save_config_value()` in `cli.py` + +**CommandDef 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. `""`, `"[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. + +--- + +## TUI Architecture (ui-tui + tui_gateway) + +The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via `hermes --tui` or `HERMES_TUI=1`. + +### Process Model + +``` +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. + +### Transport + +Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog. + +### Key Surfaces + +| 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 | + +### Slash Command Flow + +1. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx` +2. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback + +### Dev Commands + +```bash +cd 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 type-check # typecheck only (tsc --noEmit) +npm run lint # eslint +npm run fmt # prettier +npm test # vitest +``` + +### TUI in the Dashboard (`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`. + +- Browser loads `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). +- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not). +- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:;]` 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. + +--- + +## Adding New Tools + +For most custom or local-only tools, do **not** edit Hermes core. Use the plugin +route instead: create `~/.hermes/plugins//plugin.yaml` and +`~/.hermes/plugins//__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`:** +```python +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. + +--- + +## Dependency Pinning Policy + +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,=0.28.1,<1"` | +| Git URL | Commit SHA | `git+https://...@<40-char-sha>` | +| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@ # v4` | +| CI-only pip | `==exact` | `pyyaml==6.0.2` | + +**When adding a new dependency to `pyproject.toml`:** +1. Pin to `>=current_version,=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). + +--- + +## Adding Configuration + +### config.yaml options: +1. Add to `DEFAULT_CONFIG` in `hermes_cli/config.py` +2. Bump `_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. + +### 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 +"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`). + +### Config loaders (three paths — know which one you're in): + +| 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. + +### Working directory: +- **CLI** — uses the process's current directory (`os.getcwd()`). +- **Messaging** — uses `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`. + +--- + +## Skin/Theme System + +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. + +### Architecture + +``` +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 config +- `get_active_skin()` — returns cached `SkinConfig` for the current skin +- `set_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 default +- Missing skin values inherit from the `default` skin automatically + +### What skins customize + +| 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` | + +### Built-in skins + +- `default` — Classic Hermes gold/kawaii (the current look) +- `ares` — Crimson/bronze war-god theme with custom spinner wings +- `mono` — Clean grayscale monochrome +- `slate` — Cool blue developer-focused theme + +### Adding a built-in skin + +Add to `_BUILTIN_SKINS` dict in `hermes_cli/skin_engine.py`: + +```python +"mytheme": { + "name": "mytheme", + "description": "Short description", + "colors": { ... }, + "spinner": { ... }, + "branding": { ... }, + "tool_prefix": "┊", +}, +``` + +### User skins (YAML) + +Users create `~/.hermes/skins/.yaml`: + +```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. + +--- + +## Plugins + +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. + +### General plugins (`hermes_cli/plugins.py` + `plugins//`) + +`PluginManager` discovers plugins from `~/.hermes/plugins/`, `./.hermes/plugins/`, +and pip entry points. Each plugin exposes a `register(ctx)` function that +can: + +- Register Python-callback lifecycle hooks: + `pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`, + `on_session_start`, `on_session_end` +- Register new tools via `ctx.register_tool(...)` +- Register CLI subcommands via `ctx.register_cli_command(...)` — the + plugin's argparse tree is wired into `hermes` at startup so + `hermes ` works with no change to `main.py` + +Hooks 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). + +### Memory-provider plugins (`plugins/memory//`) + +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//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 `. 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. + +### Model-provider plugins (`plugins/model-providers//`) + +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: `/plugins/model-providers//` +2. User: `$HERMES_HOME/plugins/model-providers//` +3. Legacy: `/providers/.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`. + +### Dashboard / context-engine / image-gen plugin directories + +`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`](https://github.com/NousResearch/hermes-example-plugins) +companion repo, not in this tree. + +--- + +## Skills + +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//`. 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/`. + +### SKILL.md frontmatter + +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.`, prompted +during setup, injected at load time). + +Top-level `tags:` and `category:` are also accepted and mirrored from +`metadata.hermes.*` by the loader. + +### Skill authoring standards (HARDLINE) + +Every new or modernized skill — bundled, optional, or contributed — +must meet these standards before merge. Reviewers reject PRs that +violate them. + +1. **`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///SKILL.md').read_text(), + re.MULTILINE) + assert len(m.group(1)) <= 60, len(m.group(1)) + ``` + +2. **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. + +3. **`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. + +4. **`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. + +5. **SKILL.md body uses the modern section order.** `# 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`. + +6. **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. + +7. **Tests live at `tests/skills/test__skill.py`** and use only + stdlib + pytest + `unittest.mock`. No live network calls. Run via + `scripts/run_tests.sh tests/skills/test__skill.py -q`. + +8. **`.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. + +--- + +## 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..enabled` / `tools..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 ` 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 ` +(`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. + +--- + +## Kanban (multi-agent work queue) + +Durable SQLite-backed board that lets multiple profiles / workers +collaborate on shared tasks. Users drive it via `hermes kanban `; +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`. + +--- + +## Important Policies + +### Prompt Caching Must Not Break + +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. + +### Background Process Notifications (Gateway) + +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 message +- `error` — only the final message when exit code != 0 +- `off` — no watcher messages at all + +--- + +## Profiles: Multi-Instance Support + +Hermes 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. + +### Rules for profile-safe code + +1. **Use `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" + ``` + +2. **Use `display_hermes_home()` for user-facing messages.** Import from `hermes_constants`. + This returns `~/.hermes` for default or `~/.hermes/profiles/` 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") + ``` + +3. **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"`. + +4. **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")}): + ... + ``` + +5. **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 `gateway/platforms/telegram.py` for the canonical pattern. + +6. **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. + +## Known Pitfalls + +### DO NOT hardcode `~/.hermes` paths +Use `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. + +### DO NOT introduce new `simple_term_menu` usage +Existing 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. + +### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code +Leaks 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. + +### DO NOT hardcode cross-tool references in schema descriptions +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. + +### The gateway has TWO message guards — both must bypass approval/control commands +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). + +### Squash merges from stale branches silently revert recent fixes +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. + +### Don't wire in dead code without E2E validation +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`. + +### Tests must not write to `~/.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`: +```python +@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 +``` + +--- + +## Testing + +**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, +4 xdist workers matching GHA ubuntu-latest). 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). + +```bash +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::test_x # one test +scripts/run_tests.sh -v --tb=long # pass-through pytest flags +``` + +### Why the wrapper (and why the old "just call pytest" doesn't work) + +Five real sources of local-vs-CI drift the script closes: + +| | Without wrapper | With wrapper | +|---|---|---| +| Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. 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 | +| xdist workers | `-n auto` = all cores (20+ on a workstation) | `-n 4` matching CI | + +`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest +invocation (including IDE integrations) gets hermetic behavior — but the wrapper +is belt-and-suspenders. + +### Running without the wrapper (only if you must) + +If you can't use the wrapper (e.g. on Windows or inside an IDE that shells +pytest directly), at minimum activate the venv and pass `-n 4`: + +```bash +source .venv/bin/activate # or: source venv/bin/activate +python -m pytest tests/ -q -n 4 +``` + +Worker count above 4 will surface test-ordering flakes that CI never sees. + +Always run the full suite before pushing changes. + +### Don't write change-detector tests + +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:** + +```python +# 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:** + +```python +# 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. diff --git a/gateway/config.py b/gateway/config.py index bc077b1994ea8..d9572a59f0f08 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -479,6 +479,7 @@ class GatewayConfig: # Session isolation in shared chats group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available thread_sessions_per_user: bool = False # When False (default), threads are shared across all participants + shared_group_chat_ids: List[str] = field(default_factory=list) # Specific group/channel chat IDs that should share one session # Unauthorized DM policy unauthorized_dm_behavior: str = "pair" # "pair" or "ignore" @@ -583,6 +584,7 @@ def to_dict(self) -> Dict[str, Any]: "stt_enabled": self.stt_enabled, "group_sessions_per_user": self.group_sessions_per_user, "thread_sessions_per_user": self.thread_sessions_per_user, + "shared_group_chat_ids": self.shared_group_chat_ids, "unauthorized_dm_behavior": self.unauthorized_dm_behavior, "streaming": self.streaming.to_dict(), "session_store_max_age_days": self.session_store_max_age_days, @@ -628,6 +630,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": group_sessions_per_user = data.get("group_sessions_per_user") thread_sessions_per_user = data.get("thread_sessions_per_user") + shared_group_chat_ids = data.get("shared_group_chat_ids") or [] + if not isinstance(shared_group_chat_ids, list): + shared_group_chat_ids = [] unauthorized_dm_behavior = _normalize_unauthorized_dm_behavior( data.get("unauthorized_dm_behavior"), "pair", @@ -651,6 +656,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": stt_enabled=_coerce_bool(stt_enabled, True), group_sessions_per_user=_coerce_bool(group_sessions_per_user, True), thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False), + shared_group_chat_ids=[str(v).strip() for v in shared_group_chat_ids if str(v).strip()], unauthorized_dm_behavior=unauthorized_dm_behavior, streaming=StreamingConfig.from_dict(data.get("streaming", {})), session_store_max_age_days=session_store_max_age_days, @@ -720,6 +726,10 @@ def load_gateway_config() -> GatewayConfig: if sr and isinstance(sr, dict): gw_data["default_reset_policy"] = sr + shared_group_chat_ids = yaml_cfg.get("shared_group_chat_ids") + if shared_group_chat_ids is not None: + gw_data["shared_group_chat_ids"] = shared_group_chat_ids + qc = yaml_cfg.get("quick_commands") if qc is not None: if isinstance(qc, dict): diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 125bc1fb6adff..3d8568b8740ce 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2989,6 +2989,7 @@ async def handle_message(self, event: MessageEvent) -> None: event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []), ) # On-entry self-heal: if the adapter still has an _active_sessions diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index 7a4af3ad68574..86b53ebf5b6f1 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -223,8 +223,12 @@ async def disconnect(self) -> None: def _webhook_url(self) -> str: """Compute the external webhook URL for BlueBubbles registration.""" host = self.webhook_host - if host in {"0.0.0.0", "127.0.0.1", "localhost", "::"}: - host = "localhost" + # BlueBubbles Server/Electron on macOS can resolve localhost to ::1, + # while Hermes aiohttp listener is bound to IPv4 127.0.0.1 by default. + # Register the literal IPv4 loopback address so real inbound iMessage + # webhooks hit the listener deterministically. + if host in {"0.0.0.0", "::", "localhost"}: + host = "127.0.0.1" return f"http://{host}:{self.webhook_port}{self.webhook_path}" @property diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index a9b0447080de1..87bcd0d5dc499 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -3058,6 +3058,7 @@ def _media_batch_key(self, event: MessageEvent) -> str: event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []), ) return f"{session_key}:media:{event.message_type.value}" @@ -3343,6 +3344,7 @@ def _text_batch_key(self, event: MessageEvent) -> str: event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []), ) @staticmethod diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 28b086291ae87..7d8cdf84a8c86 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -2255,6 +2255,7 @@ def _text_batch_key(self, event: MessageEvent) -> str: thread_sessions_per_user=self.config.extra.get( "thread_sessions_per_user", False ), + shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []), ) def _enqueue_text_event(self, event: MessageEvent) -> None: diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 5accfdb410899..0fa25d6380276 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -2875,11 +2875,13 @@ def _has_active_session_for_thread( store_cfg = getattr(session_store, "config", None) gspu = getattr(store_cfg, "group_sessions_per_user", True) if store_cfg else True tspu = getattr(store_cfg, "thread_sessions_per_user", False) if store_cfg else False + shared_group_chat_ids = getattr(store_cfg, "shared_group_chat_ids", []) if store_cfg else [] session_key = build_session_key( source, group_sessions_per_user=gspu, thread_sessions_per_user=tspu, + shared_group_chat_ids=shared_group_chat_ids, ) session_store._ensure_loaded() diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 7b13cbc33dc4b..e1d3c19ad1d33 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -4872,6 +4872,7 @@ def _text_batch_key(self, event: MessageEvent) -> str: event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []), ) def _enqueue_text_event(self, event: MessageEvent) -> None: @@ -4961,6 +4962,7 @@ def _photo_batch_key(self, event: MessageEvent, msg: Message) -> str: event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []), ) media_group_id = getattr(msg, "media_group_id", None) if media_group_id: diff --git a/gateway/platforms/wecom.py b/gateway/platforms/wecom.py index 5aad1e09cc506..a12e440f7f540 100644 --- a/gateway/platforms/wecom.py +++ b/gateway/platforms/wecom.py @@ -569,6 +569,7 @@ def _text_batch_key(self, event: MessageEvent) -> str: event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []), ) def _enqueue_text_event(self, event: MessageEvent) -> None: diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index 18d0787c97845..8579acd91c287 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -2510,6 +2510,7 @@ async def handle(self, ctx: InboundContext, next_fn) -> None: ctx.source, group_sessions_per_user=adapter.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=adapter.config.extra.get("thread_sessions_per_user", False), + shared_group_chat_ids=adapter.config.extra.get("shared_group_chat_ids", []), ) async def _dispatch_inbound_event() -> None: diff --git a/gateway/run.py b/gateway/run.py index 9ca87452f9787..68c07b57688c3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -925,6 +925,7 @@ def _reload_runtime_env_preserving_config_authority() -> None: build_session_context, build_session_context_prompt, build_session_key, + group_sessions_per_user_for_source, is_shared_multi_user_session, ) from gateway.delivery import DeliveryRouter @@ -2066,6 +2067,7 @@ def _session_key_for_source(self, source: SessionSource) -> str: source, group_sessions_per_user=getattr(config, "group_sessions_per_user", True), thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False), + shared_group_chat_ids=getattr(config, "shared_group_chat_ids", []), ) def _telegram_topic_mode_enabled(self, source: SessionSource) -> bool: @@ -4320,17 +4322,9 @@ async def _process_handoff(self, row: Dict[str, Any]) -> None: ) # Compute the gateway's session_key for that destination using the - # same rules its adapters use, so switch_session targets the right - # entry. For thread destinations build_session_key keys without - # user_id (thread_sessions_per_user defaults to False) — so the - # next real user message in the thread shares this same session. - platform_cfg = self.config.platforms.get(platform) - extra = platform_cfg.extra if platform_cfg else {} - session_key = build_session_key( - dest_source, - group_sessions_per_user=extra.get("group_sessions_per_user", True), - thread_sessions_per_user=extra.get("thread_sessions_per_user", False), - ) + # same rules its adapters/session store use, so switch_session targets + # the right entry (including any shared_group_chat_ids overrides). + session_key = self._session_key_for_source(dest_source) # Make sure there's an entry in the session_store for this key. If # the home channel has never been used, get_or_create_session @@ -5975,6 +5969,10 @@ def _create_adapter( "thread_sessions_per_user", getattr(self.config, "thread_sessions_per_user", False), ) + config.extra.setdefault( + "shared_group_chat_ids", + getattr(self.config, "shared_group_chat_ids", []), + ) # ── Plugin-registered platforms (checked first) ─────────────────── try: @@ -7649,7 +7647,11 @@ async def _prepare_inbound_message_text( """ history = history or [] message_text = event.text or "" - _group_sessions_per_user = getattr(self.config, "group_sessions_per_user", True) + _group_sessions_per_user = group_sessions_per_user_for_source( + source, + group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True), + shared_group_chat_ids=getattr(self.config, "shared_group_chat_ids", []), + ) _thread_sessions_per_user = getattr(self.config, "thread_sessions_per_user", False) # Use the same helper every other call site uses so the write key here # matches the consume key at the run_conversation site — even if the diff --git a/gateway/session.py b/gateway/session.py index 5f6fcb9a62fae..55849e4ef1bf8 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -593,14 +593,39 @@ def is_shared_multi_user_session( if source.chat_type == "dm": return False if source.thread_id: + if not group_sessions_per_user: + return True return not thread_sessions_per_user return not group_sessions_per_user +def group_sessions_per_user_for_source( + source: SessionSource, + *, + group_sessions_per_user: bool = True, + shared_group_chat_ids: Optional[List[str]] = None, +) -> bool: + """Return the effective per-user isolation setting for this source. + + ``group_sessions_per_user`` remains the safe global default. Specific + group/channel chat IDs can opt into one shared session by listing the raw + ``chat_id`` (or ``chat_id_alt``) in ``shared_group_chat_ids``. + """ + if source.chat_type in {"group", "channel"}: + ids = {str(v).strip() for v in (shared_group_chat_ids or []) if str(v).strip()} + if ids and ( + str(source.chat_id) in ids + or (source.chat_id_alt is not None and str(source.chat_id_alt) in ids) + ): + return False + return group_sessions_per_user + + def build_session_key( source: SessionSource, group_sessions_per_user: bool = True, thread_sessions_per_user: bool = False, + shared_group_chat_ids: Optional[List[str]] = None, ) -> str: """Build a deterministic session key from a message source. @@ -625,6 +650,12 @@ def build_session_key( shared session per chat. - Without identifiers, messages fall back to one session per platform/chat_type. """ + group_sessions_per_user = group_sessions_per_user_for_source( + source, + group_sessions_per_user=group_sessions_per_user, + shared_group_chat_ids=shared_group_chat_ids, + ) + platform = source.platform.value if source.chat_type == "dm": dm_chat_id = source.chat_id @@ -743,10 +774,16 @@ def _save(self) -> None: def _generate_session_key(self, source: SessionSource) -> str: """Generate a session key from a source.""" - return build_session_key( + group_sessions_per_user = group_sessions_per_user_for_source( source, group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True), + shared_group_chat_ids=getattr(self.config, "shared_group_chat_ids", []), + ) + return build_session_key( + source, + group_sessions_per_user=group_sessions_per_user, thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False), + shared_group_chat_ids=getattr(self.config, "shared_group_chat_ids", []), ) def _is_session_expired(self, entry: SessionEntry) -> bool: @@ -1334,7 +1371,11 @@ def build_session_context( home_channels=home_channels, shared_multi_user_session=is_shared_multi_user_session( source, - group_sessions_per_user=getattr(config, "group_sessions_per_user", True), + group_sessions_per_user=group_sessions_per_user_for_source( + source, + group_sessions_per_user=getattr(config, "group_sessions_per_user", True), + shared_group_chat_ids=getattr(config, "shared_group_chat_ids", []), + ), thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False), ), ) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index efe0b5d1de70c..9e3fe18a24671 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -4850,6 +4850,7 @@ def _text_batch_key(self, event: MessageEvent) -> str: event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []), ) def _enqueue_text_event(self, event: MessageEvent) -> None: diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index da7673011fe87..34b55198e7fd4 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -199,6 +199,7 @@ def test_full_roundtrip(self): quick_commands={"limits": {"type": "exec", "command": "echo ok"}}, group_sessions_per_user=False, thread_sessions_per_user=True, + shared_group_chat_ids=["group-1", "group-2"], ) d = config.to_dict() restored = GatewayConfig.from_dict(d) @@ -209,6 +210,15 @@ def test_full_roundtrip(self): assert restored.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}} assert restored.group_sessions_per_user is False assert restored.thread_sessions_per_user is True + assert restored.shared_group_chat_ids == ["group-1", "group-2"] + + def test_from_dict_coerces_shared_group_chat_ids_to_strings(self): + restored = GatewayConfig.from_dict({"shared_group_chat_ids": [" group-1 ", 123, ""]}) + assert restored.shared_group_chat_ids == ["group-1", "123"] + + def test_from_dict_ignores_non_list_shared_group_chat_ids(self): + restored = GatewayConfig.from_dict({"shared_group_chat_ids": "group-1"}) + assert restored.shared_group_chat_ids == [] def test_roundtrip_preserves_unauthorized_dm_behavior(self): config = GatewayConfig( @@ -306,6 +316,23 @@ def test_thread_sessions_per_user_defaults_to_false(self, tmp_path, monkeypatch) assert config.thread_sessions_per_user is False + def test_bridges_shared_group_chat_ids_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "shared_group_chat_ids:\n" + " - group-1\n" + " - 123\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.shared_group_chat_ids == ["group-1", "123"] + def test_bridges_discord_thread_require_mention_from_config_yaml(self, tmp_path, monkeypatch): """discord.thread_require_mention in config.yaml should reach the runtime env var.""" hermes_home = tmp_path / ".hermes" diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 6e2c39f797277..160711d608d68 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -12,6 +12,7 @@ build_session_context_prompt, build_session_key, canonical_whatsapp_identifier, + group_sessions_per_user_for_source, ) # Legacy name preserved for these tests; product renamed the function to @@ -765,6 +766,132 @@ def test_store_shares_group_sessions_when_disabled_in_config(self, store): assert second_entry.session_key == "agent:main:discord:group:guild-123" assert first_entry.session_id == second_entry.session_id + def test_store_shares_only_configured_group_chat_ids(self, store): + store.config.group_sessions_per_user = True + store.config.shared_group_chat_ids = ["guild-123"] + + shared_alice = SessionSource( + platform=Platform.DISCORD, + chat_id="guild-123", + chat_type="group", + user_id="alice", + user_name="Alice", + ) + shared_bob = SessionSource( + platform=Platform.DISCORD, + chat_id="guild-123", + chat_type="group", + user_id="bob", + user_name="Bob", + ) + isolated = SessionSource( + platform=Platform.DISCORD, + chat_id="guild-999", + chat_type="group", + user_id="alice", + user_name="Alice", + ) + + assert group_sessions_per_user_for_source( + shared_alice, + group_sessions_per_user=True, + shared_group_chat_ids=["guild-123"], + ) is False + assert group_sessions_per_user_for_source( + isolated, + group_sessions_per_user=True, + shared_group_chat_ids=["guild-123"], + ) is True + + first_entry = store.get_or_create_session(shared_alice) + second_entry = store.get_or_create_session(shared_bob) + isolated_entry = store.get_or_create_session(isolated) + + assert first_entry.session_key == "agent:main:discord:group:guild-123" + assert second_entry.session_key == "agent:main:discord:group:guild-123" + assert first_entry.session_id == second_entry.session_id + assert isolated_entry.session_key == "agent:main:discord:group:guild-999:alice" + + def test_shared_group_chat_id_override_does_not_affect_dms(self): + source = SessionSource( + platform=Platform.DISCORD, + chat_id="guild-123", + chat_type="dm", + user_id="alice", + ) + + assert group_sessions_per_user_for_source( + source, + group_sessions_per_user=True, + shared_group_chat_ids=["guild-123"], + ) is True + assert build_session_key( + source, + group_sessions_per_user=True, + shared_group_chat_ids=["guild-123"], + ) == "agent:main:discord:dm:guild-123" + + def test_shared_group_chat_id_override_matches_chat_id_alt(self): + alice = SessionSource( + platform=Platform.SIGNAL, + chat_id="display-name", + chat_id_alt="stable-group-id", + chat_type="group", + user_id="alice", + ) + bob = SessionSource( + platform=Platform.SIGNAL, + chat_id="display-name", + chat_id_alt="stable-group-id", + chat_type="group", + user_id="bob", + ) + + assert group_sessions_per_user_for_source( + alice, + group_sessions_per_user=True, + shared_group_chat_ids=["stable-group-id"], + ) is False + assert build_session_key( + alice, + group_sessions_per_user=True, + shared_group_chat_ids=["stable-group-id"], + ) == build_session_key( + bob, + group_sessions_per_user=True, + shared_group_chat_ids=["stable-group-id"], + ) + + def test_shared_group_chat_id_marks_thread_as_shared_even_when_threads_per_user(self): + source = SessionSource( + platform=Platform.DISCORD, + chat_id="guild-123", + chat_type="group", + thread_id="thread-1", + user_id="alice", + ) + + effective_group_sessions_per_user = group_sessions_per_user_for_source( + source, + group_sessions_per_user=True, + shared_group_chat_ids=["guild-123"], + ) + + assert effective_group_sessions_per_user is False + assert build_session_key( + source, + group_sessions_per_user=effective_group_sessions_per_user, + thread_sessions_per_user=True, + ) == "agent:main:discord:group:guild-123:thread-1" + + from gateway.session import is_shared_multi_user_session + + assert is_shared_multi_user_session( + source, + group_sessions_per_user=effective_group_sessions_per_user, + thread_sessions_per_user=True, + ) is True + def test_telegram_dm_includes_chat_id(self): """Non-WhatsApp DMs should also include chat_id to separate users.""" source = SessionSource( diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 0f83e40c3c96e..cd7b74878d2b4 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -774,7 +774,14 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, elif platform == Platform.WECOM: result = await _send_wecom(pconfig.extra, chat_id, chunk) elif platform == Platform.BLUEBUBBLES: - result = await _send_bluebubbles(pconfig.extra, chat_id, chunk) + # When send_message is called from inside a running BlueBubbles gateway, + # re-use the live adapter. Creating a fresh BlueBubblesAdapter calls + # connect(), which tries to bind the webhook listener port again and + # fails with EADDRINUSE. If live adapter access fails, fall back to + # one-shot REST, which does not bind the webhook port. + result = await _send_via_adapter(platform, pconfig, chat_id, chunk) + if isinstance(result, dict) and result.get("error"): + result = await _send_bluebubbles(pconfig.extra, chat_id, chunk) elif platform == Platform.QQBOT: result = await _send_qqbot(pconfig, chat_id, chunk) elif platform == Platform.YUANBAO: @@ -1589,32 +1596,65 @@ async def _send_weixin(pconfig, chat_id, message, media_files=None): async def _send_bluebubbles(extra, chat_id, message): - """Send via BlueBubbles iMessage server using the adapter's REST API.""" - try: - from gateway.platforms.bluebubbles import BlueBubblesAdapter, check_bluebubbles_requirements - if not check_bluebubbles_requirements(): - return {"error": "BlueBubbles requirements not met (need aiohttp + httpx)."} - except ImportError: - return {"error": "BlueBubbles adapter not available."} + """Send via BlueBubbles iMessage server using one-shot REST only. + Do not instantiate/connect BlueBubblesAdapter here: connect() starts the + webhook listener and will collide with a running gateway on port 8645. This + tool path only needs outbound REST: resolve the chat, then POST message/text. + """ try: - from gateway.config import PlatformConfig - pconfig = PlatformConfig(extra=extra) - adapter = BlueBubblesAdapter(pconfig) - connected = await adapter.connect() - if not connected: - return _error("BlueBubbles: failed to connect to server") - try: - result = await adapter.send(chat_id, message) - if not result.success: - return _error(f"BlueBubbles send failed: {result.error}") - return {"success": True, "platform": "bluebubbles", "chat_id": chat_id, "message_id": result.message_id} - finally: - await adapter.disconnect() + import httpx + from urllib.parse import quote + from gateway.platforms.bluebubbles import BlueBubblesAdapter + + server_url = (extra.get("server_url") or extra.get("url") or os.getenv("BLUEBUBBLES_SERVER_URL", "")).rstrip("/") + password = extra.get("password") or os.getenv("BLUEBUBBLES_PASSWORD", "") + if not server_url or not password: + return _error("BlueBubbles: BLUEBUBBLES_SERVER_URL and BLUEBUBBLES_PASSWORD are required") + + def api_url(path: str) -> str: + sep = chr(38) if "?" in path else "?" + return f"{server_url}{path}{sep}password={quote(password, safe='')}" + + async with httpx.AsyncClient(timeout=30.0) as client: + guid = chat_id if ";-;" in str(chat_id) or ";+;" in str(chat_id) else None + if not guid: + resp = await client.post(api_url("/api/v1/chat/query"), json={"limit": 200, "offset": 0, "with": ["participants"]}) + resp.raise_for_status() + chats = (resp.json() or {}).get("data") or [] + wanted = str(chat_id).strip() + variants = {wanted} + digits = "".join(ch for ch in wanted if ch.isdigit()) + if digits: + variants.update({digits, "1" + digits[-10:], "+1" + digits[-10:]}) + for chat in chats: + candidates = {str(chat.get("guid") or ""), str(chat.get("chatIdentifier") or "")} + for participant in chat.get("participants") or []: + if isinstance(participant, dict): + candidates.add(str(participant.get("address") or "")) + if variants & {c.strip() for c in candidates if c}: + guid = chat.get("guid") + break + if not guid: + return _error(f"BlueBubbles chat not found for target: {chat_id}") + + chunks = [] + for para in [p.strip() for p in re.split(r'\n\s*\n', message) if p.strip()] or [message]: + if len(para) <= BlueBubblesAdapter.MAX_MESSAGE_LENGTH: + chunks.append(para) + else: + chunks.extend(BlueBubblesAdapter.truncate_message(para, max_length=BlueBubblesAdapter.MAX_MESSAGE_LENGTH)) + + last_id = None + for chunk in chunks: + resp = await client.post(api_url("/api/v1/message/text"), json={"chatGuid": guid, "tempGuid": f"temp-{time.time()}", "message": chunk}) + resp.raise_for_status() + data = (resp.json() or {}).get("data") or {} + last_id = data.get("guid") or data.get("messageGuid") or "ok" + return {"success": True, "platform": "bluebubbles", "chat_id": chat_id, "message_id": str(last_id or "ok")} except Exception as e: return _error(f"BlueBubbles send failed: {e}") - async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None): """Send via Feishu/Lark using the adapter's send pipeline.""" try: