diff --git a/api/package.json b/api/package.json index 3cdb8cb3b31..8197ac16b6c 100644 --- a/api/package.json +++ b/api/package.json @@ -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", @@ -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", diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index a42afb36257..9858b4d69ee 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -9,6 +9,7 @@ const { ErrorTypes, UsageEvents, getRunStepDurationMs, + getRunStepCloseMetadata, } = require('librechat-data-provider'); const { GraphEvents, @@ -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`, diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 6371b1c40fe..a5c2bbdfe97 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -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. */ @@ -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. */ @@ -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. */ @@ -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. */ @@ -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. */ @@ -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. */ diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index 875368196fd..65175294c02 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -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 = { @@ -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: { @@ -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: { @@ -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: { @@ -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: { diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index f215d60cda3..5ce4e4d603b 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -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), }), }; @@ -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({ @@ -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, diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index dd388f232dc..e8156e9f6c9 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -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); @@ -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' }], }; @@ -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 }], @@ -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 }), diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index b92787c0adb..17fc2c92fa3 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -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 @@ -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} @@ -899,6 +901,7 @@ const ChatForm = memo(function ChatForm({
methods.getValues('text')} onConsumed={consumeComposer} disabled={filesLoading} diff --git a/client/src/components/Chat/Input/DuringRunSendButton.tsx b/client/src/components/Chat/Input/DuringRunSendButton.tsx index 0d0902e385a..2b09f60c2de 100644 --- a/client/src/components/Chat/Input/DuringRunSendButton.tsx +++ b/client/src/components/Chat/Input/DuringRunSendButton.tsx @@ -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. */ @@ -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. */ @@ -137,10 +139,12 @@ const DuringRunSendButton = React.memo( icon: