mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-24 03:09:26 +00:00
🗿 feat: Add Attached Stateful Code Environments (#15352)
* feat: add attached stateful code environments * fix: include code environment in lazy agent type * fix: harden stateful environment routing * fix: complete code environment route isolation * fix: declare code environment map type * fix: preserve configured code execution routes * fix: harden stateful environment updates * test: assert route-scoped sandbox readiness * fix: harden stateful environment lifecycle * fix: isolate migrated code sessions * test: assert route-qualified code sessions
This commit is contained in:
parent
dc77b78d3e
commit
c1cb591d49
59 changed files with 1433 additions and 103 deletions
|
|
@ -356,6 +356,9 @@ const loadTools = async ({
|
|||
resolveCodeExecutionContext({
|
||||
statefulSessions,
|
||||
environment: agent?.stateful_code_environment,
|
||||
environmentId: agent?.code_environment_id,
|
||||
environments:
|
||||
options.req?.config?.endpoints?.agents?.statefulCodeSessions?.environments,
|
||||
userId: user,
|
||||
agentId: agent?.id,
|
||||
conversationId: options.req?.body?.conversationId,
|
||||
|
|
@ -365,6 +368,7 @@ const loadTools = async ({
|
|||
agentId: agent?.id,
|
||||
codeApiBaseUrl: codeExecutionContext.baseUrl,
|
||||
executionProfile: codeExecutionContext.executionProfile,
|
||||
executionRouteKey: codeExecutionContext.executionRouteKey,
|
||||
});
|
||||
if (toolContext) {
|
||||
dynamicToolContextMap[tool] = toolContext;
|
||||
|
|
|
|||
|
|
@ -855,6 +855,7 @@ describe('createToolEndCallback', () => {
|
|||
codeExecutionContext: {
|
||||
baseUrl: 'https://code-stateful.example.com',
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey: `stateful:${'a'.repeat(32)}`,
|
||||
},
|
||||
});
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
|
|
@ -870,6 +871,7 @@ describe('createToolEndCallback', () => {
|
|||
conversationId: 'thread789',
|
||||
codeApiBaseUrl: 'https://code-stateful.example.com',
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey: `stateful:${'a'.repeat(32)}`,
|
||||
}),
|
||||
);
|
||||
expect(res.write).toHaveBeenCalledTimes(2);
|
||||
|
|
|
|||
|
|
@ -1047,6 +1047,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo
|
|||
session_id: sessionId,
|
||||
codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl,
|
||||
executionProfile: metadata.codeExecutionContext?.executionProfile,
|
||||
executionRouteKey: metadata.codeExecutionContext?.executionRouteKey,
|
||||
preparedBuffer,
|
||||
downloadFallback,
|
||||
});
|
||||
|
|
@ -1391,6 +1392,7 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
|
|||
session_id: sessionId,
|
||||
codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl,
|
||||
executionProfile: metadata.codeExecutionContext?.executionProfile,
|
||||
executionRouteKey: metadata.codeExecutionContext?.executionRouteKey,
|
||||
preparedBuffer,
|
||||
downloadFallback,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -404,6 +404,9 @@ class AgentClient extends BaseClient {
|
|||
hide_sequential_outputs: agent.hide_sequential_outputs,
|
||||
stateful_code_sessions: agent.stateful_code_sessions,
|
||||
stateful_code_environment: agent.stateful_code_environment,
|
||||
execution_route_key:
|
||||
agent.codeExecutionContext?.executionRouteKey ??
|
||||
agent.codeExecutionContext?.executionProfile,
|
||||
artifacts: agent.artifacts,
|
||||
recursion_limit: agent.recursion_limit,
|
||||
subagents: agent.subagents,
|
||||
|
|
|
|||
|
|
@ -443,7 +443,27 @@ const isCodeInterpreterCapabilityEnabled = (req) => {
|
|||
/** Reject a newly selected stateful workspace scope that the deployment owner
|
||||
* has excluded. Disabled sessions and unrelated edits remain saveable so an
|
||||
* allowlist tightening never silently rewrites or strands an existing agent. */
|
||||
const validateStatefulCodeEnvironment = (req, res, enabled, environment) => {
|
||||
const validateStatefulCodeEnvironment = (
|
||||
req,
|
||||
res,
|
||||
enabled,
|
||||
environment,
|
||||
environmentId,
|
||||
environmentIdSelected = false,
|
||||
) => {
|
||||
if (enabled !== true && !environmentIdSelected) {
|
||||
return true;
|
||||
}
|
||||
if (environmentId != null) {
|
||||
const configuredEnvironments =
|
||||
req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments ?? [];
|
||||
if (!configuredEnvironments.some((configured) => configured.id === environmentId)) {
|
||||
res.status(400).json({
|
||||
error: `Stateful code environment is not configured: ${environmentId}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (enabled !== true) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -721,6 +741,8 @@ const createAgentHandler = async (req, res) => {
|
|||
res,
|
||||
agentData.stateful_code_sessions,
|
||||
agentData.stateful_code_environment,
|
||||
agentData.code_environment_id,
|
||||
agentData.code_environment_id != null,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
|
|
@ -997,13 +1019,22 @@ const updateAgentHandler = async (req, res) => {
|
|||
normalizeToolResourceFiles(req.body?.tool_resources);
|
||||
const validatedData = agentUpdateSchema.parse(req.body);
|
||||
// Preserve explicit null for avatar to allow resetting the avatar
|
||||
const { avatar: avatarField, _id, ...rest } = validatedData;
|
||||
const {
|
||||
avatar: avatarField,
|
||||
code_environment_id: codeEnvironmentIdField,
|
||||
_id,
|
||||
...rest
|
||||
} = validatedData;
|
||||
const updateData = removeNullishValues(rest);
|
||||
if (codeEnvironmentIdField !== undefined) {
|
||||
updateData.code_environment_id = codeEnvironmentIdField;
|
||||
}
|
||||
let existingAgent;
|
||||
|
||||
const includesStatefulConfiguration =
|
||||
updateData.stateful_code_sessions !== undefined ||
|
||||
updateData.stateful_code_environment !== undefined;
|
||||
updateData.stateful_code_environment !== undefined ||
|
||||
updateData.code_environment_id !== undefined;
|
||||
const includesToolsConfiguration = Array.isArray(updateData.tools);
|
||||
const includesToolOptionsConfiguration = updateData.tool_options !== undefined;
|
||||
if (
|
||||
|
|
@ -1016,13 +1047,17 @@ const updateAgentHandler = async (req, res) => {
|
|||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
|
||||
const codeEnvironmentSelectionChanged =
|
||||
updateData.code_environment_id !== undefined &&
|
||||
updateData.code_environment_id !== existingAgent.code_environment_id;
|
||||
const statefulConfigurationChanged =
|
||||
(updateData.stateful_code_sessions !== undefined &&
|
||||
(updateData.stateful_code_sessions === true) !==
|
||||
(existingAgent.stateful_code_sessions === true)) ||
|
||||
(updateData.stateful_code_environment !== undefined &&
|
||||
(updateData.stateful_code_environment ?? 'user') !==
|
||||
(existingAgent.stateful_code_environment ?? 'user'));
|
||||
(existingAgent.stateful_code_environment ?? 'user')) ||
|
||||
codeEnvironmentSelectionChanged;
|
||||
const activatesCodeExecution =
|
||||
includesToolsConfiguration &&
|
||||
updateData.tools.includes(Tools.execute_code) &&
|
||||
|
|
@ -1032,12 +1067,18 @@ const updateAgentHandler = async (req, res) => {
|
|||
updateData.stateful_code_sessions ?? existingAgent.stateful_code_sessions;
|
||||
const effectiveStatefulEnvironment =
|
||||
updateData.stateful_code_environment ?? existingAgent.stateful_code_environment;
|
||||
const effectiveCodeEnvironmentId =
|
||||
updateData.code_environment_id === null
|
||||
? undefined
|
||||
: (updateData.code_environment_id ?? existingAgent.code_environment_id);
|
||||
if (
|
||||
!validateStatefulCodeEnvironment(
|
||||
req,
|
||||
res,
|
||||
effectiveStatefulSessions,
|
||||
effectiveStatefulEnvironment,
|
||||
effectiveCodeEnvironmentId,
|
||||
codeEnvironmentSelectionChanged,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
|
|
@ -1227,6 +1268,11 @@ const updateAgentHandler = async (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (updateData.code_environment_id === null) {
|
||||
delete updateData.code_environment_id;
|
||||
updateData.$unset = { code_environment_id: 1 };
|
||||
}
|
||||
|
||||
let updatedAgent =
|
||||
Object.keys(updateData).length > 0
|
||||
? await db.updateAgent({ id }, updateData, {
|
||||
|
|
@ -1333,6 +1379,7 @@ const duplicateAgentHandler = async (req, res) => {
|
|||
res,
|
||||
newAgentData.stateful_code_sessions,
|
||||
newAgentData.stateful_code_environment,
|
||||
newAgentData.code_environment_id,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
|
|
@ -1925,6 +1972,7 @@ const revertAgentVersionHandler = async (req, res) => {
|
|||
res,
|
||||
revertVersion.stateful_code_sessions,
|
||||
revertVersion.stateful_code_environment,
|
||||
revertVersion.code_environment_id,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -237,6 +237,39 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
expect(await Agent.countDocuments()).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects an unconfigured code environment id', async () => {
|
||||
mockReq.config = {
|
||||
endpoints: {
|
||||
agents: {
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['user'],
|
||||
environments: [
|
||||
{
|
||||
id: 'configured-vm',
|
||||
name: 'Configured VM',
|
||||
type: 'attached',
|
||||
baseURL: 'https://code.example.com/v1',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mockReq.body = {
|
||||
name: 'Invalid Environment Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
stateful_code_sessions: true,
|
||||
code_environment_id: 'missing-vm',
|
||||
};
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(await Agent.countDocuments()).toBe(0);
|
||||
});
|
||||
|
||||
test('should block configured agent instruction content before persistence', async () => {
|
||||
mockReq.config = {
|
||||
filters: {
|
||||
|
|
@ -1182,6 +1215,98 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
expect(agentInDb.stateful_code_sessions).not.toBe(true);
|
||||
});
|
||||
|
||||
test('rejects updating an agent to an unconfigured code environment id', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.config = {
|
||||
endpoints: {
|
||||
agents: {
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['user'],
|
||||
environments: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mockReq.body = { code_environment_id: 'missing-vm' };
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(agentInDb.code_environment_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test('allows disabling stateful sessions after the configured environment is removed', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
stateful_code_sessions: true,
|
||||
code_environment_id: 'removed-vm',
|
||||
},
|
||||
);
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.config = {
|
||||
endpoints: {
|
||||
agents: {
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['user'],
|
||||
environments: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mockReq.body = {
|
||||
stateful_code_sessions: false,
|
||||
code_environment_id: 'removed-vm',
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(agentInDb.stateful_code_sessions).toBe(false);
|
||||
expect(agentInDb.code_environment_id).toBe('removed-vm');
|
||||
});
|
||||
|
||||
test('restores the deployment-default code environment', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
stateful_code_sessions: true,
|
||||
code_environment_id: 'attached-vm',
|
||||
},
|
||||
);
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.config = {
|
||||
endpoints: {
|
||||
agents: {
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['user'],
|
||||
environments: [
|
||||
{
|
||||
id: 'attached-vm',
|
||||
name: 'Attached VM',
|
||||
type: 'attached',
|
||||
baseURL: 'https://bridge.example.com/v1',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mockReq.body = { code_environment_id: null };
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(agentInDb.code_environment_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test('allows unrelated edits to an existing scope after policy is tightened', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const {
|
|||
sendUploadPolicyError,
|
||||
resolveUploadErrorMessage,
|
||||
verifyAgentUploadPermission,
|
||||
createCodeExecutionRouteKey,
|
||||
getCodeExecutionBaseUrl,
|
||||
assertUploadContentAllowed,
|
||||
hasActiveFilePolicy,
|
||||
|
|
@ -350,7 +351,29 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => {
|
|||
return res.status(400).send('Bad request');
|
||||
}
|
||||
const executionProfile = requestedProfile ?? 'default';
|
||||
const baseUrl = getCodeExecutionBaseUrl(executionProfile);
|
||||
const requestedRouteKey = req.query.execution_route_key;
|
||||
if (
|
||||
requestedRouteKey != null &&
|
||||
(typeof requestedRouteKey !== 'string' ||
|
||||
executionProfile !== 'stateful' ||
|
||||
!/^stateful:[a-f0-9]{32}$/.test(requestedRouteKey))
|
||||
) {
|
||||
logger.debug(`${logPrefix} invalid execution_route_key`);
|
||||
return res.status(400).send('Bad request');
|
||||
}
|
||||
const environments =
|
||||
req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments;
|
||||
const configuredEnvironment = requestedRouteKey
|
||||
? environments?.find(
|
||||
(environment) =>
|
||||
createCodeExecutionRouteKey('stateful', environment) === requestedRouteKey,
|
||||
)
|
||||
: undefined;
|
||||
if (requestedRouteKey && !configuredEnvironment) {
|
||||
logger.debug(`${logPrefix} unknown execution_route_key`);
|
||||
return res.status(404).send('Not found');
|
||||
}
|
||||
const baseUrl = getCodeExecutionBaseUrl(executionProfile, configuredEnvironment);
|
||||
|
||||
const { getDownloadStream } = getStrategyFunctions(FileSources.execute_code);
|
||||
if (!getDownloadStream) {
|
||||
|
|
|
|||
|
|
@ -45,11 +45,15 @@ 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',
|
||||
),
|
||||
getCodeExecutionBaseUrl: jest.fn((profile, environment) => {
|
||||
if (environment?.baseURL) {
|
||||
return environment.baseURL;
|
||||
}
|
||||
if (profile === 'stateful') {
|
||||
return process.env.LIBRECHAT_CODE_BASEURL_STATEFUL;
|
||||
}
|
||||
return 'https://code-default.example.com/v1';
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
|
|
@ -69,6 +73,7 @@ jest.mock('~/config', () => ({
|
|||
|
||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { createCodeExecutionRouteKey } = require('@librechat/api');
|
||||
|
||||
// Import the router after mocks
|
||||
const router = require('./files');
|
||||
|
|
@ -84,6 +89,7 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||
let AclEntry;
|
||||
let User;
|
||||
let methods;
|
||||
let requestConfig;
|
||||
let modelsToCleanup = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -121,6 +127,7 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||
id: otherUserId?.toString() || 'default-user',
|
||||
role: SystemRoles.USER,
|
||||
};
|
||||
req.config = requestConfig;
|
||||
req.app.locals = {};
|
||||
next();
|
||||
});
|
||||
|
|
@ -148,6 +155,7 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
requestConfig = {};
|
||||
|
||||
// Clear database - clean up all test data
|
||||
await File.deleteMany({});
|
||||
|
|
@ -1249,6 +1257,43 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||
});
|
||||
|
||||
describe('GET /files/code/download/:session_id/:fileId', () => {
|
||||
it('resolves a configured environment route for a persisted fallback', async () => {
|
||||
const environment = {
|
||||
id: 'managed-vm',
|
||||
name: 'Managed VM',
|
||||
type: 'managed',
|
||||
baseURL: 'https://managed-code.example.com/v1',
|
||||
default: true,
|
||||
owner: 'deployment',
|
||||
};
|
||||
requestConfig = {
|
||||
endpoints: {
|
||||
agents: {
|
||||
statefulCodeSessions: { environments: [environment] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const executionRouteKey = createCodeExecutionRouteKey('stateful', environment);
|
||||
const getDownloadStream = jest.fn().mockResolvedValue({
|
||||
data: Readable.from(['configured output']),
|
||||
});
|
||||
getStrategyFunctions.mockReturnValue({ getDownloadStream });
|
||||
const sessionId = 's'.repeat(21);
|
||||
const codeFileId = 'f'.repeat(21);
|
||||
|
||||
const response = await request(app).get(
|
||||
`/files/code/download/${sessionId}/${codeFileId}?execution_profile=stateful&execution_route_key=${encodeURIComponent(executionRouteKey)}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getDownloadStream).toHaveBeenCalledWith(
|
||||
`${sessionId}/${codeFileId}`,
|
||||
{ kind: 'user', id: otherUserId.toString() },
|
||||
expect.any(Object),
|
||||
{ baseUrl: environment.baseURL, executionProfile: 'stateful' },
|
||||
);
|
||||
});
|
||||
|
||||
it('routes a persisted stateful fallback through the stateful Code API', async () => {
|
||||
const getDownloadStream = jest.fn().mockResolvedValue({
|
||||
headers: {
|
||||
|
|
@ -1285,6 +1330,15 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('rejects an unmapped configured-environment route before contacting Code API', async () => {
|
||||
const response = await request(app).get(
|
||||
`/files/code/download/${'s'.repeat(21)}/${'f'.repeat(21)}?execution_profile=stateful&execution_route_key=stateful:${'a'.repeat(32)}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(getStrategyFunctions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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`,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const {
|
|||
buildAgentContextAttachmentsByAgentId,
|
||||
collectCodeExecutionProfileRoutes,
|
||||
getLazySubagentConfigId,
|
||||
resolveCodeExecutionContext,
|
||||
createStatefulCodeEnvironmentPolicyError,
|
||||
buildSubagentThreadTaskConfig,
|
||||
backgroundCompletionWakeupsEnabled,
|
||||
|
|
@ -921,9 +922,11 @@ const initializeClient = async ({
|
|||
};
|
||||
|
||||
const toLazySubagentMetadata = async (agent) => {
|
||||
const lazyCodeEnvAvailable =
|
||||
codeEnvAvailable === true && agent.tools?.includes(Tools.execute_code) === true;
|
||||
const statefulCodeSessions =
|
||||
statefulSessionsAvailable === true &&
|
||||
codeEnvAvailable === true &&
|
||||
lazyCodeEnvAvailable &&
|
||||
agent.stateful_code_sessions === true &&
|
||||
agent.tools?.includes(Tools.execute_code) === true;
|
||||
const statefulCodeEnvironment = agent.stateful_code_environment ?? 'user';
|
||||
|
|
@ -933,6 +936,23 @@ const initializeClient = async ({
|
|||
) {
|
||||
throw createStatefulCodeEnvironmentPolicyError(statefulCodeEnvironment);
|
||||
}
|
||||
const configuredCodeEnvironments =
|
||||
appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments;
|
||||
const hasConfiguredCodeEnvironment =
|
||||
agent.code_environment_id != null ||
|
||||
configuredCodeEnvironments?.some((environment) => environment.default === true) === true;
|
||||
const codeExecutionContext =
|
||||
lazyCodeEnvAvailable && (!statefulCodeSessions || hasConfiguredCodeEnvironment)
|
||||
? resolveCodeExecutionContext({
|
||||
statefulSessions: statefulCodeSessions,
|
||||
environment: statefulCodeEnvironment,
|
||||
environmentId: agent.code_environment_id,
|
||||
environments: configuredCodeEnvironments,
|
||||
userId,
|
||||
agentId: agent.id,
|
||||
conversationId,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
|
|
@ -946,10 +966,11 @@ const initializeClient = async ({
|
|||
memoryAvailable === true && agent.tools?.includes(Tools.memory) === true,
|
||||
subagents: agent.subagents,
|
||||
configId: getLazySubagentConfigId(agent),
|
||||
codeEnvAvailable:
|
||||
codeEnvAvailable === true && agent.tools?.includes(Tools.execute_code) === true,
|
||||
codeEnvAvailable: lazyCodeEnvAvailable,
|
||||
statefulCodeSessions,
|
||||
statefulCodeEnvironment,
|
||||
codeExecutionContext,
|
||||
codeSessionKey: codeExecutionContext?.codeSessionKey,
|
||||
includeReasoningHistory: getIncludeReasoningHistory(agent),
|
||||
alwaysApplySkillPrimes: await resolveLazyAlwaysApplySkillPrimes(agent),
|
||||
};
|
||||
|
|
@ -1182,6 +1203,8 @@ const initializeClient = async ({
|
|||
codeEnvAvailable: metadata.codeEnvAvailable,
|
||||
statefulCodeSessions: metadata.statefulCodeSessions,
|
||||
statefulCodeEnvironment: metadata.statefulCodeEnvironment,
|
||||
codeExecutionContext: metadata.codeExecutionContext,
|
||||
codeSessionKey: metadata.codeSessionKey,
|
||||
includeReasoningHistory: metadata.includeReasoningHistory,
|
||||
alwaysApplySkillPrimes: metadata.alwaysApplySkillPrimes,
|
||||
lazySubagentConfigs: lazyChildren,
|
||||
|
|
|
|||
|
|
@ -1204,6 +1204,60 @@ describe('initializeClient — subagent loading', () => {
|
|||
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retains a configured Code API route on lazy subagent descriptors', async () => {
|
||||
const subAgent = await createAgent({
|
||||
id: SUBAGENT_ID,
|
||||
name: 'Attached Stateful 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',
|
||||
});
|
||||
await grantView(subAgent);
|
||||
mockInitializeAgent.mockResolvedValue(
|
||||
makePrimaryConfig({
|
||||
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
|
||||
}),
|
||||
);
|
||||
const req = makeSubagentReq();
|
||||
req.config.endpoints.agents.capabilities.push('execute_code', 'stateful_code_sessions');
|
||||
req.config.endpoints.agents.statefulCodeSessions = {
|
||||
allowedEnvironments: ['agent-user'],
|
||||
environments: [
|
||||
{
|
||||
id: 'attached-vm',
|
||||
name: 'Attached VM',
|
||||
type: 'attached',
|
||||
baseURL: 'https://bridge.example.com/v1/',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await initializeClient({
|
||||
req,
|
||||
res: {},
|
||||
signal: new AbortController().signal,
|
||||
endpointOption: makeEndpointOption(),
|
||||
});
|
||||
|
||||
expect(agentClientArgs.agent.lazySubagentConfigs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
codeSessionKey: expect.stringMatching(/^execute_code:stateful:[a-f0-9]{32}:v3:/),
|
||||
codeExecutionContext: expect.objectContaining({
|
||||
baseUrl: 'https://bridge.example.com/v1',
|
||||
environmentId: 'attached-vm',
|
||||
environmentType: 'attached',
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey: expect.stringMatching(/^stateful:[a-f0-9]{32}$/),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits a descriptor when its metadata lookup fails without aborting the primary run', async () => {
|
||||
const primaryConfig = makePrimaryConfig({
|
||||
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const FormData = require('form-data');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getCodeBaseURL } = require('@librechat/agents');
|
||||
const { getCodeEnvRefs } = require('librechat-data-provider');
|
||||
const { EModelEndpoint, getCodeEnvRefs } = require('librechat-data-provider');
|
||||
const {
|
||||
logAxiosError,
|
||||
appendCodeEnvFile,
|
||||
|
|
@ -12,6 +12,7 @@ const {
|
|||
buildCodeEnvDownloadQuery,
|
||||
getCodeApiAuthHeaders,
|
||||
getCodeExecutionBaseUrl,
|
||||
createCodeExecutionRouteKey,
|
||||
CODE_API_EXPECTED_PROFILE_HEADER,
|
||||
} = require('@librechat/api');
|
||||
|
||||
|
|
@ -82,8 +83,40 @@ async function deleteCodeEnvFile(req, file) {
|
|||
|
||||
const missingOrUnsupportedStatuses = new Set([404, 405]);
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
for (const [executionProfile, ref] of refs) {
|
||||
const baseURL = getCodeExecutionBaseUrl(executionProfile);
|
||||
for (const [executionRouteKey, ref] of refs) {
|
||||
const executionProfile = ref.executionProfile ?? 'default';
|
||||
const environments =
|
||||
req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments;
|
||||
const configuredEnvironment = environments?.find(
|
||||
(environment) =>
|
||||
createCodeExecutionRouteKey(executionProfile, environment) === executionRouteKey,
|
||||
);
|
||||
if (
|
||||
executionProfile === 'stateful' &&
|
||||
executionRouteKey !== executionProfile &&
|
||||
!configuredEnvironment
|
||||
) {
|
||||
logger.warn(
|
||||
`[deleteCodeEnvFile] Skipping remote cleanup for unmapped historical route ${executionRouteKey}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let baseURL;
|
||||
try {
|
||||
baseURL = getCodeExecutionBaseUrl(executionProfile, configuredEnvironment);
|
||||
} catch (error) {
|
||||
if (
|
||||
executionProfile === 'stateful' &&
|
||||
executionRouteKey === executionProfile &&
|
||||
!configuredEnvironment
|
||||
) {
|
||||
logger.warn(
|
||||
'[deleteCodeEnvFile] Skipping remote cleanup for retired legacy stateful route',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const query = buildCodeEnvDownloadQuery({
|
||||
kind: ref.kind,
|
||||
id: ref.id,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ const {
|
|||
codeServerHttpAgent,
|
||||
codeServerHttpsAgent,
|
||||
getCodeApiAuthHeaders,
|
||||
getCodeExecutionBaseUrl,
|
||||
} = require('@librechat/api');
|
||||
const { deleteCodeEnvFile, getCodeOutputDownloadStream, uploadCodeEnvFile } = require('./crud');
|
||||
|
||||
|
|
@ -269,6 +270,43 @@ describe('Code CRUD', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('skips remote cleanup instead of falling back when a historical route is unmapped', async () => {
|
||||
const historicalRoute = 'stateful:0123456789abcdef0123456789abcdef';
|
||||
const historicalFile = {
|
||||
metadata: {
|
||||
codeEnvRefs: {
|
||||
[historicalRoute]: {
|
||||
...file.metadata.codeEnvRef,
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey: historicalRoute,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(deleteCodeEnvFile(req, historicalFile)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockAxios).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips legacy stateful cleanup after its endpoint is retired', async () => {
|
||||
getCodeExecutionBaseUrl.mockImplementationOnce(() => {
|
||||
throw new Error('LIBRECHAT_CODE_BASEURL_STATEFUL is not configured');
|
||||
});
|
||||
const legacyStatefulFile = {
|
||||
metadata: {
|
||||
codeEnvRef: {
|
||||
...file.metadata.codeEnvRef,
|
||||
executionProfile: 'stateful',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(deleteCodeEnvFile(req, legacyStatefulFile)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockAxios).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([404, 405])(
|
||||
'falls back to the legacy code environment delete route after a %s',
|
||||
async (status) => {
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ const prepareCodeOutputForInspection = async ({
|
|||
* @param {string} params.messageId - The current message ID.
|
||||
* @param {number} params.expiresAt - Expiration timestamp (24 hours from creation).
|
||||
* @param {'default'|'stateful'} [params.executionProfile] - Code API route for later fallback download.
|
||||
* @param {string} [params.executionRouteKey] - Deployment-local route identity.
|
||||
* @returns {Object} Fallback response with download URL.
|
||||
*/
|
||||
const createDownloadFallback = ({
|
||||
|
|
@ -241,12 +242,20 @@ const createDownloadFallback = ({
|
|||
toolCallId,
|
||||
conversationId,
|
||||
executionProfile,
|
||||
executionRouteKey,
|
||||
}) => {
|
||||
const basePath = getBasePath();
|
||||
const profileQuery = executionProfile === 'stateful' ? '?execution_profile=stateful' : '';
|
||||
const query = new URLSearchParams();
|
||||
if (executionProfile === 'stateful') {
|
||||
query.set('execution_profile', 'stateful');
|
||||
}
|
||||
if (executionRouteKey && executionRouteKey !== executionProfile) {
|
||||
query.set('execution_route_key', executionRouteKey);
|
||||
}
|
||||
const routeQuery = query.size > 0 ? `?${query.toString()}` : '';
|
||||
return {
|
||||
filename: name,
|
||||
filepath: `${basePath}/api/files/code/download/${session_id}/${id}${profileQuery}`,
|
||||
filepath: `${basePath}/api/files/code/download/${session_id}/${id}${routeQuery}`,
|
||||
expiresAt,
|
||||
conversationId,
|
||||
toolCallId,
|
||||
|
|
@ -494,6 +503,7 @@ const runPreviewFinalize = ({ finalize, fileId, previewRevision, onResolved }) =
|
|||
* @param {string} params.messageId - The current message ID.
|
||||
* @param {string} [params.codeApiBaseUrl] - Trusted per-agent Code API endpoint.
|
||||
* @param {'default'|'stateful'} [params.executionProfile] - Trusted execution profile.
|
||||
* @param {string} [params.executionRouteKey] - Trusted deployment-local route identity.
|
||||
* @param {Buffer} [params.preparedBuffer] - Bytes downloaded during a
|
||||
* no-write content inspection preflight.
|
||||
* @param {boolean} [params.downloadFallback] - Return the bounded download
|
||||
|
|
@ -512,6 +522,7 @@ const processCodeOutput = async ({
|
|||
freshClaimAfter,
|
||||
codeApiBaseUrl,
|
||||
executionProfile = 'default',
|
||||
executionRouteKey = executionProfile,
|
||||
preparedBuffer,
|
||||
downloadFallback,
|
||||
}) => {
|
||||
|
|
@ -535,6 +546,7 @@ const processCodeOutput = async ({
|
|||
session_id,
|
||||
conversationId,
|
||||
executionProfile,
|
||||
executionRouteKey,
|
||||
expiresAt: currentDate.getTime() + 86400000,
|
||||
}),
|
||||
};
|
||||
|
|
@ -565,6 +577,7 @@ const processCodeOutput = async ({
|
|||
session_id,
|
||||
conversationId,
|
||||
executionProfile,
|
||||
executionRouteKey,
|
||||
expiresAt: currentDate.getTime() + 86400000,
|
||||
}),
|
||||
};
|
||||
|
|
@ -579,6 +592,7 @@ const processCodeOutput = async ({
|
|||
storage_session_id: session_id,
|
||||
file_id: id,
|
||||
executionProfile,
|
||||
...(executionRouteKey !== executionProfile ? { executionRouteKey } : {}),
|
||||
};
|
||||
|
||||
/* `safeName` keeps the directory structure (`a/b/file.txt` -> `a/b/file.txt`)
|
||||
|
|
@ -752,6 +766,7 @@ const processCodeOutput = async ({
|
|||
session_id,
|
||||
conversationId,
|
||||
executionProfile,
|
||||
executionRouteKey,
|
||||
expiresAt: currentDate.getTime() + 86400000,
|
||||
}),
|
||||
};
|
||||
|
|
@ -935,6 +950,7 @@ const processCodeOutput = async ({
|
|||
session_id,
|
||||
conversationId,
|
||||
executionProfile,
|
||||
executionRouteKey,
|
||||
expiresAt: currentDate.getTime() + 86400000,
|
||||
}),
|
||||
};
|
||||
|
|
@ -1102,6 +1118,7 @@ const primeFiles = async (options) => {
|
|||
agentResourceType,
|
||||
codeApiBaseUrl,
|
||||
executionProfile = 'default',
|
||||
executionRouteKey = executionProfile,
|
||||
} = options;
|
||||
const codeApiRoute = { baseUrl: codeApiBaseUrl, executionProfile };
|
||||
const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? [];
|
||||
|
|
@ -1160,7 +1177,7 @@ const primeFiles = async (options) => {
|
|||
continue;
|
||||
}
|
||||
|
||||
const ref = getCodeEnvRefForProfile(file.metadata, executionProfile);
|
||||
const ref = getCodeEnvRefForProfile(file.metadata, executionRouteKey);
|
||||
const sourceRef = ref ?? getCodeEnvRefs(file.metadata)[0]?.[1];
|
||||
if (!sourceRef) {
|
||||
skippedNoRef += 1;
|
||||
|
|
@ -1250,6 +1267,7 @@ const primeFiles = async (options) => {
|
|||
storage_session_id: uploaded.storage_session_id,
|
||||
file_id: uploaded.file_id,
|
||||
executionProfile,
|
||||
...(executionRouteKey !== executionProfile ? { executionRouteKey } : {}),
|
||||
...(sourceRef.kind === 'skill' ? { version: sourceRef.version } : {}),
|
||||
};
|
||||
|
||||
|
|
@ -1258,7 +1276,7 @@ const primeFiles = async (options) => {
|
|||
await updateFile({
|
||||
file_id: file.file_id,
|
||||
'metadata.codeEnvRef': updatedRefs.codeEnvRef,
|
||||
[`metadata.codeEnvRefs.${executionProfile}`]: newRef,
|
||||
[`metadata.codeEnvRefs.${executionRouteKey}`]: newRef,
|
||||
});
|
||||
sessions.set(newRef.storage_session_id, true);
|
||||
pushFile(newRef.storage_session_id, newRef.file_id);
|
||||
|
|
|
|||
|
|
@ -978,11 +978,13 @@ describe('Code Process', () => {
|
|||
describe('fallback behavior', () => {
|
||||
it('preserves the stateful route in generated downloads and fallbacks', async () => {
|
||||
mockAxios.mockRejectedValue(new Error('Network error'));
|
||||
const executionRouteKey = `stateful:${'a'.repeat(32)}`;
|
||||
|
||||
const { file: result } = await processCodeOutput({
|
||||
...baseParams,
|
||||
codeApiBaseUrl: 'https://code-stateful.example.com',
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey,
|
||||
});
|
||||
|
||||
expect(mockAxios).toHaveBeenCalledWith(
|
||||
|
|
@ -992,6 +994,9 @@ describe('Code Process', () => {
|
|||
}),
|
||||
);
|
||||
expect(result.filepath).toContain('execution_profile=stateful');
|
||||
expect(result.filepath).toContain(
|
||||
`execution_route_key=${encodeURIComponent(executionRouteKey)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to download URL when saveBuffer is not available', async () => {
|
||||
|
|
@ -1091,14 +1096,19 @@ describe('Code Process', () => {
|
|||
|
||||
it('persists the originating profile on a stateful artifact ref', async () => {
|
||||
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
|
||||
const executionRouteKey = `stateful:${'a'.repeat(32)}`;
|
||||
|
||||
const { file: result } = await processCodeOutput({
|
||||
...baseParams,
|
||||
codeApiBaseUrl: 'https://code-stateful.example.com',
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey,
|
||||
});
|
||||
|
||||
expect(result.metadata.codeEnvRef.executionProfile).toBe('stateful');
|
||||
expect(result.metadata.codeEnvRef).toEqual(
|
||||
expect.objectContaining({ executionProfile: 'stateful', executionRouteKey }),
|
||||
);
|
||||
expect(result.metadata.codeEnvRefs[executionRouteKey]).toEqual(result.metadata.codeEnvRef);
|
||||
});
|
||||
|
||||
/* Phase C lock-in: outputs are ALWAYS user-scoped, never skill-scoped.
|
||||
|
|
|
|||
|
|
@ -785,6 +785,8 @@ async function loadToolDefinitionsWrapper({
|
|||
enabledCapabilities.has(AgentCapabilities.stateful_code_sessions) &&
|
||||
agent.stateful_code_sessions === true,
|
||||
environment: agent.stateful_code_environment,
|
||||
environmentId: agent.code_environment_id,
|
||||
environments: req.config?.endpoints?.agents?.statefulCodeSessions?.environments,
|
||||
userId: req.user.id,
|
||||
agentId: agent.id,
|
||||
conversationId: runtimeRequestBody?.conversationId,
|
||||
|
|
@ -1392,6 +1394,7 @@ async function loadToolDefinitionsWrapper({
|
|||
agentResourceType,
|
||||
codeApiBaseUrl: resolvedCodeExecutionContext.baseUrl,
|
||||
executionProfile: resolvedCodeExecutionContext.executionProfile,
|
||||
executionRouteKey: resolvedCodeExecutionContext.executionRouteKey,
|
||||
});
|
||||
if (toolContext) {
|
||||
dynamicToolContextMap[Tools.execute_code] = toolContext;
|
||||
|
|
@ -1642,6 +1645,8 @@ async function loadAgentTools({
|
|||
resolveCodeExecutionContext({
|
||||
statefulSessions: statefulCodeSessions,
|
||||
environment: agent.stateful_code_environment,
|
||||
environmentId: agent.code_environment_id,
|
||||
environments: req.config?.endpoints?.agents?.statefulCodeSessions?.environments,
|
||||
userId: req.user.id,
|
||||
agentId: agent.id,
|
||||
conversationId: requestBody?.conversationId ?? req.body?.conversationId,
|
||||
|
|
@ -2022,6 +2027,8 @@ async function loadToolsForExecution({
|
|||
const codeExecutionContext = resolveCodeExecutionContext({
|
||||
statefulSessions: statefulCodeSessions,
|
||||
environment: agent?.stateful_code_environment,
|
||||
environmentId: agent?.code_environment_id,
|
||||
environments: req.config?.endpoints?.agents?.statefulCodeSessions?.environments,
|
||||
userId: req.user.id,
|
||||
agentId: agent?.id,
|
||||
conversationId: conversationId ?? runtimeRequestBody?.conversationId,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ export type AgentForm = {
|
|||
memory_scope?: MemoryScope;
|
||||
/** Sharing scope for stateful Code API workspaces. */
|
||||
stateful_code_environment?: StatefulCodeEnvironment;
|
||||
/** Operator-configured managed or attached execution environment. */
|
||||
code_environment_id?: string | null;
|
||||
provider?: AgentProvider | OptionWithIcon;
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids?: string[];
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ const ENVIRONMENT_LABELS = {
|
|||
conversation: 'com_ui_stateful_code_environment_conversation',
|
||||
} as const;
|
||||
|
||||
const DEPLOYMENT_DEFAULT_ENVIRONMENT = '__deployment_default__';
|
||||
|
||||
export default function StatefulSessions() {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
|
|
@ -39,7 +41,9 @@ export default function StatefulSessions() {
|
|||
const enabled = watch(AgentCapabilities.stateful_code_sessions) ?? false;
|
||||
const codeEnabled = watch(AgentCapabilities.execute_code);
|
||||
const environment = watch('stateful_code_environment') ?? 'user';
|
||||
const codeEnvironmentId = watch('code_environment_id');
|
||||
const configuredEnvironments = agentsConfig?.statefulCodeSessions?.allowedEnvironments;
|
||||
const executionEnvironments = agentsConfig?.statefulCodeSessions?.environments ?? [];
|
||||
const allowedEnvironments = resolveAllowedStatefulCodeEnvironments(configuredEnvironments);
|
||||
|
||||
const handleChange = (value: boolean) => {
|
||||
|
|
@ -91,6 +95,46 @@ export default function StatefulSessions() {
|
|||
</HoverCard>
|
||||
{enabled && codeEnabled === true && (
|
||||
<div className="space-y-2 pl-1">
|
||||
{executionEnvironments.length > 0 && (
|
||||
<>
|
||||
<label
|
||||
className="text-xs font-medium text-text-secondary"
|
||||
htmlFor="code-environment-id"
|
||||
>
|
||||
{localize('com_ui_code_environment')}
|
||||
</label>
|
||||
<Select
|
||||
value={codeEnvironmentId ?? DEPLOYMENT_DEFAULT_ENVIRONMENT}
|
||||
onValueChange={(value) => {
|
||||
setValue(
|
||||
'code_environment_id',
|
||||
value === DEPLOYMENT_DEFAULT_ENVIRONMENT ? null : value,
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="code-environment-id" data-testid="code-environment-id">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEPLOYMENT_DEFAULT_ENVIRONMENT}>
|
||||
{localize('com_ui_code_environment_deployment_default')}
|
||||
</SelectItem>
|
||||
{executionEnvironments.map((candidate) => (
|
||||
<SelectItem key={candidate.id} value={candidate.id}>
|
||||
{candidate.name}
|
||||
{candidate.type === 'attached'
|
||||
? ` (${localize('com_ui_code_environment_attached')})`
|
||||
: ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{localize('com_nav_info_code_environment')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<label
|
||||
className="text-xs font-medium text-text-secondary"
|
||||
htmlFor="stateful-code-environment"
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
|
|||
hide_sequential_outputs,
|
||||
stateful_code_sessions,
|
||||
stateful_code_environment,
|
||||
code_environment_id,
|
||||
recursion_limit,
|
||||
category,
|
||||
support_contact,
|
||||
|
|
@ -124,6 +125,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
|
|||
hide_sequential_outputs,
|
||||
stateful_code_sessions: normalizedStatefulCodeSessions,
|
||||
stateful_code_environment: normalizedStatefulCodeEnvironment,
|
||||
code_environment_id: agent_id ? code_environment_id : (code_environment_id ?? undefined),
|
||||
recursion_limit,
|
||||
category,
|
||||
support_contact,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ function AgentSelect({
|
|||
avatar_preview: fullAgent.avatar?.filepath ?? '',
|
||||
avatar_action: null,
|
||||
stateful_code_environment: fullAgent.stateful_code_environment ?? 'user',
|
||||
code_environment_id: fullAgent.code_environment_id,
|
||||
};
|
||||
|
||||
Object.entries(fullAgent).forEach(([name, value]) => {
|
||||
|
|
|
|||
|
|
@ -141,6 +141,24 @@ describe('composeAgentUpdatePayload', () => {
|
|||
|
||||
expect(payload.stateful_code_environment).toBe('agent-user');
|
||||
});
|
||||
|
||||
it('sends a deployment-default reset for an existing agent', () => {
|
||||
const form = createForm();
|
||||
form.code_environment_id = null;
|
||||
|
||||
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
|
||||
|
||||
expect(payload.code_environment_id).toBeNull();
|
||||
});
|
||||
|
||||
it('omits a deployment-default reset when creating an agent', () => {
|
||||
const form = createForm();
|
||||
form.code_environment_id = null;
|
||||
|
||||
const { payload } = composeAgentUpdatePayload(form);
|
||||
|
||||
expect(payload.code_environment_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistAvatarChanges', () => {
|
||||
|
|
|
|||
|
|
@ -2168,6 +2168,10 @@
|
|||
"com_ui_standard": "Standard",
|
||||
"com_ui_stateful_sessions": "Stateful code sessions",
|
||||
"com_ui_stateful_code_environment": "Stateful environment",
|
||||
"com_ui_code_environment": "Execution environment",
|
||||
"com_ui_code_environment_attached": "attached VM",
|
||||
"com_ui_code_environment_deployment_default": "Deployment default",
|
||||
"com_nav_info_code_environment": "Choose the managed runtime or attached VM that owns this workspace.",
|
||||
"com_ui_stateful_code_environment_user": "User workspace (recommended)",
|
||||
"com_ui_stateful_code_environment_agent_user": "Agent + user workspace",
|
||||
"com_ui_stateful_code_environment_conversation": "Conversation workspace",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ endpoints:
|
|||
- chain
|
||||
- ocr
|
||||
- run_in_background
|
||||
# __E2E_CODE_BRIDGE_CONFIG__
|
||||
# Keep the shared mock profile non-interactive except for the dedicated
|
||||
# approval probe. This exercises real HITL pause/resume without wedging the
|
||||
# existing file-authoring, steering, background-tool, or MCP specs.
|
||||
|
|
|
|||
|
|
@ -209,6 +209,23 @@ function writeRuntimeMockConfig() {
|
|||
.replace('# __E2E_DYNAMIC_MCP_ALLOWED_DOMAIN__', dynamicMcpConfig.allowedDomain)
|
||||
.replace('# __E2E_DYNAMIC_MCP_STDIO_ENV__', dynamicMcpConfig.stdioEnv)
|
||||
.replace('# __E2E_DYNAMIC_MCP_NETWORK_SERVERS__', dynamicMcpConfig.networkServers);
|
||||
const codeBridgeURL = process.env.E2E_CODE_BRIDGE_URL;
|
||||
config = config.replace(
|
||||
'# __E2E_CODE_BRIDGE_CONFIG__',
|
||||
codeBridgeURL
|
||||
? [
|
||||
' - stateful_code_sessions',
|
||||
'statefulCodeSessions:',
|
||||
' allowedEnvironments: ["conversation"]',
|
||||
' environments:',
|
||||
' - id: e2e-vm',
|
||||
' name: E2E attached VM',
|
||||
' type: attached',
|
||||
` baseURL: ${JSON.stringify(codeBridgeURL)}`,
|
||||
' default: true',
|
||||
].join('\n ')
|
||||
: '# __E2E_CODE_BRIDGE_CONFIG__',
|
||||
);
|
||||
/** Keep the generated config in lockstep with the overridable label-server
|
||||
* port: the template hard-codes 8889, so an `E2E_LABEL_PORT` override that
|
||||
* moved only the server and its health check would report ready while
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const ASK_USER_QUESTION_MARKER = 'E2E_ASK_USER_QUESTION:';
|
|||
const RESUME_ICON_REPLY_MARKER = 'E2E_RESUME_ICON_REPLY:';
|
||||
const FORCED_ERROR_MARKER = 'E2E_FORCED_ERROR:';
|
||||
const MARKDOWN_REPLY_MARKER = 'E2E_MARKDOWN_REPLY';
|
||||
const STATEFUL_CODE_MARKER = 'E2E_STATEFUL_CODE:';
|
||||
/** Two prose paragraphs, so a spec can select the message's *closing* block. */
|
||||
const PARAGRAPHS_REPLY_MARKER = 'E2E_PARAGRAPHS_REPLY';
|
||||
const MERMAID_ARTIFACT_REPLY_MARKER = 'E2E_MERMAID_ARTIFACT_REPLY';
|
||||
|
|
@ -88,6 +89,7 @@ const RESUME_ICON_REPLY_CHUNKS = 240;
|
|||
const CREATE_FILE_TOOL_NAME = 'create_file';
|
||||
const EDIT_FILE_TOOL_NAME = 'edit_file';
|
||||
const BASH_TOOL_NAME = 'bash_tool';
|
||||
const STATEFUL_CODE_VALUE = 'librechat-bridge-persisted';
|
||||
const SKILL_TOOL_NAME = 'skill';
|
||||
const CREATE_SKILL_TOOL_CALL_ID = 'call_e2e_create_skill';
|
||||
const EDIT_SKILL_TOOL_CALL_ID = 'call_e2e_edit_skill';
|
||||
|
|
@ -1647,6 +1649,42 @@ function backgroundCompletionResponses(text) {
|
|||
};
|
||||
}
|
||||
|
||||
function statefulCodeResponses(operation, toolNames) {
|
||||
if (!toolNames.has(BASH_TOOL_NAME)) {
|
||||
return {
|
||||
responses: [`E2E stateful code unavailable: ${BASH_TOOL_NAME} was not advertised.`],
|
||||
};
|
||||
}
|
||||
|
||||
const commands = {
|
||||
write: `printf ${STATEFUL_CODE_VALUE} > /mnt/data/librechat-bridge-state.txt && cat /mnt/data/librechat-bridge-state.txt`,
|
||||
read: 'cat /mnt/data/librechat-bridge-state.txt',
|
||||
};
|
||||
const command = commands[operation];
|
||||
if (!command) {
|
||||
return { responses: [`E2E stateful code failed: unsupported operation ${operation}`] };
|
||||
}
|
||||
|
||||
return {
|
||||
responses: ['', ''],
|
||||
toolCalls: [
|
||||
{
|
||||
id: `call_e2e_stateful_code_${operation}`,
|
||||
name: BASH_TOOL_NAME,
|
||||
args: { command },
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
resolveOnStream: (streamMessages) => {
|
||||
const toolText = findLastToolMessageText(streamMessages, STATEFUL_CODE_VALUE);
|
||||
if (!toolText) {
|
||||
return null;
|
||||
}
|
||||
return { responses: [`E2E stateful code ${operation} observed ${STATEFUL_CODE_VALUE}`] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseHandoffScript(text) {
|
||||
const encodedScript = getMarkerValue(text, HANDOFF_MARKER);
|
||||
if (!encodedScript) {
|
||||
|
|
@ -2274,6 +2312,11 @@ function resolveResponses({ graph, messages, text, toolNames }) {
|
|||
return reply;
|
||||
}
|
||||
|
||||
const statefulCodeOperation = getMarkerValue(text, STATEFUL_CODE_MARKER);
|
||||
if (statefulCodeOperation) {
|
||||
return statefulCodeResponses(statefulCodeOperation, toolNames);
|
||||
}
|
||||
|
||||
const steerToolLabel = getMarkerValue(text, STEER_TOOL_REPLY_MARKER);
|
||||
if (steerToolLabel) {
|
||||
return steerToolReplyResponses(steerToolLabel, toolNames);
|
||||
|
|
|
|||
66
e2e/specs/mock/stateful-code-bridge.spec.ts
Normal file
66
e2e/specs/mock/stateful-code-bridge.spec.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { AgentDetail } from './agents.helpers';
|
||||
import { cleanupAgent, openAgentBuilder, uniqueAgentName } from './agents.helpers';
|
||||
import {
|
||||
MOCK_ENDPOINTS,
|
||||
NEW_CHAT_PATH,
|
||||
getAccessToken,
|
||||
messagesView,
|
||||
requestJson,
|
||||
sendMessageAndWaitForCompletion,
|
||||
} from './helpers';
|
||||
|
||||
const CODE_VALUE = 'librechat-bridge-persisted';
|
||||
|
||||
test.describe('attached stateful code environment', () => {
|
||||
test.skip(!process.env.E2E_CODE_BRIDGE_URL, 'E2E_CODE_BRIDGE_URL is required');
|
||||
|
||||
test('routes two conversation turns through the bridge and preserves workspace state', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 15000 });
|
||||
|
||||
const name = uniqueAgentName('E2E Attached Code Agent');
|
||||
let agentId: string | undefined;
|
||||
|
||||
try {
|
||||
const token = await getAccessToken(page);
|
||||
const agent = await requestJson<AgentDetail>(page, {
|
||||
path: '/api/agents',
|
||||
token,
|
||||
method: 'POST',
|
||||
body: {
|
||||
name,
|
||||
description: 'Exercises the outbound stateful code bridge.',
|
||||
instructions: 'Use the requested code tool and report its output.',
|
||||
provider: MOCK_ENDPOINTS[0].label,
|
||||
model: MOCK_ENDPOINTS[0].model,
|
||||
tools: ['execute_code'],
|
||||
stateful_code_sessions: true,
|
||||
stateful_code_environment: 'conversation',
|
||||
code_environment_id: 'e2e-vm',
|
||||
},
|
||||
});
|
||||
agentId = agent.id;
|
||||
|
||||
const form = await openAgentBuilder(page);
|
||||
await form.getByRole('combobox', { name: 'Agent', exact: true }).click();
|
||||
await page.getByRole('option', { name }).click();
|
||||
await expect(form.getByLabel('Agent name')).toHaveValue(name);
|
||||
await form.getByRole('button', { name: 'Select Agent' }).click();
|
||||
|
||||
await sendMessageAndWaitForCompletion(page, 'E2E_STATEFUL_CODE:write');
|
||||
await expect(
|
||||
messagesView(page).getByText(`E2E stateful code write observed ${CODE_VALUE}`),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await sendMessageAndWaitForCompletion(page, 'E2E_STATEFUL_CODE:read');
|
||||
await expect(
|
||||
messagesView(page).getByText(`E2E stateful code read observed ${CODE_VALUE}`),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
await cleanupAgent(page, agentId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -566,6 +566,18 @@ endpoints:
|
|||
# # (optional) Limit the workspace scopes users may select. Omit to allow all three.
|
||||
# statefulCodeSessions:
|
||||
# allowedEnvironments: ["user", "agent-user", "conversation"]
|
||||
# # Route an agent to an operator-managed Code API or to a Code API whose
|
||||
# # `remote-bridge` backend leases work to an outbound @librechat/code worker.
|
||||
# environments:
|
||||
# - id: managed-default
|
||||
# name: Managed Code API
|
||||
# type: managed
|
||||
# baseURL: https://code.example.com/v1
|
||||
# default: true
|
||||
# - id: engineering-vm
|
||||
# name: Engineering VM
|
||||
# type: attached
|
||||
# baseURL: https://code-bridge.example.com/v1
|
||||
# # (optional) Trusted origin for internal event delivery. By default, LibreChat
|
||||
# # uses its own bound listener; override only for an internal TLS/front-door route.
|
||||
# eventDriven:
|
||||
|
|
|
|||
|
|
@ -565,6 +565,29 @@ describe('collectCodeExecutionProfileRoutes', () => {
|
|||
expect(routes).toEqual([{ codeExecutionContext: graphContext, codeSessionKeys: [graphKey] }]);
|
||||
});
|
||||
|
||||
it('keeps configured stateful deployments in separate routing namespaces', () => {
|
||||
const context = (executionRouteKey: string, baseUrl: string) => ({
|
||||
baseUrl,
|
||||
codeSessionKey: `execute_code:stateful:${executionRouteKey}`,
|
||||
executionProfile: 'stateful' as const,
|
||||
executionRouteKey,
|
||||
runtimeSessionHint: `v3:${executionRouteKey}:user:scope`,
|
||||
statefulSessions: true,
|
||||
});
|
||||
const first = context('stateful:first', 'https://first.example/v1');
|
||||
const second = context('stateful:second', 'https://second.example/v1');
|
||||
|
||||
const routes = collectCodeExecutionProfileRoutes([
|
||||
{ codeEnvAvailable: true, codeExecutionContext: first },
|
||||
{ codeEnvAvailable: true, codeExecutionContext: second },
|
||||
]);
|
||||
|
||||
expect(routes).toEqual([
|
||||
{ codeExecutionContext: first, codeSessionKeys: [first.codeSessionKey] },
|
||||
{ codeExecutionContext: second, codeSessionKeys: [second.codeSessionKey] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('derives and includes the trusted profile for a lazy subagent descriptor', () => {
|
||||
process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'https://stateful.example.com/v1';
|
||||
const routes = collectCodeExecutionProfileRoutes(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { Constants } from '@librechat/agents';
|
||||
import type { FileRefs, CodeEnvFile, ToolSessionMap, CodeSessionContext } from '@librechat/agents';
|
||||
import type { StatefulCodeEnvironment } from 'librechat-data-provider';
|
||||
import { resolveCodeExecutionContext, type CodeExecutionContext } from './execution';
|
||||
import {
|
||||
getCodeExecutionRouteKey,
|
||||
resolveCodeExecutionContext,
|
||||
type CodeExecutionContext,
|
||||
} from './execution';
|
||||
|
||||
/**
|
||||
* Minimal shape for an agent that may contribute primed code files to its
|
||||
|
|
@ -54,7 +58,7 @@ export function collectCodeExecutionProfileRoutes(
|
|||
scope?: { userId: string; conversationId?: string | null },
|
||||
): CodeExecutionProfileRoute[] {
|
||||
const routes = new Map<
|
||||
CodeExecutionContext['executionProfile'],
|
||||
string,
|
||||
{ codeExecutionContext: CodeExecutionContext; codeSessionKeys: Set<string> }
|
||||
>();
|
||||
const visited = new Set<CodeFilesAgent>();
|
||||
|
|
@ -78,12 +82,13 @@ export function collectCodeExecutionProfileRoutes(
|
|||
})
|
||||
: undefined);
|
||||
if (agent.codeEnvAvailable === true && context) {
|
||||
const route = routes.get(context.executionProfile) ?? {
|
||||
const routeKey = getCodeExecutionRouteKey(context);
|
||||
const route = routes.get(routeKey) ?? {
|
||||
codeExecutionContext: context,
|
||||
codeSessionKeys: new Set<string>(),
|
||||
};
|
||||
route.codeSessionKeys.add(agent.codeSessionKey ?? context.codeSessionKey);
|
||||
routes.set(context.executionProfile, route);
|
||||
routes.set(routeKey, route);
|
||||
}
|
||||
enqueueCodeFilesChildren(agent, queue, visited);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,15 @@ describe('agent context compatibility', () => {
|
|||
expect(changed.digest).not.toBe(fingerprint.digest);
|
||||
});
|
||||
|
||||
it('invalidates initialized context when the code execution route changes', () => {
|
||||
const fingerprint = (executionRouteKey: string) =>
|
||||
createInitializedAgentContextFingerprint({
|
||||
agents: [{ id: 'agent-1', execution: { executionRouteKey } }],
|
||||
});
|
||||
|
||||
expect(fingerprint('stateful:first').digest).not.toBe(fingerprint('stateful:second').digest);
|
||||
});
|
||||
|
||||
it('invalidates a constant-version deployment Skill when its body changes', () => {
|
||||
const fingerprint = (body: string) =>
|
||||
createInitializedAgentContextFingerprint({
|
||||
|
|
|
|||
|
|
@ -94,4 +94,95 @@ describe('resolveCodeExecutionContext', () => {
|
|||
expect(first.runtimeSessionHint).not.toContain('user-1');
|
||||
expect(second.runtimeSessionHint).not.toContain('user-2');
|
||||
});
|
||||
|
||||
it('routes an agent to its configured attached environment', () => {
|
||||
const context = resolveCodeExecutionContext({
|
||||
statefulSessions: true,
|
||||
environment: 'agent-user',
|
||||
environmentId: 'my-vm',
|
||||
environments: [
|
||||
{
|
||||
id: 'managed',
|
||||
name: 'Managed',
|
||||
type: 'managed',
|
||||
baseURL: 'https://managed.example/v1',
|
||||
},
|
||||
{
|
||||
id: 'my-vm',
|
||||
name: 'My VM',
|
||||
type: 'attached',
|
||||
baseURL: 'https://bridge.example/v1/',
|
||||
},
|
||||
],
|
||||
userId: 'user-1',
|
||||
agentId: 'agent-1',
|
||||
});
|
||||
|
||||
expect(context).toEqual(
|
||||
expect.objectContaining({
|
||||
baseUrl: 'https://bridge.example/v1',
|
||||
environmentId: 'my-vm',
|
||||
environmentType: 'attached',
|
||||
executionProfile: 'stateful',
|
||||
}),
|
||||
);
|
||||
expect(context.runtimeSessionHint).toMatch(/^v3:[a-f0-9]{12}:agent-user:/);
|
||||
expect(context.executionRouteKey).toMatch(/^stateful:[a-f0-9]{32}$/);
|
||||
});
|
||||
|
||||
it('namespaces configured deployments independently of the shared wire profile', () => {
|
||||
const environment = (id: string, baseURL: string) => ({
|
||||
id,
|
||||
name: id,
|
||||
type: 'attached' as const,
|
||||
baseURL,
|
||||
default: true,
|
||||
});
|
||||
const first = resolveCodeExecutionContext({
|
||||
statefulSessions: true,
|
||||
environments: [environment('first', 'https://first.example/v1')],
|
||||
userId: 'user-1',
|
||||
});
|
||||
const replacement = resolveCodeExecutionContext({
|
||||
statefulSessions: true,
|
||||
environments: [environment('first', 'https://replacement.example/v1')],
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(first.executionProfile).toBe('stateful');
|
||||
expect(replacement.executionProfile).toBe('stateful');
|
||||
expect(first.runtimeSessionHint).toBe(replacement.runtimeSessionHint);
|
||||
expect(first.executionRouteKey).not.toBe(replacement.executionRouteKey);
|
||||
expect(first.codeSessionKey).not.toBe(replacement.codeSessionKey);
|
||||
});
|
||||
|
||||
it('uses the operator-selected default environment when the agent has no override', () => {
|
||||
const context = resolveCodeExecutionContext({
|
||||
statefulSessions: true,
|
||||
environments: [
|
||||
{
|
||||
id: 'default-vm',
|
||||
name: 'Default VM',
|
||||
type: 'attached',
|
||||
baseURL: 'https://bridge.example/v1',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(context.environmentId).toBe('default-vm');
|
||||
expect(context.baseUrl).toBe('https://bridge.example/v1');
|
||||
});
|
||||
|
||||
it('fails closed when an agent references an unknown configured environment', () => {
|
||||
expect(() =>
|
||||
resolveCodeExecutionContext({
|
||||
statefulSessions: true,
|
||||
environmentId: 'missing-vm',
|
||||
environments: [],
|
||||
userId: 'user-1',
|
||||
}),
|
||||
).toThrow('Stateful code environment "missing-vm" is not configured');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,43 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { Constants, getCodeBaseURL } from '@librechat/agents';
|
||||
import type { StatefulCodeEnvironment } from 'librechat-data-provider';
|
||||
import type { StatefulCodeEnvironment, TAgentsEndpoint } from 'librechat-data-provider';
|
||||
|
||||
export const CODE_API_EXPECTED_PROFILE_HEADER = 'X-CodeAPI-Expected-Profile';
|
||||
|
||||
export type CodeExecutionProfile = 'default' | 'stateful';
|
||||
export type CodeEnvironmentConfig = NonNullable<
|
||||
NonNullable<TAgentsEndpoint['statefulCodeSessions']>['environments']
|
||||
>[number];
|
||||
|
||||
export interface CodeExecutionContext {
|
||||
baseUrl: string;
|
||||
codeSessionKey: string;
|
||||
executionProfile: CodeExecutionProfile;
|
||||
/** Stable server-side namespace for deployment-local file references and
|
||||
* priming work. Unlike `executionProfile`, this distinguishes configured
|
||||
* Code API deployments that all use the `stateful` wire profile. */
|
||||
executionRouteKey?: string;
|
||||
runtimeSessionHint?: string;
|
||||
statefulSessions: boolean;
|
||||
environmentId?: string;
|
||||
environmentType?: CodeEnvironmentConfig['type'];
|
||||
}
|
||||
|
||||
export function createCodeExecutionRouteKey(
|
||||
profile: CodeExecutionProfile,
|
||||
environment?: Pick<CodeEnvironmentConfig, 'id' | 'baseURL'>,
|
||||
): string {
|
||||
if (profile === 'default' || environment == null) {
|
||||
return profile;
|
||||
}
|
||||
const identity = JSON.stringify([environment.id, environment.baseURL.trim().replace(/\/+$/, '')]);
|
||||
return `stateful:${createHash('sha256').update(identity).digest('hex').slice(0, 32)}`;
|
||||
}
|
||||
|
||||
export function getCodeExecutionRouteKey(
|
||||
context: Pick<CodeExecutionContext, 'executionProfile' | 'executionRouteKey'>,
|
||||
): string {
|
||||
return context.executionRouteKey ?? context.executionProfile;
|
||||
}
|
||||
|
||||
export function normalizeStatefulCodeEnvironment(
|
||||
|
|
@ -26,10 +52,16 @@ export function normalizeStatefulCodeEnvironment(
|
|||
return 'user';
|
||||
}
|
||||
|
||||
export function getCodeExecutionBaseUrl(profile: CodeExecutionProfile): string {
|
||||
export function getCodeExecutionBaseUrl(
|
||||
profile: CodeExecutionProfile,
|
||||
environment?: CodeEnvironmentConfig,
|
||||
): string {
|
||||
if (profile === 'default') {
|
||||
return getCodeBaseURL().replace(/\/+$/, '');
|
||||
}
|
||||
if (environment) {
|
||||
return environment.baseURL.trim().replace(/\/+$/, '');
|
||||
}
|
||||
const baseUrl = process.env.LIBRECHAT_CODE_BASEURL_STATEFUL?.trim().replace(/\/+$/, '');
|
||||
if (baseUrl) {
|
||||
return baseUrl;
|
||||
|
|
@ -41,31 +73,50 @@ export function getCodeExecutionBaseUrl(profile: CodeExecutionProfile): string {
|
|||
|
||||
function resolveRuntimeSessionHint(params: {
|
||||
environment: StatefulCodeEnvironment;
|
||||
environmentId?: string;
|
||||
userId: string;
|
||||
agentId?: string | null;
|
||||
conversationId?: string | null;
|
||||
}): string {
|
||||
const { environment, userId, agentId, conversationId } = params;
|
||||
const { environment, environmentId, userId, agentId, conversationId } = params;
|
||||
const scopeFingerprint = (...parts: string[]): string =>
|
||||
createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 32);
|
||||
const prefix = environmentId ? `v3:${scopeFingerprint(environmentId).slice(0, 12)}` : 'v2';
|
||||
if (environment === 'agent-user') {
|
||||
if (!agentId) {
|
||||
throw new Error('Agent-user code environments require an agent ID.');
|
||||
}
|
||||
return `v2:agent-user:${scopeFingerprint(userId, agentId)}`;
|
||||
return `${prefix}:agent-user:${scopeFingerprint(userId, agentId)}`;
|
||||
}
|
||||
if (environment === 'conversation') {
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation code environments require a conversation ID.');
|
||||
}
|
||||
return `v2:conversation:${scopeFingerprint(userId, conversationId)}`;
|
||||
return `${prefix}:conversation:${scopeFingerprint(userId, conversationId)}`;
|
||||
}
|
||||
return `v2:user:${scopeFingerprint(userId)}`;
|
||||
return `${prefix}:user:${scopeFingerprint(userId)}`;
|
||||
}
|
||||
|
||||
function resolveConfiguredEnvironment(params: {
|
||||
environmentId?: string | null;
|
||||
environments?: readonly CodeEnvironmentConfig[];
|
||||
}): CodeEnvironmentConfig | undefined {
|
||||
const { environmentId, environments } = params;
|
||||
if (environmentId) {
|
||||
const configured = environments?.find((environment) => environment.id === environmentId);
|
||||
if (!configured) {
|
||||
throw new Error(`Stateful code environment "${environmentId}" is not configured.`);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
return environments?.find((environment) => environment.default === true);
|
||||
}
|
||||
|
||||
export function resolveCodeExecutionContext(params: {
|
||||
statefulSessions: boolean;
|
||||
environment?: StatefulCodeEnvironment | string | null;
|
||||
environmentId?: string | null;
|
||||
environments?: readonly CodeEnvironmentConfig[];
|
||||
userId?: string | null;
|
||||
agentId?: string | null;
|
||||
conversationId?: string | null;
|
||||
|
|
@ -80,21 +131,27 @@ export function resolveCodeExecutionContext(params: {
|
|||
}
|
||||
|
||||
const environment = normalizeStatefulCodeEnvironment(params.environment);
|
||||
const configuredEnvironment = resolveConfiguredEnvironment(params);
|
||||
if (!params.userId) {
|
||||
throw new Error('Stateful code environments require an authenticated user ID.');
|
||||
}
|
||||
const runtimeSessionHint = resolveRuntimeSessionHint({
|
||||
environment,
|
||||
environmentId: configuredEnvironment?.id,
|
||||
userId: params.userId,
|
||||
agentId: params.agentId,
|
||||
conversationId: params.conversationId,
|
||||
});
|
||||
const executionRouteKey = createCodeExecutionRouteKey('stateful', configuredEnvironment);
|
||||
return {
|
||||
baseUrl: getCodeExecutionBaseUrl('stateful'),
|
||||
codeSessionKey: `${Constants.EXECUTE_CODE}:stateful:${runtimeSessionHint}`,
|
||||
baseUrl: getCodeExecutionBaseUrl('stateful', configuredEnvironment),
|
||||
codeSessionKey: `${Constants.EXECUTE_CODE}:${executionRouteKey}:${runtimeSessionHint}`,
|
||||
executionProfile: 'stateful',
|
||||
...(configuredEnvironment ? { executionRouteKey } : {}),
|
||||
runtimeSessionHint,
|
||||
statefulSessions: true,
|
||||
environmentId: configuredEnvironment?.id,
|
||||
environmentType: configuredEnvironment?.type,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4489,7 +4489,7 @@ describe('createToolExecuteHandler', () => {
|
|||
});
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(markSandboxReady).toHaveBeenCalledWith('v2:user:abc');
|
||||
expect(markSandboxReady).toHaveBeenCalledWith('v2:user:abc', 'stateful');
|
||||
expect(markSandboxReady).toHaveBeenCalledWith('conversation-1');
|
||||
|
||||
jest.mocked(markSandboxReady).mockClear();
|
||||
|
|
|
|||
|
|
@ -4388,6 +4388,8 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
);
|
||||
const codeExecutionContext = getCodeExecutionContext(mergedConfigurable);
|
||||
const runtimeSessionHint = codeExecutionContext?.runtimeSessionHint;
|
||||
const executionRouteKey =
|
||||
codeExecutionContext?.executionRouteKey ?? codeExecutionContext?.executionProfile;
|
||||
const sandboxConversationId =
|
||||
((metadata as Record<string, unknown>)?.thread_id as string | undefined) ??
|
||||
(mergedConfigurable?.thread_id as string | undefined) ??
|
||||
|
|
@ -4398,7 +4400,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
)?.conversationId;
|
||||
const markCodeSandboxWarm = (): void => {
|
||||
if (runtimeSessionHint) {
|
||||
void markSandboxReady(runtimeSessionHint);
|
||||
void markSandboxReady(runtimeSessionHint, executionRouteKey);
|
||||
}
|
||||
if (sandboxConversationId) {
|
||||
void markSandboxReady(sandboxConversationId);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const params = {
|
|||
baseUrl: 'https://code-stateful.example.com',
|
||||
codeSessionKey: 'execute_code:stateful:test',
|
||||
executionProfile: 'stateful' as const,
|
||||
executionRouteKey: `stateful:${'a'.repeat(32)}`,
|
||||
statefulSessions: true,
|
||||
},
|
||||
};
|
||||
|
|
@ -64,6 +65,7 @@ describe('createBackgroundCodeResultHandler generated-file preflight', () => {
|
|||
session_id: 'storage-session',
|
||||
codeApiBaseUrl: 'https://code-stateful.example.com',
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey: `stateful:${'a'.repeat(32)}`,
|
||||
preparedBuffer,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export interface CodeHarvestDeps {
|
|||
freshClaimAfter?: number;
|
||||
codeApiBaseUrl?: string;
|
||||
executionProfile?: CodeExecutionContext['executionProfile'];
|
||||
executionRouteKey?: string;
|
||||
preparedBuffer?: Buffer;
|
||||
downloadFallback?: boolean;
|
||||
}) => Promise<ProcessedCodeOutput | null>;
|
||||
|
|
@ -282,6 +283,7 @@ export function createBackgroundCodeResultHandler(deps: CodeHarvestDeps): CodeHa
|
|||
freshClaimAfter,
|
||||
codeApiBaseUrl: codeExecutionContext?.baseUrl,
|
||||
executionProfile: codeExecutionContext?.executionProfile,
|
||||
executionRouteKey: codeExecutionContext?.executionRouteKey,
|
||||
preparedBuffer,
|
||||
downloadFallback,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -973,6 +973,9 @@ export async function initializeAgent(
|
|||
const codeExecutionContext = resolveCodeExecutionContext({
|
||||
statefulSessions: effectiveStatefulSessions,
|
||||
environment: statefulCodeEnvironment,
|
||||
environmentId: agent.code_environment_id,
|
||||
environments:
|
||||
req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments,
|
||||
userId: requestFileOwnerId,
|
||||
agentId: agent.id,
|
||||
conversationId,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ type VersionedAgent = Pick<
|
|||
| 'skills_enabled'
|
||||
| 'stateful_code_sessions'
|
||||
| 'stateful_code_environment'
|
||||
| 'code_environment_id'
|
||||
| 'artifacts'
|
||||
| 'recursion_limit'
|
||||
| 'agent_ids'
|
||||
|
|
@ -89,6 +90,7 @@ export function selectLazySubagentConfig(agent: VersionedAgent): Omit<VersionedA
|
|||
skills_enabled,
|
||||
stateful_code_sessions,
|
||||
stateful_code_environment,
|
||||
code_environment_id,
|
||||
artifacts,
|
||||
recursion_limit,
|
||||
agent_ids,
|
||||
|
|
@ -118,6 +120,7 @@ export function selectLazySubagentConfig(agent: VersionedAgent): Omit<VersionedA
|
|||
skills_enabled,
|
||||
stateful_code_sessions,
|
||||
stateful_code_environment,
|
||||
code_environment_id,
|
||||
artifacts,
|
||||
recursion_limit,
|
||||
agent_ids,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { StatefulCodeEnvironment } from 'librechat-data-provider';
|
||||
import type { CodeExecutionContext } from './execution';
|
||||
import {
|
||||
markSandboxReady,
|
||||
maybePrewarmCodeSandbox,
|
||||
|
|
@ -12,6 +13,7 @@ interface TestAgent {
|
|||
id: string;
|
||||
statefulCodeSessions?: boolean;
|
||||
statefulCodeEnvironment?: StatefulCodeEnvironment;
|
||||
codeExecutionContext?: CodeExecutionContext;
|
||||
subagentAgentConfigs?: TestAgent[];
|
||||
lazySubagentConfigs?: TestAgent[];
|
||||
}
|
||||
|
|
@ -93,6 +95,28 @@ describe('maybePrewarmCodeSandbox', () => {
|
|||
await expect(shouldSignalSandboxStart('convo-1')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('does not prewarm an attached environment that leases a single worker', async () => {
|
||||
const attachedAgent: TestAgent = {
|
||||
id: 'agent_attached',
|
||||
statefulCodeSessions: true,
|
||||
codeExecutionContext: {
|
||||
baseUrl: 'http://attached-code.test/v1',
|
||||
codeSessionKey: 'execute_code:stateful:attached',
|
||||
executionProfile: 'stateful',
|
||||
runtimeSessionHint: 'v3:attached:conversation:abc',
|
||||
statefulSessions: true,
|
||||
environmentId: 'e2e-vm',
|
||||
environmentType: 'attached',
|
||||
},
|
||||
};
|
||||
|
||||
maybePrewarmCodeSandbox({ req, conversationId: 'convo-1', agents: agents(attachedAgent) });
|
||||
await flushAsync();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
await expect(shouldSignalSandboxStart('convo-1')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('walks subagent configs for the stateful gate', async () => {
|
||||
const parent = { id: 'agent_parent', subagentAgentConfigs: [statefulAgent] };
|
||||
maybePrewarmCodeSandbox({ req, conversationId: 'convo-1', agents: agents(parent) });
|
||||
|
|
@ -196,6 +220,41 @@ describe('maybePrewarmCodeSandbox', () => {
|
|||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prewarms a replacement route even when its runtime session hint is unchanged', async () => {
|
||||
const context = (executionRouteKey: string, baseUrl: string): TestAgent => ({
|
||||
id: `agent-${executionRouteKey}`,
|
||||
statefulCodeSessions: true,
|
||||
codeExecutionContext: {
|
||||
baseUrl,
|
||||
codeSessionKey: `execute_code:${executionRouteKey}`,
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey,
|
||||
runtimeSessionHint: 'v3:stable:user:scope',
|
||||
statefulSessions: true,
|
||||
environmentType: 'managed',
|
||||
},
|
||||
});
|
||||
|
||||
maybePrewarmCodeSandbox({
|
||||
req,
|
||||
conversationId: 'convo-1',
|
||||
agents: agents(context(`stateful:${'a'.repeat(32)}`, 'https://old-code.example.com')),
|
||||
});
|
||||
await flushAsync();
|
||||
maybePrewarmCodeSandbox({
|
||||
req,
|
||||
conversationId: 'convo-2',
|
||||
agents: agents(context(`stateful:${'b'.repeat(32)}`, 'https://new-code.example.com')),
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
'https://old-code.example.com/exec',
|
||||
'https://new-code.example.com/exec',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not refire while a prewarm is in flight', async () => {
|
||||
fetchMock.mockImplementation(() => new Promise(() => undefined));
|
||||
maybePrewarmCodeSandbox({ req, conversationId: 'convo-1', agents: agents(statefulAgent) });
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ type PrewarmAgent = {
|
|||
id: string;
|
||||
statefulCodeSessions?: boolean;
|
||||
statefulCodeEnvironment?: StatefulCodeEnvironment;
|
||||
codeExecutionContext?: CodeExecutionContext;
|
||||
subagentAgentConfigs?: PrewarmAgent[];
|
||||
lazySubagentConfigs?: PrewarmAgent[];
|
||||
};
|
||||
|
|
@ -73,14 +74,18 @@ function inflightKey(conversationId: string): string {
|
|||
* any in-flight prewarm marker. Callers on hot paths should not await this;
|
||||
* `void markSandboxReady(...)` is the expected usage.
|
||||
*/
|
||||
export async function markSandboxReady(conversationId: string): Promise<void> {
|
||||
export async function markSandboxReady(
|
||||
conversationId: string,
|
||||
executionRouteKey?: string,
|
||||
): Promise<void> {
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
const cacheId = executionRouteKey ? `${executionRouteKey}:${conversationId}` : conversationId;
|
||||
const cache = sandboxCache();
|
||||
await Promise.all([
|
||||
cache.set(readyKey(conversationId), true, coldAfterMs()),
|
||||
cache.delete(inflightKey(conversationId)),
|
||||
cache.set(readyKey(cacheId), true, coldAfterMs()),
|
||||
cache.delete(inflightKey(cacheId)),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +138,10 @@ async function sendPrewarmRequest(
|
|||
* Draining also releases the socket instead of leaving the body for
|
||||
* undici to reap. */
|
||||
await response.arrayBuffer();
|
||||
await markSandboxReady(context.runtimeSessionHint ?? '');
|
||||
await markSandboxReady(
|
||||
context.runtimeSessionHint ?? '',
|
||||
context.executionRouteKey ?? context.executionProfile,
|
||||
);
|
||||
logger.debug(`[prewarmCodeSandbox] Sandbox warm for ${context.runtimeSessionHint}`);
|
||||
}
|
||||
|
||||
|
|
@ -153,14 +161,18 @@ function collectPrewarmContexts(
|
|||
}
|
||||
visited.add(agent.id);
|
||||
if (agent.statefulCodeSessions === true) {
|
||||
const context = resolveCodeExecutionContext({
|
||||
statefulSessions: true,
|
||||
environment: agent.statefulCodeEnvironment,
|
||||
userId,
|
||||
agentId: agent.id,
|
||||
conversationId,
|
||||
});
|
||||
contexts.set(`${context.baseUrl}:${context.runtimeSessionHint}`, context);
|
||||
const context =
|
||||
agent.codeExecutionContext ??
|
||||
resolveCodeExecutionContext({
|
||||
statefulSessions: true,
|
||||
environment: agent.statefulCodeEnvironment,
|
||||
userId,
|
||||
agentId: agent.id,
|
||||
conversationId,
|
||||
});
|
||||
if (context.environmentType !== 'attached') {
|
||||
contexts.set(`${context.baseUrl}:${context.runtimeSessionHint}`, context);
|
||||
}
|
||||
}
|
||||
pending.push(...(agent.subagentAgentConfigs ?? []), ...(agent.lazySubagentConfigs ?? []));
|
||||
}
|
||||
|
|
@ -176,10 +188,11 @@ async function maybePrewarmContext(
|
|||
if (!runtimeSessionHint) {
|
||||
return true;
|
||||
}
|
||||
const runtimeCacheId = `${context.executionRouteKey ?? context.executionProfile}:${runtimeSessionHint}`;
|
||||
const cache = sandboxCache();
|
||||
const [ready, inflight] = await Promise.all([
|
||||
cache.get(readyKey(runtimeSessionHint)),
|
||||
cache.get(inflightKey(runtimeSessionHint)),
|
||||
cache.get(readyKey(runtimeCacheId)),
|
||||
cache.get(inflightKey(runtimeCacheId)),
|
||||
]);
|
||||
if (ready != null) {
|
||||
return true;
|
||||
|
|
@ -189,7 +202,7 @@ async function maybePrewarmContext(
|
|||
return false;
|
||||
}
|
||||
await Promise.all([
|
||||
cache.set(inflightKey(runtimeSessionHint), true, PREWARM_INFLIGHT_COOLDOWN_MS),
|
||||
cache.set(inflightKey(runtimeCacheId), true, PREWARM_INFLIGHT_COOLDOWN_MS),
|
||||
cache.set(inflightKey(conversationId), true, PREWARM_INFLIGHT_COOLDOWN_MS),
|
||||
]);
|
||||
await sendPrewarmRequest(req, context);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import type { ToolInputValidationError } from '~/agents/toolValidation';
|
|||
import type { ResolvedToolApprovalHook } from '~/agents/hitl/hooks';
|
||||
import type { TerminalSteerHook } from '~/agents/steering/runtime';
|
||||
import type { ResolvedAlwaysApplySkill } from '~/agents/skills';
|
||||
import type { CodeExecutionContext } from '~/agents/execution';
|
||||
import type { MCPToolAlias } from '~/tools/classification';
|
||||
import type { SubagentUsageEvent } from '~/agents/usage';
|
||||
import type * as t from '~/types';
|
||||
|
|
@ -423,6 +424,8 @@ type RunAgent = Omit<Agent, 'tools'> & {
|
|||
statefulCodeEnvironment?: Agent['stateful_code_environment'];
|
||||
/** Trusted partition for transient code session ids and file references. */
|
||||
codeSessionKey?: string;
|
||||
/** Trusted Code API route selected during initialization. */
|
||||
codeExecutionContext?: CodeExecutionContext;
|
||||
/** Optional per-agent summarization overrides */
|
||||
summarization?: SummarizationConfig;
|
||||
/** Response field to read model reasoning from for custom OpenAI-compatible endpoints. */
|
||||
|
|
@ -466,6 +469,7 @@ type LazySubagentAgent = Pick<
|
|||
| 'codeEnvAvailable'
|
||||
| 'statefulCodeSessions'
|
||||
| 'statefulCodeEnvironment'
|
||||
| 'codeExecutionContext'
|
||||
| 'codeSessionKey'
|
||||
| 'includeReasoningHistory'
|
||||
| 'mcpToolAliases'
|
||||
|
|
@ -487,6 +491,7 @@ type SubagentTreeNode = Pick<
|
|||
| 'codeEnvAvailable'
|
||||
| 'statefulCodeSessions'
|
||||
| 'statefulCodeEnvironment'
|
||||
| 'codeExecutionContext'
|
||||
| 'codeSessionKey'
|
||||
| 'includeReasoningHistory'
|
||||
| 'mcpToolAliases'
|
||||
|
|
|
|||
|
|
@ -550,7 +550,7 @@ describe('primeInvokedSkillsForProfiles', () => {
|
|||
mockExtract.mockReturnValue(new Set(['brand-guidelines']));
|
||||
});
|
||||
|
||||
it('uploads and seeds historical skill files separately for default and stateful profiles', async () => {
|
||||
it('uploads and seeds historical skill files separately for every Code API deployment', async () => {
|
||||
const listSkillFiles = jest.fn().mockResolvedValue([
|
||||
{
|
||||
relativePath: 'references/style.md',
|
||||
|
|
@ -563,15 +563,17 @@ describe('primeInvokedSkillsForProfiles', () => {
|
|||
const getStrategyFunctions = jest.fn().mockReturnValue({
|
||||
getDownloadStream: jest.fn().mockResolvedValue(Readable.from(Buffer.from('style'))),
|
||||
});
|
||||
const batchUploadCodeEnvFiles = jest.fn().mockImplementation(({ executionProfile }) => ({
|
||||
storage_session_id: `${executionProfile}-session`,
|
||||
files: [
|
||||
{
|
||||
fileId: `${executionProfile}-file`,
|
||||
filename: 'skills/brand-guidelines/references/style.md',
|
||||
},
|
||||
],
|
||||
}));
|
||||
const batchUploadCodeEnvFiles = jest
|
||||
.fn()
|
||||
.mockImplementation(({ executionProfile, codeApiBaseUrl }) => ({
|
||||
storage_session_id: `${executionProfile}-${new URL(codeApiBaseUrl).hostname}-session`,
|
||||
files: [
|
||||
{
|
||||
fileId: `${executionProfile}-${new URL(codeApiBaseUrl).hostname}-file`,
|
||||
filename: 'skills/brand-guidelines/references/style.md',
|
||||
},
|
||||
],
|
||||
}));
|
||||
const updateSkillFileCodeEnvIds = jest.fn().mockResolvedValue({
|
||||
matchedCount: 1,
|
||||
modifiedCount: 1,
|
||||
|
|
@ -587,6 +589,7 @@ describe('primeInvokedSkillsForProfiles', () => {
|
|||
updateSkillFileCodeEnvIds,
|
||||
});
|
||||
const statefulKey = 'execute_code:stateful:v2:user:abc';
|
||||
const secondStatefulKey = 'execute_code:stateful:v2:user:def';
|
||||
const deps: PrimeInvokedSkillsForProfilesDeps = {
|
||||
...baseDeps,
|
||||
executionProfiles: [
|
||||
|
|
@ -604,32 +607,53 @@ describe('primeInvokedSkillsForProfiles', () => {
|
|||
baseUrl: 'https://stateful.example.com/v1',
|
||||
codeSessionKey: statefulKey,
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey: 'stateful:first',
|
||||
runtimeSessionHint: 'v2:user:abc',
|
||||
statefulSessions: true,
|
||||
},
|
||||
codeSessionKeys: [statefulKey],
|
||||
},
|
||||
{
|
||||
codeExecutionContext: {
|
||||
baseUrl: 'https://second-stateful.example.com/v1',
|
||||
codeSessionKey: secondStatefulKey,
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey: 'stateful:second',
|
||||
runtimeSessionHint: 'v2:user:def',
|
||||
statefulSessions: true,
|
||||
},
|
||||
codeSessionKeys: [secondStatefulKey],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await primeInvokedSkillsForProfiles(deps);
|
||||
|
||||
expect(batchUploadCodeEnvFiles).toHaveBeenCalledTimes(2);
|
||||
expect(batchUploadCodeEnvFiles).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
batchUploadCodeEnvFiles.mock.calls.map(([args]) => args.executionProfile).sort(),
|
||||
).toEqual(['default', 'stateful']);
|
||||
).toEqual(['default', 'stateful', 'stateful']);
|
||||
expect(result.initialSessions?.get('execute_code')?.files?.map((file) => file.id)).toEqual([
|
||||
'default-file',
|
||||
'default-code.example.com-file',
|
||||
]);
|
||||
expect(result.initialSessions?.get(statefulKey)?.files?.map((file) => file.id)).toEqual([
|
||||
'stateful-file',
|
||||
'stateful-stateful.example.com-file',
|
||||
]);
|
||||
expect(updateSkillFileCodeEnvIds).toHaveBeenCalledTimes(2);
|
||||
expect(result.initialSessions?.get(secondStatefulKey)?.files?.map((file) => file.id)).toEqual([
|
||||
'stateful-second-stateful.example.com-file',
|
||||
]);
|
||||
expect(updateSkillFileCodeEnvIds).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
updateSkillFileCodeEnvIds.mock.calls
|
||||
.map(([updates]) => updates[0].codeEnvRef.executionProfile)
|
||||
.sort(),
|
||||
).toEqual(['default', 'stateful']);
|
||||
).toEqual(['default', 'stateful', 'stateful']);
|
||||
expect(
|
||||
updateSkillFileCodeEnvIds.mock.calls
|
||||
.map(([updates]) => updates[0].codeEnvRef.executionRouteKey)
|
||||
.filter(Boolean)
|
||||
.sort(),
|
||||
).toEqual(['stateful:first', 'stateful:second']);
|
||||
});
|
||||
|
||||
it('keeps a successful profile Skill body and identity after another profile lookup fails', async () => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
} from 'librechat-data-provider';
|
||||
import type { CodeEnvFile, ToolSessionMap, CodeSessionContext } from '@librechat/agents';
|
||||
import type { Types } from 'mongoose';
|
||||
import type { CodeExecutionContext } from './execution';
|
||||
import type { ServerRequest } from '~/types';
|
||||
import {
|
||||
extractFileContent,
|
||||
|
|
@ -22,6 +21,7 @@ import {
|
|||
} from '~/protection';
|
||||
import { seedCodeFilesIntoSessions, type CodeExecutionProfileRoute } from './codeFilesSession';
|
||||
import { ContentFilterError, isContentFilterError } from '~/middleware/contentFilter';
|
||||
import { getCodeExecutionRouteKey, type CodeExecutionContext } from './execution';
|
||||
import { createConcurrencyLimiter, getSafeErrorMetadata } from '~/utils';
|
||||
import { assertSkillFileContentAllowed } from '~/skills/protection';
|
||||
import { createSkillContentDigest } from './compatibility';
|
||||
|
|
@ -89,7 +89,10 @@ export interface PrimeSkillFilesParams {
|
|||
},
|
||||
) => Promise<string | null>;
|
||||
/** Trusted Code API route selected for the executing agent. */
|
||||
codeExecutionContext?: Pick<CodeExecutionContext, 'baseUrl' | 'executionProfile'>;
|
||||
codeExecutionContext?: Pick<
|
||||
CodeExecutionContext,
|
||||
'baseUrl' | 'executionProfile' | 'executionRouteKey'
|
||||
>;
|
||||
/** 23-hour freshness check */
|
||||
checkIfActive?: (dateString: string) => boolean;
|
||||
/** Persists `codeEnvRef` on skill files after upload. Implementations
|
||||
|
|
@ -344,7 +347,10 @@ export async function primeSkillFiles(
|
|||
* resource-scoped (`<tenant>:skill:<id>:v:<version>`), so sharing the
|
||||
* result across requests is sound. Per-process best-effort; the awaited
|
||||
* codeEnvRef persist covers cross-turn and cross-node dedupe. */
|
||||
const flightKey = `${params.codeExecutionContext?.executionProfile ?? 'default'}:${params.skill._id}:v:${params.skill.version}`;
|
||||
const executionRouteKey = params.codeExecutionContext
|
||||
? getCodeExecutionRouteKey(params.codeExecutionContext)
|
||||
: 'default';
|
||||
const flightKey = `${executionRouteKey}:${params.skill._id}:v:${params.skill.version}`;
|
||||
const inflight = inflightPrimes.get(flightKey);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
|
|
@ -371,6 +377,9 @@ async function executePrimeSkillFiles(
|
|||
codeExecutionContext,
|
||||
} = params;
|
||||
const executionProfile = codeExecutionContext?.executionProfile ?? 'default';
|
||||
const executionRouteKey = codeExecutionContext
|
||||
? getCodeExecutionRouteKey(codeExecutionContext)
|
||||
: executionProfile;
|
||||
const inspectStoredMetadata = shouldInspectStoredSkillFileMetadata(req);
|
||||
const inspectBundledFileContent = shouldInspectStoredSkillFileContent(req);
|
||||
const inspectedBuffers = new Map<SkillFileRecord, Buffer>();
|
||||
|
|
@ -422,12 +431,12 @@ async function executePrimeSkillFiles(
|
|||
* upload on the next prime. */
|
||||
if (getSessionInfo && checkIfActive && skillFiles.length > 0) {
|
||||
const allHaveRefs = skillFiles.every(
|
||||
(sf) => getCodeEnvRefForProfile(sf, executionProfile) !== undefined,
|
||||
(sf) => getCodeEnvRefForProfile(sf, executionRouteKey) !== undefined,
|
||||
);
|
||||
if (allHaveRefs) {
|
||||
const refsBySession = new Map<string, CodeEnvRef>();
|
||||
for (const sf of skillFiles) {
|
||||
const ref = getCodeEnvRefForProfile(sf, executionProfile);
|
||||
const ref = getCodeEnvRefForProfile(sf, executionRouteKey);
|
||||
if (ref && !refsBySession.has(ref.storage_session_id)) {
|
||||
refsBySession.set(ref.storage_session_id, ref);
|
||||
}
|
||||
|
|
@ -445,7 +454,7 @@ async function executePrimeSkillFiles(
|
|||
if (allActive) {
|
||||
const files: PrimeSkillFilesResult['files'] = [];
|
||||
for (const sf of skillFiles) {
|
||||
const ref = getCodeEnvRefForProfile(sf, executionProfile);
|
||||
const ref = getCodeEnvRefForProfile(sf, executionRouteKey);
|
||||
if (!ref) continue;
|
||||
/* Cache-hit refs already carry resource identity (kind / id /
|
||||
* version) — pull them through so the artifact emitted by
|
||||
|
|
@ -574,6 +583,7 @@ async function executePrimeSkillFiles(
|
|||
file_id: f.fileId,
|
||||
version: skill.version,
|
||||
executionProfile,
|
||||
...(executionRouteKey !== executionProfile ? { executionRouteKey } : {}),
|
||||
};
|
||||
return {
|
||||
skillId: skill._id,
|
||||
|
|
@ -737,12 +747,15 @@ export async function primeInvokedSkills(
|
|||
// ALL distinct sessions for freshness. If all are active, return cached
|
||||
// references with zero re-uploads. If any expired, re-upload everything.
|
||||
const executionProfile = deps.codeExecutionContext?.executionProfile ?? 'default';
|
||||
const executionRouteKey = deps.codeExecutionContext
|
||||
? getCodeExecutionRouteKey(deps.codeExecutionContext)
|
||||
: executionProfile;
|
||||
if (!inspectStoredSkillFileContent && deps.getSessionInfo && deps.checkIfActive) {
|
||||
const allResolved = fileListResults.flatMap((r) =>
|
||||
r.files.map((f) => ({
|
||||
skillName: r.skill.name,
|
||||
file: f,
|
||||
ref: getCodeEnvRefForProfile(f, executionProfile),
|
||||
ref: getCodeEnvRefForProfile(f, executionRouteKey),
|
||||
})),
|
||||
);
|
||||
const resolvedWithRef = allResolved.filter((x) => x.ref !== undefined);
|
||||
|
|
|
|||
|
|
@ -400,6 +400,8 @@ export const agentSubagentsSchema: z.ZodOptional<z.ZodType<AgentSubagentsConfig>
|
|||
.optional();
|
||||
|
||||
/** Base agent schema with all common fields */
|
||||
const agentCodeEnvironmentIdSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/);
|
||||
|
||||
export const agentBaseSchema: z.ZodObject<
|
||||
{
|
||||
name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
|
|
@ -486,6 +488,7 @@ export const agentBaseSchema: z.ZodObject<
|
|||
hide_sequential_outputs: z.ZodOptional<z.ZodBoolean>;
|
||||
stateful_code_sessions: z.ZodOptional<z.ZodBoolean>;
|
||||
stateful_code_environment: z.ZodOptional<z.ZodEnum<['user', 'agent-user', 'conversation']>>;
|
||||
code_environment_id: z.ZodOptional<z.ZodString>;
|
||||
artifacts: z.ZodOptional<z.ZodString>;
|
||||
recursion_limit: z.ZodOptional<z.ZodNumber>;
|
||||
conversation_starters: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
|
||||
|
|
@ -558,6 +561,7 @@ export const agentBaseSchema: z.ZodObject<
|
|||
hide_sequential_outputs: z.boolean().optional(),
|
||||
stateful_code_sessions: z.boolean().optional(),
|
||||
stateful_code_environment: z.enum(['user', 'agent-user', 'conversation']).optional(),
|
||||
code_environment_id: agentCodeEnvironmentIdSchema.optional(),
|
||||
artifacts: z.string().optional(),
|
||||
recursion_limit: z.number().optional(),
|
||||
conversation_starters: z.array(z.string()).optional(),
|
||||
|
|
@ -653,6 +657,7 @@ export const agentCreateSchema: z.ZodObject<
|
|||
hide_sequential_outputs: z.ZodOptional<z.ZodBoolean>;
|
||||
stateful_code_sessions: z.ZodOptional<z.ZodBoolean>;
|
||||
stateful_code_environment: z.ZodOptional<z.ZodEnum<['user', 'agent-user', 'conversation']>>;
|
||||
code_environment_id: z.ZodOptional<z.ZodString>;
|
||||
artifacts: z.ZodOptional<z.ZodString>;
|
||||
recursion_limit: z.ZodOptional<z.ZodNumber>;
|
||||
conversation_starters: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
|
||||
|
|
@ -784,6 +789,7 @@ export const agentUpdateSchema: z.ZodObject<
|
|||
hide_sequential_outputs: z.ZodOptional<z.ZodBoolean>;
|
||||
stateful_code_sessions: z.ZodOptional<z.ZodBoolean>;
|
||||
stateful_code_environment: z.ZodOptional<z.ZodEnum<['user', 'agent-user', 'conversation']>>;
|
||||
code_environment_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
artifacts: z.ZodOptional<z.ZodString>;
|
||||
recursion_limit: z.ZodOptional<z.ZodNumber>;
|
||||
conversation_starters: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
|
||||
|
|
@ -867,6 +873,7 @@ export const agentUpdateSchema: z.ZodObject<
|
|||
'strip'
|
||||
> = agentBaseSchema.extend({
|
||||
avatar: z.union([agentAvatarSchema, z.null()]).optional(),
|
||||
code_environment_id: agentCodeEnvironmentIdSchema.nullable().optional(),
|
||||
provider: z.string().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -186,7 +186,18 @@ describe('createEndpointsConfigService', () => {
|
|||
appConfig({
|
||||
endpoints: {
|
||||
[EModelEndpoint.agents]: {
|
||||
statefulCodeSessions: { allowedEnvironments: ['user', 'agent-user'] },
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['user', 'agent-user'],
|
||||
environments: [
|
||||
{
|
||||
id: 'attached-vm',
|
||||
name: 'Attached VM',
|
||||
type: 'attached',
|
||||
baseURL: 'https://internal-code.example.com/v1',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -198,6 +209,14 @@ describe('createEndpointsConfigService', () => {
|
|||
|
||||
expect(result?.[EModelEndpoint.agents]?.statefulCodeSessions).toEqual({
|
||||
allowedEnvironments: ['user', 'agent-user'],
|
||||
environments: [
|
||||
{
|
||||
id: 'attached-vm',
|
||||
name: 'Attached VM',
|
||||
type: 'attached',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -72,12 +72,25 @@ export function createEndpointsConfigService(deps: EndpointsConfigDeps): {
|
|||
if (mergedConfig[EModelEndpoint.agents] && appConfig?.endpoints?.[EModelEndpoint.agents]) {
|
||||
const { disableBuilder, capabilities, allowedProviders, statefulCodeSessions, maxSubagents } =
|
||||
appConfig.endpoints[EModelEndpoint.agents];
|
||||
const clientStatefulCodeSessions = statefulCodeSessions
|
||||
? {
|
||||
allowedEnvironments: statefulCodeSessions.allowedEnvironments,
|
||||
environments: statefulCodeSessions.environments?.map(
|
||||
({ id, name, type, default: isDefault }) => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
default: isDefault,
|
||||
}),
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
mergedConfig[EModelEndpoint.agents] = {
|
||||
...mergedConfig[EModelEndpoint.agents],
|
||||
allowedProviders,
|
||||
disableBuilder,
|
||||
capabilities,
|
||||
statefulCodeSessions,
|
||||
statefulCodeSessions: clientStatefulCodeSessions,
|
||||
maxSubagents,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -449,6 +449,99 @@ describe('agentsEndpointSchema', () => {
|
|||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts uniquely named execution environments with exactly one default', () => {
|
||||
const result = agentsEndpointSchema.safeParse({
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['conversation'],
|
||||
environments: [
|
||||
{
|
||||
id: 'managed',
|
||||
name: 'Managed',
|
||||
type: 'managed',
|
||||
baseURL: 'https://code.example.com/v1',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: 'attached-vm',
|
||||
name: 'Attached VM',
|
||||
type: 'attached',
|
||||
baseURL: 'https://bridge.example.com/v1',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'ftp://code.example.com/v1',
|
||||
'https://code.example.com/v1?',
|
||||
'https://code.example.com/v1?token=secret',
|
||||
'https://code.example.com/v1#',
|
||||
'https://code.example.com/v1#fragment',
|
||||
])('rejects a non-base execution environment URL: %s', (baseURL) => {
|
||||
const result = agentsEndpointSchema.safeParse({
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['conversation'],
|
||||
environments: [
|
||||
{
|
||||
id: 'attached-vm',
|
||||
name: 'Attached VM',
|
||||
type: 'attached',
|
||||
baseURL,
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes surrounding whitespace in an execution environment URL', () => {
|
||||
const result = agentsEndpointSchema.safeParse({
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['conversation'],
|
||||
environments: [
|
||||
{
|
||||
id: 'attached-vm',
|
||||
name: 'Attached VM',
|
||||
type: 'attached',
|
||||
baseURL: ' https://bridge.example.com/v1/ ',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.statefulCodeSessions?.environments?.[0]?.baseURL).toBe(
|
||||
'https://bridge.example.com/v1/',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects ambiguous execution environment routing', () => {
|
||||
const environment = {
|
||||
id: 'attached-vm',
|
||||
name: 'Attached VM',
|
||||
type: 'attached',
|
||||
baseURL: 'https://bridge.example.com/v1',
|
||||
} as const;
|
||||
const parse = (environments: unknown[]) =>
|
||||
agentsEndpointSchema.safeParse({
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['conversation'],
|
||||
environments,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parse([environment]).success).toBe(false);
|
||||
expect(parse([{ ...environment, default: true }, { ...environment }]).success).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults maxSubagents to MAX_SUBAGENTS and validates its bounds', () => {
|
||||
const omitted = agentsEndpointSchema.safeParse({});
|
||||
expect(omitted.success).toBe(true);
|
||||
|
|
|
|||
|
|
@ -88,4 +88,28 @@ describe('CodeEnvRef', () => {
|
|||
expect(getCodeEnvRefForProfile({ codeEnvRef: legacy }, 'default')).toBe(legacy);
|
||||
expect(getCodeEnvRefForProfile({ codeEnvRef: legacy }, 'stateful')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retains independent pointers for configured environments sharing one profile', () => {
|
||||
const first: CodeEnvRef = {
|
||||
kind: 'skill',
|
||||
id: 'skill-1',
|
||||
version: 1,
|
||||
storage_session_id: 'first-session',
|
||||
file_id: 'first-file',
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey: 'stateful:first',
|
||||
};
|
||||
const second: CodeEnvRef = {
|
||||
...first,
|
||||
storage_session_id: 'second-session',
|
||||
file_id: 'second-file',
|
||||
executionRouteKey: 'stateful:second',
|
||||
};
|
||||
|
||||
const refs = mergeCodeEnvRef(mergeCodeEnvRef(undefined, first), second);
|
||||
|
||||
expect(getCodeEnvRefForProfile(refs, 'stateful:first')).toBe(first);
|
||||
expect(getCodeEnvRefForProfile(refs, 'stateful:second')).toBe(second);
|
||||
expect(Object.keys(refs.codeEnvRefs)).toEqual(['stateful:first', 'stateful:second']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ interface CodeEnvRefBase {
|
|||
/** Code API deployment that owns this storage pointer. Legacy refs omit
|
||||
* the field and are treated as `default`; new writes always persist it. */
|
||||
executionProfile?: CodeExecutionProfile;
|
||||
/** Deployment-specific namespace. Configured stateful environments share
|
||||
* the same wire profile, so this key prevents their file IDs from being
|
||||
* reused against a different Code API server. */
|
||||
executionRouteKey?: string;
|
||||
}
|
||||
|
||||
export type CodeExecutionProfile = 'default' | 'stateful';
|
||||
|
|
@ -64,7 +68,7 @@ export type CodeEnvRef =
|
|||
| (CodeEnvRefBase & { kind: 'user' });
|
||||
|
||||
/** Deployment-local pointers for a resource that may be used by both Code API profiles. */
|
||||
export type CodeEnvRefMap = Partial<Record<CodeExecutionProfile, CodeEnvRef>>;
|
||||
export type CodeEnvRefMap = Partial<Record<string, CodeEnvRef>>;
|
||||
|
||||
export interface CodeEnvReferenceSet {
|
||||
/** Compatibility pointer for readers that have not adopted profile-aware refs yet. */
|
||||
|
|
@ -75,14 +79,17 @@ export interface CodeEnvReferenceSet {
|
|||
|
||||
export function getCodeEnvRefForProfile(
|
||||
refs: CodeEnvReferenceSet | null | undefined,
|
||||
profile: CodeExecutionProfile,
|
||||
routeKey: string,
|
||||
): CodeEnvRef | undefined {
|
||||
const profileRef = refs?.codeEnvRefs?.[profile];
|
||||
const profileRef = refs?.codeEnvRefs?.[routeKey];
|
||||
if (profileRef) {
|
||||
return profileRef;
|
||||
}
|
||||
const legacyRef = refs?.codeEnvRef;
|
||||
if (legacyRef && (legacyRef.executionProfile ?? 'default') === profile) {
|
||||
if (
|
||||
legacyRef &&
|
||||
(legacyRef.executionRouteKey ?? legacyRef.executionProfile ?? 'default') === routeKey
|
||||
) {
|
||||
return legacyRef;
|
||||
}
|
||||
return undefined;
|
||||
|
|
@ -96,12 +103,13 @@ export function mergeCodeEnvRef(
|
|||
const codeEnvRefs: CodeEnvRefMap = { ...refs?.codeEnvRefs };
|
||||
const legacyRef = refs?.codeEnvRef;
|
||||
if (legacyRef) {
|
||||
codeEnvRefs[legacyRef.executionProfile ?? 'default'] ??= legacyRef;
|
||||
codeEnvRefs[legacyRef.executionRouteKey ?? legacyRef.executionProfile ?? 'default'] ??=
|
||||
legacyRef;
|
||||
}
|
||||
const profile = ref.executionProfile ?? 'default';
|
||||
codeEnvRefs[profile] = ref;
|
||||
const routeKey = ref.executionRouteKey ?? ref.executionProfile ?? 'default';
|
||||
codeEnvRefs[routeKey] = ref;
|
||||
return {
|
||||
codeEnvRef: codeEnvRefs.default ?? codeEnvRefs.stateful!,
|
||||
codeEnvRef: codeEnvRefs.default ?? codeEnvRefs.stateful ?? ref,
|
||||
codeEnvRefs,
|
||||
};
|
||||
}
|
||||
|
|
@ -109,14 +117,13 @@ export function mergeCodeEnvRef(
|
|||
/** Enumerates every deployment-local pointer, including legacy single-pointer records. */
|
||||
export function getCodeEnvRefs(
|
||||
refs: CodeEnvReferenceSet | null | undefined,
|
||||
): Array<[CodeExecutionProfile, CodeEnvRef]> {
|
||||
): Array<[string, CodeEnvRef]> {
|
||||
const merged: CodeEnvRefMap = { ...refs?.codeEnvRefs };
|
||||
const legacyRef = refs?.codeEnvRef;
|
||||
if (legacyRef) {
|
||||
merged[legacyRef.executionProfile ?? 'default'] ??= legacyRef;
|
||||
merged[legacyRef.executionRouteKey ?? legacyRef.executionProfile ?? 'default'] ??= legacyRef;
|
||||
}
|
||||
return (['default', 'stateful'] as const).flatMap((profile) => {
|
||||
const ref = merged[profile];
|
||||
return ref ? [[profile, ref] as [CodeExecutionProfile, CodeEnvRef]] : [];
|
||||
});
|
||||
return Object.entries(merged).flatMap(([routeKey, ref]) =>
|
||||
ref ? [[routeKey, ref] as [string, CodeEnvRef]] : [],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1001,6 +1001,28 @@ export const checkpointerSchema = z
|
|||
|
||||
export type TCheckpointerConfig = z.infer<typeof checkpointerSchema>;
|
||||
|
||||
const codeEnvironmentBaseURLSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.url()
|
||||
.refine(
|
||||
(value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
!value.includes('?') &&
|
||||
!value.includes('#') &&
|
||||
url.search.length === 0 &&
|
||||
url.hash.length === 0
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: 'Code environment baseURL must be an HTTP(S) base URL without query or fragment' },
|
||||
);
|
||||
|
||||
export const agentsEndpointSchema = baseEndpointSchema
|
||||
.omit({ baseURL: true })
|
||||
.merge(
|
||||
|
|
@ -1041,6 +1063,42 @@ export const agentsEndpointSchema = baseEndpointSchema
|
|||
statefulCodeSessions: z
|
||||
.object({
|
||||
allowedEnvironments: z.array(z.enum(STATEFUL_CODE_ENVIRONMENTS)).min(1),
|
||||
/** Operator-managed execution environments. Attached entries route to a
|
||||
* Code API deployment backed by an outbound librechat-code worker. */
|
||||
environments: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/),
|
||||
name: z.string().min(1).max(100),
|
||||
type: z.enum(['managed', 'attached']),
|
||||
baseURL: codeEnvironmentBaseURLSchema,
|
||||
default: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (!value?.environments) return;
|
||||
const ids = new Set<string>();
|
||||
let defaults = 0;
|
||||
for (const environment of value.environments) {
|
||||
if (ids.has(environment.id)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate code environment id: ${environment.id}`,
|
||||
path: ['environments'],
|
||||
});
|
||||
}
|
||||
ids.add(environment.id);
|
||||
if (environment.default === true) defaults += 1;
|
||||
}
|
||||
if (value.environments.length > 0 && defaults !== 1) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Exactly one stateful code environment must be the default',
|
||||
path: ['environments'],
|
||||
});
|
||||
}
|
||||
})
|
||||
.optional(),
|
||||
/** Optional trusted origin for in-process agent event delivery. */
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@ export const defaultAgentFormValues = {
|
|||
[Tools.web_search]: false,
|
||||
[Tools.memory]: false,
|
||||
stateful_code_environment: 'user' as const,
|
||||
code_environment_id: undefined as string | null | undefined,
|
||||
category: 'general',
|
||||
support_contact: {
|
||||
name: '',
|
||||
|
|
|
|||
|
|
@ -526,6 +526,14 @@ export type TSearchResults = {
|
|||
filter: object;
|
||||
};
|
||||
|
||||
export type TPublicCodeEnvironment = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'managed' | 'attached';
|
||||
default?: boolean;
|
||||
pairingAvailable?: boolean;
|
||||
};
|
||||
|
||||
export type TConfig = {
|
||||
order: number;
|
||||
type?: EModelEndpoint;
|
||||
|
|
@ -551,6 +559,7 @@ export type TConfig = {
|
|||
capabilities?: string[];
|
||||
statefulCodeSessions?: {
|
||||
allowedEnvironments: StatefulCodeEnvironment[];
|
||||
environments?: TPublicCodeEnvironment[];
|
||||
};
|
||||
/** Effective subagents-per-agent cap served from `endpoints.agents.maxSubagents`. */
|
||||
maxSubagents?: number;
|
||||
|
|
|
|||
|
|
@ -337,6 +337,8 @@ export type Agent = {
|
|||
stateful_code_sessions?: boolean;
|
||||
/** Stateful workspace sharing scope. Defaults to one workspace per user. */
|
||||
stateful_code_environment?: StatefulCodeEnvironment;
|
||||
/** Operator-configured managed or attached stateful execution environment. */
|
||||
code_environment_id?: string | null;
|
||||
artifacts?: ArtifactModes;
|
||||
recursion_limit?: number;
|
||||
isPublic?: boolean;
|
||||
|
|
@ -392,6 +394,7 @@ export type AgentCreateParams = {
|
|||
| 'hide_sequential_outputs'
|
||||
| 'stateful_code_sessions'
|
||||
| 'stateful_code_environment'
|
||||
| 'code_environment_id'
|
||||
| 'artifacts'
|
||||
| 'recursion_limit'
|
||||
| 'category'
|
||||
|
|
@ -422,6 +425,7 @@ export type AgentUpdateParams = {
|
|||
| 'hide_sequential_outputs'
|
||||
| 'stateful_code_sessions'
|
||||
| 'stateful_code_environment'
|
||||
| 'code_environment_id'
|
||||
| 'artifacts'
|
||||
| 'recursion_limit'
|
||||
| 'category'
|
||||
|
|
|
|||
|
|
@ -2034,6 +2034,23 @@ describe('Agent Methods', () => {
|
|||
expect(thirdUpdate!.versions).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('should unset a field and omit it from the recorded version', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
code_environment_id: 'attached-vm',
|
||||
});
|
||||
|
||||
const updated = await updateAgent({ id: agentId }, { $unset: { code_environment_id: 1 } });
|
||||
|
||||
expect(updated!.code_environment_id).toBeUndefined();
|
||||
expect(updated!.versions).toHaveLength(2);
|
||||
expect(updated!.versions![1].code_environment_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should handle parameter objects correctly', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
|
|
@ -2529,6 +2546,24 @@ describe('Agent Methods', () => {
|
|||
expect(revertedAgent.description).toBe('Original description');
|
||||
});
|
||||
|
||||
test('should clear an explicit code environment when the restored version used the default', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Default Environment Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
});
|
||||
await updateAgent({ id: agentId }, { code_environment_id: 'replacement-environment' });
|
||||
|
||||
const revertedAgent = await revertAgentVersion({ id: agentId }, 0);
|
||||
|
||||
expect(revertedAgent.code_environment_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should prune deleted skill ids when reverting to an older version', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
|
|
|
|||
|
|
@ -203,6 +203,20 @@ function resolveDocumentPath(source: Record<string, unknown>, path: string): unk
|
|||
return current;
|
||||
}
|
||||
|
||||
/** Removes a dotted operator path from an in-memory version projection. */
|
||||
function deleteDocumentPath(source: Record<string, unknown>, path: string): void {
|
||||
const segments = path.split('.');
|
||||
const leaf = segments.pop();
|
||||
if (leaf == null) return;
|
||||
let current: Record<string, unknown> = source;
|
||||
for (const segment of segments) {
|
||||
const next = current[segment];
|
||||
if (typeof next !== 'object' || next === null || next instanceof Map) return;
|
||||
current = next as Record<string, unknown>;
|
||||
}
|
||||
delete current[leaf];
|
||||
}
|
||||
|
||||
/** The values an `$addToSet` specification would add, flattening the `$each` form. */
|
||||
function addToSetCandidates(spec: unknown): unknown[] {
|
||||
if (
|
||||
|
|
@ -228,11 +242,18 @@ function operatorsMutateDocument(
|
|||
$push: unknown,
|
||||
$pull: unknown,
|
||||
$addToSet: unknown,
|
||||
$unset: unknown,
|
||||
): boolean {
|
||||
if (hasOperatorKeys($push) || hasOperatorKeys($pull)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasOperatorKeys($unset)) {
|
||||
for (const path of Object.keys($unset as Record<string, unknown>)) {
|
||||
if (resolveDocumentPath(currentObject, path) !== undefined) return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasOperatorKeys($addToSet)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -280,13 +301,24 @@ function isDuplicateVersion(
|
|||
'actionsHash',
|
||||
];
|
||||
|
||||
const { $push: _$push, $pull: _$pull, $addToSet: _$addToSet, ...directUpdates } = updateData;
|
||||
const {
|
||||
$push: _$push,
|
||||
$pull: _$pull,
|
||||
$addToSet: _$addToSet,
|
||||
$unset,
|
||||
...directUpdates
|
||||
} = updateData;
|
||||
|
||||
if (Object.keys(directUpdates).length === 0 && !actionsHash) {
|
||||
if (Object.keys(directUpdates).length === 0 && !hasOperatorKeys($unset) && !actionsHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wouldBeVersion = { ...currentData, ...directUpdates } as Record<string, unknown>;
|
||||
if (hasOperatorKeys($unset)) {
|
||||
for (const path of Object.keys($unset as Record<string, unknown>)) {
|
||||
deleteDocumentPath(wouldBeVersion, path);
|
||||
}
|
||||
}
|
||||
const lastVersion = versions[versions.length - 1] as Record<string, unknown>;
|
||||
|
||||
if (actionsHash && lastVersion.actionsHash !== actionsHash) {
|
||||
|
|
@ -656,7 +688,7 @@ export function createAgentMethods(
|
|||
if (currentAgent) {
|
||||
const currentObject = currentAgent.toObject() as unknown as Record<string, unknown>;
|
||||
const { __v, _id, id: __id, versions, author: _author, ...versionData } = currentObject;
|
||||
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||
const { $push, $pull, $addToSet, $unset, ...directUpdates } = updateData;
|
||||
|
||||
/** Self-heal: drop allowlist ids whose skill no longer exists in the
|
||||
* database or the external registry.
|
||||
|
|
@ -725,7 +757,12 @@ export function createAgentMethods(
|
|||
|
||||
const shouldCreateVersion =
|
||||
!skipVersioning &&
|
||||
(forceVersion || Object.keys(directUpdates).length > 0 || $push || $pull || $addToSet);
|
||||
(forceVersion ||
|
||||
Object.keys(directUpdates).length > 0 ||
|
||||
$push ||
|
||||
$pull ||
|
||||
$addToSet ||
|
||||
$unset);
|
||||
|
||||
if (shouldCreateVersion) {
|
||||
const duplicateVersion = isDuplicateVersion(
|
||||
|
|
@ -746,6 +783,7 @@ export function createAgentMethods(
|
|||
$push,
|
||||
$pull,
|
||||
$addToSet,
|
||||
$unset,
|
||||
);
|
||||
if (duplicateVersion && !forceVersion && !mutatesOutsideSnapshot) {
|
||||
suppressedVersionEntry = true;
|
||||
|
|
@ -759,6 +797,7 @@ export function createAgentMethods(
|
|||
delete updateData.$addToSet;
|
||||
delete updateData.$push;
|
||||
delete updateData.$pull;
|
||||
delete updateData.$unset;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -767,6 +806,11 @@ export function createAgentMethods(
|
|||
...directUpdates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (hasOperatorKeys($unset)) {
|
||||
for (const path of Object.keys($unset as Record<string, unknown>)) {
|
||||
deleteDocumentPath(versionEntry, path);
|
||||
}
|
||||
}
|
||||
|
||||
if (actionsHash) {
|
||||
versionEntry.actionsHash = actionsHash;
|
||||
|
|
@ -1200,7 +1244,14 @@ export function createAgentMethods(
|
|||
}
|
||||
}
|
||||
|
||||
const revertedAgent = await Agent.findOneAndUpdate(searchParameter, revertToVersion, {
|
||||
const restoresDeploymentDefault = !Object.prototype.hasOwnProperty.call(
|
||||
revertToVersion,
|
||||
'code_environment_id',
|
||||
);
|
||||
const revertUpdate = restoresDeploymentDefault
|
||||
? { $set: revertToVersion, $unset: { code_environment_id: 1 } }
|
||||
: { $set: revertToVersion };
|
||||
const revertedAgent = await Agent.findOneAndUpdate(searchParameter, revertUpdate, {
|
||||
new: true,
|
||||
}).lean<IAgent>();
|
||||
if (!revertedAgent) {
|
||||
|
|
|
|||
|
|
@ -2082,6 +2082,45 @@ describe('SkillFile methods', () => {
|
|||
expect(file.codeEnvRefs?.stateful?.file_id).toBe('stateful-file');
|
||||
});
|
||||
|
||||
it('persists deployment-specific route keys and their reference metadata', async () => {
|
||||
const { skill } = await methods.createSkill(makeSkillInput());
|
||||
await methods.upsertSkillFile({
|
||||
skillId: skill._id,
|
||||
relativePath: 'scripts/a.sh',
|
||||
file_id: 'f1',
|
||||
filename: 'a.sh',
|
||||
filepath: '/a',
|
||||
source: 'local',
|
||||
mimeType: 'text/plain',
|
||||
bytes: 1,
|
||||
author: owner._id,
|
||||
});
|
||||
const executionRouteKey = 'stateful:0123456789abcdef0123456789abcdef';
|
||||
|
||||
await methods.updateSkillFileCodeEnvIds([
|
||||
{
|
||||
skillId: skill._id,
|
||||
relativePath: 'scripts/a.sh',
|
||||
codeEnvRef: {
|
||||
kind: 'skill',
|
||||
id: skill._id.toString(),
|
||||
version: 1,
|
||||
storage_session_id: 'route-session',
|
||||
file_id: 'route-file',
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const [file] = await methods.listSkillFiles(skill._id);
|
||||
expect(file.codeEnvRefs?.[executionRouteKey]).toMatchObject({
|
||||
file_id: 'route-file',
|
||||
executionProfile: 'stateful',
|
||||
executionRouteKey,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports modifiedCount=0 when no SkillFile rows match the (skillId, relativePath) filter', async () => {
|
||||
const { skill } = await methods.createSkill(makeSkillInput());
|
||||
const result = await methods.updateSkillFileCodeEnvIds([
|
||||
|
|
|
|||
|
|
@ -1888,14 +1888,14 @@ export function createSkillMethods(
|
|||
if (updates.length === 0) return { matchedCount: 0, modifiedCount: 0 };
|
||||
const SkillFile = mongoose.models.SkillFile as Model<ISkillFileDocument>;
|
||||
const ops = updates.map((u) => {
|
||||
const profile = u.codeEnvRef.executionProfile ?? 'default';
|
||||
const routeKey = u.codeEnvRef.executionRouteKey ?? u.codeEnvRef.executionProfile ?? 'default';
|
||||
return {
|
||||
updateOne: {
|
||||
filter: { skillId: u.skillId, relativePath: u.relativePath },
|
||||
update: {
|
||||
$set: {
|
||||
codeEnvRef: u.codeEnvRef,
|
||||
[`codeEnvRefs.${profile}`]: u.codeEnvRef,
|
||||
[`codeEnvRefs.${routeKey}`]: u.codeEnvRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ const agentSchema: Schema<IAgent> = new Schema<IAgent>(
|
|||
type: String,
|
||||
enum: ['user', 'agent-user', 'conversation'],
|
||||
},
|
||||
code_environment_id: {
|
||||
type: String,
|
||||
},
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids: {
|
||||
type: [String],
|
||||
|
|
|
|||
|
|
@ -15,14 +15,15 @@ export const codeEnvRefSchema: Schema = new Schema(
|
|||
type: String,
|
||||
enum: ['default', 'stateful'],
|
||||
},
|
||||
executionRouteKey: { type: String },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
export const codeEnvRefMapSchema: Schema = new Schema(
|
||||
{
|
||||
default: { type: codeEnvRefSchema, default: undefined },
|
||||
stateful: { type: codeEnvRefSchema, default: undefined },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
/** Route keys are `default`, `stateful`, or `stateful:<deployment hash>`.
|
||||
* Mixed preserves dot-addressed dynamic keys as a plain object; a Mongoose
|
||||
* Map would require every existing hydrated-document reader to switch from
|
||||
* bracket access to `.get()`. Values are produced through the typed
|
||||
* CodeEnvRef write paths and the singular compatibility field remains fully
|
||||
* schema-validated. */
|
||||
export const codeEnvRefMapSchema: typeof Schema.Types.Mixed = Schema.Types.Mixed;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export interface IAgent extends Omit<Document, 'model'> {
|
|||
end_after_tools?: boolean;
|
||||
stateful_code_sessions?: boolean;
|
||||
stateful_code_environment?: 'user' | 'agent-user' | 'conversation';
|
||||
code_environment_id?: string;
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids?: string[];
|
||||
edges?: GraphEdge[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue