From 15a6cd73830787b0d0ac6cee361e071b902eb71b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 18:26:27 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=8F=9D=EF=B8=8F=20fix:=20Apply=20the?= =?UTF-8?q?=20No-Workspace=20Decision=20Wherever=20an=20Agent's=20Code=20R?= =?UTF-8?q?oute=20Resolves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A conversation set to run without attached workspaces failed mid-turn with "Choose an attached workspace before using this agent." when its agent's default machine was attached and the turn loaded a skill or another tool at execution time. Request-start initialization honoured the decision, but the execution-time tool loader re-resolved the agent's attached default and then demanded a workspace selection the conversation intentionally never made. The per-agent code rule now lives in one typed function, resolveAgentCodeExecution (packages/api/src/code/agent.ts): it applies the conversation's no-workspace opt-out, the run-code grant, the stateful capability and the stateful environment policy, then resolves the base route. Request-start initialization, lazy subagent metadata and graph members, the execution-time and definition tool loaders, and the legacy execute_code loader all go through it, so an opted-out agent lands on the managed default route with no code tools at every site. Per-call subagent machine choices offer and accept no attached machine in such a conversation, and a source invariant test keeps new resolver call sites on the shared rule. --- api/app/clients/tools/util/handleTools.js | 36 +-- .../services/Endpoints/agents/initialize.js | 87 ++---- .../Endpoints/agents/initialize.spec.js | 84 +++++ api/server/services/ToolService.js | 125 +++----- .../services/__tests__/ToolService.spec.js | 241 +++++++++------ packages/api/src/agents/initialize.ts | 77 +---- packages/api/src/code/agent.spec.ts | 287 ++++++++++++++++++ packages/api/src/code/agent.ts | 195 ++++++++++++ packages/api/src/code/index.ts | 1 + packages/api/src/code/targets.spec.ts | 20 ++ packages/api/src/code/targets.ts | 6 +- 11 files changed, 835 insertions(+), 324 deletions(-) create mode 100644 packages/api/src/code/agent.spec.ts create mode 100644 packages/api/src/code/agent.ts diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 717195c6680..91e87bb10d7 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -29,8 +29,8 @@ const { buildWebSearchDynamicContext, codeExecutionAuthHeaders, getCodeFileLocation, - resolveCodeExecutionContext, - resolveCodeExecutionWorkspaceSelections, + withRequestCodeInputs, + resolveAgentCodeExecution, resolveMCPClientCapabilityProfile, } = require('@librechat/api'); const { @@ -386,30 +386,20 @@ const loadTools = async ({ if (tool === Tools.execute_code) { requestedTools[tool] = async () => { - const statefulSessions = - agent?.stateful_code_sessions === true && - (await checkCapability(options.req, AgentCapabilities.stateful_code_sessions)); const codeExecutionContext = options.codeExecutionContext ?? - resolveCodeExecutionContext({ - statefulSessions, - environment: agent?.stateful_code_environment, - environmentId: agent?.code_environment_id, - environmentIds: agent?.code_environment_ids, - allowEnvironmentSelection: - options.req?.config?.endpoints?.agents?.statefulCodeSessions - ?.allowEnvironmentSelection, - workspaceSelections: resolveCodeExecutionWorkspaceSelections({ - conversation: options.req?.resolvedConversation, - request: options.req?.body, + resolveAgentCodeExecution( + withRequestCodeInputs({ + req: options.req ?? {}, + agent, + codeExecutionAvailable: true, + statefulSessionsAvailable: + agent?.stateful_code_sessions === true && + (await checkCapability(options.req, AgentCapabilities.stateful_code_sessions)), + userId: user, + conversationId: options.req?.body?.conversationId, }), - inheritedEnvironments: options.req?.codeWorkspaceInheritance, - environments: - options.req?.config?.endpoints?.agents?.statefulCodeSessions?.environments, - userId: user, - agentId: agent?.id, - conversationId: options.req?.body?.conversationId, - }); + ).context; const { files, toolContext } = await primeCodeFiles({ ...options, signal, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 333979348b5..836add4a92d 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -26,15 +26,13 @@ const { getLazySubagentConfigId, createRoutedGraphMemberLoader, createViewableSubagentLoader, - resolveCodeExecutionContext, + resolveAgentCodeFlags, + resolveAgentCodeExecution, resolveCodeExecutionWorkspaceSelections, - optsOutOfAttachedCodeEnvironment, - isImplicitStatefulCodeRouteAvailable, resolveCodeExecutionWorkspaceContext, resolveSubagentCodeWorkspaceInheritance, resolveSubagentCodeAvailability, guardRoutableSubagent, - createStatefulCodeEnvironmentPolicyError, buildSubagentThreadTaskConfig, backgroundCompletionWakeupsEnabled, createLazyAgentHistoryResolver, @@ -1151,6 +1149,7 @@ const initializeClientWithProvider = async ({ }); subagentCodeRouting = createSubagentCodeRouting({ + codeEnvironmentMode: runtimeRequestBody?.codeEnvironmentMode, allowEnvironmentSelection: appConfig.endpoints?.agents?.statefulCodeSessions?.allowEnvironmentSelection, persistedSelections: admittedConversation?.codeWorkspaces, @@ -1166,77 +1165,47 @@ const initializeClientWithProvider = async ({ }), }); + /** Inputs the shared per-agent code rule reads for a subagent in this request. */ + const getSubagentCodeParams = (agent) => ({ + agent, + requestBody: runtimeRequestBody, + conversation: admittedConversation, + codeExecutionAvailable: codeEnvAvailable === true, + statefulSessionsAvailable: statefulSessionsAvailable === true, + allowedStatefulCodeEnvironments, + allowEnvironmentSelection: + appConfig.endpoints?.agents?.statefulCodeSessions?.allowEnvironmentSelection, + inheritedEnvironments: req.codeWorkspaceInheritance, + environments: appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments, + userId, + conversationId, + }); + /** The code flags a lazy subagent runs with in this request. */ const getSubagentCodeFlags = (agent) => { - const configuredCodeEnvironments = - appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments; - const attachedEnvironmentOptOut = optsOutOfAttachedCodeEnvironment( - agent, - runtimeRequestBody, - configuredCodeEnvironments, - isImplicitStatefulCodeRouteAvailable( - process.env.CODE_ENVIRONMENT_DECISION_VERSION, - process.env.LIBRECHAT_CODE_BASEURL_STATEFUL, - ), - ); - const lazyCodeEnvAvailable = - codeEnvAvailable === true && - agent.tools?.includes(Tools.execute_code) === true && - !attachedEnvironmentOptOut; - const statefulCodeSessions = - statefulSessionsAvailable === true && - lazyCodeEnvAvailable && - agent.stateful_code_sessions === true && - agent.tools?.includes(Tools.execute_code) === true; - const statefulCodeEnvironment = agent.stateful_code_environment ?? 'user'; - if ( - statefulCodeSessions && - !allowedStatefulCodeEnvironments.includes(statefulCodeEnvironment) - ) { - throw createStatefulCodeEnvironmentPolicyError(statefulCodeEnvironment); - } + const flags = resolveAgentCodeFlags(getSubagentCodeParams(agent)); return { - configuredCodeEnvironments, - lazyCodeEnvAvailable, - statefulCodeSessions, - statefulCodeEnvironment, + lazyCodeEnvAvailable: flags.codeEnvAvailable, + statefulCodeSessions: flags.statefulSessions, + statefulCodeEnvironment: flags.statefulCodeEnvironment, }; }; const toLazySubagentMetadata = async (agent) => { - const { - configuredCodeEnvironments, - lazyCodeEnvAvailable, - statefulCodeSessions, - statefulCodeEnvironment, - } = getSubagentCodeFlags(agent); + const { lazyCodeEnvAvailable, statefulCodeSessions, statefulCodeEnvironment } = + getSubagentCodeFlags(agent); const codeAvailability = await resolveSubagentCodeAvailability({ agentId: agent.id, codeEnvAvailable: lazyCodeEnvAvailable, statefulCodeSessions, resolveContext: async () => { if (!lazyCodeEnvAvailable) return undefined; + const params = getSubagentCodeParams(agent); return resolveCodeExecutionWorkspaceContext({ - context: resolveCodeExecutionContext({ - statefulSessions: statefulCodeSessions, - environment: statefulCodeEnvironment, - environmentId: agent.code_environment_id, - environmentIds: agent.code_environment_ids, - allowEnvironmentSelection: - appConfig.endpoints?.agents?.statefulCodeSessions?.allowEnvironmentSelection, - workspaceSelections: resolveCodeExecutionWorkspaceSelections({ - conversation: admittedConversation, - request: runtimeRequestBody, - }), - inheritedEnvironments: req.codeWorkspaceInheritance, - environments: configuredCodeEnvironments, - userId, - agentId: agent.id, - conversationId, - }), + context: resolveAgentCodeExecution(params).context, requestedSelections: runtimeRequestBody?.codeWorkspaces, persistedSelections: admittedConversation?.codeWorkspaces, - environments: configuredCodeEnvironments, + environments: params.environments, getAppConfig, }); }, diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index ac9b3f5fc06..2454fc2af47 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -2400,6 +2400,90 @@ describe('initializeClient — subagent loading', () => { } }); + it('keeps a lazy subagent off its attached machine in a conversation that chose no workspace', async () => { + const subAgent = await createAgent({ + id: SUBAGENT_ID, + name: 'Attached Default Subagent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['execute_code'], + stateful_code_sessions: true, + stateful_code_environment: 'agent-user', + code_environment_id: 'attached-vm', + code_environment_ids: ['other-vm'], + }); + await grantView(subAgent); + mockInitializeAgent.mockResolvedValue( + makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] }, + }), + ); + const attachedVm = (id) => ({ + id, + name: id, + type: 'attached', + owner: 'deployment', + workerId: `${id}-worker`, + baseURL: 'https://bridge.example.com/v1/', + }); + const req = makeSubagentReq(); + req.body.codeEnvironmentMode = 'without_attached'; + req.config.endpoints.agents.capabilities.push('execute_code', 'stateful_code_sessions'); + req.config.endpoints.agents.statefulCodeSessions = { + allowedEnvironments: ['agent-user'], + allowEnvironmentSelection: true, + environments: [attachedVm('attached-vm'), attachedVm('other-vm')], + }; + const fetchSpy = jest.spyOn(globalThis, 'fetch'); + + try { + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + const descriptor = agentClientArgs.agent.lazySubagentConfigs[0]; + expect(descriptor).toEqual( + expect.objectContaining({ + codeEnvAvailable: false, + statefulCodeSessions: false, + codeExecutionContext: undefined, + subagentHostArgs: undefined, + codeExecutionChoices: undefined, + }), + ); + expect(descriptor.description ?? '').not.toContain('Unavailable in this conversation'); + + const initCalls = mockInitializeAgent.mock.calls.length; + await expect( + descriptor.resolve({ + signal: new AbortController().signal, + hostArgs: { machine: 'other-vm' }, + }), + ).rejects.toThrow(/rejected/); + expect(mockInitializeAgent).toHaveBeenCalledTimes(initCalls); + mockInitializeAgent.mockResolvedValueOnce(makeSubagentConfig(SUBAGENT_ID)); + await descriptor.resolve({ signal: new AbortController().signal }); + + const childInit = mockInitializeAgent.mock.calls.at(-1)[0]; + expect(childInit.agent.code_environment_id).toBe('attached-vm'); + expect(childInit.requestBody).toEqual( + expect.objectContaining({ codeEnvironmentMode: 'without_attached' }), + ); + expect( + mockInitializeAgent.mock.calls.some( + ([params]) => params.agent?.code_environment_id === 'other-vm', + ), + ).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + it.each([ [true, 'request', false], [false, 'request', false], diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 77af238288e..5d6c7e556a8 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -58,8 +58,8 @@ const { resolveAttachedWorkspaceAdmissionOptions, resolveAttachedWorkspaceRequestTimeoutMs, createContextProgrammaticBashTool, - resolveCodeExecutionContext, - resolveCodeExecutionWorkspaceSelections, + withRequestCodeInputs, + resolveAgentCodeExecution, resolveCodeExecutionWorkspaceContext, resolveRunFileCodeExecutionContext, resolveCallerCapabilityProjectionSnapshot, @@ -876,32 +876,21 @@ async function loadToolDefinitionsWrapper({ /** Gates the sandbox everywhere it is reachable, not just the `execute_code` * entry in `filteredTools`: this flag also drives tool classification and the * programmatic bash tool, which would otherwise run code for a denied role. */ - const codeExecutionEnabled = - agent.tools?.includes(Tools.execute_code) === true && - enabledCapabilities.has(AgentCapabilities.execute_code) && - canUseTool(Tools.execute_code); - const baseCodeExecutionContext = - codeExecutionContext ?? - resolveCodeExecutionContext({ - statefulSessions: - codeExecutionEnabled && - enabledCapabilities.has(AgentCapabilities.stateful_code_sessions) && - agent.stateful_code_sessions === true, - environment: agent.stateful_code_environment, - environmentId: agent.code_environment_id, - environmentIds: agent.code_environment_ids, - allowEnvironmentSelection: - req.config?.endpoints?.agents?.statefulCodeSessions?.allowEnvironmentSelection, - workspaceSelections: resolveCodeExecutionWorkspaceSelections({ - conversation: req.resolvedConversation, - request: runtimeRequestBody, + const { codeEnvAvailable: codeExecutionEnabled, context: baseCodeExecutionContext } = + resolveAgentCodeExecution( + withRequestCodeInputs({ + req, + agent, + requestBody: runtimeRequestBody, + codeExecutionAvailable: + enabledCapabilities.has(AgentCapabilities.execute_code) && canUseTool(Tools.execute_code), + statefulSessionsAvailable: enabledCapabilities.has( + AgentCapabilities.stateful_code_sessions, + ), + conversationId: runtimeRequestBody?.conversationId, + resolvedContext: codeExecutionContext, }), - inheritedEnvironments: req.codeWorkspaceInheritance, - environments: req.config?.endpoints?.agents?.statefulCodeSessions?.environments, - userId: req.user.id, - agentId: agent.id, - conversationId: runtimeRequestBody?.conversationId, - }); + ); const resolvedCodeExecutionContext = await resolveCodeExecutionWorkspaceContext({ context: baseCodeExecutionContext, requestedSelections: runtimeRequestBody?.codeWorkspaces, @@ -1810,34 +1799,22 @@ async function loadAgentTools({ }); } - const codeExecutionEnabled = - agent.tools?.includes(Tools.execute_code) === true && - enabledCapabilities.has(AgentCapabilities.execute_code) && - canUseTool(Tools.execute_code); const runtimeRequestBody = requestBody ?? req.body; - const statefulCodeSessions = - codeExecutionEnabled && - enabledCapabilities.has(AgentCapabilities.stateful_code_sessions) && - agent.stateful_code_sessions === true; - const baseCodeExecutionContext = - providedCodeExecutionContext ?? - resolveCodeExecutionContext({ - statefulSessions: statefulCodeSessions, - environment: agent.stateful_code_environment, - environmentId: agent.code_environment_id, - environmentIds: agent.code_environment_ids, - allowEnvironmentSelection: - req.config?.endpoints?.agents?.statefulCodeSessions?.allowEnvironmentSelection, - workspaceSelections: resolveCodeExecutionWorkspaceSelections({ - conversation: req.resolvedConversation, - request: runtimeRequestBody, + const { codeEnvAvailable: codeExecutionEnabled, context: baseCodeExecutionContext } = + resolveAgentCodeExecution( + withRequestCodeInputs({ + req, + agent, + requestBody: runtimeRequestBody, + codeExecutionAvailable: + enabledCapabilities.has(AgentCapabilities.execute_code) && canUseTool(Tools.execute_code), + statefulSessionsAvailable: enabledCapabilities.has( + AgentCapabilities.stateful_code_sessions, + ), + conversationId: runtimeRequestBody?.conversationId, + resolvedContext: providedCodeExecutionContext, }), - inheritedEnvironments: req.codeWorkspaceInheritance, - environments: req.config?.endpoints?.agents?.statefulCodeSessions?.environments, - userId: req.user.id, - agentId: agent.id, - conversationId: runtimeRequestBody?.conversationId, - }); + ); const codeExecutionContext = await resolveCodeExecutionWorkspaceContext({ context: baseCodeExecutionContext, requestedSelections: runtimeRequestBody?.codeWorkspaces, @@ -2244,7 +2221,7 @@ async function loadToolsForExecution({ } /** Short-circuits before the role read, so an agent without `execute_code` or a * deployment with the capability off pays nothing for the check. */ - const codeExecutionEnabled = + const codeExecutionAllowed = enabledCapabilities?.has(AgentCapabilities.execute_code) === true && agent?.tools?.includes(Tools.execute_code) === true && (await checkToolRolePermission({ @@ -2255,30 +2232,22 @@ async function loadToolsForExecution({ context: 'loadToolsForExecution', })); - /** Resolve the trusted endpoint/profile from the actually executing agent. - * This stays per-agent across handoffs and subagents; no graph-global stateful - * flag or model-supplied value is consulted. */ - const statefulCodeSessions = - codeExecutionEnabled && - enabledCapabilities?.has(AgentCapabilities.stateful_code_sessions) === true && - agent?.stateful_code_sessions === true; - const baseCodeExecutionContext = resolveCodeExecutionContext({ - statefulSessions: statefulCodeSessions, - environment: agent?.stateful_code_environment, - environmentId: agent?.code_environment_id, - environmentIds: agent?.code_environment_ids, - allowEnvironmentSelection: - req.config?.endpoints?.agents?.statefulCodeSessions?.allowEnvironmentSelection, - workspaceSelections: resolveCodeExecutionWorkspaceSelections({ - conversation: req.resolvedConversation, - request: runtimeRequestBody, - }), - inheritedEnvironments: req.codeWorkspaceInheritance, - environments: req.config?.endpoints?.agents?.statefulCodeSessions?.environments, - userId: req.user.id, - agentId: agent?.id, - conversationId: conversationId ?? runtimeRequestBody?.conversationId, - }); + /** Resolve the trusted endpoint/profile from the actually executing agent under the + * same per-agent rule as initialization, including the conversation's "No workspace" + * decision. This stays per-agent across handoffs and subagents; no graph-global + * stateful flag or model-supplied value is consulted. */ + const { codeEnvAvailable: codeExecutionEnabled, context: baseCodeExecutionContext } = + resolveAgentCodeExecution( + withRequestCodeInputs({ + req, + agent, + requestBody: runtimeRequestBody, + codeExecutionAvailable: codeExecutionAllowed, + statefulSessionsAvailable: + enabledCapabilities?.has(AgentCapabilities.stateful_code_sessions) === true, + conversationId: conversationId ?? runtimeRequestBody?.conversationId, + }), + ); const codeExecutionContext = await resolveCodeExecutionWorkspaceContext({ context: baseCodeExecutionContext, requestedSelections: runtimeRequestBody?.codeWorkspaces, diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index df7cbed6be6..6363889e0e4 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -31,40 +31,17 @@ const attachedWorkspaceOperations = [ 'edit_file', 'execute_command', ]; -const mockResolveCodeExecutionContext = jest.fn( - ({ statefulSessions, environment, userId, agentId, conversationId }) => { - if (!statefulSessions) { - return { - baseUrl: (process.env.LIBRECHAT_CODE_BASEURL ?? 'https://api.librechat.ai').replace( - /\/$/, - '', - ), - codeSessionKey: 'execute_code', - executionProfile: 'default', - statefulSessions: false, - }; - } - const baseUrl = process.env.LIBRECHAT_CODE_BASEURL_STATEFUL?.replace(/\/$/, ''); - if (!baseUrl) { - throw new Error('LIBRECHAT_CODE_BASEURL_STATEFUL is not configured'); - } - const fingerprint = (...parts) => - createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 32); - let runtimeSessionHint = `v2:user:${fingerprint(userId)}`; - if (environment === 'agent-user') { - runtimeSessionHint = `v2:agent-user:${fingerprint(userId, agentId)}`; - } else if (environment === 'conversation') { - runtimeSessionHint = `v2:conversation:${fingerprint(userId, conversationId)}`; - } - return { - baseUrl, - codeSessionKey: `execute_code:stateful:${runtimeSessionHint}`, - executionProfile: 'stateful', - runtimeSessionHint, - statefulSessions: true, - }; - }, -); +const fingerprint = (...parts) => + createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 32); +const attachedEnvironment = (overrides = {}) => ({ + id: 'personal-machine', + name: 'Personal machine', + type: 'attached', + owner: 'deployment', + baseURL: 'http://attached-code.test/v1', + workerId: 'worker-abc', + ...overrides, +}); const mockResolveCodeExecutionWorkspaceContext = jest.fn(async ({ context }) => { if (context.environmentType !== 'attached') { return context; @@ -117,7 +94,6 @@ jest.mock('@librechat/api', () => ({ GenerationJobManager: { emitChunk: (...args) => mockEmitChunk(...args), }, - resolveCodeExecutionContext: (...args) => mockResolveCodeExecutionContext(...args), resolveCodeExecutionWorkspaceContext: (...args) => mockResolveCodeExecutionWorkspaceContext(...args), createAttachedWorkspaceBashTool: (...args) => mockCreateAttachedWorkspaceBashTool(...args), @@ -794,15 +770,10 @@ describe('ToolService - Action Capability Gating', () => { ]; const req = createMockReq(capabilities); req.config.endpoints[EModelEndpoint.agents].codeApiMaxRetryWaitMs = 0; + req.config.endpoints[EModelEndpoint.agents].statefulCodeSessions = { + environments: [attachedEnvironment()], + }; mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); - mockResolveCodeExecutionContext.mockReturnValueOnce({ - baseUrl: 'https://attached-code.example.com/v1', - codeSessionKey: 'attached-session', - executionProfile: 'stateful', - statefulSessions: true, - environmentType: 'attached', - environmentId: 'personal-machine', - }); const result = await loadAgentTools({ req, @@ -811,6 +782,7 @@ describe('ToolService - Action Capability Gating', () => { id: 'attached-agent', tools: [Tools.execute_code], stateful_code_sessions: true, + code_environment_id: 'personal-machine', }, definitionsOnly, }); @@ -1096,7 +1068,7 @@ describe('ToolService - Action Capability Gating', () => { expect(mockPrimeSearchFiles).toHaveBeenCalledWith(expectedParams); expect(mockPrimeCodeFiles).toHaveBeenCalledWith({ ...expectedParams, - codeApiBaseUrl: 'https://api.librechat.ai', + codeApiBaseUrl: 'https://api.librechat.ai/v1', executionProfile: 'default', codeFileLocation: 'sandbox', }); @@ -3031,11 +3003,10 @@ describe('ToolService - Action Capability Gating', () => { actionsEnabled: false, }); - expect(result.configurable.codeExecutionContext.executionProfile).toBe('stateful'); - expect(mockResolveCodeExecutionContext).toHaveBeenLastCalledWith( + expect(result.configurable.codeExecutionContext).toEqual( expect.objectContaining({ - statefulSessions: true, - conversationId: 'resolved-api-conversation', + executionProfile: 'stateful', + runtimeSessionHint: `v2:conversation:${fingerprint('user_123', 'resolved-api-conversation')}`, }), ); } finally { @@ -3064,9 +3035,6 @@ describe('ToolService - Action Capability Gating', () => { ], }; mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); - mockResolveCodeExecutionContext.mockImplementationOnce( - jest.requireActual('@librechat/api').resolveCodeExecutionContext, - ); const result = await loadToolsForExecution({ req, @@ -3118,9 +3086,11 @@ describe('ToolService - Action Capability Gating', () => { actionsEnabled: false, }); - expect(result.configurable.codeExecutionContext.executionProfile).toBe('stateful'); - expect(mockResolveCodeExecutionContext).toHaveBeenLastCalledWith( - expect.objectContaining({ statefulSessions: true, environment: 'agent-user' }), + expect(result.configurable.codeExecutionContext).toEqual( + expect.objectContaining({ + executionProfile: 'stateful', + runtimeSessionHint: `v2:agent-user:${fingerprint('user_123', 'stateful-agent')}`, + }), ); } finally { delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL; @@ -3151,9 +3121,11 @@ describe('ToolService - Action Capability Gating', () => { actionsEnabled: false, }); - expect(result.configurable.codeExecutionContext.executionProfile).toBe('stateful'); - expect(mockResolveCodeExecutionContext).toHaveBeenLastCalledWith( - expect.objectContaining({ statefulSessions: true, environment: 'agent-user' }), + expect(result.configurable.codeExecutionContext).toEqual( + expect.objectContaining({ + executionProfile: 'stateful', + runtimeSessionHint: `v2:agent-user:${fingerprint('user_123', 'stateful-agent')}`, + }), ); } finally { delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL; @@ -3172,32 +3144,29 @@ describe('ToolService - Action Capability Gating', () => { codeWorkspaces: [{ environmentId: 'personal-machine', workspaceId: 'project-a' }], }; mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); - mockResolveCodeExecutionContext.mockReturnValueOnce({ - baseUrl: 'http://attached-code.test/v1', - codeSessionKey: 'execute_code:stateful:attached', - executionProfile: 'stateful', - statefulSessions: true, - environmentType: 'attached', - environmentId: 'personal-machine', - bridgeWorkerId: 'worker-abc', - codeEnvironmentConfigSchema: { - limits: { - maxCommandTimeoutMs: 80_000, - defaultCommandTimeoutMs: 60_000, - maxQueueWaitMs: 0, - maxRequestTimeoutMs: 90_000, - maxRunTimeoutMs: 180_000, - minCommandAdmissionMs: 15_000, - }, - admission: { - queueWaitMs: 60_000, - initialDelayMs: 1_000, - maxDelayMs: 30_000, - multiplier: 2, - jitterRatio: 0.2, - }, - }, - }); + req.config.endpoints[EModelEndpoint.agents].statefulCodeSessions = { + environments: [ + attachedEnvironment({ + configSchema: { + limits: { + maxCommandTimeoutMs: 80_000, + defaultCommandTimeoutMs: 60_000, + maxQueueWaitMs: 0, + maxRequestTimeoutMs: 90_000, + maxRunTimeoutMs: 180_000, + minCommandAdmissionMs: 15_000, + }, + admission: { + queueWaitMs: 60_000, + initialDelayMs: 1_000, + maxDelayMs: 30_000, + multiplier: 2, + jitterRatio: 0.2, + }, + }, + }), + ], + }; const toolRegistry = new Map([ [AgentConstants.BASH_TOOL, { name: AgentConstants.BASH_TOOL }], ]); @@ -3210,6 +3179,7 @@ describe('ToolService - Action Capability Gating', () => { tools: [Tools.execute_code], stateful_code_sessions: true, stateful_code_environment: 'agent-user', + code_environment_id: 'personal-machine', git_identity: { name: 'LibreChat Agent', email: 'agent@example.com' }, }, toolNames: [AgentConstants.BASH_TOOL], @@ -3254,15 +3224,9 @@ describe('ToolService - Action Capability Gating', () => { codeWorkspaces: [{ environmentId: 'personal-machine', workspaceId: 'project-a' }], }; mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); - mockResolveCodeExecutionContext.mockReturnValueOnce({ - baseUrl: 'http://attached-code.test/v1', - codeSessionKey: 'execute_code:stateful:attached', - executionProfile: 'stateful', - statefulSessions: true, - environmentType: 'attached', - environmentId: 'personal-machine', - bridgeWorkerId: 'worker-abc', - }); + req.config.endpoints[EModelEndpoint.agents].statefulCodeSessions = { + environments: [attachedEnvironment()], + }; mockResolveCodeExecutionWorkspaceContext.mockImplementationOnce(async ({ context }) => ({ ...context, codeWorkspace: { @@ -3282,6 +3246,7 @@ describe('ToolService - Action Capability Gating', () => { tools: [Tools.execute_code], stateful_code_sessions: true, stateful_code_environment: 'agent-user', + code_environment_id: 'personal-machine', }, toolNames: [AgentConstants.BASH_TOOL], toolRegistry: new Map([[AgentConstants.BASH_TOOL, { name: AgentConstants.BASH_TOOL }]]), @@ -3293,6 +3258,84 @@ describe('ToolService - Action Capability Gating', () => { ); }); + describe('in a conversation that chose no workspace', () => { + const SKYNET = 'code-yuwoQAAPhY1WMaDD6oIk'; + const capabilities = [ + AgentCapabilities.tools, + AgentCapabilities.skills, + AgentCapabilities.execute_code, + AgentCapabilities.stateful_code_sessions, + ]; + const terra = { + id: 'agent_nw3URLZi7gDH4kDgHGsWF', + tools: [Tools.execute_code], + stateful_code_sessions: true, + code_environment_id: SKYNET, + }; + const createDecisionReq = (codeEnvironmentMode) => { + const req = createMockReq(capabilities); + req.config.endpoints.agents.statefulCodeSessions = { + environments: [attachedEnvironment({ id: SKYNET, name: 'Skynet' })], + }; + req.body = { codeEnvironmentMode }; + req.resolvedConversation = { conversationId: 'convo-nows', codeEnvironmentMode }; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockResolveCodeExecutionWorkspaceContext.mockImplementationOnce( + jest.requireActual('@librechat/api').resolveCodeExecutionWorkspaceContext, + ); + return req; + }; + const loadFor = (req, toolNames, toolRegistry) => + loadToolsForExecution({ + req, + res: {}, + conversationId: 'convo-nows', + requestBody: req.body, + agent: terra, + toolNames, + toolRegistry, + actionsEnabled: false, + }); + + it('loads a skill on the managed route instead of requiring an attached workspace', async () => { + const result = await loadFor(createDecisionReq('without_attached'), [ + AgentConstants.SKILL_TOOL, + ]); + + expect(result.configurable.codeExecutionContext).toEqual( + expect.objectContaining({ executionProfile: 'default', statefulSessions: false }), + ); + expect(result.configurable.codeExecutionContext.environmentType).toBeUndefined(); + expect(mockResolveCodeExecutionWorkspaceContext).toHaveBeenCalledTimes(1); + }); + + it('offers none of the attached machine tools', async () => { + const toolRegistry = new Map( + [AgentConstants.BASH_TOOL, Tools.execute_code].map((name) => [name, { name }]), + ); + + const result = await loadFor( + createDecisionReq('without_attached'), + [AgentConstants.BASH_TOOL, Tools.execute_code], + toolRegistry, + ); + + expect(result.loadedTools).toEqual([]); + expect(mockCreateAttachedWorkspaceBashTool).not.toHaveBeenCalled(); + expect(mockLoadToolsUtil).not.toHaveBeenCalled(); + }); + + it('still requires a workspace selection when the conversation runs on attached machines', async () => { + await expect( + loadFor(createDecisionReq('attached'), [AgentConstants.SKILL_TOOL]), + ).rejects.toMatchObject({ + name: 'CodeWorkspaceSelectionError', + reason: 'required', + code: ErrorTypes.CODE_WORKSPACE_UNAVAILABLE, + }); + }); + }); + it('resolves stateful routing when handle_skill is the only requested tool', async () => { const capabilities = [ AgentCapabilities.tools, @@ -3317,9 +3360,11 @@ describe('ToolService - Action Capability Gating', () => { actionsEnabled: false, }); - expect(result.configurable.codeExecutionContext.executionProfile).toBe('stateful'); - expect(mockResolveCodeExecutionContext).toHaveBeenLastCalledWith( - expect.objectContaining({ statefulSessions: true, environment: 'agent-user' }), + expect(result.configurable.codeExecutionContext).toEqual( + expect.objectContaining({ + executionProfile: 'stateful', + runtimeSessionHint: `v2:agent-user:${fingerprint('user_123', 'stateful-agent')}`, + }), ); } finally { delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL; @@ -3581,9 +3626,6 @@ describe('ToolService - Action Capability Gating', () => { ], }; mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); - mockResolveCodeExecutionContext.mockImplementationOnce( - jest.requireActual('@librechat/api').resolveCodeExecutionContext, - ); const result = await loadToolsForExecution({ req, res: {}, @@ -3633,9 +3675,6 @@ describe('ToolService - Action Capability Gating', () => { endpoints: { agents: { statefulCodeSessions: { environments: [environment] } } }, }); mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); - mockResolveCodeExecutionContext.mockImplementationOnce( - jest.requireActual('@librechat/api').resolveCodeExecutionContext, - ); process.env.TEST_PTC_DEPLOYMENT_TOKEN = `deployment-token-${statefulWorkspace}`; const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 9d648632b44..633c6402e16 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -64,6 +64,7 @@ import type { TextContentFragment } from '../protection/types'; import type { CheckAccessParams } from '../middleware/access'; import type { GetProjectFiles } from '../projects/resources'; import type { MCPToolAlias } from '~/tools/classification'; +import type { CodeExecutionContext } from './execution'; import type { AgentExecutionContext } from './runtime'; import { injectSkillCatalog, @@ -74,13 +75,6 @@ import { unionPrimeAllowedTools, MAX_PRIMED_SKILLS_PER_TURN, } from './skills'; -import { - normalizeStatefulCodeEnvironment, - resolveCodeExecutionContext, - resolveCodeExecutionWorkspaceSelections, - type CodeEnvironmentConfig, - type CodeExecutionContext, -} from './execution'; import { resolveChatProjectFiles, resolveChatProjectPolicyFiles, @@ -119,10 +113,6 @@ import { formatChatProjectInstructions, hydrateChatProjectContextResources, } from '../projects/context'; -import { - createStatefulCodeEnvironmentPolicyError, - isFatalAgentInitializationError, -} from './errors'; import { assertChatProjectInstructions, ChatProjectResourcesChangedError } from '../projects/turn'; import { extractAgentContent, extractSkillContent } from '../protection/adapters/submissions'; import { createConfiguredContentInspector, inspectContent } from '../protection/runtime'; @@ -130,7 +120,6 @@ import { assertAgentAttachmentLimits, isModelBoundAttachmentFile } from './attac import { assertModelBoundContent } from '../middleware/modelBoundContent'; import { resolveAttachedWorkspaceReadFileLines } from '~/code/workspace'; import { isValidInstructionsPromptLink } from './instructions/linked'; -import { isImplicitStatefulCodeRouteAvailable } from '../code/config'; import { PARTIAL_RESOLVED_CONVERSATION } from './conversationSymbols'; import { registerMemoryTools, memoryToolUsageGuard } from './memory'; import { applyIntentLabels, sanitizeIntentLabels } from './intent'; @@ -140,7 +129,9 @@ import { resolveToolRoleGrants } from '~/tools/rolePermissions'; import { createRequestAgentExecutionContext } from './runtime'; import { resolveTurnDeliveryRouting } from './files/delivery'; import { filterFilesByEndpointRuntimeConfig } from '~/files'; +import { isFatalAgentInitializationError } from './errors'; import { hasActiveFilePolicy } from '../protection/files'; +import { resolveAgentCodeExecution } from '~/code/agent'; import { hasActiveFileFieldPolicy } from '~/protection'; import { applyBackgroundToolCalls } from './background'; import { applyTurnDelivery } from './files/delivery'; @@ -897,25 +888,6 @@ export type InitializedAgent = Agent & { }; export const DEFAULT_MAX_CONTEXT_TOKENS = 32000; -/** Returns true when a conversation-level choice disables an attached environment. */ -export function optsOutOfAttachedCodeEnvironment( - agent: Agent, - requestBody: RequestBody | undefined, - environments: readonly CodeEnvironmentConfig[] | undefined, - implicitStatefulRouteAvailable = false, -): boolean { - if (requestBody?.codeEnvironmentMode !== 'without_attached') return false; - const configured = agent.code_environment_id - ? environments?.find(({ id }) => id === agent.code_environment_id) - : environments?.find(({ default: isDefault }) => isDefault === true); - return ( - agent.stateful_code_sessions === true && - (configured?.type === 'attached' || - (configured == null && - (Boolean(agent.code_environment_id) || !implicitStatefulRouteAvailable))) - ); -} - /** * Parameters for initializing an agent * Matches the CJS signature from api/server/services/Endpoints/agents/agent.js @@ -1621,46 +1593,27 @@ export async function initializeAgent( const agentRequestsCodeExec = (agent.tools ?? []).includes(Tools.execute_code); const configuredCodeEnvironments = appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments; - const attachedEnvironmentOptOut = optsOutOfAttachedCodeEnvironment( + const { + attachedEnvironmentOptOut, + codeEnvAvailable: effectiveCodeEnvAvailable, + statefulSessions: effectiveStatefulSessions, + statefulCodeEnvironment, + context: codeExecutionContext, + } = resolveAgentCodeExecution({ agent, requestBody, - configuredCodeEnvironments, - isImplicitStatefulCodeRouteAvailable( - process.env.CODE_ENVIRONMENT_DECISION_VERSION, - process.env.LIBRECHAT_CODE_BASEURL_STATEFUL, - ), - ); - const effectiveCodeEnvAvailable = - params.codeEnvAvailable === true && agentRequestsCodeExec && !attachedEnvironmentOptOut; - const effectiveStatefulSessions = - effectiveCodeEnvAvailable && - params.statefulSessionsAvailable === true && - agent.stateful_code_sessions === true; - const statefulCodeEnvironment = normalizeStatefulCodeEnvironment(agent.stateful_code_environment); - if (effectiveStatefulSessions) { - const allowedStatefulCodeEnvironments = resolveAllowedStatefulCodeEnvironments( + conversation: runtime.resolvedConversation, + codeExecutionAvailable: params.codeEnvAvailable === true, + statefulSessionsAvailable: params.statefulSessionsAvailable === true, + allowedStatefulCodeEnvironments: resolveAllowedStatefulCodeEnvironments( params.allowedStatefulCodeEnvironments ?? appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.allowedEnvironments, - ); - if (!allowedStatefulCodeEnvironments.includes(statefulCodeEnvironment)) { - throw createStatefulCodeEnvironmentPolicyError(statefulCodeEnvironment); - } - } - const codeExecutionContext = resolveCodeExecutionContext({ - statefulSessions: effectiveStatefulSessions, - environment: statefulCodeEnvironment, - environmentId: agent.code_environment_id, - environmentIds: agent.code_environment_ids, + ), allowEnvironmentSelection: appConfig?.endpoints?.agents?.statefulCodeSessions?.allowEnvironmentSelection, - workspaceSelections: resolveCodeExecutionWorkspaceSelections({ - conversation: runtime.resolvedConversation, - request: requestBody, - }), inheritedEnvironments: runtime.codeWorkspaceInheritance, environments: configuredCodeEnvironments, userId: requestFileOwnerId, - agentId: agent.id, conversationId, }); const attachedWorkspaceTools = diff --git a/packages/api/src/code/agent.spec.ts b/packages/api/src/code/agent.spec.ts new file mode 100644 index 00000000000..8dbf77f46a4 --- /dev/null +++ b/packages/api/src/code/agent.spec.ts @@ -0,0 +1,287 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { Tools } from 'librechat-data-provider'; +import type { CodeEnvironmentConfig } from '~/agents/execution'; +import type { AgentCodeRouteAgent } from './agent'; +import { + withRequestCodeInputs, + resolveAgentCodeFlags, + resolveAgentCodeExecution, + optsOutOfAttachedCodeEnvironment, +} from './agent'; +import { resolveCodeExecutionWorkspaceContext } from './capabilities'; +import { CodeWorkspaceSelectionError } from './errors'; + +const SKYNET = 'code-yuwoQAAPhY1WMaDD6oIk'; + +const environments: CodeEnvironmentConfig[] = [ + { + id: 'managed', + name: 'Managed', + type: 'managed', + baseURL: 'https://managed.example/v1', + owner: 'deployment', + }, + { + id: SKYNET, + name: 'Skynet', + type: 'attached', + baseURL: 'https://bridge.example/v1/', + workerId: 'worker-skynet', + owner: 'deployment', + }, +]; + +/** The incident agent: stateful code sessions on an attached default machine. */ +const terra: AgentCodeRouteAgent = { + id: 'agent_nw3URLZi7gDH4kDgHGsWF', + tools: [Tools.execute_code], + stateful_code_sessions: true, + stateful_code_environment: 'conversation', + code_environment_id: SKYNET, +}; + +const baseParams = { + codeExecutionAvailable: true, + statefulSessionsAvailable: true, + environments, + userId: 'user-1', + conversationId: 'eb29d36a-1224-57ec-979b-5ec7d3475fa5', +}; + +describe('resolveAgentCodeExecution', () => { + it('runs an attached-default agent on the managed default route in a no-workspace conversation', async () => { + const resolved = resolveAgentCodeExecution({ + ...baseParams, + agent: terra, + requestBody: { codeEnvironmentMode: 'without_attached' }, + }); + + expect(resolved).toMatchObject({ + attachedEnvironmentOptOut: true, + codeEnvAvailable: false, + statefulSessions: false, + }); + expect(resolved.context).toEqual( + expect.objectContaining({ executionProfile: 'default', statefulSessions: false }), + ); + expect(resolved.context.environmentType).toBeUndefined(); + await expect( + resolveCodeExecutionWorkspaceContext({ context: resolved.context, environments }), + ).resolves.toBe(resolved.context); + }); + + it('still requires a workspace selection when the conversation runs on attached machines', async () => { + for (const requestBody of [{ codeEnvironmentMode: 'attached' as const }, {}]) { + const resolved = resolveAgentCodeExecution({ ...baseParams, agent: terra, requestBody }); + + expect(resolved).toMatchObject({ + attachedEnvironmentOptOut: false, + codeEnvAvailable: true, + statefulSessions: true, + }); + expect(resolved.context).toEqual( + expect.objectContaining({ environmentId: SKYNET, environmentType: 'attached' }), + ); + await expect( + resolveCodeExecutionWorkspaceContext({ context: resolved.context, environments }), + ).rejects.toEqual(new CodeWorkspaceSelectionError('required')); + } + }); + + it('keeps a managed stateful agent on its machine in a no-workspace conversation', () => { + const resolved = resolveAgentCodeExecution({ + ...baseParams, + agent: { ...terra, code_environment_id: 'managed' }, + requestBody: { codeEnvironmentMode: 'without_attached' }, + }); + + expect(resolved).toMatchObject({ + attachedEnvironmentOptOut: false, + codeEnvAvailable: true, + statefulSessions: true, + }); + expect(resolved.context).toEqual( + expect.objectContaining({ environmentId: 'managed', environmentType: 'managed' }), + ); + }); + + it('gives no code to an agent that does not list execute_code or lacks the grant', () => { + const withoutTool = resolveAgentCodeFlags({ ...baseParams, agent: { ...terra, tools: [] } }); + const withoutGrant = resolveAgentCodeFlags({ + ...baseParams, + agent: terra, + codeExecutionAvailable: false, + }); + const withoutAgent = resolveAgentCodeFlags({ ...baseParams, agent: undefined }); + + for (const flags of [withoutTool, withoutGrant, withoutAgent]) { + expect(flags).toMatchObject({ codeEnvAvailable: false, statefulSessions: false }); + } + }); + + it('enforces the stateful environment policy only for agents that run stateful code', () => { + expect(() => + resolveAgentCodeFlags({ + ...baseParams, + agent: terra, + allowedStatefulCodeEnvironments: ['user'], + }), + ).toThrow('Stateful code environment is not allowed by this deployment: conversation'); + expect( + resolveAgentCodeFlags({ + ...baseParams, + agent: terra, + requestBody: { codeEnvironmentMode: 'without_attached' }, + allowedStatefulCodeEnvironments: ['user'], + }).statefulSessions, + ).toBe(false); + }); + + it('applies the conversation rule to a context initialization already resolved', () => { + const resolvedContext = resolveAgentCodeExecution({ ...baseParams, agent: terra }).context; + const reused = resolveAgentCodeExecution({ + ...baseParams, + agent: terra, + requestBody: { codeEnvironmentMode: 'without_attached' }, + resolvedContext, + }); + + expect(reused.context).toBe(resolvedContext); + expect(reused.codeEnvAvailable).toBe(false); + }); + + it('opts out an unresolvable stateful route only when no implicit managed route exists', () => { + const agent = { ...terra, code_environment_id: undefined }; + const body = { codeEnvironmentMode: 'without_attached' as const }; + + expect(optsOutOfAttachedCodeEnvironment(agent, body, [], false)).toBe(true); + expect(optsOutOfAttachedCodeEnvironment(agent, body, [], true)).toBe(false); + expect(optsOutOfAttachedCodeEnvironment(agent, {}, environments, false)).toBe(false); + }); +}); + +describe('withRequestCodeInputs', () => { + it('reads the admitted conversation, deployment machines and principal from the request', () => { + const inheritance = new Map([[terra.id ?? '', SKYNET]]); + const conversation = { codeWorkspaces: [{ environmentId: SKYNET, workspaceId: 'librechat' }] }; + const params = withRequestCodeInputs({ + req: { + body: { codeEnvironmentMode: 'attached' }, + user: { id: 'user-1' } as never, + config: { + endpoints: { + agents: { statefulCodeSessions: { environments, allowEnvironmentSelection: false } }, + }, + } as never, + resolvedConversation: conversation, + codeWorkspaceInheritance: inheritance, + }, + agent: terra, + codeExecutionAvailable: true, + statefulSessionsAvailable: true, + conversationId: 'convo-1', + }); + + expect(params).toEqual( + expect.objectContaining({ + requestBody: { codeEnvironmentMode: 'attached' }, + conversation, + environments, + allowEnvironmentSelection: false, + inheritedEnvironments: inheritance, + userId: 'user-1', + conversationId: 'convo-1', + }), + ); + expect( + withRequestCodeInputs({ + req: { body: {}, user: { id: 'user-1' } as never }, + requestBody: { codeEnvironmentMode: 'without_attached' }, + codeExecutionAvailable: true, + statefulSessionsAvailable: true, + userId: 'tool-user', + }), + ).toEqual( + expect.objectContaining({ + requestBody: { codeEnvironmentMode: 'without_attached' }, + userId: 'tool-user', + }), + ); + }); +}); + +/** + * Every server path that resolves an agent's code route must apply the conversation's + * "No workspace" decision. The route primitives may only be called from the shared rule + * and from callers that consume flags the rule already resolved. + */ +describe('agent code route invariant', () => { + const repoRoot = path.resolve(__dirname, '../../../..'); + const roots = ['packages/api/src', 'api'].map((root) => path.join(repoRoot, root)); + const isSource = (file: string): boolean => + /\.(js|ts)$/.test(file) && + !/\.(spec|test)\.(js|ts)$/.test(file) && + !file.endsWith('.d.ts') && + !file.split(path.sep).includes('__tests__') && + !file.split(path.sep).includes('__mocks__'); + + const walk = (dir: string): string[] => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + if (entry.name === 'node_modules' || entry.name === 'dist') return []; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return walk(full); + return isSource(full) ? [full] : []; + }); + + const sources = roots.flatMap(walk).map((file) => ({ + file: path.relative(repoRoot, file).split(path.sep).join('/'), + text: fs.readFileSync(file, 'utf8'), + })); + const count = (text: string, call: string): number => + text.split(new RegExp(`\\b${call}\\(`)).length - 1; + const callers = (call: string): string[] => + sources.filter(({ text }) => count(text, call) > 0).map(({ file }) => file); + + it('scans both server workspaces', () => { + expect(sources.some(({ file }) => file === 'api/server/services/ToolService.js')).toBe(true); + expect(sources.some(({ file }) => file === 'packages/api/src/code/agent.ts')).toBe(true); + }); + + it('resolves base code routes only through the shared rule', () => { + expect(callers('resolveCodeExecutionContext').sort()).toEqual([ + /** Fallbacks over flags `initializeAgent` already resolved; they never pass a machine. */ + 'packages/api/src/agents/codeFilesSession.ts', + 'packages/api/src/agents/execution.ts', + 'packages/api/src/agents/prewarm.ts', + 'packages/api/src/code/agent.ts', + /** Per-call subagent targets: gated on the rule's flags and the conversation's mode. */ + 'packages/api/src/code/targets.ts', + ]); + expect(callers('optsOutOfAttachedCodeEnvironment')).toEqual(['packages/api/src/code/agent.ts']); + }); + + it('binds workspaces only to routes the shared rule resolved', () => { + const routePrimitives = [ + 'packages/api/src/code/capabilities.ts', + 'packages/api/src/code/targets.ts', + ]; + const outsideRule = sources.filter( + ({ file, text }) => + count(text, 'resolveCodeExecutionWorkspaceContext') > 0 && !routePrimitives.includes(file), + ); + + expect(outsideRule.map(({ file }) => file).sort()).toEqual([ + 'api/server/services/Endpoints/agents/initialize.js', + 'api/server/services/ToolService.js', + ]); + for (const { file, text } of outsideRule) { + expect({ + file, + unguarded: + count(text, 'resolveCodeExecutionWorkspaceContext') > + count(text, 'resolveAgentCodeExecution'), + }).toEqual({ file, unguarded: false }); + } + }); +}); diff --git a/packages/api/src/code/agent.ts b/packages/api/src/code/agent.ts new file mode 100644 index 00000000000..26e1c8c8ba5 --- /dev/null +++ b/packages/api/src/code/agent.ts @@ -0,0 +1,195 @@ +import { Tools } from 'librechat-data-provider'; +import type { Agent, StatefulCodeEnvironment } from 'librechat-data-provider'; +import type { IConversation } from '@librechat/data-schemas'; +import type { CodeEnvironmentConfig, CodeExecutionContext } from '~/agents/execution'; +import type { RequestBody, ServerRequest } from '~/types'; +import { + resolveCodeExecutionContext, + normalizeStatefulCodeEnvironment, + resolveCodeExecutionWorkspaceSelections, +} from '~/agents/execution'; +import { createStatefulCodeEnvironmentPolicyError } from '~/agents/errors'; +import { isImplicitStatefulCodeRouteAvailable } from './config'; + +/** The saved agent fields that decide where an agent runs code. */ +export type AgentCodeRouteAgent = Partial< + Pick< + Agent, + | 'id' + | 'tools' + | 'stateful_code_sessions' + | 'stateful_code_environment' + | 'code_environment_id' + | 'code_environment_ids' + > +>; + +/** A request body carrying the conversation's admitted code environment decision. */ +export type CodeEnvironmentDecisionCarrier = Pick< + RequestBody, + 'codeEnvironmentMode' | 'codeWorkspaces' +>; + +/** Whether the conversation chose to run without attached machines ("No workspace"). */ +export function runsWithoutAttachedCodeEnvironments( + requestBody: CodeEnvironmentDecisionCarrier | null | undefined, +): boolean { + return requestBody?.codeEnvironmentMode === 'without_attached'; +} + +/** Returns true when a conversation-level choice disables an attached environment. */ +export function optsOutOfAttachedCodeEnvironment( + agent: AgentCodeRouteAgent | null | undefined, + requestBody: CodeEnvironmentDecisionCarrier | null | undefined, + environments: readonly CodeEnvironmentConfig[] | undefined, + implicitStatefulRouteAvailable = false, +): boolean { + if (agent == null || !runsWithoutAttachedCodeEnvironments(requestBody)) return false; + const configured = agent.code_environment_id + ? environments?.find(({ id }) => id === agent.code_environment_id) + : environments?.find(({ default: isDefault }) => isDefault === true); + return ( + agent.stateful_code_sessions === true && + (configured?.type === 'attached' || + (configured == null && + (Boolean(agent.code_environment_id) || !implicitStatefulRouteAvailable))) + ); +} + +export interface AgentCodeFlagsParams { + /** The executing agent; a missing agent runs no agent-scoped code. */ + agent?: AgentCodeRouteAgent | null; + /** Normalized request body; its `codeEnvironmentMode` is the conversation's decision. */ + requestBody?: CodeEnvironmentDecisionCarrier | null; + /** Deployment `execute_code` capability AND the principal's run-code grant. */ + codeExecutionAvailable: boolean; + /** Deployment `stateful_code_sessions` capability. */ + statefulSessionsAvailable: boolean; + /** The requesting principal's tenant-scoped machine list. */ + environments?: readonly CodeEnvironmentConfig[]; + /** Enforced when present: a stateful agent outside this list is refused. */ + allowedStatefulCodeEnvironments?: readonly StatefulCodeEnvironment[]; +} + +export interface AgentCodeFlags { + /** The conversation's "No workspace" decision removed this agent's attached machine. */ + attachedEnvironmentOptOut: boolean; + /** The agent gets code tools this request. */ + codeEnvAvailable: boolean; + /** The agent runs stateful code sessions this request. */ + statefulSessions: boolean; + statefulCodeEnvironment: StatefulCodeEnvironment; +} + +/** + * The single per-agent code rule. A conversation that chose to run without attached + * machines turns off code for an agent whose default is an attached machine (or an + * unresolvable stateful route), so it is never routed to that machine and never needs + * a workspace selection, whichever path (request start, a subagent, a graph member, or + * an execution-time tool load) asks. + */ +export function resolveAgentCodeFlags(params: AgentCodeFlagsParams): AgentCodeFlags { + const { agent } = params; + const attachedEnvironmentOptOut = optsOutOfAttachedCodeEnvironment( + agent, + params.requestBody, + params.environments, + isImplicitStatefulCodeRouteAvailable( + process.env.CODE_ENVIRONMENT_DECISION_VERSION, + process.env.LIBRECHAT_CODE_BASEURL_STATEFUL, + ), + ); + const codeEnvAvailable = + params.codeExecutionAvailable && + agent?.tools?.includes(Tools.execute_code) === true && + !attachedEnvironmentOptOut; + const statefulSessions = + codeEnvAvailable && params.statefulSessionsAvailable && agent?.stateful_code_sessions === true; + const statefulCodeEnvironment = normalizeStatefulCodeEnvironment( + agent?.stateful_code_environment, + ); + if ( + statefulSessions && + params.allowedStatefulCodeEnvironments != null && + !params.allowedStatefulCodeEnvironments.includes(statefulCodeEnvironment) + ) { + throw createStatefulCodeEnvironmentPolicyError(statefulCodeEnvironment); + } + return { attachedEnvironmentOptOut, codeEnvAvailable, statefulSessions, statefulCodeEnvironment }; +} + +export interface AgentCodeExecutionParams extends AgentCodeFlagsParams { + /** The admitted conversation; its sealed workspace selections win over the request. */ + conversation?: Pick, 'codeWorkspaces'> | null; + /** Deployment ceiling for per-chat machine selection. */ + allowEnvironmentSelection?: boolean; + /** Request-scoped subagent inheritance, keyed by saved agent ID. */ + inheritedEnvironments?: ReadonlyMap; + userId?: string | null; + conversationId?: string | null; + /** A context initialization already resolved for this agent under this rule. */ + resolvedContext?: CodeExecutionContext; +} + +export interface AgentCodeExecution extends AgentCodeFlags { + /** Base route; bind its workspace with `resolveCodeExecutionWorkspaceContext`. */ + context: CodeExecutionContext; +} + +/** + * Resolves an agent's code flags and base route under {@link resolveAgentCodeFlags}. + * Every resolver of an agent's code route goes through here; an opted-out agent lands + * on the managed default route, which workspace binding passes through unchanged. + */ +export function resolveAgentCodeExecution(params: AgentCodeExecutionParams): AgentCodeExecution { + const flags = resolveAgentCodeFlags(params); + if (params.resolvedContext != null) { + return { ...flags, context: params.resolvedContext }; + } + const { agent, requestBody } = params; + const context = resolveCodeExecutionContext({ + statefulSessions: flags.statefulSessions, + environment: flags.statefulCodeEnvironment, + environmentId: agent?.code_environment_id, + environmentIds: agent?.code_environment_ids, + allowEnvironmentSelection: params.allowEnvironmentSelection, + workspaceSelections: resolveCodeExecutionWorkspaceSelections({ + conversation: params.conversation, + request: requestBody, + }), + inheritedEnvironments: params.inheritedEnvironments, + environments: params.environments, + userId: params.userId, + agentId: agent?.id, + conversationId: params.conversationId, + }); + return { ...flags, context }; +} + +export type RequestAgentCodeExecutionParams = Omit< + AgentCodeExecutionParams, + 'conversation' | 'allowEnvironmentSelection' | 'inheritedEnvironments' | 'environments' +> & { + /** The authenticated request whose admitted conversation, config and principal apply. */ + req: Pick< + ServerRequest, + 'body' | 'user' | 'config' | 'resolvedConversation' | 'codeWorkspaceInheritance' + >; +}; + +/** Fills the request-scoped inputs of the per-agent code rule from an authenticated request. */ +export function withRequestCodeInputs({ + req, + ...params +}: RequestAgentCodeExecutionParams): AgentCodeExecutionParams { + const statefulCodeSessions = req.config?.endpoints?.agents?.statefulCodeSessions; + return { + ...params, + requestBody: params.requestBody ?? req.body, + conversation: req.resolvedConversation, + allowEnvironmentSelection: statefulCodeSessions?.allowEnvironmentSelection, + inheritedEnvironments: req.codeWorkspaceInheritance, + environments: statefulCodeSessions?.environments, + userId: params.userId ?? req.user?.id, + }; +} diff --git a/packages/api/src/code/index.ts b/packages/api/src/code/index.ts index 237e3a04561..9837f93ba4b 100644 --- a/packages/api/src/code/index.ts +++ b/packages/api/src/code/index.ts @@ -11,3 +11,4 @@ export * from './inheritance'; export * from './errors'; export * from './instructions'; export * from './targets'; +export * from './agent'; diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index 37ade394aa2..44c50c1927d 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -257,6 +257,9 @@ describe('subagent code targets', () => { await expect( resolveSubagentCodeTargets(params({ persistedSelections: 'tampered' })), ).resolves.toMatchObject({ targets: [] }); + await expect( + resolveSubagentCodeTargets(params({ codeEnvironmentMode: 'without_attached' })), + ).resolves.toMatchObject({ targets: [] }); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -875,6 +878,23 @@ describe('subagent code routing', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it('offers and accepts no attached machine in a conversation that chose no workspace', async () => { + const fetchSpy = serveWorkers(allOnline); + const routing = createSubagentCodeRouting({ + ...request, + codeEnvironmentMode: 'without_attached', + }); + + await expect(routing.describe(reviewer, flags)).resolves.toEqual({}); + await expect( + routing.place({ agent: reviewer, flags, context: call({ machine: 'buildbox' }) }), + ).rejects.toMatchObject({ argument: 'machine', rejection: 'not_allowed' }); + await expect( + routing.place({ agent: reviewer, flags, context: call({ workspace: 'agents' }) }), + ).rejects.toMatchObject({ argument: 'workspace', rejection: 'not_allowed' }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it('skips queued target probes once the resolution is canceled', async () => { const fetchSpy = serveWorkers(allOnline); const controller = new AbortController(); diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index ea64a66ee5c..797c50a30a5 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -8,6 +8,7 @@ import type { import type { SubagentExecutionContext } from '@librechat/agents'; import type { CodeEnvironmentConfig, CodeExecutionContext } from '~/agents/execution'; import type { CodeCapabilityConfigLoader } from './capabilities'; +import type { RequestBody } from '~/types'; import { resolveCodeExecutionContext, isExecutableAttachedEnvironment, @@ -17,6 +18,7 @@ import { CodeWorkspaceSelectionError, describeCodeWorkspaceUnavailableSubagent } import { resolveCodeExecutionWorkspaceContext } from './capabilities'; import { guardUnavailableSubagent } from '~/agents/lazySubagents'; import { isCodeEnvironmentSelectionEnabled } from './protocol'; +import { runsWithoutAttachedCodeEnvironments } from './agent'; import { createConcurrencyLimiter } from '~/utils/promise'; /** Subagent call property naming the attached machine the child runs on. */ @@ -114,6 +116,8 @@ export interface SubagentCodeTargetParams { /** The child's saved machine allowlist. */ environmentIds?: readonly string[]; allowEnvironmentSelection?: boolean; + /** The conversation's admitted mode; "No workspace" admits no attached machine. */ + codeEnvironmentMode?: RequestBody['codeEnvironmentMode']; /** The conversation's sealed decision; authoritative over the request. */ persistedSelections?: unknown; requestedSelections?: unknown; @@ -233,7 +237,7 @@ async function resolveCandidate( export async function resolveSubagentCodeTargets( params: SubagentCodeTargetParams, ): Promise { - if (!params.statefulSessions) { + if (!params.statefulSessions || runsWithoutAttachedCodeEnvironments(params)) { return NO_TARGETS; } const selections = params.persistedSelections ?? params.requestedSelections; From 2b0dae99e2b274b24352dd47878423b4b43e37cf Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 18:35:46 -0400 Subject: [PATCH 2/3] fix: Pass the Implicit Stateful Route Rollout Into the Shared Code Rule The per-agent code rule read the implicit stateful route rollout from the environment itself. It now takes it as a parameter, and the request boundaries (initializeAgent and withRequestCodeInputs) resolve it from the deployment configuration, so the rule stays a pure function of its inputs. --- .../services/Endpoints/agents/initialize.js | 25 +++++++------- packages/api/src/agents/initialize.ts | 5 +++ packages/api/src/code/agent.spec.ts | 33 +++++++++++++++++++ packages/api/src/code/agent.ts | 23 +++++++++---- 4 files changed, 66 insertions(+), 20 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 836add4a92d..9b2b8c0ce85 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -27,6 +27,7 @@ const { createRoutedGraphMemberLoader, createViewableSubagentLoader, resolveAgentCodeFlags, + withRequestCodeInputs, resolveAgentCodeExecution, resolveCodeExecutionWorkspaceSelections, resolveCodeExecutionWorkspaceContext, @@ -1166,20 +1167,16 @@ const initializeClientWithProvider = async ({ }); /** Inputs the shared per-agent code rule reads for a subagent in this request. */ - const getSubagentCodeParams = (agent) => ({ - agent, - requestBody: runtimeRequestBody, - conversation: admittedConversation, - codeExecutionAvailable: codeEnvAvailable === true, - statefulSessionsAvailable: statefulSessionsAvailable === true, - allowedStatefulCodeEnvironments, - allowEnvironmentSelection: - appConfig.endpoints?.agents?.statefulCodeSessions?.allowEnvironmentSelection, - inheritedEnvironments: req.codeWorkspaceInheritance, - environments: appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments, - userId, - conversationId, - }); + const getSubagentCodeParams = (agent) => + withRequestCodeInputs({ + req, + agent, + requestBody: runtimeRequestBody, + codeExecutionAvailable: codeEnvAvailable === true, + statefulSessionsAvailable: statefulSessionsAvailable === true, + allowedStatefulCodeEnvironments, + conversationId, + }); /** The code flags a lazy subagent runs with in this request. */ const getSubagentCodeFlags = (agent) => { diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 633c6402e16..71554c35e90 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -122,6 +122,7 @@ import { resolveAttachedWorkspaceReadFileLines } from '~/code/workspace'; import { isValidInstructionsPromptLink } from './instructions/linked'; import { PARTIAL_RESOLVED_CONVERSATION } from './conversationSymbols'; import { registerMemoryTools, memoryToolUsageGuard } from './memory'; +import { isImplicitStatefulCodeRouteAvailable } from '~/code/config'; import { applyIntentLabels, sanitizeIntentLabels } from './intent'; import { prepareQueuedCodeFileContext } from '~/files/code/queued'; import { ContentFilterError } from '../middleware/contentFilter'; @@ -1613,6 +1614,10 @@ export async function initializeAgent( appConfig?.endpoints?.agents?.statefulCodeSessions?.allowEnvironmentSelection, inheritedEnvironments: runtime.codeWorkspaceInheritance, environments: configuredCodeEnvironments, + implicitStatefulRouteAvailable: isImplicitStatefulCodeRouteAvailable( + process.env.CODE_ENVIRONMENT_DECISION_VERSION, + process.env.LIBRECHAT_CODE_BASEURL_STATEFUL, + ), userId: requestFileOwnerId, conversationId, }); diff --git a/packages/api/src/code/agent.spec.ts b/packages/api/src/code/agent.spec.ts index 8dbf77f46a4..4c4016201d4 100644 --- a/packages/api/src/code/agent.spec.ts +++ b/packages/api/src/code/agent.spec.ts @@ -159,9 +159,42 @@ describe('resolveAgentCodeExecution', () => { expect(optsOutOfAttachedCodeEnvironment(agent, body, [], true)).toBe(false); expect(optsOutOfAttachedCodeEnvironment(agent, {}, environments, false)).toBe(false); }); + + it('keeps an implicit stateful agent on code only when the caller reports the route live', () => { + const agent = { ...terra, code_environment_id: undefined }; + const params = { + ...baseParams, + agent, + environments: [], + requestBody: { codeEnvironmentMode: 'without_attached' as const }, + }; + + expect(resolveAgentCodeFlags(params).codeEnvAvailable).toBe(false); + expect( + resolveAgentCodeFlags({ ...params, implicitStatefulRouteAvailable: true }).codeEnvAvailable, + ).toBe(true); + }); }); describe('withRequestCodeInputs', () => { + afterEach(() => { + delete process.env.CODE_ENVIRONMENT_DECISION_VERSION; + delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL; + }); + + it('reports the deployment implicit stateful route rollout', () => { + const request = { + req: { body: {} }, + codeExecutionAvailable: true, + statefulSessionsAvailable: true, + }; + expect(withRequestCodeInputs(request).implicitStatefulRouteAvailable).toBe(false); + + process.env.CODE_ENVIRONMENT_DECISION_VERSION = '1'; + process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'https://stateful.example/v1'; + expect(withRequestCodeInputs(request).implicitStatefulRouteAvailable).toBe(true); + }); + it('reads the admitted conversation, deployment machines and principal from the request', () => { const inheritance = new Map([[terra.id ?? '', SKYNET]]); const conversation = { codeWorkspaces: [{ environmentId: SKYNET, workspaceId: 'librechat' }] }; diff --git a/packages/api/src/code/agent.ts b/packages/api/src/code/agent.ts index 26e1c8c8ba5..2a7c2ef4404 100644 --- a/packages/api/src/code/agent.ts +++ b/packages/api/src/code/agent.ts @@ -69,6 +69,8 @@ export interface AgentCodeFlagsParams { environments?: readonly CodeEnvironmentConfig[]; /** Enforced when present: a stateful agent outside this list is refused. */ allowedStatefulCodeEnvironments?: readonly StatefulCodeEnvironment[]; + /** Whether the deployment's versioned implicit stateful route is live. */ + implicitStatefulRouteAvailable?: boolean; } export interface AgentCodeFlags { @@ -94,10 +96,7 @@ export function resolveAgentCodeFlags(params: AgentCodeFlagsParams): AgentCodeFl agent, params.requestBody, params.environments, - isImplicitStatefulCodeRouteAvailable( - process.env.CODE_ENVIRONMENT_DECISION_VERSION, - process.env.LIBRECHAT_CODE_BASEURL_STATEFUL, - ), + params.implicitStatefulRouteAvailable === true, ); const codeEnvAvailable = params.codeExecutionAvailable && @@ -168,7 +167,11 @@ export function resolveAgentCodeExecution(params: AgentCodeExecutionParams): Age export type RequestAgentCodeExecutionParams = Omit< AgentCodeExecutionParams, - 'conversation' | 'allowEnvironmentSelection' | 'inheritedEnvironments' | 'environments' + | 'conversation' + | 'allowEnvironmentSelection' + | 'inheritedEnvironments' + | 'environments' + | 'implicitStatefulRouteAvailable' > & { /** The authenticated request whose admitted conversation, config and principal apply. */ req: Pick< @@ -177,7 +180,11 @@ export type RequestAgentCodeExecutionParams = Omit< >; }; -/** Fills the request-scoped inputs of the per-agent code rule from an authenticated request. */ +/** + * Fills the request-scoped inputs of the per-agent code rule from an authenticated request: + * its admitted conversation, principal-scoped machines and inheritance, and the deployment's + * implicit stateful route rollout. + */ export function withRequestCodeInputs({ req, ...params @@ -191,5 +198,9 @@ export function withRequestCodeInputs({ inheritedEnvironments: req.codeWorkspaceInheritance, environments: statefulCodeSessions?.environments, userId: params.userId ?? req.user?.id, + implicitStatefulRouteAvailable: isImplicitStatefulCodeRouteAvailable( + process.env.CODE_ENVIRONMENT_DECISION_VERSION, + process.env.LIBRECHAT_CODE_BASEURL_STATEFUL, + ), }; } From 8f062c3246e6deb6164415030cb688a8842bf88a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 18:46:48 -0400 Subject: [PATCH 3/3] fix: Carry the Initializer's Code Opt-Out Into Tool Definition Loading initializeAgent hands the tool loaders a partial agent record (id, tools, provider, model) together with the code route it already resolved. Without the agent's route fields the definition loaders could not see a no-workspace opt-out and recomputed code execution as enabled, so tool classification could advertise the programmatic bash tool that execution then refuses. initializeAgent now passes attachedEnvironmentOptOut through every tool loader, and the shared rule keeps an opt-out its caller already decided. --- api/server/controllers/agents/openai.js | 2 ++ api/server/controllers/agents/responses.js | 2 ++ .../services/Endpoints/agents/initialize.js | 2 ++ .../Endpoints/agents/initialize.spec.js | 29 ++++++++++++++++++ api/server/services/ToolService.js | 5 ++++ .../services/__tests__/ToolService.spec.js | 30 +++++++++++++++++++ .../src/agents/__tests__/initialize.test.ts | 6 ++++ packages/api/src/agents/initialize.ts | 3 ++ packages/api/src/code/agent.spec.ts | 17 +++++++++++ packages/api/src/code/agent.ts | 20 +++++++++---- 10 files changed, 110 insertions(+), 6 deletions(-) diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 817723c29db..c768bdefbcf 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -135,6 +135,7 @@ function createToolLoader({ req, res, signal, definitionsOnly = true }) { tool_resources, requestBody, codeExecutionContext, + attachedEnvironmentOptOut, accessibleMcpServerNames, }) { const agent = { id: agentId, tools, provider, model, tool_options }; @@ -147,6 +148,7 @@ function createToolLoader({ req, res, signal, definitionsOnly = true }) { requestBody, tool_resources, codeExecutionContext, + attachedEnvironmentOptOut, agentResourceType: ResourceType.REMOTE_AGENT, definitionsOnly, accessibleMcpServerNames, diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index d0a4eeb495e..21c4076e014 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -184,6 +184,7 @@ function createToolLoader({ req, res, signal, definitionsOnly = true }) { tool_resources, requestBody, codeExecutionContext, + attachedEnvironmentOptOut, accessibleMcpServerNames, }) { const agent = { id: agentId, tools, provider, model, tool_options }; @@ -196,6 +197,7 @@ function createToolLoader({ req, res, signal, definitionsOnly = true }) { requestBody, tool_resources, codeExecutionContext, + attachedEnvironmentOptOut, agentResourceType: ResourceType.REMOTE_AGENT, definitionsOnly, accessibleMcpServerNames, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 9b2b8c0ce85..b82704399ce 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -168,6 +168,7 @@ function createToolLoader( tool_resources, requestBody, codeExecutionContext, + attachedEnvironmentOptOut, accessibleMcpServerNames, }) { const agent = { id: agentId, tools, provider, model, tool_options }; @@ -182,6 +183,7 @@ function createToolLoader( requestBody, tool_resources, codeExecutionContext, + attachedEnvironmentOptOut, definitionsOnly, accessibleMcpServerNames, upstreamTokenProvider, diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 2454fc2af47..9922136450d 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -2400,6 +2400,35 @@ describe('initializeClient — subagent loading', () => { } }); + it('forwards the initializer code opt-out through the tool loader', async () => { + const codeExecutionContext = { + baseUrl: 'https://api.librechat.ai/v1', + codeSessionKey: 'execute_code', + executionProfile: 'default', + statefulSessions: false, + }; + mockInitializeAgent.mockImplementationOnce(async (params) => { + await params.loadTools({ + agentId: PRIMARY_ID, + tools: ['execute_code'], + codeExecutionContext, + attachedEnvironmentOptOut: true, + }); + return makePrimaryConfig({}); + }); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(loadAgentTools).toHaveBeenCalledWith( + expect.objectContaining({ codeExecutionContext, attachedEnvironmentOptOut: true }), + ); + }); + it('keeps a lazy subagent off its attached machine in a conversation that chose no workspace', async () => { const subAgent = await createAgent({ id: SUBAGENT_ID, diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 5d6c7e556a8..7479aa49338 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -835,6 +835,7 @@ async function loadToolDefinitionsWrapper({ jobCreatedAt, tool_resources, codeExecutionContext, + attachedEnvironmentOptOut, accessibleMcpServerNames, signal, upstreamTokenProvider: suppliedUpstreamTokenProvider, @@ -889,6 +890,7 @@ async function loadToolDefinitionsWrapper({ ), conversationId: runtimeRequestBody?.conversationId, resolvedContext: codeExecutionContext, + attachedEnvironmentOptOut, }), ); const resolvedCodeExecutionContext = await resolveCodeExecutionWorkspaceContext({ @@ -1666,6 +1668,7 @@ async function loadAgentTools({ jobCreatedAt, definitionsOnly = true, codeExecutionContext: providedCodeExecutionContext, + attachedEnvironmentOptOut, accessibleMcpServerNames, upstreamTokenProvider, upstreamTokenProviderResolver, @@ -1682,6 +1685,7 @@ async function loadAgentTools({ jobCreatedAt, tool_resources, codeExecutionContext: providedCodeExecutionContext, + attachedEnvironmentOptOut, accessibleMcpServerNames, signal, upstreamTokenProvider, @@ -1813,6 +1817,7 @@ async function loadAgentTools({ ), conversationId: runtimeRequestBody?.conversationId, resolvedContext: providedCodeExecutionContext, + attachedEnvironmentOptOut, }), ); const codeExecutionContext = await resolveCodeExecutionWorkspaceContext({ diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 6363889e0e4..87a34985645 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -3325,6 +3325,36 @@ describe('ToolService - Action Capability Gating', () => { expect(mockLoadToolsUtil).not.toHaveBeenCalled(); }); + it('keeps the initializer opt-out for the definitions of a partial agent record', async () => { + const req = createDecisionReq('without_attached'); + req.config.endpoints.agents.capabilities.push(AgentCapabilities.programmatic_tools); + mockGetEndpointsConfig.mockResolvedValue( + createEndpointsConfig(req.config.endpoints.agents.capabilities), + ); + const loadDefinitions = (attachedEnvironmentOptOut) => + loadAgentTools({ + req, + res: {}, + agent: { id: terra.id, tools: [Tools.execute_code, 'calculator'] }, + definitionsOnly: true, + codeExecutionContext: { + baseUrl: 'https://api.librechat.ai/v1', + codeSessionKey: 'execute_code', + executionProfile: 'default', + statefulSessions: false, + }, + attachedEnvironmentOptOut, + }); + + await loadDefinitions(true); + const optedOut = mockLoadToolDefinitions.mock.calls.at(-1)[0]; + await loadDefinitions(undefined); + const kept = mockLoadToolDefinitions.mock.calls.at(-1)[0]; + + expect(optedOut.codeExecutionEnabled).toBe(false); + expect(kept.codeExecutionEnabled).toBe(true); + }); + it('still requires a workspace selection when the conversation runs on attached machines', async () => { await expect( loadFor(createDecisionReq('attached'), [AgentConstants.SKILL_TOOL]), diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index eb0042ebc87..9ac8f6bcf50 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -3361,6 +3361,9 @@ describe('initializeAgent — execute_code capability expansion', () => { const primeCall = primeResources.mock.calls[primeResources.mock.calls.length - 1][0]; expect(primeCall.enabledToolResources.has(EToolResources.execute_code)).toBe(false); expect(primeCall.tool_resources).not.toHaveProperty(EToolResources.execute_code); + expect(loadTools).toHaveBeenLastCalledWith( + expect.objectContaining({ attachedEnvironmentOptOut: true }), + ); }); it('does not disable managed code tools for the without-attached decision', async () => { @@ -3408,6 +3411,9 @@ describe('initializeAgent — execute_code capability expansion', () => { expect(result.toolDefinitions?.map(({ name }) => name)).toEqual( expect.arrayContaining(['bash_tool', 'read_file']), ); + expect(loadTools).toHaveBeenLastCalledWith( + expect.objectContaining({ attachedEnvironmentOptOut: false }), + ); }); it('honors without-attached when the configured environment is no longer visible', async () => { diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 71554c35e90..4eb34948a1e 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -925,6 +925,8 @@ export interface InitializeAgentParams { requestBody?: RequestBody; /** Trusted endpoint/profile resolved for this agent before any code-file priming. */ codeExecutionContext: CodeExecutionContext; + /** The conversation's "No workspace" decision removed this agent's code tools. */ + attachedEnvironmentOptOut?: boolean; /** Full accessible MCP server names (operator + user DB) when the heal * already fetched them — lets execution-side collision guards see * cross-tier shadowing without another registry round-trip. */ @@ -2123,6 +2125,7 @@ export async function initializeAgent( tool_resources: runtimeToolResources, requestBody, codeExecutionContext, + attachedEnvironmentOptOut, accessibleMcpServerNames: resolvedAuditNames, }); diff --git a/packages/api/src/code/agent.spec.ts b/packages/api/src/code/agent.spec.ts index 4c4016201d4..57d6576d718 100644 --- a/packages/api/src/code/agent.spec.ts +++ b/packages/api/src/code/agent.spec.ts @@ -138,6 +138,23 @@ describe('resolveAgentCodeExecution', () => { ).toBe(false); }); + it('keeps an opt-out initialization decided for a caller holding a partial agent record', () => { + const stripped = { id: terra.id, tools: terra.tools }; + + expect(resolveAgentCodeFlags({ ...baseParams, agent: stripped }).codeEnvAvailable).toBe(true); + expect( + resolveAgentCodeFlags({ ...baseParams, agent: stripped, attachedEnvironmentOptOut: true }), + ).toMatchObject({ attachedEnvironmentOptOut: true, codeEnvAvailable: false }); + expect( + resolveAgentCodeFlags({ + ...baseParams, + agent: terra, + requestBody: { codeEnvironmentMode: 'without_attached' }, + attachedEnvironmentOptOut: false, + }).attachedEnvironmentOptOut, + ).toBe(true); + }); + it('applies the conversation rule to a context initialization already resolved', () => { const resolvedContext = resolveAgentCodeExecution({ ...baseParams, agent: terra }).context; const reused = resolveAgentCodeExecution({ diff --git a/packages/api/src/code/agent.ts b/packages/api/src/code/agent.ts index 2a7c2ef4404..d1be2877616 100644 --- a/packages/api/src/code/agent.ts +++ b/packages/api/src/code/agent.ts @@ -71,6 +71,12 @@ export interface AgentCodeFlagsParams { allowedStatefulCodeEnvironments?: readonly StatefulCodeEnvironment[]; /** Whether the deployment's versioned implicit stateful route is live. */ implicitStatefulRouteAvailable?: boolean; + /** + * The opt-out initialization already decided for this agent. A caller that only holds a + * partial agent record (a tool loader) passes it so the decision is never lost; the rule + * can add an opt-out but never clears one. + */ + attachedEnvironmentOptOut?: boolean; } export interface AgentCodeFlags { @@ -92,12 +98,14 @@ export interface AgentCodeFlags { */ export function resolveAgentCodeFlags(params: AgentCodeFlagsParams): AgentCodeFlags { const { agent } = params; - const attachedEnvironmentOptOut = optsOutOfAttachedCodeEnvironment( - agent, - params.requestBody, - params.environments, - params.implicitStatefulRouteAvailable === true, - ); + const attachedEnvironmentOptOut = + params.attachedEnvironmentOptOut === true || + optsOutOfAttachedCodeEnvironment( + agent, + params.requestBody, + params.environments, + params.implicitStatefulRouteAvailable === true, + ); const codeEnvAvailable = params.codeExecutionAvailable && agent?.tools?.includes(Tools.execute_code) === true &&