🚦 fix: Restrict Programmatic Tool Execution Maps (#15105)

* fix: restrict programmatic tool execution maps

* chore: bump `@librechat/agents` to v3.6.10

* fix: honor live programmatic caller projections

* style: sort caller capability imports

* test: expect caller projection loader argument

* chore: bump agents sdk to v3.6.11

* refactor: use SDK caller projection type

* style: sort agent handler imports
This commit is contained in:
Danny Avila 2026-08-22 11:02:09 -04:00 committed by GitHub
parent d3e70159ca
commit 89494d45fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 456 additions and 37 deletions

View file

@ -2,6 +2,7 @@
- **Agent run envelope**: the versioned, JSON-safe request contract created after ingress authentication and protocol validation but before agent, provider, tool, or MCP initialization. It carries only the validated protocol payload and the minimum trusted principal identifiers. The execution host rehydrates all runtime state from those identifiers.
- **MCP runtime request body**: trusted chat identifiers supplied only while an MCP server handles an agent request. It enables request-scoped header placeholders without retaining user-specific request data on a shared server definition.
- **Caller Capability Projection**: the versioned, SDK-owned classification of currently active tools by direct and programmatic callers. Event-driven execution transports this projection as data; LibreChat intersects it with its trusted registry and never recomputes deferred-tool discovery policy or treats the projection as authorization.
- **Subagent thread**: a durable, view-only child conversation owned by one parent conversation and subagent identity. A parent agent may continue it by stable `threadId`; each continuation uses a fresh execution lease restored from the canonical child transcript. It is not an ordinary human-writable chat.
- **Live subagent task owner**: the one API process holding a detached child execution, its abort controller, and its bounded control queue. Redis may route trusted poll/control envelopes to that owner, but it does not migrate or persist the executor; Mongo persists only the logical child thread and its continuation fence.
- **Subagent completion wakeup**: a durable internal `continue` trigger pre-registered before detached child execution so a process crash cannot lose the wakeup. Delivery defers until the child's terminal transcript is persisted, targets the initiating agent and exact parent response branch, carries task metadata rather than child output, waits for the parent generation to settle, and starts the parent turn that collects the result through the existing task store.

View file

@ -46,8 +46,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "5.1.6",
"@redis/client": "5.10.0",
"@librechat/agents": "^3.6.9",
"@librechat/agents": "^3.6.11",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
@ -63,6 +62,7 @@
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-node": "^0.221.0",
"@opentelemetry/semantic-conventions": "^1.39.0",
"@redis/client": "5.10.0",
"@smithy/node-http-handler": "^4.4.5",
"ai-tokenizer": "^1.0.6",
"axios": "^1.16.0",

View file

@ -701,7 +701,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
agent never gains sandbox access even if the admin enabled the
capability globally. */
const toolExecuteOptions = {
loadTools: async (toolNames, agentId) => {
loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => {
const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
const result = await loadToolsForExecution({
req,
@ -713,6 +713,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
agent: ctx.agent ?? agent,
signal: abortController.signal,
toolRegistry: ctx.toolRegistry,
callerCapabilityProjection,
backgroundToolNames: ctx.backgroundToolNames,
intentToolNames: ctx.intentToolNames,
mcpAvailableTools: ctx.mcpAvailableTools,

View file

@ -1016,7 +1016,7 @@ const executeResponse = async (envelope, { req, res }) => {
// Create tool execute options for event-driven tool execution
const toolExecuteOptions = {
loadTools: async (toolNames, agentId) => {
loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => {
const ctx =
agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
const result = await loadToolsForExecution({
@ -1029,6 +1029,7 @@ const executeResponse = async (envelope, { req, res }) => {
agent: ctx.agent ?? agent,
signal: abortController.signal,
toolRegistry: ctx.toolRegistry,
callerCapabilityProjection,
backgroundToolNames: ctx.backgroundToolNames,
intentToolNames: ctx.intentToolNames,
mcpAvailableTools: ctx.mcpAvailableTools,
@ -1206,7 +1207,7 @@ const executeResponse = async (envelope, { req, res }) => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null });
const toolExecuteOptions = {
loadTools: async (toolNames, agentId) => {
loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => {
const ctx =
agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
const result = await loadToolsForExecution({
@ -1219,6 +1220,7 @@ const executeResponse = async (envelope, { req, res }) => {
agent: ctx.agent ?? agent,
signal: abortController.signal,
toolRegistry: ctx.toolRegistry,
callerCapabilityProjection,
backgroundToolNames: ctx.backgroundToolNames,
intentToolNames: ctx.intentToolNames,
mcpAvailableTools: ctx.mcpAvailableTools,

View file

@ -329,7 +329,7 @@ const initializeClient = async ({
const endpointTokenConfigByAgentId = new Map();
const toolExecuteOptions = {
loadTools: async (toolNames, agentId) => {
loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => {
const ctx = agentToolContexts.get(agentId) ?? {};
logger.debug(`[ON_TOOL_EXECUTE] ctx found: ${!!ctx.userMCPAuthMap}, agent: ${ctx.agent?.id}`);
logger.debug(`[ON_TOOL_EXECUTE] toolRegistry size: ${ctx.toolRegistry?.size ?? 'undefined'}`);
@ -344,6 +344,7 @@ const initializeClient = async ({
toolNames,
agent: ctx.agent,
toolRegistry: ctx.toolRegistry,
callerCapabilityProjection,
backgroundToolNames: ctx.backgroundToolNames,
intentToolNames: ctx.intentToolNames,
mcpAvailableTools: ctx.mcpAvailableTools,

View file

@ -43,6 +43,7 @@ const {
AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE,
isFatalAgentInitializationError,
resolveCodeExecutionContext,
resolveCallerCapabilityProjectionSnapshot,
} = require('@librechat/api');
const {
Time,
@ -1902,6 +1903,7 @@ async function loadAgentTools({
* @param {string} [params.agentResourceType] - Permission resource type for the authorized agent route
* @param {string[]} params.toolNames - Names of tools to load
* @param {Map} [params.toolRegistry] - Tool registry
* @param {unknown} [params.callerCapabilityProjection] - SDK-owned live caller projection
* @param {Record<string, import('@librechat/api').LCAvailableTools>} [params.mcpAvailableTools] - Run-scoped MCP tool definitions
* @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] - Run-scoped MCP connections
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap] - User MCP auth map
@ -1922,6 +1924,7 @@ async function loadToolsForExecution({
agentResourceType,
toolNames,
toolRegistry,
callerCapabilityProjection,
backgroundToolNames,
intentToolNames,
mcpAvailableTools,
@ -1943,6 +1946,12 @@ async function loadToolsForExecution({
requestBody: runtimeRequestBody,
requestScopedConnections: mcpRequestScopedConnections,
};
const activeCallerCapabilities = resolveCallerCapabilityProjectionSnapshot(
callerCapabilityProjection,
);
const activeCodeExecutionToolNames = activeCallerCapabilities
? new Set(activeCallerCapabilities.codeExecutionToolNames)
: undefined;
/** Per-agent set of tools that received the injected `run_in_background`
* param; the event-driven executor gates background dispatch and the
* `check_background_task` poll tool on this reliable per-agent channel. */
@ -2104,9 +2113,14 @@ async function loadToolsForExecution({
let ptcOrchestratedToolNames = [];
if (isPTC && toolRegistry) {
ptcOrchestratedToolNames = Array.from(toolRegistry.keys()).filter(
(name) => !specialToolNames.has(name),
);
ptcOrchestratedToolNames = Array.from(toolRegistry.values())
.filter(
(toolDef) =>
!specialToolNames.has(toolDef.name) &&
(toolDef.allowed_callers ?? ['direct']).includes('code_execution') &&
(activeCodeExecutionToolNames == null || activeCodeExecutionToolNames.has(toolDef.name)),
)
.map((toolDef) => toolDef.name);
}
const requestedNonSpecialToolNames = toolNames.filter((name) => !specialToolNames.has(name));
@ -2199,7 +2213,9 @@ async function loadToolsForExecution({
if (
tool.name &&
tool.name !== AgentConstants.PROGRAMMATIC_TOOL_CALLING &&
tool.name !== AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING
tool.name !== AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING &&
(toolRegistry.get(tool.name)?.allowed_callers ?? ['direct']).includes('code_execution') &&
(activeCodeExecutionToolNames == null || activeCodeExecutionToolNames.has(tool.name))
) {
ptcToolMap.set(tool.name, tool);
}

View file

@ -2586,7 +2586,9 @@ describe('ToolService - Action Capability Gating', () => {
},
},
};
const toolRegistry = new Map([[mcpTool, { name: mcpTool }]]);
const toolRegistry = new Map([
[mcpTool, { name: mcpTool, allowed_callers: ['code_execution'] }],
]);
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
await loadToolsForExecution({
@ -2609,6 +2611,169 @@ describe('ToolService - Action Capability Gating', () => {
);
});
it('loads only code_execution tools into the PTC execution map', async () => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.programmatic_tools,
AgentCapabilities.execute_code,
];
const req = createMockReq(capabilities);
const programmaticTool = {
name: 'programmatic_tool',
invoke: jest.fn(),
};
const toolRegistry = new Map([
[
programmaticTool.name,
{ name: programmaticTool.name, allowed_callers: ['code_execution'] },
],
['direct_tool', { name: 'direct_tool', allowed_callers: ['direct'] }],
]);
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockLoadToolsUtil.mockResolvedValue({
loadedTools: [programmaticTool],
toolContextMap: {},
});
const result = await loadToolsForExecution({
req,
res: {},
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
toolRegistry,
actionsEnabled: false,
});
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
expect.objectContaining({ tools: ['programmatic_tool'] }),
);
expect([...result.configurable.ptcToolMap.keys()]).toEqual(['programmatic_tool']);
});
it('intersects PTC loading with the SDK live caller projection', async () => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.programmatic_tools,
AgentCapabilities.execute_code,
];
const req = createMockReq(capabilities);
const activeTool = { name: 'active_programmatic_tool', invoke: jest.fn() };
const deferredTool = { name: 'deferred_programmatic_tool', invoke: jest.fn() };
const toolRegistry = new Map([
[activeTool.name, { name: activeTool.name, allowed_callers: ['code_execution'] }],
[
deferredTool.name,
{
name: deferredTool.name,
allowed_callers: ['code_execution'],
defer_loading: true,
},
],
]);
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockLoadToolsUtil.mockResolvedValue({
loadedTools: [activeTool],
toolContextMap: {},
});
const result = await loadToolsForExecution({
req,
res: {},
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
toolRegistry,
callerCapabilityProjection: {
version: 1,
directToolNames: [],
codeExecutionToolNames: [activeTool.name],
directOnlyToolNames: [],
codeExecutionOnlyToolNames: [activeTool.name],
},
actionsEnabled: false,
});
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
expect.objectContaining({ tools: [activeTool.name] }),
);
expect([...result.configurable.ptcToolMap.keys()]).toEqual([activeTool.name]);
});
it('treats an empty versioned caller projection as authoritative', async () => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.programmatic_tools,
AgentCapabilities.execute_code,
];
const req = createMockReq(capabilities);
const toolRegistry = new Map([
[
'deferred_programmatic_tool',
{ name: 'deferred_programmatic_tool', allowed_callers: ['code_execution'] },
],
]);
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
const result = await loadToolsForExecution({
req,
res: {},
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
toolRegistry,
callerCapabilityProjection: {
version: 1,
directToolNames: [],
codeExecutionToolNames: [],
directOnlyToolNames: [],
codeExecutionOnlyToolNames: [],
},
actionsEnabled: false,
});
expect(mockLoadToolsUtil).not.toHaveBeenCalled();
expect(result.configurable.ptcToolMap).toEqual(new Map());
});
it('falls back to registry projection for unknown snapshot versions', async () => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.programmatic_tools,
AgentCapabilities.execute_code,
];
const req = createMockReq(capabilities);
const programmaticTool = { name: 'programmatic_tool', invoke: jest.fn() };
const toolRegistry = new Map([
[
programmaticTool.name,
{ name: programmaticTool.name, allowed_callers: ['code_execution'] },
],
]);
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockLoadToolsUtil.mockResolvedValue({
loadedTools: [programmaticTool],
toolContextMap: {},
});
await loadToolsForExecution({
req,
res: {},
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
toolRegistry,
callerCapabilityProjection: {
version: 2,
directToolNames: [],
codeExecutionToolNames: [],
directOnlyToolNames: [],
codeExecutionOnlyToolNames: [],
},
actionsEnabled: false,
});
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
expect.objectContaining({ tools: [programmaticTool.name] }),
);
});
it('does not load PTC when programmatic tools capability is disabled', async () => {
const capabilities = [AgentCapabilities.tools, AgentCapabilities.execute_code];
const req = createMockReq(capabilities);

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.6.9",
"@librechat/agents": "^3.6.11",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
@ -10630,9 +10630,9 @@
}
},
"node_modules/@librechat/agents": {
"version": "3.6.9",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.6.9.tgz",
"integrity": "sha512-qVR0A7nwD56SYWPGi559P9zUwtwWwt/53EPGc/iPNjQfPNRbYnNzEideZ2D4EDoXcF4aW3GtnPlbhjHKveikuw==",
"version": "3.6.11",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.6.11.tgz",
"integrity": "sha512-/VK9BpP5BHtWUQWJ34gMv+cOvfd9qoJepaolAm3/GXLQWBmQKHPMHUsN8C6JWUdQTWSRgtvHCGCaxzjV9hNCyQ==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.115.0",
@ -42822,7 +42822,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "5.1.6",
"@librechat/agents": "^3.6.9",
"@librechat/agents": "^3.6.11",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.30.0",
"@opentelemetry/api": "^1.9.0",

View file

@ -113,8 +113,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "5.1.6",
"@redis/client": "5.10.0",
"@librechat/agents": "^3.6.9",
"@librechat/agents": "^3.6.11",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.30.0",
"@opentelemetry/api": "^1.9.0",
@ -127,6 +126,7 @@
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/semantic-conventions": "^1.39.0",
"@redis/client": "5.10.0",
"@smithy/node-http-handler": "^4.4.5",
"ai-tokenizer": "^1.0.6",
"axios": "^1.16.0",

View file

@ -0,0 +1,33 @@
import { resolveCallerCapabilityProjectionSnapshot } from './callerCapabilities';
describe('resolveCallerCapabilityProjectionSnapshot', () => {
it('accepts a complete v1 snapshot, including empty projections', () => {
const snapshot = {
version: 1 as const,
directToolNames: [],
codeExecutionToolNames: [],
directOnlyToolNames: [],
codeExecutionOnlyToolNames: [],
};
expect(resolveCallerCapabilityProjectionSnapshot(snapshot)).toBe(snapshot);
});
it('falls back for unknown versions and partial snapshots', () => {
expect(
resolveCallerCapabilityProjectionSnapshot({
version: 2,
directToolNames: [],
codeExecutionToolNames: [],
directOnlyToolNames: [],
codeExecutionOnlyToolNames: [],
}),
).toBeUndefined();
expect(
resolveCallerCapabilityProjectionSnapshot({
version: 1,
codeExecutionToolNames: [],
}),
).toBeUndefined();
});
});

View file

@ -0,0 +1,30 @@
import type { CallerCapabilityProjectionSnapshot } from '@librechat/agents';
/**
* Accepts only complete snapshots for the version this host understands.
* Missing or future versions intentionally fall back to the legacy registry
* projection during a rolling SDK/host deployment.
*/
export function resolveCallerCapabilityProjectionSnapshot(
value: unknown,
): CallerCapabilityProjectionSnapshot | undefined {
if (value == null || typeof value !== 'object') {
return undefined;
}
const snapshot = value as Partial<CallerCapabilityProjectionSnapshot>;
const nameLists = [
snapshot.directToolNames,
snapshot.codeExecutionToolNames,
snapshot.directOnlyToolNames,
snapshot.codeExecutionOnlyToolNames,
];
if (
snapshot.version !== 1 ||
nameLists.some(
(names) => !Array.isArray(names) || names.some((name) => typeof name !== 'string'),
)
) {
return undefined;
}
return snapshot as CallerCapabilityProjectionSnapshot;
}

View file

@ -48,13 +48,21 @@ function createHandler(
function invokeHandler(
handler: ReturnType<typeof createToolExecuteHandler>,
toolCalls: ToolCallRequest[],
callerCapabilityProjection?: {
version: 1;
directToolNames: string[];
codeExecutionToolNames: string[];
directOnlyToolNames: string[];
codeExecutionOnlyToolNames: string[];
},
): Promise<ToolExecuteResult[]> {
return new Promise((resolve, reject) => {
const request: ToolExecuteBatchRequest = {
const request = {
toolCalls,
callerCapabilityProjection,
resolve,
reject,
};
} as ToolExecuteBatchRequest & { callerCapabilityProjection?: unknown };
handler.handle('on_tool_execute', request);
});
}
@ -503,7 +511,7 @@ describe('createToolExecuteHandler', () => {
);
expect(loadTools).toHaveBeenCalledTimes(1);
expect(loadTools).toHaveBeenCalledWith(['allowed_tool'], undefined, configurable);
expect(loadTools).toHaveBeenCalledWith(['allowed_tool'], undefined, configurable, undefined);
expect(JSON.stringify(jest.mocked(loadTools).mock.calls)).not.toContain(protectedName);
expect(results[0]).toEqual(
expect.objectContaining({
@ -819,7 +827,8 @@ describe('createToolExecuteHandler', () => {
const capturedConfigs: Record<string, unknown>[] = [];
const legacyPtcTool = createMockTool(Constants.PROGRAMMATIC_TOOL_CALLING, capturedConfigs);
const toolRegistry = new Map([
['custom_tool', { name: 'custom_tool' }],
['custom_tool', { name: 'custom_tool', allowed_callers: ['code_execution'] }],
['direct_tool', { name: 'direct_tool', allowed_callers: ['direct'] }],
['create_file', { name: 'create_file' }],
[Constants.PROGRAMMATIC_TOOL_CALLING, { name: Constants.PROGRAMMATIC_TOOL_CALLING }],
[
@ -828,7 +837,11 @@ describe('createToolExecuteHandler', () => {
],
[Constants.TOOL_SEARCH, { name: Constants.TOOL_SEARCH }],
]);
const ptcToolMap = new Map([['custom_tool', createMockTool('custom_tool', [])]]);
const customTool = createMockTool('custom_tool', []);
const ptcToolMap = new Map([
['custom_tool', customTool],
['direct_tool', createMockTool('direct_tool', [])],
]);
const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({
loadedTools: [legacyPtcTool] as never[],
configurable: {
@ -848,8 +861,120 @@ describe('createToolExecuteHandler', () => {
]);
expect(capturedConfigs).toHaveLength(1);
expect(capturedConfigs[0].toolDefs).toEqual([{ name: 'custom_tool' }]);
expect(capturedConfigs[0].toolMap).toBe(ptcToolMap);
expect(capturedConfigs[0].toolDefs).toEqual([
{ name: 'custom_tool', allowed_callers: ['code_execution'] },
]);
expect(capturedConfigs[0].disallowedToolDefs).toEqual([{ name: 'direct_tool' }]);
expect(capturedConfigs[0].toolMap).toEqual(new Map([['custom_tool', customTool]]));
});
it('uses the SDK live projection as the authoritative active PTC policy', async () => {
const capturedConfigs: Record<string, unknown>[] = [];
const legacyPtcTool = createMockTool(Constants.PROGRAMMATIC_TOOL_CALLING, capturedConfigs);
const activeProgrammaticTool = createMockTool('active_programmatic_tool', []);
const deferredProgrammaticTool = createMockTool('deferred_programmatic_tool', []);
const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({
loadedTools: [legacyPtcTool] as never[],
configurable: {
toolRegistry: new Map([
[
'active_programmatic_tool',
{ name: 'active_programmatic_tool', allowed_callers: ['code_execution'] },
],
[
'deferred_programmatic_tool',
{
name: 'deferred_programmatic_tool',
allowed_callers: ['code_execution'],
defer_loading: true,
},
],
['active_direct_tool', { name: 'active_direct_tool' }],
['deferred_direct_tool', { name: 'deferred_direct_tool', defer_loading: true }],
]),
ptcToolMap: new Map([
['active_programmatic_tool', activeProgrammaticTool],
['deferred_programmatic_tool', deferredProgrammaticTool],
]),
},
}));
const callerCapabilityProjection = {
version: 1 as const,
directToolNames: ['active_direct_tool'],
codeExecutionToolNames: ['active_programmatic_tool'],
directOnlyToolNames: ['active_direct_tool'],
codeExecutionOnlyToolNames: ['active_programmatic_tool'],
};
const handler = createToolExecuteHandler({ loadTools });
await invokeHandler(
handler,
[
{
id: 'call_projected',
name: Constants.PROGRAMMATIC_TOOL_CALLING,
args: { code: 'active_programmatic_tool "{}"' },
},
],
callerCapabilityProjection,
);
expect(loadTools).toHaveBeenCalledWith(
[Constants.PROGRAMMATIC_TOOL_CALLING],
undefined,
undefined,
callerCapabilityProjection,
);
expect(capturedConfigs[0].toolDefs).toEqual([
{ name: 'active_programmatic_tool', allowed_callers: ['code_execution'] },
]);
expect(capturedConfigs[0].disallowedToolDefs).toEqual([{ name: 'active_direct_tool' }]);
expect(capturedConfigs[0].toolMap).toEqual(
new Map([['active_programmatic_tool', activeProgrammaticTool]]),
);
});
it('treats an empty versioned projection as authoritative', async () => {
const capturedConfigs: Record<string, unknown>[] = [];
const legacyPtcTool = createMockTool(Constants.PROGRAMMATIC_TOOL_CALLING, capturedConfigs);
const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({
loadedTools: [legacyPtcTool] as never[],
configurable: {
toolRegistry: new Map([
[
'deferred_programmatic_tool',
{ name: 'deferred_programmatic_tool', allowed_callers: ['code_execution'] },
],
['deferred_direct_tool', { name: 'deferred_direct_tool' }],
]),
ptcToolMap: new Map([
['deferred_programmatic_tool', createMockTool('deferred_programmatic_tool', [])],
]),
},
}));
const handler = createToolExecuteHandler({ loadTools });
await invokeHandler(
handler,
[
{
id: 'call_empty_projection',
name: Constants.PROGRAMMATIC_TOOL_CALLING,
args: { code: 'print("done")' },
},
],
{
version: 1,
directToolNames: [],
codeExecutionToolNames: [],
directOnlyToolNames: [],
codeExecutionOnlyToolNames: [],
},
);
expect(capturedConfigs[0].toolDefs).toEqual([]);
expect(capturedConfigs[0].disallowedToolDefs).toEqual([]);
expect(capturedConfigs[0].toolMap).toEqual(new Map());
});
});

View file

@ -12,6 +12,7 @@ import type {
ToolExecuteResult,
ToolExecuteBatchRequest,
SubagentTaskConfig,
CallerCapabilityProjectionSnapshot,
} from '@librechat/agents';
import type { StructuredToolInterface } from '@librechat/agents/langchain/tools';
import type { ValidationIssue } from '@librechat/data-schemas';
@ -66,6 +67,7 @@ import {
INTENT_ARG,
} from './intent';
import { getSafeErrorMetadata, logAxiosError, runOutsideTracing, truncateMiddle } from '~/utils';
import { resolveCallerCapabilityProjectionSnapshot } from './callerCapabilities';
import { buildSkillPrimeMessage, SKILL_FILE_PREFIX } from './skills';
import { parseFrontmatter } from '../skills/import';
import { cleanCodeToolOutput } from './cleanup';
@ -99,6 +101,8 @@ export interface ToolExecuteOptions {
agentId?: string,
/** Immutable run configuration available before deferred tools connect. */
configurable?: Record<string, unknown>,
/** SDK-owned live caller capability projection for this agent context. */
callerCapabilityProjection?: CallerCapabilityProjectionSnapshot,
) => Promise<{
loadedTools: StructuredToolInterface[];
/** Additional configurable properties to merge (e.g., userMCPAuthMap) */
@ -4152,6 +4156,13 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
return {
handle: async (_event: string, data: ToolExecuteBatchRequest) => {
const { toolCalls, agentId, configurable, metadata, resolve, reject } = data;
const callerCapabilityProjection = resolveCallerCapabilityProjectionSnapshot(
(
data as ToolExecuteBatchRequest & {
callerCapabilityProjection?: unknown;
}
).callerCapabilityProjection,
);
/** Optional per-call channel (agents SDK > 3.2.33); cast keeps older
* installed SDK typings compiling until the release lands. */
const onResult = (
@ -4202,6 +4213,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
toolNames,
agentId,
sourceConfigurable,
callerCapabilityProjection,
);
const toolMap = new Map(loadedTools.map((t) => [t.name, t]));
const loadedConfigurable = toolConfigurable as Record<string, unknown> | undefined;
@ -4939,18 +4951,45 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
| Map<string, StructuredToolInterface>
| undefined;
if (toolRegistry) {
const activeCodeExecutionToolNames = callerCapabilityProjection
? new Set(callerCapabilityProjection.codeExecutionToolNames)
: undefined;
const activeDirectOnlyToolNames = callerCapabilityProjection
? new Set(callerCapabilityProjection.directOnlyToolNames)
: undefined;
const fileAuthoringToolNames =
getFileAuthoringToolNames(mergedConfigurable) ?? new Set<string>();
const filteredToolDefs: LCTool[] = Array.from(toolRegistry.values()).filter(
(t) =>
t.name !== Constants.PROGRAMMATIC_TOOL_CALLING &&
t.name !== Constants.BASH_PROGRAMMATIC_TOOL_CALLING &&
t.name !== Constants.TOOL_SEARCH &&
/* Host-only poll tool: implemented by the ON_TOOL_EXECUTE
* shortcut, not callable from PTC-generated code. */
t.name !== CHECK_BACKGROUND_TASK_NAME &&
!fileAuthoringToolNames.has(t.name),
);
const eligibleToolDefs: LCTool[] = [];
const disallowedToolDefs: LCTool[] = [];
for (const toolDef of toolRegistry.values()) {
const isInnerTool =
toolDef.name !== Constants.PROGRAMMATIC_TOOL_CALLING &&
toolDef.name !== Constants.BASH_PROGRAMMATIC_TOOL_CALLING &&
toolDef.name !== Constants.TOOL_SEARCH &&
toolDef.name !== CHECK_BACKGROUND_TASK_NAME &&
!fileAuthoringToolNames.has(toolDef.name);
if (!isInnerTool) {
continue;
}
const allowsCodeExecution = (
toolDef.allowed_callers ?? ['direct']
).includes('code_execution');
if (
allowsCodeExecution &&
(activeCodeExecutionToolNames == null ||
activeCodeExecutionToolNames.has(toolDef.name))
) {
eligibleToolDefs.push(toolDef);
} else if (
!allowsCodeExecution &&
(activeDirectOnlyToolNames == null ||
activeDirectOnlyToolNames.has(toolDef.name))
) {
disallowedToolDefs.push({
name: toolDef.name,
});
}
}
/* PTC-generated calls don't go through the host background
* interceptor, so strip the injected `run_in_background`
* param from target schemas (the registry entries were
@ -4961,12 +5000,16 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
* bridge must not advertise them. */
const toolDefs = stripIntentLabelsFromToolDefinitions(
stripBackgroundFromToolDefinitions(
filteredToolDefs,
eligibleToolDefs,
mergedConfigurable?.backgroundToolNames as string[] | undefined,
),
);
toolCallConfig.toolDefs = toolDefs;
toolCallConfig.toolMap = ptcToolMap ?? toolMap;
toolCallConfig.disallowedToolDefs = disallowedToolDefs;
const eligibleNames = new Set(toolDefs.map((toolDef) => toolDef.name));
toolCallConfig.toolMap = new Map(
[...(ptcToolMap ?? toolMap)].filter(([name]) => eligibleNames.has(name)),
);
}
}

View file

@ -1,6 +1,7 @@
export * from './avatars';
export * from './attachments';
export * from './chain';
export * from './callerCapabilities';
export * from './client';
export * from './config';
export * from './checkpointer';

View file

@ -190,6 +190,7 @@ describe('createAgentChatCompletion - MCP permission user propagation', () => {
['deferred_mcp_tool'],
'agent_test',
expect.objectContaining({ requestBody: runArgs.requestBody }),
undefined,
);
});