Files
gitmost/docs/backlog/ai-chat-tool-definitions-duplicated.md
vvzvlad 5d8860e47b docs(backlog): clean up backlog documentation
Remove outdated process sections from several backlog markdown files and add new backlog items for AI chat step limits, endpoint status config, and API key field UI improvements.
2026-06-18 20:02:01 +03:00

6.3 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-баг.

Фикс

Единый реестр спеков (полное устранение дублирования).** Вынести в 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 — общая зависимость обоих пакетов, типы переносятся.