diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 5f3618de4c..35cf7de784 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -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 | 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; } diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 402d011fd6..b5899c5215 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -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'); diff --git a/packages/api/src/agents/context.spec.ts b/packages/api/src/agents/context.spec.ts new file mode 100644 index 0000000000..c5358209c7 --- /dev/null +++ b/packages/api/src/agents/context.spec.ts @@ -0,0 +1,528 @@ +import { z } from 'zod'; +import { Constants } from 'librechat-data-provider'; +import { DynamicStructuredTool } from '@langchain/core/tools'; +import type { Logger } from 'winston'; +import type { MCPManager } from '~/mcp/MCPManager'; +import { + extractMCPServers, + getMCPInstructionsForServers, + buildAgentInstructions, + applyContextToAgent, +} from './context'; + +// Test schema for DynamicStructuredTool +const testSchema = z.object({}); + +describe('Agent Context Utilities', () => { + describe('extractMCPServers', () => { + it('should return empty array when agent has no tools', () => { + const agent = { id: 'test-agent' }; + expect(extractMCPServers(agent)).toEqual([]); + }); + + it('should return empty array when agent tools array is empty', () => { + const agent = { id: 'test-agent', tools: [] }; + expect(extractMCPServers(agent)).toEqual([]); + }); + + it('should extract unique MCP server names from tools', () => { + const tool1 = new DynamicStructuredTool({ + name: `tool1${Constants.mcp_delimiter}server1`, + description: 'Test tool 1', + schema: testSchema, + func: async () => 'result', + }); + + const tool2 = new DynamicStructuredTool({ + name: `tool2${Constants.mcp_delimiter}server2`, + description: 'Test tool 2', + schema: testSchema, + func: async () => 'result', + }); + + const agent = { id: 'test-agent', tools: [tool1, tool2] }; + const result = extractMCPServers(agent); + + expect(result).toContain('server1'); + expect(result).toContain('server2'); + expect(result).toHaveLength(2); + }); + + it('should return unique server names when multiple tools use same server', () => { + const tool1 = new DynamicStructuredTool({ + name: `tool1${Constants.mcp_delimiter}server1`, + description: 'Test tool 1', + schema: testSchema, + func: async () => 'result', + }); + + const tool2 = new DynamicStructuredTool({ + name: `tool2${Constants.mcp_delimiter}server1`, + description: 'Test tool 2', + schema: testSchema, + func: async () => 'result', + }); + + const agent = { id: 'test-agent', tools: [tool1, tool2] }; + const result = extractMCPServers(agent); + + expect(result).toEqual(['server1']); + expect(result).toHaveLength(1); + }); + + it('should ignore tools without MCP delimiter', () => { + const mcpTool = new DynamicStructuredTool({ + name: `tool1${Constants.mcp_delimiter}server1`, + description: 'MCP tool', + schema: testSchema, + func: async () => 'result', + }); + + const regularTool = new DynamicStructuredTool({ + name: 'regular_tool', + description: 'Regular tool', + schema: testSchema, + func: async () => 'result', + }); + + const agent = { id: 'test-agent', tools: [mcpTool, regularTool] }; + const result = extractMCPServers(agent); + + expect(result).toEqual(['server1']); + expect(result).toHaveLength(1); + }); + + it('should handle mixed tool types (string and DynamicStructuredTool)', () => { + const mcpTool = new DynamicStructuredTool({ + name: `tool1${Constants.mcp_delimiter}server1`, + description: 'MCP tool', + schema: testSchema, + func: async () => 'result', + }); + + const agent = { id: 'test-agent', tools: [mcpTool, 'string-tool'] }; + const result = extractMCPServers(agent); + + expect(result).toEqual(['server1']); + }); + + it('should filter out empty server names', () => { + const toolWithEmptyServer = new DynamicStructuredTool({ + name: `tool1${Constants.mcp_delimiter}`, + description: 'Tool with empty server', + schema: testSchema, + func: async () => 'result', + }); + + const agent = { id: 'test-agent', tools: [toolWithEmptyServer] }; + const result = extractMCPServers(agent); + + expect(result).toEqual([]); + }); + }); + + describe('getMCPInstructionsForServers', () => { + let mockMCPManager: jest.Mocked; + let mockLogger: Logger; + + beforeEach(() => { + mockMCPManager = { + formatInstructionsForContext: jest.fn(), + } as unknown as jest.Mocked; + + mockLogger = { + debug: jest.fn(), + error: jest.fn(), + } as unknown as Logger; + }); + + it('should return empty string when server array is empty', async () => { + const result = await getMCPInstructionsForServers([], mockMCPManager, mockLogger); + + expect(result).toBe(''); + expect(mockMCPManager.formatInstructionsForContext).not.toHaveBeenCalled(); + }); + + it('should fetch and return MCP instructions successfully', async () => { + const instructions = '# MCP Instructions\nUse these tools carefully'; + mockMCPManager.formatInstructionsForContext.mockResolvedValue(instructions); + + const result = await getMCPInstructionsForServers( + ['server1', 'server2'], + mockMCPManager, + mockLogger, + ); + + expect(result).toBe(instructions); + expect(mockMCPManager.formatInstructionsForContext).toHaveBeenCalledWith([ + 'server1', + 'server2', + ]); + expect(mockLogger.debug).toHaveBeenCalledWith( + '[AgentContext] Fetched MCP instructions for servers:', + ['server1', 'server2'], + ); + }); + + it('should return empty string when MCP manager returns empty', async () => { + mockMCPManager.formatInstructionsForContext.mockResolvedValue(''); + + const result = await getMCPInstructionsForServers(['server1'], mockMCPManager, mockLogger); + + expect(result).toBe(''); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully and log them', async () => { + const error = new Error('MCP fetch failed'); + mockMCPManager.formatInstructionsForContext.mockRejectedValue(error); + + const result = await getMCPInstructionsForServers(['server1'], mockMCPManager, mockLogger); + + expect(result).toBe(''); + expect(mockLogger.error).toHaveBeenCalledWith( + '[AgentContext] Failed to get MCP instructions:', + error, + ); + }); + + it('should work without logger', async () => { + const instructions = 'Test instructions'; + mockMCPManager.formatInstructionsForContext.mockResolvedValue(instructions); + + const result = await getMCPInstructionsForServers(['server1'], mockMCPManager); + + expect(result).toBe(instructions); + // Should not throw even without logger + }); + + it('should handle errors without logger', async () => { + mockMCPManager.formatInstructionsForContext.mockRejectedValue(new Error('Test error')); + + const result = await getMCPInstructionsForServers(['server1'], mockMCPManager); + + expect(result).toBe(''); + // Should not throw even without logger + }); + }); + + describe('buildAgentInstructions', () => { + it('should combine all parts with double newlines', () => { + const result = buildAgentInstructions({ + sharedRunContext: 'Shared context', + baseInstructions: 'Base instructions', + mcpInstructions: 'MCP instructions', + }); + + expect(result).toBe('Shared context\n\nBase instructions\n\nMCP instructions'); + }); + + it('should filter out empty parts', () => { + const result = buildAgentInstructions({ + sharedRunContext: 'Shared context', + baseInstructions: '', + mcpInstructions: 'MCP instructions', + }); + + expect(result).toBe('Shared context\n\nMCP instructions'); + }); + + it('should return undefined when all parts are empty', () => { + const result = buildAgentInstructions({ + sharedRunContext: '', + baseInstructions: '', + mcpInstructions: '', + }); + + expect(result).toBeUndefined(); + }); + + it('should handle only shared context', () => { + const result = buildAgentInstructions({ + sharedRunContext: 'Shared context only', + }); + + expect(result).toBe('Shared context only'); + }); + + it('should handle only base instructions', () => { + const result = buildAgentInstructions({ + baseInstructions: 'Base instructions only', + }); + + expect(result).toBe('Base instructions only'); + }); + + it('should handle only MCP instructions', () => { + const result = buildAgentInstructions({ + mcpInstructions: 'MCP instructions only', + }); + + expect(result).toBe('MCP instructions only'); + }); + + it('should trim whitespace from combined result', () => { + const result = buildAgentInstructions({ + sharedRunContext: ' Shared context ', + baseInstructions: ' Base instructions ', + }); + + expect(result).toBe('Shared context \n\n Base instructions'); + }); + + it('should handle undefined parts', () => { + const result = buildAgentInstructions({ + sharedRunContext: undefined, + baseInstructions: 'Base', + mcpInstructions: undefined, + }); + + expect(result).toBe('Base'); + }); + }); + + describe('applyContextToAgent', () => { + let mockMCPManager: jest.Mocked; + let mockLogger: Logger; + + beforeEach(() => { + mockMCPManager = { + formatInstructionsForContext: jest.fn(), + } as unknown as jest.Mocked; + + mockLogger = { + debug: jest.fn(), + error: jest.fn(), + } as unknown as Logger; + }); + + it('should apply context successfully with all components', async () => { + const agent = { + id: 'test-agent', + instructions: 'Original instructions', + tools: [ + new DynamicStructuredTool({ + name: `tool${Constants.mcp_delimiter}server1`, + description: 'Test tool', + schema: testSchema, + func: async () => 'result', + }), + ], + }; + + mockMCPManager.formatInstructionsForContext.mockResolvedValue('MCP instructions'); + + await applyContextToAgent({ + agent, + sharedRunContext: 'Shared context', + mcpManager: mockMCPManager, + agentId: 'test-agent', + logger: mockLogger, + }); + + expect(agent.instructions).toBe( + 'Shared context\n\nOriginal instructions\n\nMCP instructions', + ); + expect(mockLogger.debug).toHaveBeenCalledWith( + '[AgentContext] Applied context to agent: test-agent', + ); + }); + + it('should use ephemeral agent MCP servers when provided', async () => { + const agent = { + id: 'test-agent', + instructions: 'Base instructions', + tools: [], + }; + + mockMCPManager.formatInstructionsForContext.mockResolvedValue('Ephemeral MCP'); + + await applyContextToAgent({ + agent, + sharedRunContext: 'Context', + mcpManager: mockMCPManager, + ephemeralAgent: { mcp: ['ephemeral-server'] }, + logger: mockLogger, + }); + + expect(mockMCPManager.formatInstructionsForContext).toHaveBeenCalledWith([ + 'ephemeral-server', + ]); + expect(agent.instructions).toContain('Ephemeral MCP'); + }); + + it('should prefer agent tools over empty ephemeral MCP array', async () => { + const agent = { + id: 'test-agent', + instructions: 'Base', + tools: [ + new DynamicStructuredTool({ + name: `tool${Constants.mcp_delimiter}agent-server`, + description: 'Test tool', + schema: testSchema, + func: async () => 'result', + }), + ], + }; + + mockMCPManager.formatInstructionsForContext.mockResolvedValue('Agent MCP'); + + await applyContextToAgent({ + agent, + sharedRunContext: '', + mcpManager: mockMCPManager, + ephemeralAgent: { mcp: [] }, + logger: mockLogger, + }); + + expect(mockMCPManager.formatInstructionsForContext).toHaveBeenCalledWith(['agent-server']); + }); + + it('should work without agentId', async () => { + const agent = { + id: 'test-agent', + instructions: 'Base', + tools: [], + }; + + mockMCPManager.formatInstructionsForContext.mockResolvedValue(''); + + await applyContextToAgent({ + agent, + sharedRunContext: 'Context', + mcpManager: mockMCPManager, + logger: mockLogger, + }); + + expect(agent.instructions).toBe('Context\n\nBase'); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + + it('should work without logger', async () => { + const agent = { + id: 'test-agent', + instructions: 'Base', + tools: [ + new DynamicStructuredTool({ + name: `tool${Constants.mcp_delimiter}server1`, + description: 'Test tool', + schema: testSchema, + func: async () => 'result', + }), + ], + }; + + mockMCPManager.formatInstructionsForContext.mockResolvedValue('MCP'); + + await applyContextToAgent({ + agent, + sharedRunContext: 'Context', + mcpManager: mockMCPManager, + }); + + expect(agent.instructions).toBe('Context\n\nBase\n\nMCP'); + }); + + it('should handle MCP fetch error gracefully and set fallback instructions', async () => { + const agent = { + id: 'test-agent', + instructions: 'Base instructions', + tools: [ + new DynamicStructuredTool({ + name: `tool${Constants.mcp_delimiter}server1`, + description: 'Test tool', + schema: testSchema, + func: async () => 'result', + }), + ], + }; + + const error = new Error('MCP fetch failed'); + mockMCPManager.formatInstructionsForContext.mockRejectedValue(error); + + await applyContextToAgent({ + agent, + sharedRunContext: 'Shared context', + mcpManager: mockMCPManager, + agentId: 'test-agent', + logger: mockLogger, + }); + + // getMCPInstructionsForServers catches the error and returns empty string + // So agent still has shared context + base instructions (without MCP) + expect(agent.instructions).toBe('Shared context\n\nBase instructions'); + // Error is logged by getMCPInstructionsForServers, not applyContextToAgent + expect(mockLogger.error).toHaveBeenCalledWith( + '[AgentContext] Failed to get MCP instructions:', + error, + ); + }); + + it('should handle invalid tools gracefully without throwing', async () => { + const agent = { + id: 'test-agent', + instructions: 'Base', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools: null as any, // Invalid tools - should not crash + }; + + mockMCPManager.formatInstructionsForContext.mockResolvedValue(''); + + await applyContextToAgent({ + agent, + sharedRunContext: 'Context', + mcpManager: mockMCPManager, + logger: mockLogger, + }); + + // extractMCPServers handles null tools gracefully, returns [] + // getMCPInstructionsForServers returns early with '', so no MCP instructions + // Agent should still have shared context + base instructions + expect(agent.instructions).toBe('Context\n\nBase'); + expect(mockMCPManager.formatInstructionsForContext).not.toHaveBeenCalled(); + }); + + it('should preserve empty base instructions', async () => { + const agent = { + id: 'test-agent', + instructions: '', + tools: [ + new DynamicStructuredTool({ + name: `tool${Constants.mcp_delimiter}server1`, + description: 'Test tool', + schema: testSchema, + func: async () => 'result', + }), + ], + }; + + mockMCPManager.formatInstructionsForContext.mockResolvedValue('MCP only'); + + await applyContextToAgent({ + agent, + sharedRunContext: 'Shared', + mcpManager: mockMCPManager, + }); + + expect(agent.instructions).toBe('Shared\n\nMCP only'); + }); + + it('should handle missing instructions field on agent', async () => { + const agent = { + id: 'test-agent', + instructions: undefined, + tools: [], + }; + + mockMCPManager.formatInstructionsForContext.mockResolvedValue(''); + + await applyContextToAgent({ + agent, + sharedRunContext: 'Context', + mcpManager: mockMCPManager, + }); + + expect(agent.instructions).toBe('Context'); + }); + }); +}); diff --git a/packages/api/src/agents/context.ts b/packages/api/src/agents/context.ts new file mode 100644 index 0000000000..cc5c4a6623 --- /dev/null +++ b/packages/api/src/agents/context.ts @@ -0,0 +1,148 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { Constants } from 'librechat-data-provider'; +import type { Agent, TEphemeralAgent } from 'librechat-data-provider'; +import type { Logger } from 'winston'; +import type { MCPManager } from '~/mcp/MCPManager'; + +/** + * Agent type with optional tools array that can contain DynamicStructuredTool or string. + * For context operations, we only require id and instructions, other Agent fields are optional. + */ +export type AgentWithTools = Pick & + Partial> & { + tools?: Array; + }; + +/** + * Extracts unique MCP server names from an agent's tools. + * @param agent - The agent with tools + * @returns Array of unique MCP server names + */ +export function extractMCPServers(agent: AgentWithTools): string[] { + if (!agent?.tools?.length) { + return []; + } + const mcpServers = new Set(); + for (let i = 0; i < agent.tools.length; i++) { + const tool = agent.tools[i]; + if (tool instanceof DynamicStructuredTool && tool.name.includes(Constants.mcp_delimiter)) { + const serverName = tool.name.split(Constants.mcp_delimiter).pop(); + if (serverName) { + mcpServers.add(serverName); + } + } + } + return Array.from(mcpServers); +} + +/** + * Fetches MCP instructions for the given server names. + * @param {string[]} mcpServers - Array of MCP server names + * @param {MCPManager} mcpManager - MCP manager instance + * @param {Logger} [logger] - Optional logger instance + * @returns {Promise} MCP instructions string, empty if none + */ +export async function getMCPInstructionsForServers( + mcpServers: string[], + mcpManager: MCPManager, + logger?: Logger, +): Promise { + if (!mcpServers.length) { + return ''; + } + try { + const mcpInstructions = await mcpManager.formatInstructionsForContext(mcpServers); + if (mcpInstructions && logger) { + logger.debug('[AgentContext] Fetched MCP instructions for servers:', mcpServers); + } + return mcpInstructions || ''; + } catch (error) { + if (logger) { + logger.error('[AgentContext] Failed to get MCP instructions:', error); + } + return ''; + } +} + +/** + * Builds final instructions for an agent by combining shared run context and agent-specific context. + * Order: sharedRunContext -> baseInstructions -> mcpInstructions + * + * @param {Object} params + * @param {string} [params.sharedRunContext] - Run-level context shared by all agents (file context, RAG, memory) + * @param {string} [params.baseInstructions] - Agent's base instructions + * @param {string} [params.mcpInstructions] - Agent's MCP server instructions + * @returns {string | undefined} Combined instructions, or undefined if empty + */ +export function buildAgentInstructions({ + sharedRunContext, + baseInstructions, + mcpInstructions, +}: { + sharedRunContext?: string; + baseInstructions?: string; + mcpInstructions?: string; +}): string | undefined { + const parts = [sharedRunContext, baseInstructions, mcpInstructions].filter(Boolean); + const combined = parts.join('\n\n').trim(); + return combined || undefined; +} + +/** + * Applies run context and MCP instructions to an agent's configuration. + * Mutates the agent object in place. + * + * @param {Object} params + * @param {Agent} params.agent - The agent to update + * @param {string} params.sharedRunContext - Run-level shared context + * @param {MCPManager} params.mcpManager - MCP manager instance + * @param {Object} [params.ephemeralAgent] - Ephemeral agent config (for MCP override) + * @param {string} [params.agentId] - Agent ID for logging + * @param {Logger} [params.logger] - Optional logger instance + * @returns {Promise} + */ +export async function applyContextToAgent({ + agent, + sharedRunContext, + mcpManager, + ephemeralAgent, + agentId, + logger, +}: { + agent: AgentWithTools; + sharedRunContext: string; + mcpManager: MCPManager; + ephemeralAgent?: TEphemeralAgent; + agentId?: string; + logger?: Logger; +}): Promise { + const baseInstructions = agent.instructions || ''; + + try { + const mcpServers = ephemeralAgent?.mcp?.length ? ephemeralAgent.mcp : extractMCPServers(agent); + const mcpInstructions = await getMCPInstructionsForServers(mcpServers, mcpManager, logger); + + agent.instructions = buildAgentInstructions({ + sharedRunContext, + baseInstructions, + mcpInstructions, + }); + + if (agentId && logger) { + logger.debug(`[AgentContext] Applied context to agent: ${agentId}`); + } + } catch (error) { + agent.instructions = buildAgentInstructions({ + sharedRunContext, + baseInstructions, + mcpInstructions: '', + }); + + if (logger) { + logger.error( + `[AgentContext] Failed to apply context to agent${agentId ? ` ${agentId}` : ''}, using base instructions only:`, + error, + ); + } + } +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 5efc22a397..77e7f9e2cc 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 './context'; export * from './edges'; export * from './initialize'; export * from './legacy'; diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index 9e1deb20c1..da773071e7 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -217,7 +217,7 @@ export type Agent = { description: string | null; created_at: number; avatar: AgentAvatar | null; - instructions: string | null; + instructions?: string | null; additional_instructions?: string | null; tools?: string[]; projectIds?: string[];