🧩 chore: Extract Agent Client Utilities to /packages/api (#11789)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

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:
Danny Avila 2026-02-13 23:17:53 -05:00 committed by GitHub
parent 467df0f07a
commit f72378d389
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 181 additions and 175 deletions

View file

@ -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);

View file

@ -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: '^_',
},
],
},
},
{

View 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;
}
};
}

View file

@ -1,5 +1,6 @@
export * from './avatars';
export * from './chain';
export * from './client';
export * from './context';
export * from './edges';
export * from './handlers';

View file

@ -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> & {