Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion api/app/clients/tools/util/handleTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const {
Permissions,
EToolResources,
PermissionTypes,
AgentCapabilities,
} = require('librechat-data-provider');
const {
availableTools,
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions api/server/controllers/agents/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions api/server/controllers/agents/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions api/server/services/Endpoints/agents/addedConvo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand All @@ -79,6 +81,7 @@ const processAddedConvo = async ({
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
}) => {
const addedConvo = endpointOption.addedConvo;
Expand Down Expand Up @@ -174,6 +177,7 @@ const processAddedConvo = async ({
ephemeralSkillsToggle,
}),
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
skillStates,
defaultActiveOnShare,
Expand Down
7 changes: 7 additions & 0 deletions api/server/services/Endpoints/agents/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -405,6 +408,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
accessibleSkillIds: primaryScopedSkillIds,
skillAuthoringAvailable: primarySkillAuthoringAvailable,
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
skillStates,
defaultActiveOnShare,
Expand Down Expand Up @@ -481,6 +485,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
},
{
Expand Down Expand Up @@ -553,6 +558,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
statefulSessionsAvailable,
memoryAvailable,
});

Expand Down Expand Up @@ -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,
Expand Down
17 changes: 15 additions & 2 deletions api/server/services/Files/Code/process.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -1056,7 +1059,14 @@ async function readSandboxFile({ file_path, session_id, files, req }) {
* @param {ServerRequest} [params.req] - Current authenticated request, used to mint Code API auth.
* @returns {Promise<{stdout?: string, stderr?: string, session_id?: string, files?: Array<Object>} | null>}
*/
async function writeSandboxFile({ file_path, content, session_id, files, req }) {
async function writeSandboxFile({
file_path,
content,
session_id,
files,
runtime_session_hint,
req,
}) {
const baseURL = getCodeBaseURL();
if (!baseURL) {
return null;
Expand Down Expand Up @@ -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;
}
Expand Down
13 changes: 13 additions & 0 deletions api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand Down Expand Up @@ -1534,6 +1546,7 @@ async function loadToolsForExecution({
try {
const bashTool = createBashExecutionTool({
authHeaders: () => getCodeApiAuthHeaders(req),
statefulSessions: statefulCodeSessions,
});
allLoadedTools.push(bashTool);
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions client/src/common/agents-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -52,6 +58,7 @@ export default function AdvancedPanel() {
<section className="flex flex-col gap-3">
<span className={groupHeadingClass}>{localize('com_ui_essentials')}</span>
<MaxAgentSteps />
{statefulSessionsEnabled && <StatefulSessions />}
</section>

<OrchestrationHub currentAgentId={currentAgentId} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useFormContext } from 'react-hook-form';
import { AgentCapabilities } from 'librechat-data-provider';
import {
Switch,
HoverCard,
HoverCardPortal,
HoverCardContent,
HoverCardTrigger,
CircleHelpIcon,
} from '@librechat/client';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';

export default function StatefulSessions() {
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { setValue, watch } = methods;

const enabled = watch(AgentCapabilities.stateful_code_sessions) ?? false;
const codeEnabled = watch(AgentCapabilities.execute_code);

const handleChange = (value: boolean) => {
setValue(AgentCapabilities.stateful_code_sessions, value, { shouldDirty: true });
};

return (
<HoverCard openDelay={50}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={codeEnabled ? 'text-sm' : 'text-sm text-text-tertiary'}>
{localize('com_ui_stateful_sessions')}
</div>
<HoverCardTrigger>
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
</HoverCardTrigger>
</div>
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">
{localize('com_nav_info_stateful_sessions')}
</p>
</div>
</HoverCardContent>
</HoverCardPortal>
<Switch
id="stateful-code-sessions"
checked={enabled && codeEnabled === true}
onCheckedChange={handleChange}
Comment on lines +49 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the hidden stateful flag when code is disabled

If an agent has stateful sessions enabled and the user later turns off Code Interpreter, this switch renders unchecked and disabled but the form value remains true; composeAgentUpdatePayload still persists stateful_code_sessions: true. The saved agent therefore doesn't match the UI, and re-enabling Code Interpreter later silently reactivates stateful sessions. Please clear the form value when codeEnabled becomes false, or omit/force false in the payload when the code tool is disabled.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c0919bc. StatefulSessions.tsx now clears the form value via a useEffect — when execute_code is turned off it sets stateful_code_sessions to false (dirty), so the saved agent matches the disabled UI and re-enabling Code Interpreter later doesn't silently reactivate stateful sessions.

className="ml-4"
data-testid="stateful-code-sessions"
disabled={codeEnabled !== true}
aria-label={localize('com_ui_stateful_sessions')}
/>
</div>
</HoverCard>
);
}
7 changes: 7 additions & 0 deletions client/src/components/SidePanel/Agents/AgentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -81,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 ?? '';
Expand All @@ -101,6 +107,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,
Expand Down
1 change: 1 addition & 0 deletions client/src/components/SidePanel/Agents/AgentSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading