refactor(ai-chat): dedupe node-arg JSON normalization into a shared helper
First, safe step of docs/backlog/ai-chat-tool-definitions-duplicated.md: the
"node may be a JSON object OR a JSON string" quirk was hand-copied at 6 tool
sites. Extract it into a single parseNodeArg() helper per package and call it at
every site. Behavior-preserving — each site's throw message is byte-identical
(patch/insert: 'node was a string but not valid JSON'; update_page_json: 'content
was a string but not valid JSON'); no tool name/description/schema changed.
Two helper copies (packages/mcp/src/lib/parse-node-arg.ts and
apps/server/src/core/ai-chat/tools/parse-node-arg.ts) are intentional: the
ESM-only @docmost/mcp cannot be imported by the CommonJS server (it is loaded at
runtime via the Function('import()') trick), so runtime code cannot cross that
boundary by a normal import. Each copy is now the single source within its
package (6 inline copies -> 2 helpers). packages/mcp/build rebuilt in sync.
Tests: parse-node-arg.spec.ts (server, Jest) + parse-node-arg.test.mjs (mcp,
node:test) — object passthrough, valid-string parse, invalid-string throw with
the right message. Server tsc clean; mcp suite 254 pass; agent structural-edit
path verified live in-browser (agent inserted a node, persisted to the doc).
Deferred (documented for the record, since the backlog doc is removed with this
commit): the FULL transport-agnostic tool-spec registry (one name+schema+
description per tool shared by both transports) and deriving DocmostClientLike
from the real client type. Both are blocked by the current architecture, not by
effort: (1) @docmost/mcp ships no type declarations and is ESM-only, so a
type-only derivation needs declaration emission + tsconfig path wiring, and the
real client's precise return types break the in-app tool test stubs (attempted,
reverted to keep tsc green); (2) the two transports intentionally DIVERGE in tool
NAMES (snake_case x38 vs camelCase x41), membership (in-app adds getCurrentPage/
listSidebarPages, omits delete_comment/image tools) and model-facing
DESCRIPTIONS, so a unified registry would change behavior on BOTH the agent and
external MCP clients and needs its own verification pass. This is forward-looking
debt (the code is correct today), to be done incrementally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,108 +0,0 @@
|
||||
# Дублирование определений инструментов: 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-баг.
|
||||
|
||||
## Расширение: дублируется не только описания инструментов — ещё и конвертер (PM ↔ Markdown)
|
||||
|
||||
Зафиксировано при планировании встраивания git-синка (`docmost-sync` → gitmost,
|
||||
нативная in-process интеграция). Та же болезнь «несколько рукописных копий одного
|
||||
кода» теперь касается слоя конвертации ProseMirror ↔ Markdown и его lib, а не
|
||||
только метаданных инструментов.
|
||||
|
||||
- **Копия в gitmost** — `packages/mcp/src/lib/`: `markdown-converter.ts` (~885
|
||||
строк), `markdown-document.ts` (~136), `node-ops.ts`, `diff.ts`,
|
||||
`docmost-schema.ts`. Канонизатора (`canonicalize.ts`) здесь НЕТ.
|
||||
- **Копия в docmost-sync** — `packages/docmost-client/src/lib/`: тот же набор +
|
||||
`canonicalize.ts` (~11 КБ, держит идемпотентность round-trip, SPEC §11) +
|
||||
`markdown-document.ts` с режимом «тело + якоря, без тредов комментов»
|
||||
(`includeCommentThreads:false`, на ~20 строк больше).
|
||||
- **Третья копия (планируется)** — план git-синка вендорит чистую часть
|
||||
конвертера в новый `packages/git-sync` (collab-файл не нужен: запись идёт
|
||||
нативно через `openDirectConnection` + `@docmost/editor-ext`).
|
||||
|
||||
Копии уже молча разъехались (docmost-sync vs `packages/mcp`): `collaboration.ts`
|
||||
~329 изменённых строк, `node-ops.ts` ~53, `markdown-converter.ts` ~24,
|
||||
`markdown-document.ts` ~20. Отдельно: `docmost-schema.ts` в lib дублирует
|
||||
**реальную** схему сервера `@docmost/editor-ext` (её использует collab/persistence)
|
||||
— расхождение схем = риск битой конвертации нод.
|
||||
|
||||
Вывод: тот же фикс-вектор (единый источник правды), что и для инструментов, стоит
|
||||
распространить на конвертер — общий пакет конвертации, потребляемый `mcp`,
|
||||
`git-sync` и (в идеале) сервером. До конвергенции git-sync держит вендоренную
|
||||
копию валидированного конвертера с гейтом round-trip против схемы `editor-ext`
|
||||
(осознанный долг «третья копия сейчас, объединяем позже»).
|
||||
|
||||
## Фикс
|
||||
|
||||
Единый реестр спеков (полное устранение дублирования).** Вынести в
|
||||
`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` — общая зависимость
|
||||
обоих пакетов, типы переносятся.
|
||||
Reference in New Issue
Block a user