From dccd7ae496d947d29fa05c5818ca5cf2393d70c9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 1 Sep 2026 18:37:45 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B6=20fix:=20Preserve=20Multi-Agent=20?= =?UTF-8?q?Tool=20Call=20History=20(#15468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧭 fix: Preserve Multi-Agent Tool History * 🧰 fix: Preserve Lazy Subagent Tool History * πŸ§ͺ test: Enable Lazy Code History Fixture * 🧭 fix: Derive Effective Lazy Tool History * πŸ§ͺ fix: Tolerate Empty MCP Name Audits * πŸ§ͺ test: Mock Lazy MCP Name Audit * 🧭 fix: Expand Lazy Historical Tool Names * 🧱 fix: Derive Durable Wildcard History * 🧬 fix: Traverse Nested Wildcard History * πŸ”­ fix: Resolve Wildcard Server Boundaries * πŸ” fix: Persist MCP Call Identity * πŸͺͺ fix: Stamp Resolved MCP Execution Identity * πŸ•ΈοΈ fix: Resolve Graph Member MCP Identity * 🧭 fix: Fall Back From Empty Graph Member ID * 🧭 fix: Preserve Aggregated MCP Identity * 🧭 fix: Harden MCP Identity Resolution * 🧭 test: Assert Concurrent Graph Limit Invariant * 🧭 fix: Centralize Lazy History Policy * πŸ“¦ chore: Bump `@librechat/agents` to v3.7.13 * πŸ§ͺ test: Align Content Filter Fixture With SDK History --- api/package.json | 2 +- .../agents/__tests__/callbacks.spec.js | 35 +++ .../agents/__tests__/openai.spec.js | 2 +- .../agents/__tests__/responses.unit.spec.js | 2 +- api/server/controllers/agents/callbacks.js | 48 +++- api/server/controllers/agents/client.js | 21 +- api/server/controllers/agents/client.test.js | 92 +++++- api/server/controllers/agents/openai.js | 10 +- api/server/controllers/agents/responses.js | 10 +- .../services/Endpoints/agents/initialize.js | 272 ++++++++++-------- .../Endpoints/agents/initialize.spec.js | 212 +++++++++++++- .../mock/content-filters.persisted.spec.ts | 4 +- package-lock.json | 10 +- packages/api/package.json | 2 +- packages/api/src/agents/history.spec.ts | 104 +++++++ packages/api/src/agents/index.ts | 2 + packages/api/src/agents/lazyHistory.spec.ts | 70 +++++ packages/api/src/agents/lazyHistory.ts | 186 ++++++++++++ packages/api/src/agents/mcpIdentity.spec.ts | 52 ++++ packages/api/src/agents/mcpIdentity.ts | 93 ++++++ packages/api/src/agents/tools.spec.ts | 227 +++++++++++++++ packages/api/src/agents/tools.ts | 258 ++++++++++++++++- packages/data-provider/src/types/agents.ts | 2 + 23 files changed, 1566 insertions(+), 150 deletions(-) create mode 100644 packages/api/src/agents/history.spec.ts create mode 100644 packages/api/src/agents/lazyHistory.spec.ts create mode 100644 packages/api/src/agents/lazyHistory.ts create mode 100644 packages/api/src/agents/mcpIdentity.spec.ts create mode 100644 packages/api/src/agents/mcpIdentity.ts diff --git a/api/package.json b/api/package.json index 4dcbe1e049..2ac5107e36 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.7.11", + "@librechat/agents": "^3.7.13", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js index aec8c48a9a..383d1d083c 100644 --- a/api/server/controllers/agents/__tests__/callbacks.spec.js +++ b/api/server/controllers/agents/__tests__/callbacks.spec.js @@ -124,6 +124,41 @@ describe('resumable event generation fencing', () => { ); }); + it('resolves MCP identity from a function-shaped root tool call', async () => { + const { GraphEvents } = jest.requireActual('@librechat/agents'); + const { getDefaultHandlers } = require('../callbacks'); + const resolveMcpServerName = jest.fn(() => 'server'); + const data = { + id: 'step-function-tool', + index: 0, + stepDetails: { + type: 'tool_calls', + tool_calls: [ + { + id: 'call-function-tool', + function: { name: 'lookup_mcp_server', arguments: '{}' }, + }, + ], + }, + }; + const handlers = getDefaultHandlers({ + res: { write: jest.fn() }, + aggregateContent: jest.fn(), + toolEndCallback: jest.fn(), + collectedUsage: [], + resolveMcpServerName, + }); + + await handlers[GraphEvents.ON_RUN_STEP].handle(GraphEvents.ON_RUN_STEP, data, { + agent_id: 'lazy-agent', + }); + + expect(resolveMcpServerName).toHaveBeenCalledWith('lookup_mcp_server', 'lazy-agent'); + expect(data.stepDetails.tool_calls[0]).toEqual( + expect.objectContaining({ name: 'lookup_mcp_server', mcpServerName: 'server' }), + ); + }); + it('publishes root event-child progress through the child activity transport', async () => { const { nanoid } = require('nanoid'); nanoid.mockReturnValueOnce('invocation-1').mockReturnValueOnce('invocation-2'); diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index 9dab458650..feca57e144 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -203,7 +203,7 @@ jest.mock('@librechat/api', () => ({ buildAgentContextAttachmentsByAgentId: (...args) => mockBuildAgentContextAttachmentsByAgentId(...args), createChunk: jest.fn().mockReturnValue({}), - buildToolSet: jest.fn().mockReturnValue(new Set()), + buildRunToolSet: jest.fn().mockReturnValue(new Set()), buildInitialToolSessions: jest.fn().mockReturnValue(mockInitialSessions), AgentRunEnvelopeError: MockAgentRunEnvelopeError, createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args), diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 7570cd6c8e..49b53bac89 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -223,7 +223,7 @@ jest.mock('@librechat/api', () => ({ }), buildInitialToolSessions: jest.fn().mockReturnValue(mockInitialSessions), applyContextToAgent: (...args) => mockApplyContextToAgent(...args), - buildToolSet: jest.fn().mockReturnValue(new Set()), + buildRunToolSet: jest.fn().mockReturnValue(new Set()), AgentRunEnvelopeError: MockAgentRunEnvelopeError, createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args), createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({ diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 5f3579fd34..7dc0dd5b02 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -327,13 +327,30 @@ function subagentPhaseToGraphEvent(event) { /** * Folds a single {@link SubagentUpdateEvent} into the given content * aggregator. Silent no-op for phases outside the aggregator's domain. - * @param {{ aggregateContent: Function }} aggregator + * @param {{ aggregateContent: Function, contentParts?: Array, stepMap?: Map }} aggregator * @param {SubagentUpdateEvent} event */ function feedSubagentAggregator(aggregator, event) { const graphEvent = subagentPhaseToGraphEvent(event); if (!graphEvent) return; aggregator.aggregateContent({ event: graphEvent, data: event.data }); + + /** The SDK aggregator intentionally projects run-step tool calls onto its + * public content shape, so host-only routing metadata is not copied. Restore + * the server-owned identity by call id after that projection; otherwise the + * persistence fallback has to parse an ambiguous delimiter-bearing name. */ + const toolCalls = event.data?.stepDetails?.tool_calls ?? []; + const stepIndex = aggregator.stepMap?.get(event.data?.id)?.index; + if (!Number.isInteger(stepIndex) || !Array.isArray(aggregator.contentParts)) { + return; + } + for (let index = 0; index < toolCalls.length; index++) { + const source = toolCalls[index]; + const target = aggregator.contentParts[stepIndex + index]?.tool_call; + if (target?.id === source?.id && typeof source?.mcpServerName === 'string') { + target.mcpServerName = source.mcpServerName; + } + } } /** @@ -362,6 +379,7 @@ function feedSubagentAggregator(aggregator, event) { * used to persist the breakdown only when the final call emitted usage. * @param {Array} [options.usageEmitSink] - Array collecting each emitted * `on_token_usage` payload (incl. cost) so the response's usage rollup can be persisted. + * @param {(toolName: string, agentId?: string) => string | undefined} [options.resolveMcpServerName] * @returns {Record} The default handlers. * @throws {Error} If the request is not found. */ @@ -383,6 +401,7 @@ function getDefaultHandlers({ contextUsageSink = null, usageEmitSink = null, eventChildActivity = null, + resolveMcpServerName = null, }) { if (!res || !aggregateContent) { throw new Error( @@ -496,6 +515,19 @@ function getDefaultHandlers({ * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata. */ handle: async (event, data, metadata) => { + for (const toolCall of data?.stepDetails?.tool_calls ?? []) { + const toolName = toolCall?.name ?? toolCall?.function?.name; + if (toolCall?.name == null && typeof toolName === 'string') { + toolCall.name = toolName; + } + const serverName = resolveMcpServerName?.( + toolName, + metadata?.agent_id ?? metadata?.agentId, + ); + if (serverName) { + toolCall.mcpServerName = serverName; + } + } aggregateContent({ event, data }); if (data?.stepDetails.type === StepTypes.TOOL_CALLS) { await emitForJob({ event, data }); @@ -699,6 +731,20 @@ function getDefaultHandlers({ * consistent "don't record" rule for subagent traces. */ if (!visible) return; + const memberAgentId = + typeof data?.memberAgentId === 'string' && data.memberAgentId.trim() !== '' + ? data.memberAgentId + : data?.subagentAgentId; + for (const toolCall of data?.data?.stepDetails?.tool_calls ?? []) { + const toolName = toolCall?.name ?? toolCall?.function?.name; + if (toolCall?.name == null && typeof toolName === 'string') { + toolCall.name = toolName; + } + const serverName = resolveMcpServerName?.(toolName, memberAgentId); + if (serverName) { + toolCall.mcpServerName = serverName; + } + } if (subagentAggregatorsByToolCallId && data?.parentToolCallId) { const key = data.parentToolCallId; let aggregator = subagentAggregatorsByToolCallId.get(key); diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index efcf38ed83..3f06de59ee 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -9,7 +9,7 @@ const { createRun, isEnabled, checkAccess, - buildToolSet, + buildRunToolSet, logToolError, sanitizeTitle, payloadParser, @@ -127,6 +127,7 @@ const { hasModelBoundContentProtection, assertResumeRuntimeContentAllowed, collectReachableAgents, + stampMcpServerIdentities, getDynamicToolContexts, getSafeErrorMetadata, createInitializedAgentContextFingerprint, @@ -507,6 +508,15 @@ class AgentClient extends BaseClient { buffer.clear(); } + /** Stamps host-resolved MCP identities onto persisted calls so future replay + * can distinguish delimiter-bearing tool names from longer server names. */ + stampMcpServerIdentities() { + stampMcpServerIdentities({ + contentParts: this.contentParts, + roots: [this.options.agent, ...(this.agentConfigs?.values() ?? [])], + }); + } + /** * Apply one drained steer to host state: append the steer content part at * the live content index, bump the shared index offset so subsequent SDK @@ -3852,7 +3862,12 @@ class AgentClient extends BaseClient { version: 'v2', }; - const toolSet = buildToolSet(this.options.agent); + const toolSet = buildRunToolSet( + this.options.agent, + this.agentConfigs?.values(), + this.options.subagentTasks == null ? undefined : [Constants.CHECK_BACKGROUND_TASK], + payload, + ); const tokenCounter = await createCachedTokenCounter(this.getEncoding()); /** Pre-resolve invoked skill bodies + re-prime files before formatting messages */ @@ -4423,6 +4438,7 @@ class AgentClient extends BaseClient { } this.finalizeSubagentContent(); + this.stampMcpServerIdentities(); await this.settleActivityLabels(); /** Flush subagent usage emits the sink fired without awaiting, so their @@ -4858,6 +4874,7 @@ class AgentClient extends BaseClient { } this.finalizeSubagentContent(); + this.stampMcpServerIdentities(); await this.settleActivityLabels(); if (this.pendingSubagentEmits.length > 0) { diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 94773ed745..7e04e768f4 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -6567,7 +6567,7 @@ describe('AgentClient - finalizeSubagentContent', () => { * `ON_SUBAGENT_UPDATE` handler so we exercise the same get-or-create * aggregator logic the live request uses, rather than constructing * aggregators directly in the test. */ - const runSubagentEvents = async (events) => { + const runSubagentEvents = async (events, resolveMcpServerName) => { const map = new Map(); const handlers = getDefaultHandlers({ res: { write: jest.fn(), writableEnded: false }, @@ -6575,6 +6575,7 @@ describe('AgentClient - finalizeSubagentContent', () => { toolEndCallback: jest.fn(), collectedUsage: [], subagentAggregatorsByToolCallId: map, + resolveMcpServerName, }); const handler = handlers[GraphEvents.ON_SUBAGENT_UPDATE]; for (const e of events) { @@ -6651,6 +6652,94 @@ describe('AgentClient - finalizeSubagentContent', () => { expect(buffer.size).toBe(0); }); + it('stamps durable MCP server identity onto nested persisted tool calls', () => { + const client = makeClient(new Map()); + client.options.agent.accessibleMcpServerNames = ['bar', 'foo_mcp_bar']; + client.options.agent.toolDefinitions = [ + { + name: 'gitlab-get_mcp_server_version_mcp_bar', + serverName: 'bar', + }, + ]; + client.contentParts = [ + { + type: 'tool_call', + tool_call: { + name: Constants.SUBAGENT, + subagent_content: [ + { + type: 'tool_call', + tool_call: { name: 'gitlab-get_mcp_server_version_mcp_bar' }, + }, + ], + }, + }, + ]; + + client.stampMcpServerIdentities(); + + expect(client.contentParts[0].tool_call.subagent_content[0].tool_call.mcpServerName).toBe( + 'bar', + ); + }); + + it('retains the resolved lazy-agent MCP identity for an ambiguous nested key', async () => { + const resolveMcpServerName = jest.fn(() => 'bar'); + const buffer = await runSubagentEvents( + [ + { + ...event('run_step', { + id: 'step_tool', + index: 0, + stepDetails: { + type: 'tool_calls', + tool_calls: [ + { + id: 'inner_1', + function: { name: 'lookup_mcp_foo_mcp_bar', arguments: '{}' }, + }, + ], + }, + }), + memberAgentId: 'member', + }, + ], + resolveMcpServerName, + ); + const client = makeClient(buffer); + client.options.agent.accessibleMcpServerNames = ['bar', 'foo_mcp_bar']; + client.contentParts = [ + { + type: 'tool_call', + tool_call: { id: 'call_sub', name: Constants.SUBAGENT, args: '{}' }, + }, + ]; + + client.finalizeSubagentContent(); + const inner = client.contentParts[0].tool_call.subagent_content[0].tool_call; + expect(resolveMcpServerName).toHaveBeenCalledWith('lookup_mcp_foo_mcp_bar', 'member'); + expect(inner.mcpServerName).toBe('bar'); + + const emptyMemberResolver = jest.fn(() => 'bar'); + await runSubagentEvents( + [ + { + ...event('run_step', { + id: 'step_tool_2', + index: 0, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'inner_2', name: 'lookup_mcp_foo_mcp_bar', args: '{}' }], + }, + }), + memberAgentId: '', + }, + ], + emptyMemberResolver, + ); + expect(emptyMemberResolver).toHaveBeenCalledWith('lookup_mcp_foo_mcp_bar', 'child'); + }); + it('ignores tool_call parts whose name is not SUBAGENT', async () => { const buffer = await runSubagentEvents([ event( @@ -6809,6 +6898,7 @@ describe('AgentClient - resumeCompletion content protection', () => { applyHideSequentialOutputsFilter: jest.fn(), rebaseActivityPhaseBounds: jest.fn(), finalizeSubagentContent: jest.fn(), + stampMcpServerIdentities: jest.fn(), settleActivityLabels: jest.fn().mockResolvedValue(undefined), recordCollectedUsage: jest.fn().mockResolvedValue(undefined), }); diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index c327ce7f98..3e2bdb1b8d 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -13,7 +13,7 @@ const { createRun, createChunk, applyContextToAgent, - buildToolSet, + buildRunToolSet, buildInitialToolSessions, buildAgentScopedContext, buildInlineMemoryContext, @@ -806,7 +806,13 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { const openaiMessages = convertMessages(request.messages); - const toolSet = buildToolSet(primaryConfig); + const toolSet = buildRunToolSet( + primaryConfig, + handoffAgentConfigs.values(), + undefined, + openaiMessages, + true, + ); const formatted = formatAgentMessages(stripActivityLabelParts(openaiMessages), {}, toolSet); const formattedMessages = formatted.messages; const initialSummary = formatted.summary; diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 9abc9d22b6..f80f0ebd57 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -13,7 +13,7 @@ const { createRun, applyContextToAgent, buildInitialToolSessions, - buildToolSet, + buildRunToolSet, AgentRunEnvelopeError, createAgentRunEnvelope, createAgentExecutionContext, @@ -1004,7 +1004,13 @@ const executeResponse = async (envelope, { req, res }) => { // Merge previous messages with new input const allMessages = [...previousMessages, ...inputMessages]; - const toolSet = buildToolSet(primaryConfig); + const toolSet = buildRunToolSet( + primaryConfig, + handoffAgentConfigs.values(), + undefined, + allMessages, + true, + ); const formatted = formatAgentMessages(stripActivityLabelParts(allMessages), {}, toolSet); const formattedMessages = formatted.messages; const initialSummary = formatted.summary; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 53732e2e5d..1c0f71c5e0 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -14,7 +14,6 @@ const { discoverConnectedAgents, resolveAgentTokenConfig, resolveAgentScopedSkillIds, - resolveAlwaysApplySkills, resolveModelSpecSkillIds, getAgentStartupTelemetry, isContentFilterError, @@ -25,6 +24,7 @@ const { createStatefulCodeEnvironmentPolicyError, buildSubagentThreadTaskConfig, backgroundCompletionWakeupsEnabled, + createLazyAgentHistoryResolver, } = require('@librechat/api'); const { ResourceType, @@ -33,6 +33,7 @@ const { MAX_SUBAGENT_DEPTH, isAgentsEndpoint, AgentCapabilities, + normalizeServerName, Tools, MAX_SUBAGENT_GRAPH_NODES, MAX_SUBAGENT_RUN_CONFIGS, @@ -228,6 +229,8 @@ const initializeClient = async ({ const codeEnvAvailable = enabledCapabilities.has(AgentCapabilities.execute_code); const backgroundToolsAvailable = enabledCapabilities.has(AgentCapabilities.run_in_background); const toolIntentsAvailable = enabledCapabilities.has(AgentCapabilities.tool_intents); + const deferredToolsAvailable = enabledCapabilities.has(AgentCapabilities.deferred_tools); + const programmaticToolsAvailable = enabledCapabilities.has(AgentCapabilities.programmatic_tools); const statefulSessionsAvailable = enabledCapabilities.has( AgentCapabilities.stateful_code_sessions, ); @@ -318,6 +321,22 @@ const initializeClient = async ({ * }>} */ const agentToolContexts = new Map(); + const resolveMcpServerName = (toolName, agentId) => { + if (typeof toolName !== 'string' || typeof agentId !== 'string') { + return undefined; + } + const context = agentToolContexts.get(agentId); + const registered = context?.toolRegistry?.get?.(toolName); + if (typeof registered?.mcpRawServerName === 'string') { + return normalizeServerName(registered.mcpRawServerName); + } + for (const [serverName, tools] of Object.entries(context?.mcpAvailableTools ?? {})) { + if (tools?.[toolName]?.function) { + return normalizeServerName(serverName); + } + } + return undefined; + }; /** Attach only the host-resolved route for the actually executing agent. * Runnable metadata is transport data and may contain caller-controlled * keys, so discard any incoming route context before resolving from the @@ -779,12 +798,8 @@ const initializeClient = async ({ const skippedAgentIds = new Set(discoveredSkippedIds ?? []); const lazyMetadataByAgentId = new Map(); - const eventActorContextRequested = - req._isAgentTrigger === true && req._agentEventBindingParentConversationId != null; - /** Request-scoped cache: lazy descriptors sharing the same Skill ACL scope - * reuse one metadata-only always-apply lookup without initializing tools, - * files, model clients, or MCP connections. */ - const lazyAlwaysApplySkillsByScope = new Map(); + const lazyMetadataLoadsByAgentId = new Map(); + const resolveLazyMetadata = createConcurrencyLimiter(SUBAGENT_GRAPH_LOAD_CONCURRENCY); const subagentGraphIds = new Set(); const expandedSubagentDescriptorState = { configCount: 0, rootAgentIds: [] }; @@ -895,36 +910,32 @@ const initializeClient = async ({ ), ); - const resolveLazyAlwaysApplySkillPrimes = (agent) => { - if (!eventActorContextRequested) { - return Promise.resolve([]); - } - const scopedSkillIds = resolveAgentScopedSkillIds({ - agent, - accessibleSkillIds, - skillsCapabilityEnabled, - ephemeralSkillsToggle, - }); - if (scopedSkillIds.length === 0 || typeof skillDbMethods.listAlwaysApplySkills !== 'function') { - return Promise.resolve([]); - } - const scopeKey = scopedSkillIds - .map((skillId) => skillId.toString()) - .sort() - .join(':'); - let resolution = lazyAlwaysApplySkillsByScope.get(scopeKey); - if (resolution == null) { - resolution = resolveAlwaysApplySkills({ - listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills, - accessibleSkillIds: scopedSkillIds, - userId, - skillStates, - defaultActiveOnShare, - }); - lazyAlwaysApplySkillsByScope.set(scopeKey, resolution); - } - return resolution; - }; + const lazyHistoryResolver = createLazyAgentHistoryResolver({ + accessibleSkillIds, + editableSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + userId, + userRole, + skillStates, + defaultActiveOnShare, + maxCatalogSkills: appConfig?.endpoints?.[EModelEndpoint.agents]?.skills?.maxCatalogSkills, + listSkillsByAccess: skillDbMethods.listSkillsByAccess, + listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills, + getAccessibleMcpServerNames, + configuredMcpServerNames: Object.keys(appConfig?.mcpConfig ?? {}), + canAuthorSkillFiles: ({ agent, scopedEditableSkillIds }) => + canAuthorSkillFiles({ + agent, + scopedEditableSkillIds, + skillCreateAllowed, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + deferredToolsAvailable, + programmaticToolsAvailable, + backgroundToolsAvailable, + }); const toLazySubagentMetadata = async (agent) => { const lazyCodeEnvAvailable = @@ -958,6 +969,12 @@ const initializeClient = async ({ conversationId, }) : undefined; + const { alwaysApplySkillPrimes, historicalToolNames, historicalMcpServerNames } = + await lazyHistoryResolver.resolve({ + agent, + codeExecutionAvailable: lazyCodeEnvAvailable, + memoryAvailable, + }); return { id: agent.id, name: agent.name, @@ -977,7 +994,9 @@ const initializeClient = async ({ codeExecutionContext, codeSessionKey: codeExecutionContext?.codeSessionKey, includeReasoningHistory: getIncludeReasoningHistory(agent), - alwaysApplySkillPrimes: await resolveLazyAlwaysApplySkillPrimes(agent), + alwaysApplySkillPrimes, + historicalToolNames, + historicalMcpServerNames, }; }; @@ -985,15 +1004,22 @@ const initializeClient = async ({ if (skippedAgentIds.has(agentId)) return null; const cached = lazyMetadataByAgentId.get(agentId); if (cached) return cached; + let loading = lazyMetadataLoadsByAgentId.get(agentId); + if (!loading) { + loading = resolveLazyMetadata(async () => { + const agent = await db.getAgentWithVersionCount({ id: agentId }); + if (!agent || !(await hasSubagentViewAccess(agent, agentId))) { + skippedAgentIds.add(agentId); + return null; + } + const metadata = await toLazySubagentMetadata(agent); + lazyMetadataByAgentId.set(agentId, metadata); + return metadata; + }); + lazyMetadataLoadsByAgentId.set(agentId, loading); + } try { - const agent = await db.getAgentWithVersionCount({ id: agentId }); - if (!agent || !(await hasSubagentViewAccess(agent, agentId))) { - skippedAgentIds.add(agentId); - return null; - } - const metadata = await toLazySubagentMetadata(agent); - lazyMetadataByAgentId.set(agentId, metadata); - return metadata; + return await loading; } catch (error) { if (isFatalAgentInitializationError(error)) { throw error; @@ -1001,6 +1027,10 @@ const initializeClient = async ({ logger.error(`[initializeClient] Error loading subagent metadata ${agentId}:`, error); skippedAgentIds.add(agentId); return null; + } finally { + if (lazyMetadataLoadsByAgentId.get(agentId) === loading) { + lazyMetadataLoadsByAgentId.delete(agentId); + } } }; @@ -1158,90 +1188,95 @@ const initializeClient = async ({ } const nextAncestors = new Set(ancestors); nextAncestors.add(agent.id); - const descriptors = []; - for (const subagentId of subagentIds) { - if (skippedAgentIds.has(subagentId) || nextAncestors.has(subagentId)) continue; - if (subagentId !== primaryConfig.id) { - assertSubagentGraphRoom(subagentId); - } - const existing = - subagentId === primaryConfig.id ? primaryConfig : agentConfigs.get(subagentId); - if (existing) { - countExpandedSubagentDescriptor(subagentId); + const descriptors = await Promise.all( + subagentIds.map(async (subagentId) => { + if (skippedAgentIds.has(subagentId) || nextAncestors.has(subagentId)) return null; + const existing = + subagentId === primaryConfig.id ? primaryConfig : agentConfigs.get(subagentId); + if (existing) { + if (subagentId !== primaryConfig.id) { + assertSubagentGraphRoom(subagentId); + subagentGraphIds.add(subagentId); + } + countExpandedSubagentDescriptor(subagentId); + const existingChildren = await buildLazySubagentDescriptors( + existing, + depth + 1, + nextAncestors, + ); + existing.lazySubagentConfigs = existingChildren.filter((child) => child.configId); + existing.subagentAgentConfigs = existingChildren.filter((child) => !child.configId); + return existing; + } + const metadata = await loadSubagentMetadata(subagentId); + if (!metadata) return null; if (subagentId !== primaryConfig.id) { + assertSubagentGraphRoom(subagentId); subagentGraphIds.add(subagentId); } - const existingChildren = await buildLazySubagentDescriptors( - existing, + countExpandedSubagentDescriptor(subagentId); + const childDescriptors = await buildLazySubagentDescriptors( + metadata, depth + 1, nextAncestors, ); - existing.lazySubagentConfigs = existingChildren.filter((child) => child.configId); - existing.subagentAgentConfigs = existingChildren.filter((child) => !child.configId); - descriptors.push(existing); - continue; - } - const metadata = await loadSubagentMetadata(subagentId); - if (!metadata) continue; - countExpandedSubagentDescriptor(subagentId); - subagentGraphIds.add(subagentId); - const childDescriptors = await buildLazySubagentDescriptors( - metadata, - depth + 1, - nextAncestors, - ); - const lazyChildren = childDescriptors.filter((child) => child.configId); - const eagerChildren = childDescriptors.filter((child) => !child.configId); - const subagentGraphMemberMetadata = await loadGraphMemberCapabilityMetadata(metadata); - descriptors.push({ - id: metadata.id, - name: metadata.name, - description: metadata.description, - provider: metadata.provider, - model: metadata.model, - model_parameters: metadata.model_parameters, - recursion_limit: metadata.recursion_limit, - memory_scope: metadata.memory_scope, - memoryToolsRegistered: metadata.memoryToolsRegistered, - subagents: metadata.subagents, - configId: metadata.configId, - codeEnvAvailable: metadata.codeEnvAvailable, - statefulCodeSessions: metadata.statefulCodeSessions, - statefulCodeEnvironment: metadata.statefulCodeEnvironment, - codeExecutionContext: metadata.codeExecutionContext, - codeSessionKey: metadata.codeSessionKey, - includeReasoningHistory: metadata.includeReasoningHistory, - alwaysApplySkillPrimes: metadata.alwaysApplySkillPrimes, - lazySubagentConfigs: lazyChildren, - subagentAgentConfigs: eagerChildren, - subagentGraphMemberMetadata, - resolve: async (context) => - initializeLazySubagent({ - agentId: metadata.id, - configId: metadata.configId, - context, - lazyChildren, - }).then(async (config) => { - config.subagentAgentConfigs = eagerChildren; - graphMemberConfigsById.set(config.id, config); - await resolveGraphSubagentsFor(config, context.signal); - return config; - }), - }); - } - return descriptors; + const lazyChildren = childDescriptors.filter((child) => child.configId); + const eagerChildren = childDescriptors.filter((child) => !child.configId); + const subagentGraphMemberMetadata = await loadGraphMemberCapabilityMetadata(metadata); + return { + id: metadata.id, + name: metadata.name, + description: metadata.description, + provider: metadata.provider, + model: metadata.model, + model_parameters: metadata.model_parameters, + recursion_limit: metadata.recursion_limit, + memory_scope: metadata.memory_scope, + memoryToolsRegistered: metadata.memoryToolsRegistered, + subagents: metadata.subagents, + configId: metadata.configId, + codeEnvAvailable: metadata.codeEnvAvailable, + statefulCodeSessions: metadata.statefulCodeSessions, + statefulCodeEnvironment: metadata.statefulCodeEnvironment, + codeExecutionContext: metadata.codeExecutionContext, + codeSessionKey: metadata.codeSessionKey, + includeReasoningHistory: metadata.includeReasoningHistory, + alwaysApplySkillPrimes: metadata.alwaysApplySkillPrimes, + historicalToolNames: metadata.historicalToolNames, + historicalMcpServerNames: metadata.historicalMcpServerNames, + lazySubagentConfigs: lazyChildren, + subagentAgentConfigs: eagerChildren, + subagentGraphMemberMetadata, + resolve: async (context) => + initializeLazySubagent({ + agentId: metadata.id, + configId: metadata.configId, + context, + lazyChildren, + }).then(async (config) => { + config.subagentAgentConfigs = eagerChildren; + graphMemberConfigsById.set(config.id, config); + await resolveGraphSubagentsFor(config, context.signal); + return config; + }), + }; + }), + ); + return descriptors.filter(Boolean); }; const resolveSubagentTrees = async (rootConfigs) => { expandedSubagentDescriptorState.rootAgentIds = rootConfigs .filter((config) => config?.id) .map((config) => config.id); - for (const config of rootConfigs) { - if (!config?.id) continue; - const descriptors = await buildLazySubagentDescriptors(config); - config.lazySubagentConfigs = descriptors.filter((child) => child.configId); - config.subagentAgentConfigs = descriptors.filter((child) => !child.configId); - } + await Promise.all( + rootConfigs.map(async (config) => { + if (!config?.id) return; + const descriptors = await buildLazySubagentDescriptors(config); + config.lazySubagentConfigs = descriptors.filter((child) => child.configId); + config.subagentAgentConfigs = descriptors.filter((child) => !child.configId); + }), + ); }; const rootSubagentConfigs = [primaryConfig, ...agentConfigs.values()]; @@ -1543,6 +1578,7 @@ const initializeClient = async ({ contextUsageSink, usageEmitSink, eventChildActivity, + resolveMcpServerName, }); const client = new AgentClient({ diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 7251d7d980..d23bde9089 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -59,9 +59,11 @@ jest.mock('~/server/controllers/agents/callbacks', () => ({ })); const mockLoadToolsForExecution = jest.fn(); +const mockGetAccessibleMcpServerNames = jest.fn(); jest.mock('~/server/services/ToolService', () => ({ loadAgentTools: jest.fn(), loadToolsForExecution: (...args) => mockLoadToolsForExecution(...args), + getAccessibleMcpServerNames: (...args) => mockGetAccessibleMcpServerNames(...args), isFatalAgentInitializationError: (error) => [ 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE', @@ -667,6 +669,8 @@ describe('initializeClient β€” subagent loading', () => { capturedToolExecuteOptions = undefined; mockLoadToolsForExecution.mockReset(); mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [], configurable: {} }); + mockGetAccessibleMcpServerNames.mockReset(); + mockGetAccessibleMcpServerNames.mockResolvedValue([]); testUser = await User.create({ email: 'subagent@example.com', @@ -925,7 +929,7 @@ describe('initializeClient β€” subagent loading', () => { provider: 'openai', model: 'gpt-4', author: new mongoose.Types.ObjectId(), - tools: ['run_query_mcp_warehouse'], + tools: [`${Constants.mcp_all}${Constants.mcp_delimiter}warehouse`], }); await grantView(subAgent); @@ -934,6 +938,7 @@ describe('initializeClient β€” subagent loading', () => { statusCode: 503, }); loadAgentTools.mockRejectedValueOnce(toolError); + mockGetAccessibleMcpServerNames.mockResolvedValueOnce(['warehouse']); mockInitializeAgent .mockResolvedValueOnce( makePrimaryConfig({ @@ -959,6 +964,13 @@ describe('initializeClient β€” subagent loading', () => { }); expect(mockInitializeAgent).toHaveBeenCalledTimes(1); + expect(mockGetAccessibleMcpServerNames).toHaveBeenCalledWith(testUser._id.toString(), 'USER'); + expect(agentClientArgs.agent.lazySubagentConfigs[0].historicalToolNames).toEqual([ + `${Constants.mcp_all}${Constants.mcp_delimiter}warehouse`, + ]); + expect(agentClientArgs.agent.lazySubagentConfigs[0].historicalMcpServerNames).toEqual([ + 'warehouse', + ]); await expect( agentClientArgs.agent.lazySubagentConfigs[0].resolve({ signal: new AbortController().signal, @@ -990,13 +1002,23 @@ describe('initializeClient β€” subagent loading', () => { ) .mockRejectedValueOnce(resourceRecoveryError); + const req = makeSubagentReq(); + req.config.endpoints.agents.capabilities.push('execute_code'); await initializeClient({ - req: makeSubagentReq(), + req, res: {}, signal: new AbortController().signal, endpointOption: makeEndpointOption(), }); + expect(agentClientArgs.agent.lazySubagentConfigs[0].historicalToolNames).toEqual([ + 'execute_code', + 'bash_tool', + 'read_file', + 'create_file', + 'edit_file', + ]); + await expect( agentClientArgs.agent.lazySubagentConfigs[0].resolve({ signal: new AbortController().signal, @@ -1049,6 +1071,7 @@ describe('initializeClient β€” subagent loading', () => { statefulCodeEnvironment: 'agent-user', memory_scope: 'agent', memoryToolsRegistered: false, + historicalToolNames: ['web'], }), ); expect(agentClientArgs.agent.lazySubagentConfigs[0]).not.toHaveProperty('tools'); @@ -1134,9 +1157,19 @@ describe('initializeClient β€” subagent loading', () => { }); expect(mockInitializeAgent).toHaveBeenCalledTimes(1); - expect(listAlwaysApplySkillsSpy).not.toHaveBeenCalled(); + expect(listAlwaysApplySkillsSpy).toHaveBeenCalledTimes(1); for (const descriptor of agentClientArgs.agent.lazySubagentConfigs) { - expect(descriptor.alwaysApplySkillPrimes).toEqual([]); + expect(descriptor.alwaysApplySkillPrimes).toEqual([ + expect.objectContaining({ + _id: skill._id, + name: 'lazy-specialist', + version: 1, + body: '# Lazy specialist v1\n', + }), + ]); + expect(descriptor.historicalToolNames).toEqual( + expect.arrayContaining(['skill', 'read_file']), + ); } const eventReq = makeSubagentReq(); @@ -1151,7 +1184,7 @@ describe('initializeClient β€” subagent loading', () => { }); expect(mockInitializeAgent).toHaveBeenCalledTimes(2); - expect(listAlwaysApplySkillsSpy).toHaveBeenCalledTimes(1); + expect(listAlwaysApplySkillsSpy).toHaveBeenCalledTimes(2); expect(agentClientArgs.agent.lazySubagentConfigs).toHaveLength(2); for (const descriptor of agentClientArgs.agent.lazySubagentConfigs) { expect(descriptor.alwaysApplySkillPrimes).toEqual([ @@ -1162,12 +1195,163 @@ describe('initializeClient β€” subagent loading', () => { body: '# Lazy specialist v1\n', }), ]); + expect(descriptor.historicalToolNames).toEqual( + expect.arrayContaining(['skill', 'read_file']), + ); } } finally { listAlwaysApplySkillsSpy.mockRestore(); } }); + it('loads independent lazy Skill scopes concurrently', async () => { + const secondSubagentId = 'agent_subagent_parallel_skill_2'; + const skills = []; + for (const name of ['parallel-skill-one', 'parallel-skill-two']) { + const { skill } = await createSkill({ + name, + description: `${name} description`, + body: `# ${name}\n`, + alwaysApply: true, + author: testUser._id, + authorName: testUser.name, + }); + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.SKILL, + resourceId: skill._id, + permBits: PermissionBits.VIEW, + grantedBy: testUser._id, + }); + skills.push(skill); + } + for (const [id, skill] of [ + [SUBAGENT_ID, skills[0]], + [secondSubagentId, skills[1]], + ]) { + const agent = await createAgent({ + id, + name: id, + provider: 'openai', + model: 'gpt-4', + author: testUser._id, + tools: [], + skills_enabled: true, + skills: [skill._id.toString()], + }); + await grantView(agent); + } + mockInitializeAgent.mockResolvedValue( + makePrimaryConfig({ + subagents: { + enabled: true, + allowSelf: false, + agent_ids: [SUBAGENT_ID, secondSubagentId], + }, + }), + ); + const req = makeSubagentReq(); + req.config.endpoints.agents.capabilities.push('skills'); + const skillDbMethods = getSkillDbMethods(); + const listAlwaysApplySkills = skillDbMethods.listAlwaysApplySkills.bind(skillDbMethods); + let activeQueries = 0; + let maxActiveQueries = 0; + const bothQueriesStarted = deferred(); + const waitForBothQueries = async () => { + let timer; + await Promise.race([ + bothQueriesStarted.promise, + new Promise((resolve) => { + timer = setTimeout(resolve, 1000); + }), + ]); + clearTimeout(timer); + }; + const listAlwaysApplySkillsSpy = jest + .spyOn(skillDbMethods, 'listAlwaysApplySkills') + .mockImplementation(async (...args) => { + activeQueries += 1; + maxActiveQueries = Math.max(maxActiveQueries, activeQueries); + if (activeQueries === 2) { + bothQueriesStarted.resolve(); + } + await waitForBothQueries(); + try { + return await listAlwaysApplySkills(...args); + } finally { + activeQueries -= 1; + } + }); + + try { + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(listAlwaysApplySkillsSpy).toHaveBeenCalledTimes(2); + expect(maxActiveQueries).toBe(2); + expect(agentClientArgs.agent.lazySubagentConfigs).toHaveLength(2); + } finally { + listAlwaysApplySkillsSpy.mockRestore(); + } + }); + + it('bounds concurrent lazy metadata reads', async () => { + const subagentIds = Array.from({ length: 6 }, (_, index) => `agent_bounded_lazy_${index}`); + for (const id of subagentIds) { + await createViewableAgent(id); + } + mockInitializeAgent.mockResolvedValue( + makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, agent_ids: subagentIds }, + }), + ); + const originalGetAgent = db.getAgentWithVersionCount.bind(db); + const fourReadsStarted = deferred(); + let activeReads = 0; + let maxActiveReads = 0; + const getAgentSpy = jest + .spyOn(db, 'getAgentWithVersionCount') + .mockImplementation(async (...args) => { + activeReads += 1; + maxActiveReads = Math.max(maxActiveReads, activeReads); + if (activeReads === 4) { + fourReadsStarted.resolve(); + } + let timer; + await Promise.race([ + fourReadsStarted.promise, + new Promise((resolve) => { + timer = setTimeout(resolve, 1000); + }), + ]); + clearTimeout(timer); + try { + return await originalGetAgent(...args); + } finally { + activeReads -= 1; + } + }); + + try { + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + expect(agentClientArgs.agent.lazySubagentConfigs).toHaveLength(subagentIds.length); + expect(maxActiveReads).toBe(4); + } finally { + getAgentSpy.mockRestore(); + } + }); + it('rejects a disallowed lazy subagent scope before exposing it for prewarm', async () => { const subAgent = await createAgent({ id: SUBAGENT_ID, @@ -2167,14 +2351,18 @@ describe('initializeClient β€” subagent loading', () => { }), ).rejects.toThrow(`maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents`); expect(mockInitializeAgent).toHaveBeenCalledTimes(1); - expect(logger.warn).toHaveBeenCalledWith( - '[initializeClient] Subagent graph node limit exceeded', - expect.objectContaining({ - loadedSubagentCount: 34, - stagedSubagentCount: secondMemberIds.length, - maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES, - }), + const stagedLimitWarning = logger.warn.mock.calls.find( + ([message, metadata]) => + message === '[initializeClient] Subagent graph node limit exceeded' && + Number.isInteger(metadata?.stagedSubagentCount), ); + expect(stagedLimitWarning).toBeDefined(); + expect(stagedLimitWarning[1]).toEqual( + expect.objectContaining({ maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES }), + ); + expect( + stagedLimitWarning[1].loadedSubagentCount + stagedLimitWarning[1].stagedSubagentCount, + ).toBeGreaterThan(MAX_SUBAGENT_GRAPH_NODES); }); it('rejects a branching DAG that exceeds expanded descriptor capacity', async () => { diff --git a/e2e/specs/mock/content-filters.persisted.spec.ts b/e2e/specs/mock/content-filters.persisted.spec.ts index 07e08c661c..7bcbda8198 100644 --- a/e2e/specs/mock/content-filters.persisted.spec.ts +++ b/e2e/specs/mock/content-filters.persisted.spec.ts @@ -2979,7 +2979,9 @@ test.describe('persisted source-aware content filters', () => { { type: 'tool_call', tool_call: { - name: `e2e_${testCase.field}`, + id: `e2e-${testCase.field}-${suffix}`, + name: 'conditional_transfer', + args: '{}', output: testCase.marker, }, }, diff --git a/package-lock.json b/package-lock.json index e65032a744..edec2bffb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.7.11", + "@librechat/agents": "^3.7.13", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -10633,9 +10633,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.7.11", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.7.11.tgz", - "integrity": "sha512-XDk71WaqpaO/746dQ3kgBcWBXY2rullysfY7yTjbjr361CVfUgMkosiqFYn7OgSdF2BbHbfzyyqurwLEGSS/Wg==", + "version": "3.7.13", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.7.13.tgz", + "integrity": "sha512-V9KGPap07qgOgAzO5yZUAhav+KxzxfETj/gXDPmDHWw7IVyv4nILBDZzJ/Bu7TnvlzmleikKgnJDa6r9TNTMkQ==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.115.0", @@ -42638,7 +42638,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.7.11", + "@librechat/agents": "^3.7.13", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.30.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/package.json b/packages/api/package.json index 746fb3ad3b..c23ae58663 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -113,7 +113,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.7.11", + "@librechat/agents": "^3.7.13", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.30.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/src/agents/history.spec.ts b/packages/api/src/agents/history.spec.ts new file mode 100644 index 0000000000..6f2b23e13b --- /dev/null +++ b/packages/api/src/agents/history.spec.ts @@ -0,0 +1,104 @@ +import { formatAgentMessages } from '@librechat/agents'; +import { Constants, ContentTypes } from 'librechat-data-provider'; +import { AIMessage, ToolMessage } from '@librechat/agents/langchain/messages'; +import type { TPayload } from '@librechat/agents'; +import { buildRunToolSet } from './tools'; + +describe('agent tool history formatting', () => { + it('preserves handoff and destination-agent calls as structured pairs', () => { + const primary = { + edges: [{ from: 'primary', to: 'researcher', edgeType: 'handoff' as const }], + toolDefinitions: [{ name: 'primary_tool' }], + }; + const researcher = { + toolDefinitions: [{ name: 'research_database' }], + }; + const transferName = `${Constants.LC_TRANSFER_TO_}researcher`; + const payload: TPayload = [ + { role: 'user', content: 'Research this topic' }, + { + role: 'assistant', + content: [ + { + type: ContentTypes.TEXT, + text: 'I delegated the research.', + tool_call_ids: ['transfer-1', 'research-1'], + }, + { + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'transfer-1', + name: transferName, + args: '{}', + output: '', + }, + }, + { + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'research-1', + name: 'research_database', + args: '{}', + output: '{"result":"verified"}', + }, + }, + ], + }, + ]; + + const toolSet = buildRunToolSet(primary, [researcher]); + const { messages } = formatAgentMessages(payload, undefined, toolSet); + const assistant = messages[1] as AIMessage; + const toolMessages = messages.filter( + (message) => message instanceof ToolMessage, + ) as ToolMessage[]; + + expect(assistant.tool_calls?.map(({ name }) => name)).toEqual([ + transferName, + 'research_database', + ]); + expect(JSON.stringify(assistant.content)).not.toContain('Tool:'); + expect(toolMessages.map(({ name }) => name)).toEqual([transferName, 'research_database']); + }); + + it('preserves an authorized MCP wildcard call without a live catalog', () => { + const primary = { + historicalToolNames: [`${Constants.mcp_all}${Constants.mcp_delimiter}warehouse`], + }; + const payload: TPayload = [ + { role: 'user', content: 'Run the saved query' }, + { + role: 'assistant', + content: [ + { + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'subagent-1', + name: `${Constants.SUBAGENT}`, + args: '{}', + output: 'Finished the query', + subagent_content: [ + { + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'query-1', + name: 'run_query_mcp_warehouse', + args: '{}', + output: '{"rows":1}', + }, + }, + ], + }, + }, + ], + }, + ]; + + const toolSet = buildRunToolSet(primary, null, null, payload); + const { messages } = formatAgentMessages(payload, undefined, toolSet); + + expect(toolSet).toContain('run_query_mcp_warehouse'); + expect(JSON.stringify(messages)).not.toContain('Tool:'); + expect(messages.some((message) => message instanceof ToolMessage)).toBe(true); + }); +}); diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 5cf3a71986..2f1d2c6002 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -25,7 +25,9 @@ export * from './backgroundCompletionWakeup'; export * from './initialize'; export * from './legacy'; export * from './lazySubagents'; +export * from './lazyHistory'; export * from './memory'; +export * from './mcpIdentity'; export * from './orphans'; export * from './migration'; export * from './parameters'; diff --git a/packages/api/src/agents/lazyHistory.spec.ts b/packages/api/src/agents/lazyHistory.spec.ts new file mode 100644 index 0000000000..d6de0afed6 --- /dev/null +++ b/packages/api/src/agents/lazyHistory.spec.ts @@ -0,0 +1,70 @@ +import { Types } from 'mongoose'; +import { createLazyAgentHistoryResolver } from './lazyHistory'; + +const userId = new Types.ObjectId().toString(); + +function makeResolver(options: { skillId: Types.ObjectId; disabled?: boolean; active?: boolean }) { + const { skillId, disabled = false, active = true } = options; + return createLazyAgentHistoryResolver({ + accessibleSkillIds: [skillId], + editableSkillIds: [], + skillsCapabilityEnabled: true, + ephemeralSkillsToggle: false, + userId, + skillStates: { [skillId.toString()]: active }, + listSkillsByAccess: async () => ({ + skills: [ + { + _id: skillId, + name: 'analysis', + description: 'Analyze carefully.', + author: new Types.ObjectId(userId), + disableModelInvocation: disabled, + }, + ], + }), + listAlwaysApplySkills: async () => ({ skills: [] }), + deferredToolsAvailable: false, + programmaticToolsAvailable: false, + backgroundToolsAvailable: false, + }); +} + +const agent = { + id: `agent_${new Types.ObjectId().toString()}`, + skills_enabled: true, + skills: [], + tools: [], +}; + +describe('createLazyAgentHistoryResolver', () => { + it('omits Skill tools when every scoped skill is inactive', async () => { + const metadata = await makeResolver({ + skillId: new Types.ObjectId(), + active: false, + }).resolve({ agent, codeExecutionAvailable: false, memoryAvailable: false }); + + expect(metadata.historicalToolNames).not.toContain('skill'); + expect(metadata.historicalToolNames).not.toContain('read_file'); + }); + + it('keeps read_file but omits skill when only non-model-invocable skills are active', async () => { + const metadata = await makeResolver({ + skillId: new Types.ObjectId(), + disabled: true, + }).resolve({ agent, codeExecutionAvailable: false, memoryAvailable: false }); + + expect(metadata.historicalToolNames).not.toContain('skill'); + expect(metadata.historicalToolNames).toContain('read_file'); + }); + + it('keeps skill and read_file when the active catalog is model-visible', async () => { + const metadata = await makeResolver({ skillId: new Types.ObjectId() }).resolve({ + agent, + codeExecutionAvailable: false, + memoryAvailable: false, + }); + + expect(metadata.historicalToolNames).toEqual(expect.arrayContaining(['skill', 'read_file'])); + }); +}); diff --git a/packages/api/src/agents/lazyHistory.ts b/packages/api/src/agents/lazyHistory.ts new file mode 100644 index 0000000000..c731268a1e --- /dev/null +++ b/packages/api/src/agents/lazyHistory.ts @@ -0,0 +1,186 @@ +import { logger } from '@librechat/data-schemas'; +import { Constants, Tools } from 'librechat-data-provider'; +import type { Agent } from 'librechat-data-provider'; +import type { Types } from 'mongoose'; +import type { + ResolveAlwaysApplySkillsParams, + ResolvedAlwaysApplySkill, + ResolvedSkillCatalog, + TListSkillsByAccess, +} from './skills'; +import { + resolveAgentScopedSkillIds, + resolveAlwaysApplySkills, + resolveSkillCatalog, +} from './skills'; +import { buildHistoricalToolNames } from './tools'; + +type LazyHistoryAgent = Pick; + +export interface LazyAgentHistoryCapabilities { + deferredToolsAvailable: boolean; + programmaticToolsAvailable: boolean; + backgroundToolsAvailable: boolean; +} + +export interface CreateLazyAgentHistoryResolverParams extends LazyAgentHistoryCapabilities { + accessibleSkillIds: Types.ObjectId[]; + editableSkillIds: Types.ObjectId[]; + skillsCapabilityEnabled: boolean; + ephemeralSkillsToggle: boolean; + userId?: string; + userRole?: string; + skillStates?: Record; + defaultActiveOnShare?: boolean; + maxCatalogSkills?: number; + listSkillsByAccess?: TListSkillsByAccess; + listAlwaysApplySkills?: ResolveAlwaysApplySkillsParams['listAlwaysApplySkills']; + getAccessibleMcpServerNames?: (userId?: string, role?: string) => Promise; + configuredMcpServerNames?: readonly string[]; + canAuthorSkillFiles?: (params: { + agent: LazyHistoryAgent; + scopedEditableSkillIds: Types.ObjectId[]; + }) => boolean; +} + +export interface LazyAgentHistoryMetadata { + alwaysApplySkillPrimes: ResolvedAlwaysApplySkill[]; + historicalToolNames: string[]; + historicalMcpServerNames: string[]; +} + +export interface LazyAgentHistoryResolver { + resolve(params: { + agent: LazyHistoryAgent; + codeExecutionAvailable: boolean; + memoryAvailable: boolean; + }): Promise; +} + +function skillScopeKey(ids: readonly Types.ObjectId[]): string { + return ids + .map((skillId) => skillId.toString()) + .sort() + .join(':'); +} + +/** + * Creates one request-scoped lazy-history policy resolver. + * + * The resolver owns Skill activation/catalog policy, per-scope query caching, + * MCP name auditing, and capability-to-tool expansion so the legacy host only + * wires request dependencies and consumes typed metadata. + */ +export function createLazyAgentHistoryResolver( + params: CreateLazyAgentHistoryResolverParams, +): LazyAgentHistoryResolver { + const alwaysApplyByScope = new Map>(); + const catalogByScope = new Map>(); + let mcpServerNames: Promise | undefined; + + const resolveAlwaysApply = (scopedSkillIds: Types.ObjectId[]) => { + if (scopedSkillIds.length === 0 || !params.listAlwaysApplySkills) { + return Promise.resolve([]); + } + const key = skillScopeKey(scopedSkillIds); + let resolution = alwaysApplyByScope.get(key); + if (!resolution) { + resolution = resolveAlwaysApplySkills({ + listAlwaysApplySkills: params.listAlwaysApplySkills, + accessibleSkillIds: scopedSkillIds, + userId: params.userId, + skillStates: params.skillStates, + defaultActiveOnShare: params.defaultActiveOnShare, + }); + alwaysApplyByScope.set(key, resolution); + } + return resolution; + }; + + const resolveCatalog = (scopedSkillIds: Types.ObjectId[]) => { + const key = skillScopeKey(scopedSkillIds); + let resolution = catalogByScope.get(key); + if (!resolution) { + resolution = resolveSkillCatalog({ + accessibleSkillIds: scopedSkillIds, + listSkillsByAccess: params.listSkillsByAccess, + userId: params.userId, + skillStates: params.skillStates, + defaultActiveOnShare: params.defaultActiveOnShare, + maxCatalogSkills: params.maxCatalogSkills, + }); + catalogByScope.set(key, resolution); + } + return resolution; + }; + + const resolveMcpServerNames = () => { + mcpServerNames ??= Promise.resolve( + params.getAccessibleMcpServerNames?.(params.userId, params.userRole) ?? [], + ) + .then((names) => [...new Set([...(names ?? []), ...(params.configuredMcpServerNames ?? [])])]) + .catch((error) => { + logger.warn( + '[createLazyAgentHistoryResolver] Failed to resolve MCP names for lazy history normalization:', + error, + ); + return [...new Set(params.configuredMcpServerNames ?? [])]; + }); + return mcpServerNames; + }; + + return { + async resolve({ agent, codeExecutionAvailable, memoryAvailable }) { + const scopedSkillIds = resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds: params.accessibleSkillIds, + skillsCapabilityEnabled: params.skillsCapabilityEnabled, + ephemeralSkillsToggle: params.ephemeralSkillsToggle, + }); + const scopedEditableSkillIds = resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds: params.editableSkillIds, + skillsCapabilityEnabled: params.skillsCapabilityEnabled, + ephemeralSkillsToggle: params.ephemeralSkillsToggle, + }); + const [alwaysApplySkillPrimes, catalog] = await Promise.all([ + resolveAlwaysApply(scopedSkillIds), + resolveCatalog(scopedSkillIds), + ]); + const configuredAndSkillToolNames = [ + ...(agent.tools ?? []), + ...alwaysApplySkillPrimes.flatMap((prime) => prime.allowedTools ?? []), + ]; + const historicalMcpServerNames = configuredAndSkillToolNames.some((name) => + name.includes(Constants.mcp_delimiter), + ) + ? await resolveMcpServerNames() + : []; + const skillAuthoringAvailable = + params.canAuthorSkillFiles?.({ agent, scopedEditableSkillIds }) === true; + + return { + alwaysApplySkillPrimes, + historicalMcpServerNames, + historicalToolNames: Array.from( + buildHistoricalToolNames({ + configuredToolNames: agent.tools, + alwaysApplyToolNames: alwaysApplySkillPrimes.flatMap( + (prime) => prime.allowedTools ?? [], + ), + toolOptions: agent.tool_options, + rawMcpServerNames: historicalMcpServerNames, + codeExecutionAvailable, + memoryAvailable: memoryAvailable && agent.tools?.includes(Tools.memory) === true, + skillsAvailable: catalog.visibleCount > 0, + skillFileAccessAvailable: catalog.activeSkills.length > 0, + skillAuthoringAvailable, + deferredToolsAvailable: params.deferredToolsAvailable, + programmaticToolsAvailable: params.programmaticToolsAvailable, + backgroundToolsAvailable: params.backgroundToolsAvailable, + }), + ), + }; + }, + }; +} diff --git a/packages/api/src/agents/mcpIdentity.spec.ts b/packages/api/src/agents/mcpIdentity.spec.ts new file mode 100644 index 0000000000..9748f8ec46 --- /dev/null +++ b/packages/api/src/agents/mcpIdentity.spec.ts @@ -0,0 +1,52 @@ +import type { PersistedMcpContentPart } from './mcpIdentity'; +import { stampMcpServerIdentities } from './mcpIdentity'; + +describe('stampMcpServerIdentities', () => { + it('preserves exact nested execution identity over an ambiguous parsed boundary', () => { + const contentParts = [ + { + tool_call: { + name: 'subagent', + subagent_content: [ + { + tool_call: { + name: 'lookup_mcp_foo_mcp_bar', + mcpServerName: 'bar', + }, + }, + ], + }, + }, + ]; + + stampMcpServerIdentities({ + contentParts, + roots: [{ accessibleMcpServerNames: ['bar', 'foo_mcp_bar'] }], + }); + + expect(contentParts[0].tool_call.subagent_content?.[0].tool_call.mcpServerName).toBe('bar'); + }); + + it('uses resolved tool definitions before parsing a legacy tool key', () => { + const contentParts: PersistedMcpContentPart[] = [ + { tool_call: { name: 'gitlab-get_mcp_server_version_mcp_bar' } }, + ]; + + stampMcpServerIdentities({ + contentParts, + roots: [ + { + accessibleMcpServerNames: ['bar', 'version_mcp_bar'], + toolDefinitions: [ + { + name: 'gitlab-get_mcp_server_version_mcp_bar', + serverName: 'bar', + }, + ], + }, + ], + }); + + expect(contentParts[0]?.tool_call?.mcpServerName).toBe('bar'); + }); +}); diff --git a/packages/api/src/agents/mcpIdentity.ts b/packages/api/src/agents/mcpIdentity.ts new file mode 100644 index 0000000000..4f58873305 --- /dev/null +++ b/packages/api/src/agents/mcpIdentity.ts @@ -0,0 +1,93 @@ +import { Constants, normalizeServerName, splitMCPToolKey } from 'librechat-data-provider'; +import type { ReachableAgent } from './traversal'; +import { collectReachableAgents } from './traversal'; + +interface McpToolDefinitionLike { + name?: string; + serverName?: string; +} + +interface McpRegisteredToolLike { + mcpRawServerName?: string; +} + +export interface McpIdentityAgent extends ReachableAgent { + accessibleMcpServerNames?: readonly string[]; + historicalMcpServerNames?: readonly string[]; + toolDefinitions?: readonly McpToolDefinitionLike[]; + toolRegistry?: Iterable; +} + +export interface PersistedMcpToolCall { + name?: string; + mcpServerName?: string; + subagent_content?: PersistedMcpContentPart[]; +} + +export interface PersistedMcpContentPart { + tool_call?: PersistedMcpToolCall; +} + +export interface StampMcpServerIdentitiesParams { + contentParts?: PersistedMcpContentPart[]; + roots: readonly (McpIdentityAgent | null | undefined)[]; +} + +/** + * Stamps durable MCP server identities onto persisted native tool calls. + * + * Exact execution metadata wins. Tool registries and definitions provide a + * server-owned fallback, followed by boundary-aware parsing for legacy calls. + */ +export function stampMcpServerIdentities({ + contentParts, + roots, +}: StampMcpServerIdentitiesParams): void { + if (!Array.isArray(contentParts)) { + return; + } + + const serverByToolName = new Map(); + const boundaryNames = new Set(); + for (const agent of collectReachableAgents(roots)) { + for (const rawName of [ + ...(agent.accessibleMcpServerNames ?? []), + ...(agent.historicalMcpServerNames ?? []), + ]) { + boundaryNames.add(rawName); + boundaryNames.add(normalizeServerName(rawName)); + } + for (const definition of agent.toolDefinitions ?? []) { + if (typeof definition.name === 'string' && typeof definition.serverName === 'string') { + serverByToolName.set(definition.name, definition.serverName); + } + } + for (const [name, tool] of agent.toolRegistry ?? []) { + if (typeof tool?.mcpRawServerName === 'string') { + serverByToolName.set(name, tool.mcpRawServerName); + } + } + } + + const knownNames = [...boundaryNames]; + const stampPart = (part: PersistedMcpContentPart): void => { + const toolCall = part?.tool_call; + if (!toolCall || typeof toolCall.name !== 'string') { + return; + } + + let serverName = toolCall.mcpServerName ?? serverByToolName.get(toolCall.name); + if (serverName == null && toolCall.name.includes(Constants.mcp_delimiter)) { + const [toolName, parsedServerName] = splitMCPToolKey(toolCall.name, knownNames); + if (toolName && parsedServerName) { + serverName = parsedServerName; + } + } + if (typeof serverName === 'string') { + toolCall.mcpServerName = normalizeServerName(serverName); + } + toolCall.subagent_content?.forEach(stampPart); + }; + + contentParts.forEach(stampPart); +} diff --git a/packages/api/src/agents/tools.spec.ts b/packages/api/src/agents/tools.spec.ts index e88a100d88..7903d302eb 100644 --- a/packages/api/src/agents/tools.spec.ts +++ b/packages/api/src/agents/tools.spec.ts @@ -40,8 +40,11 @@ jest.mock('@librechat/agents', () => ({ import { CODE_EXECUTION_TOOLS } from '@librechat/agents'; import type { LCTool, LCToolRegistry } from '@librechat/agents'; +import { Constants } from 'librechat-data-provider'; import { buildToolSet, + buildRunToolSet, + buildHistoricalToolNames, BuildToolSetConfig, registerCodeExecutionTools, registerFileAuthoringTools, @@ -145,6 +148,14 @@ describe('buildToolSet', () => { }); describe('edge cases', () => { + it('includes names retained on unresolved lazy agent descriptors', () => { + const toolSet = buildToolSet({ + historicalToolNames: ['lazy_search', 'lazy_calculator'], + }); + + expect(toolSet).toEqual(new Set(['lazy_search', 'lazy_calculator'])); + }); + it('returns empty set when agentConfig is null', () => { const toolSet = buildToolSet(null); expect(toolSet.size).toBe(0); @@ -192,6 +203,222 @@ describe('buildToolSet', () => { }); }); +describe('buildRunToolSet', () => { + const agent = (id: string, ...toolNames: string[]) => ({ + id, + toolDefinitions: toolNames.map((name) => ({ name })), + }); + + it('returns an empty set without a primary or additional agent', () => { + expect(buildRunToolSet(null)).toEqual(new Set()); + }); + + it('collects tools recursively across every reachable agent shape', () => { + const eager = agent('eager', 'eager_tool'); + const lazy = { + id: 'lazy', + historicalToolNames: ['lazy_tool'], + }; + const metadata = agent('metadata', 'metadata_tool'); + const graphMember = agent('graph-member', 'graph_tool'); + const primary = { + ...agent('primary', 'primary_tool'), + subagentAgentConfigs: [eager], + lazySubagentConfigs: [lazy], + subagentGraphMemberMetadata: [metadata], + subagentGraphConfigs: [{ memberConfigs: [graphMember] }], + }; + + expect(buildRunToolSet(primary)).toEqual( + new Set([ + 'subagent', + 'conditional_transfer', + 'primary_tool', + 'eager_tool', + 'lazy_tool', + 'metadata_tool', + 'graph_tool', + ]), + ); + }); + + it('adds only effective handoff destinations as transfer tools', () => { + const primary = { + ...agent('primary', 'primary_tool'), + edges: [ + { from: 'primary', to: 'writer', edgeType: 'handoff' as const }, + { from: 'writer', to: ['reviewer', 'publisher'] }, + { from: 'publisher', to: 'archive', edgeType: 'direct' as const }, + ], + }; + + const toolSet = buildRunToolSet(primary, [agent('disconnected', 'disconnected_tool')]); + + expect(toolSet).toEqual( + new Set([ + 'subagent', + 'conditional_transfer', + 'primary_tool', + 'disconnected_tool', + 'lc_transfer_to_writer', + 'lc_transfer_to_reviewer', + 'lc_transfer_to_publisher', + ]), + ); + }); + + it('includes host-generated controls supplied by the run', () => { + expect(buildRunToolSet(agent('primary'), null, ['check_background_task'])).toEqual( + new Set(['subagent', 'conditional_transfer', 'check_background_task']), + ); + }); +}); + +describe('buildHistoricalToolNames', () => { + it('normalizes MCP names and expands toolkits and deferred search', () => { + expect( + buildHistoricalToolNames({ + configuredToolNames: ['search_mcp_Connector: Company', 'image_gen_oai'], + toolOptions: { + 'search_mcp_Connector: Company': { defer_loading: true }, + }, + rawMcpServerNames: ['Connector: Company'], + deferredToolsAvailable: true, + }), + ).toEqual( + new Set(['search_mcp_Connector__Company', 'image_gen_oai', 'image_edit_oai', 'tool_search']), + ); + }); + + it('expands code, memory, skill, programmatic, and background controls', () => { + expect( + buildHistoricalToolNames({ + configuredToolNames: ['execute_code', 'memory', 'lookup'], + alwaysApplyToolNames: ['skill_allowed_tool'], + toolOptions: { lookup: { allowed_callers: ['code_execution'], run_in_background: true } }, + codeExecutionAvailable: true, + memoryAvailable: true, + skillsAvailable: true, + skillAuthoringAvailable: true, + programmaticToolsAvailable: true, + backgroundToolsAvailable: true, + }), + ).toEqual( + new Set([ + 'execute_code', + 'memory', + 'lookup', + 'skill_allowed_tool', + 'bash_tool', + 'read_file', + 'create_file', + 'edit_file', + 'set_memory', + 'delete_memory', + 'skill', + 'run_tools_with_bash', + 'check_background_task', + ]), + ); + }); + + it('keeps skill file access without exposing the skill invocation tool', () => { + expect( + buildHistoricalToolNames({ + skillsAvailable: false, + skillFileAccessAvailable: true, + }), + ).toEqual(new Set(['read_file'])); + }); + + it('normalizes Action names and their options', () => { + expect( + buildHistoricalToolNames({ + configuredToolNames: [ + `${Constants.mcp_all}${Constants.mcp_delimiter}warehouse`, + 'lookup_action_api---example---com', + ], + toolOptions: { + 'lookup_action_api---example---com': { defer_loading: true }, + }, + deferredToolsAvailable: true, + }), + ).toEqual( + new Set([ + `${Constants.mcp_all}${Constants.mcp_delimiter}warehouse`, + 'lookup_action_api_example_com', + 'tool_search', + ]), + ); + }); + + it('accepts only historical calls covered by an MCP wildcard server suffix', () => { + const primary = { + id: 'primary', + accessibleMcpServerNames: ['bar', 'foo_mcp_bar', 'Connector: Company'], + toolDefinitions: [ + { name: `${Constants.mcp_all}${Constants.mcp_delimiter}bar` }, + { name: `${Constants.mcp_all}${Constants.mcp_delimiter}Connector: Company` }, + ], + }; + const messages = [ + { + content: [ + { tool_call: { name: 'search_mcp_Connector__Company' } }, + { tool_call: { name: 'search_mcp_attacker' } }, + { + tool_call: { + name: 'subagent', + subagent_content: [{ tool_call: { name: 'run_query_mcp_bar' } }], + }, + }, + ], + }, + { + tool_calls: [{ name: 'lookup_mcp_Connector__Company' }], + additional_kwargs: { + tool_calls: [{ function: { name: 'legacy_mcp_Connector__Company' } }], + }, + }, + { tool_calls: [{ name: 'lookup_mcp_foo_mcp_bar' }] }, + { + tool_calls: [{ name: 'gitlab-get_mcp_server_version_mcp_bar', mcpServerName: 'bar' }], + }, + { tool_calls: [{ name: 'legacy_mcp_tool_mcp_bar' }] }, + ]; + + expect(buildRunToolSet(primary, null, null, messages)).toEqual( + new Set([ + 'subagent', + 'conditional_transfer', + `${Constants.mcp_all}${Constants.mcp_delimiter}bar`, + `${Constants.mcp_all}${Constants.mcp_delimiter}Connector: Company`, + 'search_mcp_Connector__Company', + 'run_query_mcp_bar', + 'lookup_mcp_Connector__Company', + 'legacy_mcp_Connector__Company', + 'gitlab-get_mcp_server_version_mcp_bar', + ]), + ); + expect(buildRunToolSet(primary, null, null, messages, true)).toContain( + 'legacy_mcp_tool_mcp_bar', + ); + }); + + it('does not inspect history when the run has no MCP wildcard', () => { + const message = {}; + Object.defineProperty(message, 'content', { + get: () => { + throw new Error('history should not be inspected'); + }, + }); + + expect(() => + buildRunToolSet({ toolDefinitions: [{ name: 'web' }] }, null, null, [message]), + ).not.toThrow(); + }); +}); + describe('registerCodeExecutionTools', () => { const makeRegistry = (): LCToolRegistry => new Map() as unknown as LCToolRegistry; diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index fe43cdbb1c..afcae5c8e9 100644 --- a/packages/api/src/agents/tools.ts +++ b/packages/api/src/agents/tools.ts @@ -1,10 +1,21 @@ +import { + Constants, + normalizeActionToolName, + normalizeServerName, + splitMCPToolKey, +} from 'librechat-data-provider'; import { CODE_EXECUTION_TOOLS, BashExecutionToolDefinition, ReadFileToolDefinition, buildBashExecutionToolDescription, } from '@librechat/agents'; +import type { AgentToolOptions, GraphEdge } from 'librechat-data-provider'; import type { LCTool, LCToolRegistry } from '@librechat/agents'; +import type { ReachableAgent } from './traversal'; +import { toolkitExpansion } from '~/tools/toolkits/mapping'; +import { normalizeAgentToolKeys } from '~/mcp/utils'; +import { collectReachableAgents } from './traversal'; export const CREATE_FILE_TOOL_NAME = 'create_file'; export const EDIT_FILE_TOOL_NAME = 'edit_file'; @@ -34,6 +45,98 @@ interface ToolInstanceLike { export interface BuildToolSetConfig { toolDefinitions?: ToolDefLike[]; tools?: (ToolInstanceLike | null | undefined)[]; + /** Tool names retained on unresolved agent descriptors for history replay. */ + historicalToolNames?: readonly string[]; +} + +export interface BuildHistoricalToolNamesConfig { + configuredToolNames?: readonly string[]; + alwaysApplyToolNames?: readonly string[]; + toolOptions?: AgentToolOptions; + rawMcpServerNames?: readonly string[]; + codeExecutionAvailable?: boolean; + memoryAvailable?: boolean; + skillsAvailable?: boolean; + skillFileAccessAvailable?: boolean; + skillAuthoringAvailable?: boolean; + deferredToolsAvailable?: boolean; + programmaticToolsAvailable?: boolean; + backgroundToolsAvailable?: boolean; +} + +/** Derives the model-facing names an unresolved lazy agent can expose without loading it. */ +export function buildHistoricalToolNames(config: BuildHistoricalToolNamesConfig): Set { + const configuredToolNames = [ + ...(config.configuredToolNames ?? []), + ...(config.alwaysApplyToolNames ?? []), + ]; + const normalized = normalizeAgentToolKeys({ + tools: configuredToolNames, + toolOptions: config.toolOptions, + rawServerNames: config.rawMcpServerNames ?? [], + }); + const toolNames = new Set((normalized.tools ?? []).map(normalizeActionToolName)); + + const normalizedOptions: AgentToolOptions = {}; + for (const [name, options] of Object.entries(normalized.toolOptions ?? {})) { + const normalizedName = normalizeActionToolName(name); + normalizedOptions[normalizedName] = + normalizedName !== name + ? { ...options, ...normalizedOptions[normalizedName] } + : { ...normalizedOptions[name], ...options }; + } + + for (const name of [...toolNames]) { + for (const child of toolkitExpansion[name as keyof typeof toolkitExpansion] ?? []) { + toolNames.add(child); + } + } + + if (config.codeExecutionAvailable === true) { + toolNames.add('bash_tool'); + toolNames.add('read_file'); + toolNames.add(CREATE_FILE_TOOL_NAME); + toolNames.add(EDIT_FILE_TOOL_NAME); + } + if (config.memoryAvailable === true) { + toolNames.add('set_memory'); + toolNames.add('delete_memory'); + } + if (config.skillsAvailable === true) { + toolNames.add('skill'); + } + if ((config.skillFileAccessAvailable ?? config.skillsAvailable) === true) { + toolNames.add('read_file'); + } + if (config.skillAuthoringAvailable === true) { + toolNames.add('read_file'); + toolNames.add(CREATE_FILE_TOOL_NAME); + toolNames.add(EDIT_FILE_TOOL_NAME); + } + + const options = normalizedOptions; + const hasDeferredTool = [...toolNames].some((name) => options[name]?.defer_loading === true); + if (config.deferredToolsAvailable === true && hasDeferredTool) { + toolNames.add('tool_search'); + } + const hasProgrammaticTool = [...toolNames].some((name) => + options[name]?.allowed_callers?.includes('code_execution'), + ); + if ( + config.programmaticToolsAvailable === true && + config.codeExecutionAvailable === true && + hasProgrammaticTool + ) { + toolNames.add('run_tools_with_bash'); + } + const hasBackgroundTool = + config.codeExecutionAvailable === true || + [...toolNames].some((name) => options[name]?.run_in_background === true); + if (config.backgroundToolsAvailable === true && hasBackgroundTool) { + toolNames.add(`${Constants.CHECK_BACKGROUND_TASK}`); + } + + return toolNames; } /** @@ -51,14 +154,165 @@ export function buildToolSet(agentConfig: BuildToolSetConfig | null | undefined) return new Set(); } - const { toolDefinitions, tools } = agentConfig; + const { toolDefinitions, tools, historicalToolNames } = agentConfig; const toolNames = toolDefinitions && toolDefinitions.length > 0 ? toolDefinitions.map((def) => def.name) : (tools ?? []).map((tool) => tool?.name); - return new Set(toolNames.filter((name): name is string => Boolean(name))); + return new Set( + [...toolNames, ...(historicalToolNames ?? [])].filter((name): name is string => Boolean(name)), + ); +} + +export interface RunToolSetConfig extends BuildToolSetConfig, ReachableAgent { + readonly edges?: readonly GraphEdge[]; + readonly accessibleMcpServerNames?: readonly string[]; + readonly historicalMcpServerNames?: readonly string[]; +} + +interface HistoricalToolCallIdentity { + name: string; + mcpServerName?: string; +} + +function collectHistoricalToolCalls( + messages?: Iterable | null, +): HistoricalToolCallIdentity[] { + const calls: HistoricalToolCallIdentity[] = []; + const addCall = (value: unknown) => { + if (value == null || typeof value !== 'object') { + return; + } + const call = value as { + name?: unknown; + mcpServerName?: unknown; + function?: { name?: unknown }; + tool_call?: { name?: unknown; mcpServerName?: unknown; subagent_content?: unknown }; + subagent_content?: unknown; + }; + const name = call.name ?? call.function?.name ?? call.tool_call?.name; + if (typeof name === 'string') { + const mcpServerName = call.mcpServerName ?? call.tool_call?.mcpServerName; + calls.push({ + name, + ...(typeof mcpServerName === 'string' ? { mcpServerName } : {}), + }); + } + const nested = call.tool_call?.subagent_content ?? call.subagent_content; + if (Array.isArray(nested)) { + nested.forEach(addCall); + } + }; + + for (const value of messages ?? []) { + if (value == null || typeof value !== 'object') { + continue; + } + const message = value as { + content?: unknown; + tool_calls?: unknown; + additional_kwargs?: { tool_calls?: unknown }; + }; + if (Array.isArray(message.tool_calls)) { + message.tool_calls.forEach(addCall); + } + if (Array.isArray(message.additional_kwargs?.tool_calls)) { + message.additional_kwargs.tool_calls.forEach(addCall); + } + if (Array.isArray(message.content)) { + message.content.forEach(addCall); + } + } + return calls; +} + +/** Builds the historical tool allowlist for the complete effective run topology. */ +export function buildRunToolSet( + primaryConfig: RunToolSetConfig | null | undefined, + additionalConfigs?: Iterable | null, + hostGeneratedToolNames?: Iterable | null, + historicalMessages?: Iterable | null, + allowAmbiguousMcpToolNamesWithoutIdentity = false, +): Set { + const roots = [primaryConfig]; + if (additionalConfigs) { + roots.push(...additionalConfigs); + } + + const agents = collectReachableAgents(roots); + if (agents.length === 0) { + return new Set(); + } + + const toolSet = new Set([`${Constants.SUBAGENT}`, 'conditional_transfer']); + const wildcardServerNames = new Set(); + const knownServerNames = new Set(); + for (const name of hostGeneratedToolNames ?? []) { + toolSet.add(name); + } + for (const agent of agents) { + for (const rawName of [ + ...(agent.accessibleMcpServerNames ?? []), + ...(agent.historicalMcpServerNames ?? []), + ]) { + knownServerNames.add(rawName); + knownServerNames.add(normalizeServerName(rawName)); + } + for (const name of buildToolSet(agent)) { + toolSet.add(name); + const wildcardPrefix = `${Constants.mcp_all}${Constants.mcp_delimiter}`; + if (name.startsWith(wildcardPrefix)) { + const rawServerName = name.slice(wildcardPrefix.length); + if (rawServerName) { + wildcardServerNames.add(normalizeServerName(rawServerName)); + knownServerNames.add(rawServerName); + knownServerNames.add(normalizeServerName(rawServerName)); + } + } + } + } + + if (wildcardServerNames.size > 0) { + /** A wildcard authorizes every callable under one exact normalized server identity. + * Resolve the longest known boundary so delimiter-bearing tool names remain valid + * while a distinct longer server identity cannot masquerade as a selected suffix. */ + const boundaryNames = [...knownServerNames]; + for (const call of collectHistoricalToolCalls(historicalMessages)) { + const { name, mcpServerName } = call; + if (mcpServerName != null) { + if (wildcardServerNames.has(normalizeServerName(mcpServerName))) { + toolSet.add(name); + } + continue; + } + const [toolName, serverName] = splitMCPToolKey(name, boundaryNames); + if ( + serverName != null && + toolName.length > 0 && + (allowAmbiguousMcpToolNamesWithoutIdentity || + !toolName.includes(Constants.mcp_delimiter)) && + wildcardServerNames.has(normalizeServerName(serverName)) + ) { + toolSet.add(name); + } + } + } + + for (const edge of primaryConfig?.edges ?? []) { + if (edge.edgeType === 'direct') { + continue; + } + const destinations = Array.isArray(edge.to) ? edge.to : [edge.to]; + for (const destination of destinations) { + if (destination) { + toolSet.add(`${Constants.LC_TRANSFER_TO_}${destination}`); + } + } + } + + return toolSet; } export interface RegisterCodeExecutionToolsParams { diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index a987504293..ec05724999 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -72,6 +72,8 @@ export namespace Agents { type?: ToolCallTypes.TOOL_CALL | 'tool_call'; /** The name of the tool to be called */ name: string; + /** Host-derived MCP server identity retained to disambiguate historical tool keys. */ + mcpServerName?: string; /** The arguments to the tool call */ // eslint-disable-next-line @typescript-eslint/no-explicit-any