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
6 changes: 3 additions & 3 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,10 @@
"module-alias": "^2.2.3",
"mongodb": "^6.14.2",
"mongoose": "^8.24.1",
"multer": "^2.3.0",
"multer": "^2.4.0",
"nanoid": "^3.3.18",
"node-fetch": "^2.7.0",
"nodemailer": "^10.0.1",
"nodemailer": "^10.0.2",
"ollama": "^0.5.0",
"openai": "5.8.2",
"openid-client": "^6.5.0",
Expand All @@ -131,7 +131,7 @@
"sharp": "^0.35.4",
"swagger-ui-dist": "^5.32.15",
"ua-parser-js": "^1.0.36",
"undici": "^7.29.0",
"undici": "^7.29.1",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^5.0.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
Expand Down
2 changes: 2 additions & 0 deletions api/server/controllers/agents/callbacks.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
ErrorTypes,
UsageEvents,
getRunStepDurationMs,
getRunStepCloseMetadata,
} = require('librechat-data-provider');
const {
GraphEvents,
Expand Down Expand Up @@ -595,6 +596,7 @@ function getDefaultHandlers({
const part = typeof index === 'number' ? contentParts[index] : undefined;
if (part?.type === ContentTypes.TOOL_CALL && part.tool_call) {
part.tool_call.runStepStatus = data.status;
Object.assign(part.tool_call, getRunStepCloseMetadata(data));
/**
* The raw derivable duration, left unset rather than zeroed when
* the event cannot support a trustworthy one β€” no `created_at`,
Expand Down
6 changes: 6 additions & 0 deletions api/server/services/Files/Code/process.js
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,7 @@ async function readWorkspaceFile({
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
maxQueueWaitMs,
codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
deadlineAtMs,
/** Minted per admission attempt: a queued call outlives one token TTL. */
Expand Down Expand Up @@ -1147,6 +1148,7 @@ async function searchWorkspace({
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
maxQueueWaitMs,
codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
deadlineAtMs,
/** Minted per admission attempt: a queued call outlives one token TTL. */
Expand Down Expand Up @@ -1200,6 +1202,7 @@ async function listWorkspaceFiles({
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
maxQueueWaitMs,
codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
deadlineAtMs,
/** Minted per admission attempt: a queued call outlives one token TTL. */
Expand Down Expand Up @@ -1239,6 +1242,7 @@ async function writeWorkspaceFile({
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
maxQueueWaitMs,
codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
deadlineAtMs,
/** Minted per admission attempt: a queued call outlives one token TTL. */
Expand Down Expand Up @@ -1278,6 +1282,7 @@ async function editWorkspaceFile({
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
maxQueueWaitMs,
codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
deadlineAtMs,
/** Minted per admission attempt: a queued call outlives one token TTL. */
Expand Down Expand Up @@ -1316,6 +1321,7 @@ async function previewWorkspaceEdit({
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
maxQueueWaitMs,
codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
deadlineAtMs,
/** Minted per admission attempt: a queued call outlives one token TTL. */
Expand Down
26 changes: 26 additions & 0 deletions api/server/services/Files/Code/process.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2033,6 +2033,28 @@ describe('Code Process', () => {
});

describe('readWorkspaceFile', () => {
it('forwards the configured rate-limit budget to the workspace transport', async () => {
const req = {
...mockReq,
config: { ...mockReq.config, endpoints: { agents: { codeApiMaxRetryWaitMs: 0 } } },
};
mockExecuteWorkspaceTool.mockResolvedValueOnce({ operation: 'read_file' });

await readWorkspaceFile({
file_path: 'src/app.ts',
workspace_id: 'primary',
start_line: 1,
max_lines: 10,
codeApiBaseUrl: 'https://attached-code.example.com/v1',
executionProfile: 'stateful',
req,
});

expect(mockExecuteWorkspaceTool).toHaveBeenCalledWith(
expect.objectContaining({ codeApiMaxRetryWaitMs: 0 }),
);
});

it('forwards authenticated reads to the selected attached worker', async () => {
const controller = new AbortController();
const result = {
Expand Down Expand Up @@ -2083,6 +2105,7 @@ describe('Code Process', () => {
baseURL: 'https://attached-code.example.com/v1',
authHeaders: expect.any(Function),
maxQueueWaitMs: 0,
codeApiMaxRetryWaitMs: undefined,
maxRequestTimeoutMs: 125_000,
deadlineAtMs: 160_000,
request: {
Expand Down Expand Up @@ -2144,6 +2167,7 @@ describe('Code Process', () => {
baseURL: 'https://attached-code.example.com/v1',
authHeaders: expect.any(Function),
maxQueueWaitMs: 0,
codeApiMaxRetryWaitMs: undefined,
maxRequestTimeoutMs: undefined,
deadlineAtMs: undefined,
request: {
Expand Down Expand Up @@ -2204,6 +2228,7 @@ describe('Code Process', () => {
baseURL: 'https://attached-code.example.com/v1',
authHeaders: expect.any(Function),
maxQueueWaitMs: 0,
codeApiMaxRetryWaitMs: undefined,
maxRequestTimeoutMs: undefined,
deadlineAtMs: undefined,
request: {
Expand Down Expand Up @@ -2266,6 +2291,7 @@ describe('Code Process', () => {
baseURL: 'https://attached-code.example.com/v1',
authHeaders: expect.any(Function),
maxQueueWaitMs: 0,
codeApiMaxRetryWaitMs: undefined,
maxRequestTimeoutMs: undefined,
deadlineAtMs: undefined,
request: {
Expand Down
5 changes: 5 additions & 0 deletions api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,7 @@ async function loadToolDefinitionsWrapper({
enabled: codeExecutionEnabled,
context: resolvedCodeExecutionContext,
principalId: JSON.stringify([getTenantId(), req.user.id]),
codeApiMaxRetryWaitMs: req.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
getAuthHeaders: (workerId) => getCodeApiAuthHeaders(req, workerId),
}),
};
Expand Down Expand Up @@ -1781,6 +1782,7 @@ async function loadAgentTools({
enabled: codeExecutionEnabled,
context: codeExecutionContext,
principalId: JSON.stringify([getTenantId(), req.user.id]),
codeApiMaxRetryWaitMs: req.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
getAuthHeaders: (workerId) => getCodeApiAuthHeaders(req, workerId),
});
const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({
Expand Down Expand Up @@ -2315,9 +2317,12 @@ async function loadToolsForExecution({
maxQueueWaitMs: resolveAttachedWorkspaceQueueWaitMs(
codeExecutionContext.codeEnvironmentConfigSchema,
),
codeApiMaxRetryWaitMs: req.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs: resolveAttachedWorkspaceRequestTimeoutMs(
codeExecutionContext.codeEnvironmentConfigSchema,
),
minCommandAdmissionMs:
codeExecutionContext.codeEnvironmentConfigSchema?.limits?.minCommandAdmissionMs,
})
: createBashExecutionTool({
authHeaders,
Expand Down
52 changes: 50 additions & 2 deletions api/server/services/__tests__/ToolService.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,43 @@ describe('ToolService - Action Capability Gating', () => {
});
});

it.each([true, false])(
'passes the Code API retry limit to repository instructions (definitionsOnly=%s)',
async (definitionsOnly) => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.execute_code,
AgentCapabilities.stateful_code_sessions,
];
const req = createMockReq(capabilities);
req.config.endpoints[EModelEndpoint.agents].codeApiMaxRetryWaitMs = 0;
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockResolveCodeExecutionContext.mockReturnValueOnce({
baseUrl: 'https://attached-code.example.com/v1',
codeSessionKey: 'attached-session',
executionProfile: 'stateful',
statefulSessions: true,
environmentType: 'attached',
environmentId: 'personal-machine',
});

const result = await loadAgentTools({
req,
res: {},
agent: {
id: 'attached-agent',
tools: [Tools.execute_code],
stateful_code_sessions: true,
},
definitionsOnly,
});

expect(result.repositoryInstructionSource).toEqual(
expect.objectContaining({ codeApiMaxRetryWaitMs: 0 }),
);
},
);

describe('isActionTool β€” cross-delimiter collision guard', () => {
it('should identify real action tools', () => {
expect(isActionTool(`get_weather${actionDelimiter}api_example_com`)).toBe(true);
Expand Down Expand Up @@ -2930,6 +2967,7 @@ describe('ToolService - Action Capability Gating', () => {
AgentCapabilities.stateful_code_sessions,
];
const req = createMockReq(capabilities);
req.config.endpoints[EModelEndpoint.agents].codeApiMaxRetryWaitMs = 0;
req.body = {
codeWorkspaces: [{ environmentId: 'personal-machine', workspaceId: 'project-a' }],
};
Expand All @@ -2942,7 +2980,14 @@ describe('ToolService - Action Capability Gating', () => {
environmentType: 'attached',
environmentId: 'personal-machine',
bridgeWorkerId: 'worker-abc',
codeEnvironmentConfigSchema: { limits: { maxCommandTimeoutMs: 120000, maxQueueWaitMs: 0 } },
codeEnvironmentConfigSchema: {
limits: {
maxCommandTimeoutMs: 80_000,
maxQueueWaitMs: 0,
maxRequestTimeoutMs: 90_000,
minCommandAdmissionMs: 15_000,
},
},
});
const toolRegistry = new Map([
[AgentConstants.BASH_TOOL, { name: AgentConstants.BASH_TOOL }],
Expand All @@ -2968,8 +3013,11 @@ describe('ToolService - Action Capability Gating', () => {
baseUrl: 'http://attached-code.test/v1',
workspaceId: 'project-a',
gitIdentity: { name: 'LibreChat Agent', email: 'agent@example.com' },
maxTimeoutMs: 120000,
maxTimeoutMs: 65_000,
maxQueueWaitMs: 0,
codeApiMaxRetryWaitMs: 0,
maxRequestTimeoutMs: 90_000,
minCommandAdmissionMs: 15_000,
});
expect(mockResolveCodeExecutionWorkspaceContext).toHaveBeenCalledWith(
expect.objectContaining({ requestedSelections: req.body.codeWorkspaces }),
Expand Down
3 changes: 3 additions & 0 deletions client/src/components/Chat/Input/ChatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ const ChatForm = memo(function ChatForm({
() => conversation?.conversationId ?? Constants.NEW_CONVO,
[conversation?.conversationId],
);
const isNewConversation = conversationId === '' || conversationId === Constants.NEW_CONVO;
/**
* The quote feature merges excerpts server-side in `BaseClient.sendMessage`,
* which the Assistants endpoints bypass β€” so hide the UI there rather than
Expand Down Expand Up @@ -579,6 +580,7 @@ const ChatForm = memo(function ChatForm({
ref={submitButtonRef}
control={methods.control}
steering={steering}
isNewConversation={isNewConversation}
getText={() => methods.getValues('text')}
onConsumed={consumeComposer}
disabled={filesLoading}
Expand Down Expand Up @@ -899,6 +901,7 @@ const ChatForm = memo(function ChatForm({
<div className="shrink-0">
<InterruptSteerButton
steering={steering}
isNewConversation={isNewConversation}
getText={() => methods.getValues('text')}
onConsumed={consumeComposer}
disabled={filesLoading}
Expand Down
10 changes: 7 additions & 3 deletions client/src/components/Chat/Input/DuringRunSendButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type ActionRow = SendAction;
type DuringRunSendButtonProps = {
control: Control<{ text: string }>;
steering: SteeringControls;
isNewConversation: boolean;
getText: () => string;
onConsumed: () => void;
/** External hold (e.g. uploads in flight), mirroring the normal send button. */
Expand All @@ -31,7 +32,8 @@ type DuringRunSendButtonProps = {
* composer holds text β€” submitting steers or queues per the effective action.
* Hovering it reveals the full action list with its shortcuts: steer, queue
* (⌘/Ctrl+Enter routes to the non-default action), interrupt & steer
* (⌘/Ctrl+Shift+Enter β€” stops writing now but keeps what is written), and
* (⌘/Ctrl+Shift+Enter β€” requests an earlier safe point when a conversation
* exists, otherwise stops and sends a new turn), and
* interrupt & send (βŒ₯/Alt+Enter β€” discards the answer and starts over).
* Clearing the composer restores the Stop button.
*/
Expand Down Expand Up @@ -137,10 +139,12 @@ const DuringRunSendButton = React.memo(
icon: <Clock className="h-4 w-4 text-status-info" aria-hidden="true" />,
onClick: () => runAction((text) => steering.queueFromComposer(text)),
};
/** Keeps the half-written answer, unlike interrupt & send below it. */
/** When steering is available, keeps visible text; a new chat instead hard-stops. */
const interruptSteerRow: ActionRow = {
key: 'interrupt-steer',
label: localize('com_ui_interrupt_steer'),
label: localize(
props.isNewConversation ? 'com_ui_steer_first_turn_stop' : 'com_ui_interrupt_steer',
),
kbd: interruptSteerKbd,
icon: <ZapOff className="h-4 w-4 text-status-warning" aria-hidden="true" />,
// Matches the standalone button's gate, and deliberately NOT
Expand Down
18 changes: 12 additions & 6 deletions client/src/components/Chat/Input/InterruptSteerButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,27 @@ import { cn } from '~/utils';

type InterruptSteerButtonProps = {
steering: SteeringControls;
isNewConversation: boolean;
getText: () => string;
onConsumed: () => void;
/** External hold (e.g. uploads in flight), mirroring the send button. */
disabled?: boolean;
};

/**
* Always-visible composer control with one fixed meaning: stop writing now,
* keep what is written, and steer from here. Distinct from the send button's
* hovercard, whose primary action follows the user's during-run preference β€”
* this one never changes what it does.
* Always-visible composer control for earlier steering. Before a conversation
* exists, steering cannot reach the run, so this control instead stops the
* response and sends a new turn. Its label must reflect that fallback.
*
* `type="button"`: the composer footer sits inside the chat form, and only
* `DuringRunSendButton` may receive Enter's synthetic submit.
*/
const InterruptSteerButton = React.memo((props: InterruptSteerButtonProps) => {
const localize = useLocalize();
const { steering } = props;
const label = localize('com_ui_interrupt_steer_button');
const label = localize(
props.isNewConversation ? 'com_ui_steer_first_turn_stop' : 'com_ui_interrupt_steer_button',
);
/** Pre-empts the server's 409: a paused run cannot accept a steer. */
const disabled =
props.disabled === true || steering.pausedOnApproval || !steering.canControlGeneration;
Expand Down Expand Up @@ -63,7 +65,11 @@ const InterruptSteerButton = React.memo((props: InterruptSteerButtonProps) => {
}
/>
<Ariakit.Tooltip className="z-50 rounded-lg bg-surface-tertiary px-2 py-1 text-xs text-text-primary shadow-lg">
{localize('com_ui_interrupt_steer_desc')}
{localize(
props.isNewConversation
? 'com_ui_steer_first_turn_stop_info'
: 'com_ui_interrupt_steer_desc',
)}
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
);
Expand Down
Loading
Loading