perf(ai-chat): deferred-активация тулов в metadata чата (#490)
Активированный set сбрасывался каждый ход → модель заново гоняла loadTools, чтобы
переактивировать те же тулы (лишний round-trip на каждом ходу). Теперь набор
персистится в metadata чата и сидируется на следующем ходу.
- Миграция: jsonb-колонка metadata на ai_chats (default '{}'); db.d.ts дополнен
вручную (AiChats.metadata: Generated<Json>).
- seedActivatedTools(metadata, validDeferredNames): читает сохранённый набор,
ПЕРЕСЕКАЯ с актуальными validDeferredNames — смена allowlist/ролей не воскресит
несуществующий тул (иначе prepareAgentStep получил бы фантомное активное имя).
Сид только при deferredEnabled.
- Персист на завершении хода (once-guard, во всех терминальных ветках рядом со
snapshotTurnEnd): детерминированно отсортированный набор, merge в существующий
bag (другие ключи сохраняются), запись пропускается если ничего нового не
активировано (обычный ход не даёт лишней записи).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
OUTPUT_DEGENERATION_ERROR,
|
||||
lastAssistantContextTokens,
|
||||
lastAssistantReplayOverflow,
|
||||
seedActivatedTools,
|
||||
} from './ai-chat.service';
|
||||
import type { AiChatMessage, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { buildSystemPrompt } from './ai-chat.prompt';
|
||||
@@ -439,6 +440,45 @@ describe('lastAssistantContextTokens', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #490 deferred-tool activation persisted across turns.
|
||||
describe('seedActivatedTools', () => {
|
||||
const valid = new Set(['Search_web', 'getPageJson', 'diffPageVersions']);
|
||||
|
||||
it('seeds from persisted metadata, intersected with current valid names', () => {
|
||||
expect(
|
||||
seedActivatedTools(
|
||||
{ activatedTools: ['Search_web', 'getPageJson'] },
|
||||
valid,
|
||||
),
|
||||
).toEqual(['Search_web', 'getPageJson']);
|
||||
});
|
||||
|
||||
it('drops a stored tool that is no longer valid (allowlist/role changed)', () => {
|
||||
// 'Habr_publish' was activated before but is not in the current allowlist.
|
||||
expect(
|
||||
seedActivatedTools({ activatedTools: ['Search_web', 'Habr_publish'] }, valid),
|
||||
).toEqual(['Search_web']);
|
||||
});
|
||||
|
||||
it('is empty/robust for missing, non-array, or unknown-shaped metadata', () => {
|
||||
expect(seedActivatedTools(undefined, valid)).toEqual([]);
|
||||
expect(seedActivatedTools({}, valid)).toEqual([]);
|
||||
expect(seedActivatedTools({ activatedTools: 'nope' }, valid)).toEqual([]);
|
||||
expect(
|
||||
seedActivatedTools({ activatedTools: [1, 'getPageJson', null] }, valid),
|
||||
).toEqual(['getPageJson']);
|
||||
});
|
||||
|
||||
it('de-duplicates stored names', () => {
|
||||
expect(
|
||||
seedActivatedTools(
|
||||
{ activatedTools: ['getPageJson', 'getPageJson'] },
|
||||
valid,
|
||||
),
|
||||
).toEqual(['getPageJson']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lastAssistantReplayOverflow', () => {
|
||||
const row = (
|
||||
role: string,
|
||||
|
||||
@@ -936,10 +936,17 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
// supplied or the supplied one does not belong to this workspace.
|
||||
let isNewChat = false;
|
||||
let chatId = body.chatId;
|
||||
// Persisted chat-level metadata bag (#490): read once here so the deferred-tool
|
||||
// activation set can be seeded from the previous turn. Undefined for a new chat.
|
||||
let chatMetadata: Record<string, unknown> | undefined;
|
||||
if (chatId) {
|
||||
const existing = await this.aiChatRepo.findById(chatId, workspace.id);
|
||||
if (!existing) {
|
||||
chatId = undefined;
|
||||
} else {
|
||||
chatMetadata = (existing.metadata ?? undefined) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
}
|
||||
}
|
||||
// The open page the client sent is attacker-controllable — BOTH its id and
|
||||
@@ -1393,10 +1400,19 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
// tools + ALL external MCP tools), computed from the ACTUAL toolset so an
|
||||
// external tool is loadable by its namespaced name. loadTools rejects any
|
||||
// name outside this set.
|
||||
const activatedTools = new Set<string>();
|
||||
const validDeferredNames = new Set<string>(
|
||||
Object.keys(baseTools).filter((k) => !CORE_TOOL_SET.has(k)),
|
||||
);
|
||||
// #490: seed the activation set from the chat's PERSISTED set so the model
|
||||
// does not re-run loadTools every turn to re-activate the same tools. Only
|
||||
// when deferred loading is enabled, and ALWAYS intersected with the CURRENT
|
||||
// valid deferred names — an allowlist/role change must never resurrect a tool
|
||||
// that no longer exists (prepareAgentStep would get a phantom active name).
|
||||
const activatedTools = new Set<string>(
|
||||
deferredEnabled
|
||||
? seedActivatedTools(chatMetadata, validDeferredNames)
|
||||
: [],
|
||||
);
|
||||
// Add the loadTools meta-tool ONLY when the feature is enabled; when off the
|
||||
// toolset and behavior are exactly as before.
|
||||
const tools = deferredEnabled
|
||||
@@ -1406,6 +1422,39 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
: baseTools;
|
||||
|
||||
// #490: persist the (deterministically ordered) activation set back onto the
|
||||
// chat metadata at turn end, so the NEXT turn seeds from it. Once-guarded and
|
||||
// skipped when nothing new was activated (the set equals its seed) so an
|
||||
// ordinary turn adds no extra write. Preserves other metadata keys.
|
||||
let activatedToolsPersisted = false;
|
||||
const persistActivatedTools = async (): Promise<void> => {
|
||||
if (!deferredEnabled || activatedToolsPersisted || !chatId) return;
|
||||
activatedToolsPersisted = true;
|
||||
const current = [...activatedTools].sort();
|
||||
const seeded = seedActivatedTools(chatMetadata, validDeferredNames).sort();
|
||||
if (current.length === 0 || current.join(' | ||||