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

Модули и SDK

Модуль — отдельный процесс, расширяющий саму Еву. Он даёт ей новые руки (инструменты, которые ядро добавляет в свой реестр наравне со встроенными), может стоять на пути её действий (middleware) и может приводить целую поверхность — мессенджер, почту, голос, — ведя туры через API ядра.

Модуль — часть Евы, живущая отдельным процессом. Это не то же самое, что MCP-сервер: тот — гость, чужой инструментарий, подключённый снаружи. Разница видна во всём:

модульMCP-сервер
чейнаш, часть Евычужой, сторонний
имена инструментовtelegram_reactmcp_rzd_search
группасвоя (telegram)mcp + mcp_<сервер>
знает о туреда, получает контекстнет
может перехватывать действияданет
может вести турыданет
объявляется вmodules.serversmcp.servers

Сторонние серверы описаны отдельно — MCP-серверы. Всё ниже — про модули.

Транспорт у них общий (line-JSON-RPC), и это деталь реализации, а не общность природы: ядро отличает модуль от гостя по тому, как он объявлен, и обходится с ними по-разному.

Из чего собирается модуль

Каркас — фреймворк eva, тот же, на котором стоит ядро. Он даёт конфиг-секции с наложением файлов (секреты живут отдельно от остального), логи и супервизор компонентов с корректным завершением. Писать под модуль свой разбор YAML, свою инициализацию логов и свою склейку вечных задач не нужно — и не надо: это ровно те грабли, на которых мост однажды оказался немым в journal.

Предметная часть — eva-sdk: инструменты, стадии и клиент API ядра. Rust-версия лежит в sdk/ этого репозитория, Python — eva/sdk-py.

Модуль, таким образом, выглядит так: App фреймворка читает секции, Supervisor держит компоненты, а внутри компонентов живут Module и Kernel из SDK. Пример целиком — eva/telegram-bridge: два компонента (telegram — поверхность, tools — сокет с инструментами), он же ведёт наш телеграм.

Две стороны модуля

Внутрь ядра — модуль объявляет инструменты и стадии; ядро зовёт их, передавая контекст тура.

Наружу из ядра — модуль ходит в HTTP API: заводит чаты, ведёт туры и читает их поток, дёргает модель одноразовым вызовом.

Инструментальному модулю (погода, домашняя автоматика) хватает первой стороны. Поверхности нужны обе: она ловит входящее у себя, ведёт тур в ядре, доставляет ответ обратно и по дороге обслуживает свои инструменты.

Имена и группы

Модуль объявляет короткие имена — react, skip, send_photo. Полное имя даёт ядро: <модуль>_<инструмент>, то есть telegram_react. Оно же кладёт инструмент в группу с именем модуля.

Группа работает как у встроенных: ею целиком гейтят доступ (telegram в master-списке поверхности) и переключают в её меню настроек. Для модели, промптов и настроек инструмент модуля неотличим от родного — и это намеренно.

Вход инструмента

Аргументы объявляются типом — тем же #[data], которым пишутся конфиги ядра. Из типа рождается JSON Schema для модели (доки полей становятся описаниями, Option — необязательным полем), он же приезжает в обработчик разобранным:

#![allow(unused)]
fn main() {
#[data]
struct React {
    /// Эмодзи из набора реакций телеграма.
    emoji: String,
    /// Другое сообщение; без него — то, на которое отвечаешь.
    message_id: Option<i64>,
}
}

Схема и разбор идут от одного типа, так что разъехаться не могут: то, что обещано модели, — ровно то, что инструмент прочтёт. Вход не по схеме — ошибка инструмента, модель её читает и зовёт заново. Инструменту без аргументов есть NoInput.

В Python то же самое: вход — датакласс, тип берётся из аннотации второго параметра обработчика, схему из него рождает adaptix.

Контекст вызова

Инструмент модуля зовут вне его собственного запроса, поэтому вместе с вызовом приезжает контекст тура — без него поверхность не знает, чей ответ гасит skip и к какому сообщению цеплять картинку:

{
  "chat_id": "3095944a-…",
  "sender": "p7",
  "master": true,
  "trusted": false,
  "privileged": true,
  "client_ops": false,
  "anchor": { "chat_id": -1001234567890, "message_id": 4471 }
}

client_ops говорит, что тур ведёт клиент с руками (app/tui): пока он открыт, модулю достижима машина клиента — см. Kernel::ops ниже.

anchor появляется, только если тур привязан к внешней поверхности, и проставляет её тот же модуль при запуске тура.

Что модуль просит у ядра

ручказачем
POST /v1/chatsзавести чат под внешний диалог (дальше маппинг хранит модуль)
POST /v1/chats/{id}/messagesтур; ответ — поток событий (text_delta, tool_call, done)
GET /v1/chats/{id}/messagesхвост истории: достать цитату, уехавшую из окна
POST /v1/chats/{id}/cancelкнопка «отмена» на своей стороне
POST /v1/completeодноразовый вызов модели без тура и истории — триаж, служебные строчки; cache: true кэширует повторяющуюся system-шапку, schema (JSON Schema) просит ответ объектом этой формы — текст ответа тогда и есть этот JSON, но разбирать его всё равно стоит настороженно: апстрим без поддержки схему молча игнорирует
POST /v1/stats/triageитог одной пачки триажа: engaged — сколько сообщений судья вовлёк, responded — на сколько из них ответ действительно ушёл. Разрыв копится в /v1/stats как triage.misses: растёт — судья будит модуль впустую
GET /v1/toolsреестр инструментов: имена, описания и схемы (ровно те, что уезжают модели), группы, иконки, гейты достижимости (trusted_only, client_only) и on_demand — для меню
GET /v1/models, PUT /v1/chats/{id}/modelвыбор модели чата
POST /v1/persons/resolveвнешний id → личность ядра
GET /v1/configснимок настроек ядра (секреты затёрты) — следовать им, а не дублировать в своём конфиге
POST /v1/ops/read_bytes, POST /v1/chats/{id}/ops/read_bytesкусок файла с машины ядра или клиента; в SDK — Kernel::ops(Side::…) с read_bytes/read_all. Только чтение; клиентская сторона живёт, пока в чате открыт тур client_ops-клиента, и клиент вправе спросить Господина

Поля запроса тура, нужные именно поверхности: anchor, chat_title, reply_to_ext_id/reply_to_text, guest, max_tokens, brief, surface_model, effort, surface_effort, tools.

Блокнот чата (GET /v1/chats/{id}/notes, метод Kernel::notes) поверхности нужен там, где решение принимается мимо тура: в туре блокнот кладёт в промпт само ядро, а одноразовый вызов модели истории не видит вовсе. Слова, которыми такой промпт объявляет блок, живут в SDK — модуль notes: Kernel::notes_prompt отдаёт рамку, notes::notebook собирает готовый блок из закреплённых заметок. Своя копия формулировки в каждой поверхности расходится с остальными на первой же правке, и молча.

Аутентификации у ядра нет: модуль на той же машине ходит на 127.0.0.1, модуль снаружи — через тот же reverse-proxy, что и клиенты.

Что чьё

Граница простая: ядро отвечает за Еву, модуль — за свою предметную область.

  • в ядре: личности, память, история чата, туры, реестр инструментов, политика доступа к опасным инструментам;
  • в модуле: всё, что знает только он, — токены поверхности, свой whitelist, дебаунсы, потолки и краткость, кэш вложений, привязка «внешний чат → чат ядра». Модуль держит свою базу и в базу ядра не лезет.

Правило рабочее, а не эстетическое: модуль перезапускается и обновляется отдельно от ядра, а общая база сделала бы их одним целым.

Живучесть

  • Модуль объявляется в modules.servers (явная запись — одобрение по факту) или кладёт сокет в modules.socket_dir: тогда он виден в module_list и включается инструментом module_approve, а решение живёт в базе ядра;
  • уход и возврат сокета ядро замечает на лету: перезапуск модуля не требует перезапуска ядра;
  • пока модуль лежит, его инструменты остаются в реестре из кэша — модель видит их и получает внятную ошибку вызова, а не пустоту.

Скелет

use eva::{data, cli::App, component_configs::ComponentConfigs, supervisor::Supervisor};
use eva_sdk::{Kernel, KernelConfig, Module, ToolCtx, ToolsConfig};

#[data]
struct Forecast {
    /// Город, для которого нужен прогноз.
    city: String,
}

#[data]
struct Config {
    /// Как достучаться до ядра: url, токен прокси, сколько ждать на старте.
    /// Структура общая для всех модулей — своих полей заводить не нужно.
    kernel: KernelConfig,
}

async fn entrypoint(_args: CliArgs, configs: ComponentConfigs) -> eyre::Result<()> {
    let cfg = configs.get::<Config>();
    // подключиться и дождаться, пока ядро ответит, — одной строкой.
    // Модуль стартует вместе с ядром, а на передеплое и раньше
    let kernel = Kernel::up(&cfg.kernel).await?;

    // сторона «внутрь»: инструменты, которые ядро добавит в реестр как
    // weather_forecast в группе weather
    let module = Module::new("weather")
        // чем модуль представляется Еве, когда набор инструментов свернул
        // его в строку: по этой фразе она решает, догружать ли семейство
        .about("Weather forecasts by city")
        .tool(
            "forecast",
            "Прогноз на завтра в указанном городе",
            |_ctx: ToolCtx, input: Forecast| async move { Ok(format!("в {} дождь", input.city)) },
        );

    Supervisor::new(configs)
        // сокет, корректная остановка и одноразовость — внутри component()
        .add::<ToolsConfig>(module.component())
        .wait_for_completion()
        .await
}

fn main() -> eyre::Result<()> {
    App::default()
        .env_prefix("WEATHER_")
        .require::<Config>("weather")
        // секция сокета живёт в SDK — своей заводить не нужно
        .optional::<ToolsConfig>("tools")
        .run(entrypoint)
}

Поверхности добавляют второй компонент — свой цикл: поймала входящее, kernel.turn(chat_id, text).anchor(…).send(), прочитала поток, доставила ответ. Каждый компонент — своя секция конфига и свой лог-скоуп.

Поверхность добавляет к этому свой цикл: поймала входящее — kernel.turn(chat_id, text).anchor(…).send(), прочитала поток, доставила ответ.