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