🧶 fix: Preserve Multi-Agent Tool Call History (#15468)

* 🧭 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
This commit is contained in:
Danny Avila 2026-09-01 18:37:45 -04:00 • committed by GitHub
parent a290677d21
commit dccd7ae496
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1566 additions and 150 deletions

View file

@ -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",

View file

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

View file

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

View file

@ -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 }) => ({

View file

@ -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<TTokenUsageEvent>} [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<string, t.EventHandler>} 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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 () => {

View file

@ -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,
},
},

10
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

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

View file

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

View file

@ -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']));
});
});

View file

@ -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<Agent, 'id' | 'skills' | 'skills_enabled' | 'tools' | 'tool_options'>;
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<string, boolean>;
defaultActiveOnShare?: boolean;
maxCatalogSkills?: number;
listSkillsByAccess?: TListSkillsByAccess;
listAlwaysApplySkills?: ResolveAlwaysApplySkillsParams['listAlwaysApplySkills'];
getAccessibleMcpServerNames?: (userId?: string, role?: string) => Promise<string[]>;
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<LazyAgentHistoryMetadata>;
}
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<string, Promise<ResolvedAlwaysApplySkill[]>>();
const catalogByScope = new Map<string, Promise<ResolvedSkillCatalog>>();
let mcpServerNames: Promise<string[]> | 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,
}),
),
};
},
};
}

View file

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

View file

@ -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<McpIdentityAgent> {
accessibleMcpServerNames?: readonly string[];
historicalMcpServerNames?: readonly string[];
toolDefinitions?: readonly McpToolDefinitionLike[];
toolRegistry?: Iterable<readonly [string, McpRegisteredToolLike]>;
}
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<string, string>();
const boundaryNames = new Set<string>();
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);
}

View file

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

View file

@ -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<string> {
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<RunToolSetConfig> {
readonly edges?: readonly GraphEdge[];
readonly accessibleMcpServerNames?: readonly string[];
readonly historicalMcpServerNames?: readonly string[];
}
interface HistoricalToolCallIdentity {
name: string;
mcpServerName?: string;
}
function collectHistoricalToolCalls(
messages?: Iterable<unknown> | 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<RunToolSetConfig | null | undefined> | null,
hostGeneratedToolNames?: Iterable<string> | null,
historicalMessages?: Iterable<unknown> | null,
allowAmbiguousMcpToolNamesWithoutIdentity = false,
): Set<string> {
const roots = [primaryConfig];
if (additionalConfigs) {
roots.push(...additionalConfigs);
}
const agents = collectReachableAgents(roots);
if (agents.length === 0) {
return new Set();
}
const toolSet = new Set<string>([`${Constants.SUBAGENT}`, 'conditional_transfer']);
const wildcardServerNames = new Set<string>();
const knownServerNames = new Set<string>();
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 {

View file

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