Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Хуки: middleware и события

Два разных механизма, и путать их дорого:

middlewareon-события
когдаДО действияПОСЛЕ факта
ядро ждёт?да, синхроннонет
может отменить?данет
цена ошибкитормозит действиеничего
чем платимзадержкой каждого действияничем

Правило выбора простое: нужно помешать — middleware; нужно узнать — событие. Логировать через middleware так же неверно, как пытаться запретить что-то из обработчика события.

Статус: сторона модуля есть в обоих SDK (Module::stage, @module.stage). Сторона ядра — разбор цепочки, журнал и on-подписки — в работе; исходный дизайн — ~/dev/eva/todo/hooks-middleware.md, контракт исполнителя добит по мотивам хуков Codex и зафиксирован ниже.

Middleware: перехват с правом вето

Стадия — точка, где ядро само собирается что-то сделать и согласно подождать чужого решения.

стадиячто перехватываетчто можно
turn.startначало тураотменить тур, подправить мету и промпт
tool.callвызов инструментазапретить (модель получит отказ), подменить вход
tool.resultрезультат инструмента до показа моделискрыть или заменить результат — исполнение уже случилось
message.outисходящее сообщениеотменить или переписать
memory.saveзапись в памятьвето или правка записи
triage.decisionвердикт триажапереопределить

У tool.result вето действует на результат, а не на исполнение: запрещать действие поздно, оно случилось. cancel прячет выдачу от модели (с причиной), некасающееся замечание — это patch, который сохраняет исходный вывод и заворачивает его в обёртку с текстом обработчика.

Обработчик получает {stage, payload} и отвечает одним из трёх:

  • continue — пропустить как есть;
  • cancel(reason) — запретить; причину увидят и модель, и журнал;
  • patch(payload) — пропустить, подменив нагрузку.
#![allow(unused)]
fn main() {
use eva_sdk::{Decision, Module, payload, stage};

Module::new("guard")
    .stage(stage::ToolCall, |_ctx, call: payload::ToolCall| async move {
        if call.name == "shell"
            && call.input["command"].as_str().is_some_and(|c| c.contains("rm -rf"))
        {
            return Decision::Cancel("такое только руками".into());
        }
        Decision::Continue
    })
    .serve_uds("/run/eva-mcp/guard.sock")
    .await
}

В Rust-SDK стадия типизирована: маркер stage::ToolCall приносит тип нагрузки (payload::ToolCall), и Decision::Patch принимает её же — конверт {stage, payload} остаётся деталью провода. Поля, которых версия SDK ещё не знает, переживают round-trip через rest, а не теряются в патче. Первым аргументом обработчик получает StageCtx: контекст тура и клиент API ядра — ядро называет свой адрес в handshake (_meta["dev.eva/api"] запроса initialize), так что перехватчик может вести туры и completion, не таская адрес ядра в своём конфиге.

from eva_sdk import Cancel, Continue, Module, Stage

module = Module("guard")

@module.stage(Stage.TOOL_CALL)
async def no_rm(stage: str, payload: dict):
    command = payload.get("input", {}).get("command", "")
    if payload.get("name") == "shell" and "rm -rf" in command:
        return Cancel("такое только руками")
    return Continue()

Fail-open — намеренно, fail-closed — по объявлению. Молчание, падение обработчика и таймаут трактуются как continue: сломанный перехватчик тормозит своё действие, а не всю Еву. Но гвард, охраняющий необратимое (вето на мутирующий tool.call, чистка секретов на memory.save), с fail-open перестаёт охранять ровно в момент собственной смерти — молча. Поэтому обработчик может объявить себя fail_closed на конкретной стадии: его падение и таймаут читаются как cancel с причиной в журнале. Дефолт остаётся fail-open; fail-closed — осознанный выбор автора гварда, и цена его названа: умерший обработчик останавливает охраняемое действие.

Стадии объявляются в handshake (_meta["dev.eva/middleware"]), так что ядро зовёт только тех, кто их объявил, и ничего не платит за остальных.

Контракт исполнителя (зафиксировано 08.08.2026)

Решения на сторону ядра — до кода, чтобы код спорил с ними, а не наоборот. Форма подсмотрена у хуков Codex, но взято только то, что ложится на наш дизайн:

  1. Конверт остаётся нашим. middleware/handle {stage, payload}{continue | cancel(reason) | patch(payload)}. Отдельных полей «подави вывод» и «скажи модели вот это» не заводим: и то и другое — patch результата.
  2. Правка входа — по стабильной «хук-форме». На tool.call обработчик видит и правит не сырые внутренности вызова, а объявленную инструментом стабильную форму аргументов; инструмент умеет собрать вызов из неё обратно. Рефакторинг инструмента не ломает чужие хуки.
  3. Один запуск на вызов, не на алиас. Хук с матчером на несколько имён (write_file|apply_patch) срабатывает один раз, в payload идёт каноническое имя инструмента.
  4. Цепочка последовательна. Порядок — ссылки before/after, патчи композируются в порядке цепочки, журнал пишет реакции в нём же. Codex гоняет хуки параллельно и разрешает конфликт правок порядком завершения («последний писатель побеждает») — нам это не подходит: порядок цепочки и есть договорённость, случайности гонки в ней не место.
  5. Доверие — по хэшу нормализованной личности. Хэшируется не текст объявления, а нормализованное описание обработчика: один и тот же хук, объявленный двумя способами, — одна личность. Состояния: Managed | Trusted | Modified | Untrusted; правка доверенного переводит его в Modified, и он не исполняется до повторного одобрения через очередь одобрений — та же механика, что у module_approve.
  6. Слив выхлопа. Вывод обработчика сверх бюджета пишется целиком во временный файл, в контекст едет голова с хвостом и путь: болтливый хук не раздувает контекст, и ничего не потеряно — можно дочитать.
  7. Остановка не виснет на хуках. Таймаут обработчиков завершения зажимается с предупреждением в журнале: хук выхода не может повесить выключение ядра.

Порядок и цепочка

Обработчиков может быть много, и порядок задаётся ссылками, а не временем добавления: у записи есть before и after. Модель переставляет перехватчики, переписывая ссылки, а не пересчитывая номера.

Заметили цикл или битую ссылку — цепочка отключается целиком, события идут мимо неё, а ядро заводит чат в папке проблем и просит починить. Правка ссылок требует подтверждения Господина: молча переписать порядок собственных ограничений Ева не может.

Кто бывает обработчиком

  1. Процессный модуль — обычный модуль на SDK, объявивший стадии.
  2. Запись в базе — код, который Ева пишет себе сама; их исполняет раннер: тонкий процесс на том же SDK, который забирает включённые записи, собирает цепочку и отвечает на middleware/handle. Спавнить процесс на каждый вызов нельзя — это сотня миллисекунд на каждый tool.call.
  3. Сама модель — стадия может звать hook-тур с промптом и ровно тремя инструментами: hook_continue, hook_cancel, hook_patch. Такой тур идёт с полностью выключенным middleware (ни чужим, ни своим) и жёстким таймаутом. Дорого — только точечно, на конкретную стадию с фильтром.

События: узнать после факта

События сообщают, что уже случилось. Ядро их не ждёт, отменить ими ничего нельзя, зато и стоят они ровно ничего.

turn.completed / turn.failed, cron.fired / cron.failed, message.silent, chat.created / chat.compacted / chat.reaped, memory.saved / memory.forgotten, module.attached / module.detached, mcp.server_down / mcp.server_up, broken.set / broken.cleared, spend.day, person.created, skill.*, плюс собственные custom.<имя> из инструмента event_emit и от модулей.

Подписчиков два сорта: код (читает поток событий) и LLM-триггер — событие запускает тур с заданным промптом. Второе и делает Еву инициативной: «на spend.day посчитай, куда ушли деньги, и скажи, если дорого».

Журнал

У обоих механизмов общий журнал: точка, фаза, кто перехватил или подписался, решение и что из этого вышло. Без него отладка невозможна — перехватчик, тихо отменяющий действия, выглядит как «Ева сломалась».