diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 8e8a993a5d..49240a6b3b 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -6,18 +6,22 @@ const { Tokenizer, checkAccess, buildToolSet, - logAxiosError, sanitizeTitle, + logToolError, + payloadParser, resolveHeaders, createSafeUser, initializeAgent, getBalanceConfig, getProviderConfig, + omitTitleOptions, memoryInstructions, applyContextToAgent, + createTokenCounter, GenerationJobManager, getTransactionsConfig, createMemoryProcessor, + createMultiAgentMapper, filterMalformedContentParts, } = require('@librechat/api'); const { @@ -25,9 +29,7 @@ const { Providers, TitleMethod, formatMessage, - labelContentByAgent, formatAgentMessages, - getTokenCountForMessage, createMetadataAggregator, } = require('@librechat/agents'); const { @@ -51,177 +53,6 @@ const { loadAgent } = require('~/models/Agent'); const { getMCPManager } = require('~/config'); const db = require('~/models'); -const omitTitleOptions = new Set([ - 'stream', - 'thinking', - 'streaming', - 'clientOptions', - 'thinkingConfig', - 'thinkingBudget', - 'includeThoughts', - 'maxOutputTokens', - 'additionalModelRequestFields', -]); - -/** - * @param {ServerRequest} req - * @param {Agent} agent - * @param {string} endpoint - */ -const payloadParser = ({ req, endpoint }) => { - if (isAgentsEndpoint(endpoint)) { - return; - } - return req.body?.endpointOption?.model_parameters; -}; - -function createTokenCounter(encoding) { - return function (message) { - const countTokens = (text) => Tokenizer.getTokenCount(text, encoding); - return getTokenCountForMessage(message, countTokens); - }; -} - -function logToolError(graph, error, toolId) { - logAxiosError({ - error, - message: `[api/server/controllers/agents/client.js #chatCompletion] Tool Error "${toolId}"`, - }); -} - -/** Regex pattern to match agent ID suffix (____N) */ -const AGENT_SUFFIX_PATTERN = /____(\d+)$/; - -/** - * Finds the primary agent ID within a set of agent IDs. - * Primary = no suffix (____N) or lowest suffix number. - * @param {Set} agentIds - * @returns {string | null} - */ -function findPrimaryAgentId(agentIds) { - let primaryAgentId = null; - let lowestSuffixIndex = Infinity; - - for (const agentId of agentIds) { - const suffixMatch = agentId.match(AGENT_SUFFIX_PATTERN); - if (!suffixMatch) { - return agentId; - } - const suffixIndex = parseInt(suffixMatch[1], 10); - if (suffixIndex < lowestSuffixIndex) { - lowestSuffixIndex = suffixIndex; - primaryAgentId = agentId; - } - } - - return primaryAgentId; -} - -/** - * Creates a mapMethod for getMessagesForConversation that processes agent content. - * - Strips agentId/groupId metadata from all content - * - For parallel agents (addedConvo with groupId): filters each group to its primary agent - * - For handoffs (agentId without groupId): keeps all content from all agents - * - For multi-agent: applies agent labels to content - * - * The key distinction: - * - Parallel execution (addedConvo): Parts have both agentId AND groupId - * - Handoffs: Parts only have agentId, no groupId - * - * @param {Agent} primaryAgent - Primary agent configuration - * @param {Map} [agentConfigs] - Additional agent configurations - * @returns {(message: TMessage) => TMessage} Map method for processing messages - */ -function createMultiAgentMapper(primaryAgent, agentConfigs) { - const hasMultipleAgents = (primaryAgent.edges?.length ?? 0) > 0 || (agentConfigs?.size ?? 0) > 0; - - /** @type {Record | null} */ - let agentNames = null; - if (hasMultipleAgents) { - agentNames = { [primaryAgent.id]: primaryAgent.name || 'Assistant' }; - if (agentConfigs) { - for (const [agentId, agentConfig] of agentConfigs.entries()) { - agentNames[agentId] = agentConfig.name || agentConfig.id; - } - } - } - - return (message) => { - if (message.isCreatedByUser || !Array.isArray(message.content)) { - return message; - } - - // Check for metadata - const hasAgentMetadata = message.content.some((part) => part?.agentId || part?.groupId != null); - if (!hasAgentMetadata) { - return message; - } - - try { - // Build a map of groupId -> Set of agentIds, to find primary per group - /** @type {Map>} */ - const groupAgentMap = new Map(); - - for (const part of message.content) { - const groupId = part?.groupId; - const agentId = part?.agentId; - if (groupId != null && agentId) { - if (!groupAgentMap.has(groupId)) { - groupAgentMap.set(groupId, new Set()); - } - groupAgentMap.get(groupId).add(agentId); - } - } - - // For each group, find the primary agent - /** @type {Map} */ - const groupPrimaryMap = new Map(); - for (const [groupId, agentIds] of groupAgentMap) { - const primary = findPrimaryAgentId(agentIds); - if (primary) { - groupPrimaryMap.set(groupId, primary); - } - } - - /** @type {Array} */ - const filteredContent = []; - /** @type {Record} */ - const agentIdMap = {}; - - for (const part of message.content) { - const agentId = part?.agentId; - const groupId = part?.groupId; - - // Filtering logic: - // - No groupId (handoffs): always include - // - Has groupId (parallel): only include if it's the primary for that group - const isParallelPart = groupId != null; - const groupPrimary = isParallelPart ? groupPrimaryMap.get(groupId) : null; - const shouldInclude = !isParallelPart || !agentId || agentId === groupPrimary; - - if (shouldInclude) { - const newIndex = filteredContent.length; - const { agentId: _a, groupId: _g, ...cleanPart } = part; - filteredContent.push(cleanPart); - if (agentId && hasMultipleAgents) { - agentIdMap[newIndex] = agentId; - } - } - } - - const finalContent = - Object.keys(agentIdMap).length > 0 && agentNames - ? labelContentByAgent(filteredContent, agentIdMap, agentNames) - : filteredContent; - - return { ...message, content: finalContent }; - } catch (error) { - logger.error('[AgentClient] Error processing multi-agent message:', error); - return message; - } - }; -} - class AgentClient extends BaseClient { constructor(options = {}) { super(null, options); diff --git a/eslint.config.mjs b/eslint.config.mjs index 9990e0fc35..f53c4e83dd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -291,6 +291,15 @@ export default [ files: ['./packages/api/**/*.ts'], rules: { 'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + }, + ], }, }, { diff --git a/packages/api/src/agents/client.ts b/packages/api/src/agents/client.ts new file mode 100644 index 0000000000..fd5d50f211 --- /dev/null +++ b/packages/api/src/agents/client.ts @@ -0,0 +1,162 @@ +import { logger } from '@librechat/data-schemas'; +import { isAgentsEndpoint } from 'librechat-data-provider'; +import { labelContentByAgent, getTokenCountForMessage } from '@librechat/agents'; +import type { MessageContentComplex } from '@librechat/agents'; +import type { Agent, TMessage } from 'librechat-data-provider'; +import type { BaseMessage } from '@langchain/core/messages'; +import type { ServerRequest } from '~/types'; +import Tokenizer from '~/utils/tokenizer'; +import { logAxiosError } from '~/utils'; + +export const omitTitleOptions = new Set([ + 'stream', + 'thinking', + 'streaming', + 'clientOptions', + 'thinkingConfig', + 'thinkingBudget', + 'includeThoughts', + 'maxOutputTokens', + 'additionalModelRequestFields', +]); + +export function payloadParser({ req, endpoint }: { req: ServerRequest; endpoint: string }) { + if (isAgentsEndpoint(endpoint)) { + return; + } + return req.body?.endpointOption?.model_parameters; +} + +export function createTokenCounter(encoding: Parameters[1]) { + return function (message: BaseMessage) { + const countTokens = (text: string) => Tokenizer.getTokenCount(text, encoding); + return getTokenCountForMessage(message, countTokens); + }; +} + +export function logToolError(_graph: unknown, error: unknown, toolId: string) { + logAxiosError({ + error, + message: `[api/server/controllers/agents/client.js #chatCompletion] Tool Error "${toolId}"`, + }); +} + +const AGENT_SUFFIX_PATTERN = /____(\d+)$/; + +/** Finds the primary agent ID within a set of agent IDs (no suffix or lowest suffix number) */ +export function findPrimaryAgentId(agentIds: Set): string | null { + let primaryAgentId: string | null = null; + let lowestSuffixIndex = Infinity; + + for (const agentId of agentIds) { + const suffixMatch = agentId.match(AGENT_SUFFIX_PATTERN); + if (!suffixMatch) { + return agentId; + } + const suffixIndex = parseInt(suffixMatch[1], 10); + if (suffixIndex < lowestSuffixIndex) { + lowestSuffixIndex = suffixIndex; + primaryAgentId = agentId; + } + } + + return primaryAgentId; +} + +type ContentPart = TMessage['content'] extends (infer U)[] | undefined ? U : never; + +/** + * Creates a mapMethod for getMessagesForConversation that processes agent content. + * - Strips agentId/groupId metadata from all content + * - For parallel agents (addedConvo with groupId): filters each group to its primary agent + * - For handoffs (agentId without groupId): keeps all content from all agents + * - For multi-agent: applies agent labels to content + * + * The key distinction: + * - Parallel execution (addedConvo): Parts have both agentId AND groupId + * - Handoffs: Parts only have agentId, no groupId + */ +export function createMultiAgentMapper(primaryAgent: Agent, agentConfigs?: Map) { + const hasMultipleAgents = (primaryAgent.edges?.length ?? 0) > 0 || (agentConfigs?.size ?? 0) > 0; + + let agentNames: Record | null = null; + if (hasMultipleAgents) { + agentNames = { [primaryAgent.id]: primaryAgent.name || 'Assistant' }; + if (agentConfigs) { + for (const [agentId, agentConfig] of agentConfigs.entries()) { + agentNames[agentId] = agentConfig.name || agentConfig.id; + } + } + } + + return (message: TMessage): TMessage => { + if (message.isCreatedByUser || !Array.isArray(message.content)) { + return message; + } + + const hasAgentMetadata = message.content.some( + (part) => + (part as ContentPart & { agentId?: string; groupId?: number })?.agentId || + (part as ContentPart & { groupId?: number })?.groupId != null, + ); + if (!hasAgentMetadata) { + return message; + } + + try { + const groupAgentMap = new Map>(); + + for (const part of message.content) { + const p = part as ContentPart & { agentId?: string; groupId?: number }; + const groupId = p?.groupId; + const agentId = p?.agentId; + if (groupId != null && agentId) { + if (!groupAgentMap.has(groupId)) { + groupAgentMap.set(groupId, new Set()); + } + groupAgentMap.get(groupId)!.add(agentId); + } + } + + const groupPrimaryMap = new Map(); + for (const [groupId, agentIds] of groupAgentMap) { + const primary = findPrimaryAgentId(agentIds); + if (primary) { + groupPrimaryMap.set(groupId, primary); + } + } + + const filteredContent: ContentPart[] = []; + const agentIdMap: Record = {}; + + for (const part of message.content) { + const p = part as ContentPart & { agentId?: string; groupId?: number }; + const agentId = p?.agentId; + const groupId = p?.groupId; + + const isParallelPart = groupId != null; + const groupPrimary = isParallelPart ? groupPrimaryMap.get(groupId) : null; + const shouldInclude = !isParallelPart || !agentId || agentId === groupPrimary; + + if (shouldInclude) { + const newIndex = filteredContent.length; + const { agentId: _a, groupId: _g, ...cleanPart } = p; + filteredContent.push(cleanPart as ContentPart); + if (agentId && hasMultipleAgents) { + agentIdMap[newIndex] = agentId; + } + } + } + + const finalContent = + Object.keys(agentIdMap).length > 0 && agentNames + ? labelContentByAgent(filteredContent as MessageContentComplex[], agentIdMap, agentNames) + : filteredContent; + + return { ...message, content: finalContent as TMessage['content'] }; + } catch (error) { + logger.error('[AgentClient] Error processing multi-agent message:', error); + return message; + } + }; +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index a5a0c340fe..9d13b3dd8e 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -1,5 +1,6 @@ export * from './avatars'; export * from './chain'; +export * from './client'; export * from './context'; export * from './edges'; export * from './handlers'; diff --git a/packages/api/src/types/http.ts b/packages/api/src/types/http.ts index 6544447310..c304e9089e 100644 --- a/packages/api/src/types/http.ts +++ b/packages/api/src/types/http.ts @@ -1,5 +1,6 @@ -import type { Request } from 'express'; import type { IUser, AppConfig } from '@librechat/data-schemas'; +import type { TEndpointOption } from 'librechat-data-provider'; +import type { Request } from 'express'; /** * LibreChat-specific request body type that extends Express Request body @@ -11,8 +12,10 @@ export type RequestBody = { conversationId?: string; parentMessageId?: string; endpoint?: string; + endpointType?: string; model?: string; key?: string; + endpointOption?: Partial; }; export type ServerRequest = Request & {