Хуки: middleware и события
Два разных механизма, и путать их дорого:
| middleware | on-события | |
|---|---|---|
| когда | ДО действия | ПОСЛЕ факта |
| ядро ждёт? | да, синхронно | нет |
| может отменить? | да | нет |
| цена ошибки | тормозит действие | ничего |
| чем платим | задержкой каждого действия | ничем |
Правило выбора простое: нужно помешать — 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, но взято только то, что ложится на наш дизайн:
- Конверт остаётся нашим.
middleware/handle {stage, payload}→{continue | cancel(reason) | patch(payload)}. Отдельных полей «подави вывод» и «скажи модели вот это» не заводим: и то и другое —patchрезультата. - Правка входа — по стабильной «хук-форме». На
tool.callобработчик видит и правит не сырые внутренности вызова, а объявленную инструментом стабильную форму аргументов; инструмент умеет собрать вызов из неё обратно. Рефакторинг инструмента не ломает чужие хуки. - Один запуск на вызов, не на алиас. Хук с матчером на несколько имён
(
write_file|apply_patch) срабатывает один раз, в payload идёт каноническое имя инструмента. - Цепочка последовательна. Порядок — ссылки
before/after, патчи композируются в порядке цепочки, журнал пишет реакции в нём же. Codex гоняет хуки параллельно и разрешает конфликт правок порядком завершения («последний писатель побеждает») — нам это не подходит: порядок цепочки и есть договорённость, случайности гонки в ней не место. - Доверие — по хэшу нормализованной личности. Хэшируется не текст
объявления, а нормализованное описание обработчика: один и тот же хук,
объявленный двумя способами, — одна личность. Состояния:
Managed | Trusted | Modified | Untrusted; правка доверенного переводит его вModified, и он не исполняется до повторного одобрения через очередь одобрений — та же механика, что уmodule_approve. - Слив выхлопа. Вывод обработчика сверх бюджета пишется целиком во временный файл, в контекст едет голова с хвостом и путь: болтливый хук не раздувает контекст, и ничего не потеряно — можно дочитать.
- Остановка не виснет на хуках. Таймаут обработчиков завершения зажимается с предупреждением в журнале: хук выхода не может повесить выключение ядра.
Порядок и цепочка
Обработчиков может быть много, и порядок задаётся ссылками, а не
временем добавления: у записи есть before и after. Модель переставляет
перехватчики, переписывая ссылки, а не пересчитывая номера.
Заметили цикл или битую ссылку — цепочка отключается целиком, события идут мимо неё, а ядро заводит чат в папке проблем и просит починить. Правка ссылок требует подтверждения Господина: молча переписать порядок собственных ограничений Ева не может.
Кто бывает обработчиком
- Процессный модуль — обычный модуль на SDK, объявивший стадии.
- Запись в базе — код, который Ева пишет себе сама; их исполняет
раннер: тонкий процесс на том же SDK, который забирает включённые
записи, собирает цепочку и отвечает на
middleware/handle. Спавнить процесс на каждый вызов нельзя — это сотня миллисекунд на каждыйtool.call. - Сама модель — стадия может звать 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 посчитай, куда ушли деньги, и скажи, если
дорого».
Журнал
У обоих механизмов общий журнал: точка, фаза, кто перехватил или подписался, решение и что из этого вышло. Без него отладка невозможна — перехватчик, тихо отменяющий действия, выглядит как «Ева сломалась».