Files
gitmost/docs/backlog/ai-chat-tool-definitions-duplicated.md

7.8 KiB

Дублирование определений инструментов: in-app агент vs standalone MCP-пакет

Статус: зафиксировано в беклоге, код не менялся. Это forward-looking стоимость поддержки, НЕ баг — код корректен сегодня. Фиксируем, чтобы при росте набора инструментов (см. §16) долг не разъезжался молча.

Суть

Один и тот же набор инструментов поверх одного DocmostClient описан тремя независимыми рукописными слоями. Каждое добавление инструмента или правка его model-facing описания требует синхронной правки в 2–3 местах, а parity-баги (расхождение копий) приходится чинить/переоткрывать дважды.

Где дублируется (три слоя)

  1. Standalone MCP-серверpackages/mcp/src/index.ts (~38 registerTool). Для внешних MCP-клиентов (stdio/http). На каждый инструмент: zod-схема + длинное model-facing описание + тонкий execute, вызывающий DocmostClient.
  2. Встроенный AI-чатapps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts (~39 tool({...}) через ai-SDK). Своя zod-схема + своё описание + свой execute поверх ТОГО ЖЕ клиента (@docmost/mcp грузится в tools/docmost-client.loader.ts:188 через динамический import()).
  3. Ручная копия сигнатур — интерфейс DocmostClientLike в apps/server/src/core/ai-chat/tools/docmost-client.loader.ts:9 (в комментарии прямо: «Signatures here mirror that file exactly»), скопирован руками из packages/mcp/src/client.ts.

Что именно продублировано (с подтверждением по коду)

  • zod-схема + описание каждого инструмента — в слоях 1 и 2 целиком.
  • Квирк «node как объект ИЛИ JSON-строка» реализован дважды (НЕ в общем клиенте):
    • in-app: ai-chat-tools.service.ts:686 (patchNode), :745 (insertNode), :800 (updatePageJson);
    • standalone: index.ts:526 (patch_node), :578 (insert_node), :350 (update_page_json).
  • Guardrail/семантика transformPage (dryRun) описана в обоих: ai-chat-tools.service.ts:~935 и index.ts:~1006.

Почему разделение слоёв 1 и 2 само по себе оправдано

У путей разный транспорт и auth-контекст, и это правильно держать раздельно: in-app путь чеканит per-user JWT + provenance collab-токен (подписанная agent-claim, docmost-client.loader.ts:159getCollabToken; см. план §6.5), а standalone обслуживает внешних клиентов по stdio/http. Но это оправдывает два тонких адаптера (execute + auth-обвязка), а НЕ две рукописные копии МЕТАДАННЫХ (схема + описание + квирки). Метаданные можно объявить один раз и переиспользовать обоими транспортами.

Доказательство стоимости (наблюдалось при фиксе edit_page_text)

При исправлении ложного «успеха» edit_page_text (refuse форматных правок + verify-отчёт):

  • Поведение легло в общий DocmostClient → автоматически дошло до обоих агентов ОДНОЙ правкой. Это «хороший» случай — логика в едином источнике.
  • Описание инструмента пришлось править ДВАЖДЫ: в index.ts (кодером) и отдельно в ai-chat-tools.service.ts:617, где описание продолжало рекламировать «Markdown wrappers tolerated via strip-and-retry» — ровно ту формулировку, что ввела исходного агента в заблуждение. Копия молча разъехалась и какое-то время встроенный агент получал устаревшую подсказку. Это и есть материализованный parity-баг.

Варианты фикса (выбрать при реализации)

  • A. Единый реестр спеков (полное устранение дублирования). Вынести в packages/mcp один источник на инструмент: name + zod-схема + model-facing описание + общий хелпер нормализации node-строки (для patch/insert/update). И index.ts, и ai-chat-tools.service.ts импортируют спеки и добавляют только свой execute/auth. DocmostClientLike — выводить из типа реального клиента (type-only import / генерация), а не копировать руками.
    • Ограничение: @docmost/mcp — ESM-only, сервер грузит его через трюк new Function('import(specifier)') (docmost-client.loader.ts:174), потому что module:commonjs даунлевелит import() в require(). Реестр спеков (данные + zod) должен пересекать ту же ESM/CJS-границу — выполнимо тем же динамическим импортом; ai-SDK tool() и MCP registerTool() имеют разную форму, поэтому реестр экспортирует транспорт-агностичные {name, schema, description}, а каждая сторона оборачивает их сама. zod — общая зависимость обоих пакетов, типы переносятся.
  • B. Минимально — общий источник описаний + node-хелпер. Свести в один модуль только длинные model-facing описания (то, что реально разъезжается и уже дало баг) и хелпер нормализации node-строки; zod-схемы и execute оставить раздельными. Меньше риска и проще через ESM-границу (описания — просто строки), закрывает основной симптом (дрейф описаний), но не убирает дубль схем.

Рекомендация: B как дешёвый первый шаг (убирает дрейф описаний — главный наблюдавшийся вред), A — когда набор инструментов начнёт активно расти (§16) и дубль схем/квирков станет ощутимым.

Процесс

  • Реализация — режим делегирования (по умолчанию): рефакторинг через два пакета (packages/mcp + apps/server) → general-purpose кодеру, затем обязательный прогон review. Прогнать packages/mcp unit-тесты и серверные spec'и (ai-chat-tools.service.spec.ts).
  • Не коммитить; в конце предложить сообщение коммита.