6dad309b51
Эскалация (владелец) — Опция A: 100k только фолбэк для НЕсконфигурированных инсталляций; при заданном chatContextWindow бюджет = floor(0.7×window) БЕЗ капа (бюджетер — защита от брика об контекст-окно, не эконом-лимитер). Спек репиннут resolveReplayBudget(1_000_000)→700_000. DO1: агрессивный next-turn recovery ×0.5 вынесен в чистую resolveEffectiveReplayThreshold + тест линковки replayOverflow→0.5×бюджет (mutation-verified). DO2: checkNewComments partial-failure — per-page reject скипается (→null), скан резолвится, порядок выживших сохранён; тест #7 (mutation-verified). DO3: ai-chat.write-volume.spec.ts → .int-spec.ts (WAL-гард не бежал НИ в одном CI-lane) + маппер @docmost/token-estimate в jest-integration.json; реальный WAL на pg:5432 зелёный (трейс v1 140MB→v2 0.04MB). DO4: CHANGELOG [Unreleased] по #490. Follow-up: issue #520 (эскалация агрессивной доли при незаданном окне + малом реальном контексте). Ребейзнут на develop (волна 1 смержена): только 6 коммитов #490 над develop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3022 lines
140 KiB
TypeScript
3022 lines
140 KiB
TypeScript
import {
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
Logger,
|
|
OnModuleDestroy,
|
|
OnModuleInit,
|
|
ServiceUnavailableException,
|
|
} from '@nestjs/common';
|
|
import { FastifyReply } from 'fastify';
|
|
import {
|
|
streamText,
|
|
generateText,
|
|
convertToModelMessages,
|
|
stepCountIs,
|
|
type UIMessage,
|
|
type ModelMessage,
|
|
type LanguageModel,
|
|
} from 'ai';
|
|
import { AiService } from '../../integrations/ai/ai.service';
|
|
import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
|
|
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
|
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
|
|
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|
import { PageAccessService } from '../page/page-access/page-access.service';
|
|
import {
|
|
User,
|
|
Workspace,
|
|
AiChatMessage,
|
|
AiAgentRole,
|
|
} from '@docmost/db/types/entity.types';
|
|
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
|
import { McpClientsService } from './external-mcp/mcp-clients.service';
|
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
import { AiChatStreamRegistryService } from './ai-chat-stream-registry.service';
|
|
import { buildSystemPrompt } from './ai-chat.prompt';
|
|
import {
|
|
CORE_TOOL_KEYS,
|
|
CORE_TOOL_SET,
|
|
LOAD_TOOLS_NAME,
|
|
makeLoadToolsTool,
|
|
buildExternalToolCatalog,
|
|
} from './tools/tool-tiers';
|
|
import {
|
|
RunAlreadyActiveError,
|
|
AiChatRunService,
|
|
} from './ai-chat-run.service';
|
|
import { inAppToolCallCapMs } from './tools/ai-chat-tools.service';
|
|
import { computePageChange } from './page-change/page-change.util';
|
|
import {
|
|
sanitizeSelection,
|
|
type SelectionContext,
|
|
} from './tools/current-page.util';
|
|
import { roleModelOverride } from './roles/role-model-config';
|
|
import {
|
|
resolveReplayBudget,
|
|
resolveEffectiveReplayThreshold,
|
|
isContextOverflowError,
|
|
trimHistoryForReplay,
|
|
} from './history-budget';
|
|
import {
|
|
startSseHeartbeat,
|
|
stripStreamingHopByHopHeaders,
|
|
} from './sse-resilience';
|
|
import {
|
|
isDegenerateOutput,
|
|
truncateDegeneratedTail,
|
|
shouldCheckDegeneration,
|
|
} from './output-degeneration';
|
|
|
|
// Max agent steps per turn. One step = one model generation; a step that calls
|
|
// tools is followed by another step carrying the tool results. Raised from 8 so
|
|
// multi-search research questions are not cut off mid-investigation, then from 20
|
|
// to 50 (#444) so read-heavy turns (e.g. dozens of searchInPage sweeps) do not
|
|
// exhaust the budget before acting.
|
|
const MAX_AGENT_STEPS = 50;
|
|
|
|
// How many steps before the LAST one the step-budget warning starts firing
|
|
// (#444). At MAX-STEP_BUDGET_WARNING_LEAD .. MAX-2 the model is told to stop
|
|
// exploring and start acting, with the remaining count decreasing each step; the
|
|
// last step (MAX-1) has its own final nudge / lockdown instead (see
|
|
// prepareAgentStep).
|
|
const STEP_BUDGET_WARNING_LEAD = 6;
|
|
|
|
// Wall-clock ceiling for building the external MCP toolset during the per-turn
|
|
// setup phase (before streamText owns the lifecycle). Defense-in-depth ABOVE the
|
|
// per-server connect bound in mcp-clients.service (CONNECT_TIMEOUT_MS): even if
|
|
// that per-server timeout regressed, this outer deadline — together with the run's
|
|
// abort signal — guarantees the setup phase can never wedge a turn at step 0 (the
|
|
// production hang) and the run always finalizes. It stays a TRUE backstop because
|
|
// buildEntry connects to the servers CONCURRENTLY, so the total build time is
|
|
// bounded by the SLOWEST single server (~2×CONNECT_TIMEOUT_MS), not the SUM across
|
|
// them — the per-server bound fires first no matter how many servers are enabled,
|
|
// and this outer deadline only catches a total build stall the per-server bound
|
|
// somehow missed.
|
|
const MCP_TOOLSET_BUILD_DEADLINE_MS = 60_000;
|
|
|
|
// System-prompt addendum injected ONLY on the final step (see prepareAgentStep).
|
|
// It forbids further tool calls and tells the model to synthesize the best
|
|
// answer it can from what it already gathered, so a tool-heavy turn never ends
|
|
// empty.
|
|
const FINAL_STEP_INSTRUCTION =
|
|
'You have reached the maximum number of tool-use steps for this turn. ' +
|
|
'Do NOT call any more tools. Using only the information already gathered, ' +
|
|
"write the most complete, useful final answer you can now, in the user's " +
|
|
'language. If the information is incomplete, say so explicitly: summarize ' +
|
|
'what you found, what is still missing, and give your best partial conclusion.';
|
|
|
|
// SOFT final-step nudge (#444), used when the final-step lockdown toggle is OFF
|
|
// (the new default). Unlike FINAL_STEP_INSTRUCTION it does NOT strip tools
|
|
// (toolChoice stays untouched), so the model is never forced into a tool-less
|
|
// state mid-work — that tool-stripping is what triggered the 255KB token-loop
|
|
// degeneration incident. It only asks the model to finish with a text summary.
|
|
const FINAL_STEP_NUDGE =
|
|
'This is the LAST step of this turn. Write your final answer to the user now.\n' +
|
|
'You may still call tools, but the turn ends after this step either way —\n' +
|
|
'prefer finishing with a clear text summary of what was done and what remains.';
|
|
|
|
// Synthetic marker text appended in onFinish when a step-exhausted turn produced
|
|
// NO text at all (#444, mitigates the "empty turn" the lockdown used to prevent
|
|
// when the toggle is OFF). Makes the exhausted-without-answer state explicit to
|
|
// the user and, on replay, to the model on the next turn.
|
|
// The persisted content is the app's base locale (en-US) — which is ALSO the
|
|
// i18n key the client localizes through `t()` — instead of a hardcoded Russian
|
|
// string (it used to render Russian for every locale, and fed Russian back to
|
|
// the model on replay). Keep it a plain, model-readable English sentence so the
|
|
// next turn's replay reads cleanly; the client resolves the locale.
|
|
const STEP_LIMIT_NO_ANSWER_MARKER =
|
|
'(Step limit reached — no final answer was produced; the work may be ' +
|
|
'unfinished. Reply "continue" to let the agent carry on.)';
|
|
|
|
// Reason recorded in ai_chat_runs.error / the assistant row when the token-
|
|
// degeneration detector (#444) aborts a run. Distinct from a user Stop (no error)
|
|
// and from a server restart ('streaming' -> swept to 'aborted' with no message).
|
|
const OUTPUT_DEGENERATION_ERROR =
|
|
'Output degeneration detected (repeated token loop)';
|
|
|
|
// Prefix recorded on the assistant row when the provider rejected the turn for
|
|
// CONTEXT OVERFLOW (#490): the replayed history exceeded the model's window. The
|
|
// row is ALSO stamped `metadata.replayOverflow` so the NEXT turn's budgeter trims
|
|
// aggressively (the reactive recovery — the overflowing turn had no usage signal
|
|
// to trigger preventive trimming, so the classified 400 is what un-bricks it).
|
|
export const CONTEXT_OVERFLOW_ERROR_PREFIX =
|
|
'Диалог превысил контекстное окно модели; история будет агрессивно ' +
|
|
'сокращена на следующем ходу.';
|
|
|
|
/**
|
|
* Compute the step-budget warning text (#444), or '' when this step is outside
|
|
* the warning band. The warning fires on steps
|
|
* MAX_AGENT_STEPS-STEP_BUDGET_WARNING_LEAD .. MAX_AGENT_STEPS-2 (NOT the last
|
|
* step, which has its own final nudge/lockdown), telling the model to stop
|
|
* exploring and start acting. `N` is the number of tool-use steps still
|
|
* remaining (`MAX_AGENT_STEPS - 1 - stepNumber`), so it decreases toward the
|
|
* end. Pure.
|
|
*/
|
|
export function stepBudgetWarning(stepNumber: number): string {
|
|
const isLastStep = stepNumber >= MAX_AGENT_STEPS - 1;
|
|
const inBand = stepNumber >= MAX_AGENT_STEPS - STEP_BUDGET_WARNING_LEAD;
|
|
if (isLastStep || !inBand) return '';
|
|
const remaining = MAX_AGENT_STEPS - 1 - stepNumber;
|
|
return (
|
|
`Only ${remaining} tool-use steps remain in this turn. Stop exploring and start acting now\n` +
|
|
'(make the edits / create the comments / produce results). Leave room to finish\n' +
|
|
'with a final text answer.'
|
|
);
|
|
}
|
|
|
|
// Pure, unit-testable: decide per-step overrides. Responsibilities:
|
|
// 1. Final-step handling. Two modes, chosen by `finalStepLockdownEnabled`:
|
|
// - toggle ON (legacy): on the final allowed step force a text-only
|
|
// synthesis answer (toolChoice 'none' + FINAL_STEP_INSTRUCTION). This WINS
|
|
// — it takes precedence over the deferred-tool narrowing below.
|
|
// - toggle OFF (new default, #444): do NOT touch toolChoice — tools stay
|
|
// available on every step incl. the last, so the model is never stripped
|
|
// of its tools mid-work (the cause of the token-loop degeneration
|
|
// incident). A SOFT nudge (FINAL_STEP_NUDGE) is appended to `system`, and
|
|
// the deferred-tool `activeTools` narrowing still applies to the last step
|
|
// (both `activeTools` and `system` are returned together).
|
|
// 2. Step-budget warning (#444): on steps in the warning band (but not the
|
|
// last, which has its own nudge/lockdown) append stepBudgetWarning(...) to
|
|
// `system` so the model starts acting before it runs out of steps.
|
|
// 3. Deferred tool visibility (#332): when `deferredEnabled`, expose only the
|
|
// CORE tools + loadTools + whatever loadTools has activated so far this turn
|
|
// (`activatedTools`), via `activeTools`. Deferred tools stay in the
|
|
// <tool_catalog> until the model loads them.
|
|
//
|
|
// `system` is the in-scope system prompt; we CONCATENATE so the original
|
|
// persona/context is preserved — a bare `system` override would REPLACE the
|
|
// whole system prompt for the step. `activatedTools` is PER-TURN mutable state
|
|
// owned by the streaming loop (a closure Set grown by loadTools); it is passed
|
|
// in (not module-global, not persisted) so this stays a pure function of its
|
|
// arguments.
|
|
//
|
|
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
|
|
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
|
|
export function prepareAgentStep(
|
|
stepNumber: number,
|
|
system: string,
|
|
activatedTools: ReadonlySet<string> | readonly string[] = [],
|
|
deferredEnabled = false,
|
|
finalStepLockdownEnabled = false,
|
|
):
|
|
| { toolChoice: 'none'; system: string }
|
|
| { activeTools: string[]; system?: string }
|
|
| { system: string }
|
|
| undefined {
|
|
const isLastStep = stepNumber >= MAX_AGENT_STEPS - 1;
|
|
|
|
// Legacy final-step lockdown (toggle ON): text-only synthesis. WINS over the
|
|
// deferred narrowing AND drops tools for this step.
|
|
if (isLastStep && finalStepLockdownEnabled) {
|
|
return {
|
|
toolChoice: 'none',
|
|
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
|
|
};
|
|
}
|
|
|
|
// Compute the extra system text for this step: the soft final nudge on the last
|
|
// step (toggle OFF), or the step-budget warning in the warning band. At most one
|
|
// of these applies (stepBudgetWarning returns '' on the last step).
|
|
const extra = isLastStep ? FINAL_STEP_NUDGE : stepBudgetWarning(stepNumber);
|
|
const systemForStep = extra ? `${system}\n\n${extra}` : undefined;
|
|
|
|
// Deferred tool loading: narrow this step's visible tools to CORE + loadTools +
|
|
// the tools already activated this turn. Applies on EVERY step incl. the last
|
|
// (toggle OFF), so the model keeps its core tools available while being nudged
|
|
// to finish. Return `system` alongside `activeTools` when we have extra text.
|
|
if (deferredEnabled) {
|
|
const activated = Array.isArray(activatedTools)
|
|
? activatedTools
|
|
: [...activatedTools];
|
|
const activeTools = [...CORE_TOOL_KEYS, LOAD_TOOLS_NAME, ...activated];
|
|
return systemForStep ? { activeTools, system: systemForStep } : { activeTools };
|
|
}
|
|
|
|
// Deferred OFF: all tools stay active; only append the extra system text (if any).
|
|
return systemForStep ? { system: systemForStep } : undefined;
|
|
}
|
|
|
|
export {
|
|
MAX_AGENT_STEPS,
|
|
STEP_BUDGET_WARNING_LEAD,
|
|
FINAL_STEP_INSTRUCTION,
|
|
FINAL_STEP_NUDGE,
|
|
STEP_LIMIT_NO_ANSWER_MARKER,
|
|
OUTPUT_DEGENERATION_ERROR,
|
|
};
|
|
|
|
// Pure, unit-testable post-processing for a model-generated title (#199): trim
|
|
// whitespace, strip a single pair of surrounding quotes the model often adds,
|
|
// drop a trailing period, and hard-cap the length to the page-title column.
|
|
export function cleanGeneratedTitle(text: string): string {
|
|
return text
|
|
.trim()
|
|
.replace(/^["']|["']$/g, '')
|
|
.replace(/\.+$/, '')
|
|
.trim()
|
|
.slice(0, 255);
|
|
}
|
|
|
|
/**
|
|
* Pure, unit-testable (#198): decide whether THIS turn is an interrupt-resume,
|
|
* i.e. it directly follows a user interruption of the previous (still-partial)
|
|
* assistant turn. The client "send now" flag is only a HINT — confirm it against
|
|
* the just-loaded history so a spoofed/stale flag cannot inject the interrupt
|
|
* note onto an ordinary turn.
|
|
*
|
|
* `history` is the model history oldest -> newest, with the just-inserted user
|
|
* row as its tail; the turn before it is `history[len-2]`. We treat the new turn
|
|
* as an interrupt-resume only when the client said so AND the preceding assistant
|
|
* turn really ended unfinished: 'aborted' (onAbort already finalized it), or
|
|
* still 'streaming' (onAbort has not finalized yet — the abort/resend race; the
|
|
* partial output is already in history thanks to the step-granular write path).
|
|
*/
|
|
export function isInterruptResume(
|
|
history: Array<{
|
|
role: string;
|
|
status?: string | null;
|
|
metadata?: unknown;
|
|
}>,
|
|
clientInterrupted: boolean | undefined,
|
|
): boolean {
|
|
if (clientInterrupted !== true) return false;
|
|
const prev = history[history.length - 2];
|
|
if (prev?.role !== 'assistant') return false;
|
|
// #487: a reconcile STAMP (metadata.finalizeFailed) is NOT a genuine user
|
|
// interruption — the previous turn's process died and a reconcile settled the
|
|
// row as 'aborted'. Treating it as an interrupt-resume would inject a false
|
|
// "you were interrupted" note. Exclude any finalizeFailed row.
|
|
const meta = prev.metadata as { finalizeFailed?: unknown } | null | undefined;
|
|
if (meta && meta.finalizeFailed === true) return false;
|
|
return prev.status === 'aborted' || prev.status === 'streaming';
|
|
}
|
|
|
|
/**
|
|
* Whether two timestamps refer to the SAME instant (#274 page-change fast path).
|
|
* The snapshot's `pageUpdatedAt` comes back from Postgres as a Date, the live
|
|
* page's `updatedAt` is a Date too; compare by epoch millis so a value that
|
|
* round-tripped through the driver as a string still matches. Either side
|
|
* missing => treat as different (fall through to the diff, never a false skip).
|
|
*/
|
|
export function sameInstant(
|
|
a: Date | string | null | undefined,
|
|
b: Date | string | null | undefined,
|
|
): boolean {
|
|
if (a == null || b == null) return false;
|
|
const ta = new Date(a).getTime();
|
|
const tb = new Date(b).getTime();
|
|
if (Number.isNaN(ta) || Number.isNaN(tb)) return false;
|
|
return ta === tb;
|
|
}
|
|
|
|
/**
|
|
* Race `work` against an abort signal AND a wall-clock deadline, so a hung
|
|
* external-MCP toolset build during the pre-streamText setup phase can NEITHER
|
|
* wedge the turn NOR make it un-stoppable, and the run always finalizes. It
|
|
* - resolves with `work`'s value when it settles first;
|
|
* - REJECTS EARLY if `signal` aborts (with `signal.reason` when that is an Error,
|
|
* else a generic `Error('aborted')`) — so an explicit Stop is honored mid-setup;
|
|
* - REJECTS EARLY if `deadlineMs` elapses (defense-in-depth backstop);
|
|
* - invokes `onLateResolve(value)` when `work` settles AFTER the race was already
|
|
* lost, so the caller can release any resources that abandoned value owns
|
|
* (e.g. close leased MCP clients that would otherwise leak their sockets).
|
|
*
|
|
* A rejection handler is attached to `work` so a late rejection is never an
|
|
* unhandledRejection; the timer is unref'd and cleared once the race settles.
|
|
*/
|
|
export function raceAgainstAbortAndTimeout<T>(
|
|
work: Promise<T>,
|
|
signal: AbortSignal,
|
|
deadlineMs: number,
|
|
onLateResolve?: (value: T) => void,
|
|
): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
let settled = false;
|
|
const cleanup = () => {
|
|
clearTimeout(timer);
|
|
signal.removeEventListener('abort', onAbort);
|
|
};
|
|
const onAbort = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
reject(
|
|
signal.reason instanceof Error ? signal.reason : new Error('aborted'),
|
|
);
|
|
};
|
|
const timer = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
reject(new Error(`setup timed out after ${deadlineMs}ms`));
|
|
}, deadlineMs);
|
|
// Do not keep the process alive just for this setup-deadline timer.
|
|
timer.unref?.();
|
|
if (signal.aborted) {
|
|
onAbort();
|
|
} else {
|
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
}
|
|
work.then(
|
|
(value) => {
|
|
if (settled) {
|
|
// The race was already lost (abort/deadline): hand the abandoned value to
|
|
// the caller so it can release the resources that value owns.
|
|
onLateResolve?.(value);
|
|
return;
|
|
}
|
|
settled = true;
|
|
cleanup();
|
|
resolve(value);
|
|
},
|
|
(err: unknown) => {
|
|
// A late rejection after the race is already handled — swallow so it is
|
|
// never an unhandledRejection.
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
reject(err instanceof Error ? err : new Error(String(err)));
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Payload accepted from the client `useChat` POST body. We do NOT bind a strict
|
|
* DTO (the global ValidationPipe whitelist would strip the useChat-specific
|
|
* fields), so this is a loose shape parsed straight off `req.body`.
|
|
*/
|
|
export interface AiChatStreamBody {
|
|
chatId?: string;
|
|
// The agent role selected by the client. Honoured ONLY when creating a new
|
|
// chat (no valid chatId) — it is persisted to ai_chats.role_id and is
|
|
// immutable afterwards. For existing chats the role is read from the chat row,
|
|
// never from this field, so it cannot be swapped per-turn.
|
|
roleId?: string | null;
|
|
// The page the user is currently viewing (client-supplied), or null on a
|
|
// non-page route. Used ONLY as prompt context so the agent knows what "this
|
|
// page" refers to; the page itself is never fetched server-side here. The id
|
|
// is attacker-controllable but harmless: the agent reads/writes via its
|
|
// CASL-enforced page tools, which 403 on a page the user cannot access.
|
|
//
|
|
// `selection` is the user's editor selection snapshotted client-side at send
|
|
// time (#388). It is CLIENT-controlled and UNTRUSTED — a loose `unknown` here
|
|
// (the body is parsed off req.body without a DTO) that is type-checked and
|
|
// capped by `sanitizeSelection` before it is ever surfaced to the model.
|
|
openPage?: { id?: string; title?: string; selection?: unknown } | null;
|
|
// Set by the client "send now" action (#198): this turn immediately follows a
|
|
// user interruption of the previous turn. A hint only — the server re-confirms
|
|
// it against persisted history (`isInterruptResume`) before injecting the
|
|
// interrupt note, so a spoofed/stale flag on an ordinary turn is ignored.
|
|
interrupted?: boolean;
|
|
// #487: server-side supersede CAS. When present, this POST asks the server to
|
|
// STOP the run `supersede.runId` (which the client saw as the chat's active run)
|
|
// and, once it has settled, start THIS turn in its place. The server validates
|
|
// the target against the chat and answers 400 (wrong chat) / 409
|
|
// SUPERSEDE_TARGET_MISMATCH / 409 SUPERSEDE_TIMEOUT, or proceeds normally
|
|
// (degrade / ready). Absent => an ordinary send (rejected with 409
|
|
// A_RUN_ALREADY_ACTIVE if a run is already active on the chat).
|
|
supersede?: { runId?: string } | null;
|
|
// useChat sends the full UIMessage list; the last one is the new user turn.
|
|
messages?: UIMessage[];
|
|
}
|
|
|
|
/**
|
|
* Optional run-lifecycle hooks (#184 phase 1). When supplied, the turn is wrapped
|
|
* in a first-class server-side RUN: `begin` is called once the chat id is known
|
|
* and returns the run's AbortSignal (decoupled from the HTTP socket — a browser
|
|
* disconnect no longer governs the abort), and the lifecycle callbacks persist
|
|
* the run's progress and terminal status. Absent (the default) => the legacy
|
|
* socket-bound behavior is unchanged.
|
|
*/
|
|
export interface AiChatRunHooks {
|
|
// Called once the chat id is resolved; returns the run handle whose `signal`
|
|
// drives the agent loop's abort. Returning null disables run tracking (the
|
|
// turn falls back to the passed-in socket signal).
|
|
begin(chatId: string): Promise<{ runId: string; signal: AbortSignal } | null>;
|
|
onAssistantSeeded?(
|
|
runId: string,
|
|
assistantMessageId: string,
|
|
): Promise<void> | void;
|
|
onStep?(runId: string, stepCount: number): void;
|
|
onSettled?(
|
|
runId: string,
|
|
status: 'completed' | 'error' | 'aborted',
|
|
error?: string,
|
|
): Promise<void> | void;
|
|
}
|
|
|
|
export interface AiChatStreamArgs {
|
|
user: User;
|
|
workspace: Workspace;
|
|
sessionId: string;
|
|
body: AiChatStreamBody;
|
|
res: FastifyReply;
|
|
signal: AbortSignal;
|
|
// Run-lifecycle hooks (#184). When present the turn becomes a detached,
|
|
// durable RUN whose abort is governed by the run (explicit stop), not the
|
|
// socket; when absent the turn stays socket-bound (legacy behavior).
|
|
runHooks?: AiChatRunHooks;
|
|
// Resolved by the controller BEFORE res.hijack(), so an unconfigured provider
|
|
// (AiNotConfiguredException -> 503) surfaces as clean JSON before streaming.
|
|
// For a role with a model override this already carries the override-resolved
|
|
// model (or the controller threw a 503 if the override driver was unconfigured).
|
|
model: LanguageModel;
|
|
// The agent role to apply this turn, pre-resolved by the controller from the
|
|
// chat row (existing chat) or the request body (new chat). null => universal
|
|
// assistant. Carried here so the turn never re-loads it.
|
|
role: AiAgentRole | null;
|
|
// #487: true when this turn was started by SUPERSEDING a still-live previous run
|
|
// (the controller ran the supersede CAS to a `ready` result). Adds the
|
|
// SUPERSEDE_NOTE to the system prompt (the previous run's last ops may still be
|
|
// applying — no side-effect quiescence). Absent on an ordinary send.
|
|
superseded?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Per-user AI chat orchestration (§6.1/§6.5/§6.7 stage 1).
|
|
*
|
|
* Message persistence shape (ai_chat_messages):
|
|
* - `role` : 'user' | 'assistant'
|
|
* - `content` : the message's plain text (assistant final text; user text).
|
|
* The migration column is `text`, so plain text is stored.
|
|
* - `tool_calls` : jsonb — the assistant's tool steps/calls/results for this
|
|
* turn (trace; also surfaced in the UI as an action log).
|
|
* - `metadata` : jsonb — the assistant message's reconstructable UIMessage
|
|
* `parts` plus finishReason/usage, so multi-turn tool history
|
|
* can be rebuilt for `convertToModelMessages`.
|
|
*/
|
|
@Injectable()
|
|
export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
|
private readonly logger = new Logger(AiChatService.name);
|
|
|
|
constructor(
|
|
private readonly ai: AiService,
|
|
private readonly aiChatRepo: AiChatRepo,
|
|
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
|
private readonly aiChatPageSnapshotRepo: AiChatPageSnapshotRepo,
|
|
private readonly aiSettings: AiSettingsService,
|
|
private readonly tools: AiChatToolsService,
|
|
private readonly mcpClients: McpClientsService,
|
|
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
|
|
private readonly pageRepo: PageRepo,
|
|
private readonly pageAccess: PageAccessService,
|
|
// Reads the AI_CHAT_DEFERRED_TOOLS toggle (#332). Injected last so existing
|
|
// positional constructor callers (tests) only append one stub.
|
|
private readonly environment: EnvironmentService,
|
|
// #184 phase 1.5 run-stream registry. OPTIONAL so existing positional
|
|
// constructions (int-specs) compile unchanged; Nest always injects the real
|
|
// provider in production. Only ever touched on the run-wrapped + flag-on path.
|
|
private readonly streamRegistry?: AiChatStreamRegistryService,
|
|
// #487: the run lifecycle service, for the periodic + opportunistic reconcile
|
|
// (zombie re-drive + stale-run abort). OPTIONAL so positional test
|
|
// constructions compile unchanged; Nest always injects the real singleton, so
|
|
// reconcile sees the SAME in-memory active/zombie maps the runner mutates.
|
|
private readonly aiChatRunService?: AiChatRunService,
|
|
) {}
|
|
|
|
// #487: periodic reconcile timer (single-process phase 1). Started in
|
|
// onModuleInit, cleared in onModuleDestroy.
|
|
private reconcileTimer?: ReturnType<typeof setInterval>;
|
|
|
|
/**
|
|
* Crash-recovery sweep on server start (#183): any assistant row left in the
|
|
* 'streaming' state is the relic of a turn whose process died before it
|
|
* reached a terminal status. Flip those to 'aborted' so history/export show
|
|
* them settled (with whatever finished steps were already persisted) instead
|
|
* of perpetually "streaming". Best-effort: a sweep failure is logged but must
|
|
* never block server startup.
|
|
*/
|
|
async onModuleInit(): Promise<void> {
|
|
try {
|
|
const swept = await this.aiChatMessageRepo.sweepStreaming();
|
|
if (swept > 0) {
|
|
this.logger.log(
|
|
`Startup sweep: marked ${swept} dangling 'streaming' assistant ` +
|
|
`message(s) as 'aborted'.`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Startup sweep of dangling 'streaming' messages failed: ${
|
|
err instanceof Error ? err.message : 'unknown error'
|
|
}`,
|
|
);
|
|
}
|
|
|
|
// #487: start the PERIODIC reconcile (was boot-only). It heals both directions
|
|
// of the run<->message lifecycle asymmetry that a boot sweep alone left to the
|
|
// NEXT restart. Single-process phase 1: the in-memory active/zombie maps are
|
|
// authoritative, so "no live entry" is a safe primary gate.
|
|
const staleMs = this.reconcileStalenessMs();
|
|
// boot-warn if the per-call cap is configured so high the derived staleness is
|
|
// unusually long (a stale run then lingers longer before reconcile aborts it).
|
|
if (staleMs > 30 * 60 * 1000) {
|
|
this.logger.warn(
|
|
`#487 reconcile staleness is ${Math.round(staleMs / 60000)}min ` +
|
|
`(derived from max(2 x per-call cap, 15min)); a per-call cap this high ` +
|
|
`delays stale-run recovery. Review AI_CHAT_INAPP_TOOL_CALL_CAP_MS.`,
|
|
);
|
|
}
|
|
const intervalMs = this.reconcileIntervalMs();
|
|
this.reconcileTimer = setInterval(() => {
|
|
void this.reconcile().catch((err) => {
|
|
this.logger.warn(
|
|
`Periodic reconcile failed: ${
|
|
err instanceof Error ? err.message : 'unknown error'
|
|
}`,
|
|
);
|
|
});
|
|
}, intervalMs);
|
|
this.reconcileTimer.unref?.();
|
|
}
|
|
|
|
/** #487: stop the periodic reconcile timer on shutdown. */
|
|
onModuleDestroy(): void {
|
|
if (this.reconcileTimer) {
|
|
clearInterval(this.reconcileTimer);
|
|
this.reconcileTimer = undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* #487: reconcile staleness threshold X — a run/message is only a "no live
|
|
* runner" abort candidate once UNTOUCHED past this. Derived as
|
|
* max(2 x per-call cap, 15min): 2x the longest legitimate single tool call plus
|
|
* a floor, so a marathon turn making steady progress (updatedAt bumped each
|
|
* step) is never swept.
|
|
*/
|
|
private reconcileStalenessMs(): number {
|
|
return Math.max(2 * inAppToolCallCapMs(), 15 * 60 * 1000);
|
|
}
|
|
|
|
/** #487: how often the periodic reconcile runs (env-tunable, default 2min). */
|
|
private reconcileIntervalMs(): number {
|
|
const raw = Number(process.env.AI_CHAT_RECONCILE_INTERVAL_MS);
|
|
return Number.isFinite(raw) && raw > 0 ? raw : 2 * 60 * 1000;
|
|
}
|
|
|
|
/**
|
|
* #487: the periodic BIDIRECTIONAL reconcile. Runs the clauses IN ORDER; each is
|
|
* best-effort (a failure of one never blocks the others). Single-process phase 1
|
|
* — the run service's in-memory maps are authoritative for "live entry".
|
|
*
|
|
* (a) re-drive ZOMBIE runs (a terminal write that gave up) — apply the intended
|
|
* status via the conditional UPDATE;
|
|
* (b) message 'streaming' + its RUN terminal -> stamp the message by the run's
|
|
* status (succeeded-run + stuck row -> 'aborted'+finalizeFailed, NOT
|
|
* 'completed' with empty parts — the final text lived only in the dead
|
|
* process's memory, a documented loss);
|
|
* (c) run active + NO live entry + NO zombie + stale -> aborted (the run
|
|
* service applies the "no entry" primary gate + last-progress staleness);
|
|
* (d) message 'streaming' + age>X + NO active run on the chat -> aborted
|
|
* (historical-row safety, double-gated).
|
|
*/
|
|
async reconcile(): Promise<void> {
|
|
const staleMs = this.reconcileStalenessMs();
|
|
|
|
// (a) zombie re-drive.
|
|
if (this.aiChatRunService) {
|
|
for (const runId of this.aiChatRunService.zombieRunIds()) {
|
|
try {
|
|
await this.aiChatRunService.settleZombie(runId);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Reconcile (a) zombie ${runId} re-drive failed: ${
|
|
err instanceof Error ? err.message : 'unknown error'
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// (b) message streaming + run terminal -> stamp message by run status.
|
|
try {
|
|
const stuck = await this.aiChatMessageRepo.findStreamingWithTerminalRun();
|
|
for (const s of stuck) {
|
|
// succeeded-run -> 'aborted' (NOT 'completed'-empty); failed -> 'error';
|
|
// aborted -> 'aborted'. All via the finalizeFailed stamp.
|
|
const status = s.runStatus === 'failed' ? 'error' : 'aborted';
|
|
await this.aiChatMessageRepo.stampTerminalIfStreaming(
|
|
s.messageId,
|
|
s.workspaceId,
|
|
status,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Reconcile (b) message<-run failed: ${
|
|
err instanceof Error ? err.message : 'unknown error'
|
|
}`,
|
|
);
|
|
}
|
|
|
|
// (c) stale active run with no live runner -> aborted.
|
|
if (this.aiChatRunService) {
|
|
try {
|
|
await this.aiChatRunService.reconcileStaleRuns(staleMs);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Reconcile (c) stale-run abort failed: ${
|
|
err instanceof Error ? err.message : 'unknown error'
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// (d) historical streaming row, no active run on the chat, stale -> aborted.
|
|
try {
|
|
await this.aiChatMessageRepo.sweepStreamingWithoutActiveRun(staleMs);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Reconcile (d) historical-row sweep failed: ${
|
|
err instanceof Error ? err.message : 'unknown error'
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* #487: OPPORTUNISTIC single-chat reconcile at the start of a turn (beginRun /
|
|
* supersede path), so a user who returns to a chat with a stuck streaming row
|
|
* (its run already terminal) sees it settled WITHOUT waiting for the periodic
|
|
* job. Best-effort — a failure NEVER fails the turn (swallowed by the caller).
|
|
*/
|
|
async reconcileChat(chatId: string, workspaceId: string): Promise<void> {
|
|
const stuck = await this.aiChatMessageRepo.findStreamingWithTerminalRun(50, {
|
|
chatId,
|
|
workspaceId,
|
|
});
|
|
for (const s of stuck) {
|
|
const status = s.runStatus === 'failed' ? 'error' : 'aborted';
|
|
await this.aiChatMessageRepo.stampTerminalIfStreaming(
|
|
s.messageId,
|
|
s.workspaceId,
|
|
status,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the agent role that applies to this stream request, scoped to the
|
|
* workspace and soft-delete aware. For an EXISTING chat the role is read from
|
|
* `ai_chats.role_id` (authoritative — never from the body). For a NEW chat
|
|
* (no valid chatId) the role comes from the request body's `roleId`. Returns
|
|
* null for the universal assistant or when the referenced role is missing /
|
|
* soft-deleted.
|
|
*/
|
|
async resolveRoleForRequest(
|
|
workspace: Workspace,
|
|
body: AiChatStreamBody,
|
|
): Promise<AiAgentRole | null> {
|
|
let roleId: string | null | undefined;
|
|
if (body.chatId) {
|
|
const chat = await this.aiChatRepo.findById(body.chatId, workspace.id);
|
|
// A valid existing chat fixes the role from its own row.
|
|
if (chat) roleId = chat.roleId;
|
|
else roleId = body.roleId; // stale chatId => treated as a new chat
|
|
} else {
|
|
roleId = body.roleId;
|
|
}
|
|
if (!roleId) return null;
|
|
// A disabled or soft-deleted role falls back to the universal assistant: it
|
|
// must not apply its persona/model override even to a chat that was bound to
|
|
// it earlier. findLiveEnabled enforces this (live + enabled + workspace
|
|
// scope), server-authoritatively, for both the new-chat (body.roleId) and
|
|
// existing-chat (chat.role_id) paths — the single shared invariant.
|
|
return (
|
|
(await this.aiAgentRoleRepo.findLiveEnabled(roleId, workspace.id)) ?? null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve the chat language model for the workspace, applying the role's
|
|
* optional model override. Exposed so the controller can resolve it BEFORE
|
|
* res.hijack(): an unconfigured provider (incl. a role pointing at an
|
|
* unconfigured driver) throws AiNotConfiguredException there and returns a
|
|
* clean 503 instead of breaking mid-stream.
|
|
*/
|
|
getChatModel(
|
|
workspaceId: string,
|
|
role?: AiAgentRole | null,
|
|
): Promise<LanguageModel> {
|
|
return this.ai.getChatModel(workspaceId, roleModelOverride(role));
|
|
}
|
|
|
|
/**
|
|
* Validate the client-supplied open page and return its AUTHORITATIVE identity
|
|
* ({ id, title }) or null. The client controls BOTH the id and the title in the
|
|
* request body, so neither is trusted: the id must resolve to a real page in
|
|
* THIS workspace that the user may read, and the title is taken from the DB row
|
|
* (never the client) so the model can't be told it is "on Page A" while the id
|
|
* points at page B (#159). Fail-closed — any missing / foreign / inaccessible
|
|
* page, or any non-Forbidden access-check fault, returns null.
|
|
*/
|
|
private async resolveOpenPageContext(
|
|
openPage:
|
|
| { id?: string; title?: string; selection?: unknown }
|
|
| null
|
|
| undefined,
|
|
workspace: Workspace,
|
|
user: User,
|
|
): Promise<{
|
|
id: string;
|
|
title: string;
|
|
updatedAt: Date;
|
|
selection: SelectionContext | null;
|
|
} | null> {
|
|
const candidatePageId = openPage?.id;
|
|
if (!candidatePageId) return null;
|
|
const page = await this.pageRepo.findById(candidatePageId);
|
|
if (!page || page.workspaceId !== workspace.id) return null;
|
|
try {
|
|
await this.pageAccess.validateCanView(page, user);
|
|
} catch (e) {
|
|
// A ForbiddenException is the expected "user cannot read this page" case;
|
|
// log anything else (e.g. a DB error) so a real fault is not masked.
|
|
if (!(e instanceof ForbiddenException)) {
|
|
this.logger.warn(
|
|
`open page access check failed: ${
|
|
e instanceof Error ? e.message : 'unknown error'
|
|
}`,
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
// updatedAt is the page's last-modified instant, used by the #274 per-turn
|
|
// page-change detection as a cheap fast path (unchanged instant => skip the
|
|
// render + diff). The system-prompt / tool consumers ignore the extra field.
|
|
//
|
|
// The sanitized editor selection (#388) is attached ONLY here, on a
|
|
// successful page resolve: the fail-closed branches above return null for the
|
|
// WHOLE context, so a selection can never outlive a foreign/missing/deleted
|
|
// page (decision 3). Downstream consumers that don't care (detectPageChange,
|
|
// snapshotOpenPage) ignore the extra field, same as updatedAt.
|
|
return {
|
|
id: page.id,
|
|
title: page.title ?? '',
|
|
updatedAt: page.updatedAt,
|
|
selection: sanitizeSelection(openPage?.selection),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Per-turn page-change detection (#274). The agent rebuilds its context from the
|
|
* DB each turn and otherwise cannot tell that the user hand-edited the open page
|
|
* since it last spoke — so it can silently overwrite those edits. This compares
|
|
* the page's CURRENT Markdown against the snapshot taken at the END of the
|
|
* agent's previous turn (see `snapshotOpenPage`) and, when a human changed
|
|
* something in between, returns a `{ title, diff }` the caller feeds to
|
|
* `buildSystemPrompt` as an ephemeral note.
|
|
*
|
|
* Edge cases: page not open / no snapshot (first turn) / page untouched since
|
|
* the snapshot (updatedAt fast path) / empty-after-normalization diff => null
|
|
* (no note). Best-effort: any fault is logged and downgraded to "no note" so it
|
|
* never breaks the turn.
|
|
*/
|
|
private async detectPageChange(
|
|
chatId: string,
|
|
openPageContext: { id: string; title: string; updatedAt: Date } | null,
|
|
workspace: Workspace,
|
|
user: User,
|
|
sessionId: string,
|
|
): Promise<{ title: string; diff: string } | null> {
|
|
if (!openPageContext) return null;
|
|
try {
|
|
const snapshot = await this.aiChatPageSnapshotRepo.findByChatPage(
|
|
chatId,
|
|
openPageContext.id,
|
|
workspace.id,
|
|
);
|
|
// No snapshot yet => first turn on this page; there is nothing to diff
|
|
// against. onFinish seeds it; the note starts from the NEXT turn.
|
|
if (!snapshot) return null;
|
|
// Fast path: the page has not been touched since the snapshot instant, so
|
|
// nothing changed — skip the render + diff entirely.
|
|
if (sameInstant(snapshot.pageUpdatedAt, openPageContext.updatedAt)) {
|
|
return null;
|
|
}
|
|
// Render the current page the SAME way the snapshot end was rendered, so
|
|
// pure formatting never registers as a change.
|
|
const currentMd = await this.tools.exportPageMarkdown(
|
|
user,
|
|
sessionId,
|
|
workspace.id,
|
|
chatId,
|
|
openPageContext.id,
|
|
);
|
|
const change = computePageChange(snapshot.contentMd, currentMd);
|
|
if (!change.changed) return null;
|
|
return {
|
|
title: openPageContext.title || 'Untitled',
|
|
diff: change.diff,
|
|
};
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`page-change detection skipped (chat ${chatId}): ${
|
|
err instanceof Error ? err.message : 'unknown error'
|
|
}`,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write the end-of-turn snapshot for the open page (#274): the page's current
|
|
* Markdown after ALL of the agent's edits this turn, plus the page's
|
|
* updated_at. The agent's own edits are therefore baked into the snapshot, so
|
|
* the next turn's diff isolates exactly what a HUMAN changed in between. Also
|
|
* seeds the snapshot on the first turn. Best-effort — a deleted/foreign page or
|
|
* any fault simply skips the write (no snapshot, no note next turn).
|
|
*
|
|
* Ordering note (deliberate): read updated_at BEFORE exporting, and store that
|
|
* earlier value. This keeps the stored updated_at <= the true version of the
|
|
* stored content, which is the SAFE direction for the fast path: it can only
|
|
* ever be too conservative (force an extra diff), never falsely skip. Concretely
|
|
* — if a user edit lands in the tiny window between the read and the export, the
|
|
* export captures the NEW content while we store the OLDER updated_at; next turn
|
|
* the two updated_ats differ, so the fast path is bypassed and we diff — which
|
|
* resolves to "no change" because that edit is already baked into the stored
|
|
* content. The only cost is not emitting a page_changed note for that specific
|
|
* window edit, which is safe: the snapshot already contains it, so it can never
|
|
* be silently overwritten later.
|
|
*
|
|
* The OPPOSITE order (read updated_at AFTER the export) is what would be unsafe:
|
|
* a concurrent edit's NEWER updated_at would be stored alongside the OLDER
|
|
* exported content, and next turn's fast path would then match on updated_at and
|
|
* SKIP detection while the content genuinely diverged — a real missed edit. So
|
|
* we intentionally do NOT re-read updated_at after the export.
|
|
*/
|
|
private async snapshotOpenPage(
|
|
chatId: string,
|
|
pageId: string,
|
|
workspace: Workspace,
|
|
user: User,
|
|
sessionId: string,
|
|
): Promise<void> {
|
|
try {
|
|
const freshPage = await this.pageRepo.findById(pageId);
|
|
// Page deleted during the turn (or somehow foreign) => don't write.
|
|
if (!freshPage || freshPage.workspaceId !== workspace.id) return;
|
|
// Fast-path (#490): if a snapshot already exists at THIS page version
|
|
// (same updated_at instant), its content is already current — skip the full
|
|
// Markdown export + upsert entirely. A turn that did NOT touch the open page
|
|
// (the common case) thus does no snapshot work. This mirrors the read-side
|
|
// fast path in detectPageChange (sameInstant): both trust that a page edit
|
|
// bumps updated_at. When the agent (or a human) DID edit the page this turn,
|
|
// updated_at advanced, so this does not match and we re-export as before.
|
|
const existing = await this.aiChatPageSnapshotRepo.findByChatPage(
|
|
chatId,
|
|
pageId,
|
|
workspace.id,
|
|
);
|
|
if (existing && sameInstant(existing.pageUpdatedAt, freshPage.updatedAt)) {
|
|
return;
|
|
}
|
|
const currentMd = await this.tools.exportPageMarkdown(
|
|
user,
|
|
sessionId,
|
|
workspace.id,
|
|
chatId,
|
|
pageId,
|
|
);
|
|
await this.aiChatPageSnapshotRepo.upsert({
|
|
chatId,
|
|
pageId,
|
|
workspaceId: workspace.id,
|
|
contentMd: currentMd,
|
|
pageUpdatedAt: freshPage.updatedAt,
|
|
});
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`page snapshot skipped (chat ${chatId}): ${
|
|
err instanceof Error ? err.message : 'unknown error'
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async stream({
|
|
user,
|
|
workspace,
|
|
sessionId,
|
|
body,
|
|
res,
|
|
signal,
|
|
model,
|
|
role,
|
|
runHooks,
|
|
superseded,
|
|
}: AiChatStreamArgs): Promise<void> {
|
|
// Resolve / create the chat. A new chat is created when no valid chatId is
|
|
// 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
|
|
// its title. Resolve it ONCE against the DB (workspace-scoped + access-
|
|
// checked) and use the AUTHORITATIVE identity everywhere below: the system
|
|
// prompt context, the getCurrentPage tool, and the new-chat history origin.
|
|
// Previously the client title was echoed verbatim, so a navigation / two-tab
|
|
// desync (openPage.id -> page B, title -> "Page A") made the model report
|
|
// "updated Page A" while it edited page B (#159). Null when no page is open
|
|
// or the page is foreign / inaccessible / missing.
|
|
const openPageContext = await this.resolveOpenPageContext(
|
|
body.openPage,
|
|
workspace,
|
|
user,
|
|
);
|
|
|
|
if (!chatId) {
|
|
// The history-list origin is the validated open page (see above):
|
|
// persisting an unvalidated id would leak a title via the chat-list join,
|
|
// or violate the page_id FK on insert (this runs after res.hijack(), so a
|
|
// DB error would break the stream).
|
|
const originPageId: string | null = openPageContext?.id ?? null;
|
|
// ORPHAN-ON-BEGIN-FAILURE tradeoff (#486, B3): the chat row is inserted
|
|
// HERE, before runHooks.begin below. If begin fails (e.g. a 503 / run-slot
|
|
// rejection) the turn aborts before the client is told this new chatId, so
|
|
// an empty chat is left behind and a retry mints ANOTHER one. We accept this
|
|
// over reordering: begin needs a chatId to bind the run to, and inserting
|
|
// the chat first keeps the id stable + the FK/history-join invariants above
|
|
// intact. Orphan empty chats are cheap and swept by normal chat cleanup.
|
|
const chat = await this.aiChatRepo.insert({
|
|
creatorId: user.id,
|
|
workspaceId: workspace.id,
|
|
// Bind the chat to the resolved role (if any) at creation time. The role
|
|
// is immutable afterwards (later turns read it from this column).
|
|
roleId: role?.id ?? null,
|
|
// Validated above: a real, readable page in this workspace, else null.
|
|
pageId: originPageId,
|
|
});
|
|
chatId = chat.id;
|
|
isNewChat = true;
|
|
}
|
|
|
|
// Start the durable RUN now that the chat id is known (#184 phase 1). The
|
|
// returned `runId` + `signal` make the turn a first-class server-side object
|
|
// whose abort is governed by the run (an explicit user stop), NOT by the HTTP
|
|
// socket — so a browser disconnect no longer ends the turn. With no runHooks
|
|
// (the default / flag off) the turn stays socket-bound via `signal` and
|
|
// `runId` is undefined, leaving the legacy path byte-for-byte unchanged.
|
|
let runId: string | undefined;
|
|
let effectiveSignal = signal;
|
|
if (runHooks) {
|
|
try {
|
|
const handle = await runHooks.begin(chatId);
|
|
if (handle) {
|
|
runId = handle.runId;
|
|
effectiveSignal = handle.signal;
|
|
}
|
|
} catch (err) {
|
|
// RACE BACKSTOP: the run-row INSERT lost the chat's single active slot
|
|
// (the partial unique index rejected it). This is the AUTHORITATIVE
|
|
// concurrency gate — the controller's pre-check is only a fast-path, and a
|
|
// request that slipped past it must NOT proceed. Reject the turn with a
|
|
// 409 NOW, BEFORE any AI/provider call: no tokens are spent and no
|
|
// untracked turn streams. (Matches the controller's pre-check 409.)
|
|
if (err instanceof RunAlreadyActiveError) {
|
|
throw new ConflictException({
|
|
message: 'An agent run is already in progress for this chat',
|
|
code: 'A_RUN_ALREADY_ACTIVE',
|
|
});
|
|
}
|
|
// Any OTHER run-start failure (e.g. a DB-pool blip) must FAIL THE TURN,
|
|
// not silently stream without a run-row. The old fallback let the turn
|
|
// continue untracked: in autonomous mode nobody could then abort it —
|
|
// /stop can't see a run that doesn't exist, a client disconnect doesn't
|
|
// abort it, and the one-run-per-chat gate would let a SECOND run in. That
|
|
// is an unstoppable, invisible run until process restart. Reject NOW,
|
|
// BEFORE the first byte (nothing is written yet, no user row inserted, no
|
|
// MCP lease taken), so the controller's post-hijack catch turns this
|
|
// HttpException into an honest 503 on the raw socket. Same policy for BOTH
|
|
// modes — #487 inherits it (no mode-branching here).
|
|
this.logger.error(
|
|
`Failed to begin agent run (chat ${chatId}); failing the turn`,
|
|
err as Error,
|
|
);
|
|
throw new ServiceUnavailableException({
|
|
message:
|
|
'Could not start the agent run. This is usually temporary — please try again.',
|
|
code: 'A_RUN_BEGIN_FAILED',
|
|
// Self-describe the status in the body: the controller's post-hijack
|
|
// catch writes getResponse() verbatim onto the raw socket, and an
|
|
// object-arg HttpException does NOT inject statusCode. Without it the
|
|
// client's 503 classifier (which reads the body JSON) could not see the
|
|
// status. With it present, the client's A_RUN_BEGIN_FAILED branch (which
|
|
// runs strictly before the generic-503 branch) shows "temporary, retry".
|
|
statusCode: 503,
|
|
});
|
|
}
|
|
}
|
|
|
|
// #487: opportunistic single-chat reconcile — settle any streaming row on this
|
|
// chat whose run is already terminal BEFORE this turn's history load, so the
|
|
// user never waits on the periodic job and the new turn's model history is not
|
|
// polluted by a stuck 'streaming' row. Best-effort: it must NEVER fail the turn.
|
|
try {
|
|
await this.reconcileChat(chatId, workspace.id);
|
|
} catch (err) {
|
|
this.logger.debug(
|
|
`Opportunistic reconcile for chat ${chatId} failed (ignored): ${
|
|
err instanceof Error ? err.message : 'unknown error'
|
|
}`,
|
|
);
|
|
}
|
|
|
|
try {
|
|
// Extract the incoming user turn (the last user message from useChat).
|
|
const incoming = lastUserMessage(body.messages);
|
|
const incomingText = uiMessageText(incoming);
|
|
|
|
// #489: sanitize client-supplied parts ON RECEIPT. The client only ever
|
|
// sends `sendMessage({ text })` (a single text part); there is no
|
|
// file/attachment path. Any other part — most dangerously a tool-part in
|
|
// `input-available` state — is untrusted data that, once persisted to
|
|
// `metadata.parts` verbatim, is REPLAYED through convertToModelMessages on
|
|
// every later turn. A malformed tool-part makes that conversion throw,
|
|
// 500-ing every future turn of the chat forever ("bricked"). Drop any
|
|
// non-whitelisted part with a warn.
|
|
const sanitizedParts = sanitizeUserParts(incoming?.parts, (type) =>
|
|
this.logger.warn(
|
|
`Dropping unsupported user message part '${type}' on chat ${chatId}`,
|
|
),
|
|
);
|
|
|
|
// #489: rebuild the conversation from persisted history (not the client
|
|
// payload) and CONVERT it to model messages BEFORE persisting the user row.
|
|
// Load the OLD history (WITHOUT the new row) and append the incoming turn in
|
|
// memory for the conversion. This makes the insert happen only after a
|
|
// successful conversion, so a conversion failure cannot leave a DUPLICATE
|
|
// user row behind on the client's retry (the "bricked chat" that accreted a
|
|
// dup on every 500). `findAllByChat` returns chronological order (oldest ->
|
|
// newest) and keeps a 5000-row memory-safety backstop (on overflow it keeps
|
|
// the NEWEST rows and logs a warning); that is a safety net far above any
|
|
// realistic chat, not a conversational limit.
|
|
const oldHistory = await this.aiChatMessageRepo.findAllByChat(
|
|
chatId,
|
|
workspace.id,
|
|
);
|
|
const uiMessages: Array<Omit<UIMessage, 'id'> & { id: string }> = [
|
|
...oldHistory.map(rowToUiMessage),
|
|
{
|
|
id: 'pending-user',
|
|
role: 'user',
|
|
parts: (sanitizedParts && sanitizedParts.length > 0
|
|
? sanitizedParts
|
|
: textPart(incomingText)) as UIMessage['parts'],
|
|
},
|
|
];
|
|
// convertToModelMessages is async in ai@6.0.134 (returns Promise<ModelMessage[]>).
|
|
// Resilient (#489): a single poisoned row in the OLD history is isolated via
|
|
// per-row conversion and degraded to plain text with a "[tool context
|
|
// omitted]" marker rather than 500-ing the whole turn (silent loss of tool
|
|
// context is not acceptable — the model must see the truncation).
|
|
let messages = await convertHistoryResilient(uiMessages, (index, err) =>
|
|
this.logger.warn(
|
|
`Degraded unconvertible history row ${index} on chat ${chatId} to text: ${
|
|
err instanceof Error ? err.message : 'unknown error'
|
|
}`,
|
|
),
|
|
);
|
|
|
|
// Persist the user message only AFTER a successful conversion (#489).
|
|
await this.aiChatMessageRepo.insert({
|
|
chatId,
|
|
workspaceId: workspace.id,
|
|
userId: user.id,
|
|
role: 'user',
|
|
content: incomingText,
|
|
// jsonb column: UIMessage parts are JSON-serializable at runtime but not
|
|
// structurally `JsonValue`, so cast through unknown. Persist the SANITIZED
|
|
// parts (never the raw client parts) so the row is always convertible.
|
|
metadata: (sanitizedParts ? { parts: sanitizedParts } : null) as never,
|
|
});
|
|
|
|
// Interrupt-resume detection (#198): the client "send now" flag is only a
|
|
// hint — confirm it against the persisted history (the preceding assistant
|
|
// turn must really be aborted/streaming) so a spoofed flag cannot inject the
|
|
// interrupt note onto an ordinary turn. The partial output the model needs is
|
|
// already in `messages` (the aborted assistant row replays via findRecent).
|
|
// Append the new user turn (shape-only) so index -2 is the prior assistant.
|
|
const interrupted = isInterruptResume(
|
|
[...oldHistory, { role: 'user', status: null, metadata: null }],
|
|
body.interrupted,
|
|
);
|
|
|
|
// Per-turn page-change detection (#274): if the open page was hand-edited by
|
|
// the user since the agent's last turn ended, compute the unified diff so the
|
|
// system prompt can warn the agent its copy is stale (else it overwrites those
|
|
// edits). Best-effort (null on the fast path / first turn / any fault) — never
|
|
// blocks the turn. Snapshot is (re)written at turn end in onFinish below.
|
|
const pageChanged = await this.detectPageChange(
|
|
chatId,
|
|
openPageContext,
|
|
workspace,
|
|
user,
|
|
sessionId,
|
|
);
|
|
|
|
// The model is resolved by the controller before hijack (clean 503 path).
|
|
// Here we only need the admin-configured system prompt.
|
|
const resolved = await this.aiSettings.resolve(workspace.id);
|
|
|
|
// History-replay token budget (#490). The full conversation is replayed to
|
|
// the provider every turn, so a long chat eventually 400s on the context
|
|
// window — forever. Bound the REPLAYED history (never the persisted rows).
|
|
// PRIMARY signal is the provider's own fact: the last turn's contextTokens.
|
|
const replayBudget = resolveReplayBudget(resolved?.chatContextWindowRaw);
|
|
if (replayBudget.usedDefault) {
|
|
// The default fires precisely for installs with NO configured window —
|
|
// the ones that hit terminal overflow. Warn so it is observable.
|
|
this.logger.warn(
|
|
`AI chat (chat ${chatId}): no chatContextWindow configured; ` +
|
|
`applying the default replay budget (${replayBudget.thresholdTokens} tokens).`,
|
|
);
|
|
}
|
|
// Last turn's provider-reported context size (authoritative when present).
|
|
const priorContextTokens = lastAssistantContextTokens(oldHistory);
|
|
// Reactive recovery (#490): if the LAST turn was rejected for context
|
|
// overflow (stamped by onError), trim AGGRESSIVELY this turn — the
|
|
// overflowing turn produced no usage signal, so a normal-threshold trim may
|
|
// not shrink enough to fit. This is what un-bricks a chat that just 400'd.
|
|
const priorOverflowed = lastAssistantReplayOverflow(oldHistory);
|
|
const effectiveThreshold = resolveEffectiveReplayThreshold(
|
|
replayBudget.thresholdTokens,
|
|
priorOverflowed,
|
|
);
|
|
if (priorOverflowed) {
|
|
this.logger.warn(
|
|
`AI chat (chat ${chatId}): previous turn hit context overflow; ` +
|
|
`applying aggressive replay budget (${effectiveThreshold} tokens).`,
|
|
);
|
|
}
|
|
const preTrim = trimHistoryForReplay(
|
|
messages,
|
|
effectiveThreshold,
|
|
// A prior OVERFLOW means the provider count is stale/absent — force the
|
|
// char-estimate path by ignoring priorContextTokens on recovery.
|
|
priorOverflowed ? undefined : priorContextTokens,
|
|
);
|
|
messages = preTrim.messages;
|
|
// Observability (#490): record the budgeter's decision on the turn so the UI
|
|
// can surface "replay truncated at N tokens". Threaded into flushAssistant.
|
|
let replayTrimmedToTokens: number | undefined = preTrim.trimmed
|
|
? preTrim.estimatedTokens
|
|
: undefined;
|
|
if (preTrim.trimmed) {
|
|
this.logger.log(
|
|
`AI chat (chat ${chatId}): replay history trimmed to ~${preTrim.estimatedTokens} ` +
|
|
`tokens (budget ${replayBudget.thresholdTokens}).`,
|
|
);
|
|
}
|
|
|
|
// Build the external MCP toolset FIRST so the system prompt can carry each
|
|
// connected server's admin-authored guidance (#180). Merge in admin-
|
|
// configured external MCP tools (web search, etc.; §6.8). A down/slow
|
|
// external server never crashes the turn — toolsFor skips it and records the
|
|
// outcome. The returned client handles MUST be closed in the streamText
|
|
// lifecycle (onFinish/onError/onAbort) — leaking them is a bug. Docmost
|
|
// tools take precedence on a name clash (external are namespaced, so a clash
|
|
// is not expected; the spread order makes intent explicit).
|
|
let external: Awaited<ReturnType<McpClientsService['toolsFor']>> = {
|
|
tools: {},
|
|
clients: [],
|
|
outcomes: [],
|
|
instructions: [],
|
|
};
|
|
try {
|
|
// Bound the external-MCP toolset build by BOTH the run's abort signal and
|
|
// a generous wall-clock deadline. This is the pre-streamText setup phase,
|
|
// which streamText's terminal callbacks do NOT yet govern — so without this
|
|
// a hung build would hang the turn at step 0 forever (the production hang),
|
|
// unobservant of an explicit Stop. The deadline is defense-in-depth ABOVE
|
|
// the per-server connect bound in mcp-clients.service. On a LATE resolve
|
|
// (the race was already lost) RELEASE the abandoned toolset's leases —
|
|
// c.close() here is the lease handle, so it decrements the cache entry's
|
|
// refcount; it does NOT force-close the transports (the cache OWNS the
|
|
// clients and closes them on TTL/evict). This just prevents the lease
|
|
// refcount from being pinned >=1 forever by a toolset nobody will consume.
|
|
external = await raceAgainstAbortAndTimeout(
|
|
this.mcpClients.toolsFor(workspace.id),
|
|
effectiveSignal,
|
|
MCP_TOOLSET_BUILD_DEADLINE_MS,
|
|
(late) => {
|
|
void Promise.all(
|
|
late.clients.map((c) => c.close().catch(() => undefined)),
|
|
);
|
|
},
|
|
);
|
|
} catch (err) {
|
|
// An explicit Stop reached the RUN's signal DURING setup: re-throw so the
|
|
// outer catch finalizes the run as aborted — never swallow a Stop. #487: the
|
|
// turn is ALWAYS run-wrapped now (both modes), so `effectiveSignal` is the
|
|
// RUN signal and `runId` is set in BOTH — a Stop (from /ai-chat/stop or a
|
|
// legacy disconnect's requestStop) aborts it identically. The `runId` guard
|
|
// now only defends the theoretical no-handle fallback (`begin` returned
|
|
// nothing, leaving `effectiveSignal` as the bare socket signal): there we
|
|
// keep the old warn-and-proceed rather than re-throw.
|
|
if (runId && effectiveSignal.aborted) {
|
|
throw err;
|
|
}
|
|
// Otherwise a down/slow server (build timeout or other fault) must never
|
|
// break the turn: proceed with Docmost-only tools. Never log URLs/headers —
|
|
// short message only.
|
|
this.logger.warn(
|
|
`External MCP toolset unavailable: ${
|
|
err instanceof Error ? err.message : 'unknown error'
|
|
}`,
|
|
);
|
|
}
|
|
|
|
// Close every external client EXACTLY ONCE across the turn's terminal
|
|
// callbacks (onFinish/onError/onAbort all fire at most once collectively,
|
|
// but guard anyway). DEFINED HERE — before the prompt/toolset are built — so
|
|
// that if buildSystemPrompt or forUser throws AFTER the external lease was
|
|
// taken (toolsFor above), the lease is still released. Otherwise its refCount
|
|
// stays >= 1 forever and the external undici sockets leak until restart
|
|
// (#180 reorder moved toolsFor ahead of these; #185 review). Close errors are
|
|
// swallowed so they never break the response.
|
|
let clientsClosed = false;
|
|
const closeExternalClients = async (): Promise<void> => {
|
|
if (clientsClosed) return;
|
|
clientsClosed = true;
|
|
await Promise.all(
|
|
external.clients.map((c) =>
|
|
c.close().catch((closeErr) => {
|
|
this.logger.warn(
|
|
`Failed to close external MCP client: ${
|
|
closeErr instanceof Error ? closeErr.message : 'unknown error'
|
|
}`,
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
};
|
|
|
|
// Turn-end snapshot of the open page (#274), run EXACTLY ONCE across the
|
|
// terminal callbacks. This MUST run on onError/onAbort too, not only on the
|
|
// successful onFinish: the write tools commit page edits to the DB
|
|
// synchronously during a step, so an agent edit followed by an abort/error
|
|
// (client disconnect, stop(), provider failure) still persists and bumps
|
|
// page.updatedAt. If the snapshot did not advance on those paths, the NEXT
|
|
// turn would diff the agent's OWN committed edit against the stale previous
|
|
// snapshot and mis-report it as a user edit — breaking the "own edits excluded
|
|
// by construction" guarantee. Best-effort (snapshotOpenPage swallows + logs);
|
|
// skipped when no page is open.
|
|
let snapshotWritten = false;
|
|
const snapshotTurnEnd = async (): Promise<void> => {
|
|
if (snapshotWritten) return;
|
|
snapshotWritten = true;
|
|
if (!openPageContext) return;
|
|
await this.snapshotOpenPage(
|
|
chatId,
|
|
openPageContext.id,
|
|
workspace,
|
|
user,
|
|
sessionId,
|
|
);
|
|
};
|
|
|
|
// Build the system prompt + Docmost toolset. If either throws after the
|
|
// external MCP lease was taken above, release the lease before rethrowing so
|
|
// the leased transports are not leaked (#185 review).
|
|
// Deferred tool loading toggle (#332). When ON, the model sees a compact
|
|
// <tool_catalog> and only CORE tools + loadTools are active each step; other
|
|
// tools (fat/rare in-app tools + ALL external MCP tools) load on demand. When
|
|
// OFF, every tool is active and nothing below changes.
|
|
const deferredEnabled = this.environment.isAiChatDeferredToolsEnabled();
|
|
// Final-step lockdown toggle (#444). Default OFF: the last step keeps its
|
|
// tools and gets only a soft nudge (prepareAgentStep), and the token-
|
|
// degeneration detector (onChunk below) is the anti-babble guard. ON =
|
|
// legacy tool-stripping lockdown on the last step.
|
|
const finalStepLockdownEnabled =
|
|
this.environment.isAiChatFinalStepLockdownEnabled();
|
|
|
|
let system: string;
|
|
let docmostTools: Awaited<ReturnType<AiChatToolsService['forUser']>>;
|
|
try {
|
|
// Assemble the deferred catalog for the system prompt: hand-written lines
|
|
// for the in-app deferred tools + a derived line for each external MCP tool
|
|
// (also deferred by default). Only built when the feature is enabled.
|
|
const toolCatalog = deferredEnabled
|
|
? [
|
|
...(await this.tools.getInAppDeferredCatalog()),
|
|
...buildExternalToolCatalog(external.tools),
|
|
]
|
|
: [];
|
|
|
|
system = buildSystemPrompt({
|
|
workspace,
|
|
adminPrompt: resolved?.systemPrompt,
|
|
// The role (pre-resolved by the controller) REPLACES the persona layer;
|
|
// the safety framework is still appended by buildSystemPrompt.
|
|
roleInstructions: role?.instructions,
|
|
// Server-validated open page (authoritative title), not the client value.
|
|
openedPage: openPageContext,
|
|
// Guidance only for servers that connected and yielded ≥1 callable tool.
|
|
mcpInstructions: external.instructions,
|
|
// History-confirmed interrupt-resume flag (#198): adds the interrupt note
|
|
// so the model treats the partial answer above as cut off, not finished.
|
|
interrupted,
|
|
// #487: this turn superseded a still-live run — warn the model the
|
|
// previous run's last ops may still be applying (no quiescence).
|
|
superseded,
|
|
// Detected between-turns human edit to the open page (#274): adds the
|
|
// page_changed note + unified diff so the agent doesn't overwrite it.
|
|
pageChanged,
|
|
// Deferred tool loading (#332): renders the <tool_catalog> block (only
|
|
// when enabled + non-empty) so the model can activate deferred tools.
|
|
deferredToolsEnabled: deferredEnabled,
|
|
toolCatalog,
|
|
});
|
|
|
|
// Pass the resolved chatId so the write tools can mint provenance tokens
|
|
// (access + collab) carrying { actor:'agent', aiChatId: chatId }, making
|
|
// agent REST/collab writes attributable and non-spoofable (§6.5/§6.6).
|
|
docmostTools = await this.tools.forUser(
|
|
user,
|
|
sessionId,
|
|
workspace.id,
|
|
chatId,
|
|
// Same server-validated open page used by the system prompt above;
|
|
// exposed to the model via getCurrentPage so page identity (and the
|
|
// AUTHORITATIVE title) survives prompt mangling / client title spoofing.
|
|
openPageContext,
|
|
);
|
|
} catch (err) {
|
|
await closeExternalClients();
|
|
throw err;
|
|
}
|
|
|
|
// Base toolset: external MCP tools + Docmost in-app tools (Docmost wins on a
|
|
// name clash — external are namespaced, so no clash is expected).
|
|
const baseTools = { ...external.tools, ...docmostTools };
|
|
|
|
// Deferred tool loading state (#332), scoped to THIS streaming loop:
|
|
// - `activatedTools` is per-TURN mutable state — a fresh closure Set created
|
|
// per streamText call, NOT module-global and NOT persisted, so a new turn
|
|
// starts cold. loadTools.execute adds to it; prepareAgentStep reads it to
|
|
// widen `activeTools` on the NEXT step.
|
|
// - `validDeferredNames` = every tool that is NOT core (the in-app deferred
|
|
// 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 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
|
|
? {
|
|
...baseTools,
|
|
[LOAD_TOOLS_NAME]: makeLoadToolsTool(activatedTools, validDeferredNames),
|
|
}
|
|
: 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(' |