mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-23 10:36:09 +00:00
🧶 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:
parent
a290677d21
commit
dccd7ae496
23 changed files with 1566 additions and 150 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
104
packages/api/src/agents/history.spec.ts
Normal file
104
packages/api/src/agents/history.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
70
packages/api/src/agents/lazyHistory.spec.ts
Normal file
70
packages/api/src/agents/lazyHistory.spec.ts
Normal 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']));
|
||||
});
|
||||
});
|
||||
186
packages/api/src/agents/lazyHistory.ts
Normal file
186
packages/api/src/agents/lazyHistory.ts
Normal 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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
52
packages/api/src/agents/mcpIdentity.spec.ts
Normal file
52
packages/api/src/agents/mcpIdentity.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
93
packages/api/src/agents/mcpIdentity.ts
Normal file
93
packages/api/src/agents/mcpIdentity.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue