From 19d736811ea55c64abe92fbfcb8a49a12604ad7f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 7 Jul 2026 07:38:06 -0400 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20feat:=20stateful=5Fcode=5Fsessi?= =?UTF-8?q?ons=20capability=20for=20warm=20Code=20API=20sandbox=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/app/clients/tools/util/handleTools.js | 12 ++++++- api/server/services/ToolService.js | 11 ++++++ packages/api/src/agents/run.ts | 36 +++++++++++++++++++ .../src/agents/statefulCodeSessions.spec.ts | 27 ++++++++++++++ packages/data-provider/src/config.ts | 1 + 5 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/agents/statefulCodeSessions.spec.ts diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index a01d4fcdeca..f653c26a088 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -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,19 @@ 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 (off by default); the + * matching wire hint is set in the run config. Older @librechat/agents + * ignore the param. */ + const statefulSessions = await checkCapability( + options.req, + AgentCapabilities.stateful_code_sessions, + ); return createCodeExecutionTool({ user_id: user, files, authHeaders: () => getCodeApiAuthHeaders(options.req), + statefulSessions, }); }; continue; diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 0b24714ec68..b5d4e192b85 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -1483,6 +1483,16 @@ 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; + * 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; + const isPTC = isPTCRequested && enabledCapabilities.has(AgentCapabilities.programmatic_tools) && @@ -1534,6 +1544,7 @@ async function loadToolsForExecution({ try { const bashTool = createBashExecutionTool({ authHeaders: () => getCodeApiAuthHeaders(req), + statefulSessions: statefulCodeSessions, }); allLoadedTools.push(bashTool); } catch (error) { diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 7a76fc62b6e..6a191cb7212 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -3,6 +3,7 @@ import { Run, Providers, Constants } from '@librechat/agents'; import { KnownEndpoints, EModelEndpoint, + AgentCapabilities, MAX_SUBAGENT_DEPTH, MAX_SUBAGENT_RUN_CONFIGS, extractEnvVariable, @@ -778,6 +779,26 @@ function isAskUserQuestionAdminDisabled(appConfig?: AppConfig): boolean { return appConfig?.filteredTools?.includes(ASK_USER_QUESTION_TOOL_NAME) === true; } +/** + * Whether this run opts its remote sandbox tools into best-effort stateful + * runtime sessions (a warm per-session MicroVM workspace on the Code API). + * Requires BOTH code execution active in the run (`codeEnvActive`, i.e. some + * reachable agent has execute_code/bash) AND the admin `stateful_code_sessions` + * capability — a stateful sandbox session is meaningless without code + * execution. Off by default: the capability is absent from + * `defaultAgentCapabilities`, and the SDK derives the session hint from + * `thread_id` (= conversationId), so this is never a trust boundary. + */ +export function resolveStatefulCodeSessions( + codeEnvActive: boolean, + agentsEndpointConfig: { capabilities?: AgentCapabilities[] } | undefined, +): boolean { + if (!codeEnvActive) { + return false; + } + return new Set(agentsEndpointConfig?.capabilities).has(AgentCapabilities.stateful_code_sessions); +} + /** * Whether any agent reachable in the run — primary, handoff/parallel, or a * nested subagent — opts into cross-turn `reasoning_content` reconstruction. @@ -1282,6 +1303,12 @@ export async function createRun({ * and the resume route). When disabled, nothing attaches and the run is identical * to before this feature shipped. */ + // `enableToolOutputReferences` (bash present anywhere in the run) doubles as + // the "code execution active" signal for the stateful-session gate. + const statefulCodeSessions = resolveStatefulCodeSessions( + enableToolOutputReferences, + agentsEndpointConfig, + ); // 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 @@ -1378,6 +1405,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. diff --git a/packages/api/src/agents/statefulCodeSessions.spec.ts b/packages/api/src/agents/statefulCodeSessions.spec.ts new file mode 100644 index 00000000000..d83e103074c --- /dev/null +++ b/packages/api/src/agents/statefulCodeSessions.spec.ts @@ -0,0 +1,27 @@ +import { AgentCapabilities } from 'librechat-data-provider'; +import { resolveStatefulCodeSessions } from './run'; + +describe('resolveStatefulCodeSessions', () => { + const withCap = { + capabilities: [AgentCapabilities.execute_code, AgentCapabilities.stateful_code_sessions], + }; + const withoutCap = { capabilities: [AgentCapabilities.execute_code] }; + + it('enables only when code execution is active AND the capability is present', () => { + expect(resolveStatefulCodeSessions(true, withCap)).toBe(true); + }); + + it('stays off when the capability is absent, even with code execution active', () => { + expect(resolveStatefulCodeSessions(true, withoutCap)).toBe(false); + }); + + it('stays off when code execution is inactive, even with the capability present', () => { + expect(resolveStatefulCodeSessions(false, withCap)).toBe(false); + }); + + it('stays off (default) when the endpoint config or capabilities are missing', () => { + expect(resolveStatefulCodeSessions(true, undefined)).toBe(false); + expect(resolveStatefulCodeSessions(true, {})).toBe(false); + expect(resolveStatefulCodeSessions(true, { capabilities: [] })).toBe(false); + }); +}); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 5a753ab1ac6..653d112552e 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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', From 268fad78c219276e5fde6396d5c1f8721876e088 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 7 Jul 2026 08:49:28 -0400 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20feat:=20per-agent=20stateful=20?= =?UTF-8?q?code=20sessions=20(builder=20toggle=20+=20init=20gating)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/app/clients/tools/util/handleTools.js | 13 ++-- api/server/controllers/agents/client.js | 1 + api/server/controllers/agents/openai.js | 6 ++ api/server/controllers/agents/responses.js | 6 ++ .../services/Endpoints/agents/addedConvo.js | 4 ++ .../services/Endpoints/agents/initialize.js | 7 +++ api/server/services/ToolService.js | 10 ++-- client/src/common/agents-types.ts | 1 + .../Agents/Advanced/AdvancedPanel.tsx | 11 +++- .../Agents/Advanced/StatefulSessions.tsx | 59 +++++++++++++++++++ .../SidePanel/Agents/AgentPanel.tsx | 2 + .../SidePanel/Agents/AgentSelect.tsx | 1 + client/src/locales/en/translation.json | 2 + packages/api/src/agents/discovery.ts | 4 ++ packages/api/src/agents/initialize.ts | 21 +++++++ packages/api/src/agents/run.ts | 57 +++++++++++------- packages/api/src/agents/skills.ts | 4 ++ .../src/agents/statefulCodeSessions.spec.ts | 55 +++++++++++------ packages/api/src/agents/tools.ts | 28 +++++++-- packages/api/src/agents/validation.ts | 4 ++ .../data-provider/src/types/assistants.ts | 4 ++ packages/data-schemas/src/schema/agent.ts | 3 + packages/data-schemas/src/types/agent.ts | 1 + 23 files changed, 249 insertions(+), 55 deletions(-) create mode 100644 client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index f653c26a088..01b523a9a5d 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -303,13 +303,12 @@ const loadTools = async ({ primedCodeFiles = files; } /* Hedge the execute_code description toward persistence only when the - * admin `stateful_code_sessions` capability is on (off by default); the - * matching wire hint is set in the run config. Older @librechat/agents - * ignore the param. */ - const statefulSessions = await checkCapability( - options.req, - AgentCapabilities.stateful_code_sessions, - ); + * 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, diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 7cb265464ec..bc90da0e2ce 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -744,6 +744,7 @@ class AgentClient extends BaseClient { : memoryConfig.agent?.provider, }, codeEnvAvailable: memoryCapabilities.has(AgentCapabilities.execute_code), + statefulSessionsAvailable: memoryCapabilities.has(AgentCapabilities.stateful_code_sessions), }, { getFiles: db.getFiles, diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 9000c247087..9045e0055c8 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -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, diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index c88545c3e6b..a0d40195c5c 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -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, diff --git a/api/server/services/Endpoints/agents/addedConvo.js b/api/server/services/Endpoints/agents/addedConvo.js index 37c703124db..91ea170d346 100644 --- a/api/server/services/Endpoints/agents/addedConvo.js +++ b/api/server/services/Endpoints/agents/addedConvo.js @@ -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, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 530901e691f..2a7e0264862 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -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, diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index b5d4e192b85..ffc0993d1e6 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -1485,13 +1485,15 @@ async function loadToolsForExecution({ /** * Opt bash_tool into the hedged stateful-session description. Gated on code - * execution being enabled AND the admin `stateful_code_sessions` capability; - * 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. + * 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; + enabledCapabilities?.has(AgentCapabilities.stateful_code_sessions) === true && + agent?.stateful_code_sessions === true; const isPTC = isPTCRequested && diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index b5e6aefb37b..4f3e99383e3 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -25,6 +25,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 = { diff --git a/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx b/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx index 79249496fbb..1491297fa79 100644 --- a/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx @@ -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() {
{localize('com_ui_essentials')} + {statefulSessionsEnabled && }
diff --git a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx new file mode 100644 index 00000000000..d4dee3d9fe2 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx @@ -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(); + 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 ( + +
+
+
+ {localize('com_ui_stateful_sessions')} +
+ + + +
+ + +
+

+ {localize('com_nav_info_stateful_sessions')} +

+
+
+
+ +
+
+ ); +} diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index c5759397945..f521d0d371c 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -72,6 +72,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, @@ -101,6 +102,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, diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index 0deebd96bbe..d9a3775b50b 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -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[] = []; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 1f19dd928de..e94b09b2010 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -516,6 +516,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": "العربية", @@ -1831,6 +1832,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", diff --git a/packages/api/src/agents/discovery.ts b/packages/api/src/agents/discovery.ts index ef9d86a21b0..33b981d1411 100644 --- a/packages/api/src/agents/discovery.ts +++ b/packages/api/src/agents/discovery.ts @@ -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, diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index d8a89c5dd36..e6848d58970 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -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, diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 6a191cb7212..66dc5cff70b 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -3,7 +3,6 @@ import { Run, Providers, Constants } from '@librechat/agents'; import { KnownEndpoints, EModelEndpoint, - AgentCapabilities, MAX_SUBAGENT_DEPTH, MAX_SUBAGENT_RUN_CONFIGS, extractEnvVariable, @@ -340,6 +339,12 @@ type RunAgent = Omit & { * 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. */ @@ -780,23 +785,36 @@ function isAskUserQuestionAdminDisabled(appConfig?: AppConfig): boolean { } /** - * Whether this run opts its remote sandbox tools into best-effort stateful - * runtime sessions (a warm per-session MicroVM workspace on the Code API). - * Requires BOTH code execution active in the run (`codeEnvActive`, i.e. some - * reachable agent has execute_code/bash) AND the admin `stateful_code_sessions` - * capability — a stateful sandbox session is meaningless without code - * execution. Off by default: the capability is absent from - * `defaultAgentCapabilities`, and the SDK derives the session hint from + * 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 resolveStatefulCodeSessions( - codeEnvActive: boolean, - agentsEndpointConfig: { capabilities?: AgentCapabilities[] } | undefined, -): boolean { - if (!codeEnvActive) { - return false; +export function anyAgentHasStatefulSessions(agents: Array): boolean { + const visited = new Set(); + 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 new Set(agentsEndpointConfig?.capabilities).has(AgentCapabilities.stateful_code_sessions); + return false; } /** @@ -1303,12 +1321,9 @@ export async function createRun({ * and the resume route). When disabled, nothing attaches and the run is identical * to before this feature shipped. */ - // `enableToolOutputReferences` (bash present anywhere in the run) doubles as - // the "code execution active" signal for the stateful-session gate. - const statefulCodeSessions = resolveStatefulCodeSessions( - enableToolOutputReferences, - agentsEndpointConfig, - ); + // 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 diff --git a/packages/api/src/agents/skills.ts b/packages/api/src/agents/skills.ts index f6cc0ea9f19..377e3dfc22b 100644 --- a/packages/api/src/agents/skills.ts +++ b/packages/api/src/agents/skills.ts @@ -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; diff --git a/packages/api/src/agents/statefulCodeSessions.spec.ts b/packages/api/src/agents/statefulCodeSessions.spec.ts index d83e103074c..70ac3e65e9e 100644 --- a/packages/api/src/agents/statefulCodeSessions.spec.ts +++ b/packages/api/src/agents/statefulCodeSessions.spec.ts @@ -1,27 +1,48 @@ -import { AgentCapabilities } from 'librechat-data-provider'; -import { resolveStatefulCodeSessions } from './run'; +import { anyAgentHasStatefulSessions } from './run'; -describe('resolveStatefulCodeSessions', () => { - const withCap = { - capabilities: [AgentCapabilities.execute_code, AgentCapabilities.stateful_code_sessions], - }; - const withoutCap = { capabilities: [AgentCapabilities.execute_code] }; +type WalkInput = Parameters[0]; - it('enables only when code execution is active AND the capability is present', () => { - expect(resolveStatefulCodeSessions(true, withCap)).toBe(true); +interface TestAgent { + id: string; + statefulCodeSessions?: boolean; + subagentAgentConfigs?: Array; +} + +function agent( + id: string, + statefulCodeSessions?: boolean, + subagentAgentConfigs?: Array, +): TestAgent { + return { id, statefulCodeSessions, subagentAgentConfigs }; +} + +function walk(agents: Array): 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 when the capability is absent, even with code execution active', () => { - expect(resolveStatefulCodeSessions(true, withoutCap)).toBe(false); + 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('stays off when code execution is inactive, even with the capability present', () => { - expect(resolveStatefulCodeSessions(false, withCap)).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('stays off (default) when the endpoint config or capabilities are missing', () => { - expect(resolveStatefulCodeSessions(true, undefined)).toBe(false); - expect(resolveStatefulCodeSessions(true, {})).toBe(false); - expect(resolveStatefulCodeSessions(true, { capabilities: [] })).toBe(false); + 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); }); }); diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index 9b9cabb9c99..d08459443ab 100644 --- a/packages/api/src/agents/tools.ts +++ b/packages/api/src/agents/tools.ts @@ -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 ?? []; diff --git a/packages/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts index 8fc63414e59..fae0ad18e0b 100644 --- a/packages/api/src/agents/validation.ts +++ b/packages/api/src/agents/validation.ts @@ -442,6 +442,7 @@ export const agentBaseSchema: z.ZodObject< >; end_after_tools: z.ZodOptional; hide_sequential_outputs: z.ZodOptional; + stateful_code_sessions: z.ZodOptional; artifacts: z.ZodOptional; recursion_limit: z.ZodOptional; conversation_starters: z.ZodOptional>; @@ -693,6 +694,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(), @@ -785,6 +787,7 @@ export const agentCreateSchema: z.ZodObject< >; end_after_tools: z.ZodOptional; hide_sequential_outputs: z.ZodOptional; + stateful_code_sessions: z.ZodOptional; artifacts: z.ZodOptional; recursion_limit: z.ZodOptional; conversation_starters: z.ZodOptional>; @@ -1095,6 +1098,7 @@ export const agentUpdateSchema: z.ZodObject< >; end_after_tools: z.ZodOptional; hide_sequential_outputs: z.ZodOptional; + stateful_code_sessions: z.ZodOptional; artifacts: z.ZodOptional; recursion_limit: z.ZodOptional; conversation_starters: z.ZodOptional>; diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index bae2a7a72f9..27effe78e63 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -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; @@ -321,6 +323,7 @@ export type AgentCreateParams = { | 'edges' | 'end_after_tools' | 'hide_sequential_outputs' + | 'stateful_code_sessions' | 'artifacts' | 'recursion_limit' | 'category' @@ -348,6 +351,7 @@ export type AgentUpdateParams = { | 'edges' | 'end_after_tools' | 'hide_sequential_outputs' + | 'stateful_code_sessions' | 'artifacts' | 'recursion_limit' | 'category' diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index 5ef6a9cf0d7..5495fc82964 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -74,6 +74,9 @@ const agentSchema: Schema = new Schema( end_after_tools: { type: Boolean, }, + stateful_code_sessions: { + type: Boolean, + }, /** @deprecated Use edges instead */ agent_ids: { type: [String], diff --git a/packages/data-schemas/src/types/agent.ts b/packages/data-schemas/src/types/agent.ts index 416112141d1..fb402d54ad3 100644 --- a/packages/data-schemas/src/types/agent.ts +++ b/packages/data-schemas/src/types/agent.ts @@ -35,6 +35,7 @@ export interface IAgent extends Omit { authorName?: string; hide_sequential_outputs?: boolean; end_after_tools?: boolean; + stateful_code_sessions?: boolean; /** @deprecated Use edges instead */ agent_ids?: string[]; edges?: GraphEdge[]; From 45c1cd74c6f8617d90f9641a3f6782a1d877467c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 9 Jul 2026 10:14:43 -0400 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=90=9B=20fix:=20propagate=20runtime?= =?UTF-8?q?=5Fsession=5Fhint=20to=20sandbox=20executor=20in=20event-driven?= =?UTF-8?q?=20tool=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/api/src/agents/handlers.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index d430af1230d..a1c25027fa2 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -3340,6 +3340,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) From c0919bc72626986da54fd26638d4c73628a204b4 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 9 Jul 2026 10:44:11 -0400 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20fix:=20address=20Codex=20rev?= =?UTF-8?q?iew=20findings=20for=20stateful=20code=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- api/server/services/Files/Code/process.js | 17 +++++++++++++++-- .../Agents/Advanced/StatefulSessions.tsx | 10 ++++++++++ packages/api/src/agents/handlers.ts | 9 +++++++++ packages/api/src/agents/openai/service.ts | 19 +++++++++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 10ba254a19c..098f41eb2ca 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -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} | 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; } diff --git a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx index d4dee3d9fe2..762de3613d7 100644 --- a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; import { AgentCapabilities } from 'librechat-data-provider'; import { @@ -20,6 +21,15 @@ export default function StatefulSessions() { const enabled = watch(AgentCapabilities.stateful_code_sessions) ?? false; const codeEnabled = watch(AgentCapabilities.execute_code); + /** Clear the persisted opt-in when Code Interpreter is turned off so the saved + * agent matches the disabled UI — otherwise `composeAgentUpdatePayload` keeps + * `stateful_code_sessions: true` and re-enabling code silently reactivates it. */ + useEffect(() => { + if (codeEnabled !== true && enabled) { + setValue(AgentCapabilities.stateful_code_sessions, false, { shouldDirty: true }); + } + }, [codeEnabled, enabled, setValue]); + const handleChange = (value: boolean) => { setValue(AgentCapabilities.stateful_code_sessions, value, { shouldDirty: true }); }; diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index a1c25027fa2..845fc6231c9 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -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) { diff --git a/packages/api/src/agents/openai/service.ts b/packages/api/src/agents/openai/service.ts index 65493e83754..09b796cf8a8 100644 --- a/packages/api/src/agents/openai/service.ts +++ b/packages/api/src/agents/openai/service.ts @@ -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) From 14a34f215257e1e94b3b06e69c02ef1f646debc9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 9 Jul 2026 12:09:14 -0400 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=90=9B=20fix:=20normalize=20stateful?= =?UTF-8?q?=5Fcode=5Fsessions=20on=20save=20when=20Code=20Interpreter=20di?= =?UTF-8?q?sabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Agents/Advanced/StatefulSessions.tsx | 10 ---------- .../SidePanel/Agents/AgentPanel.tsx | 7 ++++++- .../__tests__/AgentPanel.helpers.spec.ts | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx index 762de3613d7..d4dee3d9fe2 100644 --- a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; import { AgentCapabilities } from 'librechat-data-provider'; import { @@ -21,15 +20,6 @@ export default function StatefulSessions() { const enabled = watch(AgentCapabilities.stateful_code_sessions) ?? false; const codeEnabled = watch(AgentCapabilities.execute_code); - /** Clear the persisted opt-in when Code Interpreter is turned off so the saved - * agent matches the disabled UI — otherwise `composeAgentUpdatePayload` keeps - * `stateful_code_sessions: true` and re-enabling code silently reactivates it. */ - useEffect(() => { - if (codeEnabled !== true && enabled) { - setValue(AgentCapabilities.stateful_code_sessions, false, { shouldDirty: true }); - } - }, [codeEnabled, enabled, setValue]); - const handleChange = (value: boolean) => { setValue(AgentCapabilities.stateful_code_sessions, value, { shouldDirty: true }); }; diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index f521d0d371c..9fdced929ae 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -82,6 +82,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 ?? ''; @@ -102,7 +107,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n subagents, end_after_tools, hide_sequential_outputs, - stateful_code_sessions, + stateful_code_sessions: normalizedStatefulCodeSessions, recursion_limit, category, support_contact, diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts index 394fcac5d8c..c0f701200e8 100644 --- a/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts +++ b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts @@ -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', () => {