LibreChat/packages/api/src/agents/initialize.ts
Danny Avila 68eac104ad
🗂️ fix: Scope Handoff Agent Context Docs (#13167)
* fix: Scope agent context docs to handoff agents

* fix: Deduplicate scoped request context

* refactor: Extract agent attachment helpers
2026-05-18 15:36:22 -04:00

1029 lines
40 KiB
TypeScript

import { Providers } from '@librechat/agents';
import { logger } from '@librechat/data-schemas';
import {
Tools,
Constants,
ErrorTypes,
EModelEndpoint,
EToolResources,
paramEndpoints,
isAgentsEndpoint,
replaceSpecialVars,
providerEndpointMap,
} from 'librechat-data-provider';
import type {
AgentToolResources,
AgentToolOptions,
TEndpointOption,
TFile,
Agent,
TUser,
} from 'librechat-data-provider';
import type { GenericTool, LCToolRegistry, ToolMap, LCTool } from '@librechat/agents';
import type { Response as ServerResponse } from 'express';
import type { IMongoFile } from '@librechat/data-schemas';
import type { InitializeResultBase, ServerRequest, EndpointDbMethods } from '~/types';
import {
optionalChainWithEmptyCheck,
extractLibreChatParams,
getModelMaxTokens,
getThreadData,
} from '~/utils';
import { filterFilesByEndpointConfig } from '~/files';
import { generateArtifactsPrompt } from '~/prompts';
import { getProviderConfig } from '~/endpoints';
import {
injectSkillCatalog,
resolveManualSkills,
resolveAlwaysApplySkills,
unionPrimeAllowedTools,
MAX_PRIMED_SKILLS_PER_TURN,
} from './skills';
import { registerCodeExecutionTools } from './tools';
import { primeResources } from './resources';
import type { ResolvedManualSkill, ResolvedAlwaysApplySkill } from './skills';
import type { TFilterFilesByAgentAccess } from './resources';
/**
* Fraction of context budget reserved as headroom when no explicit maxContextTokens is set.
* Reduced from 0.10 to 0.05 alongside the introduction of summarization, which actively
* manages overflow. `createRun` can further override this via `SummarizationConfig.reserveRatio`.
*/
const DEFAULT_RESERVE_RATIO = 0.05;
const temporalSpecialVarRegex = /{{\s*(current_date|current_datetime|iso_datetime)\s*}}/i;
function hasTemporalSpecialVars(text: string): boolean {
return temporalSpecialVarRegex.test(text);
}
function appendAdditionalInstructions(agent: Agent, text?: string | null): void {
if (text == null || text === '') {
return;
}
agent.additional_instructions = [agent.additional_instructions ?? '', text]
.filter(Boolean)
.join('\n\n');
}
function getToolName(tool: unknown): string | undefined {
if (tool == null || typeof tool !== 'object') {
return undefined;
}
const { name } = tool as { name?: unknown };
return typeof name === 'string' ? name : undefined;
}
function hasToolDefinition(toolDefinitions: LCTool[] | undefined, name: string): boolean {
return toolDefinitions?.some((toolDefinition) => toolDefinition.name === name) === true;
}
function resolveAnthropicToolConflicts({
provider,
tools,
toolDefinitions,
}: {
provider?: string;
tools?: unknown[];
toolDefinitions?: LCTool[];
}): unknown[] | undefined {
if (provider !== Providers.ANTHROPIC || !tools?.length) {
return tools;
}
if (!hasToolDefinition(toolDefinitions, Tools.web_search)) {
return tools;
}
let removed = 0;
const resolvedTools = tools.filter((tool) => {
const shouldRemove = getToolName(tool) === Tools.web_search;
if (shouldRemove) {
removed += 1;
}
return !shouldRemove;
});
if (removed > 0) {
logger.debug(
`[initializeAgent] Removed ${removed} Anthropic native web_search tool(s); LibreChat web_search is enabled.`,
);
}
return resolvedTools;
}
/**
* Extended agent type with additional fields needed after initialization
*/
export type InitializedAgent = Agent & {
tools: GenericTool[];
/** @deprecated use requestAttachments or agentContextAttachments based on sharing semantics. */
attachments: IMongoFile[];
/** Files attached to the current user message/run and safe to share across run agents. */
requestAttachments: IMongoFile[];
/** Files attached to this agent's permanent context via tool_resources. */
agentContextAttachments: IMongoFile[];
toolContextMap: Record<string, unknown>;
dynamicToolContextMap?: Record<string, unknown>;
maxContextTokens: number;
/** Pre-ratio context budget (agentMaxContextNum - maxOutputTokensNum). Used by createRun to apply a configurable reserve ratio. */
baseContextTokens?: number;
useLegacyContent: boolean;
resendFiles: boolean;
tool_resources?: AgentToolResources;
userMCPAuthMap?: Record<string, Record<string, string>>;
/** Tool map for ToolNode to use when executing tools (required for PTC) */
toolMap?: ToolMap;
/** Tool registry for PTC and tool search (only present when MCP tools with env classification exist) */
toolRegistry?: LCToolRegistry;
/** Serializable tool definitions for event-driven execution */
toolDefinitions?: LCTool[];
/** Precomputed flag indicating if any tools have defer_loading enabled (for efficient runtime checks) */
hasDeferredTools?: boolean;
/** Whether the actions capability is enabled (resolved during tool loading) */
actionsEnabled?: boolean;
/** Maximum characters allowed in a single tool result before truncation. */
maxToolResultChars?: number;
/**
* Whether the code-execution environment is available *for this agent*.
* Narrower than the incoming `params.codeEnvAvailable` admin flag — this
* is `admin_capability_enabled && agent.tools.includes('execute_code')`,
* computed once here so downstream code (`injectSkillCatalog`,
* `enrichWithSkillConfigurable`, `primeInvokedSkills`) doesn't have to
* re-scan the tool list on every runtime handler invocation.
* Authoritative for both persisted and ephemeral agents: the
* ephemeral-agent toggle is reconciled into `agent.tools` upstream
* (`packages/api/src/agents/added.ts`), so the check is uniform.
*/
codeEnvAvailable: boolean;
/** Accessible skill IDs for ACL checking at execute time */
accessibleSkillIds?: import('mongoose').Types.ObjectId[];
/**
* Names of skills the runtime can resolve, mirroring `accessibleSkillIds`.
* Surfaced to the runtime so handlers like `read_file` can decide if a
* `{firstSegment}/...` path refers to a real skill (vs. a code-env path
* that should be routed to the bash fallback) without an extra DB lookup.
*/
activeSkillNames?: Set<string>;
/** Number of skills in the catalog (used to determine if SkillTool should be registered) */
skillCount?: number;
/**
* Skills the user manually invoked for this turn via the `$` popover, resolved
* to their SKILL.md bodies. The AgentClient injects these as meta user
* messages right before the latest user message in the LLM's formatted
* message array — deterministic priming without a tool roundtrip.
*/
manualSkillPrimes?: ResolvedManualSkill[];
/**
* Skills auto-primed this turn because their `always-apply` frontmatter
* flag is set. Resolved against the same `accessibleSkillIds` set and
* subjected to the same active-state / ACL filters as the catalog, then
* handed to the AgentClient for splicing alongside manual primes. Their
* `allowedTools` entries also union into the agent's effective tool set
* via `unionPrimeAllowedTools` (same pipeline as manual primes).
*/
alwaysApplySkillPrimes?: ResolvedAlwaysApplySkill[];
/**
* Pre-uploaded code-env file refs from `tool_resources.execute_code`
* (carries the conversation's prior-turn generated artifacts and any
* user uploads). Captured by the `loadTools` callback; the AgentClient
* merges these across all run agents into `Graph.sessions[EXECUTE_CODE]`
* before run start so the very first `execute_code` / `bash_tool` call
* sees them as `_injected_files`. Without this seed the agents-side
* `CodeExecutor` falls back to `/files/{session_id}` — but `session_id`
* is itself only populated by a previous successful execution, so on
* call #1 the sandbox can't see the files at all.
*/
primedCodeFiles?: import('@librechat/agents').CodeEnvFile[];
};
export const DEFAULT_MAX_CONTEXT_TOKENS = 32000;
/**
* Parameters for initializing an agent
* Matches the CJS signature from api/server/services/Endpoints/agents/agent.js
*/
export interface InitializeAgentParams {
/** Request object */
req: ServerRequest;
/** Response object */
res: ServerResponse;
/** Agent to initialize */
agent: Agent;
/** Conversation ID (optional) */
conversationId?: string | null;
/** Parent message ID for determining the current thread (optional) */
parentMessageId?: string | null;
/** Request files */
requestFiles?: IMongoFile[];
/** Function to load agent tools */
loadTools?: (params: {
req: ServerRequest;
res: ServerResponse;
provider: string;
agentId: string;
tools: string[];
model: string | null;
tool_options: AgentToolOptions | undefined;
tool_resources: AgentToolResources | undefined;
}) => Promise<{
/** Full tool instances (only present when definitionsOnly=false) */
tools?: GenericTool[];
toolContextMap?: Record<string, unknown>;
dynamicToolContextMap?: Record<string, unknown>;
userMCPAuthMap?: Record<string, Record<string, string>>;
toolRegistry?: LCToolRegistry;
/** Serializable tool definitions for event-driven mode */
toolDefinitions?: LCTool[];
hasDeferredTools?: boolean;
actionsEnabled?: boolean;
/**
* Pre-uploaded code-env file refs for the agent's
* `tool_resources.execute_code`. Bubbled up so the run host can seed
* `Graph.sessions[EXECUTE_CODE]` before the first tool call —
* otherwise `_injected_files` is empty on call #1 and prior-turn
* artifacts don't reach the sandbox.
*/
primedCodeFiles?: import('@librechat/agents').CodeEnvFile[];
} | null>;
/** Endpoint option (contains model_parameters and endpoint info) */
endpointOption?: Partial<TEndpointOption>;
/** Set of allowed providers */
allowedProviders: Set<string>;
/** Whether this is the initial agent */
isInitialAgent?: boolean;
/** Accessible skill IDs for this user (pre-computed by the caller via ACL query) */
accessibleSkillIds?: import('mongoose').Types.ObjectId[];
/** Whether the code execution environment is available (execute_code capability enabled) */
codeEnvAvailable?: boolean;
/** Per-user skill active/inactive overrides for filtering the skill catalog. */
skillStates?: Record<string, boolean>;
/** Admin-configured default for shared skills (`true` = shared skills auto-activate). */
defaultActiveOnShare?: boolean;
/**
* Skill names the user invoked manually for this turn via the `$` popover.
* Resolved here (ACL + active-state filtered) and attached to the returned
* InitializedAgent as `manualSkillPrimes` for the AgentClient to inject as
* meta user messages before the LLM call.
*/
manualSkills?: string[];
}
/**
* Database methods required for agent initialization
* Most methods come from data-schemas via createMethods()
* getConvoFiles not yet in data-schemas but included here for consistency
*/
export interface InitializeAgentDbMethods extends EndpointDbMethods {
/** Update usage tracking for multiple files */
updateFilesUsage: (files: Array<{ file_id: string }>, fileIds?: string[]) => Promise<unknown[]>;
/** Get files from database */
getFiles: (filter: unknown, sort: unknown, select: unknown) => Promise<unknown[]>;
/** Filter files by agent access permissions (ownership or agent attachment) */
filterFilesByAgentAccess?: TFilterFilesByAgentAccess;
/** Get tool files by IDs (user-uploaded files only, code files handled separately) */
getToolFilesByIds: (fileIds: string[], toolSet: Set<EToolResources>) => Promise<unknown[]>;
/** Get conversation file IDs */
getConvoFiles: (conversationId: string) => Promise<string[] | null>;
/** Get code-generated files by conversation ID and the file_ids
* referenced from messages in the current thread (collected via
* `messages.files[].file_id` during thread walk). */
getCodeGeneratedFiles?: (conversationId: string, threadFileIds?: string[]) => Promise<unknown[]>;
/** Get user-uploaded execute_code files by file IDs (from message.files in thread) */
getUserCodeFiles?: (fileIds: string[]) => Promise<unknown[]>;
/** Get messages for a conversation (supports select for field projection) */
getMessages?: (
filter: { conversationId: string },
select?: string,
) => Promise<Array<{
messageId: string;
parentMessageId?: string;
files?: Array<{ file_id: string }>;
}> | null>;
/** List skill summaries for catalog injection (paginated, omits body/frontmatter) */
listSkillsByAccess?: (params: {
accessibleIds: import('mongoose').Types.ObjectId[];
limit: number;
cursor?: string | null;
}) => Promise<{
skills: Array<{
_id: import('mongoose').Types.ObjectId;
name: string;
description: string;
author: import('mongoose').Types.ObjectId;
/**
* When `true`, the skill is excluded from the catalog injected into
* the agent's additional_instructions and the model cannot invoke it
* via the `skill` tool. Manual `$` invocation is unaffected.
*/
disableModelInvocation?: boolean;
/**
* When `false`, the skill is hidden from the `$` popover and rejected
* by the manual-invocation resolver. Defaults to `true`.
*/
userInvocable?: boolean;
}>;
has_more?: boolean;
after?: string | null;
}>;
/**
* Load a single skill by name, constrained to an ACL-accessible ID set.
* Returns the full document (including `body`) so manual invocation can
* prime SKILL.md without a second DB round-trip.
*
* `preferUserInvocable` (manual paths): on a same-name collision,
* prefer the newest doc with `userInvocable !== false`.
* `preferModelInvocable` (model paths — `skill` / `read_file`): on a
* same-name collision, prefer the newest doc with
* `disableModelInvocation !== true`. Both fall back to the newest match
* so the explicit-rejection error paths still fire when only the
* non-preferred variant exists.
*/
getSkillByName?: (
name: string,
accessibleIds: import('mongoose').Types.ObjectId[],
options?: { preferUserInvocable?: boolean; preferModelInvocable?: boolean },
) => Promise<{
_id: import('mongoose').Types.ObjectId;
name: string;
body: string;
author: import('mongoose').Types.ObjectId;
/**
* Skill-declared tool allowlist, forwarded verbatim from the skill doc.
* Surfaced so the resolver can carry it onto `ResolvedManualSkill` for
* future runtime enforcement without a second round-trip.
*/
allowedTools?: string[];
/**
* Set when the skill was authored with `disable-model-invocation: true`.
* The skill tool handler short-circuits on this so a model that names
* such a skill (e.g. via hallucination or stale catalog) gets a clear
* rejection instead of silently executing.
*/
disableModelInvocation?: boolean;
/**
* Set when the skill was authored with `user-invocable: false`. The
* manual-invocation resolver skips with a warn log so an API-direct
* caller can't bypass the popover-side filter.
*/
userInvocable?: boolean;
} | null>;
/**
* Load accessible skills with `alwaysApply: true`, eagerly including
* `body` so the priming pipeline can splice at turn start without a
* per-skill round-trip. Cursor-paginated so the resolver can fill its
* active-state budget even when early-sorted rows are inactive for
* the current user.
*/
listAlwaysApplySkills?: (params: {
accessibleIds: import('mongoose').Types.ObjectId[];
limit: number;
cursor?: string | null;
}) => Promise<{
skills: Array<{
_id: import('mongoose').Types.ObjectId;
name: string;
body: string;
author: import('mongoose').Types.ObjectId;
allowedTools?: string[];
}>;
has_more?: boolean;
after?: string | null;
}>;
}
/**
* Initializes an agent for use in requests.
* Handles file processing, tool loading, provider configuration, and context token calculations.
*
* This function is exported from @librechat/api and replaces the CJS version from
* api/server/services/Endpoints/agents/agent.js
*
* @param params - Initialization parameters
* @param deps - Optional dependency injection for testing
* @returns Promise resolving to initialized agent with tools and configuration
* @throws Error if agent provider is not allowed or if required dependencies are missing
*/
export async function initializeAgent(
params: InitializeAgentParams,
db?: InitializeAgentDbMethods,
): Promise<InitializedAgent> {
const {
req,
res,
agent,
loadTools,
requestFiles = [],
conversationId,
endpointOption,
parentMessageId,
allowedProviders,
isInitialAgent = false,
} = params;
if (!db) {
throw new Error('initializeAgent requires db methods to be passed');
}
if (
isAgentsEndpoint(endpointOption?.endpoint) &&
allowedProviders.size > 0 &&
!allowedProviders.has(agent.provider)
) {
throw new Error(
`{ "type": "${ErrorTypes.INVALID_AGENT_PROVIDER}", "info": "${agent.provider}" }`,
);
}
let currentFiles: IMongoFile[] | undefined;
const _modelOptions = structuredClone(
Object.assign(
{ model: agent.model },
agent.model_parameters ?? { model: agent.model },
isInitialAgent === true ? endpointOption?.model_parameters : {},
),
);
const { resendFiles, maxContextTokens, modelOptions } = extractLibreChatParams(
_modelOptions as Record<string, unknown>,
);
const provider = agent.provider;
agent.endpoint = provider;
/**
* Load conversation files for ALL agents, not just the initial agent.
* This enables handoff agents to access files that were uploaded earlier
* in the conversation. Without this, file_search and execute_code tools
* on handoff agents would fail to find previously attached files.
*/
if (conversationId != null && resendFiles) {
const fileIds = (await db.getConvoFiles(conversationId)) ?? [];
const toolResourceSet = new Set<EToolResources>();
for (const tool of agent.tools ?? []) {
if (EToolResources[tool as keyof typeof EToolResources]) {
toolResourceSet.add(EToolResources[tool as keyof typeof EToolResources]);
}
}
const toolFiles = (await db.getToolFilesByIds(fileIds, toolResourceSet)) as IMongoFile[];
/**
* Retrieve execute_code files filtered to the current thread.
* This includes both code-generated files and user-uploaded execute_code files.
*/
let codeGeneratedFiles: IMongoFile[] = [];
let userCodeFiles: IMongoFile[] = [];
if (toolResourceSet.has(EToolResources.execute_code)) {
let threadFileIds: string[] | undefined;
if (parentMessageId && parentMessageId !== Constants.NO_PARENT && db.getMessages) {
/** Only select fields needed for thread traversal. Both
* `files` (user uploads) and `attachments` (code-execution
* outputs from `processCodeOutput`) carry the `file_id`
* refs the next turn must prime — selecting only `files`
* silently drops every code-output ref. */
const messages = await db.getMessages(
{ conversationId },
'messageId parentMessageId files attachments',
);
if (messages && messages.length > 0) {
/** Walk the parent chain and collect file_ids referenced by
* any message in the thread (`messages.files[].file_id` +
* `messages.attachments[].file_id`). Used as the primary
* anchor for both `getCodeGeneratedFiles` and
* `getUserCodeFiles` — message ids no longer needed at
* this layer. */
threadFileIds = getThreadData(messages, parentMessageId).fileIds;
}
}
/** Code-generated and user-uploaded execute_code files share the
* same primary anchor: file_ids referenced by messages in the
* current thread. The two queries differ only by `context`
* (`execute_code` for generated outputs, others for uploads).
* Anchoring both on `threadFileIds` reaches files regardless of
* which sibling first generated them — see `getCodeGeneratedFiles`
* for the branched-conversation rationale. */
if (db.getCodeGeneratedFiles) {
codeGeneratedFiles = (await db.getCodeGeneratedFiles(
conversationId,
threadFileIds,
)) as IMongoFile[];
}
if (db.getUserCodeFiles && threadFileIds && threadFileIds.length > 0) {
userCodeFiles = (await db.getUserCodeFiles(threadFileIds)) as IMongoFile[];
}
}
const allToolFiles = toolFiles.concat(codeGeneratedFiles, userCodeFiles);
if (requestFiles.length || allToolFiles.length) {
currentFiles = (await db.updateFilesUsage(requestFiles.concat(allToolFiles))) as IMongoFile[];
}
} else if (requestFiles.length) {
currentFiles = (await db.updateFilesUsage(requestFiles)) as IMongoFile[];
}
if (currentFiles && currentFiles.length) {
let endpointType: EModelEndpoint | undefined;
if (!paramEndpoints.has(agent.endpoint ?? '')) {
endpointType = EModelEndpoint.custom;
}
currentFiles = filterFilesByEndpointConfig(req, {
files: currentFiles,
endpoint: agent.endpoint ?? '',
endpointType,
});
}
const {
attachments: primedAttachments,
requestAttachments: primedRequestAttachments,
agentContextAttachments: primedAgentContextAttachments,
tool_resources,
} = await primeResources({
req: req as never,
getFiles: db.getFiles as never,
filterFiles: db.filterFilesByAgentAccess,
appConfig: req.config,
agentId: agent.id,
attachments: currentFiles
? (Promise.resolve(currentFiles) as unknown as Promise<TFile[]>)
: undefined,
tool_resources: agent.tool_resources,
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
});
/**
* Pre-resolve manually-invoked + always-apply skill primes so their
* `allowed-tools` can be unioned into the agent's effective tool set
* BEFORE `loadTools` runs. Single load is correctness-critical: a
* second `loadTools` pass would compute its own `userMCPAuthMap` /
* `toolContextMap` / OAuth flow state that the InitializedAgent never
* sees, so an MCP tool added via `allowed-tools` would be visible to
* the model but fail at execution time without its per-user auth
* context.
*
* Resolution uses `params.accessibleSkillIds` (not the active-filtered
* subset that `injectSkillCatalog` will produce later) — see
* `resolveManualSkills` doc for why a skill outside the catalog cap can
* still be authorizable for direct manual invocation.
*
* Manual + always-apply primes feed the same `unionPrimeAllowedTools`
* call — the helper is pure / set-based, so concatenating the two
* lists gives the right union with no double-counting. Manual primes
* go first so their names win on dedup (primes earlier in the list
* contribute before the same name gets deduped on a later prime).
*/
const hasSkillAccess = params.accessibleSkillIds && params.accessibleSkillIds.length > 0;
let manualSkillPrimes: ResolvedManualSkill[] | undefined;
let alwaysApplySkillPrimes: ResolvedAlwaysApplySkill[] | undefined;
let extraAllowedToolNames: string[] = [];
let perSkillExtras: Map<string, string[]> = new Map();
if (hasSkillAccess) {
const [manualPrimesResult, alwaysApplyPrimesResult] = await Promise.all([
params.manualSkills?.length && db.getSkillByName
? resolveManualSkills({
names: params.manualSkills,
getSkillByName: db.getSkillByName,
accessibleSkillIds: params.accessibleSkillIds!,
userId: req.user?.id,
skillStates: params.skillStates,
defaultActiveOnShare: params.defaultActiveOnShare,
})
: Promise.resolve<ResolvedManualSkill[] | undefined>(undefined),
db.listAlwaysApplySkills
? resolveAlwaysApplySkills({
listAlwaysApplySkills: db.listAlwaysApplySkills,
accessibleSkillIds: params.accessibleSkillIds!,
userId: req.user?.id,
skillStates: params.skillStates,
defaultActiveOnShare: params.defaultActiveOnShare,
})
: Promise.resolve<ResolvedAlwaysApplySkill[] | undefined>(undefined),
]);
manualSkillPrimes = manualPrimesResult;
alwaysApplySkillPrimes = alwaysApplyPrimesResult;
/**
* Cross-list dedup: when a user `$`-invokes a skill that is also
* marked `always-apply`, the always-apply copy is dropped here so
* the same SKILL.md body isn't primed twice in the same turn.
* Manual wins because it sits closer to the user message and
* carries explicit intent. Done at the initializer (not just at
* splice time in `injectSkillPrimes`) so persisted user-bubble
* `alwaysAppliedSkills` pills reflect the post-dedup set and the
* tool-union step below doesn't bill allowed-tools to the dropped
* always-apply entry.
*/
if (
alwaysApplySkillPrimes &&
alwaysApplySkillPrimes.length > 0 &&
manualSkillPrimes &&
manualSkillPrimes.length > 0
) {
const manualNames = new Set(manualSkillPrimes.map((p) => p.name));
const deduped = alwaysApplySkillPrimes.filter((p) => !manualNames.has(p.name));
const removed = alwaysApplySkillPrimes.length - deduped.length;
if (removed > 0) {
logger.info(
`[initializeAgent] Dropped ${removed} always-apply prime(s) already present in the manual list; same-named skills prime only once per turn.`,
);
alwaysApplySkillPrimes = deduped;
}
}
/**
* Enforce the combined `MAX_PRIMED_SKILLS_PER_TURN` ceiling up-front
* so persisted user-bubble `alwaysAppliedSkills` pills stay in sync
* with what actually gets primed. `injectSkillPrimes` re-applies the
* cap as defense-in-depth at splice time. Always-apply primes are
* truncated first — manual invocation is explicit user intent and
* should never be silently dropped.
*/
const manualCount = manualSkillPrimes?.length ?? 0;
const alwaysApplyCount = alwaysApplySkillPrimes?.length ?? 0;
if (alwaysApplySkillPrimes && manualCount + alwaysApplyCount > MAX_PRIMED_SKILLS_PER_TURN) {
const budgetForAlwaysApply = Math.max(0, MAX_PRIMED_SKILLS_PER_TURN - manualCount);
const dropped = alwaysApplyCount - budgetForAlwaysApply;
logger.warn(
`[initializeAgent] Combined primes (${manualCount} manual + ${alwaysApplyCount} always-apply) exceeds MAX_PRIMED_SKILLS_PER_TURN (${MAX_PRIMED_SKILLS_PER_TURN}); truncating ${dropped} always-apply prime(s) so persisted user-message pills stay in sync with what got primed.`,
);
alwaysApplySkillPrimes = alwaysApplySkillPrimes.slice(0, budgetForAlwaysApply);
}
const primesForUnion = [...(manualSkillPrimes ?? []), ...(alwaysApplySkillPrimes ?? [])];
if (primesForUnion.length > 0) {
const union = unionPrimeAllowedTools({
primes: primesForUnion,
agentToolNames: agent.tools ?? [],
});
extraAllowedToolNames = union.extraToolNames;
perSkillExtras = union.perSkillExtras;
}
}
const baseToolNames = agent.tools ?? [];
const requestedToolNames =
extraAllowedToolNames.length > 0 ? [...baseToolNames, ...extraAllowedToolNames] : baseToolNames;
/**
* `loadTools` failures take two forms:
* 1. The wrapper throws — rare; only when something around the
* try/catch in `createToolLoader` itself fails.
* 2. The wrapper returns `undefined` — the typical CJS path: every
* production loader (`createToolLoader` in `initialize.js`,
* `openai.js`, `responses.js`) catches `loadAgentTools` errors and
* returns `undefined`. Without explicit handling, the empty
* fallback object below would silently drop the agent's baseline
* tools for the turn (not just the skill-added extras).
*
* If a skill-contributed `allowed-tools` entry is the culprit, retry
* with just `agent.tools` so the agent's own tools still load (the
* dropped-tools debug log below picks up which extras vanished). If
* the retry-without-extras also fails, propagate / fall through with
* the empty fallback — the agent's own tools are the problem.
*/
const callLoadTools = async (tools: string[]) =>
loadTools?.({
req,
res,
provider,
agentId: agent.id,
tools,
model: agent.model,
tool_options: agent.tool_options,
tool_resources,
});
let loadToolsResult;
const initialFailedSilently = (result: unknown) =>
result == null && extraAllowedToolNames.length > 0;
try {
loadToolsResult = await callLoadTools(requestedToolNames);
} catch (err) {
if (extraAllowedToolNames.length > 0) {
logger.warn(
`[allowedTools] loadTools threw with skill-added extras [${extraAllowedToolNames.join(', ')}]; retrying without them:`,
err instanceof Error ? err.message : err,
);
loadToolsResult = await callLoadTools(baseToolNames);
} else {
throw err;
}
}
if (initialFailedSilently(loadToolsResult)) {
/* Production loaders swallow errors and return undefined. Treat that
the same as a throw when extras were requested — the agent's own
tools must still load. */
logger.warn(
`[allowedTools] loadTools returned no result with skill-added extras [${extraAllowedToolNames.join(', ')}]; retrying without them.`,
);
loadToolsResult = await callLoadTools(baseToolNames);
}
const {
toolRegistry,
toolContextMap,
dynamicToolContextMap,
userMCPAuthMap,
toolDefinitions: loadedToolDefinitions,
hasDeferredTools,
actionsEnabled,
tools: structuredTools,
primedCodeFiles,
} = loadToolsResult ?? {
tools: [],
toolContextMap: {},
dynamicToolContextMap: {},
userMCPAuthMap: undefined,
toolRegistry: undefined,
toolDefinitions: [],
hasDeferredTools: false,
actionsEnabled: undefined,
primedCodeFiles: undefined,
};
let toolDefinitions = loadedToolDefinitions;
/**
* Tolerant filter: anything `loadTools` couldn't resolve (capability
* disabled, plugin missing, name unknown to the registry) is silently
* dropped with an attributed debug log. Cross-ecosystem skills authored
* against tools LibreChat hasn't shipped yet (Claude Code's `edit_file`,
* etc.) import without breaking — they light up automatically once
* support lands. Skips when there were no extras to begin with.
*/
if (extraAllowedToolNames.length > 0) {
const loadedNames = new Set((toolDefinitions ?? []).map((d) => d.name));
const dropped = extraAllowedToolNames.filter((n) => !loadedNames.has(n));
if (dropped.length > 0) {
const sources: string[] = [];
for (const [skillName, names] of perSkillExtras) {
const droppedFromSkill = names.filter((n) => !loadedNames.has(n));
if (droppedFromSkill.length > 0) {
sources.push(`"${skillName}" → [${droppedFromSkill.join(', ')}]`);
}
}
logger.debug(
`[allowedTools] Dropped unrecognized tool names: ${
sources.length > 0 ? sources.join('; ') : dropped.join(', ')
}`,
);
}
}
const { getOptions, overrideProvider, customEndpointConfig } = getProviderConfig({
provider,
appConfig: req.config,
});
if (overrideProvider !== agent.provider) {
agent.provider = overrideProvider;
}
const finalModelOptions = {
...modelOptions,
model: agent.model,
};
const options: InitializeResultBase = await getOptions({
req,
endpoint: provider,
model_parameters: finalModelOptions,
db,
});
const llmConfig = options.llmConfig as Record<string, unknown>;
const tokensModel =
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : (llmConfig?.model as string);
const maxOutputTokens = optionalChainWithEmptyCheck(
llmConfig?.maxOutputTokens as number | undefined,
llmConfig?.maxTokens as number | undefined,
0,
);
const agentMaxContextTokens = optionalChainWithEmptyCheck(
maxContextTokens,
getModelMaxTokens(
tokensModel ?? '',
providerEndpointMap[overrideProvider as keyof typeof providerEndpointMap],
options.endpointTokenConfig,
),
DEFAULT_MAX_CONTEXT_TOKENS,
);
if (
agent.endpoint === EModelEndpoint.azureOpenAI &&
(llmConfig?.azureOpenAIApiInstanceName as string | undefined) == null
) {
agent.provider = Providers.OPENAI;
}
if (options.provider != null) {
agent.provider = options.provider;
}
/**
* Unify code-execution tools around `bash_tool` + `read_file` when the
* agent explicitly lists `execute_code` in its tools and the admin
* capability is enabled for the run. The legacy `execute_code` tool
* (backed by `CodeExecutionToolDefinition` + `primeCodeFiles`) is no
* longer registered; the string `execute_code` on the agent document
* stays as the capability-trigger marker but expands into the
* `bash_tool` + `read_file` pair here. The initial `read_file`
* definition is code-only; `injectSkillCatalog` upgrades it later in
* this initializer when skill files are actually available.
*
* `effectiveCodeEnvAvailable` is the per-agent truth: the admin-level
* `params.codeEnvAvailable` AND the agent actually asking for code
* execution. Computed once and reused by the expansion block below,
* the `injectSkillCatalog` call, and the returned `InitializedAgent`.
* Downstream handlers (runtime `configurable`, `primeInvokedSkills`)
* read it from the stored per-agent value so a skills-only agent
* never accidentally registers `bash_tool` or primes sandbox files
* just because the admin globally enabled code execution.
*
* Done BEFORE the `hasAgentTools` / GOOGLE_TOOL_CONFLICT gate so
* execute-code-only agents on Google/Vertex still trip the conflict
* guard when provider-specific tools are also configured. Also before
* `injectSkillCatalog` so the skill path's own
* `registerCodeExecutionTools` call upgrades `read_file` from the
* code-only description to the skill-aware description without adding a
* duplicate — exactly one copy of each tool reaches the LLM.
*/
const agentRequestsCodeExec = (agent.tools ?? []).includes(Tools.execute_code);
const effectiveCodeEnvAvailable = params.codeEnvAvailable === true && agentRequestsCodeExec;
if (effectiveCodeEnvAvailable) {
const codeExecResult = registerCodeExecutionTools({
toolRegistry,
toolDefinitions,
includeBash: true,
includeSkillFileInstructions: false,
enableToolOutputReferences: effectiveCodeEnvAvailable,
});
toolDefinitions = codeExecResult.toolDefinitions;
} else if (agentRequestsCodeExec) {
/**
* Agent asked for `execute_code` but the admin-level gate is off —
* surface a debug log so operators tracing "why isn't code
* interpreter working?" get a clear signal. The event-driven tool
* loader (`loadToolDefinitionsWrapper`) doesn't log capability-
* disabled warnings for the definitions-only path, so without this,
* the tool silently vanishes from the LLM's definitions with no trace.
*/
logger.debug(
`[initializeAgent] Agent "${agent.id}" requests execute_code but codeEnvAvailable=${String(params.codeEnvAvailable)}; skipping bash_tool + read_file registration.`,
);
}
/** Check for tool presence from either full instances or definitions (event-driven mode) */
const hasAgentTools = (structuredTools?.length ?? 0) > 0 || (toolDefinitions?.length ?? 0) > 0;
const providerTools = resolveAnthropicToolConflicts({
provider: agent.provider,
tools: options.tools,
toolDefinitions,
});
const hasProviderTools = (providerTools?.length ?? 0) > 0;
let tools: GenericTool[] = hasProviderTools
? (providerTools as GenericTool[])
: (structuredTools ?? []);
if (
(agent.provider === Providers.GOOGLE || agent.provider === Providers.VERTEXAI) &&
hasProviderTools &&
hasAgentTools
) {
throw new Error(`{ "type": "${ErrorTypes.GOOGLE_TOOL_CONFLICT}"}`);
} else if (
(agent.provider === Providers.OPENAI ||
agent.provider === Providers.AZURE ||
agent.provider === Providers.ANTHROPIC) &&
hasProviderTools &&
structuredTools?.length
) {
tools = structuredTools.concat(providerTools as GenericTool[]);
}
agent.model_parameters = { ...options.llmConfig } as Agent['model_parameters'];
if (options.configOptions) {
(agent.model_parameters as Record<string, unknown>).configuration = options.configOptions;
}
if (agent.instructions && agent.instructions !== '') {
const resolvedInstructions = replaceSpecialVars({
text: agent.instructions,
user: req.user ? (req.user as unknown as TUser) : null,
now: req.conversationCreatedAt,
});
if (hasTemporalSpecialVars(agent.instructions)) {
agent.instructions = undefined;
appendAdditionalInstructions(agent, resolvedInstructions);
} else {
agent.instructions = resolvedInstructions;
}
}
if (typeof agent.artifacts === 'string' && agent.artifacts !== '') {
const artifactsPromptResult = generateArtifactsPrompt({
endpoint: agent.provider,
artifacts: agent.artifacts as never,
});
appendAdditionalInstructions(agent, artifactsPromptResult);
}
let skillCount = 0;
/**
* IDs authorized for runtime skill execution — starts as the ACL-scoped set
* and gets replaced with the active-filtered subset after catalog injection.
* Ensures `getSkillByName` cannot resolve a deactivated skill even if the
* LLM (or a direct-invocation path) names one.
*/
let executableSkillIds = params.accessibleSkillIds;
let activeSkillNames: Set<string> | undefined;
const { accessibleSkillIds } = params;
if (accessibleSkillIds && accessibleSkillIds.length > 0) {
const skillResult = await injectSkillCatalog({
agent,
toolDefinitions,
toolRegistry,
accessibleSkillIds,
contextWindowTokens: Number(agentMaxContextTokens) || 200_000,
listSkillsByAccess: db?.listSkillsByAccess,
codeEnvAvailable: effectiveCodeEnvAvailable,
userId: req.user?.id,
skillStates: params.skillStates,
defaultActiveOnShare: params.defaultActiveOnShare,
});
toolDefinitions = skillResult.toolDefinitions;
skillCount = skillResult.skillCount;
executableSkillIds = skillResult.activeSkillIds;
activeSkillNames = skillResult.activeSkillNames;
}
const agentMaxContextNum = Number(agentMaxContextTokens) || DEFAULT_MAX_CONTEXT_TOKENS;
const maxOutputTokensNum = Number(maxOutputTokens) || 0;
const baseContextTokens = Math.max(0, agentMaxContextNum - maxOutputTokensNum);
const toMongoFiles = (files: Array<TFile | undefined> | undefined): IMongoFile[] =>
(files ?? []).filter((a): a is TFile => a != null).map((a) => a as unknown as IMongoFile);
const finalAttachments: IMongoFile[] = toMongoFiles(primedAttachments);
const requestAttachments: IMongoFile[] = toMongoFiles(primedRequestAttachments);
const agentContextAttachments: IMongoFile[] = toMongoFiles(primedAgentContextAttachments);
const compatibilityAttachments =
finalAttachments.length > 0
? finalAttachments
: requestAttachments.concat(agentContextAttachments);
const endpointConfigs = req.config?.endpoints;
const providerConfig =
customEndpointConfig ?? endpointConfigs?.[agent.provider as keyof typeof endpointConfigs];
const providerMaxToolResultChars =
providerConfig != null &&
typeof providerConfig === 'object' &&
!Array.isArray(providerConfig) &&
'maxToolResultChars' in providerConfig
? (providerConfig.maxToolResultChars as number | undefined)
: undefined;
const maxToolResultCharsResolved =
providerMaxToolResultChars ?? endpointConfigs?.all?.maxToolResultChars;
const initializedAgent: InitializedAgent = {
...agent,
resendFiles,
toolRegistry,
tool_resources,
userMCPAuthMap,
toolDefinitions,
hasDeferredTools,
actionsEnabled,
baseContextTokens,
codeEnvAvailable: effectiveCodeEnvAvailable,
skillCount,
accessibleSkillIds: executableSkillIds,
activeSkillNames,
manualSkillPrimes,
alwaysApplySkillPrimes,
attachments: compatibilityAttachments,
requestAttachments,
agentContextAttachments,
toolContextMap: toolContextMap ?? {},
dynamicToolContextMap: dynamicToolContextMap ?? {},
useLegacyContent: !!options.useLegacyContent,
tools: (tools ?? []) as GenericTool[] & string[],
maxToolResultChars: maxToolResultCharsResolved,
maxContextTokens:
maxContextTokens != null && maxContextTokens > 0
? maxContextTokens
: Math.max(1024, Math.round(baseContextTokens * (1 - DEFAULT_RESERVE_RATIO))),
primedCodeFiles,
};
return initializedAgent;
}