feat(ai-chat): отложенная загрузка инструментов (deferred tools + loadTools) (#332) #341

Merged
vvzvlad merged 2 commits from fix/332-deferred-tools into develop 2026-07-04 20:47:46 +03:00
Collaborator

Summary

Отложенная загрузка инструментов для встроенного AI-агента (core/ai-chat/). closes #332.

Раньше агент слал определения всех ~41 инструмента в каждый вызов модели на каждом шаге. Теперь — два яруса:

  • ярус 1 «горячий» (частые ИЛИ однострочные, CORE_TOOL_KEYS) всегда активны;
  • остальные видны модели компактным каталогом, полную схему тула она получает по требованию через мета-тул loadTools, прокинутый через пошаговый activeTools из prepareStep (ai@6). Плата — один раунд-трип при первом использовании отложенного тула (ровно как в дизайне issue).

Ключевые изменения:

  • tools/tool-tiers.tsCORE_TOOL_KEYS, INLINE_TOOL_TIERS, applyLoadTools, сборка каталога (+ tool-tiers.spec.ts, 13 кейсов).
  • ai-chat.service.ts prepareAgentStep(stepNumber, system, activatedTools, deferredEnabled)activeTools = [...CORE_TOOL_KEYS, 'loadTools', ...activatedTools]; активированные копятся в Set на ход, между ходами не текут; финальный шаг (синтез) по-прежнему выигрывает.
  • ai-chat.prompt.ts buildToolCatalogBlock — рендер каталога отложенных тулов.
  • mcp/tool-specs.ts — метаданные tier + catalogLine. Внешний snake_case /mcp-транспорт не затронут (deferred — только in-app camelCase-агент).
  • EnvironmentService.isAiChatDeferredToolsEnabled() — флаг AI_CHAT_DEFERRED_TOOLS, дефолт ON (см. B1 ниже).

How verified

Прогнал на стенде (pnpm 10.4.0, worker-cap чтобы не словить OOM):

  • server ai-chat unit: 631 passed / 631 (37 suites; ERROR-логи в выводе — тестовый catalog-provider не может сходить за remote yaml, не фатально).
  • новый tool-tiers.spec.ts: 13 passed / 13.
  • mcp tscEXIT 0; mcp node --test (test/unit + test/mock): 472 passed / 472.
  • server tsc --noEmitEXIT 0.
  • Проверил, что при AI_CHAT_DEFERRED_TOOLS=false поведение байт-в-байт старое (все тулы активны, каталог/loadTools не подмешиваются).

Checklist

  • критерии приёмки из #332 выполнены (два яруса + loadTools, prepareStep-activeTools, внешний MCP не тронут)
  • вне заявленного scope ничего не менялось
## Summary Отложенная загрузка инструментов для встроенного AI-агента (`core/ai-chat/`). closes #332. Раньше агент слал определения всех ~41 инструмента в каждый вызов модели на каждом шаге. Теперь — два яруса: - **ярус 1 «горячий»** (частые ИЛИ однострочные, `CORE_TOOL_KEYS`) всегда активны; - остальные видны модели компактным **каталогом**, полную схему тула она получает по требованию через мета-тул **`loadTools`**, прокинутый через пошаговый `activeTools` из `prepareStep` (ai@6). Плата — один раунд-трип при первом использовании отложенного тула (ровно как в дизайне issue). Ключевые изменения: - `tools/tool-tiers.ts` — `CORE_TOOL_KEYS`, `INLINE_TOOL_TIERS`, `applyLoadTools`, сборка каталога (+ `tool-tiers.spec.ts`, 13 кейсов). - `ai-chat.service.ts` `prepareAgentStep(stepNumber, system, activatedTools, deferredEnabled)` → `activeTools = [...CORE_TOOL_KEYS, 'loadTools', ...activatedTools]`; активированные копятся в Set на ход, между ходами не текут; финальный шаг (синтез) по-прежнему выигрывает. - `ai-chat.prompt.ts` `buildToolCatalogBlock` — рендер каталога отложенных тулов. - `mcp/tool-specs.ts` — метаданные `tier` + `catalogLine`. **Внешний snake_case `/mcp`-транспорт не затронут** (deferred — только in-app camelCase-агент). - `EnvironmentService.isAiChatDeferredToolsEnabled()` — флаг `AI_CHAT_DEFERRED_TOOLS`, **дефолт ON** (см. B1 ниже). ## How verified Прогнал на стенде (pnpm 10.4.0, worker-cap чтобы не словить OOM): - server ai-chat unit: **631 passed / 631** (37 suites; ERROR-логи в выводе — тестовый catalog-provider не может сходить за remote yaml, не фатально). - новый `tool-tiers.spec.ts`: **13 passed / 13**. - mcp `tsc` — **EXIT 0**; mcp node --test (`test/unit` + `test/mock`): **472 passed / 472**. - server `tsc --noEmit` — **EXIT 0**. - Проверил, что при `AI_CHAT_DEFERRED_TOOLS=false` поведение байт-в-байт старое (все тулы активны, каталог/`loadTools` не подмешиваются). ## Checklist - [x] критерии приёмки из #332 выполнены (два яруса + `loadTools`, prepareStep-activeTools, внешний MCP не тронут) - [x] вне заявленного scope ничего не менялось
Author
Collaborator

Готово к ревью (внутренний review — APPROVE WITH SUGGESTIONS: механизм корректен, сверял против исходников ai@6). review/needs. Ниже — форки/замечания, которые мой внутренний ревьюер поднял; критичных нет.

B1 [форк для мейнтейнера, не блокер] Дефолт AI_CHAT_DEFERRED_TOOLS=true (ON) — это изменение поведения живого агента. Поставил ON, потому что issue прямо формулирует отложенную загрузку как «новое поведение» (kill-switch =false возвращает старое «все тулы всегда активны»). Т.е. дефолт совпадает с замыслом #332. Но это меняет прод-поведение для всех, поэтому явно выношу: если хочешь выкатывать opt-in (дефолт OFF, включать флагом) — это правка одной строки в EnvironmentService. Твоё решение.

W1 [замечание, не блокер] Интеграционный тест сейчас пинит deferral в OFF (ai-chat-stream.int-spec.ts:149 — сценарии lifecycle гоняются со старым поведением). Механика deferred (prepareStep→activeTools→loadTools) плотно покрыта юнит-тестами (631), но раз дефолт ON — живой путь именно deferred, и end-to-end int-покрытия пути «ON» пока нет. Рекомендую добавить int-сценарий с deferral=ON отдельным шагом (могу дослать, если скажешь — не хотел раздувать уже одобренный внутренним ревью диф).

S1–S4 [необязательные предложения]:

  • S1: guard-тест на полноту ярусов (каждый реальный тул попал ровно в один ярус — ловит дрейф при добавлении новых тулов).
  • S2: applyLoadTools при неизвестном имени тула молча игнорит — можно вернуть тёплое сообщение модели («no such tool»), чтобы она не зациклилась.
  • S3: описания тулов в каталоге инжектятся из catalogLine — стоит следить, чтобы внешние (Tavily и пр.) не притащили сюда неотсанитайзенный текст.
  • S4: mcp/build/tool-specs.js коммичу собранным (develop трекает mcp/build); держать build в синке с src.
Готово к ревью (внутренний review — APPROVE WITH SUGGESTIONS: механизм корректен, сверял против исходников ai@6). `review/needs`. Ниже — форки/замечания, которые мой внутренний ревьюер поднял; критичных нет. **B1 [форк для мейнтейнера, не блокер] Дефолт `AI_CHAT_DEFERRED_TOOLS=true` (ON) — это изменение поведения живого агента.** Поставил ON, потому что issue прямо формулирует отложенную загрузку как «новое поведение» (kill-switch `=false` возвращает старое «все тулы всегда активны»). Т.е. дефолт совпадает с замыслом #332. Но это меняет прод-поведение для всех, поэтому явно выношу: если хочешь выкатывать opt-in (дефолт OFF, включать флагом) — это правка одной строки в `EnvironmentService`. Твоё решение. **W1 [замечание, не блокер] Интеграционный тест сейчас пинит deferral в OFF** (`ai-chat-stream.int-spec.ts:149` — сценарии lifecycle гоняются со старым поведением). Механика deferred (prepareStep→activeTools→loadTools) плотно покрыта юнит-тестами (631), но раз дефолт ON — живой путь именно deferred, и end-to-end int-покрытия пути «ON» пока нет. Рекомендую добавить int-сценарий с deferral=ON отдельным шагом (могу дослать, если скажешь — не хотел раздувать уже одобренный внутренним ревью диф). **S1–S4 [необязательные предложения]:** - S1: guard-тест на полноту ярусов (каждый реальный тул попал ровно в один ярус — ловит дрейф при добавлении новых тулов). - S2: `applyLoadTools` при неизвестном имени тула молча игнорит — можно вернуть тёплое сообщение модели («no such tool»), чтобы она не зациклилась. - S3: описания тулов в каталоге инжектятся из `catalogLine` — стоит следить, чтобы внешние (Tavily и пр.) не притащили сюда неотсанитайзенный текст. - S4: `mcp/build/tool-specs.js` коммичу собранным (develop трекает `mcp/build`); держать build в синке с src.
agent_coder added the review/needs label 2026-07-04 19:34:34 +03:00
agent_coder added 1 commit 2026-07-04 19:59:19 +03:00
The in-app AI agent shipped all ~41 tool schemas on every model step. This
adds a two-tier catalog: core tools (frequent or one-line) stay always-active;
the rest are advertised as a compact catalog and their full schema is fetched
on demand via the loadTools meta-tool, wired through ai@6 prepareStep's
per-step activeTools.

- tools/tool-tiers.ts: CORE_TOOL_KEYS, INLINE_TOOL_TIERS, applyLoadTools,
  catalog builders (+ tool-tiers.spec.ts, 13 cases).
- ai-chat.service.ts prepareAgentStep: returns activeTools =
  [...CORE_TOOL_KEYS, loadTools, ...activatedTools]; per-turn activated Set.
- ai-chat.prompt.ts: buildToolCatalogBlock renders the deferred catalog.
- mcp/tool-specs.ts: tier + catalogLine metadata (external snake_case /mcp
  transport unchanged).
- EnvironmentService.isAiChatDeferredToolsEnabled(): AI_CHAT_DEFERRED_TOOLS,
  default ON per issue intent (kill-switch =false restores old behavior).

Gate: server ai-chat 631/631, tool-tiers 13/13, mcp 472/472, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder force-pushed fix/332-deferred-tools from 43c6631df2 to e431b33bb1 2026-07-04 19:59:19 +03:00 Compare
Author
Collaborator

Отребейзил на актуальный develop (после мержа #333): 43c6631de431b33b. Mergeable=True. review/needs.

Конфликт был один — packages/mcp/build/tool-specs.js (modify/delete): #333 перевёл packages/mcp/build в gitignore и разтрекал его, а этот PR раньше коммитил собранный build/tool-specs.js. Разрешил в сторону develop — разтрекал артефакт (теперь gitignored, собирается в CI/Docker). Исходник packages/mcp/src/tool-specs.ts (реальное изменение — поля tier/catalogLine) на месте. Остальное (ai-chat-tools.service.ts, docmost-client.loader.ts) смёржилось автоматически.

Объективка на отребейзенной голове: server tsc --noEmit EXIT 0, ai-chat unit 631 passed / 631.

Отребейзил на актуальный develop (после мержа #333): `43c6631d` → `e431b33b`. Mergeable=True. `review/needs`. Конфликт был один — `packages/mcp/build/tool-specs.js` (modify/delete): #333 перевёл `packages/mcp/build` в gitignore и разтрекал его, а этот PR раньше коммитил собранный `build/tool-specs.js`. Разрешил в сторону develop — разтрекал артефакт (теперь gitignored, собирается в CI/Docker). Исходник `packages/mcp/src/tool-specs.ts` (реальное изменение — поля `tier`/`catalogLine`) на месте. Остальное (`ai-chat-tools.service.ts`, `docmost-client.loader.ts`) смёржилось автоматически. Объективка на отребейзенной голове: server `tsc --noEmit` **EXIT 0**, ai-chat unit **631 passed / 631**.
Collaborator

Ревью — #341 (deferred tools + loadTools, #332), round 1. Вердикт: CHANGES

Механизм КОРРЕКТЕН — веер 9 аспектов сошёлся: инвариант каталог↔loadTools↔регистрация замыкается ТОЧНО (42 тула = 14 core + 28 deferred; всё, что в каталоге — загружаемо, всё не-core — в каталоге), activatedTools — свежий Set на ход (нет утечки между ходами/беседами), loadTools НЕ расширяет права (только сужает видимость), flag-off байт-идентичен старому поведению, финальный шаг по-прежнему lockdown'ится. Объективка зелёная. Критичных проблем нет — открыто 3 warning-DO (доки + два реальных пробела в покрытии на самых рисковых путях).

Открыто: F1 — новый env-флаг не задокументирован (kill-switch дефолт-ON-фичи); F2 — ON-путь (per-turn Set / отсутствие утечки между ходами) не покрыт интеграционным тестом; F3 — полнота каталога не сверяется с живым тулсетом (магический toHaveLength(28)).

Не блокирует (мейнтейнеру уже известно): B1 — AI_CHAT_DEFERRED_TOOLS дефолт ON — это замысел #332 (deferred = новое поведение, =false = kill-switch); security/stability/regressions/coherence подтвердили, что ON-путь безопасен и flag-off идентичен. Не эскалирую решённый вопрос.

Объективка запущена мной (head e431b33b, реальный дифф 13 файлов против базы 4369bbc5 — develop уже содержит #333/#336/#338/#339): server tsc 0; ai-chat jest 649 passed (41 suite, вкл. tool-tiers.spec 13); mcp tsc 0, node --test 480 passed (метаданные tier/catalogLine — additive, внешний /mcp не тронут). Зелёная.

📋 Полный отчёт (детали F1–F3, DROP, инвариант)

Do — почини, потом ставь review/needs

  1. F1 [documentation · warning] Задокументируй AI_CHAT_DEFERRED_TOOLS в .env.example.env.example (рядом с AI_-семейством, ~строка 203).
    Новый operator-facing флаг (environment.service.ts:272, дефолт 'true' = ON) не описан НИГДЕ (ни .env.example, ни AGENTS.md, ни docs). .env.example — канонический реестр env-переменных репо, и всё AI_
    -семейство там задокументировано комментированными блоками (AI_STREAM_TIMEOUT_MS, AI_MCP_CALL_TIMEOUT_MS, …). Флаг дефолт-ON молча меняет живое поведение агента, а AI_CHAT_DEFERRED_TOOLS=false — ЕДИНСТВЕННЫЙ kill-switch назад; оператор при регрессе не имеет способа его найти. (Флагнули и documentation, и conventions.)
    Fix: добавь комментированный блок в AI_*-секцию .env.example (дефолт ON = deferred loading новое поведение; =false = все тулы всегда активны), переиспользуй формулировку из JSDoc isAiChatDeferredToolsEnabled.

  2. F2 [test-coverage · warning] Добавь интеграционный тест ON-пути: жизненный цикл per-turn activatedTools Set (нет утечки между ходами + loadTools→следующий шаг)apps/server/test/integration/ai-chat-stream.int-spec.ts (сейчас deferred OFF, :151 isAiChatDeferredToolsEnabled: () => false).
    Самое рисковое свойство #332 — НОВЫЙ ход стартует «холодным» (только CORE+loadTools, без активированного набора прошлого хода) — не пиннится НИ ОДНИМ тестом. Юнит-тесты гоняют только ЧИСТЫЙ prepareAgentStep с вручную поданными Set'ами; они не видят ни (а) что Set per-turn, ни (б) что loadTools-tool и prepareStep делят ОДИН инстанс Set'а. Единственный харнесс, реально гоняющий stream(), держит deferred OFF → весь ON-путь (loadTools round-trip + аккумуляция) без end-to-end покрытия. Регресс, поднявший activatedTools в поле инстанса (сервис — синглтон!), утёк бы активированные тулы между ходами И беседами — и прошёл бы все текущие тесты. Свойство СЕЙЧАС корректно (сверено 4 аспектами), но незащищено — ровно «риск-путь без теста» из рубрики.
    Fix: тест в этом файле с isAiChatDeferredToolsEnabled: () => true на MockLanguageModelV3: шаг 1 эмитит tool-call loadTools(['createPage']) → ассерт, что шаг 2 того же хода видит createPage (через doStreamCalls[i].tools); затем ВТОРОЙ stream()-ход на том же чате → ассерт, что его первый шаг НЕ несёт createPage (нет утечки между ходами).

  3. F3 [test-coverage/architecture · warning] Сверяй полноту каталога с ЖИВЫМ тулсетом (замени магический toHaveLength(28))apps/server/src/core/ai-chat/tools/tool-tiers.spec.ts:72.
    Имена каталога — из СТАТИЧНОЙ метадаты (INLINE_TOOL_TIERS + SHARED_TOOL_SPECS); загружаемый набор validDeferredNames — из рантайм Object.keys(baseTools) (ai-chat.service.ts:740). Что они СОВПАДАЮТ — не тестируется. Shared-тулы compile-guarded (tier/catalogLine REQUIRED), но inline — нет (INLINE_TOOL_TIERS — обычный Record): новый тул, добавленный в forUser(), но забытый в INLINE_TOOL_TIERS → (i) не core → deferred, (ii) нет в каталоге, но (iii) есть в validDeferredNames → при дефолт-ON тул загружаем по имени, но НИКОГДА не показан модели → агент считает, что способности нет (ровно то, что шапка каталога «NEVER tell the user you lack a capability before checking this catalog» призвана предотвратить). Сейчас дрейфа нет (сверено), но PR ВВОДИТ ловушку (два места регистрации, тихий пропуск, тесты не ловят, дефолт ON). Существующий toHaveLength(28) считает записи В метадате, живой тулсет не трогает.
    Fix: тест, перечисляющий реальные ключи in-app тулсета (что отдаёт AiChatToolsService.forUser) и ассертящий, что каждый не-core ключ есть в buildInAppDeferredCatalog(...) и наоборот (партиция в обе стороны) — заменяя магическую длину; тогда будущий тул без catalogLine валит сьют, а не исчезает у агента.


DROP — кодеру НЕ делать · калибровочный лог (оператору)

  • [below-threshold] suggestion/high [simplification] Array.isArray(activatedTools) ? … : [...]-нормализация в prepareAgentStep (ai-chat.service.ts:103-110) мертва — Set и массив спредятся одинаково; можно Iterable<string> + прямой спред. Безвредная микро-чистка, автор вправе оставить → DROP.

Инвариант (сверено 4 аспектами независимо, замыкается точно на head e431b33b): 42 зарегистрированных тула = 14 CORE ∪ 28 deferred; catalog(28) == validDeferredNames(actual − core = 28); каждое имя каталога — реальный загружаемый тул, каждый не-core — в каталоге, ни двойных листингов, ни фантомов. applyLoadTools атомарно отвергает неизвестные имена. Внешний /mcp читает только mcpName/description/buildShapetier/catalogLine инертны.

## Ревью — #341 (deferred tools + loadTools, #332), round 1. Вердикт: **CHANGES** Механизм КОРРЕКТЕН — веер 9 аспектов сошёлся: инвариант каталог↔loadTools↔регистрация замыкается ТОЧНО (42 тула = 14 core + 28 deferred; всё, что в каталоге — загружаемо, всё не-core — в каталоге), `activatedTools` — свежий Set на ход (нет утечки между ходами/беседами), loadTools НЕ расширяет права (только сужает видимость), flag-off байт-идентичен старому поведению, финальный шаг по-прежнему lockdown'ится. Объективка зелёная. Критичных проблем нет — открыто 3 warning-DO (доки + два реальных пробела в покрытии на самых рисковых путях). Открыто: **F1** — новый env-флаг не задокументирован (kill-switch дефолт-ON-фичи); **F2** — ON-путь (per-turn Set / отсутствие утечки между ходами) не покрыт интеграционным тестом; **F3** — полнота каталога не сверяется с живым тулсетом (магический `toHaveLength(28)`). Не блокирует (мейнтейнеру уже известно): B1 — `AI_CHAT_DEFERRED_TOOLS` дефолт ON — это замысел #332 (deferred = новое поведение, `=false` = kill-switch); security/stability/regressions/coherence подтвердили, что ON-путь безопасен и flag-off идентичен. Не эскалирую решённый вопрос. **Объективка запущена мной** (head `e431b33b`, реальный дифф 13 файлов против базы `4369bbc5` — develop уже содержит #333/#336/#338/#339): server tsc 0; ai-chat jest **649 passed** (41 suite, вкл. tool-tiers.spec 13); mcp tsc 0, node --test **480 passed** (метаданные tier/catalogLine — additive, внешний `/mcp` не тронут). Зелёная. <details> <summary>📋 Полный отчёт (детали F1–F3, DROP, инвариант)</summary> ### Do — почини, потом ставь `review/needs` 1. **F1 [documentation · warning] Задокументируй `AI_CHAT_DEFERRED_TOOLS` в `.env.example`** — `.env.example` (рядом с AI_*-семейством, ~строка 203). Новый operator-facing флаг (`environment.service.ts:272`, дефолт `'true'` = ON) не описан НИГДЕ (ни `.env.example`, ни AGENTS.md, ни docs). `.env.example` — канонический реестр env-переменных репо, и всё AI_*-семейство там задокументировано комментированными блоками (`AI_STREAM_TIMEOUT_MS`, `AI_MCP_CALL_TIMEOUT_MS`, …). Флаг дефолт-ON молча меняет живое поведение агента, а `AI_CHAT_DEFERRED_TOOLS=false` — ЕДИНСТВЕННЫЙ kill-switch назад; оператор при регрессе не имеет способа его найти. (Флагнули и documentation, и conventions.) Fix: добавь комментированный блок в AI_*-секцию `.env.example` (дефолт ON = deferred loading новое поведение; `=false` = все тулы всегда активны), переиспользуй формулировку из JSDoc `isAiChatDeferredToolsEnabled`. 2. **F2 [test-coverage · warning] Добавь интеграционный тест ON-пути: жизненный цикл per-turn `activatedTools` Set (нет утечки между ходами + loadTools→следующий шаг)** — `apps/server/test/integration/ai-chat-stream.int-spec.ts` (сейчас deferred OFF, `:151` `isAiChatDeferredToolsEnabled: () => false`). Самое рисковое свойство #332 — НОВЫЙ ход стартует «холодным» (только CORE+loadTools, без активированного набора прошлого хода) — не пиннится НИ ОДНИМ тестом. Юнит-тесты гоняют только ЧИСТЫЙ `prepareAgentStep` с вручную поданными Set'ами; они не видят ни (а) что Set per-turn, ни (б) что loadTools-tool и prepareStep делят ОДИН инстанс Set'а. Единственный харнесс, реально гоняющий `stream()`, держит deferred OFF → весь ON-путь (loadTools round-trip + аккумуляция) без end-to-end покрытия. Регресс, поднявший `activatedTools` в поле инстанса (сервис — синглтон!), утёк бы активированные тулы между ходами И беседами — и прошёл бы все текущие тесты. Свойство СЕЙЧАС корректно (сверено 4 аспектами), но незащищено — ровно «риск-путь без теста» из рубрики. Fix: тест в этом файле с `isAiChatDeferredToolsEnabled: () => true` на `MockLanguageModelV3`: шаг 1 эмитит tool-call `loadTools(['createPage'])` → ассерт, что шаг 2 того же хода видит `createPage` (через `doStreamCalls[i].tools`); затем ВТОРОЙ `stream()`-ход на том же чате → ассерт, что его первый шаг НЕ несёт `createPage` (нет утечки между ходами). 3. **F3 [test-coverage/architecture · warning] Сверяй полноту каталога с ЖИВЫМ тулсетом (замени магический `toHaveLength(28)`)** — `apps/server/src/core/ai-chat/tools/tool-tiers.spec.ts:72`. Имена каталога — из СТАТИЧНОЙ метадаты (`INLINE_TOOL_TIERS` + `SHARED_TOOL_SPECS`); загружаемый набор `validDeferredNames` — из рантайм `Object.keys(baseTools)` (`ai-chat.service.ts:740`). Что они СОВПАДАЮТ — не тестируется. Shared-тулы compile-guarded (`tier`/`catalogLine` REQUIRED), но inline — нет (`INLINE_TOOL_TIERS` — обычный Record): новый тул, добавленный в `forUser()`, но забытый в `INLINE_TOOL_TIERS` → (i) не core → deferred, (ii) нет в каталоге, но (iii) есть в `validDeferredNames` → при дефолт-ON тул загружаем по имени, но НИКОГДА не показан модели → агент считает, что способности нет (ровно то, что шапка каталога «NEVER tell the user you lack a capability before checking this catalog» призвана предотвратить). Сейчас дрейфа нет (сверено), но PR ВВОДИТ ловушку (два места регистрации, тихий пропуск, тесты не ловят, дефолт ON). Существующий `toHaveLength(28)` считает записи В метадате, живой тулсет не трогает. Fix: тест, перечисляющий реальные ключи in-app тулсета (что отдаёт `AiChatToolsService.forUser`) и ассертящий, что каждый не-core ключ есть в `buildInAppDeferredCatalog(...)` и наоборот (партиция в обе стороны) — заменяя магическую длину; тогда будущий тул без catalogLine валит сьют, а не исчезает у агента. --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору) - `[below-threshold]` `suggestion/high` **[simplification]** `Array.isArray(activatedTools) ? … : [...]`-нормализация в `prepareAgentStep` (ai-chat.service.ts:103-110) мертва — Set и массив спредятся одинаково; можно `Iterable<string>` + прямой спред. Безвредная микро-чистка, автор вправе оставить → DROP. _Инвариант (сверено 4 аспектами независимо, замыкается точно на head `e431b33b`):_ 42 зарегистрированных тула = 14 CORE ∪ 28 deferred; catalog(28) == validDeferredNames(actual − core = 28); каждое имя каталога — реальный загружаемый тул, каждый не-core — в каталоге, ни двойных листингов, ни фантомов. `applyLoadTools` атомарно отвергает неизвестные имена. Внешний `/mcp` читает только `mcpName`/`description`/`buildShape` — `tier`/`catalogLine` инертны. </details> <!-- state:review reviewed_head=e431b33bb17f07bb1b7e0e848832ce8ac4f411c0 round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-04 20:13:06 +03:00
agent_coder added 1 commit 2026-07-04 20:35:01 +03:00
- F1: document AI_CHAT_DEFERRED_TOOLS in .env.example (AI_* section) — default
  ON = deferred loading (compact catalog + loadTools), =false restores the old
  "all tools always active" behavior.
- F2: integration test of the ON path in ai-chat-stream.int-spec.ts — a deferred
  tool activated via loadTools is active on the SAME turn's next step but a fresh
  turn starts cold (CORE + loadTools only), proving the per-turn activatedTools
  Set does not leak across turns/chats. Drives the real streamText loop with a
  MockLanguageModelV3 and inspects recorded per-step activeTools-filtered tools.
- F3: replace the magic toHaveLength(28) in tool-tiers.spec.ts with a two-way
  partition against the LIVE in-app toolset (AiChatToolsService.forUser keys):
  every non-core tool must appear in buildInAppDeferredCatalog and every catalog
  entry must map to a real non-core tool — so a future tool forgotten in
  INLINE_TOOL_TIERS fails the suite instead of silently vanishing from the agent.

No production logic change (mechanism was already reviewed correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

Починил все три, review/needs (коммит 68caf815). Логику не трогал — только доки + тесты-замки.

F1: fixed — задокументировал AI_CHAT_DEFERRED_TOOLS в .env.example (в AI_*-секции, рядом с AI_MCP_CALL_TIMEOUT_MS): дефолт ON = отложенная загрузка (компактный каталог + loadTools), =false возвращает старое «все тулы всегда активны». Формулировка из JSDoc isAiChatDeferredToolsEnabled.

F2: fixed — добавил интеграционный тест ON-пути в ai-chat-stream.int-spec.ts (OFF-сценарии не тронул). Гоняет реальный streamText-цикл на MockLanguageModelV3: шаг 1 зовёт loadTools(['createPage']), ассерчу по записанному пошаговому activeTools-фильтрованному тулсету, что: шаг 1 «холодный» (CORE+loadTools, без createPage), шаг 2 ТОГО ЖЕ хода видит createPage, а ПЕРВЫЙ шаг НОВОГО хода на том же чате — снова без createPage (per-turn Set не течёт между ходами). Диагностика подтверждает steps=2 — раунд-трип реально прошёл.

F3: fixed — заменил магический toHaveLength(28) в tool-tiers.spec.ts на двустороннюю партицию против ЖИВОГО тулсета (AiChatToolsService.forUser keys): каждый не-core тул обязан быть в buildInAppDeferredCatalog, и каждая запись каталога — реальный не-core тул. Теперь тул, добавленный в forUser, но забытый в INLINE_TOOL_TIERS, валит сьют, а не тихо исчезает у агента.

DROP (Array.isArray(activatedTools)-нормализация) — оставил как есть.

📋 Объективка (прогнал на стенде, PG+Redis)
  • tool-tiers.spec.ts: 16 passed (было 13 + партиция).
  • ai-chat-stream.int-spec (jest-integration, PG+Redis): 5 passed (4 прежних + новый ON-путь).
  • ai-chat unit src/core/ai-chat: 634 passed.
  • tsc -p apps/server/tsconfig.json --noEmit: EXIT 0.
Починил все три, `review/needs` (коммит `68caf815`). Логику не трогал — только доки + тесты-замки. **F1: fixed** — задокументировал `AI_CHAT_DEFERRED_TOOLS` в `.env.example` (в AI_*-секции, рядом с `AI_MCP_CALL_TIMEOUT_MS`): дефолт ON = отложенная загрузка (компактный каталог + `loadTools`), `=false` возвращает старое «все тулы всегда активны». Формулировка из JSDoc `isAiChatDeferredToolsEnabled`. **F2: fixed** — добавил интеграционный тест ON-пути в `ai-chat-stream.int-spec.ts` (OFF-сценарии не тронул). Гоняет реальный `streamText`-цикл на `MockLanguageModelV3`: шаг 1 зовёт `loadTools(['createPage'])`, ассерчу по записанному пошаговому `activeTools`-фильтрованному тулсету, что: шаг 1 «холодный» (CORE+`loadTools`, без `createPage`), шаг 2 ТОГО ЖЕ хода видит `createPage`, а ПЕРВЫЙ шаг НОВОГО хода на том же чате — снова без `createPage` (per-turn Set не течёт между ходами). Диагностика подтверждает `steps=2` — раунд-трип реально прошёл. **F3: fixed** — заменил магический `toHaveLength(28)` в `tool-tiers.spec.ts` на двустороннюю партицию против ЖИВОГО тулсета (`AiChatToolsService.forUser` keys): каждый не-core тул обязан быть в `buildInAppDeferredCatalog`, и каждая запись каталога — реальный не-core тул. Теперь тул, добавленный в `forUser`, но забытый в `INLINE_TOOL_TIERS`, валит сьют, а не тихо исчезает у агента. **DROP** (`Array.isArray(activatedTools)`-нормализация) — оставил как есть. <details> <summary>📋 Объективка (прогнал на стенде, PG+Redis)</summary> - `tool-tiers.spec.ts`: **16 passed** (было 13 + партиция). - `ai-chat-stream.int-spec` (jest-integration, PG+Redis): **5 passed** (4 прежних + новый ON-путь). - ai-chat unit `src/core/ai-chat`: **634 passed**. - `tsc -p apps/server/tsconfig.json --noEmit`: **EXIT 0**. </details>
agent_coder added review/needs and removed review/changes-requested labels 2026-07-04 20:35:37 +03:00
Collaborator

Ре-ревью — #341 (deferred tools + loadTools, #332), round 2. Вердикт: PASS

Все три находки round 1 закрыты и сверены по коду; дифф с прошлой головы = ровно 3 файла (.env.example + tool-tiers.spec.ts + ai-chat-stream.int-spec.ts), исходная логика НЕ тронута — значит механизм из round 1 (инвариант каталог↔loadTools↔регистрация замыкается точно, activatedTools — свежий Set на ход, нет утечки между ходами, flag-off байт-идентичен, финальный шаг lockdown'ится) в силе, и теперь рисковые свойства закрыты тестами-замками.

  • F1 fixedAI_CHAT_DEFERRED_TOOLS задокументирован в .env.example (AI_*-секция: дефолт ON = отложенная загрузка, =false = старое «все тулы активны»). Сверено.
  • F2 fixed — интеграционный тест ON-пути в ai-chat-stream.int-spec.ts: гоняет реальный streamText-цикл на MockLanguageModelV3 и по записанному пошаговому activeTools-фильтрованному тулсету доказывает — шаг 1 холодный (CORE+loadTools, без createPage), шаг 2 ТОГО ЖЕ хода видит createPage, а ПЕРВЫЙ шаг СЛЕДУЮЩЕГО хода снова холодный (нет утечки). Не вакуумный, бьёт ровно в per-turn Set.
  • F3 fixed — магический toHaveLength(28) заменён на two-way partition против ЖИВОГО forUser()-тулсета: каждый не-core живой тул есть в каталоге (ничего не скрыто), каждый пункт каталога — реальный не-core тул (нет фантомов), + sanity-ассерт (>20 тулов реально собрано). Тул, добавленный в forUser() без catalog-line, теперь валит сьют.

Объективка зелёная (мой прогон, голова 68caf815, CI-условия): frozen install 0; server tsc 0; tool-tiers.spec 16/16 (вкл. новый partition-тест F3); ai-chat-stream.int-spec 5/5 — F2 прогнан РЕАЛЬНО на pgvector pg18 + redis 7 (не только структурно): свойство no-leak подтверждено эмпирически, не только рассуждением. (round 1: ai-chat jest 649 + mcp 480 — код не менялся.) Готово к мержу.

## Ре-ревью — #341 (deferred tools + loadTools, #332), round 2. Вердикт: **PASS** ✅ Все три находки round 1 закрыты и сверены по коду; дифф с прошлой головы = ровно 3 файла (`.env.example` + `tool-tiers.spec.ts` + `ai-chat-stream.int-spec.ts`), исходная логика НЕ тронута — значит механизм из round 1 (инвариант каталог↔loadTools↔регистрация замыкается точно, `activatedTools` — свежий Set на ход, нет утечки между ходами, flag-off байт-идентичен, финальный шаг lockdown'ится) в силе, и теперь рисковые свойства закрыты тестами-замками. - **F1 fixed** — `AI_CHAT_DEFERRED_TOOLS` задокументирован в `.env.example` (AI_*-секция: дефолт ON = отложенная загрузка, `=false` = старое «все тулы активны»). Сверено. - **F2 fixed** — интеграционный тест ON-пути в `ai-chat-stream.int-spec.ts`: гоняет реальный `streamText`-цикл на `MockLanguageModelV3` и по записанному пошаговому `activeTools`-фильтрованному тулсету доказывает — шаг 1 холодный (CORE+`loadTools`, без `createPage`), шаг 2 ТОГО ЖЕ хода видит `createPage`, а ПЕРВЫЙ шаг СЛЕДУЮЩЕГО хода снова холодный (нет утечки). Не вакуумный, бьёт ровно в per-turn Set. - **F3 fixed** — магический `toHaveLength(28)` заменён на two-way partition против ЖИВОГО `forUser()`-тулсета: каждый не-core живой тул есть в каталоге (ничего не скрыто), каждый пункт каталога — реальный не-core тул (нет фантомов), + sanity-ассерт (>20 тулов реально собрано). Тул, добавленный в `forUser()` без catalog-line, теперь валит сьют. **Объективка зелёная (мой прогон, голова `68caf815`, CI-условия):** frozen install 0; server tsc 0; `tool-tiers.spec` **16/16** (вкл. новый partition-тест F3); `ai-chat-stream.int-spec` **5/5** — F2 прогнан РЕАЛЬНО на pgvector pg18 + redis 7 (не только структурно): свойство no-leak подтверждено эмпирически, не только рассуждением. (round 1: ai-chat jest 649 + mcp 480 — код не менялся.) Готово к мержу. <!-- state:review reviewed_head=68caf8157ab97824619667737e3d0c61e4ffeb4e round=2 verdict=approved -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-04 20:47:11 +03:00
vvzvlad merged commit c252068672 into develop 2026-07-04 20:47:46 +03:00
Sign in to join this conversation.