Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,11 @@ TTS_API_KEY=

# LIBRECHAT_CODE_API_KEY=
# LIBRECHAT_CODE_BASEURL=
# Prewarm stateful per-conversation sandboxes in parallel with model generation (default: true).
# Optional dedicated Code API deployment for agents with Stateful code sessions enabled.
# When configured, stateless agents continue using LIBRECHAT_CODE_BASEURL while stateful
# agents fail closed onto this endpoint. The endpoint must advertise the `stateful` profile.
# LIBRECHAT_CODE_BASEURL_STATEFUL=
# Prewarm selected stateful sandboxes in parallel with model generation (default: true).
# CODE_SANDBOX_PREWARM=true
# Time in milliseconds before LibreChat treats a tracked sandbox as cold (default: 2100000 / 35 minutes).
# CODE_SANDBOX_COLD_AFTER_MS=2100000
Expand Down
1 change: 1 addition & 0 deletions api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -1444,6 +1444,7 @@ class BaseClient {
if (
file.embedded === true ||
file.metadata?.codeEnvRef != null ||
file.metadata?.codeEnvRefs != null ||
file.metadata?.fileIdentifier != null
) {
allFiles.push(file);
Expand Down
24 changes: 16 additions & 8 deletions api/app/clients/tools/util/handleTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const {
ASK_USER_QUESTION_TOOL_NAME,
resolveWebSearchSSRFAgents,
buildWebSearchDynamicContext,
resolveCodeExecutionContext,
} = require('@librechat/api');
const {
Tools,
Expand Down Expand Up @@ -331,28 +332,35 @@ const loadTools = async ({
for (const tool of tools) {
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,
userId: user,
agentId: agent?.id,
conversationId: options.req?.body?.conversationId,
});
const { files, toolContext } = await primeCodeFiles({
...options,
agentId: agent?.id,
codeApiBaseUrl: codeExecutionContext.baseUrl,
executionProfile: codeExecutionContext.executionProfile,
});
if (toolContext) {
dynamicToolContextMap[tool] = toolContext;
}
if (files?.length) {
primedCodeFiles = files;
}
/* Hedge the execute_code description toward persistence only when the
* admin `stateful_code_sessions` capability is on AND the agent opted
* in via the builder (off by default); the matching wire hint is set
* in the run config. Older @librechat/agents ignore the param. */
const statefulSessions =
agent?.stateful_code_sessions === true &&
(await checkCapability(options.req, AgentCapabilities.stateful_code_sessions));
return createCodeExecutionTool({
user_id: user,
files,
authHeaders: () => getCodeApiAuthHeaders(options.req),
statefulSessions,
...codeExecutionContext,
});
};
continue;
Expand Down
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.5.1",
"@librechat/agents": "^3.6.0",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
Expand Down
9 changes: 8 additions & 1 deletion api/server/controllers/agents/__tests__/callbacks.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ describe('createToolEndCallback', () => {
name,
toolName = 'execute_code',
hostFileAuthoring = false,
codeExecutionContext,
}) {
return {
output: {
Expand All @@ -455,7 +456,7 @@ describe('createToolEndCallback', () => {
files: [{ id: fileId, name, session_id: 'sess-1' }],
},
},
metadata: { run_id: runId, thread_id: threadId },
metadata: { run_id: runId, thread_id: threadId, codeExecutionContext },
};
}

Expand Down Expand Up @@ -679,6 +680,10 @@ describe('createToolEndCallback', () => {
name: 'created.txt',
toolName: 'create_file',
hostFileAuthoring: true,
codeExecutionContext: {
baseUrl: 'https://code-stateful.example.com',
executionProfile: 'stateful',
},
});
await toolEndCallback({ output: event.output }, event.metadata);
await Promise.all(artifactPromises);
Expand All @@ -690,6 +695,8 @@ describe('createToolEndCallback', () => {
messageId: 'run-create',
toolCallId: 'tool-create',
conversationId: 'thread789',
codeApiBaseUrl: 'https://code-stateful.example.com',
executionProfile: 'stateful',
}),
);
expect(res.write).toHaveBeenCalledTimes(1);
Expand Down
4 changes: 4 additions & 0 deletions api/server/controllers/agents/callbacks.js
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,8 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo
* ids.
*/
session_id: file.storage_session_id ?? output.artifact.session_id,
codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl,
executionProfile: metadata.codeExecutionContext?.executionProfile,
});
const fileMetadata = result?.file ?? null;
const finalize = result?.finalize;
Expand Down Expand Up @@ -1248,6 +1250,8 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
* ids.
*/
session_id: file.storage_session_id ?? output.artifact.session_id,
codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl,
executionProfile: metadata.codeExecutionContext?.executionProfile,
});
const fileMetadata = result?.file ?? null;
const finalize = result?.finalize;
Expand Down
12 changes: 3 additions & 9 deletions api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1401,7 +1401,7 @@ class AgentClient extends BaseClient {
this.contextHandlers?.processFile(file);
continue;
}
if (file.metadata?.codeEnvRef) {
if (file.metadata?.codeEnvRef || file.metadata?.codeEnvRefs) {
continue;
}
}
Expand Down Expand Up @@ -2571,7 +2571,7 @@ class AgentClient extends BaseClient {
abortController = new AbortController();
}

/** Fire-and-forget: boot the per-conversation stateful sandbox in
/** Fire-and-forget: boot each selected stateful environment in
* parallel with generation so the first execute_code/bash call lands
* on a warm VM. No-op unless a reachable agent resolved
* `statefulCodeSessions`. */
Expand Down Expand Up @@ -2617,13 +2617,7 @@ class AgentClient extends BaseClient {
? await this.options.primeInvokedSkills(payload)
: undefined;

/**
* Seed `Graph.sessions` with code-env files primed across every
* reachable agent (primary, handoff/addedConvo, and nested
* subagents) plus skill-priming output. The merge logic and its
* run-wide semantics live in `buildInitialToolSessions`; see that
* helper's doc for why this is intentionally NOT per-agent.
*/
/** Seed each reachable agent's trusted code-session partition. */
const initialSessions = buildInitialToolSessions({
skillSessions: skillPrimeResult?.initialSessions,
agents: [this.options.agent, ...(this.agentConfigs ? this.agentConfigs.values() : [])],
Expand Down
3 changes: 3 additions & 0 deletions api/server/controllers/agents/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function createToolLoader(signal, definitionsOnly = true) {
provider,
tool_options,
tool_resources,
codeExecutionContext,
accessibleMcpServerNames,
}) {
const agent = { id: agentId, tools, provider, model, tool_options };
Expand All @@ -97,6 +98,7 @@ function createToolLoader(signal, definitionsOnly = true) {
agent,
signal,
tool_resources,
codeExecutionContext,
agentResourceType: ResourceType.REMOTE_AGENT,
definitionsOnly,
accessibleMcpServerNames,
Expand Down Expand Up @@ -508,6 +510,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
req,
res,
agentResourceType: ResourceType.REMOTE_AGENT,
conversationId,
toolNames,
agent: ctx.agent ?? agent,
signal: abortController.signal,
Expand Down
4 changes: 4 additions & 0 deletions api/server/controllers/agents/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ function createToolLoader(signal, definitionsOnly = true) {
provider,
tool_options,
tool_resources,
codeExecutionContext,
accessibleMcpServerNames,
}) {
const agent = { id: agentId, tools, provider, model, tool_options };
Expand All @@ -111,6 +112,7 @@ function createToolLoader(signal, definitionsOnly = true) {
agent,
signal,
tool_resources,
codeExecutionContext,
agentResourceType: ResourceType.REMOTE_AGENT,
definitionsOnly,
accessibleMcpServerNames,
Expand Down Expand Up @@ -735,6 +737,7 @@ const executeResponse = async (envelope, { req, res }) => {
req,
res,
agentResourceType: ResourceType.REMOTE_AGENT,
conversationId,
toolNames,
agent: ctx.agent ?? agent,
signal: abortController.signal,
Expand Down Expand Up @@ -919,6 +922,7 @@ const executeResponse = async (envelope, { req, res }) => {
req,
res,
agentResourceType: ResourceType.REMOTE_AGENT,
conversationId,
toolNames,
agent: ctx.agent ?? agent,
signal: abortController.signal,
Expand Down
14 changes: 14 additions & 0 deletions api/server/routes/files/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
startUploadSseStream,
resolveUploadErrorMessage,
verifyAgentUploadPermission,
getCodeExecutionBaseUrl,
} = require('@librechat/api');
const {
Time,
Expand Down Expand Up @@ -329,6 +330,18 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => {
return res.status(400).send('Bad request');
}

const requestedProfile = req.query.execution_profile;
if (
requestedProfile != null &&
requestedProfile !== 'default' &&
requestedProfile !== 'stateful'
) {
logger.debug(`${logPrefix} invalid execution_profile`);
return res.status(400).send('Bad request');
}
const executionProfile = requestedProfile ?? 'default';
const baseUrl = getCodeExecutionBaseUrl(executionProfile);

const { getDownloadStream } = getStrategyFunctions(FileSources.execute_code);
if (!getDownloadStream) {
logger.warn(
Expand All @@ -352,6 +365,7 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => {
id: req.user.id,
},
req,
{ baseUrl, executionProfile },
);
res.set(response.headers);
response.data.pipe(res);
Expand Down
44 changes: 44 additions & 0 deletions api/server/routes/files/files.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ jest.mock('sharp', () =>
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
refreshS3FileUrls: jest.fn(),
getCodeExecutionBaseUrl: jest.fn((profile) =>
profile === 'stateful'
? process.env.LIBRECHAT_CODE_BASEURL_STATEFUL
: 'https://code-default.example.com/v1',
),
}));

jest.mock('~/cache', () => ({
Expand Down Expand Up @@ -1088,4 +1093,43 @@ describe('File Routes - Delete with Agent Access', () => {
expect(response.status).toBe(401);
});
});

describe('GET /files/code/download/:session_id/:fileId', () => {
it('routes a persisted stateful fallback through the stateful Code API', async () => {
const getDownloadStream = jest.fn().mockResolvedValue({
headers: { 'content-type': 'text/plain' },
data: Readable.from(['stateful output']),
});
getStrategyFunctions.mockReturnValue({ getDownloadStream });
process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'https://code-stateful.example.com/v1';

try {
const sessionId = 's'.repeat(21);
const codeFileId = 'f'.repeat(21);
const response = await request(app).get(
`/files/code/download/${sessionId}/${codeFileId}?execution_profile=stateful`,
);

expect(response.status).toBe(200);
expect(response.text).toBe('stateful output');
expect(getDownloadStream).toHaveBeenCalledWith(
`${sessionId}/${codeFileId}`,
{ kind: 'user', id: otherUserId.toString() },
expect.any(Object),
{ baseUrl: 'https://code-stateful.example.com/v1', executionProfile: 'stateful' },
);
} finally {
delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL;
}
});

it('rejects an unknown execution profile', async () => {
const response = await request(app).get(
`/files/code/download/${'s'.repeat(21)}/${'f'.repeat(21)}?execution_profile=attacker`,
);

expect(response.status).toBe(400);
expect(getStrategyFunctions).not.toHaveBeenCalled();
});
});
});
Loading
Loading