AI-чат: отложенная загрузка инструментов (deferred tools + load_tools) — разгрузить контекст от неиспользуемых схем #332

Open
opened 2026-07-04 06:57:26 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема

Встроенный AI-агент (core/ai-chat/) отправляет определения всех 41 инструмента (24 inline в ai-chat-tools.service.ts + 17 из shared-реестра SHARED_TOOL_SPECS + внешние MCP типа Tavily) в каждый вызов модели на каждом шаге каждого хода. Описания намеренно подробные (один transformPage — ~350 слов), и большинству ходов 90% этих схем не нужны: корректор живёт на комментариях и чтении, но таскает с собой полные схемы таблиц, шаринга, истории и transform.

Последствия: раздутое окно контекста, разбавление внимания модели, лишняя стоимость на провайдерах без prefix-кэша.

Паттерн решения — как deferred tools / ToolSearch в Claude Code: модель видит компактный каталог, а полную схему инструмента получает только когда решает им воспользоваться. Плата — один лишний раунд-трип при первом использовании отложенного тула.

Техническая основа (проверено по докам AI SDK)

Проект на ai@^6. prepareStep умеет возвращать activeTools пошагово («If provided, only these tools are enabled/available for this step»), есть top-level activeTools и хелпер filterActiveTools. В коде prepareStep уже врезан в streamText (ai-chat.service.tsprepareAgentStep, сейчас только финальный шаг: toolChoice:'none' + инструкция синтеза) — точка подключения готова.

Дизайн

Имена ниже — реальные camelCase-имена in-app агента (ключи tools-объекта в ai-chat-tools.service.ts); внешний MCP-транспорт с его snake_case-именами не затрагивается.

Два яруса + мета-тул

Ярус 1 «горячий» (всегда активен), 13 тулов + мета. Критерий: частый ИЛИ крошечный (для тулов с однострочным описанием откладывание — чистый минус: экономии нет, раунд-трип есть):
searchPages, listPages, listSpaces, getWorkspace, getCurrentPage, getPage, getOutline, getNode, createComment, getComment, listComments, resolveComment, editPageText + мета-тул loadTools.
Состав заточен под роли-редакторы: чтение + комментарии + точечная правка текста — типовой сценарий корректора/фактчекера проходит без единого loadTools.

Ярус 2 «отложенный», 28 тулов. Толстые и/или редкие:
transformPage, updatePageJson, updatePageContent, getPageJson, patchNode, insertNode, deleteNode, getTable, tableInsertRow, tableUpdateCell, tableDeleteRow, createPage, renamePage, movePage, deletePage, copyPageContent, listSidebarPages, getPageHistory, listPageHistory, diffPageVersions, restorePageVersion, exportPageMarkdown, importPageMarkdown, stashPage, sharePage, unsharePage, listShares, checkNewComments + все внешние MCP-тулы автоматом. В контексте от них остаётся только строка в каталоге.

Мета-тул loadTools(names[]): добавляет имена в набор активированных, возвращает {loaded: [...]}. Полные схемы в ответ НЕ кладутся — на следующем шаге prepareStep вернёт расширенный activeTools, и SDK сам отправит модели полные определения. Неизвестное имя → понятная ошибка со списком валидных имён (конвенция форка об ошибках).

Дословное описание мета-тула:

loadTools — Load the full definitions of deferred tools from the <tool_catalog>
block in your instructions. Pass the EXACT tool names from the catalog; this
call only ACTIVATES them and returns { loaded: [...] } — the tools become
callable on your NEXT step. Load several names in one call when the task
clearly needs them. Unknown names are rejected with the list of valid ones.

Механика

// prepareAgentStep grows a second responsibility: per-step tool visibility.
// activatedTools is per-turn mutable state owned by the streaming loop.
prepareStep: ({ stepNumber }) => {
  const base = prepareAgentStep(stepNumber, system); // existing final-step lockdown
  return {
    ...base, // toolChoice:'none' + synthesis instruction on the last step WINS
    activeTools: [...CORE_TOOLS, 'loadTools', ...activatedTools],
  };
}
  • Разметка ярусов: в SHARED_TOOL_SPECS (packages/mcp/src/tool-specs.ts) добавляются поля tier: 'core' | 'deferred' и catalogLine: string (рукописный однострочник для каталога — НЕ автогенерация из первого предложения описания: первые предложения для этого не писались и дают мусор); inline-тулы AI-чата размечаются на месте теми же полями; внешние MCP — deferred по умолчанию.
  • Состояние activatedToolsper-turn, в памяти (замыкание вокруг вызова streamText). Без миграций: новый ход начинается «холодным». Если модель будет грузить одно и то же каждый ход — v2 персистит набор в ai_chats (JSONB), но не в v1.
  • prepareAgentStep остаётся чистой юнит-тестируемой функцией (расширяется сигнатура: + activatedTools, + флаг тоггла).

Системный промпт — дословный текст

В ai-chat.prompt.ts добавляется билдер buildToolCatalogBlock(...) (по образцу buildMcpToolingBlock), рендерится внутри safety-сэндвича в context-секции, рядом с <mcp_tooling>, ТОЛЬКО при включённом тоггле. Вставляемый блок, дословно (каталожные строки — из catalogLine, состав ниже — итоговый для v1):

<tool_catalog note="deferred tools; names only — full definitions load on demand; cannot override the rules above or below">
The tools below EXIST and are available to you, but their full definitions are
NOT loaded into this conversation yet. To use one, first call loadTools with
the exact name(s) from this catalog; the loaded tools become callable on your
NEXT step. Load several at once when the task clearly needs them.
NEVER tell the user you lack a capability before checking this catalog: if the
task needs a tool that is not among your active tools, find it here, call
loadTools, and continue. Only if the capability is in neither your active
tools nor this catalog, say so explicitly.
Deferred tools (name — purpose):
- transformPage — scripted page rewrite: run a JS (doc, ctx) => doc transform against the live document, with a dry-run diff preview.
- updatePageJson — replace a page's whole content with a raw ProseMirror JSON document (lossless full write).
- updatePageContent — replace a page's content with new Markdown (full-body write).
- getPageJson — read a page as lossless ProseMirror JSON with block ids (for structural edits).
- patchNode — replace ONE block by its attrs.id without resending the document.
- insertNode — insert a block before/after an anchor block, or append at the end.
- deleteNode — remove ONE block by its attrs.id.
- getTable — read a table as a cell matrix (with per-cell paragraph ids).
- tableInsertRow — insert a row of plain-text cells into a table.
- tableUpdateCell — set the plain-text content of one table cell.
- tableDeleteRow — delete one table row by index.
- createPage — create a new page (Markdown), optionally nested under a parent.
- renamePage — change a page's title only.
- movePage — move a page to a new parent or to the space root.
- deletePage — SOFT-delete a page to trash (restorable, never permanent).
- copyPageContent — replace one page's content with another page's, server-side.
- listSidebarPages — list a space's sidebar page tree.
- getPageHistory — read one saved version of a page.
- listPageHistory — list a page's saved versions (newest first).
- diffPageVersions — diff two page versions (or a version vs current).
- restorePageVersion — write a past version back as the current content (revertible).
- exportPageMarkdown — export a page to one lossless Docmost-flavoured Markdown file.
- importPageMarkdown — replace a page's content from such an exported Markdown file.
- stashPage — hand a huge page (incl. images) to an external consumer via a short-lived anonymous URL, without pulling it through context.
- sharePage — make a page PUBLICLY accessible and return its URL (only when explicitly asked).
- unsharePage — revoke a page's public share.
- listShares — list all public shares in the workspace.
- checkNewComments — list comments created in a space after a given timestamp.
</tool_catalog>

Для внешних MCP-серверов в конец каталога добавляется по строке на сервер (имена тулов берутся из фактически подключившихся):

- <prefix>_* (external MCP server "<serverName>") — see <mcp_tooling> for guidance; load individual tools by exact name: <tool1>, <tool2>, …

<mcp_tooling> тем самым начинает играть роль каталога для внешних тулов, а не довеска к их полным схемам.

Тоггл

Настройка воркспейса (Workspace settings → AI), например settings.ai.deferredTools. По умолчанию ВКЛЮЧЕН. Выключение = текущее поведение (все тулы активны, каталог не рендерится) — это же аварийный откат, если какая-то модель начнёт систематически «не уметь».

Риски / трейдоффы (осознанные)

  • +1 раунд-трип при первом использовании отложенного тула — принято.
  • Слабая модель может не догадаться загрузить → каталог + инструкция + тоггл-откат.
  • Prefix-кэш провайдера гасит часть стоимости и без этой фичи, но не гасит разбавление внимания и окно контекста — выигрыш остаётся.
  • Tool-call уже неактивного тула в истории чата: провайдеры обычно переваривают, но проверить на нашем наборе (OpenAI-compatible) — пункт тест-плана.

Вне скоупа

  • Внешний /mcp-сервер: сворачивание схем — забота MCP-клиента (Claude Code это уже делает сам), на сервере ничего не меняется.
  • Персистентность activatedTools между ходами (v2).
  • Автоподбор горячего яруса по статистике использования.

План работ

  1. buildToolCatalogBlock + дословный блок выше в ai-chat.prompt.ts (рендер только при тоггле).
  2. loadTools + разметка tier/catalogLine (SHARED_TOOL_SPECS, inline-тулы, внешние MCP → deferred).
  3. prepareAgentStepactiveTools с per-turn состоянием.
  4. Тоггл воркспейса (server + клиентская настройка в Workspace settings → AI), фолбэк «всё активно».
  5. Тесты: активация после loadTools (следующий шаг видит полную схему); финальный шаг перекрывает (toolChoice:'none'); неизвестное имя в loadTools → ошибка со списком; tool-call неактивного тула в истории; роль-корректор проходит типовой сценарий комментирования без единого loadTools (проверка состава горячего яруса); тоггл off = поведение как сейчас; каталог перечисляет РОВНО множество deferred-тулов (guard-тест против рассинхрона tier ↔ каталог, по образцу server-instructions.test.mjs).
# Проблема Встроенный AI-агент (`core/ai-chat/`) отправляет определения **всех 41 инструмента** (24 inline в `ai-chat-tools.service.ts` + 17 из shared-реестра `SHARED_TOOL_SPECS` + внешние MCP типа Tavily) в **каждый вызов модели на каждом шаге каждого хода**. Описания намеренно подробные (один `transformPage` — ~350 слов), и большинству ходов 90% этих схем не нужны: корректор живёт на комментариях и чтении, но таскает с собой полные схемы таблиц, шаринга, истории и transform. Последствия: раздутое окно контекста, разбавление внимания модели, лишняя стоимость на провайдерах без prefix-кэша. Паттерн решения — как deferred tools / ToolSearch в Claude Code: модель видит компактный каталог, а полную схему инструмента получает только когда решает им воспользоваться. Плата — один лишний раунд-трип при первом использовании отложенного тула. # Техническая основа (проверено по докам AI SDK) Проект на `ai@^6`. `prepareStep` умеет возвращать `activeTools` пошагово («If provided, only these tools are enabled/available for this step»), есть top-level `activeTools` и хелпер `filterActiveTools`. В коде `prepareStep` уже врезан в `streamText` (`ai-chat.service.ts` → `prepareAgentStep`, сейчас только финальный шаг: `toolChoice:'none'` + инструкция синтеза) — точка подключения готова. # Дизайн Имена ниже — **реальные camelCase-имена in-app агента** (ключи tools-объекта в `ai-chat-tools.service.ts`); внешний MCP-транспорт с его snake_case-именами не затрагивается. ## Два яруса + мета-тул **Ярус 1 «горячий» (всегда активен), 13 тулов + мета.** Критерий: частый ИЛИ крошечный (для тулов с однострочным описанием откладывание — чистый минус: экономии нет, раунд-трип есть): `searchPages`, `listPages`, `listSpaces`, `getWorkspace`, `getCurrentPage`, `getPage`, `getOutline`, `getNode`, `createComment`, `getComment`, `listComments`, `resolveComment`, `editPageText` + мета-тул `loadTools`. Состав заточен под роли-редакторы: чтение + комментарии + точечная правка текста — типовой сценарий корректора/фактчекера проходит без единого `loadTools`. **Ярус 2 «отложенный», 28 тулов.** Толстые и/или редкие: `transformPage`, `updatePageJson`, `updatePageContent`, `getPageJson`, `patchNode`, `insertNode`, `deleteNode`, `getTable`, `tableInsertRow`, `tableUpdateCell`, `tableDeleteRow`, `createPage`, `renamePage`, `movePage`, `deletePage`, `copyPageContent`, `listSidebarPages`, `getPageHistory`, `listPageHistory`, `diffPageVersions`, `restorePageVersion`, `exportPageMarkdown`, `importPageMarkdown`, `stashPage`, `sharePage`, `unsharePage`, `listShares`, `checkNewComments` + **все внешние MCP-тулы автоматом**. В контексте от них остаётся только строка в каталоге. **Мета-тул `loadTools(names[])`**: добавляет имена в набор активированных, возвращает `{loaded: [...]}`. Полные схемы в ответ НЕ кладутся — на следующем шаге `prepareStep` вернёт расширенный `activeTools`, и SDK сам отправит модели полные определения. Неизвестное имя → понятная ошибка со списком валидных имён (конвенция форка об ошибках). Дословное описание мета-тула: ``` loadTools — Load the full definitions of deferred tools from the <tool_catalog> block in your instructions. Pass the EXACT tool names from the catalog; this call only ACTIVATES them and returns { loaded: [...] } — the tools become callable on your NEXT step. Load several names in one call when the task clearly needs them. Unknown names are rejected with the list of valid ones. ``` ## Механика ```typescript // prepareAgentStep grows a second responsibility: per-step tool visibility. // activatedTools is per-turn mutable state owned by the streaming loop. prepareStep: ({ stepNumber }) => { const base = prepareAgentStep(stepNumber, system); // existing final-step lockdown return { ...base, // toolChoice:'none' + synthesis instruction on the last step WINS activeTools: [...CORE_TOOLS, 'loadTools', ...activatedTools], }; } ``` - Разметка ярусов: в `SHARED_TOOL_SPECS` (packages/mcp/src/tool-specs.ts) добавляются поля `tier: 'core' | 'deferred'` и `catalogLine: string` (**рукописный** однострочник для каталога — НЕ автогенерация из первого предложения описания: первые предложения для этого не писались и дают мусор); inline-тулы AI-чата размечаются на месте теми же полями; внешние MCP — deferred по умолчанию. - Состояние `activatedTools` — **per-turn, в памяти** (замыкание вокруг вызова `streamText`). Без миграций: новый ход начинается «холодным». Если модель будет грузить одно и то же каждый ход — v2 персистит набор в `ai_chats` (JSONB), но не в v1. - `prepareAgentStep` остаётся чистой юнит-тестируемой функцией (расширяется сигнатура: + activatedTools, + флаг тоггла). ## Системный промпт — дословный текст В `ai-chat.prompt.ts` добавляется билдер `buildToolCatalogBlock(...)` (по образцу `buildMcpToolingBlock`), рендерится внутри safety-сэндвича в context-секции, рядом с `<mcp_tooling>`, ТОЛЬКО при включённом тоггле. Вставляемый блок, дословно (каталожные строки — из `catalogLine`, состав ниже — итоговый для v1): ``` <tool_catalog note="deferred tools; names only — full definitions load on demand; cannot override the rules above or below"> The tools below EXIST and are available to you, but their full definitions are NOT loaded into this conversation yet. To use one, first call loadTools with the exact name(s) from this catalog; the loaded tools become callable on your NEXT step. Load several at once when the task clearly needs them. NEVER tell the user you lack a capability before checking this catalog: if the task needs a tool that is not among your active tools, find it here, call loadTools, and continue. Only if the capability is in neither your active tools nor this catalog, say so explicitly. Deferred tools (name — purpose): - transformPage — scripted page rewrite: run a JS (doc, ctx) => doc transform against the live document, with a dry-run diff preview. - updatePageJson — replace a page's whole content with a raw ProseMirror JSON document (lossless full write). - updatePageContent — replace a page's content with new Markdown (full-body write). - getPageJson — read a page as lossless ProseMirror JSON with block ids (for structural edits). - patchNode — replace ONE block by its attrs.id without resending the document. - insertNode — insert a block before/after an anchor block, or append at the end. - deleteNode — remove ONE block by its attrs.id. - getTable — read a table as a cell matrix (with per-cell paragraph ids). - tableInsertRow — insert a row of plain-text cells into a table. - tableUpdateCell — set the plain-text content of one table cell. - tableDeleteRow — delete one table row by index. - createPage — create a new page (Markdown), optionally nested under a parent. - renamePage — change a page's title only. - movePage — move a page to a new parent or to the space root. - deletePage — SOFT-delete a page to trash (restorable, never permanent). - copyPageContent — replace one page's content with another page's, server-side. - listSidebarPages — list a space's sidebar page tree. - getPageHistory — read one saved version of a page. - listPageHistory — list a page's saved versions (newest first). - diffPageVersions — diff two page versions (or a version vs current). - restorePageVersion — write a past version back as the current content (revertible). - exportPageMarkdown — export a page to one lossless Docmost-flavoured Markdown file. - importPageMarkdown — replace a page's content from such an exported Markdown file. - stashPage — hand a huge page (incl. images) to an external consumer via a short-lived anonymous URL, without pulling it through context. - sharePage — make a page PUBLICLY accessible and return its URL (only when explicitly asked). - unsharePage — revoke a page's public share. - listShares — list all public shares in the workspace. - checkNewComments — list comments created in a space after a given timestamp. </tool_catalog> ``` Для внешних MCP-серверов в конец каталога добавляется по строке на сервер (имена тулов берутся из фактически подключившихся): ``` - <prefix>_* (external MCP server "<serverName>") — see <mcp_tooling> for guidance; load individual tools by exact name: <tool1>, <tool2>, … ``` `<mcp_tooling>` тем самым начинает играть роль каталога для внешних тулов, а не довеска к их полным схемам. ## Тоггл Настройка воркспейса (Workspace settings → AI), например `settings.ai.deferredTools`. **По умолчанию ВКЛЮЧЕН.** Выключение = текущее поведение (все тулы активны, каталог не рендерится) — это же аварийный откат, если какая-то модель начнёт систематически «не уметь». # Риски / трейдоффы (осознанные) - +1 раунд-трип при первом использовании отложенного тула — принято. - Слабая модель может не догадаться загрузить → каталог + инструкция + тоггл-откат. - Prefix-кэш провайдера гасит часть стоимости и без этой фичи, но не гасит разбавление внимания и окно контекста — выигрыш остаётся. - Tool-call уже неактивного тула в истории чата: провайдеры обычно переваривают, но проверить на нашем наборе (OpenAI-compatible) — пункт тест-плана. # Вне скоупа - Внешний `/mcp`-сервер: сворачивание схем — забота MCP-клиента (Claude Code это уже делает сам), на сервере ничего не меняется. - Персистентность activatedTools между ходами (v2). - Автоподбор горячего яруса по статистике использования. # План работ 1. `buildToolCatalogBlock` + дословный блок выше в `ai-chat.prompt.ts` (рендер только при тоггле). 2. `loadTools` + разметка `tier`/`catalogLine` (SHARED_TOOL_SPECS, inline-тулы, внешние MCP → deferred). 3. `prepareAgentStep` → `activeTools` с per-turn состоянием. 4. Тоггл воркспейса (server + клиентская настройка в Workspace settings → AI), фолбэк «всё активно». 5. Тесты: активация после `loadTools` (следующий шаг видит полную схему); финальный шаг перекрывает (`toolChoice:'none'`); неизвестное имя в `loadTools` → ошибка со списком; tool-call неактивного тула в истории; роль-корректор проходит типовой сценарий комментирования без единого `loadTools` (проверка состава горячего яруса); тоггл off = поведение как сейчас; каталог перечисляет РОВНО множество deferred-тулов (guard-тест против рассинхрона tier ↔ каталог, по образцу `server-instructions.test.mjs`).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#332