🧪 feat: stateful_code_sessions capability for warm Code API sandbox sessions (experimental) (#14150)

*  feat: stateful_code_sessions capability for warm Code API sandbox sessions

Wire the @librechat/agents stateful sandbox sub-config behind a new,
off-by-default stateful_code_sessions agent capability. createRun sets
toolExecution.sandbox.statefulSessions when code execution is active in the
run AND the capability is enabled; execute_code and bash_tool factories get
the param so their descriptions hedge toward persistence. Rides the existing
variable-not-literal runConfig pattern, so it no-ops until @librechat/agents
is bumped to the version shipping the sandbox sub-config.

*  feat: per-agent stateful code sessions (builder toggle + init gating)

Stateful sessions now require the agent's own opt-in, not just the admin
capability. New agent field stateful_code_sessions (schema + validation +
types) surfaces as a toggle in Agent Builder Advanced settings, gated on
the app capability and disabled without Code Interpreter. initializeAgent
resolves the per-agent truth (admin capability AND builder opt-in AND
code env) once: the registered bash_tool description, the execute_code
factory, and createRun's toolExecution.sandbox gate all read the same
resolved value. statefulSessionsAvailable threads through the same call
sites as codeEnvAvailable, including handoff discovery and added convos.

* 🐛 fix: propagate runtime_session_hint to sandbox executor in event-driven tool path

The event-driven ON_TOOL_EXECUTE handler built config.toolCall without the
resolved runtime_session_hint, so BashExecutor/CodeExecutor never sent
runtime_session_hint to the Code API. Every conversation then collapsed onto
the server-derived default session (no per-conversation isolation). Copy
tc.runtimeSessionHint onto toolCallConfig._runtime_session_hint, mirroring the
SDK direct-execution path.

* 🐛 fix: address Codex review findings for stateful code sessions

- OpenAI-compatible service (packages/api/src/agents/openai/service.ts) now
  derives and passes statefulSessionsAvailable alongside codeEnvAvailable, so
  the feature activates on that route (previously statefulCodeSessions resolved
  false there and createRun never sent toolExecution.sandbox).
- Thread runtime_session_hint through the host file-authoring tools
  (create_file/edit_file/read_file): those host branches return before the
  generic tool path, so readSandboxFile/writeSandboxFile now forward the
  per-conversation hint instead of falling back to the Code API default session.
- StatefulSessions builder toggle clears its form value when Code Interpreter is
  disabled, so a saved agent matches the disabled UI and re-enabling code doesn't
  silently reactivate stateful sessions.

* 🐛 fix: normalize stateful_code_sessions on save when Code Interpreter disabled

Addresses Codex review (round 2): a stale `stateful_code_sessions` opt-in
could persist when Code Interpreter (`execute_code`) is disabled from the
main agent builder without opening Advanced settings, silently reactivating
warm sessions if code was later re-enabled.

- AgentPanel: normalize in `composeAgentUpdatePayload` (the always-run save
  path) so `stateful_code_sessions` is forced to `false` whenever
  `execute_code !== true`, regardless of whether Advanced was opened.
- StatefulSessions: revert the mount-scoped useEffect (round-1 approach) —
  it only fired while the Advanced panel was mounted, missing this path.
- Add spec coverage for both branches of the normalization.
This commit is contained in:
Danny Avila 2026-07-12 08:12:04 -04:00 committed by GitHub
parent 4182f9094f
commit 53e369fba8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 366 additions and 9 deletions

View file

@ -22,6 +22,7 @@ const {
Permissions,
EToolResources,
PermissionTypes,
AgentCapabilities,
} = require('librechat-data-provider');
const {
availableTools,
@ -51,7 +52,7 @@ const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSe
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getMCPServerTools } = require('~/server/services/Config');
const { getMCPServerTools, checkCapability } = require('~/server/services/Config');
const { getMCPServersRegistry } = require('~/config');
const { getRoleByName, setMemory, deleteMemory, getFormattedMemories } = require('~/models');
@ -301,10 +302,18 @@ const loadTools = async ({
if (files?.length) {
primedCodeFiles = files;
}
/* Hedge the execute_code description toward persistence only when the
* admin `stateful_code_sessions` capability is on AND the agent opted
* in via the builder (off by default); the matching wire hint is set
* in the run config. Older @librechat/agents ignore the param. */
const statefulSessions =
agent?.stateful_code_sessions === true &&
(await checkCapability(options.req, AgentCapabilities.stateful_code_sessions));
return createCodeExecutionTool({
user_id: user,
files,
authHeaders: () => getCodeApiAuthHeaders(options.req),
statefulSessions,
});
};
continue;

View file

@ -774,6 +774,7 @@ class AgentClient extends BaseClient {
: memoryConfig.agent?.provider,
},
codeEnvAvailable: memoryCapabilities.has(AgentCapabilities.execute_code),
statefulSessionsAvailable: memoryCapabilities.has(AgentCapabilities.stateful_code_sessions),
},
{
getFiles: db.getFiles,

View file

@ -335,6 +335,9 @@ const OpenAIChatCompletionController = async (req, res) => {
ephemeralSkillsToggle,
}),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
statefulSessionsAvailable: enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
),
skillStates,
defaultActiveOnShare,
manualSkills,
@ -412,6 +415,9 @@ const OpenAIChatCompletionController = async (req, res) => {
defaultActiveOnShare,
/** @see DiscoverConnectedAgentsParams.codeEnvAvailable */
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
statefulSessionsAvailable: enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
),
},
{
getAgent: db.getAgent,

View file

@ -459,6 +459,9 @@ const createResponse = async (req, res) => {
ephemeralSkillsToggle,
}),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
statefulSessionsAvailable: enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
),
skillStates,
defaultActiveOnShare,
manualSkills,
@ -536,6 +539,9 @@ const createResponse = async (req, res) => {
defaultActiveOnShare,
/** @see DiscoverConnectedAgentsParams.codeEnvAvailable */
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
statefulSessionsAvailable: enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
),
},
{
getAgent: db.getAgent,

View file

@ -54,6 +54,8 @@ const loadAddedAgent = (params) =>
* @param {boolean} [params.codeEnvAvailable] - `execute_code` capability flag;
* forwarded verbatim to the added agent's `initializeAgent`. @see
* InitializeAgentParams.codeEnvAvailable for full semantics.
* @param {boolean} [params.statefulSessionsAvailable] - `stateful_code_sessions`
* capability flag; forwarded verbatim alongside `codeEnvAvailable`.
* @returns {Promise<{userMCPAuthMap: Object|undefined}>} The updated userMCPAuthMap
*/
const processAddedConvo = async ({
@ -79,6 +81,7 @@ const processAddedConvo = async ({
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
}) => {
const addedConvo = endpointOption.addedConvo;
@ -174,6 +177,7 @@ const processAddedConvo = async ({
ephemeralSkillsToggle,
}),
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
skillStates,
defaultActiveOnShare,

View file

@ -149,6 +149,9 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
const enabledCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities);
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
const codeEnvAvailable = enabledCapabilities.has(AgentCapabilities.execute_code);
const statefulSessionsAvailable = enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
);
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
const skillDbMethods = getSkillDbMethods();
@ -405,6 +408,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
accessibleSkillIds: primaryScopedSkillIds,
skillAuthoringAvailable: primarySkillAuthoringAvailable,
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
skillStates,
defaultActiveOnShare,
@ -481,6 +485,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
},
{
@ -553,6 +558,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
});
@ -692,6 +698,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
* false`, so `bash_tool` / `read_file` sandbox fallback are
* silently gated off even though the seed walk found it. */
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
skillStates,
defaultActiveOnShare,

View file

@ -990,7 +990,7 @@ const primeFiles = async (options) => {
* @param {ServerRequest} [params.req] - Current authenticated request, used to mint Code API auth.
* @returns {Promise<{content: string} | null>}
*/
async function readSandboxFile({ file_path, session_id, files, req }) {
async function readSandboxFile({ file_path, session_id, files, runtime_session_hint, req }) {
const baseURL = getCodeBaseURL();
if (!baseURL) {
return null;
@ -1006,6 +1006,9 @@ async function readSandboxFile({ file_path, session_id, files, req }) {
if (session_id) {
postData.session_id = session_id;
}
if (runtime_session_hint) {
postData.runtime_session_hint = runtime_session_hint;
}
if (files && files.length > 0) {
postData.files = files;
}
@ -1056,7 +1059,14 @@ async function readSandboxFile({ file_path, session_id, files, req }) {
* @param {ServerRequest} [params.req] - Current authenticated request, used to mint Code API auth.
* @returns {Promise<{stdout?: string, stderr?: string, session_id?: string, files?: Array<Object>} | null>}
*/
async function writeSandboxFile({ file_path, content, session_id, files, req }) {
async function writeSandboxFile({
file_path,
content,
session_id,
files,
runtime_session_hint,
req,
}) {
const baseURL = getCodeBaseURL();
if (!baseURL) {
return null;
@ -1090,6 +1100,9 @@ async function writeSandboxFile({ file_path, content, session_id, files, req })
if (session_id) {
postData.session_id = session_id;
}
if (runtime_session_hint) {
postData.runtime_session_hint = runtime_session_hint;
}
if (files && files.length > 0) {
postData.files = files;
}

View file

@ -1483,6 +1483,18 @@ async function loadToolsForExecution({
enabledCapabilities?.has(AgentCapabilities.execute_code) === true &&
agent?.tools?.includes(Tools.execute_code) === true;
/**
* Opt bash_tool into the hedged stateful-session description. Gated on code
* execution being enabled AND the admin `stateful_code_sessions` capability
* AND the agent's own builder opt-in; off by default. Sets prompt text only
* (the wire hint is set at run config). PTC keeps its stateless prompt in
* v1. Older @librechat/agents ignore the param.
*/
const statefulCodeSessions =
codeExecutionEnabled &&
enabledCapabilities?.has(AgentCapabilities.stateful_code_sessions) === true &&
agent?.stateful_code_sessions === true;
const isPTC =
isPTCRequested &&
enabledCapabilities.has(AgentCapabilities.programmatic_tools) &&
@ -1534,6 +1546,7 @@ async function loadToolsForExecution({
try {
const bashTool = createBashExecutionTool({
authHeaders: () => getCodeApiAuthHeaders(req),
statefulSessions: statefulCodeSessions,
});
allLoadedTools.push(bashTool);
} catch (error) {

View file

@ -26,6 +26,7 @@ export type TAgentCapabilities = {
[AgentCapabilities.memory]?: boolean;
[AgentCapabilities.end_after_tools]?: boolean;
[AgentCapabilities.hide_sequential_outputs]?: boolean;
[AgentCapabilities.stateful_code_sessions]?: boolean;
};
export type AgentForm = {

View file

@ -1,10 +1,12 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useToastContext } from '@librechat/client';
import { ChevronLeft, Check, Copy } from 'lucide-react';
import { AgentCapabilities } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import { sectionLabelClass, groupHeadingClass } from './ui';
import { useAgentPanelContext } from '~/Providers';
import StatefulSessions from './StatefulSessions';
import OrchestrationHub from './OrchestrationHub';
import MaxAgentSteps from './MaxAgentSteps';
import { useLocalize } from '~/hooks';
@ -17,7 +19,11 @@ export default function AdvancedPanel() {
const currentAgentId = watch('id');
const [copied, setCopied] = useState(false);
const { setActivePanel } = useAgentPanelContext();
const { agentsConfig, setActivePanel } = useAgentPanelContext();
const statefulSessionsEnabled = useMemo(
() => agentsConfig?.capabilities.includes(AgentCapabilities.stateful_code_sessions) ?? false,
[agentsConfig],
);
const handleCopyAgentId = async () => {
if (!currentAgentId) return;
@ -52,6 +58,7 @@ export default function AdvancedPanel() {
<section className="flex flex-col gap-3">
<span className={groupHeadingClass}>{localize('com_ui_essentials')}</span>
<MaxAgentSteps />
{statefulSessionsEnabled && <StatefulSessions />}
</section>
<OrchestrationHub currentAgentId={currentAgentId} />

View file

@ -0,0 +1,59 @@
import { useFormContext } from 'react-hook-form';
import { AgentCapabilities } from 'librechat-data-provider';
import {
Switch,
HoverCard,
HoverCardPortal,
HoverCardContent,
HoverCardTrigger,
CircleHelpIcon,
} from '@librechat/client';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';
export default function StatefulSessions() {
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { setValue, watch } = methods;
const enabled = watch(AgentCapabilities.stateful_code_sessions) ?? false;
const codeEnabled = watch(AgentCapabilities.execute_code);
const handleChange = (value: boolean) => {
setValue(AgentCapabilities.stateful_code_sessions, value, { shouldDirty: true });
};
return (
<HoverCard openDelay={50}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={codeEnabled ? 'text-sm' : 'text-sm text-text-tertiary'}>
{localize('com_ui_stateful_sessions')}
</div>
<HoverCardTrigger>
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
</HoverCardTrigger>
</div>
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">
{localize('com_nav_info_stateful_sessions')}
</p>
</div>
</HoverCardContent>
</HoverCardPortal>
<Switch
id="stateful-code-sessions"
checked={enabled && codeEnabled === true}
onCheckedChange={handleChange}
className="ml-4"
data-testid="stateful-code-sessions"
disabled={codeEnabled !== true}
aria-label={localize('com_ui_stateful_sessions')}
/>
</div>
</HoverCard>
);
}

View file

@ -73,6 +73,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
subagents,
end_after_tools,
hide_sequential_outputs,
stateful_code_sessions,
recursion_limit,
category,
support_contact,
@ -83,6 +84,11 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
avatar_action: avatarActionState,
} = data;
/* stateful_code_sessions requires Code Interpreter; force it off on save when
* execute_code is disabled so a stale opt-in can't silently reactivate later. */
const normalizedStatefulCodeSessions =
data.execute_code === true ? stateful_code_sessions : false;
const shouldResetAvatar =
avatarActionState === 'reset' && Boolean(agent_id) && !isEphemeralAgent(agent_id);
const model = _model ?? '';
@ -103,6 +109,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
subagents,
end_after_tools,
hide_sequential_outputs,
stateful_code_sessions: normalizedStatefulCodeSessions,
recursion_limit,
category,
support_contact,

View file

@ -61,6 +61,7 @@ function AgentSelect({
[AgentCapabilities.memory]: false,
[AgentCapabilities.end_after_tools]: false,
[AgentCapabilities.hide_sequential_outputs]: false,
[AgentCapabilities.stateful_code_sessions]: false,
};
const agentTools: string[] = [];

View file

@ -71,6 +71,26 @@ describe('composeAgentUpdatePayload', () => {
expect(payload.avatar).toBeUndefined();
});
it('forces stateful_code_sessions off when execute_code is disabled', () => {
const form = createForm();
form.execute_code = false;
form.stateful_code_sessions = true;
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
expect(payload.stateful_code_sessions).toBe(false);
});
it('preserves stateful_code_sessions when execute_code is enabled', () => {
const form = createForm();
form.execute_code = true;
form.stateful_code_sessions = true;
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
expect(payload.stateful_code_sessions).toBe(true);
});
});
describe('persistAvatarChanges', () => {

View file

@ -518,6 +518,7 @@
"com_nav_info_save_badges_state": "When enabled, the state of the chat badges will be saved. This means that if you create a new chat, the badges will remain in the same state as the previous chat. If you disable this option, the badges will reset to their default state every time you create a new chat",
"com_nav_info_save_draft": "When enabled, the text and attachments you enter in the chat form will be automatically saved locally as drafts. These drafts will be available even if you reload the page or switch to a different conversation. Drafts are stored locally on your device and are deleted once the message is sent.",
"com_nav_info_show_thinking": "When enabled, the chat will display the thinking dropdowns open by default, allowing you to view the AI's reasoning in real-time. When disabled, the thinking dropdowns will remain closed by default for a cleaner and more streamlined interface",
"com_nav_info_stateful_sessions": "When enabled, this agent's code executions reuse one persistent sandbox workspace per conversation: files, installed packages, and working state usually carry over between runs. The workspace may occasionally reset, so anything important should be saved under /mnt/data. Requires Code Interpreter and the app-level stateful sessions capability.",
"com_nav_info_user_name_display": "When enabled, the username of the sender will be shown above each message you send. When disabled, you will only see \"You\" above your messages.",
"com_nav_keep_screen_awake": "Keep screen awake during response generation",
"com_nav_lang_arabic": "العربية",
@ -1837,6 +1838,7 @@
"com_ui_sr_global_prompt": "Global prompt group",
"com_ui_sr_public_skill": "Public skill",
"com_ui_stack_trace": "Stack Trace",
"com_ui_stateful_sessions": "Stateful code sessions",
"com_ui_status_prefix": "Status:",
"com_ui_stop": "Stop",
"com_ui_storage": "Storage",

View file

@ -88,6 +88,8 @@ export interface DiscoverConnectedAgentsParams {
* code-execution tooling even though their parent had it.
*/
codeEnvAvailable?: InitializeAgentParams['codeEnvAvailable'];
/** Sibling of `codeEnvAvailable` — the `stateful_code_sessions` capability flag, forwarded to every handoff `initializeAgent`. */
statefulSessionsAvailable?: InitializeAgentParams['statefulSessionsAvailable'];
/**
* Run-level inline memory availability gate. Forwarded verbatim to every
* handoff agent so sub-agents that list the `memory` capability expand the
@ -163,6 +165,7 @@ export async function discoverConnectedAgents(
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
} = params;
@ -267,6 +270,7 @@ export async function discoverConnectedAgents(
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
},
db,

View file

@ -232,6 +232,10 @@ export interface ToolExecuteOptions {
file_path: string;
session_id?: string;
files?: Array<{ id: string; name: string; session_id?: string; storage_session_id?: string }>;
/** Per-conversation stateful runtime-session hint (thread_id); forwarded so a
* host file op that is the first sandbox call joins the same runtime session
* as bash_tool instead of the Code API's default session. */
runtime_session_hint?: string;
req?: ServerRequest;
}) => Promise<{ content: string } | null>;
/**
@ -245,6 +249,8 @@ export interface ToolExecuteOptions {
content: string;
session_id?: string;
files?: Array<{ id: string; name: string; session_id?: string; storage_session_id?: string }>;
/** @see readSandboxFile.runtime_session_hint */
runtime_session_hint?: string;
req?: ServerRequest;
}) => Promise<{
stdout?: string;
@ -1263,6 +1269,7 @@ async function handleSandboxFileFallback(
file_path: filePath,
session_id: ctx?.session_id,
files: ctx?.files,
...(tc.runtimeSessionHint ? { runtime_session_hint: tc.runtimeSessionHint } : {}),
...(req ? { req } : {}),
});
if (!result || result.content == null) {
@ -1431,6 +1438,7 @@ async function loadSandboxTextForAuthoring({
file_path: filePath,
session_id: ctx?.session_id,
files: ctx?.files,
...(tc.runtimeSessionHint ? { runtime_session_hint: tc.runtimeSessionHint } : {}),
...(req ? { req } : {}),
});
if (!result || result.content == null) {
@ -1505,6 +1513,7 @@ async function writeSandboxTextForAuthoring({
content,
session_id: ctx?.session_id,
files: ctx?.files,
...(tc.runtimeSessionHint ? { runtime_session_hint: tc.runtimeSessionHint } : {}),
...(req ? { req } : {}),
});
} catch (error) {
@ -3340,6 +3349,18 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
turn: tc.turn,
};
/* Stateful runtime-session hint: the SDK resolves it onto
* the request for execute_code/bash (orthogonal to the
* transient exec-session below a first call has a hint but
* no session yet). The remote executors read it off
* `config.toolCall._runtime_session_hint`; without this the
* event-driven ON_TOOL_EXECUTE path drops it and every
* conversation collapses onto the Code API's `default`
* session (no per-conversation isolation). */
if (tc.runtimeSessionHint != null && tc.runtimeSessionHint !== '') {
toolCallConfig._runtime_session_hint = tc.runtimeSessionHint;
}
if (
tc.codeSessionContext &&
isCodeSessionAwareToolCall(tc.name, mergedConfigurable)

View file

@ -282,6 +282,14 @@ export type InitializedAgent = Agent & {
* (`packages/api/src/agents/added.ts`), so the check is uniform.
*/
codeEnvAvailable: boolean;
/**
* Whether stateful code sessions are active *for this agent*: the admin
* `stateful_code_sessions` capability AND the agent's builder opt-in
* (`agent.stateful_code_sessions`) AND `codeEnvAvailable`. Resolved once
* here; `createRun` walks this per-agent value to gate the run-level
* `toolExecution.sandbox` config.
*/
statefulCodeSessions: boolean;
/** Whether host-side skill file authoring is available for this agent/run. */
skillAuthoringAvailable: boolean;
/** Host-side file authoring tool names registered for this run. */
@ -397,6 +405,8 @@ export interface InitializeAgentParams {
skillAuthoringAvailable?: boolean;
/** Whether the code execution environment is available (execute_code capability enabled) */
codeEnvAvailable?: boolean;
/** Whether stateful code sessions are available (stateful_code_sessions capability enabled) */
statefulSessionsAvailable?: boolean;
/** Whether inline memory tools are available (memory capability enabled, memory
* configured, and the user permitted). When true and the agent lists the `memory`
* capability, `set_memory` + `delete_memory` are registered for the LLM. */
@ -1069,6 +1079,14 @@ export async function initializeAgent(
*/
const agentRequestsCodeExec = (agent.tools ?? []).includes(Tools.execute_code);
const effectiveCodeEnvAvailable = params.codeEnvAvailable === true && agentRequestsCodeExec;
/** Per-agent stateful-session truth: the admin capability AND the agent's
* own builder opt-in AND a working code env. Resolved once here so the
* registered bash description, the tool factories, and `createRun`'s
* `toolExecution.sandbox` gate all agree for this agent. */
const effectiveStatefulSessions =
effectiveCodeEnvAvailable &&
params.statefulSessionsAvailable === true &&
agent.stateful_code_sessions === true;
if (effectiveCodeEnvAvailable) {
const codeExecResult = registerCodeExecutionTools({
toolRegistry,
@ -1076,6 +1094,7 @@ export async function initializeAgent(
includeBash: true,
includeSkillFileInstructions: false,
enableToolOutputReferences: effectiveCodeEnvAvailable,
statefulSessions: effectiveStatefulSessions,
});
toolDefinitions = codeExecResult.toolDefinitions;
} else if (agentRequestsCodeExec) {
@ -1219,6 +1238,7 @@ export async function initializeAgent(
contextWindowTokens: Number(agentMaxContextTokens) || 200_000,
listSkillsByAccess: db?.listSkillsByAccess,
codeEnvAvailable: effectiveCodeEnvAvailable,
statefulSessions: effectiveStatefulSessions,
userId: req.user?.id,
skillStates: params.skillStates,
defaultActiveOnShare: params.defaultActiveOnShare,
@ -1293,6 +1313,7 @@ export async function initializeAgent(
baseContextTokens,
memoryToolsRegistered: inlineMemoryRegistered,
codeEnvAvailable: effectiveCodeEnvAvailable,
statefulCodeSessions: effectiveStatefulSessions,
reasoningKey: customEndpointConfig?.customParams?.reasoningKey,
includeReasoningHistory: customEndpointConfig?.customParams?.includeReasoningHistory,
skillAuthoringAvailable,

View file

@ -146,6 +146,13 @@ interface InitializeAgentParams {
* skips the expansion (same semantics as the in-repo controllers).
*/
codeEnvAvailable?: boolean;
/**
* Whether the admin-level `stateful_code_sessions` capability is enabled.
* Threaded to `initializeAgent` alongside `codeEnvAvailable` so this
* OpenAI-compatible route resolves stateful sessions identically to the
* in-repo controllers; absent / `undefined` disables the feature.
*/
statefulSessionsAvailable?: boolean;
}
/**
@ -444,6 +451,17 @@ export async function createAgentChatCompletion(
AgentCapabilities.execute_code,
)
: undefined;
/** Mirror `codeEnvAvailable` for the stateful-session gate so an agent with
* `execute_code`, the app `stateful_code_sessions` capability, and its own
* builder opt-in resolves stateful sessions on this route too otherwise
* `statefulCodeSessions` stays false and `createRun` never sends
* `toolExecution.sandbox`. */
const statefulSessionsAvailable =
agentsConfig != null && typeof agentsConfig === 'object'
? ((agentsConfig as { capabilities?: string[] }).capabilities ?? []).includes(
AgentCapabilities.stateful_code_sessions,
)
: undefined;
// Initialize the agent first to check for disableStreaming
const initializedAgent = await deps.initializeAgent({
@ -460,6 +478,7 @@ export async function createAgentChatCompletion(
allowedProviders,
isInitialAgent: true,
codeEnvAvailable,
statefulSessionsAvailable,
});
// Determine if streaming is enabled (check both request and agent config)

View file

@ -339,6 +339,12 @@ type RunAgent = Omit<Agent, 'tools'> & {
* is actually registered.
*/
codeEnvAvailable?: boolean;
/**
* Per-agent stateful-session gate set by `initializeAgent`: the admin
* `stateful_code_sessions` capability AND the agent's builder opt-in AND
* `codeEnvAvailable`. Walked here to gate `toolExecution.sandbox`.
*/
statefulCodeSessions?: boolean;
/** Optional per-agent summarization overrides */
summarization?: SummarizationConfig;
/** Response field to read model reasoning from for custom OpenAI-compatible endpoints. */
@ -778,6 +784,39 @@ function isAskUserQuestionAdminDisabled(appConfig?: AppConfig): boolean {
return appConfig?.filteredTools?.includes(ASK_USER_QUESTION_TOOL_NAME) === true;
}
/**
* Whether any agent reachable in the run primary, handoff/parallel, or a
* nested subagent resolved `statefulCodeSessions` during initialization
* (admin `stateful_code_sessions` capability AND the agent's builder opt-in
* AND a working code env). Walks `subagentAgentConfigs` like
* {@link anyAgentHasCodeEnv}; when true, `createRun` opts the run's remote
* sandbox tools into stateful runtime sessions via `toolExecution.sandbox`.
* Off by default: the capability is absent from `defaultAgentCapabilities`,
* agents opt in individually, and the SDK derives the session hint from
* `thread_id` (= conversationId), so this is never a trust boundary.
*/
export function anyAgentHasStatefulSessions(agents: Array<RunAgent | null | undefined>): boolean {
const visited = new Set<string>();
const pending = [...agents];
for (let index = 0; index < pending.length; index++) {
const agent = pending[index];
if (agent == null || visited.has(agent.id)) {
continue;
}
visited.add(agent.id);
if (agent.statefulCodeSessions === true) {
return true;
}
for (const child of agent.subagentAgentConfigs ?? []) {
if (child != null && !visited.has(child.id)) {
pending.push(child);
}
}
}
return false;
}
/**
* Whether any agent reachable in the run primary, handoff/parallel, or a
* nested subagent opts into cross-turn `reasoning_content` reconstruction.
@ -1288,6 +1327,9 @@ export async function createRun({
* and the resume route). When disabled, nothing attaches and the run is identical
* to before this feature shipped.
*/
// Per-agent truth resolved by initializeAgent (admin capability AND builder
// opt-in AND code env) — the run opts in when any reachable agent did.
const statefulCodeSessions = anyAgentHasStatefulSessions(agents);
// Resolve the effective policy through the single seam so per-agent / per-skill
// sources can layer in later without touching this call site (see
// `resolveToolApprovalPolicy`). Only the endpoint layer is wired today, so this
@ -1388,6 +1430,15 @@ export async function createRun({
...(enableToolOutputReferences && {
toolOutputReferences: { enabled: true },
}),
// Best-effort stateful runtime sessions on the remote Code API. The SDK
// stamps a per-conversation session hint on execute_code/bash requests and
// hedges those tools' descriptions; the transport is otherwise unchanged.
// `engine` is omitted (defaults to `sandbox`) and the hint defaults to
// thread_id. Requires @librechat/agents with `toolExecution.sandbox`;
// older versions ignore the field.
...(statefulCodeSessions && {
toolExecution: { sandbox: { statefulSessions: true } },
}),
// HITL opt-in: the `humanInTheLoop` switch + the PreToolUse policy hook. Spread
// here (not just `compileOptions.checkpointer` above) so an `ask` decision raises
// a real interrupt — without these the run would never pause. Absent when disabled.

View file

@ -334,6 +334,8 @@ export interface InjectSkillCatalogParams {
listSkillsByAccess: InitializeAgentDbMethods['listSkillsByAccess'];
/** When true, registers bash_tool alongside skill + read_file. */
codeEnvAvailable?: boolean;
/** When true, bash_tool registers with the hedged stateful-session description. */
statefulSessions?: boolean;
/** Current user ID — used to determine skill ownership for active-state resolution. */
userId?: string;
/** Per-user skill overrides: `{ [skillId]: boolean }`. Missing entries use the default. */
@ -390,6 +392,7 @@ export async function injectSkillCatalog(
contextWindowTokens,
listSkillsByAccess,
codeEnvAvailable,
statefulSessions,
userId,
skillStates,
defaultActiveOnShare = false,
@ -583,6 +586,7 @@ export async function injectSkillCatalog(
toolDefinitions: workingDefs,
includeBash: codeEnvAvailable === true,
enableToolOutputReferences: codeEnvAvailable === true,
statefulSessions: statefulSessions === true,
});
workingDefs = codeExecResult.toolDefinitions;

View file

@ -0,0 +1,48 @@
import { anyAgentHasStatefulSessions } from './run';
type WalkInput = Parameters<typeof anyAgentHasStatefulSessions>[0];
interface TestAgent {
id: string;
statefulCodeSessions?: boolean;
subagentAgentConfigs?: Array<TestAgent | null>;
}
function agent(
id: string,
statefulCodeSessions?: boolean,
subagentAgentConfigs?: Array<TestAgent | null>,
): TestAgent {
return { id, statefulCodeSessions, subagentAgentConfigs };
}
function walk(agents: Array<TestAgent | null | undefined>): boolean {
return anyAgentHasStatefulSessions(agents as WalkInput);
}
describe('anyAgentHasStatefulSessions', () => {
it('is true when a top-level agent resolved the per-agent flag at initialization', () => {
expect(walk([agent('a', true)])).toBe(true);
expect(walk([agent('a', false), agent('b', true)])).toBe(true);
});
it('stays off (default) when no agent opted in or the flag is absent', () => {
expect(walk([])).toBe(false);
expect(walk([agent('a')])).toBe(false);
expect(walk([agent('a', false)])).toBe(false);
});
it('walks nested subagent configs so a stateful subagent activates the run', () => {
const grandchild = agent('c', true);
const child = agent('b', false, [grandchild]);
expect(walk([agent('a', false, [child])])).toBe(true);
});
it('tolerates null entries and cycles in the subagent graph', () => {
const a = agent('a', false);
const b = agent('b', false);
a.subagentAgentConfigs = [b, null];
b.subagentAgentConfigs = [a];
expect(walk([a, undefined, null])).toBe(false);
});
});

View file

@ -94,6 +94,13 @@ export interface RegisterCodeExecutionToolsParams {
* commands. Paired with `RunConfig.toolOutputReferences` in `createRun`.
*/
enableToolOutputReferences?: boolean;
/**
* When `true`, the registered `bash_tool` description is the hedged
* stateful-session variant (workspace usually persists across calls, may
* reset at any time). Paired with `toolExecution.sandbox.statefulSessions`
* in `createRun`; resolved per-agent during initialization.
*/
statefulSessions?: boolean;
}
export interface RegisterCodeExecutionToolsResult {
@ -377,10 +384,14 @@ export function isFileAuthoringToolDefinition(def: LCTool | undefined): boolean
* intent of the original constant while keeping the per-agent gate
* behavior introduced for tool-output references.
*/
function createBashToolDef(enableToolOutputReferences: boolean): LCTool {
function createBashToolDef(enableToolOutputReferences: boolean, statefulSessions = false): LCTool {
/* Passed as a variable (not an inline literal) so the extra
* `statefulSessions` key stays assignable against pinned SDK versions
* whose builder predates it (ignored at runtime there). */
const descriptionOpts = { enableToolOutputReferences, statefulSessions };
return Object.freeze({
name: BashExecutionToolDefinition.name,
description: buildBashExecutionToolDescription({ enableToolOutputReferences }),
description: buildBashExecutionToolDescription(descriptionOpts),
parameters: BashExecutionToolDefinition.schema as unknown as LCTool['parameters'],
}) as LCTool;
}
@ -388,7 +399,15 @@ function createBashToolDef(enableToolOutputReferences: boolean): LCTool {
const BASH_TOOL_DEF_WITH_OUTPUT_REFS = createBashToolDef(true);
const BASH_TOOL_DEF_WITHOUT_OUTPUT_REFS = createBashToolDef(false);
function buildBashToolDef(opts: { enableToolOutputReferences: boolean }): LCTool {
function buildBashToolDef(opts: {
enableToolOutputReferences: boolean;
statefulSessions?: boolean;
}): LCTool {
/* Stateful defs are built on demand: the stateless pair covers the
* default path, and per-run construction is negligible next to init. */
if (opts.statefulSessions === true) {
return createBashToolDef(opts.enableToolOutputReferences, true);
}
return opts.enableToolOutputReferences
? BASH_TOOL_DEF_WITH_OUTPUT_REFS
: BASH_TOOL_DEF_WITHOUT_OUTPUT_REFS;
@ -417,11 +436,12 @@ export function registerCodeExecutionTools(
includeBash,
includeSkillFileInstructions = true,
enableToolOutputReferences = false,
statefulSessions = false,
} = params;
const readFileDef = buildReadFileDef(includeSkillFileInstructions);
const candidates: LCTool[] = includeBash
? [readFileDef, buildBashToolDef({ enableToolOutputReferences })]
? [readFileDef, buildBashToolDef({ enableToolOutputReferences, statefulSessions })]
: [readFileDef];
const inputDefinitions = toolDefinitions ?? [];

View file

@ -443,6 +443,7 @@ export const agentBaseSchema: z.ZodObject<
>;
end_after_tools: z.ZodOptional<z.ZodBoolean>;
hide_sequential_outputs: z.ZodOptional<z.ZodBoolean>;
stateful_code_sessions: z.ZodOptional<z.ZodBoolean>;
artifacts: z.ZodOptional<z.ZodString>;
recursion_limit: z.ZodOptional<z.ZodNumber>;
conversation_starters: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
@ -695,6 +696,7 @@ export const agentBaseSchema: z.ZodObject<
edges: z.array(graphEdgeSchema).optional(),
end_after_tools: z.boolean().optional(),
hide_sequential_outputs: z.boolean().optional(),
stateful_code_sessions: z.boolean().optional(),
artifacts: z.string().optional(),
recursion_limit: z.number().optional(),
conversation_starters: z.array(z.string()).optional(),
@ -788,6 +790,7 @@ export const agentCreateSchema: z.ZodObject<
>;
end_after_tools: z.ZodOptional<z.ZodBoolean>;
hide_sequential_outputs: z.ZodOptional<z.ZodBoolean>;
stateful_code_sessions: z.ZodOptional<z.ZodBoolean>;
artifacts: z.ZodOptional<z.ZodString>;
recursion_limit: z.ZodOptional<z.ZodNumber>;
conversation_starters: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
@ -1099,6 +1102,7 @@ export const agentUpdateSchema: z.ZodObject<
>;
end_after_tools: z.ZodOptional<z.ZodBoolean>;
hide_sequential_outputs: z.ZodOptional<z.ZodBoolean>;
stateful_code_sessions: z.ZodOptional<z.ZodBoolean>;
artifacts: z.ZodOptional<z.ZodString>;
recursion_limit: z.ZodOptional<z.ZodNumber>;
conversation_starters: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;

View file

@ -565,6 +565,7 @@ export enum AgentCapabilities {
end_after_tools = 'end_after_tools',
deferred_tools = 'deferred_tools',
execute_code = 'execute_code',
stateful_code_sessions = 'stateful_code_sessions',
file_search = 'file_search',
web_search = 'web_search',
artifacts = 'artifacts',

View file

@ -285,6 +285,8 @@ export type Agent = {
edges?: GraphEdge[];
end_after_tools?: boolean;
hide_sequential_outputs?: boolean;
/** Per-agent opt-in for stateful code sessions (requires the app-level capability). */
stateful_code_sessions?: boolean;
artifacts?: ArtifactModes;
recursion_limit?: number;
isPublic?: boolean;
@ -323,6 +325,7 @@ export type AgentCreateParams = {
| 'edges'
| 'end_after_tools'
| 'hide_sequential_outputs'
| 'stateful_code_sessions'
| 'artifacts'
| 'recursion_limit'
| 'category'
@ -351,6 +354,7 @@ export type AgentUpdateParams = {
| 'edges'
| 'end_after_tools'
| 'hide_sequential_outputs'
| 'stateful_code_sessions'
| 'artifacts'
| 'recursion_limit'
| 'category'

View file

@ -74,6 +74,9 @@ const agentSchema: Schema<IAgent> = new Schema<IAgent>(
end_after_tools: {
type: Boolean,
},
stateful_code_sessions: {
type: Boolean,
},
/** @deprecated Use edges instead */
agent_ids: {
type: [String],

View file

@ -36,6 +36,7 @@ export interface IAgent extends Omit<Document, 'model'> {
authorName?: string;
hide_sequential_outputs?: boolean;
end_after_tools?: boolean;
stateful_code_sessions?: boolean;
/** @deprecated Use edges instead */
agent_ids?: string[];
edges?: GraphEdge[];