mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-15 06:58:10 +01:00
🧩 chore: Extract Agent Client Utilities to /packages/api (#11789)
Extract 7 standalone utilities from api/server/controllers/agents/client.js into packages/api/src/agents/client.ts for TypeScript support and to declutter the 1400-line controller module: - omitTitleOptions: Set of keys to exclude from title generation options - payloadParser: Extracts model_parameters from request body for non-agent endpoints - createTokenCounter: Factory for langchain-compatible token counting functions - logToolError: Callback handler for agent tool execution errors - findPrimaryAgentId: Resolves primary agent from suffixed parallel agent IDs - createMultiAgentMapper: Message content processor that filters parallel agent output to primary agents and applies agent labels for handoff/multi-agent flows Supporting changes: - Add endpointOption and endpointType to RequestBody type (packages/api/src/types/http.ts) so payloadParser can access middleware-attached fields without type casts - Add @typescript-eslint/no-unused-vars with underscore ignore patterns to the packages/api eslint config block, matching the convention used by client/ and data-provider/ blocks - Update agent controller imports to consume the moved functions from @librechat/api and remove now-unused direct imports (logAxiosError, labelContentByAgent, getTokenCountForMessage)
This commit is contained in:
parent
467df0f07a
commit
f72378d389
5 changed files with 181 additions and 175 deletions
|
|
@ -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<string>} 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<string, Agent>} [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<string, string> | 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<number, Set<string>>} */
|
||||
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<number, string>} */
|
||||
const groupPrimaryMap = new Map();
|
||||
for (const [groupId, agentIds] of groupAgentMap) {
|
||||
const primary = findPrimaryAgentId(agentIds);
|
||||
if (primary) {
|
||||
groupPrimaryMap.set(groupId, primary);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Array<TMessageContentParts>} */
|
||||
const filteredContent = [];
|
||||
/** @type {Record<number, string>} */
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
162
packages/api/src/agents/client.ts
Normal file
162
packages/api/src/agents/client.ts
Normal file
|
|
@ -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<typeof Tokenizer.getTokenCount>[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>): 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<string, Agent>) {
|
||||
const hasMultipleAgents = (primaryAgent.edges?.length ?? 0) > 0 || (agentConfigs?.size ?? 0) > 0;
|
||||
|
||||
let agentNames: Record<string, string> | 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<number, Set<string>>();
|
||||
|
||||
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<number, string>();
|
||||
for (const [groupId, agentIds] of groupAgentMap) {
|
||||
const primary = findPrimaryAgentId(agentIds);
|
||||
if (primary) {
|
||||
groupPrimaryMap.set(groupId, primary);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredContent: ContentPart[] = [];
|
||||
const agentIdMap: Record<number, string> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './avatars';
|
||||
export * from './chain';
|
||||
export * from './client';
|
||||
export * from './context';
|
||||
export * from './edges';
|
||||
export * from './handlers';
|
||||
|
|
|
|||
|
|
@ -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<TEndpointOption>;
|
||||
};
|
||||
|
||||
export type ServerRequest = Request<unknown, unknown, RequestBody> & {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue