# Hermes Agent source analysis (for a CS beginner)

> **Subject**: [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent) v0.19.0
> **Based on commit**: `d9165d7a678d4105f42921a7fc1886df3804531b` (2026-07-23, `fix: resolve current entry unlocked in try_refresh_matching no-hint branch`)
> **Analysis date**: 2026-07-24
> **Size**: about 3300 Python files (backend core) + about 1600 TypeScript/TSX files (TUI, desktop, Web dashboard)
> **How to read**: this lecture is aligned with a “Claude Code source analysis” series, 12 chapters. Each chapter tries: analogy → real source (`file:line`) → line-by-line → design trade-offs. Every “not found / not supported” claim was checked against the tree; nothing is invented.

---

> 🗺️ **Companion architecture diagram**: this project has a dedicated figure in the [**seven-agent architecture gallery**](../架构图库.html#ch1)—**control/data plane + dual-timeline state ownership (memory writes to disk immediately, but only the next session enters the prompt)**.
> Every gallery figure first states “what question it answers” and “the load-bearing claim,” then goes through three review and render-acceptance rounds.

## Chapter 1 · Project overview: a “self-evolving personal agent”

### 1.1 One sentence

**Hermes Agent is Nous Research’s open-source personal AI agent whose biggest selling point is a self-improving learning loop**: it does not only answer questions; it also turns successful experience into “skills,” writes facts into “memory,” can search its own past conversations, and does not live only in the terminal — it can hang on Telegram, Discord, Slack, WhatsApp and about 20 messaging platforms at once, on a $5 VPS or a GPU cluster.

The README’s first sentence states the positioning (`README.md:19`):

> **The self-improving AI agent built by Nous Research.** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions.

### 1.2 Who builds it

**Nous Research** — an AI research company known for open-weight models (they train the Hermes fine-tune series). That explains two “research flavors”:

- built-in **trajectory batch generation and compression** tools (`batch_runner.py`, `trajectory_compressor.py`) to produce training data for the next generation of tool-calling models;
- the model layer is fully open: default attach is their own **Nous Portal** (300+ models), but also OpenRouter, OpenAI, self-hosted endpoints, and 30+ other providers.

### 1.3 Stack

| Layer | Tech | Location |
|---|---|---|
| Backend core (agent loop, tools, gateway) | Python 3.11–3.13 (`pyproject.toml:13` requires `>=3.11,<3.14`) | repo root plus `agent/`, `tools/`, `gateway/`, etc. |
| Terminal UI (TUI) | TypeScript + React + Ink | `ui-tui/` |
| Desktop app | Electron + React (TS) | `apps/desktop/` |
| Web dashboard | Vite + React (TS) | `web/` |
| Package manager | uv (all deps **exact-version locked**, no ranges) | `pyproject.toml`, `uv.lock` |

One counter-intuitive point: **Python is the brain; TypeScript is only the face.** The TUI README is blunt (`ui-tui/README.md`): "TypeScript owns the screen. Python owns sessions, tools, model calls, and most command logic." — the TS side talks to the Python process over newline-delimited JSON-RPC on stdio (`tui_gateway/`).

### 1.4 Vs Claude Code (one-sentence level)

- **Same**: both are “model + tool-calling loop” agents; both have slash commands, AGENTS.md/CLAUDE.md context files, sub-agents, MCP, a skill system.
- **Different (one sentence)**: Claude Code is a **programming assistant that lives in the terminal** (closed-source, bound to Anthropic models); Hermes Agent is a **cross-session self-learning, model-agnostic, all-platform personal agent that lives in messaging apps** (MIT open source, model-agnostic).

```mermaid
flowchart LR
    subgraph CC["Claude Code"]
        A1[终端编程助手] --> A2[绑定 Anthropic 模型]
    end
    subgraph HA["Hermes Agent"]
        B1[个人 agent + 学习闭环] --> B2[30+ 模型供应商任选]
        B1 --> B3[约 20 个消息平台接入]
        B1 --> B4[技能/记忆/会话搜索 自我改进]
    end
    CC -.->|都对标| HA
```

---

## Chapter 2 · Panorama: one Python brain, many faces

### 2.1 Layered overview

Hermes architecture in one sentence: **one shared Python agent core, wrapped in N UI shells**. `AGENTS.md:9-14` (design guide for developers) verbatim:

> Hermes is a personal AI agent that runs the same agent core across a CLI, a messaging gateway (Telegram, Discord, Slack, and ~20 other platforms), a TUI, and an Electron desktop app. … It is extended primarily through **plugins and skills**, not by growing the core.

```mermaid
flowchart TB
    subgraph UI["界面层（多张脸）"]
        CLI["cli.py<br/>prompt_toolkit REPL"]
        TUI["ui-tui/ (TS: React+Ink)<br/>──JSON-RPC/stdio──▶ tui_gateway/ (Python)"]
        DESK["apps/desktop/<br/>Electron"]
        WEB["web/<br/>Vite 仪表盘"]
        ACP["acp_adapter/<br/>ACP 服务器（给 Zed 等编辑器）"]
        GW["gateway/<br/>消息网关"]
    end

    subgraph INPUT["输入与分流"]
        SLASH["slash 命令注册表<br/>hermes_cli/commands.py"]
        PLAT["plugins/platforms/<br/>telegram/discord/slack/whatsapp/signal/email…约20个"]
    end

    subgraph CORE["核心（Python 大脑）"]
        AGENT["run_agent.py : AIAgent 类"]
        LOOP["agent/conversation_loop.py<br/>run_conversation() 主循环"]
        CTX["agent/system_prompt.py<br/>三层系统提示词"]
        MODEL["model_tools.py<br/>工具分发 handle_function_call()"]
    end

    subgraph TOOLS["工具与能力层"]
        REG["tools/registry.py 注册表<br/>74 个内置工具"]
        ENV["tools/environments/<br/>local/docker/ssh/singularity/modal/daytona"]
        MCP["tools/mcp_tool.py<br/>MCP 客户端"]
        PLUGIN["plugins/<br/>provider/平台/看板等插件"]
    end

    CLI --> SLASH --> AGENT
    TUI --> AGENT
    DESK --> GW
    WEB --> GW
    ACP --> AGENT
    GW --> PLAT --> AGENT
    AGENT --> LOOP --> MODEL --> REG
    LOOP --> CTX
    REG --> ENV
    REG --> MCP
    REG --> PLUGIN
```

### 2.2 Key directories, quick lookup

| Directory/file | Role |
|---|---|
| `run_agent.py` (6907 lines) | the `AIAgent` class itself (`run_agent.py:400`), the agent’s “ID card + toolbox” |
| `agent/conversation_loop.py` (6163 lines) | main loop `run_conversation()` (line 669), the heart of the whole project |
| `agent/` (~160 `.py` files, including `__init__.py`) | system prompt, compression, budget, adapters (anthropic/gemini/bedrock/vertex…) |
| `tools/` (102 files) | implementations of 74 built-in tools |
| `toolsets.py` | toolset definitions: group tools, switch by platform/scene |
| `hermes_cli/` (160+ files) | the `hermes` CLI itself (setup, auth, model, doctor…) |
| `cli.py` (16590 lines) | interactive terminal REPL (the largest single file) |
| `gateway/` + `plugins/platforms/` | messaging gateway and ~20 platform adapters |
| `acp_adapter/` | ACP protocol server |
| `plugins/model-providers/` | 33 model-provider plugins |
| `cron/` | built-in scheduled-job scheduler |
| `skills/` | built-in skill library (software engineering, data science, smart home……) |
| `ui-tui/`, `apps/desktop/`, `web/` | three TS UIs |

### 2.3 What is acp_adapter?

**ACP = Agent Client Protocol** (a Zed-led standard protocol between editors ↔ AI agents, analogous to LSP for language servers). The comment in `acp_adapter/server.py:1` is one line: "ACP agent server — exposes Hermes Agent via the Agent Client Protocol." Entry is `acp_adapter/entry.py`: after start, stdout is reserved for ACP JSON-RPC transport; logs all go to stderr. So **editors that support ACP, such as Zed, can drive Hermes as an embedded agent backend** — Claude Code has this capability too (Claude Code also supports ACP); the two projects stand on the same ecosystem line for this protocol.

---

## Chapter 3 · Startup: from the `hermes` command to the chat UI

### 3.1 Where is the entry

The Python package CLI entry is defined in `pyproject.toml:308-311`:

```toml
[project.scripts]
hermes = "hermes_cli.main:main"        # pyproject.toml:309
hermes-acp = "acp_adapter.entry:main"  # pyproject.toml:311
```

So when you type `hermes` in the terminal, you actually run `main()` in `hermes_cli/main.py` (`hermes_cli/main.py:14339`).

### 3.2 The start of main() does a pile of “self-rescue”

The beginning of this `main()` is interesting — it does not parse arguments first; it chains **self-repair** (`hermes_cli/main.py:14339-14384`):

```python
def main():                                  # main.py:14339
    _set_process_title()                     # 14343: 让 ps/top 里显示 'hermes' 而不是 'python3.11'
    configure_windows_stdio()                # 14348: Windows 上强制 UTF-8，防中文乱码崩溃
    _cleanup_quarantined_exes()              # 14356: 清扫上次 hermes update 留下的旧 exe
    if "update" not in sys.argv[1:]:         # 14371
        _recover_from_interrupted_install()  # 14372: 上次更新被 Ctrl+C 打断？先把 venv 修好
    ...
    parser, subparsers, chat_parser = build_top_level_parser()  # 14383: 现在才开始解析参数
    chat_parser.set_defaults(func=cmd_chat)                     # 14384: 默认子命令 = 聊天
```

Line by line:

1. **Rename the process**: purely UX, so you can recognize it in `htop`;
2. **UTF-8 fix**: Windows consoles default to not UTF-8; printing Chinese/emoji would `UnicodeEncodeError` crash, so it must be fixed before any `print`;
3. **venv self-heal**: if the last `hermes update` crashed between `git reset --hard` and `uv pip install`, the environment is “new code citing old packages.” `main()` uses the pure-stdlib module `_early_recovery` to repair **before importing any third-party library** (`main.py:67-84` comments are candid: without this, the user cannot even run `hermes update`, and can never self-heal).

This is defensive design unique to software that “runs long-term on a personal computer and self-updates” — a Web service that dies has ops; a personal CLI that dies can only crawl back itself.

### 3.3 Argument parse and the chat entry

`main()` registers dozens of subcommands with argparse (`model`, `gateway`, `setup`, `cron`, `doctor`, `claw migrate`……), then defaults into `cmd_chat` (`main.py:2435`).

Before chat, the CLI still has a long prep: read `~/.hermes/config.yaml` and `.env`, resolve the current provider’s runtime credentials (refresh OAuth tokens if needed), enable toolsets from config, create an `AIAgent` instance, restore the last session (`/resume`), run startup security checks (`security_audit_startup.py`), and only then start the prompt_toolkit input UI. Chat flow roughly:

```mermaid
sequenceDiagram
    participant U as 用户
    participant M as hermes_cli/main.py: main()
    participant R as _early_recovery
    participant C as cli.py 交互循环
    participant A as run_agent.AIAgent
    participant L as conversation_loop

    U->>M: 敲 `hermes`
    M->>R: venv 自愈 / UTF-8 / 清旧文件
    M->>M: argparse 解析（默认 cmd_chat）
    M->>C: 加载 config.yaml + .env，创建 AIAgent
    C->>A: AIAgent(base_url, api_key, model, toolsets...)
    C->>C: prompt_toolkit 启动输入界面
    loop 每一轮对话
        C->>L: run_conversation(user_message)
        L-->>C: 流式输出 + 工具执行
    end
```

### 3.4 Auth

Auth config lives in two places (`hermes_cli/auth.py:1-16` module comment):

- `~/.hermes/.env`: traditional API keys (OpenRouter, OpenAI, custom endpoints……);
- `~/.hermes/auth.json`: OAuth credential state (**Nous Portal** uses OAuth device code, `hermes setup --portal` one-command login), with a cross-process file lock.

First-run `hermes setup` wizard walks you through provider, key, default model; with no auth, `cmd_chat` takes you into setup first. If it detects `~/.openclaw` (config dir of the similar project OpenClaw), it will actively offer a **one-click migrate** of persona, memory, skills, and API keys (`README.md:187-213`) — even user-grabbing is in the source. Very geek.

---

## Chapter 4 · Input capture and routing: where every character you type goes

### 4.1 CLI side: prompt_toolkit harvests input

Interactive terminal input is **prompt_toolkit** (`cli.py:60-75` imports a dozen components at once: `Application`, `Layout`, `KeyBindings`, `TextArea`, …), providing multiline edit, slash autocomplete, history (`FileHistory`), paste detection — a full terminal experience.

Once input is in hand, routing is roughly this decision chain:

```mermaid
flowchart TD
    A[用户输入一行文本] --> B{以 / 开头?}
    B -->|是| C["查 COMMAND_REGISTRY<br/>hermes_cli/commands.py:64"]
    C -->|命中| D[本地执行 slash 命令<br/>/model /compress /new /yolo...]
    C -->|是 /<br/>skill 名| E["加载技能内容<br/>作为本轮提示"]
    B -->|否| F{看起来像文件路径?<br/>cli.py:3109}
    F -->|是| G[作为附件读取<br/>图片走视觉]
    F -->|否| H[普通消息]
    D --> Z[回到输入提示符]
    E --> H
    G --> H
    H --> I["agent.run_conversation()<br/>cli.py:12500"]
```

### 4.2 Slash commands: one central registry

All slash commands are defined in one registry `COMMAND_REGISTRY` (`hermes_cli/commands.py:64`), **83 `CommandDef`s** in the file. Each definition looks like (`commands.py:46-61`):

```python
@dataclass(frozen=True)
class CommandDef:
    name: str            # 不带斜杠的正式名，如 "background"
    description: str     # 给人看的描述
    category: str        # 分类："Session"、"Configuration"…
    aliases: tuple = ()  # 别名，如 ("bg",)
    args_hint: str = ""  # 参数提示，如 "<prompt>"
    cli_only: bool = False       # 只在 CLI 可用
    gateway_only: bool = False   # 只在消息网关可用
```

The clever bit is the comment (`commands.py:3-5`): **"Every consumer — CLI help, gateway dispatch, Telegram BotCommands, Slack subcommand mapping, autocomplete — derives its data from COMMAND_REGISTRY."** The registry is the single source of truth: Telegram bot command menus, Slack subcommands, CLI Tab complete, all generated from this table. Add one command, five UIs light up.

A few representative commands (all from `commands.py`):

| Command | Role | Routes to |
|---|---|---|
| `/new` `/reset` | open a new session | rebuild session locally, do not send to the model |
| `/model [name]` | switch model (session-level, `--global` persists) | change config locally |
| `/compress` | manual context compact | call the compressor (Chapter 8) |
| `/background <prompt>` (aliases `/bg`, `/btw`) | throw a task to the background | asynchronously spawn an agent task |
| `/queue <prompt>` | do not interrupt current work; queue for next round | local queue |
| `/steer <prompt>` | quietly insert an instruction after the next tool call | steering channel |
| `/yolo` | toggle “skip-approval mode” | local switch |
| `/reasoning [level]` | adjust thinking strength (none/minimal/low/medium/high/xhigh/max/ultra, eight gears) | change reasoning config |

### 4.3 Busy-time input: three philosophies — `/queue`, `/steer`, `/interrupt`

When you press Enter while the agent is working, CLI behavior is configurable (`/busy` command, options `queue|steer|interrupt`, `commands.py:171`):

- **queue**: you keep talking; it does not interrupt; it looks next round;
- **steer**: your words are injected “after the next tool call”; the model changes direction mid-flight;
- **interrupt**: stop immediately and listen.

That is one more gear than Claude Code’s Esc-interrupt + queue dual mode: “mid-task steer,” answering the real pain of “long task half-done, want to add a requirement.”

### 4.4 Gateway side: messaging-platform routing

After the messaging gateway (`gateway/`) receives a Telegram (etc.) message, routing is a set of `_handle_xxx_command` async methods in `gateway/slash_commands.py` (e.g. `_handle_model_command` at `:1435`, `_handle_stop_command` at `:1070`). The comment at `hermes_cli/commands.py:396-404` explains the gateway **Level-2 handler**: when a running task receives `/model`, `/compress` mid-flight, you cannot brutally interrupt; they enter a pending queue until the current step ends.

The gateway registry also has an engineering-detail: platforms have hard caps on bot command count, written as constants — Telegram menus at most 60 (Bot API hard cap 100, `commands.py:554-555`), Slack at most 50 (`:1133`). So the registry must pick a “most worth exposing” subset per platform, not blindly sync everything.

File attachments have dedicated handling: a path typed in the CLI (`cli.py:3109-3127` recognizes `/`, `~`, `./`, `C:\`, even quoted paths) is parsed as an attachment; images go the vision path; multi-image has compact badges for narrow terminals (`cli.py:3158-3176`, even Termux small screens). On the gateway, voice messages go to text (`agent/transcription_provider.py` family).

**Three routing examples**, to pin the flowchart to reality:

1. Input `/model openrouter:anthropic/claude-sonnet-4` → hits the registry → local model switch, **no API request at all**;
2. Input `./report.pdf 帮我总结` → path detection hits → PDF content as attachment plus “帮我总结” compose a user message into the main loop;
3. While the agent is busy, input `顺便把结果发到邮箱` → follows `/busy` as queue/steer/interrupt; in steer mode this sentence is injected after the next tool call (`conversation_loop.py:823-830` `_drain_pending_redirect` does exactly that).

---

## Chapter 5 · Context assembly: how the “first page” sent to the model is built

### 5.1 A three-layer sandwich

System-prompt assembly is in `agent/system_prompt.py`. The module comment (`system_prompt.py:10-19`) splits it into three layers, joined with `\n\n`:

```python
# Three tiers are joined with ``\n\n``:
#
# * ``stable``   — identity (SOUL.md or DEFAULT_AGENT_IDENTITY), tool
#   guidance, computer-use guidance, nous subscription block, ...
# * ``context``  — caller-supplied ``system_message`` plus context files
#   (AGENTS.md / .cursorrules / etc.) discovered under ``TERMINAL_CWD``.
# * ``volatile`` — memory snapshot, USER.md profile, external memory
#   provider block, timestamp/session/model/provider line.
```

```mermaid
flowchart TB
    subgraph SP["系统提示词 = 三层拼接"]
        S["stable 稳定层<br/>身份(SOUL.md 或默认身份) + 工具使用规约<br/>+ 按模型家族定制的操作指导 + 平台提示"]
        C["context 上下文层<br/>AGENTS.md / CLAUDE.md / .cursorrules<br/>+ 调用方给的 system_message"]
        V["volatile 易变层<br/>MEMORY.md 快照 + USER.md 用户画像<br/>+ 时间戳/会话/模型行"]
    end
    S --> C --> V
    V --> R["每次请求原样发送<br/>（前缀缓存命中 → 省钱）"]
```

**Why three layers?** The answer hides in the design principle `AGENTS.md:19-23` treats as gospel for the whole project:

> **Per-conversation prompt caching is sacred.**

Upstream APIs bill by “prefix” cache: if you change the system prompt every round, the cache all misses, the user’s bill doubles. So Hermes made an iron law — **the system prompt is byte-stable for the life of a conversation** (`AGENTS.md:90-91` verbatim "a system prompt that is byte-stable for the life of a conversation"). Memory wrote new content? Persist to disk only, do not change the prompt; it takes effect next session (`tools/memory_tool.py:9-12` explicitly describes this “frozen snapshot” mode). Stable layer first (never changes), volatile last (minimize change) — to maximize cache hits.

### 5.2 Identity and persona: SOUL.md

Default identity is a hardcoded constant (`agent/prompt_builder.py:139-147`):

```python
DEFAULT_AGENT_IDENTITY = (
    "You are Hermes Agent, an intelligent AI assistant created by Nous Research. "
    ...
)
```

Users can write a custom persona in `~/.hermes/SOUL.md` (`load_soul_md()`, `prompt_builder.py:1875`); `/personality` switches preset personalities.

### 5.3 Project context files: AGENTS.md / CLAUDE.md / .cursorrules

`agent/coding_context.py:86` defines files automatically read into context:

```python
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
```

That is: Hermes **directly compatible with Claude Code’s CLAUDE.md** — switch from a Claude Code project, no context-file rewrite. `coding_context.py` also does something smarter: **detect the workspace “project fingerprint”** — recognize `pyproject.toml`, `package.json`, `Cargo.toml` and other project markers (`coding_context.py:76-83`), infer the package manager from lockfiles (uv/pnpm/yarn…, `:142-146`), mine “verify commands” (test/lint/build) from `package.json` scripts and Makefile (`:149-150`), inject those as environment hints.

### 5.4 An underrated detail: prompts customized by model family

`_EDIT_FORMAT_GUIDANCE` in `coding_context.py:171-187` is, in this author’s view, one of the most “in-the-know” designs in the whole project:

```python
_EDIT_FORMAT_GUIDANCE = {
    "patch": (("gpt", "codex"),
        "- Edit format: ... use `patch` with `mode='patch'` (V4A diff) ..."),
    "replace": (("claude", "sonnet", "opus", "haiku",
        "gemini", "gemma", "deepseek", "qwen", "kimi", "glm", "grok",
        "hermes", "llama", "mistral", "devstral", "minimax"),
        "- Edit format: ... prefer `patch` in `mode='replace'` ..."),
}
```

Meaning: GPT/Codex-family models in their official harness (codex-rs) only learned apply_patch-format edits, so tell them to use V4A diff; Claude, Gemini, DeepSeek, Qwen, Kimi, etc. saw more “string find-replace” editors in training, so tell them to use replace mode. **The same tool, different instruction manuals for different models** — the comment is clear: "Matching the edit tool format to how a model was trained reduces mistakes and wasted reasoning." A model-agnostic agent going this deep is rare.

### 5.5 Where environment info and memory are injected

The volatile layer injects: snapshots of MEMORY.md and USER.md (Chapter 8), external memory (Honcho, Chapter 8), current timestamp/session ID/model name. Injection is fixed at the end of the system prompt — again for prefix cache.

---

## Chapter 6 · The agent loop (the heart): a nearly 5500-line function

### 6.1 Where the loop body is

The core of the whole agent is `run_conversation()` in `agent/conversation_loop.py` (**line 669**). This function runs from line 669 to the end of the file (6163), nearly 5500 lines — the largest and most critical single function in the project. The loop condition is at **line 822**:

```python
while (api_call_count < agent.max_iterations
       and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
```

Three exits are obvious:

1. `api_call_count < agent.max_iterations` — per-round API call cap (default **90**, `run_agent.py:434`);
2. `iteration_budget.remaining > 0` — **iteration budget** shared across sub-agents (see 6.5);
3. `_budget_grace_call` — the “last chance” after budget exhaustion (grace call, so it can wrap up decently).

### 6.2 What each iteration does

```mermaid
flowchart TD
    A["每轮开始"] --> B["排出用户中途转向消息<br/>_drain_pending_redirect (loop:823)"]
    B --> C{"用户按了中断?<br/>loop:837"}
    C -->|是| Z["退出: interrupted_by_user"]
    C -->|否| D["消耗 1 点迭代预算<br/>loop:853"]
    D --> E["组装 API 请求<br/>messages + 工具定义"]
    E --> F["调用模型 API<br/>chat.completions.create<br/>chat_completion_helpers.py:430/2683"]
    F --> G{"模型回复里有<br/>tool_calls?"}
    G -->|没有| H["final_response = 文本<br/>loop 结束"]
    G -->|有| I["messages.append(assistant_msg)<br/>loop:5337"]
    I --> J["先把工具调用块持久化到会话库<br/>loop:5366"]
    J --> K["_execute_tool_calls 执行全部工具<br/>loop:5387 → run_agent.py:6443"]
    K --> L["工具结果以 role=tool 消息<br/>追加回 messages"]
    L --> M{"护栏触发停机?<br/>loop:5389"}
    M -->|是| Y["受控停机 guardrail_halt"]
    M -->|否| A
    H --> N["返回最终回复"]
```

In spoken-technical English: **ask the model → the model says it wants tools → record “it wants tools” into the dialogue → execute tools → record tool results into the dialogue → ask the model again with the full dialogue → until the model no longer wants tools and just speaks.** That is the common heartbeat of every tool-calling agent (the engineered version of ReAct). Claude Code’s heartbeat is the same rhythm; the difference is all in the details.

To “see” the loop, here is how the `messages` array evolves in one real conversation (every request sends this array as a whole to the API):

```python
# 第 1 次请求时：
[{"role": "system", "content": "You are Hermes Agent…(三层提示词)"},
 {"role": "user",   "content": "帮我查一下明天北京天气"}]

# 模型第 1 次回复后追加（loop:5337 的 messages.append(assistant_msg)）：
 {"role": "assistant", "content": None,
  "tool_calls": [{"id": "call_1", "function":
                  {"name": "web_search", "arguments": '{"query":"北京 明天 天气"}'}}]}

# 工具执行完再追加（role=tool 的结果消息）：
 {"role": "tool", "tool_call_id": "call_1", "name": "web_search",
  "content": "{\"results\": [...]} "}

# 第 2 次请求时：以上 4 条全部发出。模型这次不再调工具，
# 直接回复文本 → final_response 落定，循环结束。
```

Note the **strict alternation** invariant: user → assistant → tool → assistant…… `AGENTS.md:90-91` lists "never two same-role messages in a row" as an iron law alongside caching, because most providers’ APIs reject role-scrambled sequences. A lot of loop code (e.g. merging adjacent user messages near `conversation_loop.py:1142`, filling error results for illegal tool calls at `:5346-5355` to keep pairing) is carefully maintaining this invariant.

### 6.3 An easy-to-miss, life-saving order: persist first, then execute tools

Note step J in the flowchart above. The comment at `conversation_loop.py:5361-5366`:

```python
# Persist the assistant tool-call turn before any tool
# side effects run. If a destructive tool restarts or
# terminates Hermes mid-turn, resume logic still sees the
# exact tool-call block that already executed.
agent._flush_messages_to_session_db(messages, conversation_history)
```

Meaning: **write “the model decided to call tools” into the database first, then execute tools.** Why? Tools can have side effects — e.g. the `terminal` tool ran `hermes restart`, the process dies on the spot. Without persist-first, after restart the agent would not know what it just did, and might repeat a destructive operation. Order bought with blood.

### 6.4 Streaming

The streaming path is `agent/chat_completion_helpers.py:2683` (`stream = request_client.chat.completions.create(**stream_kwargs)`, where `stream_kwargs` includes `stream=True`). Stream deltas go via `stream_delta_callback` all the way to CLI/TUI/gateway render layers; TTS can even start speaking before text generation finishes (`run_conversation`’s `stream_callback` parameter, `conversation_loop.py:688-690`).

### 6.5 Iteration budget: a fuel gauge for “thinking”

`agent/iteration_budget.py` defines a thread-safe counter `IterationBudget`:

- parent agent budget = `max_iterations` (default 90);
- each sub-agent budget is independent (`delegation.max_iterations`, default 50);
- `consume()` spends, `refund()` returns (e.g. programmatic tool calls from `execute_code` do not eat budget, `iteration_budget.py:25-27`).

Why “budget” and not only a “count cap”? In a real task, some API calls are “free” (retries, re-sends after compact) and should not debit. Budget allows **semantic fuel** rather than count fuel: refundable, last-chance grace call, a clear message when exhausted (`conversation_loop.py:856` prints "⚠️ Iteration budget exhausted").

### 6.6 Interrupt

Interrupt is “cooperative”: each loop round starts by checking `agent._interrupt_requested` (`conversation_loop.py:837`); if set, break. CLI Ctrl+C, gateway `/stop`, a new message arriving, all eventually set this flag. Cooperative means an in-flight tool call itself is not force-killed — safe, but also means a stuck shell command must die by timeout, not interrupt.

---

## Chapter 7 · Tools and permissions: 74 weapons, grouped locks, approval to pass

### 7.1 How tools are registered

All built-in tools live under `tools/`, declaring themselves through the central registry in `tools/registry.py`. File tools as an example (`tools/file_tools.py`, one long call per line):

```python
registry.register(name="read_file",  toolset="file", schema=READ_FILE_SCHEMA,
                  handler=_handle_read_file, check_fn=_check_file_reqs,
                  emoji="📖", max_result_size_chars=100_000)
registry.register(name="write_file", toolset="file", schema=WRITE_FILE_SCHEMA,
                  handler=_handle_write_file, check_fn=_check_file_reqs, emoji="✍️", ...)
registry.register(name="patch",      toolset="file", schema=PATCH_SCHEMA, ...)
registry.register(name="search_files", toolset="file", schema=SEARCH_FILES_SCHEMA, ...)
```

Each tool registers five things: **name, JSON Schema (the instruction manual for the model), handler (the function that actually works), check_fn (availability check), owning toolset**. `registry.py` also has a clever design: AST-scan each `tools/*.py` file; only modules that actually call `registry.register()` at top level are imported (`registry.py:29-47`) — avoid running every tool directory’s side effects just to discover tools.

**Schema is the “user manual” for the model**; quality directly decides whether the model will use it. A real example (`tools/file_tools.py:1947`):

```python
READ_FILE_SCHEMA = {
    "name": "read_file",
    "description": "Read a text file with line numbers and pagination. "
                   "Use this instead of cat/head/tail in terminal. ... "
                   "Cannot read images — use vision_analyze for images.",
    "parameters": {
        "type": "object",
        "properties": {
            "path":   {"type": "string",  "description": "Path to the file ..."},
            "offset": {"type": "integer", "default": 1, "minimum": 1},
            "limit":  {"type": "integer", "default": 500, "maximum": 2000}
        },
        "required": ["path"]
    }
}
```

Note the wording in description is entirely for the model: “use this instead of cat,” “cannot read images, use vision_analyze” — natural-language routing. Neighbor `write_file` schema (`file_tools.py:1961`) hides two engineering details: after writing `.py`/`.json` etc., **auto-run syntax check**, only report “errors you newly introduced this time” (old errors filtered, so the model does not go fix unrelated code); and a `cross_profile` soft guardrail, default blocking the agent from modifying another Hermes profile’s skills and memory. These “schemes inside the manual” are the least flashy and most valuable assets of an agent project.

`check_fn` is “service gating”: e.g. desktop-only `read_terminal`, `open_preview` appear in the tool list only when a GUI environment is detected (`HERMES_DESKTOP`) (`toolsets.py:36-39` comment). That echoes the `AGENTS.md` “Footprint Ladder”: new capability first as CLI command + skill, then service-gated tools, then plugins, **last consider adding a core tool** — because every core tool’s schema is sent with every API call, a continuous bill.

### 7.2 Full built-in tool inventory (by group)

The table below is compiled from every `registry.register(name="...")` in `tools/*.py` (**74** total), grouped by `TOOLSETS` in `toolsets.py:96`:

| Group | Tools | Function | Key params/examples |
|---|---|---|---|
| **Terminal and process** | `terminal` | run shell commands (six backends, see 7.4) | `command`, `background=True` |
| | `process` | manage background processes (list/logs/kill) | `action="list"` |
| | `read_terminal` `close_terminal` `focus_pane` `open_preview` | desktop pane ops (GUI-gated) | read embedded terminal content |
| **Files** | `read_file` | read file (with line numbers) | `path` |
| | `write_file` | write/create file | `path`, `content` |
| | `patch` | edit file: `mode="replace"` (find-replace) or `mode="patch"` (V4A multi-file diff) | `path`, `mode` |
| | `search_files` | content search (ripgrep) | `pattern`, `path` |
| **Web** | `web_search` | web search (multi-backend: Firecrawl/Exa/Brave…) | `query` |
| | `web_extract` | fetch and extract page body | `url` |
| | `x_search` | X (Twitter) search | `query` |
| **Browser** | `browser_navigate` `browser_snapshot` `browser_click` `browser_type` `browser_scroll` `browser_back` `browser_press` `browser_get_images` `browser_vision` `browser_console` `browser_cdp` `browser_dialog` | full browser automation (snapshot=a11y tree, vision=screenshot) | `browser_click(element="登录按钮")` |
| | `computer_use` | desktop-level mouse/keyboard | screenshot + click coords |
| **Vision and generation** | `vision_analyze` | look at images (multimodal) | `image_path`, `prompt` |
| | `video_analyze` | look at video | `video_path` |
| | `image_generate` | text-to-image (FAL etc.) | `prompt` |
| | `video_generate` `xai_video_edit` `xai_video_extend` | video generate/edit | `prompt` |
| | `text_to_speech` | text to speech | `text`, `voice` |
| **Memory and planning** | `memory` | read/write MEMORY.md / USER.md (Chapter 8) | `action="add"`, `target="user"` |
| | `todo` | task-list management | `action="add"` |
| | `session_search` | FTS5 full-text search of historical sessions | `query` |
| **Skills** | `skills_list` | list available skills (metadata only) | — |
| | `skill_view` | read a skill’s full SKILL.md | `name` |
| | `skill_manage` | **the agent itself creates/modifies/deletes skills** | `action="create"` |
| **Collab and delegation** | `delegate_task` | spawn a sub-agent (Chapter 9) | `goal`, `tasks=[...]` |
| | `clarify` | actively ask the user a clarifying question | `question` |
| | `execute_code` | write a Python script that RPC-calls tools; fold a multi-step pipeline into one round | `code` |
| **Cron and kanban** | `cronjob` | manage scheduled jobs | `action="create"`, `schedule` |
| | `kanban_show` `kanban_list` `kanban_create` `kanban_complete` `kanban_block` `kanban_unblock` `kanban_comment` `kanban_link` `kanban_heartbeat` `kanban_attach` `kanban_attach_url` `kanban_attachments` | built-in kanban (task board for multi-agent collab) | `kanban_create(title="修 bug")` |
| **Project and platforms** | `project_list` `project_create` `project_switch` | desktop multi-project switch | — |
| | `discord` `discord_admin` | Discord messages and admin | — |
| | `send_message` | cross-platform send | `platform`, `target` |
| **Smart home / office** | `ha_list_entities` `ha_get_state` `ha_list_services` `ha_call_service` | Home Assistant family | `ha_call_service(domain="light")` |
| | `feishu_doc_read` `feishu_drive_list_comments` `feishu_drive_list_comment_replies` `feishu_drive_reply_comment` `feishu_drive_add_comment` | Feishu docs/drive comments | — |
| | `yb_query_group_info` `yb_query_group_members` `yb_send_dm` `yb_search_sticker` `yb_send_sticker` | Yuanbao (Tencent) group chat | — |

Additionally, enabling “tool search” adds 3 bridge tools `tool_search` / `tool_describe` / `tool_call` (Chapter 10); MCP server tools also merge in dynamically.

### 7.3 Toolsets: weapon racks switched by scene

`TOOLSETS` in `toolsets.py:96` groups tools into switchable sets (web, file, terminal, browser, skills, memory, delegation, coding……); `hermes tools` can toggle interactively. Core set `_HERMES_CORE_TOOLS` (`toolsets.py:31`) is the default base. The motive is the same as the slash-command registry: **freeze the schema at session start**, do not swap mid-session (swapping toolsets would destroy prefix cache).

### 7.4 Permissions and approval: dangerous commands need a “confirm”

**Dangerous-command detection** is in `tools/approval.py`: `DANGEROUS_PATTERNS` (`:606`) is a pattern table, `detect_dangerous_command()` (`:1988`) decides, `prompt_dangerous_approval()` (`:2273`) pops confirm in CLI / sends an async approval message on the gateway. Approval results are graded: allow once / allow for the session / add to permanent allowlist (written into `config.yaml`). There is also “smart approval”: a cheap helper model scores command risk; low risk auto-passes (`approval.py:1-11` module comment).

The most reassuring one-liner is (`approval.py:35`):

```python
_YOLO_MODE_FROZEN: bool = is_truthy_value(os.getenv("HERMES_YOLO_MODE", ""))
```

YOLO (You Only Live Once, skip-approval) mode is **frozen at module import**. The comment explains why: if every call read the env var, any in-process skill could bypass all approval by changing the env — a prompt-injection privilege-escalation channel. **The safety switch must be typed earlier than injectable code.** That detail shows threat-modeling awareness.

Two more lines of defense:

- **Tirith pre-exec scan** (`tools/tirith_security.py`): call an external tirith binary to scan content-level threats in the command (lookalike phishing URLs, pipe-to-interpreter, terminal injection); exit codes 0/1/2 = allow/block/warn; auto-download of the binary forces SHA-256 check, and with cosign also verifies supply-chain signatures.
- **Path security** (`tools/path_security.py`) and URL safety (`url_safety.py`): limit file-tool range, intercept intranet addresses, etc.

```mermaid
flowchart LR
    M[模型请求 terminal 执行命令] --> D{"detect_dangerous_command<br/>approval.py:1988"}
    D -->|安全| R[直接执行]
    D -->|危险| T{"tirith 扫描<br/>tirith_security.py"}
    T -->|拦截| X[拒绝并告知模型]
    T -->|警告/放行| Y{"YOLO 已冻结开启?"}
    Y -->|是| R
    Y -->|否| P["prompt_dangerous_approval<br/>CLI 弹窗 / 网关异步消息"]
    P -->|允许| R
    P -->|拒绝| X
    P -->|永久允许| W[写入 config.yaml 白名单] --> R
```

### 7.5 Shell-exec safety design: six “cells”

`terminal` tool backends live in `tools/environments/`, six kinds: `local` (bare on this machine), `docker` (container isolation), `ssh` (remote machine), `singularity` (HPC container), `modal` and `daytona` (two serverless cloud sandboxes). The comment in `terminal_tool.py:5-9` states the trade-off: "local" is fastest but unisolated; "docker" isolates but needs Docker installed; Modal/Daytona provide **sleep/wake persistent sandboxes** — idle environments hibernate, almost free, wake with state still there.

That is: Hermes’s security model is not “sandbox everything by default,” but **let the user pick isolation by task**: reply to email with local; dump untrusted code into Docker/Modal. Combined with the approval flow, three layers: “detect → approve → isolate.”

---

## Chapter 8 · Compaction and memory: short memory via compact, long memory via files

### 8.1 Why compact is needed

Model context windows are finite (e.g. 200K tokens), and agent sessions’ tool results (especially web pages, logs) eat space. Chat long enough and you must throw something away — Hermes’s approach is **let a cheap model write “meeting minutes.”**

### 8.2 The compressor: keep head and tail, squeeze the middle

The module comment of `agent/context_compressor.py` (`:1-18`) writes the strategy plainly:

- use a **helper model** (the cheap/fast one) to summarize the **middle** of the conversation;
- **protect the head** (system prompt, original instructions) and **the tail** (recent work site);
- structured summary template: track “resolved/todo” issues;
- historical section headings instead of “next step” wording (so old summaries are not misread as new instructions);
- **iterative summary update**: later compacts keep information from previous summaries;
- tail protection is by token budget (not a fixed message count);
- a cheap pre-scan of “tool-output pruning” before summarizing.

Trigger threshold defaults to **50%** of the context window (`agent/agent_init.py:1765`: `compression_threshold = float(_compression_cfg.get("threshold", 0.50))`), auto-raised per model; at most 3 compact attempts per round (`conversation_loop.py:781` `max_compression_attempts` default). Compact is also **the only scene allowed to rebuild the system prompt** — `AGENTS.md:19-23` says prefix cache is sacred, "the one exception is context compression".

**A concrete example**: you ask the agent to “research 20 papers on Rust async runtimes and write a survey.” After paper 12, context hits 50% of the window; compact starts — dozens of `web_extract` results from the first 12 papers (maybe hundreds of thousands of characters) go to a cheap model, compressed into a few-thousand-word structured minute: “read 12 papers, core claims are……; todo: 8 unread, the list is……”; then the agent continues paper 13 with that minute. Head (your original instruction) and tail (recent work site) are untouched. For the model, “memory” is seamlessly replaced by “notes”; work does not stop.

Another compressor with a completely different use is root `trajectory_compressor.py`: it is **not used at runtime**, but offline-processes finished agent trajectories (JSONL), same “keep head and tail, squeeze middle,” pressing trajectories into a target token budget — for producing training data (`trajectory_compressor.py:5-16` strategy list is almost isomorphic to the online compressor). One project keeping both “online money-saving compact” and “offline data-making compact” is the twin design unique to a research-company project.

```mermaid
flowchart LR
    subgraph BEFORE["压缩前（接近窗口上限）"]
        H1[系统提示+早期指令] --> M1[几十轮工具调用与结果<br/>网页/日志/代码…]
        M1 --> T1[最近几轮工作现场]
    end
    subgraph AFTER["压缩后"]
        H2[系统提示+早期指令<br/>原样保留] --> S["一条结构化摘要<br/>（辅助模型撰写：<br/>已解决/待办/关键结论）"]
        S --> T2[最近几轮工作现场<br/>原样保留]
    end
    BEFORE -->|context_compressor.py| AFTER
```

### 8.3 Long-term memory: two markdown files

Cross-session memory is provided by `tools/memory_tool.py`, extremely naive — **just two files** (`memory_tool.py:4-7`):

- `MEMORY.md`: the agent’s own notes (environment facts, project conventions, tool quirks);
- `USER.md`: what it knows about “you” (preferences, communication style, work habits).

Entries are separated by `§`; limits are in characters (because character count is model-agnostic). The memory tool has only three actions: `add`, `replace`, `remove`; the latter two locate entries by “short unique substrings” (`memory_tool.py:17-19`).

The most critical design is the **frozen snapshot** (`memory_tool.py:9-12`): both files are injected as a snapshot at session start; mid-session writes persist immediately, but **do not update the system prompt** — keep prefix cache; they take effect next session. Prefer that the agent “temporarily does not know what it just remembered” rather than touch the cache. That is Chapter 5’s iron law taken all the way.

### 8.4 Memory-system panorama: far more than two files

```mermaid
flowchart TB
    subgraph MEM["Hermes 的记忆与学习体系"]
        A["MEMORY.md / USER.md<br/>声明性记忆（事实与偏好）"]
        B["skills/<br/>程序性记忆（怎么做）<br/>skill_manage 工具自动沉淀"]
        C["会话库 SQLite + FTS5<br/>session_search 跨会话全文搜索"]
        D["curator.py 后台策展<br/>空闲时评审/归档/合并旧技能"]
        E["Honcho 外部记忆<br/>辩证式用户建模（可选）"]
        F["记忆提醒 nudge<br/>定期提示自己'该存知识了'"]
    end
    A --> G[系统提示词易变层]
    B --> G
    E --> G
    C -.按需工具调用.-> G
```

- **Curator**: `agent/curator.py` starts a background review agent when the agent is **idle**, touching only “skills the agent itself created”; it can pin/archive/merge/patch, but **never auto-deletes** (archive is recoverable); it uses the helper model, never touching the main session’s prompt cache.
- **session_search**: SQLite FTS5 full-text indexes all historical sessions; `session_search_tool.py:8` onward describes discover (with query) → expand (±window context) two modes, and can LLM-summarize old sessions.
- **Honcho**: optional third-party “dialectical user modeling” service (`hermes honcho` family of commands, `main.py:21-36`), so the agent builds a deepening cross-session model of “who you are.”

This combo of “declarative memory + procedural skills + searchable history + background curation + user modeling” is the entity behind README’s “closed learning loop.” Versus Claude Code’s auto-memory (also markdown files + load into context), Hermes adds **skill self-creation, background curation, and session search**.

---

## Chapter 9 · Sub-agents and multi-agent: send a doppelgänger to work

### 9.1 delegate_task: spawn a doppelgänger

Sub-agents are implemented by the `delegate_task` tool (`tools/delegate_tool.py`). The module comment (`delegate_tool.py:1-19`) describes what each “child” gets:

- a brand-new conversation (**without parent history**);
- its own task_id (its own terminal session, file cache);
- inherits the parent toolset, but **strips tools blocked for sub-agents**;
- a dedicated system prompt built from “goal + context.”

The parent context only sees “the delegation call” and “the summary result” — the child’s intermediate reasoning and tool calls never pollute parent context. That is the famous “context isolation” pattern from Anthropic’s multi-agent research.

The blocklist (`delegate_tool.py:46-55`):

```python
DELEGATE_BLOCKED_TOOLS = frozenset([
    "delegate_task",  # 不许递归委派
    "clarify",        # 不许找用户聊天
    "memory",         # 不许写共享记忆
    "send_message",   # 不许跨平台发消息
    "cronjob",        # 不许以父代名义排定时任务
])
```

Each line is a permission boundary: the child is a “worker,” not “another you.”

### 9.2 Foreground or background? Depends who sent it

The comment at `run_agent.py:6497-6507` clarifies foreground/background semantics, quite decisive:

> Delegations from the top-level MODEL always run in the background — the model does not get to choose.

**Delegations initiated by the top-level model always run in the background**: `delegate_task` immediately returns a handle; when the sub-agent finishes, its result returns to the parent session as **a new message**. The only exception: delegations from an “orchestrator” sub-agent (depth > 0) stay synchronous — because the orchestrator needs worker results in its own round to write a summary, and sub-agents do not own a gateway session, so async results have nowhere to go.

**Two typical uses**:

- **Fan-out**: one `delegate_task` with `tasks=[调研A, 调研B, 调研C]`, three sub-agents start in parallel, the parent keeps working, three summaries “reply” in sequence;
- **Orchestrator mode**: spawn an orchestrator sub-agent, which itself synchronously spawns several workers, collects results into one comprehensive report, then hands it to the parent — parent context from start to finish only sees one report, does not even know three workers existed.

```mermaid
sequenceDiagram
    participant P as 父 agent（主会话）
    participant D as delegate_task
    participant S1 as 子代理 A
    participant S2 as 子代理 B
    P->>D: tasks=[调研X, 调研Y]
    D->>S1: 后台启动（独立上下文/预算50）
    D->>S2: 后台启动（独立上下文/预算50）
    D-->>P: 立即返回句柄，父继续干活
    S1-->>P: 完成 → 结果作为新消息注入
    S2-->>P: 完成 → 结果作为新消息注入
    P->>P: 汇总两个结果，回复用户
```

Safety detail: sub-agents run in a thread pool, default **auto-deny** every dangerous-command approval (`_subagent_auto_deny`, `delegate_tool.py:74-86`) — because a background thread cannot get the CLI’s interactive approval callback; calling `input()` would deadlock with the parent’s TUI. The comment (`delegate_tool.py:65-73`) writes this deadlock scene very specifically; they clearly stepped on it. Only explicit `delegation.subagent_auto_approve: true` allows (an “opt-in YOLO” for cron/batch).

### 9.3 Budget and depth control

- each sub-agent has an independent `IterationBudget` (default 50, §6.5); parent 90 + each child 50, total can exceed the parent cap;
- max concurrent sub-agents, max async sub-agents, max spawn depth (`_get_max_spawn_depth`) all have limits (`delegate_tool.py:354-467`), preventing the agent from “having grandchildren” out of control.

### 9.4 execute_code: fold a multi-step pipeline into one round

Another multi-agent-related capability is the `execute_code` tool: the agent writes a Python script; the script RPC-calls other tools — e.g. “search 10 pages, extract each, assemble a table” would be 11 request rounds; now one script, **intermediate results never enter model context** (README:28 so-called "zero-context-cost turns"). That is a root-cause solution to “tool results explode the context.”

---

## Chapter 10 · Ecosystem: commands, skills, MCP, plugins, ACP

```mermaid
flowchart TB
    H["Hermes 核心（窄腰）"] --- A["83 个 slash 命令<br/>COMMAND_REGISTRY"]
    H --- B["技能系统<br/>agentskills.io 标准 + 自建技能"]
    H --- C["MCP 客户端 + mcp_serve.py<br/>（既是 MCP 食客也是 MCP 餐馆）"]
    H --- D["插件系统 plugins/<br/>平台/provider/看板/记忆…"]
    H --- E["acp_adapter<br/>ACP 服务器，接入 Zed 等编辑器"]
    H --- F["tool_search<br/>渐进式工具披露"]
```

### 10.1 Command inventory

The 83 slash commands were introduced in Chapter 4 (registry at `hermes_cli/commands.py:64`). The CLI top level also has dozens of `hermes xxx` subcommands (`main.py:6-43` docstring is a catalog): `gateway`, `setup`, `cron`, `doctor`, `honcho`, `claw migrate`, `sessions browse`……

### 10.2 Skill system: “procedural memory” compatible with agentskills.io

A skill = a directory containing `SKILL.md` (YAML frontmatter for name and description, body for steps), plus optional `references/`, `templates/`, `scripts/`, `assets/` (`tools/skills_tool.py:7-29`). It uses **progressive disclosure**: `skills_list` only shows the model metadata (name ≤64 chars, description ≤1024 chars); if interested, `skill_view` reads the full text, files on demand — so hundreds of skills will not explode context.

Most distinctive is the `skill_manage` tool (`tools/skill_manager_tool.py`): **the agent can create a skill itself after finishing a complex task**, writing “the method just verified” as reusable SKILL.md into `~/.hermes/skills/`, later calling it on similar tasks. Plus Chapter 8’s curator background review, “smarter the more you use it” is not copy; it is a code path. Skill format is compatible with the [agentskills.io](https://agentskills.io) open standard; there is also a Skills Hub for installing community skills.

### 10.3 MCP support: both client and server

- **Client**: `tools/mcp_tool.py` supports stdio, HTTP/StreamableHTTP, SSE three transports; config under `mcp_servers` in `~/.hermes/config.yaml` (module comment `:12-39` has a full example including timeout, keepalive, idle recycle). Discovered MCP tools register into the tool registry, treated the same as builtins. Also supports MCP OAuth (`mcp_oauth_manager.py`).
- **Server**: `mcp_serve.py` inversely exposes Hermes’s own capabilities as an MCP server to other MCP hosts. Bidirectional.

### 10.4 Plugin system

`plugins/` is organized by category: `platforms/` (~20 messaging-platform adapters), `model-providers/` (33 model vendors), `kanban`, `memory`, `web`, `security-guidance`, `spotify`…… Platform plugins self-register via `PluginContext.register_platform()` in `gateway/platform_registry.py` (`platform_registry.py:8`), and are **lazy-loaded** (`:175-198`): do not import discord.py for a telegram user for nothing.

### 10.5 ACP: plugging into the editor ecosystem

As in Chapter 2, `acp_adapter/` wraps Hermes as an ACP (Agent Client Protocol) server; ACP clients such as editors drive it over stdio JSON-RPC: session management (`session.py`), permission-approval forwarding (`permissions.py`, `edit_approval.py`), event stream (`events.py`). Start with `hermes acp` or the standalone `hermes-acp` command (`acp_adapter/entry.py:9-14`).

### 10.6 tool_search: “on-demand shelf” when there are too many tools

When MCP + plugin tools are many, sending the full schema every request is wasteful. `tools/tool_search.py`’s solution (module comment `:1-26`): pull deferrable tools off the model-visible list, leave only 3 bridge tools — `tool_search` (search tools), `tool_describe` (read the manual), `tool_call` (actually call). Threshold default 10%: if deferrable tools’ schema is under 10% of the context window, pass through as-is. Core tools (`_HERMES_CORE_TOOLS`) **are never deferred**. The comment also mentions a lesson from an OpenClaw cron-regression accident (the catalog must be rebuilt every time, cannot be session-cached) — you can see this project’s documentation culture: every design decision writes “why” and “what pit we stepped in.”

---

## Chapter 11 · Features: model freedom, auth freedom

### 11.1 Auth and providers

Both worlds are supported (`hermes_cli/auth.py:1-16`):

- **OAuth device code**: Nous Portal (`hermes setup --portal`, one login unlocks 300+ models and a tool gateway);
- **API key**: OpenRouter, OpenAI, custom OpenAI-compatible endpoints, etc., stored in `~/.hermes/.env` or an external secret manager (Bitwarden, 1Password, `hermes secrets` command).

`plugins/model-providers/` lists **33 provider plugins**, including: nous, openrouter, anthropic, openai-codex, gemini, bedrock, vertex, azure-foundry, deepseek, xai, groq-family fireworks/deepinfra/novita, alibaba (including coding-plan), kimi-coding, minimax, zai, qwen-oauth, ollama-cloud, huggingface, copilot, arcee, stepfun, upstage, xiaomi, custom…… plus native adapters in `agent/` for anthropic/gemini/bedrock/vertex/codex (handling each vendor’s non-OpenAI-compatible protocol).

```mermaid
flowchart LR
    U[用户] -->|hermes setup --portal| P["Nous Portal<br/>OAuth 一键登录"]
    U -->|hermes model| K["API key 提供商<br/>33 个插件"]
    P --> M["300+ 模型<br/>/model 随时换"]
    K --> M
    M --> F["fallback 链<br/>主模型挂了自动换备胎"]
    M --> MO["MoA 混部<br/>/moa 多问几个模型再综合"]
```

### 11.2 Model selection and switching

`hermes model` interactive select + `/model` in-session switch + `--global` persist (`commands.py:134`). **Fallback chain** (`hermes fallback`): when the primary is rate-limited/overloaded/disconnected, try backups in order. Also **MoA (Mixture of Agents)**: `/moa <question>` sends the same question to multiple models then synthesizes (`commands.py:116`).

### 11.3 Local models and self-hosting (NousResearch’s home turf)

As a home of open-weight models, Nous naturally treats self-hosting as first-class:

- `custom` provider + any OpenAI-compatible endpoint (vLLM, SGLang, llama.cpp all work);
- `ollama-cloud` plugin; provider-catalog comments also mention lmstudio, openai-api and other “undocumented spec providers” (`hermes_cli/provider_catalog.py:22`);
- Nous’s own **Hermes-series open-weight models** (e.g. Hermes 4) are trained for this kind of tool-calling loop — a matching “model + harness” pair with this project.

### 11.4 thinking / effort

The `/reasoning` command provides eight gears of reasoning strength (none/minimal/low/medium/high/xhigh/max/ultra, `commands.py:158-160`), and can control display of reasoning content (show/hide/full). Internally there is `agent/think_scrubber.py` (scrub thinking tags) and `agent/reasoning_timeouts.py` (reasoning timeout “de-pressure” guidance for the model, `thinking_timeout_guidance.py`).

### 11.5 Feature tour

| Feature | Location | Notes |
|---|---|---|
| Sustained goal `/goal` | `hermes_cli/goals.py` | after each round, a helper model referees: “is the goal done?” if not, auto-continue one more round — the comment calls it "the Ralph loop"; referee failure fail-opens and continues |
| Scheduled jobs | `cron/scheduler.py` | built-in scheduler (60s tick, file lock against re-entry), natural-language task description, results can deliver to any messaging platform |
| Voice mode | `tools/tts_tool.py`, `voice_mode.py` | streaming TTS, start reading before text finishes; voice messages to text |
| Kanban | `tools/kanban_tools.py` | multi-agent shared task board, 12 kanban_* tools |
| Trajectory production | `batch_runner.py`, `trajectory_compressor.py` | batch-run tasks to generate training trajectories, then compact the middle to a token budget — data for the next model |
| Desktop | `apps/desktop/` | Electron app (Hermes Desktop), with project switch, preview pane |
| Web dashboard | `web/` | Vite + React browser console |

---

## Chapter 12 · Summary: design philosophy and trade-offs

### 12.1 Three beliefs that run through everything

After reading the code, almost every corner repeats the two-and-a-half principles in `AGENTS.md:16-27`:

**① Prefix cache is sacred.** System prompt byte-stable, toolsets frozen at session level, memory via frozen snapshot, skills via progressive disclosure, compact the only exception. Everything is for “long sessions hit the cached prefix every round,” pressing the user’s API bill to a minimum. That is **economics promoted to an architectural constraint**.

**② Narrow-waist core; capability grows at the edge.** "Every model tool we add is sent on every API call, so the bar for a new *core* tool is high." (`AGENTS.md:24-27`) Priority ladder for new capability: extend existing code → CLI command + skill → service-gated tool (check_fn) → plugin → MCP → only as a last resort add a core tool. So we see: platforms are plugins, providers are plugins, even smart home and Feishu are gated tools. The core does one thing — keep the loop stable.

**③ Supply-chain paranoia and a self-healing constitution.** All deps exact-version locked (`pyproject.toml:17-30` comments record the full 2026-05-12 tightening after a PyPI poisoning incident); chained venv self-heal at startup; tirith download forces signature check; YOLO flag frozen at import. That is the basic self-requirement of software that “must run unattended for months on a stranger’s computer.”

```mermaid
flowchart TB
    P1["① 前缀缓存神圣<br/>省钱：提示词 byte-stable"] --> C["Hermes 设计内核"]
    P2["② 核心窄腰<br/>省心：能力全走插件/技能/MCP"] --> C
    P3["③ 自愈与供应链偏执<br/>省事：无人值守也要活着"] --> C
    C --> R["结果：一个能在 5 美元 VPS 上<br/>长期自主运行、越用越懂你的 agent"]
```

### 12.2 Real highlights

1. **Closed-loop learning is a code path, not a slogan**: `skill_manage` (skills from experience) → `curator.py` (review skills when idle) → `memory` (write facts) → `session_search` (search history) → Honcho (user modeling), five pieces interlocked.
2. **Model-agnostic taken to the extreme**: 33 provider plugins + prompts customized by model family (§5.4 edit-format guidance) + fallback chain + MoA.
3. **Gateway-first “agent as a contact”**: ~20 platform adapters, async approval, voice messages, cron delivery — it is not a tool you open; it is a member of your address book.

### 12.3 An honest trade-off list

| Trade-off | Cost | What you get |
|---|---|---|
| Python monolith kernel + giant files (`cli.py` 16590 lines, `run_agent.py` 6907 lines, `conversation_loop.py` 6163 lines) | beginners bounce; merge conflicts high (`AGENTS.md` itself calls for splitting the god-file into mixins) | iteration speed, monkey-patch-friendly test ecology, simple single-process deploy |
| Cooperative interrupt | a stuck tool cannot be force-killed, only timeout | will not kill a half-written file |
| Frozen-snapshot memory | in-session “remember then forget” (takes effect next round) | prefix cache 100% stable |
| Safety via “approval + optional isolation,” not default sandbox | under local backend, a mistaken approve still has risk | low friction for a personal-assistant scene |
| Extremely wide feature surface (kanban, voice, video gen, Yuanbao, Feishu……) | huge maintenance surface, uneven edge quality | the ambition of “one agent for digital life” |

### 12.4 A reading path for beginners

If you want to read this source yourself, this shallow-to-deep path is recommended:

1. `README.md` + `AGENTS.md` (understand design principles first; code will go twice as far);
2. `pyproject.toml:309` → `hermes_cli/main.py:14339` `main()` (entry);
3. `agent/conversation_loop.py:669` `run_conversation()`, with Chapter 6’s flowchart for the while loop;
4. `tools/file_tools.py` (what the simplest tool looks like) → `tools/terminal_tool.py` (what the most complex tool looks like);
5. `tools/delegate_tool.py` (sub-agents) → `agent/context_compressor.py` (compact) → `tools/memory_tool.py` (memory).

---

> **Honesty statement**: every `file:line` citation in this lecture comes from actually reading the commit above; a few functions not deeply verified (e.g. Honcho protocol details, implementation quality of some platform adapters) are only restated from official docs and module comments. The project iterates extremely fast (this commit is one day old); line numbers will drift in new versions, but architecture and principles are stable.
