From 53e369fba8876846412dae579c68b2408e30c0cf Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 12 Jul 2026 08:12:04 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20feat:=20stateful=5Fcode=5Fsessio?= =?UTF-8?q?ns=20capability=20for=20warm=20Code=20API=20sandbox=20sessions?= =?UTF-8?q?=20(experimental)=20(#14150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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. --- api/app/clients/tools/util/handleTools.js | 11 +++- 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/Files/Code/process.js | 17 +++++- api/server/services/ToolService.js | 13 ++++ client/src/common/agents-types.ts | 1 + .../Agents/Advanced/AdvancedPanel.tsx | 11 +++- .../Agents/Advanced/StatefulSessions.tsx | 59 +++++++++++++++++++ .../SidePanel/Agents/AgentPanel.tsx | 7 +++ .../SidePanel/Agents/AgentSelect.tsx | 1 + .../__tests__/AgentPanel.helpers.spec.ts | 20 +++++++ client/src/locales/en/translation.json | 2 + packages/api/src/agents/discovery.ts | 4 ++ packages/api/src/agents/handlers.ts | 21 +++++++ packages/api/src/agents/initialize.ts | 21 +++++++ packages/api/src/agents/openai/service.ts | 19 ++++++ packages/api/src/agents/run.ts | 51 ++++++++++++++++ packages/api/src/agents/skills.ts | 4 ++ .../src/agents/statefulCodeSessions.spec.ts | 48 +++++++++++++++ packages/api/src/agents/tools.ts | 28 +++++++-- packages/api/src/agents/validation.ts | 4 ++ packages/data-provider/src/config.ts | 1 + .../data-provider/src/types/assistants.ts | 4 ++ packages/data-schemas/src/schema/agent.ts | 3 + packages/data-schemas/src/types/agent.ts | 1 + 28 files changed, 366 insertions(+), 9 deletions(-) create mode 100644 client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx 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 a01d4fcdec..01b523a9a5 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,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; diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 299c2e6ac6..4a2768f809 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -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, diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 9000c24708..9045e0055c 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 c88545c3e6..a0d40195c5 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 37c703124d..91ea170d34 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 530901e691..2a7e026486 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/Files/Code/process.js b/api/server/services/Files/Code/process.js index 10ba254a19..098f41eb2c 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/api/server/services/ToolService.js b/api/server/services/ToolService.js index 0b24714ec6..ffc0993d1e 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -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) { diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index dd6bd79017..b519de7eef 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -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 = { diff --git a/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx b/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx index 79249496fb..1491297fa7 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 0000000000..d4dee3d9fe --- /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 4e6787bcc4..a796ee6c56 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -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, diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index 0deebd96bb..d9a3775b50 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/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts index 394fcac5d8..c0f701200e 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', () => { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 1aa74d4538..6d4ad0b022 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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", diff --git a/packages/api/src/agents/discovery.ts b/packages/api/src/agents/discovery.ts index ef9d86a21b..33b981d141 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/handlers.ts b/packages/api/src/agents/handlers.ts index d430af1230..845fc6231c 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) { @@ -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) diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index d8a89c5dd3..e6848d5897 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/openai/service.ts b/packages/api/src/agents/openai/service.ts index 65493e8375..09b796cf8a 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) diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 331f9f757e..9e551b09ac 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -339,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. */ @@ -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): 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 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. diff --git a/packages/api/src/agents/skills.ts b/packages/api/src/agents/skills.ts index f6cc0ea9f1..377e3dfc22 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 new file mode 100644 index 0000000000..70ac3e65e9 --- /dev/null +++ b/packages/api/src/agents/statefulCodeSessions.spec.ts @@ -0,0 +1,48 @@ +import { anyAgentHasStatefulSessions } from './run'; + +type WalkInput = Parameters[0]; + +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 (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); + }); +}); diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index 9b9cabb9c9..d08459443a 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 666dea3d9f..93699a0df7 100644 --- a/packages/api/src/agents/validation.ts +++ b/packages/api/src/agents/validation.ts @@ -443,6 +443,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>; @@ -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; hide_sequential_outputs: z.ZodOptional; + stateful_code_sessions: z.ZodOptional; artifacts: z.ZodOptional; recursion_limit: z.ZodOptional; conversation_starters: z.ZodOptional>; @@ -1099,6 +1102,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/config.ts b/packages/data-provider/src/config.ts index 58dfe30cda..0be0481846 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', diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index 57e20f5dbd..349776b34f 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; @@ -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' diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index 7000d28688..31ea657f87 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 5c833c3484..ddae19367e 100644 --- a/packages/data-schemas/src/types/agent.ts +++ b/packages/data-schemas/src/types/agent.ts @@ -36,6 +36,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[];