🧑‍🏫 fix: Multi-Agent Instructions Handling (#11484)

* 🧑‍🏫 fix: Multi-Agent Instructions Handling

* Refactored AgentClient to streamline the process of building messages by applying shared run context and agent-specific instructions.
* Introduced new utility functions in context.ts for extracting MCP server names, fetching MCP instructions, and building combined agent instructions.
* Updated the Agent type to make instructions optional, allowing for more flexible agent configurations.
* Improved the handling of context application to agents, ensuring that all relevant information is correctly integrated before execution.

* chore: Update EphemeralAgent Type in Context

* Enhanced the context.ts file by importing the TEphemeralAgent type from librechat-data-provider.
* Updated the applyContextToAgent function to use TEphemeralAgent for the ephemeralAgent parameter, improving type safety and clarity in agent context handling.

* ci: Update Agent Instructions in Tests for Clarity

* Revised test assertions in AgentClient to clarify the source of agent instructions, ensuring they are explicitly referenced as coming from agent configuration rather than build options.
* Updated comments in tests to enhance understanding of the expected behavior regarding base agent instructions and their handling in various scenarios.

* ci: Unit Tests for Agent Context Utilities

* Introduced comprehensive unit tests for agent context utilities, including functions for extracting MCP servers, fetching MCP instructions, and building agent instructions.
* Enhanced test coverage to ensure correct behavior across various scenarios, including handling of empty tools, mixed tool types, and error cases.
* Improved type definitions for AgentWithTools to clarify the structure and requirements for agent context operations.
This commit is contained in:
Danny Avila 2026-01-22 19:36:06 -05:00 committed by GitHub
parent 7204e74390
commit cfd5c793a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 758 additions and 86 deletions

View file

@ -1,6 +1,5 @@
require('events').EventEmitter.defaultMaxListeners = 100;
const { logger } = require('@librechat/data-schemas');
const { DynamicStructuredTool } = require('@langchain/core/tools');
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
const {
createRun,
@ -14,6 +13,7 @@ const {
getBalanceConfig,
getProviderConfig,
memoryInstructions,
applyContextToAgent,
GenerationJobManager,
getTransactionsConfig,
createMemoryProcessor,
@ -328,11 +328,13 @@ class AgentClient extends BaseClient {
);
}
/**
* Returns build message options. For AgentClient, agent-specific instructions
* are retrieved directly from agent objects in buildMessages, so this returns empty.
* @returns {Object} Empty options object
*/
getBuildMessagesOptions() {
return {
instructions: this.options.agent.instructions,
additional_instructions: this.options.agent.additional_instructions,
};
return {};
}
/**
@ -355,12 +357,7 @@ class AgentClient extends BaseClient {
return files;
}
async buildMessages(
messages,
parentMessageId,
{ instructions = null, additional_instructions = null },
opts,
) {
async buildMessages(messages, parentMessageId, _buildOptions, opts) {
/** Always pass mapMethod; getMessagesForConversation applies it only to messages with addedConvo flag */
const orderedMessages = this.constructor.getMessagesForConversation({
messages,
@ -374,11 +371,29 @@ class AgentClient extends BaseClient {
/** @type {number | undefined} */
let promptTokens;
/** @type {string} */
let systemContent = [instructions ?? '', additional_instructions ?? '']
.filter(Boolean)
.join('\n')
.trim();
/**
* Extract base instructions for all agents (combines instructions + additional_instructions).
* This must be done before applying context to preserve the original agent configuration.
*/
const extractBaseInstructions = (agent) => {
const baseInstructions = [agent.instructions ?? '', agent.additional_instructions ?? '']
.filter(Boolean)
.join('\n')
.trim();
agent.instructions = baseInstructions;
return agent;
};
/** Collect all agents for unified processing, extracting base instructions during collection */
const allAgents = [
{ agent: extractBaseInstructions(this.options.agent), agentId: this.options.agent.id },
...(this.agentConfigs?.size > 0
? Array.from(this.agentConfigs.entries()).map(([agentId, agent]) => ({
agent: extractBaseInstructions(agent),
agentId,
}))
: []),
];
if (this.options.attachments) {
const attachments = await this.options.attachments;
@ -413,6 +428,7 @@ class AgentClient extends BaseClient {
assistantName: this.options?.modelLabel,
});
/** For non-latest messages, prepend file context directly to message content */
if (message.fileContext && i !== orderedMessages.length - 1) {
if (typeof formattedMessage.content === 'string') {
formattedMessage.content = message.fileContext + '\n' + formattedMessage.content;
@ -422,8 +438,6 @@ class AgentClient extends BaseClient {
? (textPart.text = message.fileContext + '\n' + textPart.text)
: formattedMessage.content.unshift({ type: 'text', text: message.fileContext });
}
} else if (message.fileContext && i === orderedMessages.length - 1) {
systemContent = [systemContent, message.fileContext].join('\n');
}
const needsTokenCount =
@ -456,46 +470,35 @@ class AgentClient extends BaseClient {
return formattedMessage;
});
/**
* Build shared run context - applies to ALL agents in the run.
* This includes: file context (latest message), augmented prompt (RAG), memory context.
*/
const sharedRunContextParts = [];
/** File context from the latest message (attachments) */
const latestMessage = orderedMessages[orderedMessages.length - 1];
if (latestMessage?.fileContext) {
sharedRunContextParts.push(latestMessage.fileContext);
}
/** Augmented prompt from RAG/context handlers */
if (this.contextHandlers) {
this.augmentedPrompt = await this.contextHandlers.createContext();
systemContent = this.augmentedPrompt + systemContent;
}
// Inject MCP server instructions if available
const ephemeralAgent = this.options.req.body.ephemeralAgent;
let mcpServers = [];
// Check for ephemeral agent MCP servers
if (ephemeralAgent && ephemeralAgent.mcp && ephemeralAgent.mcp.length > 0) {
mcpServers = ephemeralAgent.mcp;
}
// Check for regular agent MCP tools
else if (this.options.agent && this.options.agent.tools) {
mcpServers = this.options.agent.tools
.filter(
(tool) =>
tool instanceof DynamicStructuredTool && tool.name.includes(Constants.mcp_delimiter),
)
.map((tool) => tool.name.split(Constants.mcp_delimiter).pop())
.filter(Boolean);
}
if (mcpServers.length > 0) {
try {
const mcpInstructions = await getMCPManager().formatInstructionsForContext(mcpServers);
if (mcpInstructions) {
systemContent = [systemContent, mcpInstructions].filter(Boolean).join('\n\n');
logger.debug('[AgentClient] Injected MCP instructions for servers:', mcpServers);
}
} catch (error) {
logger.error('[AgentClient] Failed to inject MCP instructions:', error);
if (this.augmentedPrompt) {
sharedRunContextParts.push(this.augmentedPrompt);
}
}
if (systemContent) {
this.options.agent.instructions = systemContent;
/** Memory context (user preferences/memories) */
const withoutKeys = await this.useMemory();
if (withoutKeys) {
const memoryContext = `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`;
sharedRunContextParts.push(memoryContext);
}
const sharedRunContext = sharedRunContextParts.join('\n\n');
/** @type {Record<string, number> | undefined} */
let tokenCountMap;
@ -521,36 +524,27 @@ class AgentClient extends BaseClient {
opts.getReqData({ promptTokens });
}
const withoutKeys = await this.useMemory();
const memoryContext = withoutKeys
? `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`
: '';
if (memoryContext) {
systemContent += memoryContext;
}
if (systemContent) {
this.options.agent.instructions = systemContent;
}
/**
* Pass memory context to parallel agents (addedConvo) so they have the same user context.
* Apply context to all agents.
* Each agent gets: shared run context + their own base instructions + their own MCP instructions.
*
* NOTE: This intentionally mutates the agentConfig objects in place. The agentConfigs Map
* holds references to config objects that will be passed to the graph runtime. Mutating
* them here ensures all parallel agents receive the memory context before execution starts.
* Creating new objects would not work because the Map references would still point to the old objects.
* NOTE: This intentionally mutates agent objects in place. The agentConfigs Map
* holds references to config objects that will be passed to the graph runtime.
*/
if (memoryContext && this.agentConfigs?.size > 0) {
for (const [agentId, agentConfig] of this.agentConfigs.entries()) {
if (agentConfig.instructions) {
agentConfig.instructions = agentConfig.instructions + '\n\n' + memoryContext;
} else {
agentConfig.instructions = memoryContext;
}
logger.debug(`[AgentClient] Added memory context to parallel agent: ${agentId}`);
}
}
const ephemeralAgent = this.options.req.body.ephemeralAgent;
const mcpManager = getMCPManager();
await Promise.all(
allAgents.map(({ agent, agentId }) =>
applyContextToAgent({
agent,
agentId,
logger,
mcpManager,
sharedRunContext,
ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined,
}),
),
);
return result;
}

View file

@ -1310,8 +1310,8 @@ describe('AgentClient - titleConvo', () => {
expect(client.options.agent.instructions).toContain('# MCP Server Instructions');
expect(client.options.agent.instructions).toContain('Use these tools carefully');
// Verify the base instructions are also included
expect(client.options.agent.instructions).toContain('Base instructions');
// Verify the base instructions are also included (from agent config, not buildOptions)
expect(client.options.agent.instructions).toContain('Base agent instructions');
});
it('should handle MCP instructions with ephemeral agent', async () => {
@ -1373,8 +1373,8 @@ describe('AgentClient - titleConvo', () => {
additional_instructions: null,
});
// Verify the instructions still work without MCP content
expect(client.options.agent.instructions).toBe('Base instructions only');
// Verify the instructions still work without MCP content (from agent config, not buildOptions)
expect(client.options.agent.instructions).toBe('Base agent instructions');
expect(client.options.agent.instructions).not.toContain('[object Promise]');
});
@ -1398,8 +1398,8 @@ describe('AgentClient - titleConvo', () => {
additional_instructions: null,
});
// Should still have base instructions without MCP content
expect(client.options.agent.instructions).toContain('Base instructions');
// Should still have base instructions without MCP content (from agent config, not buildOptions)
expect(client.options.agent.instructions).toContain('Base agent instructions');
expect(client.options.agent.instructions).not.toContain('[object Promise]');
});
});
@ -1945,7 +1945,8 @@ describe('AgentClient - titleConvo', () => {
expect(client.useMemory).toHaveBeenCalled();
expect(client.options.agent.instructions).toContain('Base instructions');
// Verify primary agent has its configured instructions (not from buildOptions) and memory context
expect(client.options.agent.instructions).toContain('Primary agent instructions');
expect(client.options.agent.instructions).toContain(memoryContent);
expect(parallelAgent1.instructions).toContain('Parallel agent 1 instructions');