Создание плагина для Hermes
Это руководство проведёт вас через создание полноценного плагина для Hermes с нуля. В итоге у вас получится рабочий плагин с несколькими инструментами, хуками жизненного цикла, встроенными файлами данных и упакованным навыком — то есть всем, что поддерживает система плагинов.
У Hermes есть несколько разных подключаемых интерфейсов — одни используют Python API register_*, другие управляются конфигом или работают через drop-in-каталоги. Сначала загляните в эту таблицу:
| Если вы хотите добавить… | Читайте |
|---|---|
| Собственные инструменты, хуки, слэш-команды, навыки или подкоманды CLI | Это руководство (общая поверхность плагинов) |
| Расширение для десктопного приложения (панели, страницы, статус-бар, палитра команд, темы) | Desktop Plugin SDK |
| Расширение для веб-дашборда (вкладки, слоты оболочки, темы) | Расширение дашборда |
| LLM / инференс-бэкенд (нового провайдера) | Плагины провайдера модели |
| Канал шлюза (Discord/Telegram/IRC/Teams и т. д.) | Добавление адаптеров платформ |
| Бэкенд памяти (Honcho/Mem0/Supermemory и т. д.) | Плагины провайдера памяти |
| Движок сжатия контекста | Плагины движка контекста |
| Бэкенд генерации изображений | Плагины провайдера генерации изображений |
| Бэкенд генерации видео | Плагины провайдера генерации видео |
| Бэкенд веб-поиска / извлечения данных | Плагины провайдера веб-поиска |
| Бэкенд облачного браузера (провайдер CDP-сессий в стиле Browserbase) | Плагины провайдера браузера |
Драйвер computer use (управление десктопом за инструментом computer_use) | Плагины бэкенда computer-use — ctx.register_computer_use_provider() |
| Бэкенд менеджера секретов (vault / менеджер паролей / системное хранилище ключей) | Плагины источника секретов |
| Провайдер OIDC/аутентификации для дашборда | Веб-дашборд — пользовательские провайдеры — ctx.register_dashboard_auth_provider() |
| Бэкенд TTS (любой CLI — Piper, VoxCPM, Kokoro, клонирование голоса, …) | TTS: пользовательские провайдеры-команды — управляется конфигом, Python не нужен |
| Бэкенд STT (собственный whisper / ASR CLI) | Транскрибация голосовых сообщений — укажите HERMES_LOCAL_STT_COMMAND как argv-токенизированный шаблон |
| Внешние инструменты через MCP (файловая система, GitHub, Linear, любой MCP-сервер) | MCP — объявите mcp_servers.<name> в config.yaml |
| Хуки событий шлюза (срабатывают при запуске, событиях сессии, командах) | Хуки событий — положите HOOK.yaml + handler.py в ~/.hermes/hooks/<name>/ |
| Shell-хуки (запуск shell-команды по событиям) | Shell-хуки — объявите в разделе hooks: в config.yaml |
| Дополнительные источники навыков (собственные репозитории GitHub, приватные индексы навыков) | Навыки — hermes skills tap add <repo> · Публикация tap |
| Полноценный встроенный (core) провайдер инференса (не плагин) | Добавление провайдеров |
Полную сводную таблицу всех поверхностей расширения — включая варианты, управляемые конфигом (TTS, STT, MCP, shell-хуки), и drop-in-каталоги (хуки шлюза) — смотрите в разделе Подключаемые интерфейсы.
Плагины, которые интегрируют чужой продукт или проект — бэкенды наблюдаемости/метрик, коннекторы вендорских SaaS, дашборды аналитики, привязки к платным сервисам — собираются и распространяются как отдельные репозитории плагинов, а не вливаются в NousResearch/hermes-agent. Пользователи устанавливают их в ~/.hermes/plugins/ или через pip entry point; всё, описанное в этом руководстве, работает точно так же и из отдельного репозитория. Это решение о связанности и сопровождении (основной репозиторий развивается быстро, и мы не владеем вашим бэкендом), а не оценка качества — плагин может быть отличным и всё равно жить в собственном репозитории. Расскажите о нём в Discord Nous Research, канал #plugins-skills-and-skins. Политику см. в CONTRIBUTING.md.
Для авторов плагинов Hermes уже поставляет следующее.
- Локальная проверка для каталога:
hermes plugins validate /path/to/your-plugin --install-depsзапускает ту же проверку, что и CI каталога. См. Отправка в каталог плагинов. - Проверка плагина в изоляции:
hermes plugins doctor [path-or-id]прогоняет те же обнаружение, парсер манифеста,register(ctx)и реестры, что использует Hermes, с временнымHERMES_HOME. См. Проверка через Plugin Doctor. - Состояние между обновлениями:
plugin_data_dir()иplugin_db()дают плагину каталог данных, который переживаетhermes plugins updateиremoveи следует за активным профилем. См. Хранение устойчивого состояния. - Объявление Python-зависимостей: перечислите их в
python_dependenciesвplugin.yamlили вpyproject.tomlрядом с ним. См. Python-зависимости. - Навыки вместе с плагином: регистрируйте их через
ctx.register_skill(). См. Упаковывать навыки. - Запрос привилегированных поверхностей хоста: объявите их в
capabilities:, и пользователь увидит один экран согласия. См. Объявление возможностей (capabilities). - Вызовы LLM: используйте
ctx.llm— учётные данные принадлежат хосту, а доверие проверяется по принципу «закрыто при сомнении». См. Доступ плагинов к LLM.
Переносимые пакеты Agent Plugins v1
Hermes умеет также устанавливать и загружать пакеты-каталоги, ориентированные на формат Agent Plugins v1.0.0. Это адаптер совместимости для переносимых компонентов, которыми Hermes уже владеет. Он не заменяет нативные плагины на plugin.yaml плюс register(ctx).
my-portable-plugin/
├── plugin.json
├── skills/
│ └── summarize/
│ ├── SKILL.md
│ └── references/
└── mcp.json
Установите и включите переносимый пакет обычным способом:
hermes plugins install owner/repository --no-enable
hermes plugins list
hermes plugins enable <plugin-name>
После установки переносимые пакеты отключены, пока вы явно их не включите. Включённый пакет может сразу же предоставить каталоги skills/*/SKILL.md и stdio MCP-серверы из корневого mcp.json. Навыки доступны только для чтения, находятся в отдельном пространстве имён и загружаются через skills_list и skill_view. Команды MCP передаются как один исполняемый токен с отдельным списком аргументов — никогда через shell. Полное квалифицированное имя навыка можно узнать через skills_list. Пространства имён переносимых навыков имеют детерминированный вид agent-plugin-<slug>-<hash>, производный от обнаруженного ключа плагина, так что санитизированные имена не могут конфликтовать. MCP-серверы переносимого пакета сохраняют имена, которые им даёт mcp.json, — то же правило, что и для собственного блока mcp_servers пользователя, поэтому видимое модели имя mcp__<server>__<tool> удерживает глагол инструмента в пределах лимита провайдера в 64 символа. Повторяющееся имя сервера — конфликт при загрузке: сервер из config.yaml побеждает пакет, а пакет, загруженный первым, побеждает следующий; проигравший пропускается с предупреждением, где названы оба.
Hermes проверяет локально plugin.json, фронтматтер Agent Skills, фиксированное расположение компонентов, mcp.json, разрешённые пути и то, что symlink-ы не выходят за пределы пакета. При загрузке пакета JSON-схемы по сети не запрашиваются. Плохой навык или запись MCP пропускается на своей собственной границе, если соседние валидные компоненты всё равно могут загрузиться. PLUGIN_ROOT указывает на разрешённый корень пакета. PLUGIN_DATA указывает на доступный для записи каталог в рамках профиля, которым управляет Hermes. Значения, объявленные в env переносимого MCP, — это видимые данные пакета, а не механизм хранения секретов. Не помещайте учётные данные в mcp.json.
Текущее поддерживаемое подмножество переносимого формата покрывает записи MCP типа stdio и Streamable HTTP. Записи streamable-http в переносимых пакетах маршрутизируются через уже существующий нативный удалённый MCP-клиент Hermes (тот же runtime, что обслуживает конфиг mcp_servers по URL), с соблюдением граничных правил v1: URL должен быть абсолютным http(s) без пользовательской информации и фрагмента, обычный HTTP допускается только для хостов localhost/loopback, а настроенные заголовки никогда не пересылаются через кросс-доменный редирект. Устаревшие записи sse фиксируются в логе и пропускаются. Agent Plugins v1 не определяет доверие, права доступа, происхождение (provenance) или песочницу. Включение пакета даёт его инструкциям и локальному исполняемому коду тот же уровень полного доверия, что и у остальных установленных плагинов Hermes.
Пакет может попросить Hermes ограничить один из своих MCP-серверов — так же, как это делает пользовательский trust: untrusted в config.yaml. Применяйте это к серверам, чьи инструменты тратят деньги, торгуют, отправляют сообщения или меняют аккаунты: тогда пользователь подтверждает каждый вызов, способный что-то записать, а не полагается лишь на инструкции навыка:
{
"extensions": {
"com.nousresearch.hermes": {
"servers": {
"trade": { "trust": "untrusted" }
}
}
}
}
Имя сервера должно совпадать с записью в mcp.json. При untrusted каждый вызов инструмента этого сервера, не помеченный readOnlyHint: true, сначала спрашивает пользователя, а там, где ответить некому (cron, запуски без присмотра), завершается отказом. Единственное другое допустимое значение — full, оно действует по умолчанию, так что пакет может сузить доступ, но не расширить. Сервер с тем же именем в config.yaml заменяет запись пакета вместе с её доверием. Другие среды это расширение игнорируют. trust может стоять рядом с app, requires и liveness в той же записи сервера (см. Объявления приложений).
Опубликованная спецификация сейчас помечает v1.0.0 как Working Draft, тогда как версионированный репозиторий спецификации фиксирует её как Published. Hermes ориентируется на канонические идентификаторы схемы v1.0.0 и нормативный текст, а не на ту или иную изменчивую метку статуса. Это явно очерченное поддерживаемое подмножество, а не заявление о полном соответствии Agent Plugins.
Контракт совместимости нативных плагинов
Нативные плагины на plugin.yaml плюс register(ctx) защищены поведением, а не единым глобальным номером API плагинов. Hermes не предоставляет PLUGIN_API_VERSION, не требует совпадения общего для манифеста поля api: и не привязывает версию API к посторонним значениям. Плагин, использующий документированное поведение, должен продолжать работать после обычного обновления Hermes.
Правила совместимости:
- Развитие только добавлением. Документированные методы
PluginContextне удаляются и не переименовываются. Новые параметры опциональны, имеют значения по умолчанию и должны быть keyword-only. Существующие поля возврата не удаляются и не меняют тип втихую. - Данные хуков передаются именованными полями. Новые данные хука добавляются как именованные (keyword) поля — никогда через изменение смысла или позиции существующего поля. Hermes анализирует сигнатуру колбэка: старый колбэк получает только те поля, что он объявил, а колбэк с
**kwargsполучает полный текущий набор данных. Новым плагинам стоит принимать**kwargs, чтобы получать дополнительные данные без очередной смены сигнатуры. - Манифесты открыты для добавлений. Неизвестные поля
plugin.yamlигнорируются. Поэтому более старые релизы Hermes могут загрузить плагин, чей манифест содержит метаданные, введённые в более новом релизе — при условии, что сам код плагина использует поддерживаемое поведение времени выполнения. - Интерфейсы провайдеров растут через значения по умолчанию. Новые методы провайдера получают реализацию по умолчанию. Новый контекст колбэка опционален и передаётся, только если анализ сигнатуры показывает, что провайдер его принимает. Добавление абстрактного метода или безусловно передаваемого аргумента требует переходного периода, а не одномоментной смены сигнатуры.
- Версионируйте контракт там, где он пересекает границу. Возможность (capability) может нести собственную версию схемы, если она определяет формат передаваемых данных или формат хранения (например, полезная нагрузка observer-ов или состояние источника секретов). В рамках такой локальной схемы поля должны добавляться, а не меняться. Сохранённое состояние плагина и конфиг должны оставаться читаемыми — либо нужна явная миграция; возобновлённые сессии, записанные в старом формате, всё равно должны воспроизводиться. Не добавляйте литералы версий к посторонним значениям колбэков или контекста.
Контракт охватывает только документированные поверхности. Подмена или обёртывание функций ядра, методов, атрибутов модулей или приватных таблиц на лету (присваивание AIAgent.<method>, setattr на модуле Hermes, запись в sys.modules или в словарь ядра) не является поддерживаемой точкой расширения. Такой код ломается, стоит внутренностям сдвинуться, и конфликтует с любым другим плагином, который патчит тот же шов. Каталог плагинов отклоняет это при допуске (hermes plugins validate, проверка no core override). Если нужного публичного хука нет, откройте issue с его описанием.
Политика устаревания
Документированное поведение нативного плагина можно признать устаревшим только при соблюдении всех условий:
- в руководстве по плагинам и в release notes указаны замена и инструкция по миграции;
- предупреждение выводится не чаще одного раза за процесс и называет замену и релиз, начиная с которого возможно удаление;
- старое поведение поддерживается как минимум ещё два последующих минорных релиза; и
- на всём этом окне поведенческие тесты совместимости покрывают и старый путь, и замену.
Удаление по истечении этого окна должно включать любую миграцию, нужную для сохранённых данных или возобновляемых сессий. На практике удалению предпочитают добавление алиасов и адаптеров.
Hermes закрепляет этот контракт зафиксированными (frozen) тестовыми примерами внешних плагинов, обнаруживаемыми из изолированного HERMES_HOME. Эти тесты загружают и вызывают плагин через PluginManager; они проверяют реальные результаты регистрации и колбэков, а не список внутренних символов или форму исходного кода.
Разделение модулей, сентябрь 2026: старые пути импорта удалены
В сентябре 2026 года внутренности Hermes разъехались по соседним модулям вида <stem>_<topic> (PR #102117). Внутренние пути импорта никогда не входили в контракт плагинов, описанный выше. Временный слой совместимости сохранял старые пути рабочими до 14.09.2026, теперь он удалён: плагин, который всё ещё импортирует старый путь, не загружается и падает с ImportError (причина видна в hermes plugins list).
Чтобы починить такой плагин, импортируйте имя из модуля, где оно теперь определено, а лучше пользуйтесь ctx и документированными ABC вместо внутренностей. Полная карта «старое → новое» — это COMPAT_MANIFEST.md из последнего коммита, где слой ещё поставлялся.
Что мы будем строить
Плагин calculator с двумя инструментами:
calculate— вычисляет математические выражения (2**16,sqrt(144),pi * 5**2)unit_convert— конвертирует между единицами измерения (100 F → 37.78 C,5 km → 3.11 mi)
Плюс хук, который логирует каждый вызов инструмента, и упакованный файл навыка.
Шаг 1. Создайте каталог плагина
Создайте каталог и переходите к шагу 2:
mkdir -p ~/.hermes/plugins/calculator
cd ~/.hermes/plugins/calculator
Проверка через Plugin Doctor
hermes plugins doctor [path-or-id] выполняет то же обнаружение каталога, разбор манифеста, импорт с пространством имён, вызов register(ctx), работу с реестром хуков и реестром инструментов, что использует сам Hermes. Он сообщает о некорректных именах хуков, колбэках без **kwargs, ошибках регистрации и расхождениях между объявленными и зарегистрированными инструментами/хуками. Для пакета с desktop/plugin.js он также предупреждает, если десктопное приложение запускает устаревшую копию этого файла (см. Разработка единого пакета). Передайте --ci, чтобы завершаться с ненулевым кодом при ошибке:
hermes plugins doctor . --ci
Doctor использует временный HERMES_HOME, восстанавливает состояние регистрации плагинов после проверки и блокирует прямые Python-соединения по сокету, чтобы отловить случайный сетевой доступ во время регистрации. Это не песочница: код плагина всё равно выполняется в том же процессе с правами текущего пользователя и может порождать подпроцессы, так что запускайте Doctor только на коде, которому достаточно доверяете для импорта.
Шаг 2. Напишите манифест
Создайте plugin.yaml:
name: calculator
version: 1.0.0
description: Math calculator — evaluate expressions and convert units
provides_tools:
- calculate
- unit_convert
provides_hooks:
- post_tool_call
Этот файл сообщает Hermes: «Я плагин calculator, я предоставляю инструменты и хуки». Поля provides_tools и provides_hooks — это списки того, что регистрирует плагин.
Перечисляйте в provides_tools каждый инструмент, который регистрирует ваш register(). От этого поля не зависит, загрузятся ли инструменты установленного пользователем плагина: после включения плагина доступно всё, что регистрирует register(), объявлено оно или нет. Зато поле влияет на следующее:
hermes plugins validate: проверка «объявленные инструменты» падает, если зарегистрированные инструменты не совпадают со списком, а это блокирует допуск в каталог.- Листинг каталога: плашки «N инструментов» и поиск по именам инструментов в каталоге и на странице Plugins в дашборде и десктопе.
- Подсказка об аутентификации в дашборде: для показа «needs auth» и команды
hermes auth <name>берутся проверки доступности только объявленных инструментов. - Только встроенные плагины
kind: platform: поле служит переключателем, который загружаетtools.pyв сессиях CLI/TUI, пока адаптер остаётся отложенным. См. Исходящие клиентские инструменты.
Опциональные поля, которые можно добавить:
author: Your Name
requires_env: # ограничивает загрузку переменными окружения; запрашиваются при установке
- SOME_API_KEY # простой формат — плагин отключён, если переменной нет
- name: OTHER_KEY # расширенный формат — показывает описание/url при установке
description: "Key for the Other service"
url: "https://other.com/keys"
secret: true
capabilities: # запрашиваемые привилегированные поверхности хоста (флоу согласия)
- tools.override # заменять встроенные инструменты (нужно согласие пользователя)
- llm.model_override # выбирать модель для LLM-вызовов, которыми владеет хост
Объявление возможностей (capabilities)
Если вашему плагину нужна привилегированная поверхность хоста — переопределение встроенного инструмента, выбор модели для вызовов ctx.llm и т. п., — объявите это в capabilities:. При установке/включении пользователь видит список и даёт согласие один раз; если следующая версия добавляет новую возможность, флоу обновления снова спросит согласие — но только на добавленное. Необъявленные или несогласованные возможности просто выключены (fail closed), поэтому проверяйте их наличие перед использованием и корректно деградируйте:
def register(ctx):
if ctx.has_capability("tools.override"):
ctx.register_tool(..., override=True)
else:
ctx.register_tool(...) # зарегистрировать под неконфликтующим именем
Известные идентификаторы возможностей: tools.override, llm.provider_override, llm.model_override, llm.agent_id_override, llm.profile_override, llm.task_override (канонический реестр — в hermes_cli/plugin_capabilities.py). Неизвестные идентификаторы игнорируются. Старые пер-возможностные ключи конфига (plugins.entries.<id>.allow_tool_override, …) по-прежнему работают, но считаются устаревшими — вместо них объявляйте возможности, чтобы пользователи видели единый, проверяемый экран согласия. Возможности — это согласие плюс аудит, не песочница: они лишь ограничивают доступ к поверхностям API хоста, не более того.
Плагины, распространяемые через pip, после установки не имеют каталога plugin.yaml, поэтому объявляйте возможности в метаданных дистрибутива — через сопутствующую группу entry point hermes_agent.plugin_capabilities. Каждое объявление называется <plugin-id>.<capability-id> и указывает на тот же объект, что и ваш entry point hermes_agent.plugins:
[project.entry-points."hermes_agent.plugins"]
calculator = "my_pkg:register"
[project.entry-points."hermes_agent.plugin_capabilities"]
"calculator.tools.override" = "my_pkg:register"
Hermes читает это из метаданных установленного пакета, не импортируя ваш код, поэтому hermes plugins capabilities и флоу согласия остаются точными и для установок через pip.
Использовать вход пользователя в Codex (запросы от ядра)
Плагин, которому нужна подписка пользователя на ChatGPT / Codex (выпуск клиентского секрета OpenAI Realtime, вызов эндпоинта бэкенда Codex), не должен читать, обновлять или перезаписывать ~/.codex/auth.json или auth.json самого Hermes. Refresh-токены Codex одноразовые: плагин, который обновляет токен сам, состязается с заблокированным обновлением в Hermes, и у проигравшего цепочка ротации отзывается — пользователь оказывается разлогинен. Попросите Hermes выполнить запрос за вас. Он получает учётные данные через собственный путь обновления, сам подставляет Authorization и возвращает только ответ. Токена ваш код не видит.
Объявите вход в plugin.yaml:
name: live-voice
requires_auth:
- openai-codex
Затем вызовите публичный хелпер. Это обычная функция, так что её могут импортировать и плагины дашборда (dashboard/plugin_api.py), и вендорные модули, а не только код в register(ctx):
from hermes_cli.plugin_provider_requests import ProviderNotSignedIn, credentialed_provider_request
def mint_client_secret(session: dict) -> dict:
try:
resp = credentialed_provider_request(
"openai-codex", "POST", "https://api.openai.com/v1/realtime/client_secrets",
json={"session": session}, timeout=15)
except ProviderNotSignedIn as exc:
return {"ok": False, "error": str(exc)} # tells the user how to sign in
if resp.status != 200:
return {"ok": False, "error": f"client secret failed ({resp.status}): {resp.text[:300]}"}
return {"ok": True, **resp.json()}
Это заменяет самодельный хелпер, который читал ~/.codex/auth.json, слал POST на auth.openai.com/oauth/token, когда срок токена подходил к концу, и записывал результат обратно. Удалите этот код: шага обновления у плагина больше нет.
credentialed_provider_request(provider, method, url, *, json=None, headers=None, timeout=30.0) возвращает ProviderResponse (status, headers, body, .text, .json()). Правила:
- Только объявленные плагины. Вызывающий — это плагин, чей файл находится в стеке вызовов. Hermes находит его в каталоге плагинов и читает его
plugin.yaml. Плагин, который не перечислил провайдера вrequires_auth, и код вне любого установленного плагина получаютPermissionError. Вызывайте хелпер из собственной функции. Если передать «голый» хелпер вrun_in_executor, в стеке не останется кадра плагина, и вызов будет отклонён. - Только origin провайдера. Для
openai-codexтокен отправляется только наhttps://chatgpt.comиhttps://api.openai.com. Любой другой URL вызываетPermissionErrorещё до запроса. Профиль, где учётные данные Codex направлены на собственный шлюз, тоже получает отказ: этот ключ принадлежит шлюзу. - Без редиректов. Ответ 3xx возвращается как есть, по нему никогда не переходят, так что заголовок не может попасть на третий хост.
- В пределах профиля. Учётные данные — это вход Codex активного профиля, тот же, что использует чат (
hermes auth add openai-codexили вход Codex в десктопном приложении). Если входа нет, вы получитеProviderNotSignedIn; в его сообщении названа команда для входа. - Ваши собственные
headersтоже отправляются, кроме тех, что заменили бы заголовкиAuthorization(или аккаунта), которые подставляет Hermes.
requires_auth отображается в hermes plugins show <name> и при установке, а повторное закрепление в каталоге, которое его добавляет, спрашивает пользователя перед обновлением. Как и capabilities, это согласие и прозрачность, но не песочница: код плагина в том же процессе по-прежнему может прочитать любой файл, доступный пользователю, и ревьюеры отклоняют плагины, которые так делают.
Панели использования: не обращайтесь к эндпоинтам использования сами. Применяйте agent.account_usage.fetch_account_usage("openai-codex", read_only=True). Он возвращает AccountUsageSnapshot (тариф, окна сессии и недели с used_percent и reset_at) и никогда не обновляет и не ротирует учётные данные. Выход из аккаунта — дело Hermes, а не плагина: направляйте пользователей на hermes auth logout openai-codex вместо удаления файлов учётных данных.
Справочник по манифесту v2
plugin.yaml также поддерживает дополняющую (additive) схему v2 (#64165). Все поля опциональны; манифест без manifest_version — это манифест v1, и он остаётся полностью поддерживаемым всегда. Неизвестные поля никогда не ломают загрузку — они игнорируются с предупреждением (обратная совместимость на будущее), а manifest_version новее, чем понимает текущий Hermes, всё равно загружается — с предупреждением.
| Поле | Тип | Значение |
|---|---|---|
manifest_version | int | Версия формата файла манифеста. Отсутствует = 1. Текущий максимум: 2. Независима от api_version. |
api_version | int | Поколение runtime-API плагинов, на которое рассчитан плагин (поверхность ctx / сигнатуры хуков). Намеренно отдельная ось от manifest_version — плагин с api_version: 1 может использовать манифест v2. |
requires_plugins | список | Межплагинные зависимости: - id: other-plugin с опциональным version_range: ">=1.0,<2". Рекомендательно (advisory): отсутствующая зависимость логирует понятное предупреждение, но плагин всё равно загружается — проверяйте во время выполнения через ctx.has_plugin("other-plugin"). Порядок загрузки учитывает эти связи: если A требует B, register() у B выполняется раньше, чем у A (топологическая сортировка, при равенстве — по алфавиту; циклы выдают предупреждение и откатываются к алфавитному порядку). |
python_dependencies | список строк | Объявленные Python-требования (например, "requests>=2.0,<3"). Установка запрашивает согласие; включение допускает кандидата через PM вместе с уже существующим объединением ядра, extras и включённых плагинов. Успешная подготовка публикует окружение и конфигурацию транзакционно; при сбое сохраняются прежний выбор и набор включённых плагинов. Отказ оставляет установленный плагин выключенным. Задавайте верхние границы версий. |
python_runtime | str | external — плагин сам управляет своим интерпретатором/venv (паттерн sidecar); Hermes ничего не устанавливает и не трогает pyproject.toml. |
config_schema | отображение | JSON-schema-подобное описание ключей под plugins.entries.<id>.settings: api_url: {type: str, default: "", description: "...", required: false}. Проверяется при загрузке; несоответствия логируют предупреждения с указанием ключа и ожидаемого типа — но никогда не приводят к ошибке загрузки. Типы: str, int, float, bool, list, dict (плюс алиасы JSON-schema) и secret. По этой же схеме строится форма настроек на вкладке Plugins в Desktop — см. Форма настроек в Desktop. |
license | str | Идентификатор лицензии в стиле SPDX (например, MIT). |
homepage | str | URL проекта. |
tags | список строк | Произвольные теги для обнаружения (например, [gateway, telegram]). |
provides_locales | список | Объявление языкового пакета: id (- pl) или словари {id, endonym, rtl}, чьи файлы locales/<id>[.tui|.desktop].yaml загрузчик регистрирует автоматически — см. Поставлять языковой пакет. |
requires_auth | список строк | Провайдеры, чей вход плагин использует через Hermes (сейчас: openai-codex). Обязателен для credentialed_provider_request; показывается при установке и в hermes plugins show. См. Использовать вход пользователя в Codex. |
# plugin.yaml — пример манифеста v2
name: my-plugin
version: 1.2.0
manifest_version: 2
api_version: 1
license: MIT
homepage: https://github.com/owner/my-plugin
tags: [gateway, demo]
requires_plugins:
- id: other-plugin
version_range: ">=1.0,<2"
python_dependencies:
- "somepkg>=1.0,<2" # согласие до допуска через PM
config_schema:
api_url: {type: str, default: "", description: "Service endpoint"}
Установка плагина запрашивает согласие на его Python-зависимости. При включении плагина PM готовит его требования вместе с зависимостями ядра, extras и включёнными плагинами. Включение наборов (packs) проходит через ту же транзакцию допуска. Переустановка активного плагина запрашивает согласие по его подготовленному (staged) объявлению ещё до публикации. Отказ сохраняет установленный плагин и выбранное окружение.
Установщик и допуск через PM отклоняют неподдерживаемые значения manifest_version
и невыполненные ограничения requires_hermes до публикации.
Python-зависимости
Плагин-каталог может принести собственные пакеты с PyPI. Объявите их либо в манифесте
(python_dependencies, см. выше), либо — предпочтительно — в pyproject.toml рядом с plugin.yaml:
[project]
name = "my-plugin"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = [
"somepkg>=1.0,<2",
"other[extra]>=3.11",
]
Если есть и то и другое, побеждает pyproject.toml. Что Hermes с ними делает:
- Install / enable — PM разрешает ядро, выбранные extras и объединение включённых плагинов
по всем профилям, которые делят каталог зависимостей, включая собственные корни
HERMES_HOME. Новый плагин скачивается выключенным; согласие на Python-зависимости предшествует включению.pyproject.tomlимеет приоритет надpython_dependenciesи устаревшимpip_dependencies. - Атомарная публикация — PM готовит новое поколение окружения, прежде чем опубликовать включение или замену активного плагина. Сбой разрешения, загрузки или сборки сохраняет прежнее окружение и выбор плагинов; ни одним существующим плагином не жертвуют.
- Обновления сохраняют объединение —
hermes updateучитывает включённые плагины, когда готовит новое поколение. Переустановки через pip после обновления нет.hermes plugins updateготовит замены активных плагинов, а затем подменяет их код и поколение зависимостей разом. - Гигиена требований — некорректные требования PEP 508 отклоняются. Маркеры окружения
сохраняются как есть, их вычисляет целевой интерпретатор. Зависимости на сам
hermes-agentопускаются, потому что Hermes поставляет checkout. Требования с прямым URL не управляются; для них используйте внешнюю среду выполнения, которой владеет плагин. --no-depsскачивает новый плагин без согласия на зависимости и оставляет его выключенным, даже с--enable. Обойти допуск через PM при замене активного плагина этот флаг не может.--yes-depsотвечает на вопрос о зависимостях заранее, так что установка без интерфейса (CI, SSH-автоматизация, точка входа контейнера) готовит объявленные зависимости, а не получает отказ. Флаг несовместим с--no-deps.python_runtime: externalдержит зависимости sidecar-процесса вне общего объединения. Hermes эту среду выполнения Python не ставит и её объявление не меняет.- Нечего загружать — это ошибка —
hermes plugins validateотклоняетplugin.yaml, рядом с которым нет__init__.py,desktop/plugin.jsилиplugin.json. Пакетам в pip-раскладке нужна обёртка в виде плагина-каталога. security.allow_lazy_installs: falseблокирует получение по требованию. Явное согласие на зависимости и явное включение разрешают PM подготовку; обнаружение само ничего не ставит.
HERMES_HOME/plugins/ переживает hermes update и обновления Desktop: обновление пересобирает только
venv и checkout, но никогда не трогает домашний каталог.
Политика безопасности зависимостей
Hermes держит на карантине собственные зависимости: [tool.uv] exclude-newer = "14 days" в checkout
не пускает свежеопубликованный релиз любого пакета, от которого зависит сам Hermes, в hermes update
и во встроенные ленивые установки в течение двух недель, так что взломанную загрузку успевают поймать
выше по цепочке, прежде чем она дойдёт до пользователей. На зависимости вашего плагина этот карантин не
распространяется. Когда Hermes разрешает ваш плагин в своё окружение, отсечка действует только на пакеты,
которые Hermes закрепляет сам, и больше нигде, так что плагин может поставить нижнюю границу на вчерашний
релиз и установить его сегодня — и за то, что это притянет, отвечает автор плагина, а не Hermes. (Плагин,
которому нужна более новая версия пакета, от которого зависит сам Hermes, всё равно ждёт окончания окна
для этого пакета.)
Задайте собственную политику и придерживайтесь её. Настоятельно рекомендуем:
- Верхние границы у каждой зависимости —
>=floor,<next_majorдля стабильных пакетов,>=0.29,<0.32для пакетов до 1.0. Голое>=X.Yпринимает каждый будущий релиз без проверки. - Нижняя граница — на самой старой API-совместимой версии, а не на релизе недели. Нижняя граница на
свежем wheel заставляет каждого, кто ставит плагин, перейти на него в день выхода;
>=old,!=broken,<nextсохраняет широкий диапазон и пропускает один плохой релиз. - Заведите собственный карантин новых релизов — ждите около 14 дней, прежде чем двигать нижние границы
на новый релиз, и разрешайте зависимости с
uv --exclude-newer "14 days"(илиUV_EXCLUDE_NEWER) в своём CI, чтобы протестированный вами лок совпадал с тем, что получат пользователи. - Закрепляйте лок, проверяйте обновления. Относитесь к подъёму версии зависимости как к изменению кода: прочитайте дифф в апстриме, потом закрепите заново.
Ревью каталога плагинов читает ваш список зависимостей на закреплённом SHA (plugin.yaml или
pyproject.toml) и помечает голые нижние границы и отсутствие верхних; запись не задерживают только
из-за того, что нижняя граница указывает на недавний релиз.
Шаг 3. Напишите схемы инструментов
Создайте schemas.py — это то, что читает LLM, чтобы решить, когда вызывать ваши инструменты:
"""Tool schemas — what the LLM sees."""
CALCULATE = {
"name": "calculate",
"description": (
"Evaluate a mathematical expression and return the result. "
"Supports arithmetic (+, -, *, /, **), functions (sqrt, sin, cos, "
"log, abs, round, floor, ceil), and constants (pi, e). "
"Use this for any math the user asks about."
),
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Math expression to evaluate (e.g., '2**10', 'sqrt(144)')",
},
},
"required": ["expression"],
},
}
UNIT_CONVERT = {
"name": "unit_convert",
"description": (
"Convert a value between units. Supports length (m, km, mi, ft, in), "
"weight (kg, lb, oz, g), temperature (C, F, K), data (B, KB, MB, GB, TB), "
"and time (s, min, hr, day)."
),
"parameters": {
"type": "object",
"properties": {
"value": {
"type": "number",
"description": "The numeric value to convert",
},
"from_unit": {
"type": "string",
"description": "Source unit (e.g., 'km', 'lb', 'F', 'GB')",
},
"to_unit": {
"type": "string",
"description": "Target unit (e.g., 'mi', 'kg', 'C', 'MB')",
},
},
"required": ["value", "from_unit", "to_unit"],
},
}
Почему схемы важны: поле description — это то, по чему LLM решает, когда использовать ваш инструмент. Пишите конкретно, что он делает и когда его применять. parameters определяет, какие аргументы передаёт LLM.
Шаг 4. Напишите обработчики инструментов
Создайте tools.py — это код, который реально выполняется, когда LLM вызывает ваши инструменты:
"""Tool handlers — the code that runs when the LLM calls each tool."""
import json
import math
# Safe globals for expression evaluation — no file/network access
_SAFE_MATH = {
"abs": abs, "round": round, "min": min, "max": max,
"pow": pow, "sqrt": math.sqrt, "sin": math.sin, "cos": math.cos,
"tan": math.tan, "log": math.log, "log2": math.log2, "log10": math.log10,
"floor": math.floor, "ceil": math.ceil,
"pi": math.pi, "e": math.e,
"factorial": math.factorial,
}
def calculate(args: dict, **kwargs) -> str:
"""Evaluate a math expression safely.
Rules for handlers:
1. Receive args (dict) — the parameters the LLM passed
2. Do the work
3. Return a JSON string — ALWAYS, even on error
4. Accept **kwargs for forward compatibility
"""
expression = args.get("expression", "").strip()
if not expression:
return json.dumps({"error": "No expression provided"})
try:
result = eval(expression, {"__builtins__": {}}, _SAFE_MATH)
return json.dumps({"expression": expression, "result": result})
except ZeroDivisionError:
return json.dumps({"expression": expression, "error": "Division by zero"})
except Exception as e:
return json.dumps({"expression": expression, "error": f"Invalid: {e}"})
# Conversion tables — values are in base units
_LENGTH = {"m": 1, "km": 1000, "mi": 1609.34, "ft": 0.3048, "in": 0.0254, "cm": 0.01}
_WEIGHT = {"kg": 1, "g": 0.001, "lb": 0.453592, "oz": 0.0283495}
_DATA = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4}
_TIME = {"s": 1, "ms": 0.001, "min": 60, "hr": 3600, "day": 86400}
def _convert_temp(value, from_u, to_u):
# Normalize to Celsius
c = {"F": (value - 32) * 5/9, "K": value - 273.15}.get(from_u, value)
# Convert to target
return {"F": c * 9/5 + 32, "K": c + 273.15}.get(to_u, c)
def unit_convert(args: dict, **kwargs) -> str:
"""Convert between units."""
value = args.get("value")
from_unit = args.get("from_unit", "").strip()
to_unit = args.get("to_unit", "").strip()
if value is None or not from_unit or not to_unit:
return json.dumps({"error": "Need value, from_unit, and to_unit"})
try:
# Temperature
if from_unit.upper() in {"C","F","K"} and to_unit.upper() in {"C","F","K"}:
result = _convert_temp(float(value), from_unit.upper(), to_unit.upper())
return json.dumps({"input": f"{value} {from_unit}", "result": round(result, 4),
"output": f"{round(result, 4)} {to_unit}"})
# Ratio-based conversions
for table in (_LENGTH, _WEIGHT, _DATA, _TIME):
lc = {k.lower(): v for k, v in table.items()}
if from_unit.lower() in lc and to_unit.lower() in lc:
result = float(value) * lc[from_unit.lower()] / lc[to_unit.lower()]
return json.dumps({"input": f"{value} {from_unit}",
"result": round(result, 6),
"output": f"{round(result, 6)} {to_unit}"})
return json.dumps({"error": f"Cannot convert {from_unit} → {to_unit}"})
except Exception as e:
return json.dumps({"error": f"Conversion failed: {e}"})
Ключевые правила для обработчиков:
- Сигнатура:
def my_handler(args: dict, **kwargs) -> str - Возврат: всегда строка JSON. И при успехе, и при ошибке.
- Никогда не бросайте исключение: перехватывайте все исключения, возвращайте вместо них JSON с ошибкой.
- Принимайте
**kwargs: Hermes внедряет контекстные именованные аргументы (task_id,session_id,user_task,parent_agent, …) и передаёт только те, что названы в вашей сигнатуре, поэтомуdef handler(args)работает;**kwargs— способ получать весь контекст, который со временем только пополняется.
Шаг 5. Напишите регистрацию
Создайте __init__.py — этот файл связывает схемы с обработчиками:
"""Calculator plugin — registration."""
import logging
from . import schemas, tools
logger = logging.getLogger(__name__)
# Track tool usage via hooks
_call_log = []
def _on_post_tool_call(tool_name, args, result, task_id, **kwargs):
"""Hook: runs after every tool call (not just ours)."""
_call_log.append({"tool": tool_name, "session": task_id})
if len(_call_log) > 100:
_call_log.pop(0)
logger.debug("Tool called: %s (session %s)", tool_name, task_id)
def register(ctx):
"""Wire schemas to handlers and register hooks."""
ctx.register_tool(name="calculate", toolset="calculator",
schema=schemas.CALCULATE, handler=tools.calculate)
ctx.register_tool(name="unit_convert", toolset="calculator",
schema=schemas.UNIT_CONVERT, handler=tools.unit_convert)
# This hook fires for ALL tool calls, not just ours
ctx.register_hook("post_tool_call", _on_post_tool_call)
Что делает register():
- Вызывается ровно один раз при запуске
ctx.register_tool()кладёт ваш инструмент в реестр — модель видит его сразу жеctx.register_hook()подписывает на события жизненного циклаctx.register_cli_command()регистрирует подкоманду CLI (например,hermes my-plugin <subcommand>)ctx.register_command()регистрирует внутрисессионную слэш-команду (например,/myplugin <args>внутри чата CLI / шлюза) — см. Регистрация слэш-команд нижеctx.dispatch_tool(name, arguments)— вызывает любой другой инструмент (встроенный или из другого плагина) с автоматически подключённым контекстом родительского агента (подтверждения, учётные данные, task_id). Полезно из обработчиков слэш-команд, которым нужно вызватьterminal,read_fileили любой другой инструмент так, как будто его вызвала сама модель.ctx.get_config()/ctx.set_config()дают доступ только к пространству настроек этого плагина;ctx.stateхранит собственные runtime-данные плагина в рамках активного профиля.- Если эта функция падает с ошибкой, плагин отключается, а Hermes продолжает работать без сбоев
Пример dispatch_tool — слэш-команда, запускающая инструмент:
def handle_scan(ctx, raw_args: str):
"""Implement /scan by invoking the terminal tool through the registry."""
result = ctx.dispatch_tool("terminal", {"command": f"find . -name '{raw_args}'"})
return result # returned to the caller's chat UI
def register(ctx):
# Handlers receive a single raw_args string; close over ctx via a lambda.
ctx.register_command(
"scan",
lambda raw: handle_scan(ctx, raw),
description="Find files matching a glob",
)
Вызванный таким образом инструмент проходит через обычные конвейеры подтверждения, редактирования и бюджета — это настоящий вызов инструмента, а не обход этих механизмов.
Хранение настроек и runtime-состояния
Для видимого пользователю поведения используйте ключи конфига в пространстве плагина. Hermes разрешает их под plugins.entries.<plugin-id>.settings и отклоняет глобальные, межплагинные пути и пути с traversal:
def register(ctx):
endpoint = ctx.get_config("endpoint", default="https://example.invalid")
retries = ctx.get_config("retry.attempts", default=3)
ctx.set_config("endpoint", endpoint)
ctx.set_config("retry.attempts", retries)
Для собственных курсоров, кэшей и данных дедупликации плагина используйте ctx.state, а не размещайте runtime-учёт в config.yaml:
def register(ctx):
cursor = ctx.state.get("cursor", default={"page": 0})
ctx.state.set("cursor", {"page": cursor["page"] + 1})
Состояние ограничено активным профилем, заменяется атомарно, безопасно при конкурентной записи и ограничено 10 МиБ на плагин. Переносимые пакеты используют тот же каталог, что и их PLUGIN_DATA; нативные плагины получают устойчивое к коллизиям, безопасное для Windows пространство имён. Повреждённое существующее состояние фиксируется в логе и сохраняется как есть.
У конфига и состояния разные владельцы: настройки — это видимое пользователю поведение в config.yaml, а состояние — собственные runtime-данные плагина под <HERMES_HOME>/plugin-data/. Ни один из этих API не даёт доступа к пространству имён другого плагина.
Форма настроек в Desktop
Каждый ключ, объявленный в config_schema манифеста, отображается строкой на собственной
странице плагина в разделе Settings → Plugins десктопного приложения (её открывает шестерёнка
в строке плагина на вкладке Capabilities → Plugins). Код для
Desktop писать не нужно: plugins.manage list на бэкенде возвращает схему вместе с текущим
значением каждого ключа, а сохранение идёт через тот же механизм записи, что и
ctx.set_config(), так что ваш плагин читает обратно именно plugins.entries.<id>.settings.<key>.
Форма строится по таблице в зависимости от type:
type в манифесте | Поле | Дополнительные ключи |
|---|---|---|
str (по умолчанию) | текстовое поле | choices: [a, b] (или enum:) превращает его в выпадающий список |
int, float | числовое поле | |
bool | переключатель | |
list, dict | редактор JSON | |
secret | маскированное поле | env: MY_PLUGIN_TOKEN — переменная .env, под которой хранится значение (по умолчанию <PLUGIN_ID>_<KEY> в верхнем регистре через подчёркивания) |
Каждая запись также принимает label (или title; без него ключ показывается в человекочитаемом виде,
maps_api_key → «Maps API key»), description (подсказка под названием), default и required
(помечает строку как Required).
config_schema:
api_url: {type: str, default: "https://api.example.com", label: "API URL", description: "Service endpoint"}
retries: {type: int, default: 3}
mode: {type: str, choices: [fast, careful], default: fast}
api_key: {type: secret, env: MY_PLUGIN_API_KEY, description: "Personal access token"}
Секреты никогда не попадают в config.yaml. Поле secret несёт только имя в .env и
признак того, задано ли значение; Desktop сохраняет значение тем же путём, что и API-ключи
провайдеров (PUT /api/env), а ваш плагин читает его через get_secret("MY_PLUGIN_API_KEY") из agent.secret_scope —
точно так же, как запись requires_env. Никогда не читайте .env, auth.json или файлы учётных данных других инструментов сами (правило 11 каталога). Действие plugins.manage settings отклоняет
секретные ключи и любое значение, чей тип или choices не совпадают со схемой.
Шаг 6. Проверьте работу
Запустите Hermes:
hermes
Вы должны увидеть calculator: calculate, unit_convert в списке инструментов баннера.
Попробуйте такие запросы:
What's 2 to the power of 16?
Convert 100 fahrenheit to celsius
What's the square root of 2 times pi?
How many gigabytes is 1.5 terabytes?
Проверьте статус плагина:
/plugins
Вывод:
Plugins (1):
✓ calculator v1.0.0 (2 tools, 1 hooks)
Отладка обнаружения плагина
Если плагин не отображается — или отображается, но не загружается — установите HERMES_PLUGINS_DEBUG=1, чтобы получить подробные логи обнаружения в stderr:
HERMES_PLUGINS_DEBUG=1 hermes plugins list
Вы увидите для каждого источника плагинов (bundled, user, project, entry-points):
- какие каталоги были просканированы и сколько манифестов дал каждый
- по каждому манифесту: разрешённый ключ, имя, вид (kind), источник, путь на диске
- причины пропуска:
disabled via config,not enabled in config,exclusive plugin,no plugin.yaml, depth cap reached - при загрузке: какой плагин импортируется, плюс однострочную сводку о том, что зарегистрировал
register(ctx)(инструменты, хуки, слэш-команды, команды CLI) - при ошибке разбора: полный traceback исключения (ошибки сканера YAML и т. п.)
- при ошибке
register(): полный traceback, указывающий на строку в вашем__init__.py, которая вызвала сбой
Те же логи всегда пишутся в ~/.hermes/logs/agent.log — на уровне WARNING (только ошибки) и на уровне DEBUG (всё), когда установлена переменная окружения. Так что если запустить с переменной окружения не получается (например, из шлюза), смотрите файл лога:
hermes logs --level WARNING | grep -i plugin
Частые причины, по которым плагин не появляется:
- Не включён в конфиге — плагины подключаются явно (opt-in). Выполните
hermes plugins enable <name>(имя берётся из выводаplugins list, для вложенных раскладок оно может выглядеть как<category>/<plugin>). - Неверная структура каталогов: нативные пакеты используют
~/.hermes/plugins/<plugin-name>/plugin.yaml(плоская раскладка) или один уровень категории. Переносимые пакеты используют корневойplugin.jsonв тех же местах. Всё глубже игнорируется. - Отсутствует
__init__.py: нативным пакетам нужны иplugin.yaml, и__init__.pyс функциейregister(ctx). Переносимые пакеты не импортируют Python-код и не требуют__init__.py. - Неверный
kind— адаптерам шлюза в манифесте нуженkind: platform. Провайдеры памяти автоматически определяются какkind: exclusiveи маршрутизируются через конфигmemory.provider, а не черезplugins.enabled.
Итоговая структура вашего плагина
~/.hermes/plugins/calculator/
├── plugin.yaml # "I'm calculator, I provide tools and hooks"
├── __init__.py # Wiring: schemas → handlers, register hooks
├── schemas.py # What the LLM reads (descriptions + parameter specs)
└── tools.py # What runs (calculate, unit_convert functions)
Четыре файла с чётким разделением ответственности:
- Манифест объявляет, что представляет собой плагин
- Схемы описывают инструменты для LLM
- Обработчики реализуют саму логику
- Регистрация связывает всё воедино
Что ещё умеют плагины
Поставлять файлы данных
Положите любые файлы в каталог плагина и читайте их при импорте:
# In tools.py or __init__.py
from pathlib import Path
from ruamel.yaml import YAML
_PLUGIN_DIR = Path(__file__).parent
_DATA_FILE = _PLUGIN_DIR / "data" / "languages.yaml"
with open(_DATA_FILE) as f:
_DATA = YAML(typ="safe").load(f)
Это касается файлов, которые вы поставляете. Состояние, которое вы записываете, — другое дело: см. следующий раздел.
Хранение устойчивого состояния
Никогда не пишите runtime-состояние в каталог своего плагина: это установочное дерево, и hermes plugins update / remove делают git-pull или удаляют его целиком — вместе с ним пропадут и данные ваших пользователей. Разрешённое место — корневой каталог данных для конкретного плагина: он переживает оба этих действия и следует за активным профилем:
from plugins.plugin_storage import plugin_data_dir, plugin_db
# <hermes home>/plugin-data/<name>/ — создаётся при первом использовании
state_file = plugin_data_dir("my-plugin") / "state.json"
# Либо база SQLite в <data dir>/data.db (режим WAL, дружественный к потокам)
conn = plugin_db("my-plugin")
conn.execute("CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY)")
Один каталог на плагин означает, что данные любого плагина можно найти в одном предсказуемом месте. Секретам здесь не место — чтение учётных данных идёт через обычный путь .env / области секретов, как и везде.
Упаковывать навыки
Плагины могут поставлять файлы навыков, которые агент загружает через skill_view("plugin:skill"). Зарегистрируйте их в своём __init__.py:
~/.hermes/plugins/my-plugin/
├── __init__.py
├── plugin.yaml
└── skills/
├── my-workflow/
│ └── SKILL.md
└── my-checklist/
└── SKILL.md
from pathlib import Path
def register(ctx):
skills_dir = Path(__file__).parent / "skills"
for child in sorted(skills_dir.iterdir()):
skill_md = child / "SKILL.md"
if child.is_dir() and skill_md.exists():
ctx.register_skill(child.name, skill_md)
Теперь агент может загружать ваши навыки по их пространству имён:
skill_view("my-plugin:my-workflow") # → plugin's version
skill_view("my-workflow") # → built-in version (unchanged)
Ключевые свойства:
- Навыки плагина доступны только для чтения — они не попадают в
~/.hermes/skills/и не могут редактироваться черезskill_manage. - Навыки плагина не отображаются в индексе
<available_skills>системного промпта — их нужно загружать явно. - Голые имена навыков не затрагиваются — пространство имён предотвращает конфликты со встроенными навыками.
- Когда агент загружает навык плагина, перед ним добавляется баннер контекста бандла, перечисляющий соседние навыки из того же плагина.
Старый паттерн с shutil.copy2 (копирование навыка в ~/.hermes/skills/) всё ещё работает, но создаёт риск конфликта имён со встроенными навыками. Для новых плагинов предпочитайте ctx.register_skill().
Поставлять языковой пакет
Плагин может добавить язык интерфейса или переопределить формулировки существующего сразу для всех поверхностей: Python (agent.i18n.t(): запросы на подтверждение, ответы шлюза, глаголы инструментов, подсказки), интерфейс hermes --tui и десктопное приложение. Объявите provides_locales и положите YAML-файлы; Python не нужен:
~/.hermes/plugins/hermes-lang-pl/
├── plugin.yaml
└── locales/
├── pl.yaml # core (Python) strings — same key tree as the bundled locales/en.yaml
├── pl.tui.yaml # optional: TUI strings (keys in locales/_keys.tui.json)
└── pl.desktop.yaml # optional: Desktop strings (keys in locales/_keys.desktop.json)
name: hermes-lang-pl
version: 1.0.0
description: Polish language pack
provides_locales:
- id: pl # lowercase BCP-47-style id: pl, pt-br, zh-hant
endonym: Polski # what language switchers show
rtl: false
Когда provides_locales объявлен, загрузчик вызывает ctx.register_locale_dir(<plugin>/locales) до register() (пакет из одного манифеста без __init__.py загружается так же, как десктопный плагин из одного манифеста). Каталоги многослойные и частичные: пакет → пользовательский слой (<HERMES_HOME>/locales/) → встроенный → английский → ключ. Пакету нужны только те ключи, которые он меняет, а по каждому ключу побеждает последний загруженный пакет. Основные значения сохраняют именованные {placeholders} английского; записи TUI и десктопа, где английское значение — функция, пишутся строками с позиционными заполнителями {0}, {1}.
Плагины с кодом могут регистрироваться программно. Дескрипторы здесь — это PluginRegistration, поэтому выгрузка плагина убирает слой, а регистрация никогда не меняет display.language:
def register(ctx):
here = Path(__file__).parent
ctx.register_locale("pl", here / "locales" / "pl.yaml", endonym="Polski") # YAML path
ctx.register_locale("pl", {"approval": {"denied": " ✗ Odrzucono"}}) # mapping, nested or flat
ctx.register_locale("pl", here / "locales" / "pl.tui.yaml", surface="tui") # core | tui | desktop
ctx.register_locale_dir(here / "locales") # every <lang>[.surface].yaml
hermes plugins validate проверяет, что у каждого объявленного id есть разбираемый, только текстовый locales/<id>.yaml (нетекстовый лист — ошибка), и предупреждает, называя ключи, которых нет в английском каталоге этой поверхности. Рендереры получают слой пакета через RPC i18n.languages / i18n.catalog. Руководство для пользователей: Языковые пакеты.
Зависеть от переменных окружения
Если вашему плагину нужен API-ключ:
# plugin.yaml — simple format (backwards-compatible)
requires_env:
- WEATHER_API_KEY
Если WEATHER_API_KEY не установлена, плагин отключается с понятным сообщением. Никакого падения, никакой ошибки в агенте — просто «Plugin weather disabled (missing: WEATHER_API_KEY)».
Когда пользователи выполняют hermes plugins install, им интерактивно предлагается ввести любые отсутствующие переменные из requires_env. Значения автоматически сохраняются в .env.
Для более удобной установки используйте расширенный формат с описаниями и ссылками на регистрацию:
# plugin.yaml — rich format
requires_env:
- name: WEATHER_API_KEY
description: "API key for OpenWeather"
url: "https://openweathermap.org/api"
secret: true
| Поле | Обязательно | Описание |
|---|---|---|
name | Да | Имя переменной окружения |
description | Нет | Показывается пользователю при запросе во время установки |
url | Нет | Где получить учётные данные |
secret | Нет | Если true, ввод скрывается (как в поле пароля) |
Оба формата можно смешивать в одном списке. Уже установленные переменные молча пропускаются.
Ленивая установка опциональных зависимостей Python
Для SDK, который покрывает extra проекта Hermes, вызывайте pm.ensure_import в той
операции, которой он нужен. Для проверки доступности только на чтение есть pm.available.
Не ставьте зависимости из check_fn, который опрашивается часто.
В этом примере запрашивается существующий extra bedrock:
from pm import InstallError, ensure_import
def my_tool_handler(args, **kwargs):
try:
ensure_import("bedrock")
except InstallError as exc:
return {"error": str(exc)}
import boto3
# Use the SDK here.
Аргумент — имя extra из pyproject.toml. Это не произвольная спецификация пакета и не
ключ с привязкой к плагину. Старых реестра LAZY_DEPS и исключения FeatureUnavailable
больше нет.
Если выбрано новое окружение, хелпер может сообщить, что нужен перезапуск. Верните эту
ошибку, а не импортируйте из второго окружения внутри работающего процесса. Уже доступные
зависимости ставить не нужно, даже когда security.allow_lazy_installs равен false.
Собственные Python-зависимости плагина-каталога объявляйте в dependencies под
[project] в его pyproject.toml. Если файла проекта, написанного автором, нет, PM
объединяет устаревшие списки pip_dependencies и python_dependencies из plugin.yaml
или plugin.yml. Старые файлы проекта, сгенерированные PM, эти списки не перекрывают.
Согласие, членство в workspace и проверки актуальности опираются на одно и то же
объявление. PM готовит эти зависимости вместе с требованиями ядра, прежде чем включить
плагин. Сгенерированный workspace не переписывает ни каталог плагина, ни поставляемый
lock-файл. Конфликт зависимостей означает отказ в допуске с сохранением прежнего выбора.
Другие плагины PM автоматически не отключает.
Зависимости, поставленные вручную через pip, не считаются устойчивыми объявлениями PM:
при следующей замене окружения они могут не сохраниться. Плагины-обёртки, чьи среды
выполнения Python остаются вне PM, могут опираться на
контракт сохранности HERMES_HOME для провайдеров памяти.
Устройство среды выполнения и политику ленивой установки см. в разделе
Управление пакетами.
Потокобезопасные ленивые синглтоны
Плагины часто кэшируют дорогостоящий объект — клиент SDK, HTTP-сессию, пул соединений — в переменной уровня модуля, создаваемой при первом использовании:
_client = None
def get_client():
global _client
if _client is not None:
return _client
_client = ExpensiveClient(...) # ← TOCTOU race
return _client
Это ловушка. Hermes выполняет несколько потоков в одном процессе (делегированные вызовы инструментов, фоновые воркеры, форк самоулучшения), поэтому два потока могут одновременно попасть в get_client() до того, как _client будет установлен, оба пройдут проверку is not None, оба выполнят дорогостоящее создание объекта, и вторая запись перезатрёт первую — приводя к утечке того ресурса, который открыл проигравший поток (соединение, файловый дескриптор, фоновый поток).
Не пишите блокировку вручную. Используйте хелперы из plugins/plugin_utils.py:
from plugins.plugin_utils import lazy_singleton, SingletonSlot
# Zero-arg accessor → decorate it:
@lazy_singleton
def get_client():
return ExpensiveClient(load_config()) # runs exactly once
client = get_client() # safe across threads
get_client.reset() # drop the instance (tests / teardown)
# Accessor that takes a build argument → use a slot:
_slot: SingletonSlot = SingletonSlot()
def get_client(config=None):
return _slot.get(lambda: ExpensiveClient(resolve(config)))
def reset_client():
_slot.reset()
Обе конструкции сериализуют одновременные первые вызовы через двойную проверку блокировки (double-checked locking) и запускают фабрику максимум один раз. Если фабрика бросает исключение, ничего не кэшируется, и следующий вызов повторяет попытку. Плагин памяти Honcho (client.py) — эталонный пример использования.
Практическое правило: как только вы пишете
global _something, за которым следует проверка наis Noneи создание объекта, используйте вместо этого один из этих хелперов.
Условная доступность инструментов
Для инструментов, зависящих от опциональных библиотек:
ctx.register_tool(
name="my_tool",
schema={...},
handler=my_handler,
check_fn=lambda: _has_optional_lib(), # False = tool hidden from model
)
Переопределение встроенного инструмента
Чтобы заменить встроенный инструмент собственной реализацией (например, подставить вместо
браузера по умолчанию бэкенд на headed-Chrome через CDP, или заменить
web_search собственным корпоративным индексом), передайте override=True:
def register(ctx):
ctx.register_tool(
name="browser_navigate", # same name as the built-in
toolset="plugin_my_browser", # your own toolset namespace
schema={...},
handler=my_custom_navigate,
override=True, # explicit opt-in
)
Без override=True реестр отклоняет любую регистрацию, которая бы
затенила существующий инструмент из другого набора инструментов (toolset) — это предотвращает
случайные перезаписи. Переопределение встроенного инструмента дополнительно
требует, чтобы оператор явно разрешил это через
plugins.entries.<plugin_id>.allow_tool_override: true в config.yaml;
без этого разрешения register_tool(override=True) выбрасывает
PluginToolOverrideError. Переопределение логируется, так что его
можно отследить в ~/.hermes/logs/agent.log. Плагины загружаются после встроенных
инструментов, поэтому порядок регистрации корректен: ваш обработчик заменяет
встроенный.
Плагинам не из основного набора тоже нужно разрешение оператора. Для любого плагина,
который не поставляется вместе с ядром Hermes (источник — пользовательский, проектный или pip),
override=True поверх существующего встроенного инструмента дополнительно требует
разрешения для конкретного плагина в config.yaml:
plugins:
entries:
my-plugin: # ключ плагина в реестре из вывода `hermes plugins list`
allow_tool_override: true
Без этого разрешения ctx.register_tool(..., override=True) выбрасывает
PluginToolOverrideError; поскольку исключения из register() перехватываются
загрузчиком, плагин отключается, а Hermes продолжает работать. Это ограничение существует,
потому что включённый плагин, который незаметно заменяет привилегированный встроенный
инструмент вроде shell_exec или write_file, мог бы перехватывать всё, что модель
через него направляет. Встроенные (bundled) плагины из этого правила исключены: переопределение
там — решение мейнтейнеров. Если конфиг не удаётся загрузить, ограничение по умолчанию
запрещает переопределение (fail closed).
Обычно вам никогда не придётся редактировать этот ключ вручную. hermes plugins enable <name>
спрашивает, выдавать ли это разрешение, только если манифест плагина объявляет его
в capabilities: (экран согласия, по умолчанию — нет); плагин, не объявляющий
возможностей, включается без запроса на разрешение. Флаги --allow-tool-override /
--no-allow-tool-override в любом случае явно выдают или отзывают разрешение — для
установки по сценарию или для заблаговременного разрешения плагину, который ещё не
перешёл на блок в манифесте.
То же разрешение управляет и deregister(): без него плагин не может
удалить инструмент, которым не владеет (иначе это стало бы способом обойти
проверку переопределения).
Регистрация нескольких хуков
def register(ctx):
ctx.register_hook("pre_tool_call", before_any_tool)
ctx.register_hook("post_tool_call", after_any_tool)
ctx.register_hook("pre_llm_call", inject_memory)
ctx.register_hook("on_session_start", on_new_session)
ctx.register_hook("on_session_end", on_session_end)
Справочник по хукам
Каждый хук подробно описан в справочнике по хукам событий — сигнатуры колбэков, таблицы параметров, точный момент срабатывания каждого хука и примеры. Вот сводка:
| Хук | Когда срабатывает | Сигнатура колбэка | Возвращает |
|---|---|---|---|
pre_tool_call | Перед выполнением любого инструмента | tool_name: str, args: dict, task_id: str | опциональная директива: {"action": "block", "message": ...} отменяет вызов; {"action": "approve", "message": ...} эскалирует к шлюзу подтверждения человеком |
post_tool_call | После возврата результата любым инструментом | tool_name: str, args: dict, result: str, task_id: str, duration_ms: int | игнорируется |
pre_llm_call | Один раз за ход, перед циклом вызова инструментов | session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str | внедрение контекста |
post_llm_call | Один раз за ход, после цикла вызова инструментов (только для успешных ходов) | session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str | игнорируется |
pre_api_request | Перед каждым сырым API-запросом к провайдеру (несколько за ход, когда модель вызывает инструменты) | session_id: str, model: str, provider: str, base_url: str, api_mode: str, api_call_count: int, message_count: int, tool_count: int, approx_input_tokens: int, max_tokens: int, request: dict | игнорируется |
post_api_request | После возврата каждого сырого API-запроса к провайдеру | поля pre_api_request плюс api_duration: float, finish_reason: str, response_model: str | None, usage: dict, response: dict, assistant_content_chars: int, assistant_tool_call_count: int | игнорируется |
api_request_error | API-вызов провайдера завершился исключением | поля корреляции плюс status_code: int | None, retry_count: int | None, max_retries: int | None, retryable: bool | None, reason: str | None, error: dict, request: dict | игнорируется |
pre_auxiliary_call | Перед каждой попыткой обращения к провайдеру во вспомогательном LLM-вызове (заголовки, сжатие, MoA, зрение, подтверждения, …); это не pre_api_request | aux_task: str плюс поля pre_api_request (session_id/task_id/turn_id — родительского хода или пустые, api_request_id: str, retry_count: int, streaming: bool, request: dict) | игнорируется |
post_auxiliary_call | После того как эта попытка вернулась или выбросила исключение | поля pre_auxiliary_call плюс api_duration: float, finish_reason, response_model, usage: dict | None, response: dict | None, error: str | None, error_type: str | None | игнорируется |
on_session_start | Создана новая сессия (только первый ход) | session_id: str, model: str, platform: str | игнорируется |
on_session_end | Конец каждого вызова run_conversation + выход из CLI | session_id: str, completed: bool, interrupted: bool, model: str, platform: str | игнорируется |
on_session_finalize | CLI/шлюз завершает активную сессию | session_id: str | None, platform: str | игнорируется |
on_session_reset | Шлюз подставляет новый ключ сессии (/new, /reset) | session_id: str, platform: str | игнорируется |
gateway_platform_event | Авторизованное платформо-специфичное событие нормализуется на границе шлюза (сейчас — реакции Telegram) | platform: str, event_type: str, payload: dict | игнорируется |
kanban_task_claimed | Задача канбана взята в работу (процесс диспетчера, до появления воркера) | task_id: str, board: str | None, assignee: str | None, run_id: int | None, profile_name: str | игнорируется |
kanban_task_completed | Задача канбана завершена (процесс воркера) | task_id, board, assignee, run_id, profile_name, summary: str | None | игнорируется |
kanban_task_blocked | Задача канбана заблокирована (процесс воркера) | task_id, board, assignee, run_id, profile_name, reason: str | None | игнорируется |
Большинство хуков — это чисто наблюдатели по принципу «отправил и забыл» (fire-and-forget): их возвращаемые значения игнорируются. Исключения — pre_llm_call, который может внедрять контекст в разговор, и pre_tool_call, который может вернуть директиву block/approve.
Все колбэки должны принимать **kwargs для обратной совместимости. Если колбэк хука падает с ошибкой, это логируется, и он пропускается. Остальные хуки и агент продолжают работать в обычном режиме.
Хуки жизненного цикла канбана срабатывают после фиксации изменения в БД доски, поэтому колбэк всегда видит устойчивое (durable) состояние и никогда не может удерживать блокировку записи SQLite. Поскольку воркеры канбана работают как отдельные подпроцессы hermes -p <profile> chat -q, kanban_task_claimed срабатывает в процессе диспетчера, а kanban_task_completed / kanban_task_blocked — в процессе воркера: подключайте хук в диспетчере, чтобы централизованно наблюдать за каждым переходом, или в воркере — для контекста конкретной задачи внутри сессии.
Хуки API-запросов — это наблюдатели за сырым запросом к провайдеру, на уровень ниже пары pre_llm_call / post_llm_call для каждого хода: один ход, вызывающий инструменты, делает несколько API-запросов, и эти хуки срабатывают вокруг каждого из них. Они существуют для плагинов наблюдаемости (трассировка, учёт стоимости, дашборды задержек). Ключевые аргументы request и response — это очищенные, ограниченные по размеру JSON-представления полезной нагрузки провайдера (чувствительные ключи скрыты, длинные строки обрезаны, объекты SDK нормализованы), а usage — простой словарь-сводка по токенам. Каждая полезная нагрузка несёт поля корреляции turn_id, api_request_id, task_id, session_id и api_call_count, так что плагин может сшивать между собой запросы, вызовы инструментов и ходы. api_request_error срабатывает, когда вызов к провайдеру завершается исключением, и добавляет status_code, retry_count / max_retries, retryable, reason и словарь error с полями type и message.
Внедрение контекста в pre_llm_call
Это единственный хук, чьё возвращаемое значение имеет значение. Когда колбэк pre_llm_call возвращает словарь с ключом "context" (или обычную строку), Hermes внедряет этот текст в сообщение пользователя текущего хода. Это механизм, который используют плагины памяти, интеграции RAG, guardrails и любой плагин, которому нужно передать модели дополнительный контекст.
Формат возврата
# Dict with context key
return {"context": "Recalled memories:\n- User prefers dark mode\n- Last project: hermes-agent"}
# Plain string (equivalent to the dict form above)
return "Recalled memories:\n- User prefers dark mode"
# Return None or don't return → no injection (observer-only)
return None
Любой не-None и непустой возврат с ключом "context" (или обычная непустая строка) собирается и добавляется к сообщению пользователя за текущий ход.
Разлив контекста при превышении размера
По умолчанию контекст на один хук ограничен 10 000 символами. Всё, что превышает этот лимит, записывается в $HERMES_HOME/hook_outputs/<session_id>/<uuid>.txt и заменяется превью «начало/конец» плюс путь к сохранённому файлу. Модель может прочитать полное содержимое через read_file или terminal, если оно ей действительно нужно. Это не даёт неуправляемому плагину раздувать промпт каждого последующего хода и выбивать префикс кэша промпта. Настраивается в config.yaml:
hooks:
output_spill:
enabled: true # default: true
max_chars: 10000 # default; set higher to opt out of spilling
preview_head: 500 # chars shown at the top of the preview
preview_tail: 500 # chars shown at the bottom of the preview
# directory: null # default: $HERMES_HOME/hook_outputs
Как работает внедрение
Внедрённый контекст добавляется к сообщению пользователя, а не к системному промпту. Это осознанное архитектурное решение:
- Сохранение кэша промпта — системный промпт остаётся неизменным между ходами. Anthropic и OpenRouter кэшируют префикс системного промпта, так что его стабильность экономит 75%+ входных токенов в многоходовых разговорах. Если бы плагины изменяли системный промпт, каждый ход означал бы промах кэша.
- Эфемерность — внедрение происходит только в момент вызова API. Исходное сообщение пользователя в истории разговора никогда не изменяется, и ничего не сохраняется в базе данных сессии.
- Системный промпт — территория Hermes — он содержит специфичные для модели указания, правила принудительного использования инструментов, инструкции по личности и кэшированное содержимое навыков. Плагины добавляют контекст рядом со вводом пользователя, а не изменяют базовые инструкции агента.
Пример: плагин восстановления памяти
"""Memory plugin — recalls relevant context from a vector store."""
import httpx
MEMORY_API = "https://your-memory-api.example.com"
def recall_context(session_id, user_message, is_first_turn, **kwargs):
"""Called before each LLM turn. Returns recalled memories."""
try:
resp = httpx.post(f"{MEMORY_API}/recall", json={
"session_id": session_id,
"query": user_message,
}, timeout=3)
memories = resp.json().get("results", [])
if not memories:
return None # nothing to inject
text = "Recalled context from previous sessions:\n"
text += "\n".join(f"- {m['text']}" for m in memories)
return {"context": text}
except Exception:
return None # fail silently, don't break the agent
def register(ctx):
ctx.register_hook("pre_llm_call", recall_context)
Пример: плагин guardrails
"""Guardrails plugin — enforces content policies."""
POLICY = """You MUST follow these content policies for this session:
- Never generate code that accesses the filesystem outside the working directory
- Always warn before executing destructive operations
- Refuse requests involving personal data extraction"""
def inject_guardrails(**kwargs):
"""Injects policy text into every turn."""
return {"context": POLICY}
def register(ctx):
ctx.register_hook("pre_llm_call", inject_guardrails)
Пример: хук только-наблюдатель (без внедрения)
"""Analytics plugin — tracks turn metadata without injecting context."""
import logging
logger = logging.getLogger(__name__)
def log_turn(session_id, user_message, model, is_first_turn, **kwargs):
"""Fires before each LLM call. Returns None — no context injected."""
logger.info("Turn: session=%s model=%s first=%s msg_len=%d",
session_id, model, is_first_turn, len(user_message or ""))
# No return → no injection
def register(ctx):
ctx.register_hook("pre_llm_call", log_turn)
Несколько плагинов, возвращающих контекст
Когда несколько плагинов возвращают контекст из pre_llm_call, их выходные данные объединяются двойным переводом строки и добавляются к сообщению пользователя вместе. Порядок следует порядку обнаружения плагинов (по алфавиту, по имени каталога плагина).
Middleware: изменение того, что происходит
Хуки наблюдают за циклом агента (с несколькими описанными выше формами управления). Middleware меняет то, что происходит: middleware запроса переписывает действующую полезную нагрузку до того, как её увидит что-либо ниже по цепочке, а middleware исполнения оборачивает сам вызов. Регистрируется из той же точки входа register(ctx):
def cap_find_output(tool_name, args, **kwargs):
"""Переписывает команды terminal find, чтобы ограничить их вывод."""
command = args.get("command", "")
if tool_name == "terminal" and command.startswith("find "):
return {
"args": {**args, "command": command + " | head -100"},
"source": "my-plugin",
"reason": "cap find output",
}
return None # оставить вызов без изменений
def register(ctx):
ctx.register_middleware("tool_request", cap_find_output)
Канонический список видов (kind) — VALID_MIDDLEWARE в hermes_cli/middleware.py:
| Вид | Получает | Контракт возврата |
|---|---|---|
tool_request | tool_name, args, original_args, контекстные kwargs | Верните {"args": {...}}, чтобы заменить действующие аргументы инструмента до того, как их увидят хуки, guardrails, подтверждения и исполнение. Верните None, чтобы оставить вызов без изменений. |
llm_request | request, original_request, контекстные kwargs | Верните {"request": {...}}, чтобы заменить действующие kwargs провайдера до того, как Hermes их отправит. |
tool_execution | полезная нагрузка плюс next_call | Оборачивает выполнение инструмента. Вызовите next_call(payload) ровно один раз, чтобы выполнить цепочку ниже (или пропустите вызов, чтобы прервать её), и верните результат. |
llm_execution | полезная нагрузка плюс next_call | Та же форма, оборачивает вызов провайдера. |
Правила, важные на практике:
- Цепочки middleware запроса: каждый колбэк видит полезную нагрузку такой, какой её переписали предыдущие колбэки, а
original_args/original_requestвсегда несёт копию до применения middleware. Полезные нагрузки копируются между колбэками, так что их можно свободно изменять. - В возвращаемый словарь можно включить строки
source,reasonиname. Они попадают в трассу middleware, которую хуки-наблюдатели ниже по цепочке получают как kwargmiddleware_trace. next_callв middleware исполнения — одноразовый. Повторный вызов приводит к исключению, потому что он заново выполнил бы провайдера или инструмент.- Колбэк middleware, который бросает исключение, логируется и пропускается; цепочка продолжает работу. Сбой ниже по цепочке, возникший после вашего
next_call, распространяется как есть. Middleware никогда не может сломать базовый путь выполнения. - Полезные нагрузки middleware несут
middleware_schema_version(hermes.middleware.v1) вместе с полями телеметрии наблюдателя. - Неизвестные виды регистрируются с предупреждением, а не с ошибкой, так что плагин, написанный под более новый Hermes, всё равно загрузится на более старом.
Регистрация команд CLI
Плагины могут добавлять собственное дерево подкоманд hermes <plugin>:
def _my_command(args):
"""Handler for hermes my-plugin <subcommand>."""
sub = getattr(args, "my_command", None)
if sub == "status":
print("All good!")
elif sub == "config":
print("Current config: ...")
else:
print("Usage: hermes my-plugin <status|config>")
def _setup_argparse(subparser):
"""Build the argparse tree for hermes my-plugin."""
subs = subparser.add_subparsers(dest="my_command")
subs.add_parser("status", help="Show plugin status")
subs.add_parser("config", help="Show plugin config")
subparser.set_defaults(func=_my_command)
def register(ctx):
ctx.register_tool(...)
ctx.register_cli_command(
name="my-plugin",
help="Manage my plugin",
setup_fn=_setup_argparse,
handler_fn=_my_command,
)
После регистрации пользователи могут выполнять hermes my-plugin status, hermes my-plugin config и т. д.
Плагины провайдера памяти используют вместо этого подход на основе соглашения: добавьте функцию register_cli(subparser) в файл cli.py вашего плагина. Система обнаружения плагинов памяти находит её автоматически — вызов ctx.register_cli_command() не требуется. Подробности см. в руководстве по плагинам провайдера памяти.
Ограничение активным провайдером: команды CLI плагина памяти появляются только тогда, когда его провайдер — активный memory.provider в конфиге. Если пользователь не настроил ваш провайдер, ваши команды CLI не будут засорять вывод справки.
Регистрация слэш-команд
Плагины могут регистрировать внутрисессионные слэш-команды — команды, которые пользователи вводят во время разговора (например, /lcm status или /ping). Они работают и в CLI, и в шлюзе (Telegram, Discord и т. д.).
def _handle_status(raw_args: str) -> str:
"""Handler for /mystatus — called with everything after the command name."""
if raw_args.strip() == "help":
return "Usage: /mystatus [help|check]"
return "Plugin status: all systems nominal"
def register(ctx):
ctx.register_command(
"mystatus",
handler=_handle_status,
description="Show plugin status",
)
После регистрации пользователи могут вводить /mystatus в любой сессии. Команда появляется в автодополнении, выводе /help и меню телеграм-бота.
Сигнатура: ctx.register_command(name: str, handler: Callable, description: str = "", args_hint: str = "")
| Параметр | Тип | Описание |
|---|---|---|
name | str | Имя команды без ведущего слэша (например, "lcm", "mystatus") |
handler | Callable[[str], str | None] | Вызывается со строкой сырых аргументов. Может быть и async. |
description | str | Показывается в /help, автодополнении и меню телеграм-бота |
Ключевые отличия от register_cli_command():
register_command() | register_cli_command() | |
|---|---|---|
| Вызывается как | /name в сессии | hermes name в терминале |
| Где работает | Сессии CLI, Telegram, Discord и т. д. | Только терминал |
| Обработчик получает | Строку сырых аргументов | argparse Namespace |
| Сценарий использования | Диагностика, статус, быстрые действия | Сложные деревья подкоманд, мастера настройки |
Защита от конфликтов: если плагин пытается зарегистрировать имя, конфликтующее со встроенной командой (help, model, new и т. д.), регистрация молча отклоняется с предупреждением в логе. Встроенные команды всегда имеют приоритет.
Асинхронные обработчики: диспетчер шлюза автоматически определяет и ожидает асинхронные обработчики, так что можно использовать как синхронные, так и асинхронные функции:
async def _handle_check(raw_args: str) -> str:
result = await some_async_operation()
return f"Check result: {result}"
def register(ctx):
ctx.register_command("check", handler=_handle_check, description="Run async check")
Вызов инструментов из слэш-команд
Обработчикам слэш-команд, которым нужно оркестрировать инструменты (запускать субагента через delegate_task, вызывать file_edit и т. д.), следует использовать ctx.dispatch_tool() вместо обращения к внутренностям фреймворка. Контекст родительского агента (подсказки по рабочей области, спиннер, наследование модели) подключается автоматически.
def register(ctx):
def _handle_deliver(raw_args: str):
result = ctx.dispatch_tool(
"delegate_task",
{
"goal": raw_args,
"toolsets": ["terminal", "file", "web"],
},
)
return result
ctx.register_command(
"deliver",
handler=_handle_deliver,
description="Delegate a goal to a subagent",
)
Сигнатура: ctx.dispatch_tool(name: str, args: dict, *, parent_agent=None) -> str
| Параметр | Тип | Описание |
|---|---|---|
name | str | Имя инструмента, как зарегистрировано в реестре инструментов (например, "delegate_task", "file_edit") |
args | dict | Аргументы инструмента, той же формы, что отправила бы модель |
parent_agent | Agent | None | Опциональное переопределение. Если не указано, разрешается через текущий агент CLI (либо корректно деградирует в режиме шлюза) |
Поведение во время выполнения:
- Режим CLI:
parent_agentразрешается через активный агент CLI, так что подсказки по рабочей области, спиннер и выбор модели наследуются как ожидается. - Режим шлюза: агента CLI нет, поэтому инструменты корректно деградируют — рабочая область читается из настроенного рабочего каталога терминала, спиннер не показывается.
- Явное переопределение: если вызывающий явно передаёт
parent_agent=, оно учитывается и не перезаписывается.
Это публичный, стабильный интерфейс для вызова инструментов из команд плагина. Плагины не должны обращаться к ctx._cli_ref.agent или подобному приватному состоянию.
Действия изнутри хука (профиль + инструменты)
ctx._cli_ref заполняется только в интерактивной сессии CLI. Он равен None в шлюзе, в неинтерактивных запусках hermes chat -q и в воркер-сессиях, запущенных канбаном — так что любая логика плагина, обращающаяся через _cli_ref, молча становится no-op именно в этих контекстах. Есть два стабильных API, не зависящих от типа сессии, которые покрывают то, что реально нужно хукам:
ctx.profile_name— имя активного профиля (например,"default", или профиль исполнителя (assignee) в воркере канбана). Определяется изHERMES_HOME, поэтому работает везде без зависимости от_cli_ref.ctx.dispatch_tool(name, args)— вызывает любой зарегистрированный инструмент (встроенный или из плагина), включая инструментыkanban_*,delegate_task,terminal,read_fileи т. д. Работает из колбэков хуков независимо от того, в каком процессе срабатывает хук.
Вместе они позволяют хуку жизненного цикла канбана наблюдать за переходом и действовать по доске, не трогая внутренности фреймворка:
def register(ctx):
def on_blocked(*, task_id, reason=None, **kw):
# Runs in the worker process; ctx._cli_ref is None here.
ctx.dispatch_tool("kanban_comment", {
"task_id": task_id,
"comment": f"[{ctx.profile_name}] auto-noted block: {reason}",
})
ctx.register_hook("kanban_task_blocked", on_blocked)
Чтобы выполнить полноценную подкоманду hermes <subcommand> (например, hermes kanban show), запускайте её через инструмент terminal посредством ctx.dispatch_tool("terminal", {"command": "hermes kanban show ..."}) — для headless-сессий воркера моста в слэш-команды нет, и инструменты — поддерживаемый способ управлять Hermes из хука.
Отправка сырых CDP-команд в браузер агента
Плагин, которому нужно управлять браузером агента на уровне ниже браузерных инструментов, может получить CDP-хендл, привязанный к живому соединению супервизора задачи, через SUPERVISOR_REGISTRY.capture(task_id) и отправлять команды через call(method, params=None, *, session_id=None, timeout=10.0). Когда это соединение пропадает, хендл выбрасывает CapturedCDPInvalid и никогда не переключается на более новое. Пользуйтесь им вместо чтения или записи приватных атрибутов CDPSupervisor, которые каталог отклоняет. Это доверенный внутрипроцессный транспорт: проверок origin, согласия и владения он за вас не делает. Полный контракт: Browser CDP Supervisor.
Как узнать, в каком запуске cron вы находитесь
ctx.current_cron_execution() возвращает запуск по расписанию, внутри которого выполняется текущий код, а вне cron — None. Функция работает из любого хука, срабатывающего во время запуска (pre_tool_call, post_tool_call, pre_llm_call и т. д.), и из обработчиков инструментов. Значение — замороженный CronExecution:
| Поле | Смысл |
|---|---|
job_id, job_name | Задача cron. |
execution_id | Строка этого запуска в журнале исполнений (hermes cron runs). |
source | Каким путём запущен: "builtin" (встроенный планировщик), "direct" (запуск вне него, например hermes cron run) или имя внешнего планировщика. |
scheduled_instant | Момент расписания, на который приходится этот запуск. None для ручного или иного внепланового запуска, так что по этому полю отличают запуск по расписанию от ручного. |
started_at | Когда запуск начался. |
profile | Профиль, которому принадлежит задача. |
Планировщик устанавливает значение только после того, как запуск получил право на исполнение, и сбрасывает его по завершении. Значение привязано к запуску, поэтому две задачи или два профиля, сработавшие одновременно, никогда не увидят значения друг друга. Модель подделать его не может: аргументы хуков приходят от Hermes, а подпроцессы инструментов (terminal, execute_code) работают в отдельном интерпретаторе. Субагенты, запущенные через delegate_task, получают None: сами они не являются запуском по расписанию.
def register(ctx):
def guard(*, tool_name, args, **kw):
run = ctx.current_cron_execution()
if tool_name == "deploy" and (run is None or run.scheduled_instant is None):
return {"action": "block", "message": "deploy only runs from its scheduled cron job"}
ctx.register_hook("pre_tool_call", guard)
Добавление Automation Blueprints
ctx.register_automation_blueprint(key, *, title, description, schedule_template, prompt_template, category="general", slots=(), deliver_default="origin", skills=(), tags=()) добавляет автоматизацию-заготовку с пропусками в каталог Automation Blueprints. Она появляется рядом со встроенными в /blueprint (CLI, TUI, мессенджеры), на вкладке Blueprints дашборда и на странице Cron в десктопе, с пометкой имени вашего плагина. Аргументы — это поля встроенного AutomationBlueprint (cron/blueprint_catalog.py); каждый слот — словарь из полей BlueprintSlot.
def register(ctx):
ctx.register_automation_blueprint(
"standup",
title="Team standup digest",
description="Every weekday, summarize what changed in a repo since yesterday.",
category="work",
schedule_template="{minute} {hour} * * 1-5",
prompt_template="Summarize yesterday's commits and open PRs for {repo} as a short standup digest.",
slots=[
{"name": "repo", "type": "text", "label": "Which repo?", "default": "acme/app"},
{"name": "time", "type": "time", "label": "What time?", "default": "09:15"},
{"name": "deliver", "type": "enum", "label": "Where to deliver?", "default": "origin",
"options": ["origin", "local"], "strict": False},
],
tags=["work", "daily"],
)
Дальше пользователи запускают /blueprint teamtools:standup (или просто /blueprint standup) либо выбирают заготовку в форме десктопа или дашборда.
- Ключи получают пространство имён. Ключ в каталоге —
<plugin>:<key>(teamtools:standup), поэтому плагин никогда не заменит встроенную заготовку или заготовку другого плагина. Передавайте «голый» ключ; ключ с:отклоняется. - Слоты и шаблоны.
typeслота —time(HH:MM),enum,weekdaysилиtext.prompt_templateможет использовать любой слот как{name}.schedule_templateможет использовать имена слотов плюс{minute}/{hour}(из слота с именемtime) и{dow}(из слотаrecurrence/day, иначе*). Слот с именемdeliverполучает в формах десктопа и дашборда реальные цели доставки профиля. - Проверка при регистрации. Неизвестный заполнитель, неверный тип слота, дублирующийся ключ или расписание, которое не разбирается, пишут предупреждение в лог и пропускают эту заготовку; остальной плагин загружается дальше. Если у каждого слота есть значение по умолчанию, Hermes один раз заполняет заготовку при загрузке, и сломанный шаблон падает тогда, а не при первом нажатии пользователя на Schedule it.
- На профиль. Плагины загружаются по профилям, поэтому заготовка видна только в тех профилях, где ваш плагин включён.
- Задачи переживают плагин. Планирование заготовки создаёт обычную задачу cron с уже заполненными промптом и расписанием. Отключение или удаление плагина убирает заготовку из каталога, но запущенные задачи продолжают работать. Если в
skillsзадачи указан навык, который поставляет ваш плагин, пока плагин отключён, задача этот навык пропускает (и пишет об этом в лог).
Объявлять возможность (capability) в манифесте для этого не нужно. Работает и при изоляции хоста плагинов, потому что слоты — обычные словари.
Обработка кликов по кнопкам Slack Block Kit
Плагины, публикующие сообщения Block Kit с интерактивными элементами (кнопки, меню overflow, выбор даты и т. д.), могут регистрировать обработчики кликов напрямую в адаптере Slack — без monkey-patching slack_bolt.AsyncApp.
def register(ctx):
async def _on_approve(ack, body, action):
# ack within 3 seconds — slack_bolt requirement.
await ack()
# body["channel"]["id"], body["user"]["id"], body["message"]["ts"]
# action["action_id"], action["value"]
sweep_id = (action.get("value") or "").split("|", 1)[-1]
# ...do the deterministic work, then post a follow-up.
ctx.register_slack_action_handler("inbox_sweep_approve", _on_approve)
Сигнатура: ctx.register_slack_action_handler(action_id, callback) -> None
| Параметр | Тип | Описание |
|---|---|---|
action_id | str | re.Pattern | dict | Всё, что принимает slack_bolt.App.action(): буквальный action_id, скомпилированное регулярное выражение, соответствующее нескольким id, или словарь ограничений вида {"action_id": "...", "block_id": "..."} |
callback | асинхронный вызываемый объект | Получает (ack, body, action) согласно соглашению slack_bolt |
Поведение во время выполнения:
- Обработчик ставится в очередь на этапе загрузки плагина и подключается к
slack_bolt.AsyncAppадаптера при подключении платформы Slack. - Каждый колбэк оборачивается защитным механизмом: если ваш обработчик падает с ошибкой, шлюз логирует ошибку и, по возможности, всё равно подтверждает (
ack) клик, чтобы Slack не повторял попытки. - Действуют стандартные правила slack_bolt —
await ack()в течение 3 секунд, затем долгая работа. - Для развёртываний с несколькими рабочими пространствами обработчик срабатывает на клики из любого подключённого рабочего пространства; используйте
body["team"]["id"], если нужно ограничить область поведения.
Это публичный способ для плагинов участвовать в интерактивности Slack. Более старые плагины могут патчить SlackAdapter.connect; предпочитайте вместо этого данный API. Для полной поверхности slack_bolt (события, шорткаты, команды — не только действия Block Kit) используйте общий register_platform_handler("slack", ...) ниже.
Регистрация нативных обработчиков платформы (любая платформа)
Плагинам, которым нужно получать события платформы, не маршрутизируемые базовым адаптером, — дополнительные типы обновлений, нативные колбэки кнопок, события реакций/участников, вебхук-маршруты, — можно зарегистрировать фабрику обработчика, которую адаптер платформы вызывает при подключении. Это работает на любой платформе шлюза.
def register(ctx):
def _wire(native, adapter):
# native: объект клиента/приложения платформы (см. таблицу ниже)
# adapter: экземпляр адаптера платформы (считайте доступным только для чтения)
# Импортируйте SDK платформы ЗДЕСЬ, чтобы register() работал без них.
...
ctx.register_platform_handler("discord", _wire)
Сигнатура: ctx.register_platform_handler(platform, factory) -> None
| Параметр | Тип | Описание |
|---|---|---|
platform | str | Название платформы шлюза в нижнем регистре ("telegram", "discord", "slack", "matrix", …) |
factory | вызываемый объект | Получает (native, adapter) при подключении |
Что представляет собой native для каждой платформы:
| Платформа | Объект native | Типичные хуки |
|---|---|---|
telegram | PTB Application | add_handler — любой тип обновления, колбэки с фильтром по паттерну |
discord | discord.ext.commands.Bot | add_listener — реакции, события участников, треды, голос |
slack | slack_bolt.AsyncApp | app.event() / app.action() / app.command() |
matrix | клиент Matrix | колбэки событий |
teams | Teams App | декораторы on_message / on_card_action |
dingtalk | DingTalkStreamClient | register_callback_handler для других тем потока |
feishu | клиент lark_oapi | вызовы API; маршрутизация событий |
line, api_server, msgraph_webhook | aiohttp web.Application | router.add_get/post — пользовательские маршруты (подключаются до заморозки роутера) |
| всё остальное (whatsapp, signal, irc, email, sms, ntfy, wecom, weixin, bluebubbles, yuanbao, …) | None | хук на момент подключения; работайте через дескриптор adapter |
Поведение во время выполнения:
- Фабрики ставятся в очередь на этапе загрузки плагина и вызываются при подключении платформы — для платформ, где важен порядок диспетчеризации (Telegram, Slack, Teams, aiohttp-роутеры), они выполняются до регистрации базовых обработчиков, поэтому обработчики плагина с ограниченной областью действия получают приоритет, а всё остальное проваливается дальше.
- Всегда ограничивайте область добавляемых обработчиков в таблицах диспетчеризации по первому совпадению. В Telegram используйте
CallbackQueryHandler(..., pattern=r"^myplugin:")— неограниченный по области обработчик проглотит базовые сценарии с кнопками (подтверждение exec, выбор модели, уточняющие промпты). - Каждая фабрика изолирована: если она выбрасывает исключение, ошибка логируется, а платформа всё равно подключается.
- Импортируйте SDK платформы внутри тела фабрики, а не на уровне модуля —
register()должен работать, даже если SDK не установлен. - Один плагин может зарегистрировать фабрики для нескольких платформ; каждая срабатывает только при подключении своей платформы.
Алиас для Telegram: ctx.register_telegram_handler(factory) — алиас для обратной совместимости с ctx.register_platform_handler("telegram", factory).
Пример — Telegram, инлайн-кнопки с фильтром по паттерну:
def register(ctx):
def _wire(application, adapter):
from telegram.ext import CallbackQueryHandler
async def _on_button(update, context):
query = update.callback_query
await query.answer()
# ...обработка колбэков "myplugin:*"
application.add_handler(
CallbackQueryHandler(_on_button, pattern=r"^myplugin:")
)
ctx.register_platform_handler("telegram", _wire)
Пример — Discord, события реакций:
def register(ctx):
def _wire(bot, adapter):
async def on_raw_reaction_add(payload):
... # например, голосование или модерация на основе реакций
bot.add_listener(on_raw_reaction_add, "on_raw_reaction_add")
ctx.register_platform_handler("discord", _wire)
Загрузка плагина на ходу: что включается сразу, а что — со следующей сессии
Плагин может загрузиться, когда шлюз (или сервер TUI/Desktop) уже работает: hermes plugins install/enable, установка из Desktop или дашборда, перезакрепление в каталоге или принудительное
повторное обнаружение, вызванное инструментом. Каждый из этих путей выполняет настоящее принудительное
пересканирование (discover_plugins(force=True)), и изнутри него срабатывает
PluginManager.on_plugin_loaded(callback) — по одной сводке на каждый впервые загруженный плагин
(hermes_cli/plugins_activation.py):
{"name": "late-mcp", "key": "late-mcp",
"activated_now": {"gateway_commands": ["late"], "callbacks": ["telegram"]},
"deferred": {"tools": ["late_tool"], "prompt": ["late.section"], "mcp_servers": ["worker"]}}
- Работает сразу — слэш-команды шлюза, хуки преобразования шлюза и прочие хуки, колбэки платформ:
раннер шлюза подписывается при загрузке и вызывает идемпотентный
rewire_plugin_handlers()у каждого живого адаптера, так что фабрикаregister_platform_handler(или обработчик действий Slack), зарегистрированная поздним плагином, подключается без перезапуска. Повторное подключение дедуплицируется для каждого нативного клиента по(plugin, factory qualname); в Telegram поздние обработчики поднимаются выше общих перехватчиков ядраfilters.COMMAND/CallbackQueryHandler(PTB отдаёт событие первому совпадению в группе) — ровно туда, где они стояли бы при подключении. - Откладывается — секции
toolsиpromptприменяются со следующей сессии (схема промпта и инструментов текущей сессии стабильна ради кэша — то же правило, что у/skills install);mcp_servers(серверы изmcp.jsonплагина, под именами из mcp.json) подключаются поmcp.reloadили со следующей сессии. - Обратного отключения нет: если выключить плагин на ходу, его уже подключённые обработчики остаются до перезапуска шлюза, и интерфейсы об этом сообщают.
Места установки сообщают ровно такое разделение: hermes plugins install/enable печатает его после того,
как подтолкнёт работающий шлюз (команда управляющего сокета reload-plugins), а
plugins.manage install/toggle/update возвращает activation + gateway_reloaded
(restart_required равен true, только когда не ответил ни один шлюз).
Это руководство описывает общие плагины (инструменты, хуки, слэш-команды, команды CLI). Разделы ниже описывают паттерн разработки для каждого специализированного типа плагинов; каждый ведёт к своему полному руководству со справочником полей и примерами.
Специализированные типы плагинов
Помимо общей поверхности, у Hermes есть пять специализированных типов плагинов. Каждый поставляется как каталог под plugins/<category>/<name>/ (встроенный) или ~/.hermes/plugins/<category>/<name>/ (пользовательский). Контракт различается по категориям — выберите нужный, затем читайте полное руководство.
Плагины провайдера модели — добавление LLM-бэкенда
Положите профиль в plugins/model-providers/<name>/:
# plugins/model-providers/acme/__init__.py
from providers import register_provider
from providers.base import ProviderProfile
register_provider(ProviderProfile(
name="acme",
aliases=("acme-inference",),
display_name="Acme Inference",
env_vars=("ACME_API_KEY", "ACME_BASE_URL"),
base_url="https://api.acme.example.com/v1",
auth_type="api_key",
default_aux_model="acme-small-fast",
fallback_models=("acme-large-v3", "acme-medium-v3"),
))
# plugins/model-providers/acme/plugin.yaml
name: acme-provider
kind: model-provider
version: 1.0.0
description: Acme Inference — OpenAI-compatible direct API
Обнаруживается лениво при первом вызове get_provider_profile() или list_providers() — auth.py, config.py, doctor.py, models.py, runtime_provider.py и транспорт chat_completions автоматически подключаются к нему. Пользовательские плагины переопределяют встроенные по имени.
Полное руководство: Плагины провайдера модели — справочник полей, переопределяемые хуки (prepare_messages, build_extra_body, build_api_kwargs_extras, fetch_models), выбор api_mode, типы аутентификации, тестирование.
Платформенные плагины — добавление канала шлюза
Положите адаптер в plugins/platforms/<name>/:
# plugins/platforms/myplatform/adapter.py
from gateway.platforms.base import BasePlatformAdapter
class MyPlatformAdapter(BasePlatformAdapter):
async def connect(self): ...
async def send(self, chat_id, text): ...
async def disconnect(self): ...
def check_requirements():
from agent.secret_scope import get_secret
return bool(get_secret("MYPLATFORM_TOKEN"))
def _env_enablement():
from agent.secret_scope import get_secret
tok = (get_secret("MYPLATFORM_TOKEN") or "").strip()
if not tok:
return None
return {"token": tok}
def register(ctx):
ctx.register_platform(
name="myplatform",
label="MyPlatform",
adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
check_fn=check_requirements,
required_env=["MYPLATFORM_TOKEN"],
# Auto-populate PlatformConfig.extra from env so env-only setups
# show up in `hermes gateway status` without SDK instantiation.
env_enablement_fn=_env_enablement,
# Opt in to cron delivery: `deliver=myplatform` routes to this var.
cron_deliver_env_var="MYPLATFORM_HOME_CHANNEL",
emoji="💬",
platform_hint="You are chatting via MyPlatform. Keep responses concise.",
)
# plugins/platforms/myplatform/plugin.yaml
name: myplatform-platform
label: MyPlatform
kind: platform
version: 1.0.0
description: MyPlatform gateway adapter
requires_env:
- name: MYPLATFORM_TOKEN
description: "Bot token from the MyPlatform console"
password: true
optional_env:
- name: MYPLATFORM_HOME_CHANNEL
description: "Default channel for cron delivery"
password: false
Полное руководство: Добавление адаптеров платформ — полный контракт BasePlatformAdapter, маршрутизация сообщений, ограничение по аутентификации, интеграция с мастером настройки. Смотрите plugins/platforms/irc/ как рабочий пример без сторонних зависимостей.
Плагины провайдера памяти — добавление кросс-сессионного бэкенда знаний
Положите реализацию MemoryProvider в plugins/memory/<name>/:
# plugins/memory/my-memory/__init__.py
from agent.memory_provider import MemoryProvider
class MyMemoryProvider(MemoryProvider):
@property
def name(self) -> str:
return "my-memory"
def is_available(self) -> bool:
from agent.secret_scope import get_secret
return bool(get_secret("MY_MEMORY_API_KEY"))
def initialize(self, session_id: str, **kwargs) -> None:
self._session_id = session_id
def sync_turn(self, user_content, assistant_content, *,
session_id="", messages=None) -> None:
...
def prefetch(self, query, *, session_id="") -> str:
...
def get_tool_schemas(self) -> list[dict]:
return [] # required @abstractmethod — see full guide
def register(ctx):
ctx.register_memory_provider(MyMemoryProvider())
Провайдеры памяти выбираются исключительно (single-select) — активен только один, выбранный через memory.provider в config.yaml.
Если провайдер вдобавок загружается как обычный плагин, его хуками жизненного цикла распоряжается общее обнаружение плагинов. Загрузчик памяти отдаёт хуки только на подстраховку — пока тот же источник плагина не загрузится через общее обнаружение успешно. Повторные загрузки провайдера заменяют подстраховочную группу хуков целиком, но разные колбэки внутри группы сохраняются. Хуки из разных источников плагинов это не дедуплицирует и на активацию провайдера не влияет.
Полное руководство: Плагины провайдера памяти — полный ABC MemoryProvider, контракт многопоточности, изоляция профилей, регистрация команд CLI через cli.py.
Плагины движка контекста — замена компрессора контекста
# plugins/context_engine/my-engine/__init__.py
from agent.context_engine import ContextEngine
class MyContextEngine(ContextEngine):
@property
def name(self) -> str:
return "my-engine"
def update_from_response(self, usage) -> None: ...
def should_compress(self, prompt_tokens: int = None) -> bool: ...
def compress(self, messages, current_tokens=None, focus_topic=None,
force=False, memory_context="") -> list: ...
def register(ctx):
ctx.register_context_engine(MyContextEngine())
Движки контекста выбираются исключительно (single-select) — через context.engine в config.yaml.
Полное руководство: Плагины движка контекста.
Бэкенды генерации изображений
Положите провайдера в plugins/image_gen/<name>/:
# plugins/image_gen/my-imggen/__init__.py
from agent.image_gen_provider import ImageGenProvider
class MyImageGenProvider(ImageGenProvider):
@property
def name(self) -> str:
return "my-imggen"
def is_available(self) -> bool: ...
def generate(self, prompt: str, aspect_ratio="landscape", **kwargs) -> dict:
# returns success_response(...) / error_response(...)
...
def register(ctx):
ctx.register_image_gen_provider(MyImageGenProvider())
# plugins/image_gen/my-imggen/plugin.yaml
name: my-imggen
kind: backend
version: 1.0.0
description: Custom image generation backend
Полное руководство: Плагины провайдера генерации изображений — полный ABC ImageGenProvider, метаданные list_models() / get_setup_schema(), хелперы success_response()/error_response(), вывод base64 против URL, пользовательские переопределения, распространение через pip.
Референсные примеры: plugins/image_gen/openai/ (DALL-E / GPT-Image через OpenAI SDK), plugins/image_gen/openai-codex/, plugins/image_gen/xai/ (генерация изображений Grok).
Плагины бэкенда computer-use
Инструмент computer_use работает ровно с одним драйвером через ABC ComputerUseBackend (tools/computer_use/backend.py). Выбирает его computer_use.backend в config.yaml; по умолчанию это встроенный cua-driver, который поставляется как обычный провайдер в plugins/computer_use/cua/. Другой драйвер — это плагин-провайдер в ~/.hermes/plugins/<name>/, чей register(ctx) вызывает ctx.register_computer_use_provider(). Имя каталога — значение, которое вы пишете в computer_use.backend. Провайдеры computer use выбираются по одному, как провайдеры памяти и движки контекста:
# ~/.hermes/plugins/my-driver/__init__.py
from tools.computer_use.backend import (
ActionResult, CaptureResult, ComputerUseBackend, ComputerUseProvider,
)
class MyBackend(ComputerUseBackend):
def start(self): ... # open the driver session
def stop(self): ... # tear it down (also called at exit)
def is_available(self): return True
def capture(self, mode="som", app=None, pid=None, window_id=None):
return CaptureResult(mode=mode, width=1920, height=1080, png_b64=...)
def click(self, **kw): return ActionResult(ok=True, action="click")
def drag(self, **kw): return ActionResult(ok=True, action="drag")
def scroll(self, **kw): return ActionResult(ok=True, action="scroll")
def type_text(self, text, **kw): return ActionResult(ok=True, action="type")
def key(self, keys, **kw): return ActionResult(ok=True, action="key")
def list_apps(self): return []
def focus_app(self, app, raise_window=False): return ActionResult(ok=True, action="focus_app")
def set_value(self, value, element=None):
# A driver that can't do an action says so per call; the tool schema never changes.
return ActionResult(ok=False, action="set_value", code="unsupported_action",
message="my-driver has no accessibility value setter")
class MyDriverProvider(ComputerUseProvider):
name = "my-driver"
display_name = "My driver"
def create_backend(self, *, permission_mode):
return MyBackend() # permission_mode: standard | bounded | unrestricted
def is_available(self): # gates the tool; cheap, no network
return True
def doctor(self): # optional: `hermes computer-use doctor`
print("my-driver: ok")
return 0
def register(ctx):
ctx.register_computer_use_provider(MyDriverProvider())
# ~/.hermes/plugins/my-driver/plugin.yaml
name: my-driver
version: 1.0.0
description: Alternative computer-use driver # shown in the picker
Выберите его в hermes tools → Computer Use (установленные провайдеры перечислены под cua-driver, в CLI и на панели наборов инструментов десктопа) либо задайте напрямую. Запись в plugins.enabled не нужна:
# config.yaml
computer_use:
backend: my-driver # default: cua
Правила:
- Одновременно активен один провайдер. Импортируется и создаётся только выбранный. Установленные, но не выбранные провайдеры остаются на диске и только перечисляются (по
plugin.yaml) как варианты. Провайдер никогда не активирует себя сам. - Выбор действует на профиль и читается, когда сессия запускает свой драйвер. Если настроенное имя не разрешается, вызов падает с ошибкой, в которой названы это имя и установленные провайдеры. Hermes никогда не переключается на другой драйвер.
- Схема, которую видит модель, одинакова для всех провайдеров, поэтому кэш промпта переживает смену драйвера. О неподдерживаемых действиях сообщайте по каждому действию через
ActionResult(ok=False, code="unsupported_action", ...). - Подтверждение действий, аренда Bot Desktop, дедупликация скриншотов, лимиты на элементы и маршрутизация зрения работают в инструменте, выше бэкенда. Бэкенд только управляет экраном.
hermes computer-use status/doctor/permissionsзапускают проверки cua-driver только когда выбранcua. Для любого другого провайдера они сообщают егоis_available()и запускают егоdoctor(), если он есть.- Провайдеры возвращают живые сессии драйвера, поэтому загружаются только внутри процесса. При
plugins.isolation: hostпользовательский провайдер отклоняется, а вызов сообщает, что его не удалось загрузить.
Не-Python поверхности расширения
Hermes также принимает расширения, которые вовсе не являются Python-плагинами. Они показаны в таблице Подключаемые интерфейсы; разделы ниже кратко описывают каждый стиль разработки.
MCP-серверы — регистрация внешних инструментов
Серверы Model Context Protocol (MCP) регистрируют собственные инструменты в Hermes без какого-либо Python-плагина. Объявите их в ~/.hermes/config.yaml:
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
timeout: 120
linear:
url: "https://mcp.linear.app/sse"
auth:
type: "oauth"
Hermes подключается к каждому серверу при запуске, получает список его инструментов и регистрирует их наряду со встроенными. LLM видит их точно так же, как любой другой инструмент. Полное руководство: MCP.
Хуки событий шлюза — срабатывание на события жизненного цикла
Положите манифест и обработчик в ~/.hermes/hooks/<name>/. В отличие от плагинов, шага plugins.enabled здесь нет: шлюз при запуске импортирует каждый корректный каталог хука, так что само размещение файлов и есть согласие (модель доверия):
# ~/.hermes/hooks/long-task-alert/HOOK.yaml
name: long-task-alert
description: Send a push notification when a long task finishes
events:
- agent:end
# ~/.hermes/hooks/long-task-alert/handler.py
async def handle(event_type: str, context: dict) -> None:
if context.get("duration_seconds", 0) > 120:
# send notification …
pass
Среди событий: gateway:startup, session:start, session:end, session:reset, agent:start, agent:step, agent:end и универсальное command:*. Ошибки в хуках перехватываются и логируются — они никогда не блокируют основной конвейер.
Полное руководство: Хуки событий шлюза.
Shell-хуки — запуск shell-команды при вызовах инструментов
Если вам просто нужно запускать скрипт при срабатывании инструмента (уведомления, аудит-логи, десктопные оповещения, авто-форматтеры), используйте shell-хуки в config.yaml — Python не требуется:
hooks:
- event: post_tool_call
command: "notify-send 'Tool ran: {tool_name}'"
when:
tools: [terminal, patch, write_file]
Поддерживает все те же события, что и хуки Python-плагинов (pre_tool_call, post_tool_call, pre_llm_call, post_llm_call, on_session_start, on_session_end, pre_gateway_dispatch), а также структурированный вывод JSON для решений о блокировке в pre_tool_call.
Полное руководство: Shell-хуки.
Источники навыков — добавление собственного реестра навыков
Если вы поддерживаете репозиторий навыков на GitHub (или хотите брать навыки из community-индекса помимо встроенных источников), добавьте его как tap:
hermes skills tap add myorg/skills-repo
hermes skills search my-workflow --source myorg/skills-repo
hermes skills install myorg/skills-repo/my-workflow
Публикация собственного tap — это просто репозиторий GitHub с каталогами skills/<skill-name>/SKILL.md, без сервера или регистрации в реестре.
Полные руководства: Skills Hub · Публикация собственного tap (структура репозитория, минимальный пример, нестандартные пути, уровни доверия).
TTS / STT через шаблоны команд
Любой CLI, читающий или пишущий аудио или текст, можно подключить через config.yaml — без Python-кода:
tts:
provider: voxcpm
providers:
voxcpm:
type: command
command: "voxcpm --ref ~/voice.wav --text-file {input_path} --out {output_path}"
output_format: mp3
voice_compatible: true
Для STT укажите в HERMES_LOCAL_STT_COMMAND argv-токенизированный шаблон. Он выполняется без неявной интерпретации через shell; если доверенной локальной команде нужен синтаксис shell, оборачивайте её явно в sh -c, cmd /c или PowerShell. Поддерживаемые плейсхолдеры: {input_path}, {output_path}, {format}, {voice}, {model}, {speed} (TTS); {input_path}, {output_dir}, {language}, {model} (STT). Любой CLI, работающий с путями к файлам, автоматически становится плагином.
Полные руководства: TTS: пользовательские провайдеры-команды · STT.
Распространение через pip
Чтобы распространять плагины публично, добавьте entry point в свой Python-пакет:
# pyproject.toml
[project.entry-points."hermes_agent.plugins"]
my-plugin = "my_plugin_package"
Обнаружение через entry points по-прежнему поддерживается, если дистрибутив присутствует в
окружении, которое предоставляет владелец установки (например, derivation в Nix). Это обнаружение,
а не разрешение подкладывать пакеты в поколение, выбранное PM. Для управляемых установок
распространяйте плагин-каталог с pyproject.toml или Python-требованиями в манифесте и используйте
hermes plugins install / enable, чтобы PM мог допустить его транзакционно. После выбора нового
окружения перезапустите Hermes. hermes pm install принимает имена управляемых инструментов, а не
произвольные пакеты PyPI.
Распространение для NixOS
Nix/NixOS больше не является явно поддерживаемым путём установки (только best-effort) — см. Настройка Nix. Этот раздел сохранён для пользователей, уже развёрнутых на NixOS.
Пользователи NixOS могут устанавливать ваш плагин декларативно, если вы предоставите pyproject.toml с entry points.
Плагины через entry point (рекомендуется для распространения):
# User's configuration.nix
services.hermes-agent.extraPythonPackages = [
(config.services.hermes-agent.package.python.pkgs.buildPythonPackage {
pname = "my-plugin";
version = "1.0.0";
src = pkgs.fetchFromGitHub {
owner = "you";
repo = "hermes-my-plugin";
rev = "v1.0.0";
hash = "sha256-..."; # nix-prefetch-url --unpack
};
format = "pyproject";
build-system = [ config.services.hermes-agent.package.python.pkgs.setuptools ];
})
];
Плагины-каталоги (без pyproject.toml):
services.hermes-agent.extraPlugins = [
(pkgs.fetchFromGitHub {
owner = "you";
repo = "hermes-my-plugin";
rev = "v1.0.0";
hash = "sha256-...";
})
];
Полную документацию, включая использование оверлеев и проверку конфликтов, см. в руководстве по настройке Nix.
Частые ошибки
Обработчик не возвращает строку JSON:
# Wrong — returns a dict
def handler(args, **kwargs):
return {"result": 42}
# Right — returns a JSON string
def handler(args, **kwargs):
return json.dumps({"result": 42})
Отсутствует **kwargs в сигнатуре обработчика:
# Works — the dispatcher only forwards the context keywords a signature names
def handler(args):
...
# Better — receives every injected context field (task_id, session_id, parent_agent, ...)
def handler(args, **kwargs):
...
Обработчик бросает исключения:
# Wrong — exception propagates, tool call fails
def handler(args, **kwargs):
result = 1 / int(args["value"]) # ZeroDivisionError!
return json.dumps({"result": result})
# Right — catch and return error JSON
def handler(args, **kwargs):
try:
result = 1 / int(args.get("value", 0))
return json.dumps({"result": result})
except Exception as e:
return json.dumps({"error": str(e)})
Описание схемы слишком расплывчатое:
# Bad — model doesn't know when to use it
"description": "Does stuff"
# Good — model knows exactly when and how
"description": "Evaluate a mathematical expression. Use for arithmetic, trig, logarithms. Supports: +, -, *, /, **, sqrt, sin, cos, log, pi, e."