From 89494d45fdfa481f0aaba2350f467a8b2212ec15 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 22 Aug 2026 11:02:09 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A6=20fix:=20Restrict=20Programmatic?= =?UTF-8?q?=20Tool=20Execution=20Maps=20(#15105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- CONTEXT.md | 1 + api/package.json | 4 +- api/server/controllers/agents/openai.js | 3 +- api/server/controllers/agents/responses.js | 6 +- .../services/Endpoints/agents/initialize.js | 3 +- api/server/services/ToolService.js | 24 ++- .../services/__tests__/ToolService.spec.js | 167 +++++++++++++++++- package-lock.json | 10 +- packages/api/package.json | 4 +- .../api/src/agents/callerCapabilities.spec.ts | 33 ++++ packages/api/src/agents/callerCapabilities.ts | 30 ++++ packages/api/src/agents/handlers.spec.ts | 139 ++++++++++++++- packages/api/src/agents/handlers.ts | 67 +++++-- packages/api/src/agents/index.ts | 1 + .../api/src/agents/openai/service.spec.ts | 1 + 15 files changed, 456 insertions(+), 37 deletions(-) create mode 100644 packages/api/src/agents/callerCapabilities.spec.ts create mode 100644 packages/api/src/agents/callerCapabilities.ts diff --git a/CONTEXT.md b/CONTEXT.md index 730c48f272..49ebc71484 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/api/package.json b/api/package.json index d16563e955..397baf63e3 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 303c000455..922aa639a5 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -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, diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index f0814a37ac..13a2b6c521 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -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, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index edd4615dd8..1e818b481a 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -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, diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index a421708a89..51fed9159b 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -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} [params.mcpAvailableTools] - Run-scoped MCP tool definitions * @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] - Run-scoped MCP connections * @param {Record>} [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); } diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 3d27e1c1a7..8bf3126d72 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -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); diff --git a/package-lock.json b/package-lock.json index 56d9c67e5c..47b84e9e04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.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", diff --git a/packages/api/package.json b/packages/api/package.json index 2254c5862c..8ca111d0ed 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/agents/callerCapabilities.spec.ts b/packages/api/src/agents/callerCapabilities.spec.ts new file mode 100644 index 0000000000..e96be9994d --- /dev/null +++ b/packages/api/src/agents/callerCapabilities.spec.ts @@ -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(); + }); +}); diff --git a/packages/api/src/agents/callerCapabilities.ts b/packages/api/src/agents/callerCapabilities.ts new file mode 100644 index 0000000000..5ca94835c5 --- /dev/null +++ b/packages/api/src/agents/callerCapabilities.ts @@ -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; + 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; +} diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 4a3a56fd2a..12283aa1e1 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -48,13 +48,21 @@ function createHandler( function invokeHandler( handler: ReturnType, toolCalls: ToolCallRequest[], + callerCapabilityProjection?: { + version: 1; + directToolNames: string[]; + codeExecutionToolNames: string[]; + directOnlyToolNames: string[]; + codeExecutionOnlyToolNames: string[]; + }, ): Promise { 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[] = []; 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[] = []; + 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[] = []; + 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()); }); }); diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index cf622b22eb..ce4280694f 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -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, + /** 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 | undefined; @@ -4939,18 +4951,45 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand | Map | 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(); - 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)), + ); } } diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 869c2890cc..80cfb09bef 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -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'; diff --git a/packages/api/src/agents/openai/service.spec.ts b/packages/api/src/agents/openai/service.spec.ts index b4a2849986..541c37c841 100644 --- a/packages/api/src/agents/openai/service.spec.ts +++ b/packages/api/src/agents/openai/service.spec.ts @@ -190,6 +190,7 @@ describe('createAgentChatCompletion - MCP permission user propagation', () => { ['deferred_mcp_tool'], 'agent_test', expect.objectContaining({ requestBody: runArgs.requestBody }), + undefined, ); });