sidebar_position: 6 title: "Event Hooks" description: "Run custom code at key lifecycle points — log activity, send alerts, post to webhooks" lang: ru


Перехватчики событий

У Hermes есть три системы перехватчиков, которые запускают собственный код в ключевых точках жизненного цикла:

Система Зарегистрировано через Вбегает Вариант использования
Перехватчики шлюза HOOK.yaml + handler.py в ~/.hermes/hooks/ Только шлюз Ведение журнала, оповещения, веб-перехватчики
Хуки плагинов ctx.register_hook() в плагине CLI + шлюз Инструмент перехвата, метрики, ограждения
Крючки-ракушки Блок hooks: в ~/.hermes/config.yaml, указывающий на сценарии оболочки CLI + шлюз Встраиваемые скрипты для блокировки, автоформатирования, внедрения контекста

Все три системы являются неблокирующими — ошибки в любом хуке фиксируются и протоколируются, что никогда не приводит к сбою агента.

Перехватчики событий шлюза

Перехватчики шлюза срабатывают автоматически во время работы шлюза (Telegram, Discord, Slack, WhatsApp, Teams), не блокируя основной конвейер агента.

Создание крючка

Каждый хук представляет собой каталог ~/.hermes/hooks/, содержащий два файла:

~/.hermes/hooks/
└── my-hook/
    ├── HOOK.yaml      # Declares which events to listen for
    └── handler.py     # Python handler function

КРЮК.yaml

name: my-hook
description: Log all agent activity to a file
events:
  - agent:start
  - agent:end
  - agent:step

Список events определяет, какие события запускают ваш обработчик. Вы можете подписаться на любую комбинацию событий, включая подстановочные знаки, такие как command:*.

обработчик.py

import json
from datetime import datetime
from pathlib import Path

LOG_FILE = Path.home() / ".hermes" / "hooks" / "my-hook" / "activity.log"

async def handle(event_type: str, context: dict):
    """Called for each subscribed event. Must be named 'handle'."""
    entry = {
        "timestamp": datetime.now().isoformat(),
        "event": event_type,
        **context,
    }
    with open(LOG_FILE, "a") as f:
        f.write(json.dumps(entry) + "\n")

Правила обработчика: - Должно быть имя handle - Получает event_type (строку) и context (дикт). - Может быть async def или обычный def — оба работают - Ошибки фиксируются и протоколируются, что не приводит к сбою агента.

Доступные события

Событие Когда он срабатывает Контекстные клавиши
gateway:startup Запускается процесс шлюза platforms (список названий активных платформ)
session:start Создан новый сеанс обмена сообщениями platform, user_id, session_id, session_key
session:end Сеанс завершен (до сброса) platform, user_id, session_key
session:reset Пользователь запустил /new или /reset platform, user_id, session_key
agent:start Агент начинает обработку сообщения platform, user_id, session_id, message
agent:step Каждая итерация цикла вызова инструмента platform, user_id, session_id, iteration, tool_names
agent:end Агент завершает обработку platform, user_id, session_id, message, response
command:* Любая выполненная косая черта platform, user_id, command, args

Соответствие подстановочным знакам

Обработчики, зарегистрированные для command:*, срабатывают для любого события command: (command:model, command:reset и т. д.). Отслеживайте все слэш-команды с помощью одной подписки.

Примеры

Оповещение Telegram о длинных задачах

Отправьте себе сообщение, когда агент сделает более 10 шагов:

# ~/.hermes/hooks/long-task-alert/HOOK.yaml
name: long-task-alert
description: Alert when agent is taking many steps
events:
  - agent:step
# ~/.hermes/hooks/long-task-alert/handler.py
import os
import httpx

THRESHOLD = 10
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
CHAT_ID = os.getenv("TELEGRAM_HOME_CHANNEL")

async def handle(event_type: str, context: dict):
    iteration = context.get("iteration", 0)
    if iteration == THRESHOLD and BOT_TOKEN and CHAT_ID:
        tools = ", ".join(context.get("tool_names", []))
        text = f"⚠️ Agent has been running for {iteration} steps. Last tools: {tools}"
        async with httpx.AsyncClient() as client:
            await client.post(
                f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
                json={"chat_id": CHAT_ID, "text": text},
            )

Регистратор использования команд

Отслеживайте, какие команды слэша используются:

# ~/.hermes/hooks/command-logger/HOOK.yaml
name: command-logger
description: Log slash command usage
events:
  - command:*
# ~/.hermes/hooks/command-logger/handler.py
import json
from datetime import datetime
from pathlib import Path

LOG = Path.home() / ".hermes" / "logs" / "command_usage.jsonl"

def handle(event_type: str, context: dict):
    LOG.parent.mkdir(parents=True, exist_ok=True)
    entry = {
        "ts": datetime.now().isoformat(),
        "command": context.get("command"),
        "args": context.get("args"),
        "platform": context.get("platform"),
        "user": context.get("user_id"),
    }
    with open(LOG, "a") as f:
        f.write(json.dumps(entry) + "\n")

Вебхук запуска сеанса

POST во внешнюю службу в новых сеансах:

# ~/.hermes/hooks/session-webhook/HOOK.yaml
name: session-webhook
description: Notify external service on new sessions
events:
  - session:start
  - session:reset
# ~/.hermes/hooks/session-webhook/handler.py
import httpx

WEBHOOK_URL = "https://your-service.example.com/hermes-events"

async def handle(event_type: str, context: dict):
    async with httpx.AsyncClient() as client:
        await client.post(WEBHOOK_URL, json={
            "event": event_type,
            **context,
        }, timeout=5)

Учебное пособие: BOOT.md — запуск контрольного списка при каждой загрузке шлюза

Популярный шаблон сообщества: отправьте контрольный список Markdown по адресу ~/.hermes/BOOT.md и попросите агента запускать его один раз при каждом запуске шлюза. Полезно для «при каждой загрузке проверять сбои cron в ночное время и пинговать меня в Discord, если что-то не удалось», или «подводить итоги развертывания.log за последние 24 часа и публиковать его в Slack #ops».

В этом руководстве показано, как создать его самостоятельно в виде определяемого пользователем хука. Hermes не поставляет встроенный крючок BOOT.md — вы подключаете именно то поведение, которое хотите.

Что мы строим

  1. Файл ~/.hermes/BOOT.md с инструкциями по запуску на естественном языке.
  2. Перехватчик шлюза, который срабатывает на gateway:startup, порождает одноразовый агент с разрешенной моделью/учетными данными вашего шлюза и запускает инструкции BOOT.md.
  3. Соглашение [SILENT], позволяющее агенту отказаться от отправки сообщения, когда сообщать не о чем.

Шаг 1. Напишите свой контрольный список

Создайте ~/.hermes/BOOT.md. Напишите это так, как если бы вы давали инструкции помощнику-человеку:

# Startup Checklist

1. Run `hermes cron list` and check if any scheduled jobs failed overnight.
2. If any failed, send a summary to Discord #ops using the `send_message` tool.
3. Check if `/opt/app/deploy.log` has any ERROR lines from the last 24 hours. If yes, summarize them and include in the same Discord message.
4. If nothing went wrong, reply with only `[SILENT]` so no message is sent.

Агент видит это как часть своего приглашения, поэтому все, что вы можете описать простым языком, работает — вызовы инструментов, команды оболочки, отправка сообщений, суммирование файлов.

Шаг 2: Создайте крючок

~/.hermes/hooks/boot-md/
├── HOOK.yaml
└── handler.py

~/.hermes/hooks/boot-md/HOOK.yaml

name: boot-md
description: Run ~/.hermes/BOOT.md on gateway startup
events:
  - gateway:startup

~/.hermes/hooks/boot-md/handler.py

"""Run ~/.hermes/BOOT.md on every gateway startup."""

import logging
import threading
from pathlib import Path

logger = logging.getLogger("hooks.boot-md")

BOOT_FILE = Path.home() / ".hermes" / "BOOT.md"


def _build_prompt(content: str) -> str:
    return (
        "You are running a startup boot checklist. Follow the instructions "
        "below exactly.\n\n"
        "---\n"
        f"{content}\n"
        "---\n\n"
        "Execute each instruction. Use the send_message tool to deliver any "
        "messages to platforms like Discord or Slack.\n"
        "If nothing needs attention and there is nothing to report, reply "
        "with ONLY: [SILENT]"
    )


def _run_boot_agent(content: str) -> None:
    """Spawn a one-shot agent and execute the checklist.

    Uses the gateway's resolved model and runtime credentials so this works
    against custom endpoints, aggregators, and OAuth-based providers alike.
    """
    try:
        from gateway.run import _resolve_gateway_model, _resolve_runtime_agent_kwargs
        from run_agent import AIAgent

        agent = AIAgent(
            model=_resolve_gateway_model(),
            **_resolve_runtime_agent_kwargs(),
            platform="gateway",
            quiet_mode=True,
            skip_context_files=True,
            skip_memory=True,
            max_iterations=20,
        )
        result = agent.run_conversation(_build_prompt(content))
        response = result.get("final_response", "")
        if response and "[SILENT]" not in response:
            logger.info("boot-md completed: %s", response[:200])
        else:
            logger.info("boot-md completed (nothing to report)")
    except Exception as e:
        logger.error("boot-md agent failed: %s", e)


async def handle(event_type: str, context: dict) -> None:
    if not BOOT_FILE.exists():
        return
    content = BOOT_FILE.read_text(encoding="utf-8").strip()
    if not content:
        return

    logger.info("Running BOOT.md (%d chars)", len(content))

    # Background thread so gateway startup isn't blocked on a full agent turn.
    thread = threading.Thread(
        target=_run_boot_agent,
        args=(content,),
        name="boot-md",
        daemon=True,
    )
    thread.start()

Две ключевые линии:

Без них простой AIAgent() возвращается к встроенным настройкам по умолчанию и выдает ошибку 401 для любой конечной точки, отличной от умолчанию.

Шаг 3: Проверьте это

Перезапустите шлюз:

hermes gateway restart

Смотрите логи:

hermes logs --follow --level INFO | grep boot-md

Вы должны увидеть Running BOOT.md (N chars), за которым следует либо boot-md completed: ... (сводка того, что сделал агент), либо boot-md completed (nothing to report), когда агент ответил [SILENT].

Удалите ~/.hermes/BOOT.md, чтобы отключить контрольный список — перехватчик остается загруженным, но автоматически пропускает его, когда файла нет.

Расширение шаблона

Почему это не встроенный

Более ранняя версия Hermes поставляла это как встроенный перехватчик и автоматически создавала агент с пустыми настройками по умолчанию при каждой загрузке шлюза. Это удивило пользователей с настраиваемыми конечными точками и сделало эту функцию невидимой для пользователей, которые не знали, что она работает. Сохранение его в виде документированного шаблона, созданного вами в каталоге хуков, означает, что вы точно видите, что он делает, и соглашаетесь на него, записывая файлы.

Как это работает

  1. При запуске шлюза HookRegistry.discover_and_load() сканирует ~/.hermes/hooks/.
  2. Каждый подкаталог с HOOK.yaml + handler.py загружается динамически.
  3. Обработчики регистрируются на заявленные ими события.
  4. В каждой точке жизненного цикла hooks.emit() запускает все соответствующие обработчики.
  5. Ошибки в любом обработчике фиксируются и протоколируются — сломанный хук никогда не приводит к сбою агента.

:::информация Перехватчики шлюза срабатывают только на шлюзе (Telegram, Discord, Slack, WhatsApp, Teams). CLI не загружает перехватчики шлюза. Для хуков, которые работают везде, используйте хуки плагинов.

Хуки плагинов

Плагины могут регистрировать перехватчики, которые срабатывают в сеансах как CLI, так и шлюза. Они регистрируются программно через ctx.register_hook() в функции register() вашего плагина.

def register(ctx):
    ctx.register_hook("pre_tool_call", my_tool_observer)
    ctx.register_hook("post_tool_call", my_tool_logger)
    ctx.register_hook("pre_llm_call", my_memory_callback)
    ctx.register_hook("post_llm_call", my_sync_callback)
    ctx.register_hook("on_session_start", my_init_callback)
    ctx.register_hook("on_session_end", my_cleanup_callback)

Общие правила для всех крючков:

– Обратные вызовы получают аргументы ключевых слов. Всегда принимайте **kwargs для совместимости — новые параметры могут быть добавлены в будущих версиях без нарушения работы вашего плагина. - Если обратный вызов сбой, он протоколируется и пропускается. Другие перехватчики и агент продолжают работать нормально. Некорректно работающий плагин никогда не сможет сломать агент. - Возвращаемые значения двух перехватчиков влияют на поведение: pre_tool_call может блокировать инструмент, а pre_llm_call может вводить контекст в вызов LLM. Все остальные крючки являются наблюдателями по принципу «выстрелил и забыл».

Краткая справка

Крюк Срабатывает, когда Возврат
pre_tool_call Перед выполнением любого инструмента {"action": "block", "message": str} чтобы наложить вето на вызов
post_tool_call После возврата любого инструмента игнорируется
pre_llm_call Один раз за ход, перед циклом вызова инструмента {"context": str} для добавления контекста к сообщению пользователя
post_llm_call Один раз за ход, после цикла вызова инструмента игнорируется
on_session_start Создана новая сессия (только первая очередь) игнорируется
on_session_end Сессия заканчивается игнорируется
on_session_finalize CLI/шлюз завершает активный сеанс (сброс, сохранение, статистика) игнорируется
on_session_reset Шлюз заменяет новый сеансовый ключ (например, /new, /reset) игнорируется
subagent_stop Ребенок delegate_task вышел игнорируется
pre_gateway_dispatch Шлюз получил сообщение пользователя перед аутентификацией + отправкой {"action": "skip" \| "rewrite" \| "allow", ...} для влияния на поток
pre_approval_request Опасная команда требует одобрения пользователя перед отправкой приглашения/уведомления игнорируется
post_approval_response Пользователь ответил на запрос на одобрение (или время ожидания истекло) игнорируется
transform_tool_result После возврата любого инструмента, прежде чем результат будет возвращен модели str для замены результата, None для того, чтобы оставить без изменений
transform_terminal_output Внутри инструмента terminal перед усечением/ANSI-strip/redact str, чтобы заменить необработанный вывод, None, чтобы оставить без изменений

pre_tool_call

Запускается непосредственно перед выполнением каждого инструмента — как встроенного, так и подключаемого.

Подпись обратного вызова:

def my_callback(tool_name: str, args: dict, task_id: str, **kwargs):
Параметр Тип Описание
tool_name str Имя запускаемого инструмента (например, "terminal", "web_search", "read_file")
args dict Аргументы, переданные моделью инструменту
task_id str Идентификатор сеанса/задачи. Пустая строка, если не установлена.

Срабатывает: В model_tools.py, внутри handle_function_call(), перед запуском обработчика инструмента. Срабатывает один раз за вызов инструмента — если модель вызывает 3 инструмента параллельно, это срабатывает 3 раза.

Возвращаемое значение — наложить вето на вызов:

return {"action": "block", "message": "Reason the tool call was blocked"}

Агент замыкает инструмент с помощью message, поскольку в модель возвращается ошибка. Выигрывает первая соответствующая директива блока (сначала регистрируются плагины Python, затем перехватчики оболочки). Любое другое возвращаемое значение игнорируется, поэтому существующие обратные вызовы только для наблюдателей продолжают работать без изменений.

Случаи использования: ведение журналов, журналы аудита, счетчики вызовов инструментов, блокировка опасных операций, ограничение скорости, применение политик для каждого пользователя.

Пример — журнал аудита вызовов инструментов:

import json, logging
from datetime import datetime

logger = logging.getLogger(__name__)

def audit_tool_call(tool_name, args, task_id, **kwargs):
    logger.info("TOOL_CALL session=%s tool=%s args=%s",
                task_id, tool_name, json.dumps(args)[:200])

def register(ctx):
    ctx.register_hook("pre_tool_call", audit_tool_call)

Пример — предупреждение об опасных инструментах:

DANGEROUS = {"terminal", "write_file", "patch"}

def warn_dangerous(tool_name, **kwargs):
    if tool_name in DANGEROUS:
        print(f"⚠ Executing potentially dangerous tool: {tool_name}")

def register(ctx):
    ctx.register_hook("pre_tool_call", warn_dangerous)

post_tool_call

Срабатывает сразу после возобновления выполнения каждого инструмента.

Подпись обратного вызова:

def my_callback(tool_name: str, args: dict, result: str, task_id: str,
                duration_ms: int, **kwargs):
Параметр Тип Описание
tool_name str Имя только что запущенного инструмента
args dict Аргументы, переданные моделью инструменту
result str Возвращаемое значение инструмента (всегда строка JSON)
task_id str Идентификатор сеанса/задачи. Пустая строка, если не установлена.
duration_ms int Время, которое заняла отправка инструмента, в миллисекундах (измеряется с помощью time.monotonic() около registry.dispatch()).

Срабатывает: В model_tools.py, внутри handle_function_call(), после возврата обработчика инструмента. Срабатывает один раз за вызов инструмента. Не срабатывает, если инструмент вызвал необработанное исключение (вместо этого ошибка перехватывается и возвращается в виде строки ошибки JSON, а post_tool_call срабатывает с этой строкой ошибки как result).

Возвращаемое значение: игнорируется.

Случаи использования. Регистрация результатов инструментов, сбор показателей, отслеживание показателей успеха/неуспехов инструментов, информационные панели о задержках, оповещения о бюджете для каждого инструмента, отправка уведомлений о завершении работы определенных инструментов.

Пример: отслеживание показателей использования инструмента:

from collections import Counter, defaultdict
import json

_tool_counts = Counter()
_error_counts = Counter()
_latency_ms = defaultdict(list)

def track_metrics(tool_name, result, duration_ms=0, **kwargs):
    _tool_counts[tool_name] += 1
    _latency_ms[tool_name].append(duration_ms)
    try:
        parsed = json.loads(result)
        if "error" in parsed:
            _error_counts[tool_name] += 1
    except (json.JSONDecodeError, TypeError):
        pass

def register(ctx):
    ctx.register_hook("post_tool_call", track_metrics)

pre_llm_call

Срабатывает один раз за ход, до начала цикла вызова инструмента. Это единственный хук, для которого используется возвращаемое значение — он может вставлять контекст в пользовательское сообщение текущего хода.

Подпись обратного вызова:

def my_callback(session_id: str, user_message: str, conversation_history: list,
                is_first_turn: bool, model: str, platform: str, **kwargs):
Параметр Тип Описание
session_id str Уникальный идентификатор текущей сессии
user_message str Исходное сообщение пользователя на этот ход (до применения каких-либо навыков)
conversation_history list Копия полного списка сообщений (формат OpenAI: [{"role": "user", "content": "..."}])
is_first_turn bool True, если это первый ход новой сессии, False при последующих ходах
model str Идентификатор модели (например, "anthropic/claude-sonnet-4.6")
platform str Где запущен сеанс: "cli", "telegram", "discord" и т. д.

Срабатывает: В run_agent.py, внутри run_conversation(), после сжатия контекста, но перед основным циклом while. Срабатывает один раз за вызов run_conversation() (т. е. один раз за ход пользователя), а не один раз за вызов API в цикле инструмента.

Возвращаемое значение: Если обратный вызов возвращает dict с ключом "context" или простую непустую строку, текст добавляется к сообщению пользователя текущего хода. Верните None для отсутствия инъекции.

# Inject context
return {"context": "Recalled memories:\n- User likes Python\n- Working on hermes-agent"}

# Plain string (equivalent)
return "Recalled memories:\n- User likes Python"

# No injection
return None

При внедрении контекста: Всегда сообщение пользователя, а не системное приглашение. Это сохраняет кеш подсказок — системные подсказки остаются одинаковыми на протяжении всего хода, поэтому кэшированные жетоны используются повторно. Системная подсказка — территория Гермеса (наведение модели, применение инструментов, личность, навыки). Плагины вносят контекст вместе с вводом пользователя.

Весь внедренный контекст является эфемерным — добавляется только во время вызова API. Исходное сообщение пользователя в истории разговоров никогда не изменяется, и ничего не сохраняется в базе данных сеанса.

Когда несколько плагинов возвращают контекст, их выходные данные объединяются двойными символами новой строки в порядке обнаружения плагинов (в алфавитном порядке по имени каталога).

Случаи использования: вызов памяти, внедрение контекста RAG, ограждения, пошаговая аналитика.

Пример — вызов памяти:

import httpx

MEMORY_API = "https://your-memory-api.example.com"

def recall(session_id, user_message, is_first_turn, **kwargs):
    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
        text = "Recalled context:\n" + "\n".join(f"- {m['text']}" for m in memories)
        return {"context": text}
    except Exception:
        return None

def register(ctx):
    ctx.register_hook("pre_llm_call", recall)

Пример: ограждения:

POLICY = "Never execute commands that delete files without explicit user confirmation."

def guardrails(**kwargs):
    return {"context": POLICY}

def register(ctx):
    ctx.register_hook("pre_llm_call", guardrails)

post_llm_call

Срабатывает один раз за ход, после завершения цикла вызова инструмента и получения окончательного ответа агентом. Срабатывает только при успешных поворотах — не срабатывает, если ход был прерван.

Подпись обратного вызова:

def my_callback(session_id: str, user_message: str, assistant_response: str,
                conversation_history: list, model: str, platform: str, **kwargs):
Параметр Тип Описание
session_id str Уникальный идентификатор текущей сессии
user_message str Исходное сообщение пользователя для этого хода
assistant_response str Окончательный текстовый ответ агента на этот ход
conversation_history list Копия полного списка сообщений после завершения хода
model str Идентификатор модели
platform str Где проходит сеанс

Срабатывает: В run_agent.py, внутри run_conversation(), после завершения цикла инструмента с окончательным ответом. Защищено if final_response and not interrupted — поэтому он не срабатывает, когда пользователь прерывает операцию в середине хода или когда агент достигает предела итерации, не выдавая ответа.

Возвращаемое значение: игнорируется.

Случаи использования: синхронизация данных разговоров с внешней системой памяти, вычисление показателей качества ответов, протоколирование сводок ходов, запуск последующих действий.

Пример — синхронизация с внешней памятью:

import httpx

MEMORY_API = "https://your-memory-api.example.com"

def sync_memory(session_id, user_message, assistant_response, **kwargs):
    try:
        httpx.post(f"{MEMORY_API}/store", json={
            "session_id": session_id,
            "user": user_message,
            "assistant": assistant_response,
        }, timeout=5)
    except Exception:
        pass  # best-effort

def register(ctx):
    ctx.register_hook("post_llm_call", sync_memory)

Пример: отслеживать длину ответов:

import logging
logger = logging.getLogger(__name__)

def log_response_length(session_id, assistant_response, model, **kwargs):
    logger.info("RESPONSE session=%s model=%s chars=%d",
                session_id, model, len(assistant_response or ""))

def register(ctx):
    ctx.register_hook("post_llm_call", log_response_length)

on_session_start

Срабатывает один раз при создании нового сеанса. Не срабатывает при продолжении сеанса (когда пользователь отправляет второе сообщение в существующем сеансе).

Подпись обратного вызова:

def my_callback(session_id: str, model: str, platform: str, **kwargs):
Параметр Тип Описание
session_id str Уникальный идентификатор нового сеанса
model str Идентификатор модели
platform str Где проходит сеанс

Срабатывает: В run_agent.py, внутри run_conversation(), во время первого поворота нового сеанса — особенно после построения системного приглашения, но до запуска цикла инструмента. Проверка if not conversation_history (нет предыдущих сообщений = новый сеанс).

Возвращаемое значение: игнорируется.

Примеры использования: Инициализация состояния на уровне сеанса, разогрев кешей, регистрация сеанса во внешней службе, запуск сеанса регистрации.

Пример — инициализация кэша сеанса:

_session_caches = {}

def init_session(session_id, model, platform, **kwargs):
    _session_caches[session_id] = {
        "model": model,
        "platform": platform,
        "tool_calls": 0,
        "started": __import__("datetime").datetime.now().isoformat(),
    }

def register(ctx):
    ctx.register_hook("on_session_start", init_session)

on_session_end

Срабатывает в самом конце каждого вызова run_conversation(), независимо от результата. Также срабатывает из обработчика выхода CLI, если агент находился в середине хода, когда пользователь вышел.

Подпись обратного вызова:

def my_callback(session_id: str, completed: bool, interrupted: bool,
                model: str, platform: str, **kwargs):
Параметр Тип Описание
session_id str Уникальный идентификатор сеанса
completed bool True, если агент выдал окончательный ответ, False в противном случае
interrupted bool True, если ход был прерван (пользователь отправил новое сообщение, /stop или вышел)
model str Идентификатор модели
platform str Где проходит сеанс

Пожары: В двух местах: 1. run_agent.py — в конце каждого вызова run_conversation(), после очистки. Всегда срабатывает, даже если ход ошибочный. 2. cli.py — в обработчике atexit CLI, но только если агент находился в середине хода (_agent_running=True), когда произошел выход. Это перехватывает Ctrl+C и /exit во время обработки. В данном случае completed=False и interrupted=True.

Возвращаемое значение: игнорируется.

Случаи использования: очистка буферов, закрытие соединений, сохранение состояния сеанса, регистрация продолжительности сеанса, очистка ресурсов, инициализированных в on_session_start.

Пример — промывка и очистка:

_session_caches = {}

def cleanup_session(session_id, completed, interrupted, **kwargs):
    cache = _session_caches.pop(session_id, None)
    if cache:
        # Flush accumulated data to disk or external service
        status = "completed" if completed else ("interrupted" if interrupted else "failed")
        print(f"Session {session_id} ended: {status}, {cache['tool_calls']} tool calls")

def register(ctx):
    ctx.register_hook("on_session_end", cleanup_session)

Пример — отслеживание продолжительности сеанса:

import time, logging
logger = logging.getLogger(__name__)

_start_times = {}

def on_start(session_id, **kwargs):
    _start_times[session_id] = time.time()

def on_end(session_id, completed, interrupted, **kwargs):
    start = _start_times.pop(session_id, None)
    if start:
        duration = time.time() - start
        logger.info("SESSION_DURATION session=%s seconds=%.1f completed=%s interrupted=%s",
                     session_id, duration, completed, interrupted)

def register(ctx):
    ctx.register_hook("on_session_start", on_start)
    ctx.register_hook("on_session_end", on_end)

on_session_finalize

Срабатывает, когда CLI или шлюз обрывает активный сеанс — например, когда пользователь запускает /new, сборщик мусора шлюза завершает сеанс бездействия или CLI завершает работу с активным агентом. Это последний шанс сбросить состояние, связанное с исходящим сеансом, прежде чем его личность исчезнет.

Подпись обратного вызова:

def my_callback(session_id: str | None, platform: str, **kwargs):
Параметр Тип Описание
session_id str или None Идентификатор исходящего сеанса. Может быть None, если активного сеанса не существовало.
platform str "cli" или название платформы обмена сообщениями ("telegram", "discord" и т. д.).

Срабатывает: В cli.py (при выходе /new/CLI) и gateway/run.py (когда сеанс сбрасывается или выполняется сборщик мусора). Всегда в паре с on_session_reset на стороне шлюза.

Возвращаемое значение: игнорируется.

Случаи использования. Сохранение окончательных показателей сеанса до того, как идентификатор сеанса будет удален, закрытие ресурсов каждого сеанса, создание окончательного события телеметрии, удаление операций записи в очереди.


on_session_reset

Срабатывает, когда шлюз заменяет новый сеансовый ключ для активного чата — пользователь вызвал /new, /reset, /clear или адаптер выбрал новый сеанс после окна простоя. Это позволяет плагинам реагировать на факт очистки состояния диалога, не дожидаясь следующего on_session_start.

Подпись обратного вызова:

def my_callback(session_id: str, platform: str, **kwargs):
Параметр Тип Описание
session_id str Идентификатор нового сеанса (уже изменен на новое значение).
platform str Название платформы обмена сообщениями.

Срабатывает: В gateway/run.py, сразу после выделения нового сеансового ключа, но до обработки следующего входящего сообщения. На шлюзе порядок следующий: on_session_finalize(old_id) → обмен → on_session_reset(new_id)on_session_start(new_id) на первом входящем обороте.

Возвращаемое значение: игнорируется.

Примеры использования: сброс кешей каждого сеанса с ключом session_id, создание аналитики с ротацией сеансов, заполнение сегмента нового состояния.


См. Руководство по созданию плагина для получения полного пошагового руководства, включая схемы инструментов, обработчики и расширенные шаблоны перехватчиков.


subagent_stop

Срабатывает один раз для каждого дочернего агента после завершения delegate_task. Независимо от того, делегировали ли вы одну задачу или пакет из трех, этот крючок срабатывает один раз для каждого дочернего процесса, сериализуемого в родительском потоке.

Подпись обратного вызова:

def my_callback(parent_session_id: str, child_role: str | None,
                child_summary: str | None, child_status: str,
                duration_ms: int, **kwargs):
Параметр Тип Описание
parent_session_id str Идентификатор сеанса делегирующего родительского агента
child_role str \| None Тег роли оркестратора установлен для дочернего элемента (None, если эта функция не включена)
child_summary str \| None Окончательный ответ, который ребенок возвращает родителю
child_status str "completed", "failed", "interrupted" или "error"
duration_ms int Время, потраченное настенными часами на бег ребенка, в миллисекундах

Срабатывает: В tools/delegate_tool.py, после ThreadPoolExecutor.as_completed() истощаются все дочерние фьючерсы. Запуск передается родительскому потоку, поэтому авторам хуков не нужно думать о параллельном выполнении обратного вызова.

Возвращаемое значение: игнорируется.

Случаи использования: ведение журнала действий по оркестрации, накопление дочерней длительности для выставления счетов, запись записей аудита после делегирования.

Пример: журнал действий оркестратора:

import logging
logger = logging.getLogger(__name__)

def log_subagent(parent_session_id, child_role, child_status, duration_ms, **kwargs):
    logger.info(
        "SUBAGENT parent=%s role=%s status=%s duration_ms=%d",
        parent_session_id, child_role, child_status, duration_ms,
    )

def register(ctx):
    ctx.register_hook("subagent_stop", log_subagent)

:::информация При интенсивном делегировании (например, роли оркестратора × 5 листьев × глубина вложенности) subagent_stop срабатывает много раз за ход. Обеспечьте быстрый обратный вызов; перенести дорогостоящую работу в фоновую очередь.


pre_gateway_dispatch

Срабатывает один раз для каждого входящего MessageEvent на шлюзе, после защиты внутренних событий, но перед аутентификацией/сопряжением и отправкой агента. Это точка перехвата для политик потока сообщений на уровне шлюза (окна только для прослушивания, передача вручную, маршрутизация для каждого чата и т. д.), которые не вписываются ни в один адаптер отдельной платформы.

Подпись обратного вызова:

def my_callback(event, gateway, session_store, **kwargs):
Параметр Тип Описание
event MessageEvent Нормализованное входящее сообщение (имеет .text, .source, .message_id, .internal и т. д.).
gateway GatewayRunner Активный шлюз, поэтому плагины могут вызывать gateway.adapters[platform].send(...) для ответов по побочному каналу (уведомления владельца и т. д.).
session_store SessionStore Для автоматического приема стенограмм через session_store.append_to_transcript(...).

Срабатывает: В gateway/run.py, внутри GatewayRunner._handle_message(), сразу после вычисления is_internal. Внутренние события полностью пропускают перехват (они генерируются системой (завершения фоновых процессов и т. д.) и не должны контролироваться политикой, ориентированной на пользователя).

Возвращаемое значение: None или dict. Побеждает первое признанное действие; остальные результаты плагина игнорируются. Исключения в обратных вызовах плагинов перехватываются и протоколируются; шлюз всегда не переходит к нормальной отправке в случае ошибки.

Вернуться Эффект
{"action": "skip", "reason": "..."} Отбросьте сообщение — ни ответа агента, ни процесса сопряжения, ни аутентификации. Предполагается, что плагин обработал это (например, автоматически включил в расшифровку).
{"action": "rewrite", "text": "new text"} Замените event.text, затем продолжите обычную отправку с измененным событием. Полезно для объединения буферизованных внешних сообщений в одно приглашение.
{"action": "allow"} / None Обычная отправка — запускает полную цепочку аутентификации/спаривания/агентского цикла.

Случаи использования: групповые чаты только для прослушивания (отвечают только при пометке; буферизуют окружающие сообщения в контекст); передача сообщений человеком (бесшумная обработка сообщений клиента, в то время как владелец обрабатывает чат вручную); ограничение скорости по профилям; маршрутизация на основе политик.

Пример: отключите неавторизованные DM в автоматическом режиме, не активируя код сопряжения:

def deny_unauthorized_dms(event, **kwargs):
    src = event.source
    if src.chat_type == "dm" and not _is_approved_user(src.user_id):
        return {"action": "skip", "reason": "unauthorized-dm"}
    return None

def register(ctx):
    ctx.register_hook("pre_gateway_dispatch", deny_unauthorized_dms)

Пример: переписать буфер окружающего сообщения в одно приглашение при упоминании:

_buffers = {}

def buffer_or_rewrite(event, **kwargs):
    key = (event.source.platform, event.source.chat_id)
    buf = _buffers.setdefault(key, [])
    if _bot_mentioned(event.text):
        combined = "\n".join(buf + [event.text])
        buf.clear()
        return {"action": "rewrite", "text": combined}
    buf.append(event.text)
    return {"action": "skip", "reason": "ambient-buffered"}

def register(ctx):
    ctx.register_hook("pre_gateway_dispatch", buffer_or_rewrite)

pre_approval_request

Срабатывает непосредственно перед показом пользователю запроса на одобрение — охватывает все поверхности: интерактивный интерфейс командной строки, TUI Ink, платформы шлюзов (Telegram, Discord, Slack, WhatsApp, Matrix и т. д.) и клиенты ACP (VS Code, Zed, JetBrains).

Это подходящее место для подключения настраиваемого уведомления — например, приложения в строке меню macOS, которое выводит уведомление о разрешении/запрете, или журнала аудита, в котором записывается каждый запрос на утверждение с контекстом.

Подпись обратного вызова:

def my_callback(
    command: str,
    description: str,
    pattern_key: str,
    pattern_keys: list[str],
    session_key: str,
    surface: str,
    **kwargs,
):
Параметр Тип Описание
command str Команда оболочки ожидает одобрения
description str Понятно понятные причины, по которым команда помечена (объединяется при совпадении нескольких шаблонов)
pattern_key str Первичный графический ключ, вызвавший утверждение (например, "rm_rf", "sudo")
pattern_keys list[str] Все совпавшие графические ключи
session_key str Идентификатор сеанса, полезный для определения области уведомлений для каждого чата
surface str "cli" для интерактивных подсказок CLI/TUI, "gateway" для утверждений асинхронной платформы

Возвращаемое значение: игнорируется. Крючки здесь доступны только наблюдателю; они не могут наложить вето или предварительно ответить на утверждение. Используйте pre_tool_call, чтобы заблокировать инструмент до того, как он достигнет системы утверждения.

Случаи использования: уведомления на рабочем столе, push-уведомления, ведение журнала аудита, веб-перехватчики Slack, маршрутизация эскалации, метрики.

Пример — уведомление на рабочем столе в macOS:

import subprocess

def notify_approval(command, description, session_key, **kwargs):
    title = "Hermes needs approval"
    body = f"{description}: {command[:80]}"
    subprocess.Popen([
        "osascript", "-e",
        f'display notification "{body}" with title "{title}"',
    ])

def register(ctx):
    ctx.register_hook("pre_approval_request", notify_approval)

post_approval_response

Срабатывает после того, как пользователь отвечает на запрос на одобрение (или время ожидания запроса истекает).

Подпись обратного вызова:

def my_callback(
    command: str,
    description: str,
    pattern_key: str,
    pattern_keys: list[str],
    session_key: str,
    surface: str,
    choice: str,
    **kwargs,
):

Те же кварги, что и pre_approval_request, плюс:

Параметр Тип Описание
choice str Один из "once", "session", "always", "deny" или "timeout"

Возвращаемое значение: игнорируется.

Случаи использования: закройте соответствующее уведомление на рабочем столе, запишите окончательное решение в журнал аудита, обновите показатели, настройте ограничитель скорости.

def log_decision(command, choice, session_key, **kwargs):
    logger.info("approval %s: %s for session %s", choice, command[:60], session_key)

def register(ctx):
    ctx.register_hook("post_approval_response", log_decision)

transform_tool_result

Срабатывает после возврата инструмента и до добавления результата к диалогу. Позволяет плагину перезаписать строку результата ЛЮБОГО инструмента, а не только вывод терминала, прежде чем модель увидит ее.

Подпись обратного вызова:

def my_callback(
    tool_name: str,
    arguments: dict,
    result: str,
    task_id: str | None,
    **kwargs,
) -> str | None:
Параметр Тип Описание
tool_name str Инструмент, выдавший результат (read_file, web_extract, delegate_task, …).
arguments dict Аргументы, с которыми модель вызывает инструмент.
result str Необработанная строка результата инструмента, после усечения и после ANSI-полосы.
task_id str \| None Идентификатор задачи/сеанса при работе в средах RL/тестирования.

Возвращаемое значение: str для замены результата (возвращенная строка — это то, что видит модель), None для того, чтобы оставить ее без изменений.

Случаи использования: Редактируйте персональные данные организации из выходных данных web_extract, помещайте длинные ответы инструмента JSON в сводный заголовок, добавляйте подсказки, дополненные поиском, в результаты read_file, переписывайте отчеты субагента delegate_task в схему, специфичную для проекта.

import re
SECRET = re.compile(r"sk-[A-Za-z0-9]{32,}")

def redact_secrets(tool_name, result, **kwargs):
    if SECRET.search(result):
        return SECRET.sub("[REDACTED]", result)
    return None

def register(ctx):
    ctx.register_hook("transform_tool_result", redact_secrets)

Применяется к каждому инструменту. О переписывании только для терминала см. transform_terminal_output ниже — оно уже и выполняется на более ранней стадии конвейера (до усечения, перед редактированием).


transform_terminal_output

Срабатывает внутри конвейера вывода переднего плана инструмента terminal, до усечения 50 КБ по умолчанию, удаления ANSI и секретного редактирования. Позволяет плагинам перезаписывать необработанный стандартный вывод/stderr команды оболочки до того, как его затронет какая-либо последующая обработка.

Подпись обратного вызова:

def my_callback(
    command: str,
    output: str,
    exit_code: int,
    cwd: str,
    task_id: str | None,
    **kwargs,
) -> str | None:
Параметр Тип Описание
command str Команда оболочки, выдавшая выходные данные.
output str Необработанный комбинированный поток stdout/stderr (может быть очень большим — усечение происходит после перехвата).
exit_code int Код завершения процесса.
cwd str Рабочий каталог, в котором выполнялась команда.

Возвращаемое значение: str, чтобы заменить вывод, None, чтобы оставить его без изменений.

Случаи использования: внедряйте сводные данные для команд, которые производят массивный вывод (du -ah, find, tree), помечайте выходные данные маркером, специфичным для проекта, чтобы последующие перехватчики знали, как с ними обращаться, удаляйте временной шум, который меняется между запусками и блокирует кэширование подсказок.

def summarize_find(command, output, **kwargs):
    if command.startswith("find ") and len(output) > 50_000:
        lines = output.count("\n")
        head = "\n".join(output.splitlines()[:40])
        return f"{head}\n\n[summary: {lines} paths total, showing first 40]"
    return None

def register(ctx):
    ctx.register_hook("transform_terminal_output", summarize_find)

Хорошо сочетается с transform_tool_result (который охватывает все остальные инструменты).


Крючки-ракушки

Объявите перехватчики сценариев оболочки в своем cli-config.yaml, и Hermes будет запускать их как подпроцессы всякий раз, когда срабатывает соответствующее событие перехватчика плагина — как в сеансах CLI, так и в сеансах шлюза. Разработка плагинов Python не требуется.

Используйте перехватчики оболочки, если вы хотите, чтобы однофайловый скрипт (Bash, Python, что-нибудь с шебангом) выполнял следующие действия:

Перехватчики оболочки регистрируются путем вызова agent.shell_hooks.register_from_config(cfg) как при запуске CLI (hermes_cli/main.py), так и при запуске шлюза (gateway/run.py). Они естественным образом компонуются с помощью плагинов Python — оба проходят через один и тот же диспетчер.

Краткое сравнение

Размерность Крючки-ракушки Перехватчики плагинов Перехватчики шлюза
Объявлено в Блок hooks: в ~/.hermes/config.yaml register() в плагине plugin.yaml Каталог HOOK.yaml + handler.py
Живет под ~/.hermes/agent-hooks/ (по соглашению) ~/.hermes/plugins/<name>/ ~/.hermes/hooks/<name>/
Язык Любой (Bash, Python, двоичный код Go,…) Только Python Только Python
Вбегает CLI + шлюз CLI + шлюз Только шлюз
События VALID_HOOKS (в т.ч. subagent_stop) VALID_HOOKS Жизненный цикл шлюза (gateway:startup, agent:*, command:*)
Можно заблокировать вызов инструмента Да (pre_tool_call) Да (pre_tool_call) Нет
Может внедрить контекст LLM Да (pre_llm_call) Да (pre_llm_call) Нет
Согласие Подсказка при первом использовании для каждой пары (event, command) Неявное (доверие к плагину Python) Неявное (директор доверия)
Межпроцессная изоляция Да (подпроцесс) Нет (в процессе) Нет (в процессе)

Схема конфигурации

hooks:
  <event_name>:                  # Must be in VALID_HOOKS
    - matcher: "<regex>"         # Optional; used for pre/post_tool_call only
      command: "<shell command>" # Required; runs via shlex.split, shell=False
      timeout: <seconds>         # Optional; default 60, capped at 300

hooks_auto_accept: false         # See "Consent model" below

Имена событий должны быть одним из событий перехвата плагина; опечатки приводят к ответу «Вы имели в виду X?» предупреждение и пропускаются. Неизвестные ключи внутри одной записи игнорируются; отсутствие command — это пропуск с предупреждением. timeout > 300 зажат с предупреждением.

Протокол передачи данных JSON

Каждый раз при возникновении события Hermes запускает подпроцесс для каждого совпадающего перехватчика (если позволяет сопоставитель), передает полезную нагрузку JSON на stdin и считывает stdout обратно как JSON.

stdin — полезная нагрузка, которую получает скрипт:

{
  "hook_event_name": "pre_tool_call",
  "tool_name":       "terminal",
  "tool_input":      {"command": "rm -rf /"},
  "session_id":      "sess_abc123",
  "cwd":             "/home/user/project",
  "extra":           {"task_id": "...", "tool_call_id": "..."}
}

tool_name и tool_input — это null для событий, не связанных с инструментами (pre_llm_call, subagent_stop, жизненный цикл сеанса). Дикт extra содержит все кварги, специфичные для событий (user_message, conversation_history, child_role, duration_ms, …). Несериализуемые значения преобразуются в строки, а не опускаются.

stdout — необязательный ответ:

// Block a pre_tool_call (both shapes accepted; normalised internally):
{"decision": "block", "reason":  "Forbidden: rm -rf"}   // Claude-Code style
{"action":   "block", "message": "Forbidden: rm -rf"}   // Hermes-canonical

// Inject context for pre_llm_call:
{"context": "Today is Friday, 2026-04-17"}

// Silent no-op  any empty / non-matching output is fine:

Неверный формат JSON, ненулевые коды выхода и тайм-ауты регистрируют предупреждение, но никогда не прерывают цикл агента.

Рабочие примеры

1. Автоматическое форматирование файлов Python после каждой записи.

# ~/.hermes/config.yaml
hooks:
  post_tool_call:
    - matcher: "write_file|patch"
      command: "~/.hermes/agent-hooks/auto-format.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/auto-format.sh
payload="$(cat -)"
path=$(echo "$payload" | jq -r '.tool_input.path // empty')
[[ "$path" == *.py ]] && command -v black >/dev/null && black "$path" 2>/dev/null
printf '{}\n'

Контекстное представление файла агентом не перечитывается автоматически — переформатирование влияет только на файл на диске. Последующие вызовы read_file подхватывают отформатированную версию.

2. Блокировать деструктивные команды terminal

hooks:
  pre_tool_call:
    - matcher: "terminal"
      command: "~/.hermes/agent-hooks/block-rm-rf.sh"
      timeout: 5
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/block-rm-rf.sh
payload="$(cat -)"
cmd=$(echo "$payload" | jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -qE 'rm[[:space:]]+-rf?[[:space:]]+/'; then
  printf '{"decision": "block", "reason": "blocked: rm -rf / is not permitted"}\n'
else
  printf '{}\n'
fi

3. Вводить git status в каждый ход (эквивалент Claude-Code UserPromptSubmit)

hooks:
  pre_llm_call:
    - command: "~/.hermes/agent-hooks/inject-cwd-context.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/inject-cwd-context.sh
cat - >/dev/null   # discard stdin payload
if status=$(git status --porcelain 2>/dev/null) && [[ -n "$status" ]]; then
  jq --null-input --arg s "$status" \
     '{context: ("Uncommitted changes in cwd:\n" + $s)}'
else
  printf '{}\n'
fi

Событие UserPromptSubmit в Claude Code намеренно не является отдельным событием Hermes — pre_llm_call срабатывает в том же месте и уже поддерживает внедрение контекста. Используйте его здесь.

4. Регистрируйте каждое завершение субагента.

hooks:
  subagent_stop:
    - command: "~/.hermes/agent-hooks/log-orchestration.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/log-orchestration.sh
log=~/.hermes/logs/orchestration.log
jq -c '{ts: now, parent: .session_id, extra: .extra}' < /dev/stdin >> "$log"
printf '{}\n'

Модель согласия

Каждая уникальная пара (event, command) запрашивает у пользователя подтверждение при первом ее просмотре Hermes, а затем сохраняет решение ~/.hermes/shell-hooks-allowlist.json. Последующие запуски (CLI или шлюз) пропускают запрос.

Три аварийных люка обходят интерактивную подсказку — достаточно любого:

  1. Флаг --accept-hooks в CLI (например, hermes --accept-hooks chat)
  2. Переменная среды HERMES_ACCEPT_HOOKS=1
  3. hooks_auto_accept: true в cli-config.yaml

Для запусков без TTY (шлюз, cron, CI) требуется один из этих трех — в противном случае любой вновь добавленный хук останется незарегистрированным и зарегистрирует предупреждение.

Редактированию скриптов доверяют без уведомления. Ключи белого списка указаны в точной командной строке, а не в хэше скрипта, поэтому редактирование скрипта на диске не делает согласие недействительным. hermes hooks doctor помечает отклонение времени, чтобы вы могли заметить изменения и решить, следует ли их повторно утверждать.

Интерфейс командной строки hermes hooks

Команда Что он делает
hermes hooks list Дамп настроенных перехватчиков со статусом сопоставления, тайм-аутом и согласием
hermes hooks test <event> [--for-tool X] [--payload-file F] Запустите каждый соответствующий крючок против синтетической полезной нагрузки и распечатайте проанализированный ответ
hermes hooks revoke <command> Удалить все записи белого списка, соответствующие <command> (вступает в силу при следующем перезапуске)
hermes hooks doctor Для каждого настроенного перехватчика: проверьте бит выполнения, состояние списка разрешений, отклонение времени mtime, достоверность вывода JSON и приблизительное время выполнения

Безопасность

Перехватчики оболочки запускаются с использованием ваших полных учетных данных пользователя — та же граница доверия, что и запись cron или псевдоним оболочки. Рассматривайте блок hooks: в config.yaml как привилегированную конфигурацию:

Порядок и приоритет

Как хуки плагинов Python, так и хуки оболочки проходят через один и тот же диспетчер invoke_hook(). Плагины Python регистрируются первыми (discover_and_load()), вторыми являются перехватчики оболочки (register_from_config()), поэтому решения о блоках Python pre_tool_call имеют приоритет в случае равенства. Выигрывает первый действительный блок — агрегатор возвращается, как только какой-либо обратный вызов генерирует {"action": "block", "message": str} с непустым сообщением.