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

96 lines
7.8 KiB
Markdown

# Дублирование определений инструментов: 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:159``getCollabToken`; см. план §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`).
- Не коммитить; в конце предложить сообщение коммита.