Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
16 changes: 11 additions & 5 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 @@ -341,18 +342,23 @@ const loadTools = async ({
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. */
/* Resolve the trusted route from this agent's builder setting. Stateful
* agents fail closed onto the dedicated endpoint; all others remain on
* the default stateless Code API. */
const statefulSessions =
agent?.stateful_code_sessions === true &&
(await checkCapability(options.req, AgentCapabilities.stateful_code_sessions));
const codeExecutionContext = resolveCodeExecutionContext({
statefulSessions,
environment: agent?.stateful_code_environment,
agentId: agent?.id,
conversationId: options.req?.body?.conversationId,
});
return createCodeExecutionTool({
user_id: user,
files,
authHeaders: () => getCodeApiAuthHeaders(options.req),
statefulSessions,
...codeExecutionContext,
});
};
continue;
Expand Down
10 changes: 2 additions & 8 deletions api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
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
1 change: 1 addition & 0 deletions api/server/services/Endpoints/agents/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ const initializeClient = async ({
codeEnvAvailable === true &&
agent.stateful_code_sessions === true &&
agent.tools?.includes(Tools.execute_code) === true,
statefulCodeEnvironment: agent.stateful_code_environment,
Comment thread
danny-avila marked this conversation as resolved.
includeReasoningHistory: getIncludeReasoningHistory(agent),
});

Expand Down
26 changes: 22 additions & 4 deletions api/server/services/Files/Code/process.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const {
getExtractedTextFormat,
getStorageMetadata,
buildCodeEnvDownloadQuery,
CODE_API_EXPECTED_PROFILE_HEADER,
} = require('@librechat/api');
const {
Tools,
Expand Down Expand Up @@ -1144,8 +1145,16 @@ const primeFiles = async (options) => {
* @param {ServerRequest} [params.req] - Current authenticated request, used to mint Code API auth.
* @returns {Promise<{content: string} | null>}
*/
async function readSandboxFile({ file_path, session_id, files, runtime_session_hint, req }) {
const baseURL = getCodeBaseURL();
async function readSandboxFile({
file_path,
session_id,
files,
runtime_session_hint,
codeApiBaseUrl,
executionProfile,
req,
}) {
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
Comment thread
danny-avila marked this conversation as resolved.
if (!baseURL) {
return null;
}
Expand Down Expand Up @@ -1177,6 +1186,7 @@ async function readSandboxFile({ file_path, session_id, files, runtime_session_h
'Content-Type': 'application/json',
'User-Agent': 'LibreChat/1.0',
...authHeaders,
...(executionProfile ? { [CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile } : {}),
},
httpAgent: codeServerHttpAgent,
httpsAgent: codeServerHttpsAgent,
Expand Down Expand Up @@ -1225,10 +1235,12 @@ async function readSandboxImage({
session_id,
files,
runtime_session_hint,
codeApiBaseUrl,
executionProfile,
maxBytes,
req,
}) {
const baseURL = getCodeBaseURL();
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
if (!baseURL) {
return null;
}
Expand Down Expand Up @@ -1287,6 +1299,7 @@ async function readSandboxImage({
file_path,
session_id,
runtime_session_hint,
executionProfile,
files,
req,
chunkBytes,
Expand Down Expand Up @@ -1357,6 +1370,7 @@ async function execSandboxImageChunk({
file_path,
session_id,
runtime_session_hint,
executionProfile,
files,
req,
chunkBytes,
Expand All @@ -1383,6 +1397,7 @@ async function execSandboxImageChunk({
'Content-Type': 'application/json',
'User-Agent': 'LibreChat/1.0',
...authHeaders,
...(executionProfile ? { [CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile } : {}),
},
httpAgent: codeServerHttpAgent,
httpsAgent: codeServerHttpsAgent,
Expand Down Expand Up @@ -1448,9 +1463,11 @@ async function writeSandboxFile({
session_id,
files,
runtime_session_hint,
codeApiBaseUrl,
executionProfile,
req,
}) {
const baseURL = getCodeBaseURL();
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
if (!baseURL) {
return null;
}
Expand Down Expand Up @@ -1500,6 +1517,7 @@ async function writeSandboxFile({
'Content-Type': 'application/json',
'User-Agent': 'LibreChat/1.0',
...authHeaders,
...(executionProfile ? { [CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile } : {}),
},
httpAgent: codeServerHttpAgent,
httpsAgent: codeServerHttpsAgent,
Expand Down
17 changes: 17 additions & 0 deletions api/server/services/Files/Code/process.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jest.mock('@librechat/api', () => {
flattenArtifactPath: jest.fn((name) => name.replace(/\//g, '__')),
createAxiosInstance: jest.fn(() => mockAxios),
getCodeApiAuthHeaders: jest.fn(async () => ({})),
CODE_API_EXPECTED_PROFILE_HEADER: 'X-CodeAPI-Expected-Profile',
withTimeout: (...args) => passthroughWithTimeout(...args),
hasOfficeHtmlPath: (...args) => mockHasOfficeHtmlPath(...args),
/**
Expand Down Expand Up @@ -1610,6 +1611,22 @@ describe('Code Process', () => {
expect(call.data.lang).toBe('bash');
});

it('routes to the selected profile endpoint and asserts the expected profile', async () => {
mockAxios.mockResolvedValueOnce({ data: { stdout: 'ok', stderr: '' } });

await readSandboxFile({
file_path: '/mnt/data/x.txt',
codeApiBaseUrl: 'https://stateful-code.example.com',
executionProfile: 'stateful',
runtime_session_hint: 'v1:user',
});

const call = mockAxios.mock.calls[0][0];
expect(call.url).toBe('https://stateful-code.example.com/exec');
expect(call.headers['X-CodeAPI-Expected-Profile']).toBe('stateful');
expect(call.data.runtime_session_hint).toBe('v1:user');
});

it('omits session_id and files when not provided', async () => {
mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } });

Expand Down
34 changes: 26 additions & 8 deletions api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const {
isNormalizationSensitiveName,
AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE,
isFatalAgentInitializationError,
resolveCodeExecutionContext,
} = require('@librechat/api');
const {
Time,
Expand Down Expand Up @@ -1401,6 +1402,16 @@ async function loadAgentTools({
const codeExecutionEnabled =
agent.tools?.includes(Tools.execute_code) === true &&
enabledCapabilities.has(AgentCapabilities.execute_code);
const statefulCodeSessions =
codeExecutionEnabled &&
enabledCapabilities.has(AgentCapabilities.stateful_code_sessions) &&
agent.stateful_code_sessions === true;
const codeExecutionContext = resolveCodeExecutionContext({
statefulSessions: statefulCodeSessions,
environment: agent.stateful_code_environment,
agentId: agent.id,
conversationId: req.body?.conversationId,
});
const { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools } =
await buildToolClassification({
loadedTools,
Expand All @@ -1412,6 +1423,7 @@ async function loadAgentTools({
programmaticToolsEnabled,
codeExecutionEnabled,
authHeaders: () => getCodeApiAuthHeaders(req),
codeExecutionContext,
});

const agentTools = [];
Expand Down Expand Up @@ -1707,17 +1719,20 @@ async function loadToolsForExecution({
enabledCapabilities?.has(AgentCapabilities.execute_code) === true &&
agent?.tools?.includes(Tools.execute_code) === true;

/**
* Opt bash_tool into the hedged stateful-session description. Gated on code
* execution being enabled AND the admin `stateful_code_sessions` capability
* AND the agent's own builder opt-in; off by default. Sets prompt text only
* (the wire hint is set at run config). PTC keeps its stateless prompt in
* v1. Older @librechat/agents ignore the param.
*/
/** 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 codeExecutionContext = resolveCodeExecutionContext({
statefulSessions: statefulCodeSessions,
environment: agent?.stateful_code_environment,
agentId: agent?.id,
conversationId: req.body?.conversationId,
});
Comment thread
danny-avila marked this conversation as resolved.
configurable.codeExecutionContext = codeExecutionContext;

const isPTC =
isPTCRequested &&
Expand Down Expand Up @@ -1747,6 +1762,9 @@ async function loadToolsForExecution({
for (const name of ptcToolNames) {
const ptcTool = createBashProgrammaticToolCallingTool({
authHeaders: () => getCodeApiAuthHeaders(req),
baseUrl: codeExecutionContext.baseUrl,
executionProfile: codeExecutionContext.executionProfile,
runtimeSessionHint: codeExecutionContext.runtimeSessionHint,
});
ptcTool.name = name;
allLoadedTools.push(ptcTool);
Expand All @@ -1770,7 +1788,7 @@ async function loadToolsForExecution({
try {
const bashTool = createBashExecutionTool({
authHeaders: () => getCodeApiAuthHeaders(req),
statefulSessions: statefulCodeSessions,
...codeExecutionContext,
});
allLoadedTools.push(bashTool);
} catch (error) {
Expand Down
83 changes: 83 additions & 0 deletions api/server/services/__tests__/ToolService.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,38 @@ const mockGetMCPServerTools = jest.fn();
const mockGetCachedTools = jest.fn();
const mockSendEvent = jest.fn();
const mockEmitChunk = jest.fn();
const mockResolveCodeExecutionContext = jest.fn(
({ statefulSessions, environment, 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');
}
let runtimeSessionHint = 'v1:user';
if (environment === 'agent-user') {
runtimeSessionHint = `v1:agent-user:${agentId}`;
} else if (environment === 'conversation') {
runtimeSessionHint = `v1:conversation:${conversationId}`;
}
return {
baseUrl,
codeSessionKey: `execute_code:stateful:${runtimeSessionHint}`,
executionProfile: 'stateful',
runtimeSessionHint,
statefulSessions: true,
};
},
);
jest.mock('~/server/services/Config', () => ({
getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args),
getMCPServerTools: (...args) => mockGetMCPServerTools(...args),
Expand All @@ -35,6 +67,7 @@ jest.mock('@librechat/api', () => ({
GenerationJobManager: {
emitChunk: (...args) => mockEmitChunk(...args),
},
resolveCodeExecutionContext: (...args) => mockResolveCodeExecutionContext(...args),
}));

const mockLoadToolsUtil = jest.fn();
Expand Down Expand Up @@ -1402,6 +1435,56 @@ describe('ToolService - Action Capability Gating', () => {
expect(mockLoadToolsUtil).not.toHaveBeenCalled();
});

it('keeps stateless and stateful agents on isolated execution profiles in one run', async () => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.execute_code,
AgentCapabilities.stateful_code_sessions,
];
const req = createMockReq(capabilities);
req.body = { conversationId: 'conversation-1' };
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
process.env.LIBRECHAT_CODE_BASEURL = 'http://code-default.test/v1';
process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'http://code-stateful.test/v1';

try {
const stateless = await loadToolsForExecution({
req,
res: {},
agent: { id: 'stateless-agent', tools: [Tools.execute_code] },
toolNames: [],
});
const stateful = await loadToolsForExecution({
req,
res: {},
agent: {
id: 'stateful-agent',
tools: [Tools.execute_code],
stateful_code_sessions: true,
stateful_code_environment: 'agent-user',
},
toolNames: [],
});

expect(stateless.configurable.codeExecutionContext).toEqual({
baseUrl: 'http://code-default.test/v1',
codeSessionKey: 'execute_code',
executionProfile: 'default',
statefulSessions: false,
});
expect(stateful.configurable.codeExecutionContext).toEqual({
baseUrl: 'http://code-stateful.test/v1',
codeSessionKey: 'execute_code:stateful:v1:agent-user:stateful-agent',
executionProfile: 'stateful',
runtimeSessionHint: 'v1:agent-user:stateful-agent',
statefulSessions: true,
});
} finally {
delete process.env.LIBRECHAT_CODE_BASEURL;
delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL;
}
});

it('loads bash PTC under the legacy programmatic tool name when code capabilities are enabled', async () => {
const capabilities = [
AgentCapabilities.tools,
Expand Down
Loading
Loading