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/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 333979348b5..b82704399ce 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -26,15 +26,14 @@ const { getLazySubagentConfigId, createRoutedGraphMemberLoader, createViewableSubagentLoader, - resolveCodeExecutionContext, + resolveAgentCodeFlags, + withRequestCodeInputs, + resolveAgentCodeExecution, resolveCodeExecutionWorkspaceSelections, - optsOutOfAttachedCodeEnvironment, - isImplicitStatefulCodeRouteAvailable, resolveCodeExecutionWorkspaceContext, resolveSubagentCodeWorkspaceInheritance, resolveSubagentCodeAvailability, guardRoutableSubagent, - createStatefulCodeEnvironmentPolicyError, buildSubagentThreadTaskConfig, backgroundCompletionWakeupsEnabled, createLazyAgentHistoryResolver, @@ -169,6 +168,7 @@ function createToolLoader( tool_resources, requestBody, codeExecutionContext, + attachedEnvironmentOptOut, accessibleMcpServerNames, }) { const agent = { id: agentId, tools, provider, model, tool_options }; @@ -183,6 +183,7 @@ function createToolLoader( requestBody, tool_resources, codeExecutionContext, + attachedEnvironmentOptOut, definitionsOnly, accessibleMcpServerNames, upstreamTokenProvider, @@ -1151,6 +1152,7 @@ const initializeClientWithProvider = async ({ }); subagentCodeRouting = createSubagentCodeRouting({ + codeEnvironmentMode: runtimeRequestBody?.codeEnvironmentMode, allowEnvironmentSelection: appConfig.endpoints?.agents?.statefulCodeSessions?.allowEnvironmentSelection, persistedSelections: admittedConversation?.codeWorkspaces, @@ -1166,77 +1168,43 @@ const initializeClientWithProvider = async ({ }), }); + /** Inputs the shared per-agent code rule reads for a subagent in this request. */ + 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) => { - 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..9922136450d 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -2400,6 +2400,119 @@ 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, + 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..7479aa49338 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, @@ -835,6 +835,7 @@ async function loadToolDefinitionsWrapper({ jobCreatedAt, tool_resources, codeExecutionContext, + attachedEnvironmentOptOut, accessibleMcpServerNames, signal, upstreamTokenProvider: suppliedUpstreamTokenProvider, @@ -876,32 +877,22 @@ 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, + attachedEnvironmentOptOut, }), - 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, @@ -1677,6 +1668,7 @@ async function loadAgentTools({ jobCreatedAt, definitionsOnly = true, codeExecutionContext: providedCodeExecutionContext, + attachedEnvironmentOptOut, accessibleMcpServerNames, upstreamTokenProvider, upstreamTokenProviderResolver, @@ -1693,6 +1685,7 @@ async function loadAgentTools({ jobCreatedAt, tool_resources, codeExecutionContext: providedCodeExecutionContext, + attachedEnvironmentOptOut, accessibleMcpServerNames, signal, upstreamTokenProvider, @@ -1810,34 +1803,23 @@ 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, + attachedEnvironmentOptOut, }), - 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 +2226,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 +2237,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..87a34985645 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,114 @@ 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('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]), + ).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 +3390,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 +3656,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 +3705,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/__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 9d648632b44..4eb34948a1e 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,9 +120,9 @@ 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 { isImplicitStatefulCodeRouteAvailable } from '~/code/config'; import { applyIntentLabels, sanitizeIntentLabels } from './intent'; import { prepareQueuedCodeFileContext } from '~/files/code/queued'; import { ContentFilterError } from '../middleware/contentFilter'; @@ -140,7 +130,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 +889,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 @@ -952,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. */ @@ -1621,46 +1596,31 @@ 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, + implicitStatefulRouteAvailable: isImplicitStatefulCodeRouteAvailable( + process.env.CODE_ENVIRONMENT_DECISION_VERSION, + process.env.LIBRECHAT_CODE_BASEURL_STATEFUL, + ), userId: requestFileOwnerId, - agentId: agent.id, conversationId, }); const attachedWorkspaceTools = @@ -2165,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 new file mode 100644 index 00000000000..57d6576d718 --- /dev/null +++ b/packages/api/src/code/agent.spec.ts @@ -0,0 +1,337 @@ +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('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({ + ...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); + }); + + 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' }] }; + 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..d1be2877616 --- /dev/null +++ b/packages/api/src/code/agent.ts @@ -0,0 +1,214 @@ +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[]; + /** 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 { + /** 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 = + params.attachedEnvironmentOptOut === true || + optsOutOfAttachedCodeEnvironment( + agent, + params.requestBody, + params.environments, + params.implicitStatefulRouteAvailable === true, + ); + 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' + | 'implicitStatefulRouteAvailable' +> & { + /** 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: + * its admitted conversation, principal-scoped machines and inheritance, and the deployment's + * implicit stateful route rollout. + */ +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, + implicitStatefulRouteAvailable: isImplicitStatefulCodeRouteAvailable( + process.env.CODE_ENVIRONMENT_DECISION_VERSION, + process.env.LIBRECHAT_CODE_BASEURL_STATEFUL, + ), + }; +} 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;