feat(ai-chat): отложенная загрузка инструментов (deferred tools + loadTools) (#332) #341
Reference in New Issue
Block a user
Delete Branch "fix/332-deferred-tools"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Отложенная загрузка инструментов для встроенного AI-агента (
core/ai-chat/). closes #332.Раньше агент слал определения всех ~41 инструмента в каждый вызов модели на каждом шаге. Теперь — два яруса:
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.tsprepareAgentStep(stepNumber, system, activatedTools, deferredEnabled)→activeTools = [...CORE_TOOL_KEYS, 'loadTools', ...activatedTools]; активированные копятся в Set на ход, между ходами не текут; финальный шаг (синтез) по-прежнему выигрывает.ai-chat.prompt.tsbuildToolCatalogBlock— рендер каталога отложенных тулов.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):
tool-tiers.spec.ts: 13 passed / 13.tsc— EXIT 0; mcp node --test (test/unit+test/mock): 472 passed / 472.tsc --noEmit— EXIT 0.AI_CHAT_DEFERRED_TOOLS=falseповедение байт-в-байт старое (все тулы активны, каталог/loadToolsне подмешиваются).Checklist
loadTools, prepareStep-activeTools, внешний MCP не тронут)Готово к ревью (внутренний 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 [необязательные предложения]:
applyLoadToolsпри неизвестном имени тула молча игнорит — можно вернуть тёплое сообщение модели («no such tool»), чтобы она не зациклилась.catalogLine— стоит следить, чтобы внешние (Tavily и пр.) не притащили сюда неотсанитайзенный текст.mcp/build/tool-specs.jsкоммичу собранным (develop трекаетmcp/build); держать build в синке с src.43c6631df2toe431b33bb1Отребейзил на актуальный 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 --noEmitEXIT 0, ai-chat unit 631 passed / 631.Ревью — #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/needsF1 [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= все тулы всегда активны), переиспользуй формулировку из JSDocisAiChatDeferredToolsEnabled.F2 [test-coverage · warning] Добавь интеграционный тест ON-пути: жизненный цикл per-turn
activatedToolsSet (нет утечки между ходами + loadTools→следующий шаг) —apps/server/test/integration/ai-chat-stream.int-spec.ts(сейчас deferred OFF,:151isAiChatDeferredToolsEnabled: () => 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-callloadTools(['createPage'])→ ассерт, что шаг 2 того же хода видитcreatePage(черезdoStreamCalls[i].tools); затем ВТОРОЙstream()-ход на том же чате → ассерт, что его первый шаг НЕ несётcreatePage(нет утечки между ходами).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/catalogLineREQUIRED), но 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инертны.Починил все три,
review/needs(коммит68caf815). Логику не трогал — только доки + тесты-замки.F1: fixed — задокументировал
AI_CHAT_DEFERRED_TOOLSв.env.example(в AI_*-секции, рядом сAI_MCP_CALL_TIMEOUT_MS): дефолт ON = отложенная загрузка (компактный каталог +loadTools),=falseвозвращает старое «все тулы всегда активны». Формулировка из JSDocisAiChatDeferredToolsEnabled.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.forUserkeys): каждый не-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-путь).src/core/ai-chat: 634 passed.tsc -p apps/server/tsconfig.json --noEmit: EXIT 0.Ре-ревью — #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'ится) в силе, и теперь рисковые свойства закрыты тестами-замками.AI_CHAT_DEFERRED_TOOLSзадокументирован в.env.example(AI_*-секция: дефолт ON = отложенная загрузка,=false= старое «все тулы активны»). Сверено.ai-chat-stream.int-spec.ts: гоняет реальныйstreamText-цикл наMockLanguageModelV3и по записанному пошаговомуactiveTools-фильтрованному тулсету доказывает — шаг 1 холодный (CORE+loadTools, безcreatePage), шаг 2 ТОГО ЖЕ хода видитcreatePage, а ПЕРВЫЙ шаг СЛЕДУЮЩЕГО хода снова холодный (нет утечки). Не вакуумный, бьёт ровно в per-turn Set.toHaveLength(28)заменён на two-way partition против ЖИВОГОforUser()-тулсета: каждый не-core живой тул есть в каталоге (ничего не скрыто), каждый пункт каталога — реальный не-core тул (нет фантомов), + sanity-ассерт (>20 тулов реально собрано). Тул, добавленный вforUser()без catalog-line, теперь валит сьют.Объективка зелёная (мой прогон, голова
68caf815, CI-условия): frozen install 0; server tsc 0;tool-tiers.spec16/16 (вкл. новый partition-тест F3);ai-chat-stream.int-spec5/5 — F2 прогнан РЕАЛЬНО на pgvector pg18 + redis 7 (не только структурно): свойство no-leak подтверждено эмпирически, не только рассуждением. (round 1: ai-chat jest 649 + mcp 480 — код не менялся.) Готово к мержу.