Инструменты
Гейтинг проверяется детерминированно в реестре, до исполнения — не зависит
от послушности модели. Недоступные туру инструменты в спеки провайдера не
попадают вовсе (не тратят токены и не соблазняют модель). Поверх гейтов
чат может работать набором инструментов — узким срезом под
род задачи; всё вне набора остаётся видно модели именами и назначением и
возвращается тулом tool_load. Пометки:
M — только Господин, P — только разрешённые пользователи (не гости);
без пометки — доступен всем участникам тура.
Видимость сведена в одну решётку (заимствовано у Codex): для каждого
инструмента тур получает ровно одно из состояний — Visible (спека у
модели, вызов исполняется), Reserved (строка в реестре скрытого, вызов
ждёт tool_load или набор), Hidden (диспетчеризуется, но нигде не
рекламируется — миграционный псевдоним переименованного инструмента, чтобы
не рвать реплей старых чатов) и Forbidden с причиной словами. Из неё
строятся и спеки шага, и блок Hidden tools, и отказ исполнения — три места
по построению не могут разойтись, а «почему модель не видит инструмент»
отвечается одним словом. Каталог GET /v1/tools показывает те же гейты
полями (master_only, trusted_only, privileged_only, client_only,
agent_only, lead_only, needs_agents, anchored_only, indexed_notes_only,
planning_only, unlisted, on_demand).
Внутри решётки живут два разных слоя, и путать их нельзя.
Слой прав — гейты личности (master_only, trusted_only, …), белый
список чата (тумблеры /settings в телеграме) и whitelist крон-джобы. Это
решения Господина и щит от внедрения в чужое сообщение; из недоверенного
тура их не обойти даже tool_load — базовый список остаётся его потолком.
Отказ этого слоя — Forbidden.
Слой внимания — что чат видит по умолчанию, когда прав хватает на
большее, чем стоит держать в промпте: глобальный tools.on_demand,
активный набор и курирование сна чата. Он ничего не
запрещает: скрытое им остаётся в реестре скрытого именем и назначением и
возвращается одним tool_load. Отказ этого слоя — Reserved, то есть
«не сейчас», а не «нельзя».
Коллизия имён — ошибка конфигурации, а не тихая тень: повторная
регистрация имени пропускается (первый зарегистрированный остаётся),
кричит в лог на старте и перечисляется полем collisions в
GET /v1/tools. Переподключение модуля коллизией не является — оно
сначала снимает весь свой неймспейс.
У каждого семейства — явная группа (задаётся при регистрации): memory,
person, skill, cron, chat, web, client, ask, mcp и
mcp_<сервер>; модуль приносит свою (telegram). По группе тул матчится
в списках доступа и группируется в меню настроек поверхности.
Инструмент, чей выхлоп — данные, а не текст, объявляет схему результата
(output_schema, JSON Schema): успешный ответ — один сериализованный
объект этой формы, модель ветвится по полям, а не выискивает фразу в
прозе. Wire-поля для этого у провайдеров нет — схема дописывается хвостом
описания инструмента. Первый такой — agent_list; для большинства
инструментов текст лучше, и это осознанно не обязательно.
Ошибка инструмента — двух классов (заимствовано у Codex). Обычная уезжает
модели текстом в tool_result с is_error, и тур продолжается — модель
чинится сама, на этом держится самоисправляющийся цикл. Фатальная (ядро
гасится или неконсистентно) рвёт тур: пары tool_use/tool_result
закрываются синтетикой, неисполненные соседние вызовы не исполняются,
клиент получает ошибку тура — вместо тридцати повторов вызова в сломанное.
Встроенные
Память — memory_save (M, с дедупом: похожая запись подкрепляется,
а не дублируется; ответ показывает похожие, чтобы противоречие разрешалось
на месте), memory_search (гибридный поиск, подкрепляет найденное; личное
чужому туру не отдаёт), memory_list (M; дамп с фильтрами
kind/since_days и страницами), memory_update (M; правит содержимое,
вид, важность и приватность), memory_forget (M).
Навыки — skill (загрузить тело по имени; с моделью-исполнителем —
изолированный оффлоад), skill_create / skill_edit / skill_delete
(M). У навыка есть видимость: private (по умолчанию) живёт только там,
где работает сам Господин, — в публичном чате такой навык не перечисляется в
промпте и не исполняется; surface виден везде.
Наборы — toolset (переключить активный набор; scope chat или
turn, имя null снимает), tool_load (вернуть скрытый инструмент или
семейство на текущий тур), tool_find (найти инструмент по тому, что он
должен делать, когда имени не знаешь), toolset_create / toolset_edit /
toolset_delete (M). toolset, tool_load, tool_find и skill
видны при любом наборе — иначе из узкого набора не выбраться. Подробности —
Наборы инструментов.
Крон — cron_create (M), cron_list, cron_log (журнал
исполнений), cron_update (M, правка на месте), cron_delete (M),
cron_fire (M, выстрелить сейчас: задача уходит в ближайший тик
планировщика, следующий регулярный запуск — через интервал от этого).
Джоба живёт в собственном чате на полке cron — его заводит ядро при
создании и уносит вместе с джобой; поселить таймер в чужую переписку
нельзя ни тулом, ни POST /v1/cron: оттуда он пошёл бы по общей истории с
людьми и заговорил вслух в их группе.
У create/update есть model — модель туров джобы (пишется в модель её
чата), — compaction_profile (low/med/high, в cron_update ещё и
auto: тот же профиль компакции, что у chat_compact_profile, только
прибивается чату джобы; без него автоматика даёт крон-турам low) — и
tools: whitelist имён и групп, всё вне списка джобе
недоступно. Узкий список заметно облегчает её промпт — спеки лишних тулов
не уезжают провайдеру на каждое срабатывание; пустой массив в cron_update
снимает ограничение. Whitelist сверяется с реестром на входе: незнакомое
имя — ошибка с ближайшими похожими и списком групп, а не молчаливо немая
джоба, которой нечем отправить результат.
Деньги видны на обоих уровнях: cron_log показывает цену каждого
срабатывания, cron_list — сколько джоба потратила за всю жизнь и сколько
раз стреляла. Цена запуска считается по spend.run_id, а не окном по
времени: в общем чате окно поймало бы и параллельный живой тур. Итог живёт
на самой джобе — журнал подрезается пятьюстами записями, а правка полей
идёт UPDATE-ом по id и счётчик не трогает.
Команда — agent_spawn (M, подагент на своей модели и со своим
набором; report_schema делает финальный доклад типизированным),
agent_send (M), agent_wait (M), agent_inbox (M),
agent_list (M, выхлоп — JSON по объявленной схеме), agent_stop
(M); изнутри тура подагента — agent_say и agent_done. Пятеро
follow-up’ов ждут живой команды (needs_agents): без подагента их нет ни в
списке, ни в реестре скрытого, а agent_spawn открывает семейство сам, ещё
в том же ходу. Подробности —
Агентские команды.
Личности — person_list, person_merge (M), person_rename
(M).
История и контекст (все M) — history_search (поиск по всем чатам
ядра и сырому телеграм-архиву: подстрока или регулярка; по умолчанию ищет
только речь, служебные вставки ядра — с флагом include_service), chat_clear
(стереть переписку чата начисто), chat_compact (свернуть историю не
дожидаясь порога), chat_rebuild (пересобрать саммари с нуля окнами
merge+squeeze, с живым прогрессом), chat_summary_get / chat_summary_set,
chat_compact_profile (прибить чату профиль компакции — low, med или
high, пороги крона, телеграма и лички; auto возвращает автоматику),
chat_sleep_start (курировать набор этого чата прямо
сейчас, не дожидаясь его ночной границы — тоже с живым прогрессом),
usage_stats (свой расход, доля мыслей в нём и точность триажа).
Ход — lift_turn_limit (M): снять потолок итераций до конца
текущего тура, когда задача честно требует много шагов. Разрешение
спрашивается заранее и явно (ask_questions), инструмент только применяет
решение. Тому же служат поле uncapped_iterations в запросе тура (клиент
решает за Господина) и uncapped у набора инструментов
(решает род работы) — этот путь для случая «поняли посреди тура».
think_harder (M) — из того же ряда, но про глубину, а не про длину:
поднять уровень рассуждения на оставшиеся итерации хода, когда задача
оказалась глубже, чем выглядела. Каждый следующий шаг после этого стоит
дороже, поэтому зовётся он один раз и осознанно; понизить уровень до конца
хода нельзя. Уровень тура и без него берётся из конфига
(llm.reasoning, effort записи модели), настройки чата, поля effort в
запросе или слова ultrathink в реплике Господина.
Блокнот (все M) — chat_note_save (записать или
переписать заметку целиком), chat_note_forget, chat_note_read (достать
тела пачкой; виден, только когда блокнот показан индексом),
chat_note_compact (пересобрать блокнот, не дожидаясь порога). Семейство
тихое: в лентах-мессенджерах эти шаги не рисуются (quiet).
План работы (M, тоже quiet) — turn_plan: одна
операция на вызов (init / start / done / drop / append / view),
ответ — состояние плана целиком. Виден не всегда: только рабочему туру
(клиентские операции, набор с narrate, подагент) или тому, где план уже
открыт.
Правила поверх генерации (все M) — stream_rule_list,
stream_rule_create, stream_rule_edit, stream_rule_delete. Правила —
данные: что матчится (имя вызова, regex по аргументам), в каком окружении
правило живёт и что оно тогда скажет. Список висит не в промпте, а
показывается по требованию.
Внимание и одобрения (все M) — chat_attention (пометить чат
«посмотри сюда», опционально с уведомлением), chat_delete (мягкое
удаление: чат уходит из списков, история цела и восстановима),
approval_request (спросить разрешение асинхронно — вопрос ждёт в очереди
Господина, тур не блокируется), approval_status (проверить свой запрос
или всю очередь), notify_master (постучаться по настроенным путям).
Веб — web_search (SearXNG; есть только при заданном web.searxng),
web_fetch, wikipedia, fandom, psychonautwiki, booru_search
(danbooru/e621/gelbooru, учётки из web.booru снимают анонимные лимиты).
Вики-инструменты отдают и картинки: главную — всегда, а images: N —
галерею статьи (служебные логотипы и иконки отсеиваются). Ходить за ними
в MediaWiki API через web_fetch не нужно.
Shell — shell (M): sh -c на машине ядра или на одном удалённом
хосте по ssh (shell.remote); таймаут 60 с (настраивается на вызов).
command берёт и массив: команды идут по порядку, каждая отвечает своим
блоком == $ cmd, пачка встаёт на первом провале (сделанное — блоками,
непопробованное — списком); таймаут — на команду. Это ответ модельной
привычке клеить echo ==x== между командами; с yield_ms пачка не
сочетается — сессия одна. Вывод
по умолчанию не режется — опциональный потолок задаётся shell.output_cap;
удерживается до 1 МиБ на поток головой и хвостом, выброшенная середина
называется счётом ([middle cut: N chars of M]). Вывод виден по ходу, а не
после: снимки хвоста уезжают событием tool_progress (в ленте элементов —
item_delta по элементу вызова), с троттлингом, потолком числа событий и
затиранием секретов у истока; в историю живой хвост не пишется. Долгие и
интерактивные команды не убиваются таймаутом, а живут сессией: yield_ms
у shell возвращает {session_id, output, chunk_id, wall_time_seconds}
вместо блокировки, дальше shell_write (M) дописывает ввод и/или
забирает свежий вывод (chunk_id растёт с каждой выдачей — повтор отличим
от нового; пустой опрос ждёт не меньше 5 с). Уложившаяся в выдержку команда
отвечает тем же JSON с exit_code и без session_id. Сессий не больше 16,
бездействие дольше 5 минут убивает процесс вместе с группой; отмена тура
(Esc) прибивает сессии, рождённые этим туром. Отдельных подтверждений у
сессий нет — они наследуют гейт shell: личность, не канал (из телеграма
Господин тоже может).
Кодинг — code_task (есть только при заданном llm.code_model):
одиночный оффлоад-запрос модели посильнее. Инструментов, файлов и истории у
неё нет — она видит ровно то, что вписано в task, и отвечает текстом.
Поэтому он для самодостаточных вопросов (алгоритм, разбор вставленного
куска), а работу внутри репозитория забирает agent_spawn с кодовым
набором: та же модель, но с руками. Тем, кто путает эти два пути, помогает
tools.on_demand — code_task уходит из списка по умолчанию и достаётся
по tool_load.
Рисование — image_generate (M; есть только при рисующей модели —
llm.image_model либо первая из models с image_output): одиночный
оффлоад-запрос рисующей модели — генерация по prompt и правка
существующей (source: id сохранённой картинки, last — последняя
картинка чата, нарисованная или присланная фотографией, путь к файлу —
машина Господина при клиенте на связи, иначе хост ядра, — либо
http(s)-URL; формат файла проверяется магик-байтами). Картинка ложится
в блоб-стор, модели возвращается
image <id> saved: <url> — дальше телеграм берёт её по id
(telegram_attach_photos), GUI — по URL. Расход пишется на чат. Если
модель тура сама умеет рисовать (image_output у неё), инструмент из её
списка спрятан: она рисует прямо в ответе, без второго платного вызова.
Правило рисования в промпте идёт за досягаемостью инструмента: не пускает
белый список чата — правила нет, и обещания тоже.
Клиентские (client_ops, исполняет клиент — eva-tui) — read_file,
write_file, write_diff, apply_patch, find_pattern, local_shell: событие client_op в живой
стрим, клиент постит результат в /v1/chats/{id}/ops/result. Операцию
находит по одному op_id, поэтому отвечать на неё можно через ручку
любого чата — на этом стоит работа подагентов с файлами.
Действий на проводе три — read, write, shell: write_diff и
apply_patch ядро собирает из чтения и записи, реализовывать их клиенту
нечего. Кнопки подтверждения задаёт поле decisions события, а любой
исход ожидания — ответ, таймаут, конец или отмена тура — снимается
событием request_resolved: диалог у клиента закрывается по факту, а не
по своему таймеру (контракт — в API). Тем же
проводом, но БЕЗ участия модели, ядро на входе в тур собирает файлы проекта
в блок [project] (служебное действие list_up — см. API):
все AGENTS.md от корня проекта до рабочего каталога и память проекта
.eva/MEMORY.md, с общим бюджетом и происхождением каждого куска. Правила
сборки одни на всех клиентов и живут в ядре (context.project); клиент
объявляет поддержку флагом project_docs в send_message и перестаёт
возить AGENTS.md в context сам.
Тулы тура (extra_tools в send_message) — клиент объявляет модели
свои тулы на один тур поверх реестра; исполняет их сам, тем же проводом
ops/result, что и клиентские операции: вызов приходит событием
extra_tool_call, ядро ждёт (таймаут из объявления, до 900 с), ответ —
блоками. ends_turn в объявлении делает тул вердиктом: успешный вызов
закрывает тур. Так телеграмный мост объявляет telegram_skip — вердикт
«молчу», который существует только в телеграмных турах и не мозолит глаза
модели в остальных. Контракт и типизированный SDK — в API.
read_file отдаёт окно строк с шапкой [lines A-B of N]. Окно длиннее
одного ответа обрывается по границе строки, а не вырезается серединой,
и шапка называет offset, с которого читать дальше: дочитывание точное, а
не «перечитать сначала и надеяться». Тот же приём у списка находок
find_pattern. Такой выхлоп объявляет себя оконным
(windowed_result), и гигиена контекста режет ему ХВОСТ по своему,
более щедрому потолку (context.reader_result_max_chars): общий рез
серединой верен для вердикта сборки и губителен для файла — он выбрасывает
ровно то, за чем звали, а шапка продолжает обещать диапазон, которого в
промпте уже нет. Хвостовой рез подписывается и зовёт сузить окно.
Файловые тулы берут пачку: path у read_file, read_hex и find_hex
принимает массив путей (секции == путь, отказ одного файла — строка его
секции, а не общий провал), write_file пишет несколько файлов полем
files, write_hex кладёт несколько заплат полем writes — по порядку,
с остановкой на первом отказе и честным списком того, что уже легло и что
не начиналось. Иначе модель тянется читать пачку через cat в шелле.
Пачка правок (там же) — apply_patch: формат Codex (*** Begin Patch /
Update|Add|Delete File / Move to / *** End Patch), несколько правок и
несколько файлов одним вызовом — сосед write_diff, а не замена: для
одиночной замены тот проще. Ни одного номера строки — место находится
поиском процитированных строк, строгость ослабляется в четыре шага, вплоть
до нормализации типографики: ASCII-патч ложится на файл с «ёлочками» и
длинными тире. Двухфазно: сперва весь патч читается и проверяется против
диска, и только потом пишется — неверный ханк не оставляет файлы наполовину
переписанными, а ошибка цитирует вход, и модель чинится со второй попытки.
Delete File/Move to исполняются только на рабочей машине: клиентский
провод знает лишь read/write, на машине Господина это local_shell (rm/mv).
Поиск по файлу (там же) — find_pattern: регулярное выражение и
строки, в которые оно попало, с их номерами. Поиск построчный, как у
grep, — ^ и $ держатся краёв строки, . через перевод строки не
ходит; флаги буквами (i, x, U). Файл течёт мимо потоком и не
ложится в память, а набрав max_hits, чтение гасится на месте: в
логе на сотни мегабайт попадание в начале стоит миллисекунды. Начало
пропускает сама машина (from_line), так что листать находки дёшево.
Длинная строка показывается окном вокруг вхождения — в минифицированном
коде обрезка с начала спрятала бы само совпадение. Двоичный файл —
отказ и отправка в find_hex.
Двоичные (там же) — read_hex, write_hex, find_hex: read_file
от бинаря отказывается и отправляет сюда. Клиент возит сырые байты в
base64, а дамп рисует и хекс-строку разбирает ядро — формат один на все
клиенты и под тестами. read_hex отдаёт классический дамп со смещением,
шестнадцатеричными байтами и колонкой расшифровки; шапка [bytes A-B of N]
говорит, где ты и сколько всего. Колонка по умолчанию ASCII, но для
японских игр её задают cp932 или utf-16le — тогда строки в архиве
читаются. write_hex пишет байты по смещению поверх существующих
(truncate обрубает хвост, смещение 0 плюс truncate — полная замена),
find_hex ищет образец хексом или текстом в заданной кодировке и отдаёт
смещения. Потолок дампа — 8 КБ на вызов: дамп раздувает данные всемеро,
и это уже около 12 тысяч токенов.
Вопросы кнопками — ask_questions (P): 1..3 вопроса с готовыми
вариантами одним вызовом, тур ждёт ответа внутри тул-колла. Кнопки рисует
поверхность из tool_call-события (телеграм — inline-кнопками, tui и
android — своими), ответ приходит в POST /v1/chats/{id}/questions/answer.
Вопрос адресуется полем question — позицией в массиве questions вызова;
номер стабилен, ответы на соседние вопросы его не сдвигают. Одновременные
вызовы в одном чате развязывает call_id (id из tool_call-события); без
него ответ идёт в самый ранний живой вызов. Сколько ждать —
tools.ask_ttl; по истечении вопросы растворяются, и тур продолжается без
ответа.
Телеграмные (telegram_send_to_other_chat, telegram_react,
telegram_skip и прочие) — не встроенные: их приносит
модуль поверхности, и живут они в группе telegram
наравне с остальными.
Внешние (TOML + stdin/stdout)
Файл tools.d/weather.toml:
name = "weather"
description = "Get current weather for a city"
command = ["/usr/local/bin/weather-cli"]
timeout = "30s"
[input_schema]
type = "object"
[input_schema.properties.city]
type = "string"
Вход инструмента приходит JSON’ом в stdin, stdout становится результатом,
EVA_CHAT_ID в окружении говорит, из какого чата позвали. Ненулевой код
выхода или таймаут — ошибка инструмента, модель получит её текстом.
MCP
Секция mcp.servers: stdio (ядро запускает и держит процесс) или
streamable HTTP (url + заголовки). Инструменты сервера появляются в
реестре как mcp_<сервер>_<инструмент> с группами mcp и mcp_<сервер>.
Имя приводится к алфавиту провайдеров и потолку в 128 символов; слишком
длинное не режется молча — хвостом ставится стабильный отпечаток полного
имени, так что два длинных имени с общим началом не схлопываются, а имя
не меняется между перезапусками (нестабильное имя рвало бы префикс
промпт-кэша).
Схема каждого инструмента чистится от шума ($schema, title,
examples…) и, если после этого занимает больше 5000 байт JSON, ужимается
с потерями: описания до первой фразы → словарь $defs за борт → вложенное
глубже третьего уровня в {} → ветвления anyOf/oneOf/allOf в {} —
остановка, как только влезло. Имена аргументов верхнего уровня выживают
всегда. Так один сервер с сорокакилобайтной схемой не съедает бюджет
описаний целого тура; свои инструменты сжатие обходит.
Представиться сервер может сам — _meta["dev.eva/about"] в ответе
initialize. Этой фразой его семейство показывается модели, когда активный
набор свернул его в одну строку. Поле description в конфиге сервера —
ручной override поверх сказанного сервером и единственный способ описать
тех, кто _meta не умеет (FastMCP не умеет). Промолчали оба — в строке
будут первые имена тулов.
Сервер может украсить свои тулы через _meta (конвенция dev.eva/*):
_meta["dev.eva/icon"] — эмодзи-иконка, _meta["dev.eva/brief"] — шаблон
краткого показа вызова по полям входа («{from} → {to}» рисуется как
«Череповец → Москва»). Оба уезжают клиентам в каталоге GET /v1/tools
(поля icon/brief, у встроенных тулов иконки свои) — их рисует любая
поверхность, показывающая ход тура; провайдеру LLM не отправляются. Чужие
клиенты незнакомые _meta-ключи обязаны игнорировать — конвенция ничего не
ломает.
Там же _meta["dev.eva/anchored"]: тул работает только в туре с якорем,
то есть отвечающем на конкретное сообщение поверхности. В туре без якоря
(кодовая сессия, крон) такой тул в спеки не попадает — его вызов всё равно
кончился бы ошибкой, а спека тратила бы токены и подсовывала модели
тупиковый выбор. Объявляет это модуль о себе сам, и ядру не приходится
знать, что у него за поверхность. Рядом _meta["dev.eva/master"] — тул
только для туров хозяина: так поверхность запирает своё администрирование,
не запирая разговор.
И _meta["dev.eva/ends_turn"]: успешный вызов закрывает тур — ядро не
возвращает управление модели, а сразу уходит в завершение. Для
инструментов-вердиктов вроде telegram_skip: «молчу» уже сказано,
следующая итерация могла бы только передумать или зациклиться на
повторных skip. Ошибка вызова тур не закрывает — несостоявшийся вердикт
вердиктом не является.
Последний гейт конвенции — _meta["dev.eva/unlisted"]: миграционный
псевдоним. Тул зовётся по имени, но не рекламируется ни спекой, ни
реестром скрытого — так модуль переименовывает инструмент, не ломая
реплей чатов, где модель помнит старое имя.
Доступ гейтится: access: master | allowed | everyone и trusted_only —
только из доверенного окружения. У MCP-сервера дефолт мастерский: чужой
бинарь может уметь что угодно, открывать его чужим — осознанное решение.
У модуля дефолт everyone: его ставит сам оператор, и обычно это
поверхность, чьи инструменты и есть ответ Евы в чужом туре, а что внутри
администрирование — модуль запирает поштучно.
MCP-сервер — горячезаменяемый: stdio-подключение ленивое, упавший процесс
перезапускается следующим вызовом с новым handshake, а протухшее
соединение долгоживущего модуля переподключается с одним автоповтором —
рестарт модуля невидим для модели. tools/list кэшируется в базе: лежащий
на старте ядра сервер поднимается с тулами из кэша и оживает первым же
вызовом; совсем без кэша — выключается целиком, ядро живёт дальше.
Каталог готовых серверов — MCP-серверы.
Модули
Модуль — долгоживущий соседний процесс с тем же MCP-протоколом
(line-JSON-RPC) по UDS-сокету или TCP; живёт своим systemd-юнитом, hot
reload — рестарт юнита. Регистрируется на лету, без рестарта ядра
тулами (все M): module_list (зарегистрированные, их статус и
кандидаты из modules.socket_dir), module_register (socket или tcp —
тулы появляются сразу; так Ева сама пишет себе модуль и включает его),
module_approve (подключить кандидата из socket_dir), module_unregister
(выгрузить). Решения живут в базе и переживают рестарты; ядро само
замечает уход/возврат сокета (выгрузка/переподключение со свежим
tools/list) и никогда не подключает ничего без явной команды. Статичные
модули можно объявить конфигом (modules.servers) — те управляются
конфигом и рестартом, как обычные MCP.